はじめに
前回の記事では、Tauri + Vue + TypeScript + Three.js を使って、地形ビューアにホイールズームを追加しました。
前回の記事はこちらです。
前回までで、地形ビューアには以下の機能が入りました。
- プログラム内生成メッシュ
- 高さに応じた頂点カラー
- 地形表面に沿ったグリッド線
- ドラッグ・スワイプによる回転
- ホイールによるズーム
今回は、地形の形状をもう少し自然にします。
これまでの地形は sin や cos を使った波形だったため、全体的になめらかでした。
今回は簡易的なノイズを追加し、細かい凹凸のある、ざらざらした地形 にしていきます。
今回やること
| 内容 | 説明 |
|---|---|
| Value Noiseの追加 | 座標から疑似的なノイズ値を作る |
| fBmの追加 | 複数のノイズを重ねて自然な起伏にする |
| 細かい凹凸の追加 | 表面のざらざら感を作る |
flatShading の追加 |
面ごとの陰影でポリゴン感を出す |
| 高さ範囲の調整 | 凸凹が大きくなりすぎないようにする |
今回は terrainMesh.ts を中心に変更します。
TerrainViewer.vue は、地形の高さ範囲とUI文言を少し変更します。
Step8: 簡易ノイズで地形をざらざらにする
terrainMesh.tsを更新する
smoothstep と lerp を追加する
まず、ノイズ値をなめらかにつなぐための補助関数を追加します。
現在、terrainMesh.ts には以下の関数があります。
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function clamp01(value: number): number {
return clamp(value, 0, 1);
}
この下に、以下を追加してください。
function smoothstep(t: number): number {
return t * t * (3 - 2 * t);
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
lerp は linear interpolation の略で、線形補間という意味です。
2つの値の間を、指定した割合でなめらかにつなぐために使います。
smoothstep は、補間に使う値をさらに自然に変化させるための関数です。
座標から疑似乱数を作る hash2D を追加する
次に、2D座標から疑似的な乱数を作る関数を追加します。
先ほど追加した lerp の下に、以下を追加してください。
/**
* 整数座標から 0.0 〜 1.0 の疑似乱数を作る関数です。
* 外部ライブラリなしで、毎回同じ地形を再生成できます。
*/
function hash2D(x: number, z: number): number {
const value = Math.sin(x * 127.1 + z * 311.7) * 43758.5453123;
return value - Math.floor(value);
}
この関数は、本格的な乱数生成器ではありません。
ただし、今回のように「座標に応じてそれっぽいノイズ値を作る」用途では十分使えます。
ポイントは、同じ x, z を渡すと毎回同じ値が返ることです。
そのため、アプリを再起動しても地形の形が変わりません。
valueNoise2D を追加する
次に、hash2D で作った値をなめらかにつなぐ valueNoise2D を追加します。
hash2D の下に、以下を追加してください。
/**
* なめらかな2D Value Noiseです。
*/
function valueNoise2D(x: number, z: number): number {
const x0 = Math.floor(x);
const z0 = Math.floor(z);
const x1 = x0 + 1;
const z1 = z0 + 1;
const tx = smoothstep(x - x0);
const tz = smoothstep(z - z0);
const n00 = hash2D(x0, z0);
const n10 = hash2D(x1, z0);
const n01 = hash2D(x0, z1);
const n11 = hash2D(x1, z1);
const nx0 = lerp(n00, n10, tx);
const nx1 = lerp(n01, n11, tx);
return lerp(nx0, nx1, tz) * 2 - 1;
}
valueNoise2D は、格子点ごとの疑似乱数をなめらかにつないで、-1 〜 1 のノイズ値を返します。
この時点では、まだ単体のノイズです。
次に、このノイズを複数重ねて地形らしさを出します。
fbm を追加する
次に、複数のノイズを重ねる fbm を追加します。
valueNoise2D の下に、以下を追加してください。
/**
* fBm: Fractal Brownian Motion
* 複数のノイズを重ねて、自然な凸凹感を作ります。
*/
function fbm(x: number, z: number): number {
let total = 0;
let amplitude = 1;
let frequency = 0.08;
let maxValue = 0;
for (let octave = 0; octave < 5; octave++) {
total += valueNoise2D(x * frequency, z * frequency) * amplitude;
maxValue += amplitude;
amplitude *= 0.5;
frequency *= 2.0;
}
return total / maxValue;
}
fBm は、ざっくり言うと 大きなうねりと細かい揺らぎを重ねる方法 です。
| 値 | 役割 |
|---|---|
frequency |
ノイズの細かさ |
amplitude |
ノイズの強さ |
octave |
ノイズを重ねる回数 |
今回のコードでは、5段階のノイズを重ねています。
これにより、単純な波形より自然な凹凸になります。
generateHeight を書き直す
次に、地形の高さを作る generateHeight を変更します。
現在は以下のようになっています。
function generateHeight(
x: number,
z: number,
minHeight: number,
maxHeight: number,
): number {
const wave1 = Math.sin(x * 0.25) * 0.55;
const wave2 = Math.cos(z * 0.22) * 0.45;
const wave3 = Math.sin((x + z) * 0.16) * 0.35;
const height = wave1 + wave2 + wave3;
return clamp(height, minHeight, maxHeight);
}
これを以下のように書き直してください。
function generateHeight(
x: number,
z: number,
minHeight: number,
maxHeight: number,
): number {
const broadWave =
Math.sin(x * 0.22) * 0.18 +
Math.cos(z * 0.2) * 0.16 +
Math.sin((x + z) * 0.12) * 0.14;
const hillNoise = fbm(x, z) * 1.05;
const fineRoughness =
valueNoise2D(x * 1.8, z * 1.8) * 0.08 +
valueNoise2D(x * 3.5, z * 3.5) * 0.035;
const height = broadWave + hillNoise + fineRoughness;
return clamp(height, minHeight, maxHeight);
}
ここでは、高さを3つの要素で作っています。
| 変数 | 内容 |
|---|---|
broadWave |
全体的なゆるい波 |
hillNoise |
大きめの自然な凹凸 |
fineRoughness |
表面の細かいざらざら感 |
前回までは sin と cos による規則的な波でした。
今回はノイズを使うことで、不規則な地形に近づけています。
MeshStandardMaterial に flatShading を追加する
次に、表面の質感を少しざらっと見せるため、マテリアルに flatShading を追加します。
現在は以下のようになっています。
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 0.85,
metalness: 0.0,
side: THREE.DoubleSide,
});
これを以下のように書き直してください。
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 1.0,
metalness: 0.0,
side: THREE.DoubleSide,
flatShading: true,
});
flatShading: true にすると、面ごとの陰影が出やすくなります。
なめらかな地形というより、少しポリゴン感のある見た目になります。
ただし、今回は頂点数が多いため、極端にカクカクしすぎるわけではありません。
細かい凹凸と組み合わせることで、表面のざらざら感が少し出ます。
更新後の terrainMesh.ts
ここまでの変更を反映すると、terrainMesh.ts 全体は以下のようになります。
terrainMesh.ts
import * as THREE from "three";
export type TerrainMeshOptions = {
size: number;
cellSize: number;
minHeight: number;
maxHeight: number;
};
export type TerrainGridOptions = TerrainMeshOptions & {
yOffset: number;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function clamp01(value: number): number {
return clamp(value, 0, 1);
}
function smoothstep(t: number): number {
return t * t * (3 - 2 * t);
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/**
* 整数座標から 0.0 〜 1.0 の疑似乱数を作る関数です。
* 外部ライブラリなしで、毎回同じ地形を再生成できます。
*/
function hash2D(x: number, z: number): number {
const value = Math.sin(x * 127.1 + z * 311.7) * 43758.5453123;
return value - Math.floor(value);
}
/**
* なめらかな2D Value Noiseです。
*/
function valueNoise2D(x: number, z: number): number {
const x0 = Math.floor(x);
const z0 = Math.floor(z);
const x1 = x0 + 1;
const z1 = z0 + 1;
const tx = smoothstep(x - x0);
const tz = smoothstep(z - z0);
const n00 = hash2D(x0, z0);
const n10 = hash2D(x1, z0);
const n01 = hash2D(x0, z1);
const n11 = hash2D(x1, z1);
const nx0 = lerp(n00, n10, tx);
const nx1 = lerp(n01, n11, tx);
return lerp(nx0, nx1, tz) * 2 - 1;
}
/**
* fBm: Fractal Brownian Motion
* 複数のノイズを重ねて、自然な凸凹感を作ります。
*/
function fbm(x: number, z: number): number {
let total = 0;
let amplitude = 1;
let frequency = 0.08;
let maxValue = 0;
for (let octave = 0; octave < 5; octave++) {
total += valueNoise2D(x * frequency, z * frequency) * amplitude;
maxValue += amplitude;
amplitude *= 0.5;
frequency *= 2.0;
}
return total / maxValue;
}
function heightToColor(
height: number,
minHeight: number,
maxHeight: number,
): THREE.Color {
const t = clamp01((height - minHeight) / (maxHeight - minHeight));
const red = new THREE.Color(1.0, 0.22, 0.12);
const yellow = new THREE.Color(1.0, 0.86, 0.12);
const green = new THREE.Color(0.24, 0.88, 0.14);
if (t < 0.5) {
return new THREE.Color().lerpColors(red, yellow, t * 2);
}
return new THREE.Color().lerpColors(yellow, green, (t - 0.5) * 2);
}
function generateHeight(
x: number,
z: number,
minHeight: number,
maxHeight: number,
): number {
const broadWave =
Math.sin(x * 0.22) * 0.18 +
Math.cos(z * 0.2) * 0.16 +
Math.sin((x + z) * 0.12) * 0.14;
const hillNoise = fbm(x, z) * 1.05;
const fineRoughness =
valueNoise2D(x * 1.8, z * 1.8) * 0.08 +
valueNoise2D(x * 3.5, z * 3.5) * 0.035;
const height = broadWave + hillNoise + fineRoughness;
return clamp(height, minHeight, maxHeight);
}
export function createTerrainMesh(options: TerrainMeshOptions): THREE.Mesh {
const { size, cellSize, minHeight, maxHeight } = options;
// 50m / 0.5m の場合、100セルになる
const cells = Math.floor(size / cellSize);
// 頂点数はセル数 + 1
// 100セルなら、1辺あたり101頂点になる
const vertexCountPerSide = cells + 1;
const halfSize = size / 2;
const positions: number[] = [];
const colors: number[] = [];
const indices: number[] = [];
// 頂点座標を作成する
// x, z の位置に応じて y を変化させることで地形らしい凹凸を作る
for (let zIndex = 0; zIndex < vertexCountPerSide; zIndex++) {
for (let xIndex = 0; xIndex < vertexCountPerSide; xIndex++) {
const x = xIndex * cellSize - halfSize;
const z = zIndex * cellSize - halfSize;
const y = generateHeight(x, z, minHeight, maxHeight);
positions.push(x, y, z);
// 高さ y に応じて頂点カラーを決める
const color = heightToColor(y, minHeight, maxHeight);
colors.push(color.r, color.g, color.b);
}
}
// 4つの頂点から2つの三角形を作成する
for (let zIndex = 0; zIndex < cells; zIndex++) {
for (let xIndex = 0; xIndex < cells; xIndex++) {
const a = zIndex * vertexCountPerSide + xIndex;
const b = a + 1;
const c = a + vertexCountPerSide;
const d = c + 1;
indices.push(a, c, b);
indices.push(b, c, d);
}
}
const geometry = new THREE.BufferGeometry();
// 頂点座標をgeometryに設定する
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
// 頂点カラーをgeometryに設定する
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
// 三角形を構成する頂点番号を設定する
geometry.setIndex(indices);
// ライトの当たり方を計算するために法線を作成する
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
roughness: 1.0,
metalness: 0.0,
side: THREE.DoubleSide,
flatShading: true,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = "TerrainMesh";
return mesh;
}
export function createTerrainGrid(options: TerrainGridOptions): THREE.LineSegments {
const { size, cellSize, minHeight, maxHeight, yOffset } = options;
const cells = Math.floor(size / cellSize);
const halfSize = size / 2;
const positions: number[] = [];
// x方向に伸びる線を作成する
for (let zIndex = 0; zIndex <= cells; zIndex++) {
for (let xIndex = 0; xIndex < cells; xIndex++) {
const x0 = xIndex * cellSize - halfSize;
const x1 = (xIndex + 1) * cellSize - halfSize;
const z = zIndex * cellSize - halfSize;
const y0 = generateHeight(x0, z, minHeight, maxHeight) + yOffset;
const y1 = generateHeight(x1, z, minHeight, maxHeight) + yOffset;
positions.push(x0, y0, z);
positions.push(x1, y1, z);
}
}
// z方向に伸びる線を作成する
for (let xIndex = 0; xIndex <= cells; xIndex++) {
for (let zIndex = 0; zIndex < cells; zIndex++) {
const x = xIndex * cellSize - halfSize;
const z0 = zIndex * cellSize - halfSize;
const z1 = (zIndex + 1) * cellSize - halfSize;
const y0 = generateHeight(x, z0, minHeight, maxHeight) + yOffset;
const y1 = generateHeight(x, z1, minHeight, maxHeight) + yOffset;
positions.push(x, y0, z0);
positions.push(x, y1, z1);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
const material = new THREE.LineBasicMaterial({
color: 0x1f2a1f,
transparent: true,
opacity: 0.28,
depthWrite: false,
});
const grid = new THREE.LineSegments(geometry, material);
grid.name = "TerrainSurfaceGrid";
return grid;
}
TerrainViewer.vue を更新する
次に、地形の高さ範囲とUI文言を変更します。
地形の高さ範囲を変更する
現在は以下のようになっています。
const terrainOptions = {
size: 50,
cellSize: 0.5,
minHeight: -1.2,
maxHeight: 1.2,
};
今回はノイズで起伏を増やすため、高さ範囲を少し広げます。
以下のように書き直してください。
const terrainOptions = {
size: 50,
cellSize: 0.5,
minHeight: -1.4,
maxHeight: 1.4,
};
UIの文言を変更する
画面下部の説明も、今回の内容に合わせて変更します。
現在は以下のようになっています。
<div class="overlay-panel">
<div class="label">STEP 7</div>
<h1>Interactive Terrain Viewer</h1>
<p>ドラッグ・スワイプで回転、ホイールでズームできます。</p>
</div>
これを以下のように書き直してください。
<div class="overlay-panel">
<div class="label">STEP 8</div>
<h1>Rough Procedural Terrain</h1>
<p>簡易ノイズを使って、ざらざらした地形メッシュを生成しています。</p>
</div>
実行する
以下のコマンドで実行します。
npm run tauri dev
前回よりも不規則で細かい凸凹のある地形が表示されれば成功です。
ドラッグ回転やホイールズームも、これまで通り動作するはずです。
まとめ
今回は、簡易ノイズを使って地形メッシュをより自然な凸凹にしました。
次の記事では、ここまで作った地形ビューアのコードを整理し、最終的な構成としてまとめます。
また、必要に応じて今後の拡張案も整理していきます。
次回
記事一覧
参考
