はじめに
この記事は、長野高専 Advent Calender 2023 24日目の記事です。
こんにちは。長野高専電子情報工学科5年のたのれんと申します。
みなさんはPerlinNoiseというものをご存じでしょうか。
Siv3DにはこのPerlinNoiseを生成する機能が備わっているため、簡単に使用することができます。
この記事では、PerlinNoiseとは何かとその活用例を紹介します。
PerlinNoiseとは
この記事ではPerlinNoiseがどのようなアルゴリズムか、どのような実装を行っているかについては説明しません。
PerlinNoiseは、Ken Perlin氏によって開発された滑らかな乱数を生成するためのノイズです。
乱数を生成する関数といえばRandom()
が挙げられます。PerlinNoise()
も同じようにランダム値を取得することができますが、PerlinNoise()
は繋がった値を返すという特徴があります。
これらの関数を使って1~10の値を返す乱数生成ジェネレーターを作ることを考えます。
Random()
を使うと1,8,3,10,7,4...
のようにランダムな値が返ってきますが、PerlinNoise()
を使うと3,4,5,5,4,2,1,2...
のように滑らかな変化をした値が返ってきます。
グラフにするとこんな感じ
Random()
PerlinNoise()
今回の場合は、PerlinNoise(x)
によって値を得ています。x
は画面の端から端までforループで変化させています。
そんな滑らかな値を返すPerlinNoiseですが、2次元に拡張すると次のような結果を得る事ができます。
PerlinNoise(x, y)
によって二次元のPerlinNoiseを利用することができます。変数x
及びy
は、画面上の座標を表しています。
これを利用して、自然な表現をすることができます。
フラクタルブラウン運動(FBM)
先ほどの2次元PerlinNoiseの画像を見て「なんか微妙だな...」と思いませんか?????
そこで、フラクタルブラウン運動(FBM) の登場です。
フラクタルブラウン運動とは、フラクタル と名がつく通りフラクタル図形のような自己相似性を持つ図形を複数個重ねることで、より細かい図形を作り出す...というものです。
図形の重ね方にもポイントがあり、周波数を2倍、振幅を2分の1倍にしながらオクターブの数だけ重ねます。
周波数???振幅???オクターブ?????となると思うので、一つずつ説明します。
オクターブは、難しい言い方をしてるだけで単純に重ねる回数だと思ってください。
振幅は、いわゆる振幅と同じです。Random()
との比較で出したPerlinNoiseのグラフがあったと思いますが、あれの変化の大きさだと思ってください。振幅が大きければ変化はより大きくなり、小さければ変化は小さくなります。
周波数もいわゆる周波数と同じようなものです。周波数が高ければ変化の間隔が短くなり、低ければ変化の間隔が長くなります。以下に、周波数が高いときと低いときの例を示します。
さて、戻りましてフラクタルブラウン運動ですが、周波数を2倍、振幅を2分の1倍にしながらオクターブの数だけ重ねて実現します。つまり、だんだん細かく小さくしていきながら重ねていきます。
パーリンノイズを理解する|POSTDという記事から引用すると以下のようになります。
だんだんと変化量が小さくなり、変化の間隔が短くなっているのが分かります。これらの波形を全て合成することで、フラクタルブラウン運動は完成します。
全て合成したものを以下に示します。
これを2次元に拡張したものを以下に示します。
なんかめっちゃいい感じじゃないですか?
このようにして、フラクタルブラウン運動(FBM)ではより細かいディテールのノイズを得ることができます。
活用例1 山のような表現
PerlinNoiseによって得られた値をそのまま高さに適用することで滑らかな山のような地形を生成することができます。
PerlinNoiseは値の変化が滑らかであるため、このような地形の自動生成をすることができます。
コード全文(あんまり整理してません)
# include <Siv3D.hpp>
enum class DrawMountainType : uint8
{
Dot,
Line,
Quad
};
class Mountain
{
private:
PerlinNoise noise{ RandomUint32() };
Grid<Vec2> points;
Grid<ColorF> colors;
ColormapType colorType;
DrawMountainType drawType;
double yStart = Scene::Height() + 100;
double yEnd = 0;
double xStart = 20000;
double xEnd = Scene::Width();
double noiseXStart = 0.0;
double noiseYStart = 0.0;
double scale = 10.0;
double variation = 10.0;
uint8 octave = 1;
double (*EasingFunc)(double);
public:
Mountain()
:yStart{ Scene::Height() + 100.0 },
yEnd{ 0 },
points{ Grid<Vec2>(500, 200, Vec2(0, 0)) },
colors{ Grid<ColorF>(500, 200, Palette::White) },
colorType{ ColormapType::Turbo },
drawType{ DrawMountainType::Dot },
xEnd{ (double)Scene::Width() },
xStart{ 20000 },
noiseXStart{ 0.0 },
noiseYStart{ 0.0 },
scale{ 10.0 },
variation{ 10.0 },
EasingFunc{ EaseOutSine }
{}
Mountain(uint32 xDivisionNum, uint32 yDivisionNum)
:yStart{ Scene::Height() + 100.0 },
yEnd{ 0 },
points{ Grid<Vec2>(xDivisionNum, yDivisionNum, Vec2(0, 0)) },
colors{ Grid<ColorF>(xDivisionNum, yDivisionNum, Palette::White) },
colorType{ ColormapType::Turbo },
drawType{ DrawMountainType::Dot },
xEnd{ (double)Scene::Width() },
xStart{ 20000 },
noiseXStart{ 0.0 },
noiseYStart{ 0.0 },
scale{ 10.0 },
variation{ 10.0 },
EasingFunc{ EaseOutSine }
{}
void updateNoiseX(double x)
{
noiseXStart = x;
}
void updateNoiseY(double y)
{
noiseYStart = y;
}
void regenerate()
{
noise = PerlinNoise{ RandomUint32() };
}
void update()
{
for (auto y : step(points.height()))
{
// y座標(0.0~1.0)を計算 イージングで3Dっぽい変化に見せてる
double yRatio = EasingFunc((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()))
{
points[y][x].x = Scene::Width() / 2.0 - xWidth / 2.0 + width * x;
points[y][x].y = yPos - (20 * EasingFunc(1 - yRatio)) - noise.octave2D(noiseXStart + x / scale, noiseYStart + y / scale, octave) * variation;
colors[y][x] = Colormap01(noise.octave2D0_1(noiseXStart + x / scale, noiseYStart + y / scale, octave), colorType);
}
}
}
void drawDot(double thick = 1) const
{
for (size_t y = points.height() - 1; y > 0; y--)
{
for (auto x : step(points.width()))
{
Circle{ points[y][x], thick }.draw(colors[y][x]);
}
}
}
void drawDot(double thick, ColorF color) const
{
for (size_t y = points.height() - 1; y > 0; y--)
{
for (auto x : step(points.width()))
{
Circle{ points[y][x], thick }.draw(color);
}
}
}
void drawDot(double thick, Optional<ColorF> color) const
{
for (size_t y = points.height() - 1; y > 0; y--)
{
for (auto x : step(points.width()))
{
Circle{ points[y][x], thick }.draw(color.has_value() ? *color : colors[y][x]);
}
}
}
void drawLine(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 drawLine(double thick, ColorF color) 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, color);
// 縦線
if (y < points.height() - 1) Line(points[y][x], points[y + 1][x]).draw(thick, color);
}
}
}
void drawLine(double thick, Optional<ColorF> color) 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, color.has_value() ? *color : colors[y][x]);
// 縦線
if (y < points.height() - 1) Line(points[y][x], points[y + 1][x]).draw(thick, color.has_value() ? *color : colors[y][x]);
}
}
}
void drawQuad() const
{
for (size_t y = points.height() - 1; y > 0; y--)
{
for (auto x : step(points.width()))
{
if (x < points.width() - 1 && y < points.height() - 1)
{
Quad(points[y][x], points[y][x + 1], points[y + 1][x + 1], points[y + 1][x]).draw(colors[y][x]);
}
}
}
}
void drawQuad(ColorF color) const
{
for (size_t y = points.height() - 1; y > 0; y--)
{
for (auto x : step(points.width()))
{
if (x < points.width() - 1 && y < points.height() - 1)
{
Quad(points[y][x], points[y][x + 1], points[y + 1][x + 1], points[y + 1][x]).draw(color);
}
}
}
}
void drawQuad(Optional<ColorF> color) const
{
for (size_t y = points.height() - 1; y > 0; y--)
{
for (auto x : step(points.width()))
{
if (x < points.width() - 1 && y < points.height() - 1)
{
Quad(points[y][x], points[y][x + 1], points[y + 1][x + 1], points[y + 1][x]).draw(color.has_value() ? *color : colors[y][x]);
}
}
}
}
void draw(Optional<ColorF> color) const
{
switch (drawType)
{
case DrawMountainType::Dot:
drawDot(1, color);
break;
case DrawMountainType::Line:
drawLine(1, color);
break;
case DrawMountainType::Quad:
drawQuad(color);
break;
default:
break;
}
}
void setScale(double scale)
{
this->scale = scale;
}
void setVariation(double variation)
{
this->variation = variation;
}
void setEasingFunc(double (*func)(double))
{
this->EasingFunc = func;
}
void setColorType(ColormapType colorType)
{
this->colorType = colorType;
}
void setDivisionNum(uint32 xDivisionNum, uint32 yDivisionNum)
{
points.clear();
colors.clear();
points = Grid<Vec2>(xDivisionNum, yDivisionNum);
colors = Grid<ColorF>(xDivisionNum, yDivisionNum);
}
void setDrawType(DrawMountainType drawType)
{
this->drawType = drawType;
}
Mountain& _setYStart(double yStart)
{
this->yStart = yStart;
return *this;
}
Mountain& _setYEnd(double yEnd)
{
this->yEnd = yEnd;
return *this;
}
Mountain& _setXEnd(double xEnd)
{
this->xEnd = xEnd;
return *this;
}
Mountain& _setXStart(double xStart)
{
this->xStart = xStart;
return *this;
}
Mountain& _setNoiseXStart(double noiseXStart)
{
this->noiseXStart = noiseXStart;
return *this;
}
Mountain& _setNoiseYStart(double noiseYStart)
{
this->noiseYStart = noiseYStart;
return *this;
}
Mountain& _setScale(double scale)
{
this->scale = scale;
return *this;
}
Mountain& _setVariation(double variation)
{
this->variation = variation;
return *this;
}
Mountain& _setOctave(uint8 octave)
{
this->octave = octave;
return *this;
}
Mountain& _setEasingFunc(double (*func)(double))
{
this->EasingFunc = func;
return *this;
}
Mountain& _setColorType(ColormapType colorType)
{
this->colorType = colorType;
return *this;
}
};
void Main()
{
Window::Resize(1080, 900);
const Array<String> drawTypeNames =
{
U"Dot",
U"Line",
U"Quad"
};
size_t drawTypeIndex = 0;
const Array<String> easeTypeNames =
{
U"Sine",
U"Quad",
U"Cubic",
U"Quart",
U"Quint",
U"Expo",
U"Circ"
};
size_t easeTypeIndex = 0;
const Array<double (*)(double)> easeTypes
{
EaseOutSine,
EaseOutQuad,
EaseOutCubic,
EaseOutQuart,
EaseOutQuint,
EaseOutExpo,
EaseOutCirc,
};
const Array<String> colorMapNames =
{
U"Parula", U"Heat", U"Jet", U"Turbo", U"Hot", U"Gray", U"Magma",
U"Inferno", U"Plasma", U"Viridis", U"Cividis", U"Github"
};
size_t colorMapIndex = 0;
double scale = 100.0;
double variation = 100.0;
double xDivisionNum = 500;
double yDivisionNum = 200;
double oldXDivisionNum = 500;
double oldYDivisionNum = 200;
bool monochro = false;
HSV color = Palette::White;
Optional<ColorF> optColor;
Mountain mountain = Mountain{ (uint32)xDivisionNum, (uint32)yDivisionNum }
._setXStart(Scene::Width() * 2.0)
._setYStart(Scene::Height() + 100)
._setYEnd(Scene::Height() / 1.5)
._setScale(100.0)
._setVariation(100.0)
._setOctave(2)
._setEasingFunc(EaseOutSine);
double now = 0;
double target = 0;
double velocity = 0;
mountain.update();
while (System::Update())
{
now = Math::SmoothDamp(now, target, velocity, 0.1);
if (KeySpace.down())
{
if (target == 0) target = -Scene::Width();
else target = 0;
}
mountain.draw(optColor);
{
const ScopedViewport2D viewport{ (int32)now, 0, Scene::Size() };
const Transformer2D transformer{ Mat3x2::Identity(), Mat3x2::Translate((int32)now, 0) };
RectF{ 0, 0, Scene::Size() }.draw(ColorF(1, 0.5));
if (SimpleGUI::RadioButtons(drawTypeIndex, drawTypeNames, Vec2{ 20, 20 }, 100))
{
mountain.setDrawType(DrawMountainType{ static_cast<uint8>(drawTypeIndex) });
mountain.update();
}
if (SimpleGUI::RadioButtons(colorMapIndex, colorMapNames, Vec2{ 330, 20 }, 160))
{
mountain._setColorType(ColormapType{ static_cast<uint8>(colorMapIndex) });
mountain.update();
}
if (SimpleGUI::RadioButtons(easeTypeIndex, easeTypeNames, Vec2{ 520, 20 }, 160))
{
mountain.setEasingFunc(easeTypes[easeTypeIndex]);
mountain.update();
}
SimpleGUI::CheckBox(monochro, U"単色化", Vec2{ 150, 20 });
SimpleGUI::ColorPicker(color, Vec2{ 150, 60 }, monochro);
if (monochro)
{
optColor = color;
}
else
{
optColor = none;
}
SimpleGUI::Slider(U"拡大率:{:.2f}"_fmt(scale), scale, 1.0, 250.0, Vec2{ 20, 220 }, 150, 150);
SimpleGUI::Slider(U"変化量:{:.2f}"_fmt(variation), variation, 1.0, 250.0, Vec2{ 20, 280 }, 150, 150);
mountain._setScale(scale);
mountain._setVariation(variation);
}
}
}
PerlinNoiseによって得られた値を2乗したり絶対値を取ることによってより鋭利な地形を得ることもできます。
絶対値を取ったもの↓
活用例2 動きに組み込む
PerlinNoiseを動きに組み込むことで、自然に揺れるような動き等を作ることができます。
パーティクルを揺らすだけでなく、カメラを揺らしたりテキストを揺らして視覚的な効果を自然に作ることもできます。
また、服に適用して風になびくような動きを表現したり、人体そのものに適用して待機中の身体の揺れなどを表現することもできます。
コード全文
# include <Siv3D.hpp> // OpenSiv3D v0.6.11
struct PerlinEffect : IEffect
{
Vec2 m_pos;
ColorF color;
PerlinNoise noise{ RandomUint32() };
explicit PerlinEffect(const Vec2& pos)
:m_pos{ pos + RandomVec2(5) },
color{ ColorF(1) } {}
bool update(double t)
{
m_pos -= Vec2{ 0, noise.noise1D0_1(t) * 15 };
color.a = 1 - t;
Circle{ m_pos + Vec2{noise.noise1D(t) * 100, 0}, 5 - t * 5 }.draw(color);
return t < 1.0;
}
};
void Main()
{
PerlinNoise noise;
Effect effect;
double add = 0;
while (System::Update())
{
add += Scene::DeltaTime();
if (add >= 0.05)
{
effect.add<PerlinEffect>(Cursor::PosF());
add = 0;
}
effect.update();
}
}
活用例3 川の流れのように
これはドメインワーピングと呼ばれる手法を使って作っており、PerlinNoiseで生成したノイズをPerlinNoise自身で歪めることによって流体のような動きが得られます。
前提として同じ引数を与えると何度呼び出しても返ってくる結果は同じという性質を利用しています。noise(1, 1)
を10回呼び出しても同じ値が返ってくるということです。random()
を複数回呼び出すとそれぞれ異なる値が返ってきますが、PerlinNoiseそうではないということです。
数式風にドメインワーピングを書くと以下のようになります。
result=fbm(uv+fbm(t))
uv
は座標、t
はプログラムの実行時間を表します。何回歪ませるのか等は決まってないので、100回歪ませるとかもできます。
コード全文
# include<Siv3D.hpp> // OpenSiv3D v0.6.10
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についてどのような実装になっているのか理解したい人は
パーリンノイズ|クリエイティブコーディングの教科書
というZennの記事が分かりやすいです。パーリンノイズだけでなく色々書いてあるので教科書としても優秀です。また、パーリンノイズを理解する|POSTDも理解を助けると思います。
更なる応用を目指したい人はThe Book of Shadersを見ると良いと思います。シェーダーについての記事ですが、非常にためになります。
今回はC++で書いたため、画面全体について処理を行うと非常に重くなってしまいました。この画面全体に対する処理というのは、本来CPUで処理を行うのではなく並列計算が得意なGPUに任せるべきなのですが...シェーダーなんも分からんので誰か強い人が書いてください。
今回紹介した活用例はあくまで一部で伝えきれていない部分が多くありますが、PerlinNoiseの魅力が少しでも伝わったならうれしいです!
おしまい