はじめに
この記事は、Siv3D Advent Calender 2024 2日目の記事です。
みなさんはPerlinNoiseというものをご存じでしょうか。
Siv3DにはこのPerlinNoiseを生成する機能が備わっているため、簡単に使用することができます。
この記事では、PerlinNoiseについてとその活用例を紹介します。
また、この記事は去年書いていたこの記事を書き直した記事です。
PerlinNoiseとは
PerlinNoiseは、Ken Perlin氏によって開発された滑らかな乱数を生成するためのノイズです。
乱数を生成する関数といえば、Random()
が挙げられます。PerlinNoise()
も同じように乱数を生成することができますが、Random()
と違って滑らかな値を返すという特徴があります。
この二つの関数を使って、1~10の値を返す乱数生成ジェネレーターを作ることを考えます。
Random()
を使うと1,8,3,10,7,4...
のようにランダムな値が返ってきますが、PerlinNoise()
を使うと3,4,5,5,4,2,1,2...
のように滑らかな変化をした値が返ってきます。
可視化するとこんな感じ
Random()
PerlinNoise()
このように、ランダムでありながらも滑らかな変化をしていることがわかります。
Siv3Dで使ってみる
先ほどのPerlinNoiseを可視化したものは以下のコードで作りました。
# include <Siv3D.hpp>
void Main()
{
//PerlinNoiseを宣言、初期化
PerlinNoise noise{ RandomUint32() };
while (System::Update())
{
//画面の端から端までループ
for (int x : step(Scene::Width()))
{
//PerlinNoiseによって滑らかな値を取得
double perlin = noise.noise1D(x / 100.0);
//取得した値を元にy座標を計算
double y = Scene::CenterF().y + perlin * 150;
//描画
RectF{ x, y, 2 }.draw();
}
}
}
Siv3DではPerlinNoise
というクラスを利用することで、PerlinNoiseの値を取得することができます。
//PerlinNoiseを宣言、初期化
PerlinNoise noise{ RandomUint32() };
PerlinNoise
クラスのメンバ関数noise1D(x)
によって、-1~1の範囲を滑らかに変化するランダムな値を取得することができます。
//PerlinNoiseによって-1~1の範囲の滑らかな値を取得
double perlin = noise.noise1D(x / 100.0);
引数のx
は座標を示しており、今回の場合は画面上のx座標を入力しています。
Random()
は呼び出すたびに異なるランダムな値を返していましたが、PerlinNoiseは同じ値を入力するとそれに応じた値が返ってきます(シード値を変えると値も変わります)。
100.0で割っている理由を書くと長くなるのですが、double
にキャストするためなのと変化を緩やかにするためだと思ってください。
2次元に拡張してみる
PerlinNoiseは2次元に拡張することができ、次のような結果を得る事ができます。
# include <Siv3D.hpp>
void Main()
{
//PerlinNoiseを宣言、初期化
PerlinNoise noise{ RandomUint32() };
while (System::Update())
{
//画面全体にかかるように2重forループ
//重くなり過ぎないように5分の1に下げる
for (int y : step(Scene::Height() / 5))
{
for (int x : step(Scene::Width() / 5))
{
//PerlinNoiseによって0~1の値を取得
//noise2Dによって二次元の座標から値を取得
double color = noise.noise2D0_1(x / 10.0, y / 10.0);
//描画
RectF{ x * 5, y * 5, 5 }.draw(ColorF(color));
}
}
}
}
PerlinNoise
クラスのメンバ関数noise2D(x, y)
によって二次元のPerlinNoiseを利用することができます。この関数は二つの引数を取り、一つの結果を返します。このサンプルでx
及びy
は、画面上の座標を与えています。今回は0~1の値が欲しかったのでnoise2D0_1(x, y)
を使用しました。
これを利用して、様々な表現を作り上げることができます。
フラクタルブラウン運動(FBM)
先ほどの2次元に拡張したPerlinNoiseの画像を見て、「滑らかな値が取得できるのは分かったけど、この画像なんか微妙じゃない?」と思いませんでしたか?確かに、滑らかな変化ではありますが何か不自然な気がします。
そこで、フラクタルブラウン運動(FBM) の登場です。
フラクタルブラウン運動とは、フラクタルと名がつく通り、フラクタル図形のような自己相似性を持つ図形を複数個重ねることで、より細かい図形を作り出す...というものです。
図形の重ね方にもポイントがあり、周波数を2倍、振幅を2分の1倍にしながらオクターブの数だけ重ねます。簡単に言うと、スケールを細かくしながら合成するということです。
オクターブは、単純に重ねる回数だと思ってください。
振幅は、変化の大きさだと思ってください。振幅が大きければ変化はより大きくなり、小さければ変化は小さくなります。以下に、振幅が大きいときと小さいときの例を示します。
周波数は、変化の感覚だと思ってください。周波数が高ければ変化の間隔が短くなり、低ければ変化の間隔が長くなります。以下に、周波数が高いときと低いときの例を示します。
要は波で扱われる振幅や周波数と同じです。
フラクタルブラウン運動は、周波数を2倍、振幅を2分の1倍にしながらオクターブの数だけ重ねて実現します。つまり、だんだん細かく小さくしていきながら重ねていきます。
パーリンノイズを理解する|POSTDという記事から引用すると以下のようになります。
Amplitudeが振幅、Frequencyが周波数になります。
だんだんと変化量が小さくなり、変化の間隔が短くなっているのが分かります。これらの波形を全て合成することで、フラクタルブラウン運動は完成します。
全て合成したものを以下に示します。
これを2次元に拡張したものを以下に示します。
# include <Siv3D.hpp>
void Main()
{
//PerlinNoiseを宣言、初期化
PerlinNoise noise{ RandomUint32() };
while (System::Update())
{
//画面全体にかかるように2重forループ
//重くなり過ぎないように5分の1に下げる
for (int y : step(Scene::Height() / 5))
{
for (int x : step(Scene::Width() / 5))
{
//PerlinNoiseによって0~1の値を取得
//octave2Dによってオクターブを4に設定したfbmの値を取得
double color = noise.octave2D0_1(x / 10.0, y / 10.0, 4);
//描画
RectF{ x * 5, y * 5, 5 }.draw(ColorF(color));
}
}
}
}
Siv3Dでは、PerlinNoise
クラスのメンバ関数octave2D(x, y, octave)
を使うことができます。x
、y
に加えてoctave
に任意の値を入れることでFBMを実現することができます。ただし、オクターブを大きな値にし過ぎるとその分処理が重くなっていくので気を付けてください。ここでは、オクターブの数は4としています。
このようにして、フラクタルブラウン運動(FBM)ではより細かいディテールのノイズを得ることができます。
パーリンノイズの活用例
雲にする
FBMの画像を見て、霧っぽいなと感じませんでしたか?FBMの値をそのままalpha値に適用するだけでそれっぽいものを作ることができます。
次のようなコードで実現できます。
# include <Siv3D.hpp>
void Main()
{
Scene::SetBackground(ColorF(0.25, 0.25, 0.75));
PerlinNoise perlin{ RandomUint32() };
while (System::Update())
{
double t = Scene::Time() * 50;
for (int y : step(Scene::Height() / 5))
{
for (int x : step(Scene::Width() / 5))
{
//FBMからアルファ値を取得
//最大0.5となるように0.5引いて、0を下回らないようにしておく
double alpha = Max(0.0, perlin.octave2D0_1(x / 100.0, (y + t) / 100.0, 4) - 0.5);
//描画
RectF{ x * 5, y * 5, 5 }.draw(ColorF(1, alpha * 1.5));
}
}
}
}
octave2D0_1
関数によって得られた値をアルファ値としています。この時、得られた値から0.5を引くことでFBMが0.5より大きいもののみを表示しています。また、0との最大値を取る事でマイナスになってしまうのを防いでいます。
これだけでもいい感じの雲を作ることができるのですが、もう少し厚みを持たせた雲を作ってみましょう。
# include <Siv3D.hpp>
void Main()
{
Scene::SetBackground(ColorF(0.25, 0.25, 0.75));
PerlinNoise perlin{ RandomUint32() };
while (System::Update())
{
double t = Scene::Time() * 50;
for (int y : step(Scene::Height() / 5))
{
for (int x : step(Scene::Width() / 5))
{
//FBMからアルファ値を取得
//最大0.5となるように0.5引いて、0を下回らないように
double alpha = Max(0.0, perlin.octave2D0_1(x / 100.0, (y + t) / 100.0, 4) - 0.5);
//半径10の円を描画
//FBMの値が大きければ大きいほど灰色っぽく
Circle{ x * 5, y * 5, 10 }.draw(ColorF(1 - alpha, alpha));
}
}
}
}
今までは描画の部分は一辺が5のRectF
に任せていましたが、半径10のCircle
に変更して重なり合うようにしました。また、色の指定をColorF(1 - alpha, alpha)
とすることで、FBMで得られた値が大きいほど灰色(0.5)に近くなるようにしました。今回はやっていませんが、暗い円を先に描画して明るい円を上から重ねるようにするともっといい感じになりそうな気がします。
更に、ノイズの引数にプレイヤーの座標を与えたりするとプレイヤーの移動に応じて一緒に移動する雲を作るといったこともできます。
そのまま地形に適用してみる
自然な地形の自動生成にはPerlinNoiseがよく使われており、あのMinecraftの地形の生成にも(恐らく)PerlinNoiseが使われています。
こんな感じ
# include <Siv3D.hpp>
Color getColor(double noise)
{
if (noise <= 0.2)
{
return Palette::Darkblue;
}
else if (noise <= 0.40)
{
return Palette::Dodgerblue;
}
else if (noise <= 0.50)
{
return Palette::Cornsilk;
}
else if (noise <= 0.70)
{
return Palette::Lawngreen;
}
else
{
return Palette::Darkorange;
}
return Palette::White;
}
void Main()
{
//PerlinNoiseを宣言、初期化
PerlinNoise noise{ RandomUint32() };
while (System::Update())
{
//画面全体にかかるように2重forループ
//重くなり過ぎないように5分の1に下げる
for (int y : step(Scene::Height() / 5))
{
for (int x : step(Scene::Width() / 5))
{
//PerlinNoiseによって0~1の値を取得
double color = noise.noise2D0_1(x / 20.0, y / 20.0);
color = EaseInQuad(color);
//描画
RectF{ x * 5, y * 5, 5 }.draw(getColor(color));
}
}
}
}
2次元のパーリンノイズの画像にちょっと無理やり色を付けてみただけですが、地形っぽいものが見えます。
オリジナルのカラーマップとか作ればいい感じになりそうです。
3Dにしてみるとこんな感じ
PerlinNoiseによって得られた値をそのままy座標に指定しています。
コード全文
# include <Siv3D.hpp> // OpenSiv3D v0.6.11
class Mountain
{
private:
PerlinNoise perlin{ RandomUint32() }; //パーリンノイズ
Grid<Vec2> points; //線を描画するための各座標
Grid<ColorF> colors; //それぞれの線の色
double yStart = Scene::Height() + 100; //山の描画開始y座標
double yEnd = Scene::Height() / 1.5; //山の描画終了y座標
double xStart = Scene::Width() * 2.0; //山の描画開始x座標
double xEnd = Scene::Width(); //山の描画終了x座標
public:
Mountain()
:points{ Grid<Vec2>(500, 200, Vec2(0, 0)) },
colors{ Grid<ColorF>(500, 200, Palette::White) },
yStart{ Scene::Height() + 100.0 },
yEnd{ Scene::Height() / 1.5 },
xStart{ Scene::Width() * 2.0 },
xEnd{ (double)Scene::Width() } {}
void update()
{
for (auto y : step(points.height()))
{
// 基準のy座標(0.0~1.0)を計算 イージングで3Dっぽい変化に見せてる
double yRatio = EaseOutSine((double)y / (points.height()));
// そのy座標での横線全体の長さを計算 y座標で0.0~1.0にしたものをxEndとxStartの範囲にMapしてる
double xWidth = Math::Map(yRatio, 0.0, 1.0, xStart, xEnd);
// 横幅を計算 求めた横線の長さをGridの列の数で分割
double width = xWidth / points.width();
// y座標を計算
double yPos = yStart - (yRatio * (yStart - yEnd));
// 各座標を計算
for (auto x : step(points.width()))
{
// ノイズを計算
double noise = perlin.octave2D(x / 100.0, y / 100.0, 2);
// x座標を計算
points[y][x].x = Scene::Width() / 2.0 - xWidth / 2.0 + width * x;
// y座標を計算 パーリンノイズで上下させてる
points[y][x].y = yPos - (20 * EaseOutSine(1 - yRatio)) - noise * 100;
// カラーマップによる色を取得
colors[y][x] = Colormap01(noise, ColormapType::Parula);
}
}
}
void draw(double thick = 1) const
{
for (size_t y = points.height() - 1; y > 0; y--)
{
for (auto x : step(points.width()))
{
// 横線
if (x < points.width() - 1) Line(points[y][x], points[y][x + 1]).draw(thick, colors[y][x]);
// 縦線
if (y < points.height() - 1) Line(points[y][x], points[y + 1][x]).draw(thick, colors[y][x]);
}
}
}
};
void Main()
{
Window::Resize(1080, 900);
Mountain mountain;
mountain.update();
while (System::Update())
{
mountain.draw();
}
}
3D化の部分に関しては、こちらの記事を参考にさせていただきました。
動きに組み込む
PerlinNoiseを動きに組み込むことで、自然に揺れるような動きを作ることができます。
# include <Siv3D.hpp>
struct PerlinEffect : IEffect
{
Vec2 m_pos;
PerlinNoise noise{ RandomUint32() };
explicit PerlinEffect(const Vec2& pos)
:m_pos{ pos + RandomVec2(5) } {}
bool update(double t)
{
m_pos -= Vec2{ 0, 500 } * Scene::DeltaTime();
Vec2 particlePos = m_pos + Vec2{ noise.noise1D(t) * 100, 0 };
Circle{ particlePos, 5 - t * 5 }.draw(ColorF(1, 1 - t));
return t < 1.0;
}
};
void Main()
{
Effect effect;
Stopwatch sw;
sw.start();
while (System::Update())
{
if (sw.sF() >= 0.05)
{
sw.restart();
effect.add<PerlinEffect>(Cursor::PosF());
}
effect.update();
}
}
このサンプルでは、x軸方向の動きをPerlinNoiseによって得ています。
パーティクルを揺らすだけでなく、カメラやテキストを揺らす、服に適用して風になびくような動きを表現する、人体そのものに適用して待機中の身体の揺れなどを表現するなどが考えられます。自然な感じにちょっとだけとりあえず動かしておきたい場合などに使えそうです。
ドメインワーピング
ドメインワーピングと呼ばれる手法を使うと、次のような流体のような動きを得ることができます。
# include<Siv3D.hpp>
void Main()
{
PerlinNoise noise;
while(System::Update())
{
double t = Scene::Time() * 5;
for (Vec2 p : step(Scene::Size() / 8))
{
Vec2 q{ noise.normalizedOctave2D0_1(p.x / 8.0 + t, p.y / 8.0 + t, 4),
noise.normalizedOctave2D0_1(p.x / 8.0 + t * 1.2, p.y / 8.0 + t * 1.5, 4) };
double v = noise.normalizedOctave2D0_1(p.x / 10 + q.x, p.y / 10 + q.y, 4);
RectF{ p.x * 8, p.y * 8, 8 }.draw(HSV(200, 0.5, v * 1.3));
}
}
}
PerlinNoiseによって生成されたノイズをさらにPerlinNoise自身によって歪ませることで、流体のような動作を得ることができます。前提として、同じ引数を与えると何度呼び出しても返ってくる結果は同じという性質を利用しています。noise(1, 2)
を10回呼び出しても同じ値が返ってくるということです。noise(1, 2)
の引数に更にノイズの値を足してnoise(1 + noise(1, 2), 2 + noise(1, 2))
をするみたいなことを行っています。
つまり、画面上のある座標における画素値を読み取ってそれをノイズで歪ませているみたいなことをしています。
そこで、PerlinNoiseによってテクスチャーを歪ませる動作を行うシェーダーを書いてみました。
//
// Textures
//
Texture2D g_texture0 : register(t0);
SamplerState g_sampler0 : register(s0);
namespace s3d
{
//
// VS Output / PS Input
//
struct PSInput
{
float4 position : SV_POSITION;
float4 color : COLOR0;
float2 uv : TEXCOORD0;
};
}
//
// Constant Buffer
//
cbuffer PSConstants2D : register(b0)
{
float4 g_colorAdd;
float4 g_sdfParam;
float4 g_sdfOutlineColor;
float4 g_sdfShadowColor;
float4 g_internal;
}
cbuffer Perlin : register(b1)
{
float g_time;
}
float rand(float2 n)
{
return frac(sin(dot(n, float2(12.9898, 78.233))) * 43758.5453);
}
float fade(float t)
{
return t * t * t * (t * (t * 6 - 15) + 10);
}
float noise(float2 p)
{
float2 pi = floor(p);
float2 pf = frac(p);
float w00 = rand(pi + float2(0.0, 0.0));
float w10 = rand(pi + float2(1.0, 0.0));
float w01 = rand(pi + float2(0.0, 1.0));
float w11 = rand(pi + float2(1.0, 1.0));
float2 f = float2(fade(pf.x), fade(pf.y));
float xa = lerp(w00, w10, f.x);
float xb = lerp(w01, w11, f.x);
return lerp(xa, xb, f.y);
}
float fbm(float2 p, int octaves)
{
float sum = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < octaves; i++)
{
sum += amplitude * noise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
}
return sum;
}
float4 PS(s3d::PSInput input) : SV_TARGET
{
float2 uv = input.uv;
float n = 10;
int octaves = 4;
float x = fbm(float2(uv.x * n - g_time * .0, uv.y * n - g_time * 1.2), octaves);
float y = fbm(float2(uv.x * n - g_time * .0, uv.y * n - g_time * 1.5), octaves);
float2 distortedUV = uv + float2(x, y / 2.0) * 0.05;
float4 texColor = g_texture0.Sample(g_sampler0, distortedUV);
return (texColor * input.color) + g_colorAdd;
}
# include <Siv3D.hpp>
struct Perlin
{
float time;
};
void Main()
{
const Texture windmill{ U"example/windmill.png" };
const PixelShader ps = HLSL{ U"example/shader/hlsl/perlin.hlsl", U"PS"};
if (not ps)
{
throw Error{ U"Failed to load a shader file" };
}
ConstantBuffer<Perlin> cb;
while (System::Update())
{
cb->time = static_cast<float>(Scene::Time() * 10);
{
Graphics2D::SetPSConstantBuffer(1, cb);
const ScopedCustomShader2D shader{ ps };
windmill.draw(10, 10);
}
}
}
水面の表現とかに使えそうです。
CurlNoise
2次元のパーリンノイズをベクトル場として、その勾配を求めて回転をかけると流体っぽい動きが得られるそうです。
よくわかっていないのですが、パーリンノイズをデコボコと見なしてその流れに沿った動きを得るみたいな感じだと思います。
クラス化してみました。
#include <Siv3D.hpp>
class CurlNoise
{
private:
PerlinNoise perlin{ RandomUint32() };
const double delta = 0.1;
const double scale = 128.0;
public:
CurlNoise()
{}
Vec2 noise2D(double x, double y, int32 octave = 1)
{
double dx = perlin.normalizedOctave2D((x + delta) / scale, y / scale, octave) -
perlin.normalizedOctave2D((x - delta) / scale, y / scale, octave);
double dy = perlin.normalizedOctave2D(x / scale, (y + delta) / scale, octave) -
perlin.normalizedOctave2D(x / scale, (y - delta) / scale, octave);
return Vec2(dy, -dx) / delta;
}
Vec2 noise2D(Vec2 pos, int32 octave = 1)
{
return noise2D(pos.x, pos.y, octave);
}
};
void Main()
{
Scene::SetBackground(Palette::Black);
CurlNoise curl;
Array<Vec2> particles(300);
for (auto& particle : particles)
{
particle = RandomVec2(Scene::Rect());
}
while (System::Update())
{
for (auto& particle : particles)
{
Vec2 force = curl.noise2D(particle);
particle += force * 10000 * Scene::DeltaTime();
// 画面の範囲内に留める
if (!Scene::Rect().contains(particle))
{
particle = RandomVec2(Scene::Rect());
}
Circle(particle, 2).draw(Palette::White);
}
}
}
おわりに
PerlinNoiseについてどのような実装になっているのか理解したい人は
パーリンノイズ|クリエイティブコーディングの教科書
というZennの記事が分かりやすいです。パーリンノイズだけでなく色々書いてあるので教科書としても優秀です。また、パーリンノイズを理解する|POSTDも理解を助けると思います。更なる応用を目指したい人はThe Book of Shadersを見ると良いと思います。シェーダーについての記事ですが、非常にためになります。
今回はC++で書いていましたが、画面全体に対しての処理などシェーダーを使って並列計算が得意なGPUに処理を行わせる必要性があると考えます。
また、画面全体に一つ一つRectを描画していたため、1枚の画像にすれば描画数を少なくさせることができます。
おしまい
参考文献
アルファブレンドとパーリンノイズを使ってSynthwave Sunsetを再現
プロシージャルな流体エフェクトを OpenSiv3D で実装する【Curl-Noise】
Koji's Site
パーリンノイズ|クリエイティブコーディングの教科書
パーリンノイズを理解する|POSTD
The Book of Shaders