1. 今回やること
前回(#04)では、HTTPS対応からQuest 2でのWebXR没入までを確認しました。 今回はその続きとして、「歩ける草原フィールド」 を Babylon.js で作っていきます。
やることは大きく6つです。
- fbm2D を使って、ノイズベースの地形を作る
- 地面メッシュを変形したあとに、法線を再計算して見た目を整える
- 地形がマイナス方向に沈みすぎないように、沈み込み防止を入れる
- thin instances で草を大量配置して、軽い草原表現を作る
- 草にシェーダを当てて、風ゆれを入れる
- WebXR で、地形の起伏に合わせて移動できるようにする
今回のゴールは、MMDキャラを置く前段階としての 地形・草・移動基盤 を固めることです。 つまり、次回以降の「キャラ配置」「ランダム移動」「地面吸着」を気持ちよく載せられる状態まで持っていきます。
前回の記事:
[Babylon.js #04] HTTPS設定からQuest2でのWebXR没入体験まで
Babylon.jsでWebXRを動かすにはHTTPS(Secure Context)が必須。本記事ではViteでのSSL設定、mkcertによるローカル証明書生成、Babylon.jsのcreateDefaultXRExperienceAsyncによるVR初 …
https://humanxai.info/posts/babylonjs-04-https-to-webxr-quest2-immersive/動画(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〜6lacunarity = 2.0gain = 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;
これだと起伏はそのままに、地面全体の最低位置を上げられます。
実用版(両方対応できる形)
あとで調整しやすいように、clampToZero と yOffset を分けておくと便利です。
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() で ピクセルを直接書き換えできます。
今回の流れは以下です。
- テクスチャ上の
(x, y)を作る - それを地形と同じスケール感の座標
(wx, wz)に変換 fbm2D()でノイズ値を取る- ノイズ値で「草色」と「土色」をブレンド
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_000~300_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で重い場合は、まずこの順で削ると効きます。
grassNumを減らすalphaCutoffを少し上げる(描画面積削減)- 地面サイズを狭める
- 草の高さ/幅を小さくする(重なり減る)
この章のまとめ
ここまでで、草に
- 先端だけ揺れる変形
- 位置ごとに違う位相
- 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で地形上を移動
💬 コメント