[Babylon.js #05] fBmノイズ地形とthin instancesで草原を作る

1. 今回やること

前回(#04)では、HTTPS対応からQuest 2でのWebXR没入までを確認しました。 今回はその続きとして、「歩ける草原フィールド」 を Babylon.js で作っていきます。

やることは大きく6つです。

  • fbm2D を使って、ノイズベースの地形を作る
  • 地面メッシュを変形したあとに、法線を再計算して見た目を整える
  • 地形がマイナス方向に沈みすぎないように、沈み込み防止を入れる
  • thin instances で草を大量配置して、軽い草原表現を作る
  • 草にシェーダを当てて、風ゆれを入れる
  • WebXR で、地形の起伏に合わせて移動できるようにする

今回のゴールは、MMDキャラを置く前段階としての 地形・草・移動基盤 を固めることです。 つまり、次回以降の「キャラ配置」「ランダム移動」「地面吸着」を気持ちよく載せられる状態まで持っていきます。

前回の記事:

動画(PC):

動画(VR):

2. fBmノイズ関数を用意する(fbm2D)

まずは地形の元になるノイズ関数を用意します。 今回使うのは、Value Noise + fBm(fractal Brownian motion) の組み合わせです。

fBm は、ざっくり言うと 粗いノイズと細かいノイズを何層か重ねる 方法です。 1枚のノイズだと単調になりやすいですが、重ねることで自然な地形っぽさが出ます。

script/noise.ts

// /script/noise.ts

export function fbm2D(x: number, z: number, octaves = 5, lacunarity = 2.0, gain = 0.5) {
  let amp = 1.0;
  let freq = 1.0;
  let sum = 0.0;
  let norm = 0.0;

  for (let i = 0; i < octaves; i++) {
    sum += valueNoise2D(x * freq, z * freq) * amp;
    norm += amp;
    amp *= gain;
    freq *= lacunarity;
  }

  return sum / norm; // だいたい [-1, 1]
}

function valueNoise2D(x: number, z: number) {
  const xi = Math.floor(x);
  const zi = Math.floor(z);
  const xf = x - xi;
  const zf = z - zi;

  const u = smoothstep(xf);
  const v = smoothstep(zf);

  const n00 = hash2(xi, zi);
  const n10 = hash2(xi + 1, zi);
  const n01 = hash2(xi, zi + 1);
  const n11 = hash2(xi + 1, zi + 1);

  const nx0 = lerp(n00, n10, u);
  const nx1 = lerp(n01, n11, u);

  return lerp(nx0, nx1, v) * 2 - 1; // [-1, 1]
}

// 簡易ハッシュ
function hash2(x: number, z: number) {
  const s = Math.sin(x * 127.1 + z * 311.7) * 43758.5453123;
  return s - Math.floor(s);
}

function smoothstep(t: number) {
  return t * t * (3 - 2 * t);
}

function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}

パラメータの意味(ざっくり)

  • octaves ノイズを何層重ねるか。増やすほど細かくなる(ただし重くなる)

  • lacunarity 各層ごとの周波数の増え方(通常 2.0 前後)

  • gain 各層ごとの振幅の減衰(通常 0.4〜0.6 くらい)

今回の用途(地形)なら、まずはこのあたりで十分です。

  • octaves = 4〜6
  • lacunarity = 2.0
  • gain = 0.5

ここでのポイント

この段階ではまだ地形は変形しません。 fbm2D(x, z) で 「その座標の高さの元になる値」 を返せるようにするのが目的です。

次のセクションで、これを地面メッシュの y 座標に反映して、実際に起伏を作っていきます。

3. 地面メッシュをノイズで変形する

fbm2D が用意できたので、次は Babylon.js の地面メッシュを直接書き換えて、起伏を作ります。 やることはシンプルで、各頂点の y 座標をノイズ値で更新するだけです。

ただし、地面を変形したあとに 法線の再計算 をしないと、ライティングが崩れて見えるので、そこは必須です。


main.ts 側(地面生成)

まず地面は updatable: true で作っておきます。これがないと頂点更新できません。

const ground = MeshBuilder.CreateGround(
  "ground",
  {
    width: areaSize,
    height: areaSize,
    subdivisions: 250,
    updatable: true, // 頂点更新に必要
  },
  scene,
);

// 生成直後に一回だけ変形
deformGroundWithNoise(ground);

地面変形関数

import { Mesh, VertexBuffer, VertexData } from "@babylonjs/core";
import { fbm2D } from "./script/noise";

function deformGroundWithNoise(ground: Mesh) {
  const positions = ground.getVerticesData(VertexBuffer.PositionKind);
  const indices = ground.getIndices();

  if (!positions || !indices) {
    console.warn("No positions/indices");
    return;
  }

  const amp = 20.0;   // 高さの強さ
  const scale = 0.08; // 地形の細かさ(小さいほど大きなうねり)
  const oct = 5;      // fBmの層数

  for (let i = 0; i < positions.length; i += 3) {
    const x = positions[i];
    const z = positions[i + 2];

    const n = fbm2D(x * scale, z * scale, oct);

    // 少し山谷を強調(任意)
    const shaped = Math.sign(n) * Math.pow(Math.abs(n), 1.6);

    positions[i + 1] = shaped * amp;
  }

  // 頂点位置を更新
  ground.updateVerticesData(VertexBuffer.PositionKind, positions, true);

  // 法線を再計算(重要)
  const normals = new Array<number>(positions.length);
  VertexData.ComputeNormals(positions, indices, normals);
  ground.updateVerticesData(VertexBuffer.NormalKind, normals, true);

  // 当たり判定や pick 用の境界更新
  ground.refreshBoundingInfo(true);
}

パラメータ調整のコツ

amp(高さ)

  • 大きいほど起伏が大きくなる
  • まずは 5〜20 で調整すると分かりやすいです

scale(地形の密度)

  • 小さい → なだらかで大きい丘
  • 大きい → 細かくゴツゴツ

oct

  • 増やすほどディテールが増える
  • 増やしすぎると重くなりやすいので、4〜6 くらいで十分です (50 はかなり重いので、常用はあまりおすすめしません)

「沈み込み防止」の前段として

このままだとノイズ値が負のときに、地面が y=0 より下へ沈みます。 それ自体は悪くないのですが、用途によっては「地面は下に掘らず、上にだけ盛りたい」ことがあります。

その対策(負方向を潰す / オフセットを足す)は、次のセクションでまとめて扱うと流れがきれいです。


ここでのポイント

  • CreateGround(... updatable: true) を使う
  • positions[i + 1] をノイズで更新する
  • 法線再計算は必須
  • refreshBoundingInfo(true) で Ray / Pick も安定しやすくなる

次で「沈み込み防止」を入れると、地形の使い勝手がかなり良くなります。

4. 沈み込み防止(負方向を潰す / オフセット)

地形をノイズで変形すると、fbm2D() の値は -1〜1 付近を取るので、当然 マイナス側(地面が下に沈む) も出ます。

これはこれで自然ですが、用途によっては次のようなケースがあります。

  • 地面の基準面(y=0)より下に行ってほしくない
  • WebXR移動やキャラ吸着で、地面の最低高度を安定させたい
  • 「谷」より「盛り上がり中心」の見た目にしたい

対策は主に2パターンです。


パターンA: 負方向を潰す(0未満を切る)

一番わかりやすい方法です。 ノイズから作った高さが負なら 0 に丸めます。

const h = shaped * amp;
positions[i + 1] = Math.max(0, h);

これで地面は y=0 より下に沈まなくなります。


パターンB: 全体を持ち上げる(オフセット)

谷は残したいけど、全体を上にずらしたい場合はオフセットを足します。

const h = shaped * amp;
const yOffset = 8.0;
positions[i + 1] = h + yOffset;

これだと起伏はそのままに、地面全体の最低位置を上げられます。


実用版(両方対応できる形)

あとで調整しやすいように、clampToZeroyOffset を分けておくと便利です。

function deformGroundWithNoise(ground: Mesh) {
  const positions = ground.getVerticesData(VertexBuffer.PositionKind);
  const indices = ground.getIndices();

  if (!positions || !indices) {
    console.warn("No positions/indices");
    return;
  }

  const amp = 20.0;
  const scale = 0.08;
  const oct = 5;

  // 沈み込み対策パラメータ
  const clampToZero = true; // true: 0未満を潰す
  const yOffset = 0.0;      // 全体を持ち上げたい時に使う(例: 5.0)

  for (let i = 0; i < positions.length; i += 3) {
    const x = positions[i];
    const z = positions[i + 2];

    const n = fbm2D(x * scale, z * scale, oct);
    const shaped = Math.sign(n) * Math.pow(Math.abs(n), 1.6);

    let y = shaped * amp + yOffset;

    if (clampToZero) {
      y = Math.max(0, y);
    }

    positions[i + 1] = y;
  }

  ground.updateVerticesData(VertexBuffer.PositionKind, positions, true);

  const normals = new Array<number>(positions.length);
  VertexData.ComputeNormals(positions, indices, normals);
  ground.updateVerticesData(VertexBuffer.NormalKind, normals, true);

  ground.refreshBoundingInfo(true);
}

見た目の違い

  • 負方向を潰す (Math.max(0, y))

    • 平地が増える
    • 谷が消える
    • ゲームっぽい歩きやすい地形になりやすい
  • オフセット (+ yOffset)

    • 谷は残る
    • 全体が持ち上がる
    • 自然地形っぽさを残しやすい

ちょっと自然にしたい場合(ハードに切らない)

Math.max(0, y) だと 0 の境目がやや不自然になることがあります。 その場合は、ノイズの作り方自体を「上方向寄り」にする方法もあります。

例えば [-1,1][0,1] に寄せて使う方法です。

const t = fbm2D(x * scale, z * scale, oct) * 0.5 + 0.5; // 0..1
const y = Math.pow(t, 1.6) * amp;
positions[i + 1] = y;

これは「沈み込み防止」というより、最初から 盛り地形だけ作る 形です。


ここでのポイント

  • 一番簡単なのは Math.max(0, y)
  • 谷を残したいなら + yOffset
  • 見た目重視なら、ノイズ値を最初から 0..1 に寄せるのもあり

5. DynamicTextureで地面テクスチャを生成する

次は、地面に貼るテクスチャを画像ファイルではなく、DynamicTexture でその場生成します。 今回の構成だとこれがかなり相性良いです。

  • 外部画像が不要
  • fbm2D と同じノイズ感で地形と見た目を揃えやすい
  • 色味の調整がコードだけで完結する

まずは地面テクスチャ生成関数

script/grass.ts など、地面マテリアルを作っている側に置けばOKです。

function makeGroundTexture(scene: Scene, size = 512) {
  const dt = new DynamicTexture("groundDT", { width: size, height: size }, scene, true);
  const ctx = dt.getContext();
  const img = ctx.getImageData(0, 0, size, size);
  const data = img.data;

  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      // テクスチャ座標をワールドっぽい座標に変換
      const wx = (x / size - 0.5) * areaSize;
      const wz = (y / size - 0.5) * areaSize;

      // fBmノイズ(-1..1)
      const n = fbm2D(wx * 0.08, wz * 0.08, 5);

      // 0..1 に変換
      const t = n * 0.5 + 0.5;

      // 草色と土色を混ぜる
      const grass = { r: 35, g: 90, b: 35 };
      const dirt = { r: 70, g: 55, b: 35 };

      // t が低いほど土を多めに
      const k = Math.pow(1.0 - t, 1.6);

      const r = Math.floor(grass.r * (1 - k) + dirt.r * k);
      const g = Math.floor(grass.g * (1 - k) + dirt.g * k);
      const b = Math.floor(grass.b * (1 - k) + dirt.b * k);

      const i = (y * size + x) * 4;
      data[i + 0] = r;
      data[i + 1] = g;
      data[i + 2] = b;
      data[i + 3] = 255;
    }
  }

  ctx.putImageData(img, 0, 0);
  dt.update();

  // 繰り返し可能にする
  dt.wrapU = Texture.WRAP_ADDRESSMODE;
  dt.wrapV = Texture.WRAP_ADDRESSMODE;

  return dt;
}

地面マテリアルに適用する

地面作成後に StandardMaterial に貼ります。

const groundMaterial = new StandardMaterial("groundMaterial", scene);
groundMaterial.diffuseTexture = makeGroundTexture(scene, 1024);

(groundMaterial.diffuseTexture as Texture).uScale = 6;
(groundMaterial.diffuseTexture as Texture).vScale = 6;

groundMaterial.specularColor = new Color3(0, 0, 0);      // ツヤ消し
groundMaterial.ambientColor = new Color3(0.35, 0.35, 0.35); // 黒落ち軽減
groundMaterial.diffuseColor = new Color3(1, 1, 1);       // テクスチャ色をそのまま

ground.material = groundMaterial;

何をしているか(ざっくり)

DynamicTexture は canvas 的なものなので、getImageData() で ピクセルを直接書き換えできます。

今回の流れは以下です。

  1. テクスチャ上の (x, y) を作る
  2. それを地形と同じスケール感の座標 (wx, wz) に変換
  3. fbm2D() でノイズ値を取る
  4. ノイズ値で「草色」と「土色」をブレンド
  5. putImageData()update() でGPUに反映

このやり方の良いところは、地形の起伏ロジックと見た目のロジックを揃えやすいことです。


調整ポイント

1) ノイズの細かさ

ここで効くのはこのあたりです。

const n = fbm2D(wx * 0.08, wz * 0.08, 5);
  • 0.08 を小さく → 模様が大きくなる
  • 0.08 を大きく → 模様が細かくなる
  • 5(octaves)を増やす → 情報量が増える(重くなる)

2) 草と土の比率

この式で見た目がかなり変わります。

const k = Math.pow(1.0 - t, 1.6);
  • 1.6 を大きくすると、土の出方が偏る
  • 1.0 に近いと、なだらかに混ざる

3) 色味

ここは完全に好みでOKです。

const grass = { r: 35, g: 90, b: 35 };
const dirt  = { r: 70, g: 55, b: 35 };

少し青寄りにすると夜っぽく、黄寄りにすると乾いた草地っぽくなります。


ひと工夫(ムラ感を増やす)

単色ブレンドだけだと綺麗すぎるので、少しランダムを足すと自然になります。

const jitter = (Math.random() * 2 - 1) * 6;
data[i + 0] = Math.max(0, Math.min(255, r + jitter));
data[i + 1] = Math.max(0, Math.min(255, g + jitter));
data[i + 2] = Math.max(0, Math.min(255, b + jitter));

ただし Math.random() は毎回見た目が変わるので、再現性が欲しいならノイズベースで揺らした方が安定します。


この段階の効果

ここまでで、

  • 地形の起伏(頂点変形)
  • 沈み込み防止
  • 地面の色テクスチャ(DynamicTexture)

が揃うので、平面1枚の地面でもかなり雰囲気が出ます。

6. thin instancesで草を大量配置する

ここが今回の見た目の要です。 草を1本ずつ MeshBuilder.CreatePlane() していたらすぐ重くなるので、thin instances で1つの草メッシュを大量配置します。


まずは「草1本」の元メッシュを作る

草は板ポリ2枚を十字に交差させた形にすると、軽くてそれっぽく見えます。

const p1 = MeshBuilder.CreatePlane("grassP1", { width: 0.4, height: 1.0 }, scene);
p1.position.y = 0.5;

const p2 = MeshBuilder.CreatePlane("grassP2", { width: 0.4, height: 1.0 }, scene);
p2.position.y = 0.5;
p2.rotation.y = Math.PI / 2;

const blade = Mesh.MergeMeshes([p1, p2], true, true, undefined, false, true)!;
blade.name = "grassBlade";
blade.isPickable = false;
blade.parent = ground; // 地面のローカル座標で配置しやすくする

thin instances 用の行列バッファを作る

thin instances は、各インスタンスの変換行列(Matrix)だけを大量に渡す方式です。 なので、Float32Array に 16 要素 × 本数 を詰めます。

const COUNT = grassNum; // 例: 1_000_000
const matrices = new Float32Array(16 * COUNT);

for (let i = 0; i < COUNT; i++) {
  const x = (Math.random() - 0.5) * areaSize;
  const z = (Math.random() - 0.5) * areaSize;

  // サイズにバラつき
  const s = 0.4 + Math.random() * 0.9;
  const scale = new Vector3(s, s * (1.0 + Math.random() * 1.8), s);

  // Y回転ランダム
  const rot = Quaternion.FromEulerAngles(0, Math.random() * Math.PI * 2, 0);

  // 地面に親子付けしているので、ローカル座標のままでOK
  const m = Matrix.Compose(scale, rot, new Vector3(x, 0, z));
  m.copyToArray(matrices, i * 16);
}

blade.thinInstanceSetBuffer("matrix", matrices, 16);

ここでのポイント

thin instances はかなり軽いですが、制約もあります。

  • 1本ごとに Mesh オブジェクトは作られない
  • 個別に当たり判定・個別制御はしにくい
  • でも「大量描画」には非常に強い

今回の草みたいに、見た目だけ大量に置きたいケースに最適です。


最低限の見た目(マテリアル)

この段階では、まずは StandardMaterial でもOKです。 (次の章で草シェーダに置き換える前提)

const grassMat = new StandardMaterial("grassMat", scene);
grassMat.backFaceCulling = false;
grassMat.specularColor = new Color3(0, 0, 0);

// 透過付き DynamicTexture(前章までで作っている草テクスチャ)を使う場合
grassMat.diffuseTexture = grassTexture;
grassMat.opacityTexture = grassTexture;
grassMat.useAlphaFromDiffuseTexture = true;

// VRでも破綻しにくい alpha test
grassMat.transparencyMode = 2; // MATERIAL_ALPHATEST
grassMat.alphaCutOff = 0.35;

blade.material = grassMat;

配置を少し自然にするコツ

今のままだと「全面均一」に生えるので、少しだけ密度を偏らせると自然になります。 たとえば fbm2D を使って、ノイズが低い場所はスキップします。

const n = fbm2D(x * 0.03, z * 0.03, 4); // -1..1
if (n < -0.2) {
  // この場所には草を置かない
  const off = Matrix.Translation(99999, -99999, 99999);
  off.copyToArray(matrices, i * 16);
  continue;
}

これで「草が薄い場所 / 土が見える場所」が作れます。


注意点(本数の目安)

grassNum = 1_000_000 でも通る環境はありますが、端末差がかなり出ます。 最初はこのくらいから始めると調整しやすいです。

  • PC確認用: 100_000300_000
  • しっかり最適化後: 500_000+
  • Quest系: シェーダや解像度次第で要調整

thin instances 自体は軽いですが、ピクセル描画量(透明草) が重くなるので、VRでは特に効きます。


この章のまとめ

ここまでで、

  • 草1本の元メッシュを作る
  • thin instances で大量配置する
  • ランダム回転・ランダムスケールで単調さを消す

までできました。

7. 草シェーダで風揺れを付ける

thin instances で大量配置できても、止まったままだとかなり「板」感が出ます。 ここで 頂点シェーダで先端だけ揺らす と、一気にそれっぽくなります。

ポイントはこの3つです。

  • 根元は動かさない(uv.y を使う)
  • ワールド座標ベースで位相をずらす(全部同じ動きにしない)
  • thin instances の world 行列を使う

シェーダの考え方

草1本の板ポリ頂点に対して、uv.y を「根元→先端」の重みとして使います。

  • uv.y = 0(根元)→ ほぼ動かない
  • uv.y = 1(先端)→ よく揺れる

さらに、time と位置から作った sin 波で x/z を少しずらします。


頂点シェーダ(風で頂点を動かす)

Effect.ShadersStore["windGrassVertexShader"] = `
precision highp float;

attribute vec3 position;
attribute vec2 uv;
attribute vec3 normal;

#ifdef INSTANCES
attribute vec4 world0;
attribute vec4 world1;
attribute vec4 world2;
attribute vec4 world3;
#endif

uniform mat4 viewProjection;
uniform float time;
uniform float windStrength;
uniform vec2 windDir;

varying vec2 vUV;
varying vec3 vWNormal;

mat4 getWorld() {
#ifdef INSTANCES
  return mat4(world0, world1, world2, world3);
#else
  return mat4(1.0);
#endif
}

void main() {
  vUV = uv;

  // 根元0, 先端1
  float tip = clamp(uv.y, 0.0, 1.0);

  mat4 world = getWorld();

  // まず元のワールド座標(位相計算用)
  vec3 wpos0 = (world * vec4(position, 1.0)).xyz;

  // 場所ごとに揺れ方を少し変える
  float phase = wpos0.x * 0.17 + wpos0.z * 0.13;

  // 2つの波を混ぜると単調さが減る
  float w = sin(time * 2.2 + phase) * 0.8
          + sin(time * 1.1 + phase * 1.7) * 0.2;

  // ローカル頂点を風方向にずらす(先端ほど強く)
  vec3 p = position;
  p.x += windDir.x * w * windStrength * tip;
  p.z += windDir.y * w * windStrength * tip;

  vec4 wpos = world * vec4(p, 1.0);

  // 草なのでざっくり法線変換でも十分
  vWNormal = normalize(mat3(world) * normal);

  gl_Position = viewProjection * wpos;
}
`;

フラグメントシェーダ(色・ライティング・アルファテスト)

透明草は alpha blend より alpha test の方がVRで安定しやすいです。 (ソート破綻が出にくい)

Effect.ShadersStore["windGrassFragmentShader"] = `
precision highp float;

varying vec2 vUV;
varying vec3 vWNormal;

uniform sampler2D diffuseSampler;
uniform float alphaCutoff;

uniform vec3 sunDir;
uniform vec3 sunColor;
uniform vec3 ambientColor;

uniform vec3 baseTint;
uniform vec3 tipTint;

uniform float useGamma;

void main() {
  vec4 c = texture2D(diffuseSampler, vUV);
  if (c.a < alphaCutoff) discard;

  // 必要ならγ→linear
  if (useGamma > 0.5) {
    c.rgb = pow(c.rgb, vec3(2.2));
  }

  // 根元と先端で少し色を変える
  float tip = clamp(vUV.y, 0.0, 1.0);
  vec3 tint = mix(baseTint, tipTint, tip);
  vec3 albedo = c.rgb * tint;

  // 簡易 Lambert
  vec3 N = normalize(vWNormal);
  float ndl = max(dot(N, normalize(-sunDir)), 0.0);
  vec3 lit = albedo * (ambientColor + sunColor * ndl);

  // 少し落として馴染ませる
  lit *= 0.5;

  // linear→γ
  if (useGamma > 0.5) {
    lit = pow(max(lit, 0.0), vec3(1.0 / 2.2));
  }

  gl_FragColor = vec4(lit, 1.0);
}
`;

ShaderMaterial を作る

thin instances を使うので、world0~world3 を attributes に入れるのが重要です。 これがないと全インスタンス同じ場所/同じ揺れになったり壊れます。

const mat = new ShaderMaterial(
  "windGrassMat",
  scene,
  { vertex: "windGrass", fragment: "windGrass" },
  {
    attributes: [
      VertexBuffer.PositionKind,
      VertexBuffer.UVKind,
      VertexBuffer.NormalKind,
      "world0",
      "world1",
      "world2",
      "world3",
    ],
    uniforms: [
      "viewProjection",
      "time",
      "windStrength",
      "windDir",
      "alphaCutoff",
      "sunDir",
      "sunColor",
      "ambientColor",
      "baseTint",
      "tipTint",
      "useGamma",
    ],
    samplers: ["diffuseSampler"],
    needAlphaBlending: false,
    needAlphaTesting: true,
  }
);

mat.setTexture("diffuseSampler", grassTexture); // DynamicTextureでもOK
mat.setFloat("alphaCutoff", 0.35);
mat.backFaceCulling = false;

// 風
mat.setVector2("windDir", new Vector2(0.8, 0.2));
mat.setFloat("windStrength", 0.25);

// 色味
mat.setVector3("sunDir", dirLight.direction);
mat.setVector3("sunColor", new Vector3(1.0, 1.0, 1.0));
mat.setVector3("ambientColor", new Vector3(0.05, 0.35, 0.35));
mat.setVector3("baseTint", new Vector3(0.35, 0.55, 0.35));
mat.setVector3("tipTint", new Vector3(0.55, 0.9, 0.55));

mat.setFloat("useGamma", 0.5);

blade.material = mat;

毎フレーム time を更新する

風揺れは time を進めないと動きません。

scene.onBeforeRenderObservable.add(() => {
  const t = performance.now() * 0.001;
  (blade.material as any)?.setFloat?.("time", t);
});

調整ポイント

見た目はこのへんを触ると変わりやすいです。

  • windStrength 揺れの大きさ。0.15 ~ 0.35 くらいから調整
  • windDir 風向き(XZ)
  • alphaCutoff 草の密度感。上げると細く、下げるとモサくなる
  • baseTint / tipTint 緑の「汚さ」調整にかなり効く
  • lit *= 0.5 明るすぎる時のなじませ用

注意点(VR向け)

草は「透明」「大量」「全面表示」の3つが重い要因です。 VRで重い場合は、まずこの順で削ると効きます。

  1. grassNum を減らす
  2. alphaCutoff を少し上げる(描画面積削減)
  3. 地面サイズを狭める
  4. 草の高さ/幅を小さくする(重なり減る)

この章のまとめ

ここまでで、草に

  • 先端だけ揺れる変形
  • 位置ごとに違う位相
  • alpha test の安定描画

を入れられました。

8. WebXRで地形上を移動する

地形と草原ができたら、最後は Quest / WebXR で実際に歩ける ようにします。 ここでは Babylon.js の標準テレポートではなく、スティック移動 + 地面スナップ で実装します。

この方式の良いところは、今回みたいな 起伏のある地形 と相性がいいことです。


今回の方針

  • 左スティック: 旋回
  • 右スティック: 前後左右に移動
  • 毎フレーム Ray を飛ばす: 地面の高さを拾って、Y座標を合わせる

createDefaultXRExperienceAsync() のテレポートは便利ですが、今回は地形上を自然に動きたいので disableTeleportation: true にしています。


vr.ts を用意する

./script/vr.ts に切り出しておくと、main.ts がかなり見やすくなります。

import { Engine, Scene, Vector3, Mesh, Ray } from "@babylonjs/core";
import { RAY_FIXED_Y, MOVE_SPEED, ROT_SPEED, HEIGHT_OFFSET } from "../config";

export async function vr(scene: Scene, ground: Mesh, engine: Engine) {
  const xr = await scene.createDefaultXRExperienceAsync({
    floorMeshes: [ground],
    disableTeleportation: true,
  });

  // XRカメラを直接動かさず、親となる rig を動かす
  const rig = new Mesh("playerRig", scene);
  xr.baseExperience.camera.parent = rig;

  let leftController: any = null;
  let rightController: any = null;

  xr.input.onControllerAddedObservable.add((controller) => {
    if (controller.inputSource.handedness === "left") leftController = controller;
    if (controller.inputSource.handedness === "right") rightController = controller;
  });

  const getStickValues = (controller: any) => {
    const comp =
      controller?.motionController?.getComponentOfType("thumbstick") ||
      controller?.motionController?.getComponentOfType("touchpad");

    return comp ? { x: comp.axes.x, y: comp.axes.y } : { x: 0, y: 0 };
  };

  scene.onBeforeRenderObservable.add(() => {
    if (xr.baseExperience.state !== 2 /* IN_XR */) return;

    const dt = engine.getDeltaTime() / 1000;
    const cam = xr.baseExperience.camera;

    // 左スティック: 回転
    const leftStick = getStickValues(leftController);
    if (Math.abs(leftStick.x) > 0.1) {
      rig.rotation.y += leftStick.x * ROT_SPEED * dt;
    }

    // 右スティック: 移動
    const rightStick = getStickValues(rightController);
    if (Math.abs(rightStick.x) > 0.1 || Math.abs(rightStick.y) > 0.1) {
      const forward = cam.getDirection(Vector3.Forward());
      forward.y = 0;
      forward.normalize();

      const right = cam.getDirection(Vector3.Right());
      right.y = 0;
      right.normalize();

      const moveVec = forward.scale(-rightStick.y).add(right.scale(rightStick.x));
      rig.position.addInPlace(moveVec.scale(MOVE_SPEED * dt));
    }

    // 地面スナップ(起伏追従)
    rig.computeWorldMatrix(true);

    const origin = new Vector3(rig.position.x, RAY_FIXED_Y, rig.position.z);
    const ray = new Ray(origin, Vector3.Down(), RAY_FIXED_Y + 10);
    const hit = scene.pickWithRay(ray, (m) => m === ground);

    if (hit?.hit && hit.pickedPoint) {
      rig.position.y = hit.pickedPoint.y + HEIGHT_OFFSET;
    }
  });
}

main.ts から呼び出す

ground を作ったあとに await vr(...) するだけです。

import { vr } from "./script/vr";

// ...

await vr(scene, ground, engine);

設定値は config.ts に寄せる

あとでQuest実機で調整しやすいように、移動系の値は config.ts にまとめておくのがおすすめです。

export const MOVE_SPEED = 30.0;
export const ROT_SPEED = 2.5;
export const RAY_FIXED_Y = 20;
export const HEIGHT_OFFSET = 12.0;

各値の意味

  • MOVE_SPEED 移動速度。大きすぎるとVR酔いしやすいです。まずは 10〜30 くらいで調整。

  • ROT_SPEED 旋回速度。これも速すぎると酔いやすいので注意。

  • RAY_FIXED_Y 地面高さ取得用のレイ開始Y。 地形の最大高さより十分上にしておくと安定します。

  • HEIGHT_OFFSET 視点高さの補正。 地面にめり込む/高すぎる場合はここを調整。


実装のポイント

1) XRカメラを直接動かさない

xr.baseExperience.camera を直接いじるより、親の rig を動かす 方が扱いやすいです。 HMDの実位置トラッキングとも干渉しにくくなります。

2) 移動方向は「カメラ基準」

cam.getDirection(Vector3.Forward()) を使うことで、 向いている方向に対して前進 できます(VRではこれが自然)。

3) Y方向はRayで吸着

今回は地形をノイズで変形しているので、固定Y移動だと浮いたり埋まったりします。 毎フレーム pickWithRay で地面を拾うのが手堅いです。


よくあるハマりどころ

地面を拾えない

ground.isPickable = true を忘れると pickWithRay が当たりません。

ground.isPickable = true;

空や別メッシュを拾う

scene.pickWithRay(ray) だけだと他のメッシュに当たることがあります。 今回は m === ground のフィルタを入れているので安全です。

視点が高すぎる / 低すぎる

HEIGHT_OFFSET を調整します。 モデルスケールや地形の高さ振れ幅でかなり変わります。


この章のまとめ

ここまでで、#06 でやりたかった内容は一通り揃いました。

  • fbm2D で地形生成
  • 地面メッシュ変形(法線再計算)
  • 沈み込み防止
  • DynamicTexture で地面/草テクスチャ
  • thin instances で草大量配置
  • 草シェーダで風揺れ
  • WebXRで地形上を移動