はじめに
以前、スロープの傾斜を上り下りできる実装をしましたが、それにより高低差のある地形の移動が可能になったようなので、 そろそろエリア拡大する実装をしてみたので、その実装メモです。
[JavaScript] VRで段差とスロープが壊れる理由と、正しく動かす方法
Three.js + WebXRで、段差やスロープを自然に上がれる移動処理を実装する。VRではロジックを変えず、カメラを制御しない設計が重要になる。
https://humanxai.info/posts/javascript-vr-step-slope-player-rig/どういう風に作るのか分からなかったのですが、各ジオメトリの頂点は自由に座標変更が出来るようで、座標を書き換える事で高低差のある地形を作る事が出来るようです。
こんな風に小さな山を複数作ったり。

大きく盛り上げて、エアーズロックのような地形も。

山のように持ち上げて、中央を落としたりも。

慣れるとこれらを組み合わせて、高低差のあるエリアを短時間で作る事が出来るように。
というか、生成方法が分かるとランダムに地形を生成する事も可能で、非常に面白くて空き時間に遊んでました。
UNITYだと、この辺りはUIで地形を作れるようですが、Three.jsの場合は、コードを書いて地形を生成する事になります。

VR動画
今日やったこと
- PlaneGeometry を“地形”として扱う発想
- 高さ関数で平地・山・クレーターを作る
- 地形を JSON / grid に分離した設計判断
1. PlaneGeometry は「板」じゃなく「地形」になる
Three.js で地形を作る、というと ・外部ツールで作ったモデルを読み込む ・高さマップ用の画像を用意する こういう手順を思い浮かべがちだけど、実際はもっと単純だった。
PlaneGeometry は、ただの「板」ではない。 頂点を持ったメッシュであり、その頂点は自由に書き換えられる。
まず、PlaneGeometry を地面向きにする。
geo.rotateX(-Math.PI / 2);
これだけで、XY 平面の板が XZ 平面の「地面」になる。 ここまではよくある話。
本当に世界が変わったのは次の一行だった。
pos.setY(i, h);
PlaneGeometry の position 属性には、すべての頂点座標が入っている。
その Y 値を 1点ずつ書き換えるだけ で、地面が凹凸を持ち始める。
つまりやっていることは、
- ポリゴンを増やしているわけでもない
- 特別な地形クラスを使っているわけでもない
- 画像を貼って誤魔化しているわけでもない
ただ 「この頂点の高さはどれくらいか」 を決めているだけ。
それだけで、
- 平地になる
- 山になる
- クレーターになる
という地形が生まれる。
しかも PlaneGeometry は構造が単純で、GPU 的にも非常に軽い。 モデルを大量に配置するより、 1枚の平面の頂点を計算する方が圧倒的に負荷が小さい。
この2行は、単なる初期化コードではなく、
PlaneGeometry を「描画用の板」から 「歩ける地形」に変えるスイッチ
だった。
ここを境に、 「3Dオブジェクトを置いている感覚」から 「世界を定義している感覚」に変わった。
2. 高さは「関数」で決めるだけ
地形を作ると聞くと、 「高さマップ用の画像が必要」「ノイズライブラリが必要」 という印象を持ちがちだけど、実際はそうでもない。
PlaneGeometry の頂点は、 高さを返す関数に座標を渡すだけで決まる。
const h = getHeight(x, z);
pos.setY(i, h);
この getHeight が、地形そのものだ。
sinやcosだけでも起伏は作れる- 距離関数を使えば山になる
- 引き算すればクレーターになる
ノイズは「表情を足すもの」であって、必須ではない。
継ぎ目問題と world 座標
最初に地形をタイル状に並べたとき、 必ずぶつかったのが 継ぎ目問題 だった。
同じ PlaneGeometry を並べているのに、 タイルの境界で高さがズレる。
原因は単純で、 高さ計算に ローカル座標 を使っていたから。
const x = pos.getX(i);
const z = pos.getZ(i);
これだけだと、
隣のタイルでも (x, z) はまた -15〜15 に戻ってしまう。
そこで座標を「世界基準」に変える。
const worldX = x + position[0];
const worldZ = z + position[2];
この瞬間、
- 隣のタイルでも同じ位置は同じ高さになる
- 継ぎ目が完全に消える
- タイル数を増やしても破綻しない
という状態になった。
「つなぎ目が出た → world 座標にしたら消えた」
この体験はかなり刺さった。
特別な処理を入れたわけでも、 継ぎ目専用の補正をしたわけでもない。
ただ 高さを計算する座標の基準を変えただけ。
それだけで、
- マップが連続した「世界」に変わる
- 無限に広げられる感覚が出てくる
地形生成は難しいアルゴリズムの話ではなく、 「どの座標系で考えているか」の問題なのだと ここでようやく腑に落ちた。
3. 山は“足す”、クレーターは“引く”
山やクレーターを作る、というと 別々の特殊な処理が必要に思える。
実際はまったく違っていて、 どちらも同じ距離関数だった。
const dx = x - mx;
const dz = z - mz;
const r = Math.sqrt(dx * dx + dz * dz);
中心からの距離 r を使って、
その地点にどれだけ高さを与えるかを決める。
山の正体
山は単純に、 「中心に近いほど高さを足す」だけ。
h += mountain(worldX, worldZ, mx, mz, radius, height);
- 影響範囲外なら 0
- 中心に近づくほど高さが増える
これだけで、ちゃんと「登れる山」になる。
クレーターの正体
クレーターも同じ式だ。 違うのは 符号だけ。
h += crater(worldX, worldZ, mx, mz, radius, depth);
山が「足し算」なら、 クレーターは「引き算」。
中心に向かって高さを下げるだけで、 自然な窪地が生まれる。
火口になったり、蟻地獄になったり
途中で、
- 山の頂上が凹んで火口になった
- クレーターが滑り落ちる蟻地獄になった
という現象が起きた。
これはバグではなく、 数式どおりの結果だった。
中心付近だけ高さを固定したり、 減衰カーブを急にすると、
- 中央が削れる
- 勾配が極端になる
という形になる。
平らな底が欲しいときの罠
クレーターの底を平らにしようとして、 数式だけを直しても上手くいかなかった。
原因は 頂点密度。
PlaneGeometry の分割数が少ないと、
- 平らにしたい領域に頂点が存在しない
- 三角形補間で勝手に傾く
ということが起きる。
式が間違っているのではなく、 表現するための解像度が足りていなかった。
合成するだけで地形になる
結局やっていることは、とても単純だった。
h =
baseHeight
+ mountain(...)
+ crater(...);
- 山も
- クレーターも
- 起伏も
全部「高さへの寄与」を足し引きしているだけ。
この考え方に気づいた瞬間、 地形生成は「特殊処理の集合」ではなく、 高さ関数の合成問題に見えるようになった。
失敗して火口や蟻地獄ができた話も、 いま思えば 「数式がそのまま形になった」 一番分かりやすい瞬間だった。
4. 地形を歩けるようにしたら、世界になった
ここまでで、地形そのものは作れていた。 でも正直に言うと、その段階ではまだ「景色」だった。
本当に変わったのは、 地形を歩けるようにした瞬間。
必要だったのは、この3つだけ
- 重力
- 足元に向けたレイ
- 下に張り付く処理
派手な物理エンジンは使っていない。 やっていることは驚くほど地味だ。
重力と足元レイ
キャラクターの足元から、 常に下方向へレイを飛ばす。
raycaster.set(
new THREE.Vector3(char.x, char.y + offset, char.z),
new THREE.Vector3(0, -1, 0)
);
一番近い地形との交点を拾い、 そこを「今立っている床」とみなす。
下に張り付く処理
交点があれば、
- キャラクターの Y 座標を床の高さに合わせる
交点がなければ、
- 重力として下に落とす
これだけ。
if (hit) {
char.y = hit.point.y;
} else {
char.y -= gravity;
}
これがあるか、ないか
この処理が無い状態では、
- 斜面を歩いても高さが変わらない
- 山の上に立っている実感がない
- クレーターに「降りる」感覚がない
つまり、地形が 意味を持たない。
下に張り付く処理を入れた瞬間、
- 斜面を登ると視界が変わる
- 高所に立つと距離感が生まれる
- 窪地に降りると囲まれた感じが出る
地形が、ただの形状から 移動の体験を持った空間に変わった。
見た目から体験へ
ポリゴン数を増やしたわけでも、 テクスチャを豪華にしたわけでもない。
- 重力
- 接地
- 移動
この3点が揃っただけで、 「見ている世界」から 「自分が立っている世界」に変わった。
ここを境に、 PlaneGeometry で作った地形は 背景ではなく、世界そのものになった。
5. マップ配置はコードじゃなく JSON
地形が増えてきた頃、 一番最初に限界を感じたのがここだった。
createTerrain([30, 0, 0]);
createTerrain([30, 0, 30]);
createTerrain([-30, 0, -30]);
最初は問題ない。 でも数が増えると、
- 今どこまで作ったか分からない
- 広げるたびに頭の中で座標変換が必要
- 抜けや重複が怖い
これはもう ロジックじゃなく、データ だな、と。
tileSize を基準に「論理座標」で考える
そこで一度、考え方を切り替えた。
- 実際のワールド座標は three.js 側の都合
- 人間が考えたいのは「何マス先か」
ということで、
- 1マス = 1
- 実寸への変換は
tileSizeに任せる
設計にした。
worldX = gridX * tileSize;
worldZ = gridZ * tileSize;
これで 「右に2マス」「上に1マス」 という感覚のままマップを考えられる。
grid にしたら一気に分かりやすくなった
最終的に行き着いたのが、これ。
"grid": [
["g","g","g","g","g"],
["g","g","g","g","g"],
["g","g","g","g","g"]
]
- 一目で形が分かる
- どこが空いているか即分かる
- 広げるのも一行足すだけ
正直な感想はこれだった。
最初からこれにしとけ感
grid は「地面があるか」だけを持たせる
重要なのは、 grid に 情報を詰め込みすぎないこと。
grid の役割は、
- そのマスに地面があるか
それだけ。
建物や特殊ルール、イベントは 別レイヤー に分ける。
そうしないと JSON が壊れる。
コードが短くなったのは結果
JSON にした結果、
- JS 側はただ展開するだけ
- ロジックとデータが分離された
- マップを「設計」できるようになった
createTerrain を並べていた頃より、 コードは短くなった。
でも本質はそこじゃない。
世界の構造を、コードではなくデータで扱えるようになった。
この切り替えは、 地形を増やすためというより、 これ以上混乱しないために必要だった。
💬 コメント