Three.jsで広がる液体を実装する
Spreading Liquid with Three.js (mofu-dev.com)
要約
この記事では、Three.jsを使用して液体が広がるエフェクトを実装する方法について解説しています。以前の流体シミュレーション記事で紹介した速度マップを活用し、高さマップで液体の厚みを、アルファマップで液体の痕跡を表現することで、リアルな広がりと残像感を再現しています。物理的な正確さよりも視覚的な表現を優先した実装アプローチが紹介されています。
全文翻訳
Three.jsで広がる液体を実装する
はじめに
Vue Fes Japan 2026で、リキッドファンデーションのエフェクト実装について発表しました。この記事では、以前の流体シミュレーションの記事から続き、そのエフェクトをどのように作成したかを説明します。
この液体が広がるエフェクトは、2021年にリリースされた日本の化粧品ブランドのリキッドファンデーションのプロモーションプロジェクトのために開発しました。
ちょっと前なんですが今年6月にprimavisaシリーズのファンデーション・下地のテクスチャをシミュレーションできるサイトが公開されました。 homunculusさんにお声かけいただいて実装担当しました。 いろんな種類とカラバリをお試しできるのでぜひ見てみてください。
https://t.co/RiK5wff7S9 https://t.co/dyNJNoizSY
— Misaki Nakano (@misaki_mofujp) December 18, 2021
広がる液体のデモ
オリジナルのプロジェクトはメイクアップに関するものでしたが、このデモではファンデーションの色ではなく青色を選択しました。
下の液体の上をマウスや指でドラッグしてみてください。
どこから始めたか
2021年1月にファンデーションのシミュレーションを実装するように依頼されたとき、どこから始めればよいか全く分かりませんでした。約1ヶ月かけて、ファンデーション、あるいはもっと一般的には液体がどのように見え、どのように振る舞うのかを繰り返し考えました。
物理量を表すために高さマップを使用することはすでに考えていましたが、その高さをどのように動かすべきか確信が持てませんでした。もっと簡単なアプローチはないかと考えました。
最終的に、流体アルゴリズムが最善の解決策だろうと判断しました。液体に指を滑らせると、反対側に盛り上がり、周囲の物質が引き寄せられます。これらの動きを再現するために、流体シミュレーションの実装から始めました。
仕組み
まずは背景知識
このエフェクトは、以前のブログ記事で紹介した流体シミュレーションの結果を使用しています。液体の広がる動きを作成するために、同じ速度マップを使用しています。
[画像]Stable Fluids with three.jsmofu-dev.com
オープニングデモで盛り上がっている液体は、ジオメトリを変形させるのではなく、フラグメントシェーダーでレンダリングされています。液体の厚みを表すために、高さマップと呼ばれる別のFBO(フレームバッファオブジェクト)を用意します。
高さマップとは?
高さマップは地形によく使われますが、ここでは液体の厚みを表すために使用します。下の図は、液体の厚みを記録する高さマップの例です。各位置の高さの値は、そこにどれだけの液体が存在するかを表します。
[画像: 高さマップに格納された液体の厚みを示す断面図]
実装フロー
流体シミュレーション自体の説明は以前の記事で行ったので、ここでは厚みと不透明度の更新、そして結果のレンダリングに焦点を当てます。
- 速度マップを使用してソースUVを見つけ、厚みを更新します。
- 現在の高さマップと液体が適用された履歴を使用して、アルファマップの不透明度を更新します。
- 高さマップから法線を計算し、Matcapとアルファマップでレンダリングします。
[画像: 速度マップから高さマップ、アルファマップを経てレンダリングまでの処理フロー]
1. 速度マップで厚みを移動させる
まず、高さマップの初期値を準備します。フラグメントシェーダーを使用して、中心が盛り上がった半球状の塊を作成し、その高さを高さマップのRチャンネルに格納します。
initialHeight.frag
float calculateHeight(float value) {
return sqrt((1.0 - value * value) / 12.0);
}
void main() {
vec2 point = (vUv - 0.5) * 2.0 * resolution / min(resolution.x, resolution.y);
float distanceFromCenter = length(point);
float height = calculateHeight(smoothstep(0.0, 0.3, distanceFromCenter));
height = smoothstep(0.0, 0.025, height) * height * 0.66;
gl_FragColor = vec4(height, 0.0, 0.0, 1.0);
}
これらの初期値をFBOにレンダリングしてから、速度マップを使用して厚みを更新します。
[画像: 現在の位置から厚みが減少し、バックトレースされたソース位置から追加される様子]
流体シミュレーションによって生成された速度マップは、画面上の各位置における移動の方向と大きさを記録します。
まず、現在のピクセルの厚みと速度をサンプリングします。その速度に従って、どれだけの厚みが移動すべきかを計算し、元の厚みからその量を差し引きます。
height.frag
float previousHeight = texture2D(heightMap, vUv).r;
vec2 flow = texture2D(velocity, vUv).xy;
float previousStrength = smoothstep(
velocityThreshold.x,
velocityThreshold.y,
length(flow)
);
float removed = min(
previousHeight,
previousStrength * 0.3 * previousHeight
);
removed は現在のピクセルから差し引かれる厚みの量です。
次に、前の流体シミュレーション記事にも登場した移流(Advection)を使用します。現在のピクセルから速度を逆方向にたどり、ソース位置の厚みをサンプリングします。
ここで、現在のUV、速度、経過時間からソースUVを計算する backtraceUv() 関数の実装を示します。
height.frag
vec2 backtraceUv(vec2 uv, vec2 flow, vec2 fboSize, float dt) {
vec2 ratio = max(fboSize.x, fboSize.y) / fboSize;
vec2 velocityRatio = fboSize / min(fboSize.x, fboSize.y);
return uv - flow * velocityRatio * dt * ratio;
}
height.frag
vec2 sourceUv = backtraceUv(vUv, flow, fboSize, dt);
float sourceHeight = texture2D(heightMap, sourceUv).r;
float sourceStrength = smoothstep(
velocityThreshold.x,
velocityThreshold.y,
length(texture2D(velocity, sourceUv).xy)
);
float added = min(
sourceHeight,
sourceStrength * 0.3 * sourceHeight
);
vUv は現在のピクセルの位置です。sourceUv は速度を逆方向にたどって見つかった位置です。backtraceUv() 関数は、速度とタイムステップ dt を使用してその位置を計算します。added はソース位置から現在のピクセルに持ち込まれた厚みです。
最後に、現在の厚みから removed を差し引き、added を加えて高さマップを更新します。
height.frag
float nextHeight = max(0.0, previousHeight - removed + added);
gl_FragColor = vec4(nextHeight, 0.0, 0.0, 1.0);
結果は gl_FragColor のRチャンネルに書き込まれます。GチャンネルとBチャンネルは0、Aチャンネルは1です。アルファマップとレンダリングシェーダーは後で texture2D(heightMap, vUv).r を使用してこの値を読み取ります。
このようにして、各ピクセルは一部の厚みを失い、バックトレースされたソース位置から厚みを獲得します。
また、速度が小さい場合は厚みがほとんど移動しないように smoothstep() を使用しています。これにより、静止している領域が絶えず流れるのを防ぎ、ドラッグ操作の影響を受けた領域を中心に移動を集中させます。
これは物理的に正確な、体積保存の計算ではありません。望む見た目を達成するために調整しました。各フレームで、前の高さマップをサンプリングし、結果を次の高さマップにレンダリングし、2つのFBOを交互に使用します。
2. アルファマップで痕跡を残す
[画像: 現在の厚みと過去の最大値が不透明度を決定する様子]
高さマップは液体の盛り上がった形状を表しますが、液体が薄く広がったときの痕跡を完全に再現するものではありません。厚みがほとんどなくなった場所でも色が残るようにしたかったため、別途アルファマップを追加しました。
このアルファマップは、現在の厚みに由来する値と、これまでに記録された最大値の両方を使用します。
alpha.frag
float previousMaximum = texture2D(alphaMap, vUv).r;
float current = smoothstep(
0.0,
0.01 / 0.7,
max(0.0, texture2D(heightMap, vUv).r)
);
float maximum = max(current, previousMaximum);
gl_FragColor = vec4(
maximum,
maximum * 0.5 + current * 0.5,
0.0,
0.0
);
current は現在のフレームの高さマップから計算されます。maximum は、現在の値と前の最大値の大きい方を保持します。これにより、液体が通過した場所が記録されます。
Rチャンネルは過去の最大値を格納し、Gチャンネルは最大値と現在の値の均等なブレンドを格納します。Bチャンネルは使用されません。最終レンダリングパスはGチャンネルをサンプリングします。
その結果、液体が移動して厚みが減少しても、不透明度がすぐにゼロになることはありません。液体が通過した場所には色が残り、表面に付着しているように見えます。
これは乾燥や吸収の物理モデルではありません。単に液体の痕跡を保持するための履歴です。
[画像: 高さマップとアルファマップで保持された痕跡を示す比較]
3. 高さマップから法線を計算する
最後に、高さマップを使用して液体の表面にハイライトを追加します。このデモでは、液体の隆起や畝を形成するために実際のジオメトリの頂点を移動させていません。代わりに、フラグメントシェーダー内で高さマップから表面法線を計算します。
各ピクセルについて、その高さと、X方向およびY方向の隣接ピクセルの高さをサンプリングします。これらの3つの点は2つのベクトルを形成し、それらの外積が表面法線を与えます。
render.frag
float height = texture2D(heightMap, vUv).r;
vec2 stepSize = 1.0 / resolution;
vec2 uvX = vUv + vec2(stepSize.x, 0.0);
vec2 uvY = vUv + vec2(0.0, stepSize.y);
float heightScale = 0.2 / 0.66;
vec2 ratio = resolution / min(resolution.x, resolution.y);
vec3 position = vec3(
(vUv * ratio).x,
height * heightScale,
(vUv * ratio).y
);
vec3 positionX = vec3(
(uvX * ratio).x,
texture2D(heightMap, uvX).r * heightScale,
(uvX * ratio).y
);
vec3 positionY = vec3(
(uvY * ratio).x,
texture2D(heightMap, uvY).r * heightScale,
(uvY * ratio).y
);
vec3 normal = -normalize(
cross(positionX - position, positionY - position)
);
法線は、平坦な領域と急峻な隆起領域で異なる方向を持ちます。この違いにより、平坦な表面に液体が盛り上がっているように見える陰影とハイライトをレンダリングできます。
Matcapテクスチャを色とハイライトに使用します。法線をMatcapのUV座標に変換し、その色をサンプリングして、アルファマップからの不透明度と組み合わせます。
render.frag
vec3 color = texture2D(
matcap,
normal.zx * 0.5 + 0.5
).rgb + 0.05;
float alphaValue = texture2D(alphaMap, vUv).g;
gl_FragColor = vec4(
color,
step(0.1, alphaValue) * alphaValue
);
実際のシェーダーは、法線の傾きに基づいて色も調整します。高さマップが表面の形状を制御し、Matcapが外観を制御し、アルファマップが液体の痕跡が表面にどのように残るかを決定します。
これは物理的に正確な流体シミュレーションではない
上記で説明した厚みの計算は、物理的に正確ではありません。
フラグメントシェーダーはピクセルを並列に処理するため、あるピクセルがその速度で示される宛先の別のピクセルに直接厚みを書き込むことはできません。代わりに、各宛先ピクセルは速度を逆方向にたどり、ソース位置の高さマップをサンプリングします。
しかし、複数のピクセルがほぼ同じソース領域をサンプリングする可能性があります。厚みが宛先に正確に分散されないため、削除された量と追加された量の合計は常に一致しません。したがって、液体の総量は保存されません。
[画像: 直線のストロークは厚みを比較的安定に保ちますが、曲線的なストロークは液体の量を増やす可能性があります]
上記のように、直線的な動きでは厚みは比較的安定して保たれます。しかし、円を描く場合、サンプリングバイアスが蓄積し、液体の量が増加し続けます。この方法は、液体の量を正確に保存するよりも、視覚的な外観を優先しています。
ボーナス: テクスチャの精度
開発中に興味深いことに気づきました。下のデモは、高さマップの法線から計算されたグレースケールライティングを示しています。8ビットと32ビット浮動小数点テクスチャを切り替えるには、トグルを使用してください。
[画像: 8ビットテクスチャの段階的なシェーディングと、32ビット浮動小数点テクスチャを使用したスムーズなシェーディングの比較]
32ビット浮動小数点テクスチャは、より細かい精度で高さ値を格納でき、よりスムーズな輪郭とシェーディングを生成します。一方、8ビットテクスチャは、チャンネルごとに256レベルしか格納しません。これにより高さマップが量子化され、法線とシェーディングに目に見える段差が生じます。通常は色のグラデーションに関連付ける8ビット精度の限界が、ここではシェーディングの目に見える段差として現れることに興味を覚えました。
ボーナス: 3色の液体を混ぜる
遊びで別の実験も試しました。最初のデモのような半球状の塊を3つ用意し、それぞれに異なる色を付けました。マウスや指で液体を広げて色を混ぜることができます。
各液体には独自の高さマップとアルファ履歴がありますが、すべて同じ速度マップを共有します。厚みと不透明度は、最初のデモと同じ計算を使用して更新されます。最後に、各液体の厚みに応じて色を混合し(簡略化されたクベルカ・ムンクモデルを使用)、それらの結合された高さから表面法線を計算します。厚みがほぼゼロの場所では、各色の残りのアルファ履歴を使用して混合比率を決定します。
最後に
この実装について今書くことにしたのは、この方法を再び使用する機会があまりないだろうと考えたこと、そしてAIがさらに能力を高める前に、自分で考え出した方法を記録しておきたかったからです。
過去1年間だけでも、AIは座標や空間の理解、そして私が作成したいビジュアルとそれを達成するために必要な計算や実装を結びつける点で、はるかに向上したと感じています。
これを公開することで、この方法がもはや特別なものではなくなるかもしれません。それでも、私が何を考え、どのようにしてこのビジュアルエフェクトにたどり着いたのか、その痕跡を残すことができると考えました。