プロシージャルな流体エフェクトを OpenSiv3D で実装する【Curl-Noise】



今回は、華やかな流体のような効果を生み出すのに便利な技術「Curl-Noise」のOpenSiv3Dでの実装について説明します。
...と思ったのですが、時間の都合上、詳しい解説は参考リンクに譲り、ここでは遊んでみた結果の記録を貼り付けます。

# 2D
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235367/63ddbdd7-c987-5e02-d701-c5cffd26f1ca.gif)

```cpp:Main.cpp
# include <Siv3D.hpp> // OpenSiv3D v0.6.3


struct Particle
{
	Vec2 pos;

	Vec2 velocity;

	double startTime;

	double radius;

	double weight;

	double offset;
};

void Main()
{
	Window::Resize(1280, 720);

	// 使用する画像
	const Image image{ U"example/siv3d-kun.png" };

	// テクスチャ
	const Texture texture{ image };
	// アルファ値 1 以上の領域を Polygon 化
	const Polygon polygon = image.alphaToPolygon(1, AllowHoles::No);

	// Polygon 単純化時の基準距離（ピクセル）
	double maxDistance = 4.0;

	// 単純化した Polygon
	Polygon simplifiedPolygon = polygon.simplified(maxDistance);

	simplifiedPolygon.moveBy(100, 100);

	PerlinNoise noise;




	Array<Particle> particles;

	
	const double lifeTime = 2.0;



	const Size sceneSize{ Scene::Size() };
	RenderTexture gaussianA1{ sceneSize }, gaussianB1{ sceneSize };
	RenderTexture gaussianA4{ sceneSize / 4 }, gaussianB4{ sceneSize / 4 };
	RenderTexture gaussianA8{ sceneSize / 8 }, gaussianB8{ sceneSize / 8 };

	double a1 = 1.0, a4 = 1.0, a8 = 1.0;

	auto drawFunction = [&]() {

		

		for (const auto& p : particles)
		{
			const double t = (Scene::Time() - p.startTime) / lifeTime;
			const double alpha = Sin(t * Math::Pi) * Sin(t * Math::Pi);
			const double wave = (Sin((t + p.offset) * Math::TwoPi * 5)) * 1.0;

			Circle(p.pos, p.radius).draw(ColorF(1.0, 0.5, 0.1, alpha + alpha * wave));
		}};

	while (System::Update())
	{

		if (MouseL.down())
		{
			Array<Particle> newParticles(1000);

			const double direction = Random() * Math::TwoPi;

			for (auto& particle : newParticles)
			{
				particle.pos = Cursor::Pos() + RandomVec2(Circle(30.0));

				particle.velocity = (Vec2::Right().rotated(direction) + RandomVec2(Circle(0.2))) * 10.0; 

				particle.startTime = Scene::Time();

				particle.radius = Random(1.0, 3.0);

				particle.weight = Random();

				particle.offset = Random();
			}

			std::copy(newParticles.begin(), newParticles.end(), std::back_inserter(particles));
		}



		Erase_if(particles, [](const Particle& p) {return p.startTime + lifeTime < Scene::Time(); });

		{
			const double delta = 0.1;

			for (auto& particle : particles)
			{
				const Vec2& p = particle.pos;
				const double x0 = noise.normalizedOctave2D0_1(p / 128.0, 1, 0.0);
				const double x1 = noise.normalizedOctave2D0_1((p + Vec2(delta, 0.0)) / 128.0, 1, 0.0);
				const double y0 = noise.normalizedOctave2D0_1(p / 128.0, 1, 0.0);
				const double y1 = noise.normalizedOctave2D0_1((p + Vec2(0.0, delta)) / 128.0, 1, 0.0);
				const double distance = (1.0 - 1.0 / (Max(1.0, Pow(Geometry2D::Distance(p, simplifiedPolygon), 1.0))));
				const Vec2 curl = distance * Vec2((y1 - y0) / delta, -(x1 - x0) / delta);
				const Vec2 add = curl * Scene::DeltaTime() * 50000;

				particle.velocity += -10 * (1.0 - particle.weight * 0.5) * particle.velocity * Scene::DeltaTime();

				particle.pos += particle.velocity * distance + add * particle.velocity * 0.5 + add * 0.5;
			}
		}

		drawFunction();

		texture.draw(100, 100);

		{
			// ガウスぼかし用テクスチャにもう一度シーンを描く
			{
				const ScopedRenderTarget2D target{ gaussianA1.clear(ColorF{ 0.0 }) };
				const ScopedRenderStates2D blend{ BlendState::Additive };

				drawFunction();
			}

			// オリジナルサイズのガウスぼかし (A1)
			// A1 を 1/4 サイズにしてガウスぼかし (A4)
			// A4 を 1/2 サイズにしてガウスぼかし (A8)
			Shader::GaussianBlur(gaussianA1, gaussianB1, gaussianA1);
			Shader::Downsample(gaussianA1, gaussianA4);
			Shader::GaussianBlur(gaussianA4, gaussianB4, gaussianA4);
			Shader::Downsample(gaussianA4, gaussianA8);
			Shader::GaussianBlur(gaussianA8, gaussianB8, gaussianA8);
		}

		{
			const ScopedRenderStates2D blend{ BlendState::Additive };

			if (a1)
			{
				gaussianA1.resized(sceneSize).draw(ColorF{ a1 });
			}

			if (a4)
			{
				gaussianA4.resized(sceneSize).draw(ColorF{ a4 });
			}

			if (a8)
			{
				gaussianA8.resized(sceneSize).draw(ColorF{ a8 });
			}
		}


	}
}

```

# 3D
![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235367/69228daf-8145-7bb9-ed29-29fcd1147525.gif)

```cpp:Main.cpp
# include <Siv3D.hpp>


struct Particle
{
	Vec3 pos;

	Vec3 velocity;

	double startTime;

	double radius;

	double weight;

	double offset;

	double lifeTime;
};

Vec3 GetVec3Noise(const Vec3& p, const PerlinNoise& noise)
{	
	return Vec3(noise.normalizedOctave3D(p / 128.0, 1, 0.0), noise.normalizedOctave3D(p / 128.0 + Vec3(100, 100, 100), 1, 0.0), noise.normalizedOctave3D(p / 128.0 + Vec3(-100, -100, -100), 1, 0.0));
}


void Main()
{
	Window::Resize(1280, 720);
	Scene::SetResizeMode(ResizeMode::Keep);

	const PixelShader psBright = HLSL{ U"example/shader/hlsl/extract_bright_linear.hlsl", U"PS" }
	| GLSL{ U"example/shader/glsl/extract_bright_linear.frag", {{U"PSConstants2D", 0}} };

	if (not psBright)
	{
		return;
	}

	const ColorF backgroundColor = ColorF{ 0.02 }.removeSRGBCurve();
	const MSRenderTexture renderTexture{ Scene::Size(), TextureFormat::R16G16B16A16_Float, HasDepth::Yes };
	const RenderTexture gaussianA4{ renderTexture.size() / 4 }, gaussianB4{ renderTexture.size() / 4 };
	const RenderTexture gaussianA8{ renderTexture.size() / 8 }, gaussianB8{ renderTexture.size() / 8 };
	const RenderTexture gaussianA16{ renderTexture.size() / 16 }, gaussianB16{ renderTexture.size() / 16 };
	DebugCamera3D camera{ renderTexture.size(), 30_deg, Vec3{ 10, 16, -32 } * 50 };

	bool glowEffect = true;


	Array<Particle> particles;


	PerlinNoise noise;



	while (System::Update())
	{
		camera.update(2.0);

		if (MouseL.down())
		{
			Array<Particle> newParticles(1000);

			const Vec3 direction = RandomVec3(1.0);

			for (auto& particle : newParticles)
			{
				particle.pos = RandomVec3(Sphere(10.0));

				particle.velocity = (direction + RandomVec3(Sphere(0.3))) * 20.0;

				particle.startTime = Scene::Time();

				particle.radius = Random(1.0, 5.0);

				particle.weight = Random();

				particle.offset = Random();

				particle.lifeTime = 2.0;
			}

			std::copy(newParticles.begin(), newParticles.end(), std::back_inserter(particles));
		}

		Erase_if(particles, [](const Particle& p) {return p.startTime + p.lifeTime < Scene::Time(); });

		{
			const double delta = 0.1;

			for (auto& particle : particles)
			{
				const Vec3& p = particle.pos;
				const Vec3 x0 = GetVec3Noise((p + Vec3(-delta, 0.0, 0.0)), noise);
				const Vec3 x1 = GetVec3Noise((p + Vec3(delta, 0.0, 0.0)) , noise);
				const Vec3 y0 = GetVec3Noise((p + Vec3(0.0, -delta, 0.0)), noise);
				const Vec3 y1 = GetVec3Noise((p + Vec3(0.0, delta, 0.0)) , noise);
				const Vec3 z0 = GetVec3Noise((p + Vec3(0.0, 0.0, -delta)), noise);
				const Vec3 z1 = GetVec3Noise((p + Vec3(0.0, 0.0, delta)) , noise);
				const Vec3 dx = x1 - x0;
				const Vec3 dy = y1 - y0;
				const Vec3 dz = z1 - z0;

				const Vec3 curl = Vec3(dy.z-dz.y, dz.x-dx.z, dx.y-dy.x)/(2.0 * delta);
				const Vec3 add = curl * Scene::DeltaTime() * 20000;

				particle.velocity += -10 * (1.0 - particle.weight * 0.5) * particle.velocity * Scene::DeltaTime();

				particle.pos +=  particle.velocity  + add * particle.velocity * 0.5 + add * 0.3;
			}
		}

		// 3D
		{
			Graphics3D::SetCameraTransform(camera);

			const ScopedRenderTarget3D target{ renderTexture.clear(backgroundColor) };

			PhongMaterial phong;
			phong.amibientColor = ColorF{ 1.0 };
			phong.diffuseColor = ColorF{ 0.0 };


			for (const auto& p : particles)
			{
				const double t = (Scene::Time() - p.startTime) / p.lifeTime;
				const double alpha = Sin(t * Math::Pi) * Sin(t * Math::Pi);
				const double wave = (Sin((t + p.offset) * Math::TwoPi * 5)) * 1.0;

				phong.emissionColor = ColorF(0.1, 0.5, 1.0).removeSRGBCurve() * ( alpha + alpha * wave) * 10.0;
				Sphere{ p.pos, p.radius }
				.draw(phong);
			}

		}

		// RenderTexture を 2D シーンに描画
		{
			Graphics3D::Flush();
			renderTexture.resolve();
			Shader::LinearToScreen(renderTexture);
		}

		if (glowEffect)
		{
			// 高輝度部分を抽出
			{
				const ScopedCustomShader2D shader{ psBright };
				const ScopedRenderTarget2D target{ gaussianA4.clear(ColorF{0.0}) };
				renderTexture.scaled(0.25).draw();
			}

			// 高輝度部分のぼかしテクスチャの生成
			{
				Shader::GaussianBlur(gaussianA4, gaussianB4, gaussianA4);
				Shader::Downsample(gaussianA4, gaussianA8);
				Shader::GaussianBlur(gaussianA8, gaussianB8, gaussianA8);
				Shader::GaussianBlur(gaussianA8, gaussianB8, gaussianA8);
				Shader::Downsample(gaussianA8, gaussianA16);
				Shader::GaussianBlur(gaussianA16, gaussianB16, gaussianA16);
				Shader::GaussianBlur(gaussianA16, gaussianB16, gaussianA16);
				Shader::GaussianBlur(gaussianA16, gaussianB16, gaussianA16);
			}

			// Glow エフェクト
			{
				const ScopedRenderStates2D blend{ BlendState::AdditiveRGB };

				{
					const ScopedRenderTarget2D target{ gaussianA8 };
					gaussianA16.scaled(2.0).draw(ColorF{ 3.0 });
				}

				{
					const ScopedRenderTarget2D target{ gaussianA4 };
					gaussianA8.scaled(2.0).draw(ColorF{ 1.0 });
				}

				gaussianA4.resized(Scene::Size()).draw(ColorF{ 1.0 });
			}
		}

		SimpleGUI::CheckBox(glowEffect, U"Glow", Vec2{ 20,20 });
		
	}

}


```

# 補足
Curl-Noiseは、2007年のSIGGRAPHでブリティッシュ・コロンビア大学のRobert Bridsonらが提案した、流体的な効果を簡単に発生させる方法です。https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph2007-curlnoise.pdf

参考サイト
https://prideout.net/blog/old/blog/index.html@p=63.html

3次元のパーリンノイズを持つポテンシャル場を用いて、3次元空間のベクトル場を生成する。その後、そのベクトル場に回転演算子を適用することにより、流体の流れを定義する。
ここで、ポテンシャル場からベクトル場を生成すると、そのベクトル場の発散はゼロになります。つまり、より自然界の流体の挙動に近い場を作ることができるのです。