Forest Stroll は、ブラウザの中でそのまま歩き回れる three.js の小さな森です。なだらかな丘、谷を曲がりくねって下る小川、風に揺れる何万本もの草と葉、花、魚、蝶、そして低く傾いたゴールデンアワーの太陽。赤いワンピースの女の子が水辺に立っていて、地面をクリックするとそこへ歩いていきます。three.js r160 と WebGL 2 で動き、ダウンロードする 3D モデルや画像テクスチャは一切ありません。すべては読み込み時にコードで生成されます。
この記事では、実際のソースから短いコードを引用しながら、その組み立て方を順に説明します。地形、草、風、木、そして物理エンジンもナビメッシュも使わない three.js の歩ける 3D シーンです。
レンダラー、トーンマッピング、ゴールデンアワーの空
レンダラーはページのステージ用コンテナに描画し、物理ベースで照らす屋外シーン向けに設定しています。
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.68;
ACES フィルミックトーンマッピングは明るいハイライトを白飛びさせずになだらかに丸めてくれるので、空が画面に入るシーンでは重要です。露出は 1 よりかなり低くして、草原が白っぽく飛ばないようにしています。
空は three.js の Sky アドオンで、大気散乱をモデル化したシェーダーです。濁度(turbidity)とレイリー散乱を少しかすんだ午後になるよう設定し、太陽を地平線からわずか 16° の高さに置きました。シーン全体の暖かく影の長い見た目は、これで決まります。
const phi = THREE.MathUtils.degToRad(90 - 16); // sun 16° up: golden hour
const theta = THREE.MathUtils.degToRad(150);
sunDir.setFromSphericalCoords(1, phi, theta);
skyU.sunPosition.value.copy(sunDir);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(sky).texture;
PMREMGenerator の行は手軽に効く一手です。空をプリフィルタ済みの環境マップに焼き込むので、シーン内のすべての MeshStandardMaterial が空の色をした環境光と反射をタダで得られます。そのうえで、同じ sunDir 上に暖色の DirectionalLight を置き(影と空の向きが一致するように)、空き地周辺 100 × 100 の範囲をカバーする 2048 × 2048 のソフトシャドウマップ、フィル用に上が青・下が緑の HemisphereLight、そして遠景を溶かす淡いセージ色の指数フォグ(FogExp2)を加えています。
ひとつの高さ関数から作るプロシージャル地形
シーンの中で地面に触れるものはすべて、地面がどこにあるかをひとつの関数に問い合わせます。まずは小川。その中心線は周波数の異なる 2 つのサイン波で作った曲線 x = cxOf(z) で、目立った繰り返しなしに蛇行します。
const cxOf = z => 8 + 2.6 * Math.sin(z * 0.045) + 1.3 * Math.sin(z * 0.11 + 1.3);
function terrainH(x, z) {
const d = Math.abs(x - cxOf(z));
const rolling = (Math.sin(x * 0.13 + z * 0.07) + Math.sin(x * 0.05 - z * 0.11 + 1.7)) * 0.35
+ Math.sin(x * 0.021 + z * 0.017 + 0.5) * 0.9;
const flat = ss(3.2, 7.5, d); // flatten near the water
const carve = (1 - ss(0, 4.6, d)) * 1.15; // dig the valley
const bank = Math.exp(-((d - 5.6) ** 2) / 3.0) * 0.28; // slight lip on the bank
return rolling * flat - carve + bank;
}
レイヤーとして読んでください。rolling はなだらかな丘を作る 3 つのサイン波の重ね合わせ。d は小川の中心線までの水平距離で、ss は smoothstep です。丘に flat を掛けると水辺の近くで起伏が消えて岸が平らになり、carve は小川の真下で最大 1.15 ユニット削って水路を作ります。bank は中心から約 5.6 ユニット離れた位置にある小さなガウス型の盛り上がりで、自然な岸の縁に見えます。
メッシュは平らに寝かせた PlaneGeometry(240, 240, 190, 190) で、各頂点の高さを terrainH から設定します。同じループで d から頂点カラーも書き込みます。3 ユニット以内は暗い川床、3.6〜5.4 ユニットは砂から草へのグラデーション、その先はサイン波で色相と明るさを少し揺らした草地です。最後に computeVertexNormals() を 1 回呼べば地形は完成。ハイトマップもノイズライブラリも使っていません。
terrainH は軽くて決定的なので、川床の小石 160 個、岸の岩 70 個、草の一本一本、花、木、そして女の子の足の位置まで、すべてこれで配置しています。浮くものも沈むものもありません。
小川に沿う水のリボン
水は地形で切り抜いた大きな平面ではありません。カスタムの BufferGeometry で、240 行の帯になっています。各行は cxOf(z) の両側に置いた 2 頂点なので、リボンは水路にぴったり沿って曲がります。
for (let i = 0; i < rows; i++) {
const z = waterZ0 + (waterZ1 - waterZ0) * (i / (rows - 1));
const cx = cxOf(z);
wPos.set([cx - WATER_HALF, WATER_Y, z, cx + WATER_HALF, WATER_Y, z], i * 6);
}
マテリアルは MeshPhysicalMaterial で、transmission: 0.92、ior: 1.33(水の屈折率)、少しの厚みとティール系の減衰色を設定しています。transmission を使うと three.js は不透明なシーンを追加のバッファに描画し、それを水面越しにサンプリングするので、小石や川床がわずかに歪み、色づいて透けて見えます。単純な半透明より負荷は高いものの、ごく少ないコードで説得力のある浅い水になります。
さざ波は CPU で処理しています。毎フレーム、帯の 480 頂点の高さを位置と時間の 3 つのサイン波の和から設定し、法線を再計算するだけ。コストはほぼゼロです。嵐の海の記事では、同じ考え方をはるかに大きなスケールで使っています。
25,000 本の草を 1 回のドローコールで
いちばんよく質問されるのが草で、その大部分はひとつの InstancedMesh です。1 本の草は細い PlaneGeometry(0.085, 1.05, 1, 3) から始め、根元が原点に来るよう移動したうえで、各頂点を変形します。
const yy = p.getY(i) / 1.05;
p.setX(i, p.getX(i) * (1 - yy * 0.85)); // taper to a point
p.setZ(i, p.getZ(i) + yy * yy * 0.32); // curve forward
const v = 0.5 + yy * 0.65; // dark root, bright tip
暗い根元から明るい先端への頂点グラデーションは、密な草むらのアンビエントオクルージョンを安価に代用するものです。マテリアルは vertexColors を有効にした両面の MeshStandardMaterial です。
そして 25,000 個のインスタンスを半径 58 の円盤に散らします。r = Math.sqrt(Math.random()) * 58 を使うと円盤全体で密度が均一になり(平方根がないと中心に草が集まります)、小川から 4.6 ユニット以内の点は除外して水面をすっきり保ちます。各インスタンスにはランダムな向き、わずかな傾き、0.6〜1.7 のスケール、そして setColorAt による固有の色を与えます。three.js はインスタンスカラーと頂点グラデーションを掛け合わせるので、草はどれも違う緑なのに、暗から明への変化は共通です。
結果は 25,000 本の草、ひとつのジオメトリ、ひとつのマテリアル、1 回のドローコール。行列は起動時に一度だけ作ります。
onBeforeCompile で風を吹かせる
草専用のシェーダーは書いていません。代わりに小さなヘルパーが、three.js がローカル座標を計算した直後の箇所で標準マテリアルの頂点シェーダーにパッチを当てます。これで組み込みのライティング、影、フォグはすべてそのまま動きます。
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.vertexShader = 'uniform float uTime;\n' + shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
float wph = instanceMatrix[3].x * 0.8 + instanceMatrix[3].z * 0.6;
float wh = max(position.y, 0.0);
transformed.x += (sin(uTime * 1.7 + wph) + sin(uTime * 2.9 + wph * 1.7) * 0.4) * 0.17 * wh;
transformed.z += cos(uTime * 1.3 + wph * 1.2) * 0.102 * wh;`);
};
(少し簡略化しています。実際のヘルパーでは instanceMatrix の行を #ifdef USE_INSTANCING で囲み、振幅を引数で受け取っています。)うまく動く理由は 3 つあります。
- 位相を位置から取る。
instanceMatrix[3]はインスタンスの平行移動成分なので、wphは草原全体でなめらかに変わる位相になります。隣り合う草はほぼ一緒に動き、すべての草が同時にぴくつくのではなく、突風のように揺れが草原を渡っていきます。 - 高さで重み付けする。
position.y(根元で 0)を掛けることで、根元は地面に固定されたまま、先端がいちばん大きく揺れます。 - ユニフォームを共有する。 パッチを当てたマテリアルはすべて同じ
uTimeオブジェクトを受け取るので、フレームごとにuTime.value = tを 1 回代入するだけで、草と 7 本の木の葉がまとめて動きます。
葉には同じヘルパーを小さな振幅(0.07)で、高さの重み付けをオフにして呼び出します。リーフカードは曲がるのではなく、1 枚まるごと揺れます。
円柱とリーフカードで作る木
幹は先細りの CylinderGeometry を 2〜3 段重ねてわずかにランダムに傾け、根元に広がる根の円柱を 5 本、樹冠に向かって伸びる枝を数本加えたものです。どれも起動時に canvas に描いた樹皮テクスチャ(茶色の上に縦方向のベジェ曲線の筋)を使っています。
樹冠はもうひとつの InstancedMesh です。canvas に描いた葉をテクスチャにした 0.62 ユニットの正方形を、alphaTest: 0.45 で切り抜いています。本物の半透明ではなくアルファテストを使うので、ソートが不要で深度も正しくなります。何千枚ものカードが重なる以上、これは欠かせません。カードは 1 本の木につき数個の球状クラスターに、クラスターの体積に応じて割り振られ、主に各クラスターの外側寄りに配置されます。こうすると樹冠は、ぎっしり詰まって見えることなく、豊かに見えます。
const rr = c[3] * (0.55 + 0.45 * Math.cbrt(Math.random()));
dmy.position.set(c[0] + dir.x * rr, c[1] + dir.y * rr * 0.85, c[2] + dir.z * rr);
クラスターの上側の葉は下側より明るく暖かい色にしていて、木漏れ日が差し込むように見せかけています。大きな主役の木には 9,000 枚、6 本の小さな木にはそれぞれ 2,200 枚のリーフカードがあり、合計で約 22,200 枚。その奥、中心から 62〜90 ユニットの位置にはインスタンス化した円錐 90 本がリング状に並んでいます。フォグの中では遠くの木立に見え、ほぼコストゼロで空き地に奥行きを与えます。
クリック移動:レイキャスト、制限、歩行
カメラには OrbitControls を使い、左右どちらのマウスボタンも回転に割り当てています(タッチ画面には右ボタンがないため)。そこで最初の仕事は、クリックとドラッグを見分けることです。pointerup の時点で、ポインターが 6 ピクセル以上動いていたか、押下が 700 ms より長かった場合はイベントを無視し、そうでなければカメラからクリック位置へレイを飛ばします。
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(ground, false)[0];
if (hit) setGirlTarget(hit.point.x, hit.point.z);
判定するのは地形メッシュだけなので、レイキャストは速いまま、草を「すり抜けて」いつでも地面をクリックできます。
次に setGirlTarget が、ナビメッシュなしで 2 つのルールを適用します。目標が小川の中心線から 4.4 ユニット以内なら、彼女が今いる岸側へ押し戻します。対岸をクリックしても水に入らず、手前の岸辺へ向かうのはこのためです。また目標が中心から 26 ユニットより遠ければ、その円周上まで引き戻して、森の中にとどまるようにします。最終的な地点にはリングのマーカーを置き、0.9 秒かけて広がりながら消えていきます。
歩行はアニメーションループの数行です。彼女は毎秒 2.6 ユニットで目標へまっすぐ進み、近い回り方で目標の方へ向き直ります。
const want = Math.atan2(dx, dz);
let turn = (want - g.heading + Math.PI * 3) % (Math.PI * 2) - Math.PI;
g.heading += turn * Math.min(1, dt * 7);
角度の差を −π…π に収めることで、目標が左肩のすぐ後ろにあるときに一回転してしまうのを防いでいます。gait の値は 0(立ち止まり)と 1(歩行)のあいだをなめらかに変化し、脚と腕の振り、abs(sin(phase)) による小さな弾み、わずかな前傾、おさげ髪の揺れはすべてこれに比例します。動き出しも止まり方もなめらかで、立っているときは小さな呼吸の上下動に切り替わります。彼女の高さは単に現在位置での terrainH なので、どんな斜面にも沿って歩きます。
魚、蝶、舞い落ちる葉
小さく動くものはすべて安価なパラメトリックな動きで、シミュレーションはしていません。
- 魚。 10 匹の魚は、それぞれつぶした球にシェイプジオメトリのひれを付けたもので、レーンのオフセットを持って
cxOf(z)に沿って泳ぎ、小川の端まで来ると反対側へ折り返します。それぞれが曲線の少し先を見て下流を向き、尾はサイン波で揺れます。 - 蝶。 花びらのテクスチャを貼った 6 組の羽が、リサージュ風のループ(x と z で異なる速さのサイン波)を描いて飛びます。進行方向を向いて素早く羽ばたき、地形から 0.8 ユニットより下には降りません。
- 舞い落ちる葉。 ひとつの
InstancedMeshにまとめた 60 枚のリーフカードが、11 ユニットの周期で主役の木から落ちてきます。横に流れながら回転し、行列は毎フレーム書き換えています。 - 空気。 加算合成の花粉の粒 500 個、漂う雲のスプライト 7 枚、そして大きな木を通して
sunDirの方向に傾けた光芒の板 4 枚。
ポストプロセス:ブルームとカラーグレーディング
フレームは EffectComposer のチェーンを通ります。RenderPass、続いて強さ 0.32 と控えめで、しきい値を高くして最も明るい空と日の当たった葉の縁だけが光るようにした UnrealBloomPass、トーンマッピングと sRGB 変換を行う OutputPass、そして最後にカスタムの ShaderPass。グレーディングは出力パスの後に走るため、表示用に整った色を相手にでき、数値の意味が考えやすくなります。
col = mix(col, col * col * (3.0 - 2.0 * col), 0.22); // gentle S-curve
col += vec3(0.035, 0.018, -0.012) * smoothstep(0.55, 1.0, luma); // warm highlights
col += vec3(-0.018, 0.004, 0.022) * (1.0 - smoothstep(0.0, 0.45, luma)); // cool shadows
さらに彩度を 10% 上げ、ビネットを加えています。暖かいハイライトとややティール寄りのシャドウの組み合わせで、緑ばかりのシーンがデフォルトのレンダリングではなく、夕方近くの午後らしく感じられます。
軽く保つための工夫
Web ページとしては重いシーンなので、行儀よく振る舞う必要があります。
- 繰り返すものはすべてインスタンス化:草、葉、舞い落ちる葉、小石、岩、遠くの木立は、それぞれひとつの
InstancedMeshです。 - ピクセル比の上限は 1.75。 そうしないと高 DPI のスマホでは、ブルームと transmission を通して 3 倍のピクセルを描くことになります。
- 画面外では一時停止。 ステージの見えている部分が 15% 未満になると
IntersectionObserverがrequestAnimationFrameループを止め、タブが非表示になるとvisibilitychangeリスナーが止めます。ページには一時停止ボタンもあります。 - フレーム時間の上限は 50 ms。一時停止の後も、世界が先へ飛ぶことなく落ち着いて再開します。
- 安価なレイキャスト:判定は地面のメッシュだけで、インスタンス化した植生は対象にしません。
- 「動きを減らす」設定を尊重。 システムで動きを減らす設定を有効にしている訪問者には、カメラのゆっくりした待機周回をオフにしています。また、初めて左クリック、タップ、左ボタンでのドラッグをした時点で周回は完全に止まります。
まとめ
- ひとつの高さ関数を唯一の基準にする。 地形も小物もキャラクターもそれをサンプリングすれば、シーンはまとまります。
- 先に小川を作り、その周りに地形を作る。 中心線の曲線ひとつで、水路、水のメッシュ、魚の経路、「水に入らない」ルールが一度に手に入ります。
- 草はインスタンシングと頂点パッチ。 形を整えた 1 本の草、25,000 個の行列、
onBeforeCompileの数行で、驚くほど遠くまで行けます。 - 開けたシーンのクリック移動にナビメッシュは不要:地面にレイキャストし、単純なルールで目標を制限し、向き直りと歩容をなめらかに補間するだけです。
- 雰囲気を決めるのは光とグレーディング。 低い太陽、PMREM 環境マップ、フォグ、控えめなブルーム、暖色・寒色のグレーディングは、どんなモデルにも負けない働きをします。
シーンは Forest Stroll で公開中です。草原のあちこちをクリックし、カメラを回して、草のあいだを風が渡っていく様子を眺めてみてください。気に入ったら、three.js で嵐の海を作る記事や、2 ボーン IK で棒人間を動かす記事もどうぞ。