はじめに
前回の記事では、Tauri + Vue + TypeScript + Three.js を使って、地形メッシュに頂点カラーを追加しました。
前回の記事はこちらです。
前回までで、地形の高さに応じて赤、黄、緑のグラデーションを表示できるようになりました。
今回は、地形メッシュの表面に グリッド線 を重ねて表示します。
グリッドを表示することで、地形がどのようなメッシュ構造になっているか分かりやすくなります。
今回やること
今回は以下を実装します。
| 内容 | 説明 |
|---|---|
| グリッド線の生成 | 地形表面に沿った線を作成する |
LineSegments の利用 |
Three.jsで複数の線分を描画する |
| 地形と同じ高さ計算を使う | メッシュ表面に沿うようにグリッドを配置する |
yOffset の追加 |
面と線のちらつきを防ぐ |
今回のポイントは、単純な平面グリッドではなく、地形の高さに沿ったグリッド を作ることです。
前回までの状態
前回までで、terrainMesh.ts では以下のように地形メッシュを生成していました。
const terrainMesh = createTerrainMesh({
size: 50,
cellSize: 0.5,
minHeight: -1.2,
maxHeight: 1.2,
});
今回は、この地形メッシュに加えて、同じ size、cellSize、minHeight、maxHeight を使ったグリッド線を追加します。
Step5: 地形表面にグリッド線を重ねる
terrainMesh.tsを更新する
TerrainGridOptions を追加する
まず、terrainMesh.ts にグリッド生成用の型を追加します。
現在は以下のようになっています。
export type TerrainMeshOptions = {
size: number;
cellSize: number;
minHeight: number;
maxHeight: number;
};
この下に、以下を追加してください。
export type TerrainGridOptions = TerrainMeshOptions & {
yOffset: number;
};
TerrainGridOptions は、地形メッシュ用の設定に加えて yOffset を持つ型です。
| プロパティ | 内容 |
|---|---|
size |
地形のサイズ |
cellSize |
セルの大きさ |
minHeight |
最低高さ |
maxHeight |
最高高さ |
yOffset |
グリッド線を少し上にずらす量 |
yOffset は、地形の面とグリッド線が同じ位置に重なってちらつくのを防ぐために使います。
このちらつきは z-fighting と呼ばれます。
createTerrainGrid を追加する
次に、地形表面に沿ったグリッド線を生成する関数を追加します。
createTerrainMesh() の下に、以下の関数を追加してください。
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;
}
ここでは THREE.LineSegments を使っています。
LineSegments は、2点ずつを1本の線分として描画するためのオブジェクトです。
positions.push(x0, y0, z);
positions.push(x1, y1, z);
このように2つの頂点を追加すると、そこが1本の線分になります。
更新後の 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 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 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);
}
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: 0.85,
metalness: 0.0,
side: THREE.DoubleSide,
});
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 を更新する
次に、TerrainViewer.vue 側で作成したグリッドを表示します。
import を変更する
現在は以下のようになっています。
import { createTerrainMesh } from "../lib/terrainMesh";
これを以下のように書き直してください。
import { createTerrainGrid, createTerrainMesh } from "../lib/terrainMesh";
これで、地形メッシュ生成関数に加えて、グリッド生成関数も使えるようになります。
メッシュ生成部分を書き直す
現在は以下のようになっています。
const terrainMesh = createTerrainMesh({
size: 50,
cellSize: 0.5,
minHeight: -1.2,
maxHeight: 1.2,
});
terrainGroup.add(terrainMesh);
scene.add(terrainGroup);
これを以下のように書き直してください。
const terrainOptions = {
size: 50,
cellSize: 0.5,
minHeight: -1.2,
maxHeight: 1.2,
};
const terrainMesh = createTerrainMesh(terrainOptions);
const terrainGrid = createTerrainGrid({
...terrainOptions,
yOffset: 0.025,
});
terrainGroup.add(terrainMesh);
terrainGroup.add(terrainGrid);
scene.add(terrainGroup);
ここでは、地形メッシュとグリッドで同じ設定を使うため、terrainOptions としてまとめています。
const terrainOptions = {
size: 50,
cellSize: 0.5,
minHeight: -1.2,
maxHeight: 1.2,
};
これにより、地形メッシュとグリッドで size や cellSize がずれることを防げます。
グリッドには追加で yOffset を渡しています。
const terrainGrid = createTerrainGrid({
...terrainOptions,
yOffset: 0.025,
});
...terrainOptions は、terrainOptions の中身を展開して渡す書き方です。
ここでは以下とほぼ同じ意味です。
const terrainGrid = createTerrainGrid({
size: 50,
cellSize: 0.5,
minHeight: -1.2,
maxHeight: 1.2,
yOffset: 0.025,
});
UIの文言を変更する
画面下部の説明も、今回の内容に合わせて変更します。
現在は以下のようになっています。
<div class="overlay-panel">
<div class="label">STEP 4</div>
<h1>Vertex Colored Terrain Mesh</h1>
<p>高さに応じた頂点カラーで地形メッシュを可視化しています。</p>
</div>
これを以下のように書き直してください。
<div class="overlay-panel">
<div class="label">STEP 5</div>
<h1>Terrain Mesh with Surface Grid</h1>
<p>地形表面に沿ったグリッド線を重ねて表示しています。</p>
</div>
更新後の TerrainViewer.vue
今回の変更を反映した TerrainViewer.vue は以下のようになります。
TerrainViewer.vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import * as THREE from "three";
import { createTerrainGrid, createTerrainMesh } from "../lib/terrainMesh";
// Three.jsの描画先になるHTML要素を参照する
const containerRef = ref<HTMLDivElement | null>(null);
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let terrainGroup: THREE.Group | null = null;
let animationFrameId = 0;
function initializeScene(container: HTMLDivElement): void {
// 3D空間そのものを作成する
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf2f2f2);
// 3D空間を見るためのカメラを作成する
camera = new THREE.PerspectiveCamera(
45,
container.clientWidth / container.clientHeight,
0.1,
1000,
);
// 50m x 50m の地形が見えるように、少し離れた位置にカメラを置く
camera.position.set(0, 24, 42);
camera.lookAt(0, 0, 0);
// Three.jsの描画結果をcanvasとして出力する
renderer = new THREE.WebGLRenderer({
antialias: true,
});
// 高DPIディスプレイでも粗く見えないようにする
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
// Vueの要素内にThree.jsのcanvasを追加する
container.appendChild(renderer.domElement);
// メッシュを見やすくするためにライトを追加する
const ambientLight = new THREE.AmbientLight(0xffffff, 1.4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.0);
directionalLight.position.set(20, 30, 20);
scene.add(directionalLight);
// 地形関連のオブジェクトをまとめるグループ
terrainGroup = new THREE.Group();
const terrainOptions = {
size: 50,
cellSize: 0.5,
minHeight: -1.2,
maxHeight: 1.2,
};
// プログラム内で生成した地形メッシュを作成する
const terrainMesh = createTerrainMesh(terrainOptions);
// 地形表面に沿うグリッド線を作成する
const terrainGrid = createTerrainGrid({
...terrainOptions,
yOffset: 0.025,
});
terrainGroup.add(terrainMesh);
terrainGroup.add(terrainGrid);
scene.add(terrainGroup);
// ウィンドウサイズ変更時に描画領域も更新する
window.addEventListener("resize", handleResize);
animate();
}
function handleResize(): void {
const container = containerRef.value;
if (container === null || renderer === null || camera === null) {
return;
}
const width = container.clientWidth;
const height = container.clientHeight;
// カメラのアスペクト比を現在の表示領域に合わせる
camera.aspect = width / height;
camera.updateProjectionMatrix();
// rendererの描画サイズも表示領域に合わせる
renderer.setSize(width, height);
}
function animate(): void {
if (renderer === null || scene === null || camera === null) {
return;
}
// 後のStepで回転やアニメーションを追加しやすいように描画ループにしておく
animationFrameId = requestAnimationFrame(animate);
renderer.render(scene, camera);
}
onMounted(() => {
const container = containerRef.value;
if (container === null) {
return;
}
// Vueコンポーネントが画面に表示されてからThree.jsを初期化する
initializeScene(container);
});
onBeforeUnmount(() => {
// コンポーネント破棄時にイベントやWebGLリソースを解放する
cancelAnimationFrame(animationFrameId);
window.removeEventListener("resize", handleResize);
if (renderer !== null) {
renderer.dispose();
renderer.domElement.remove();
}
renderer = null;
scene = null;
camera = null;
terrainGroup = null;
});
</script>
<template>
<div class="viewer-root">
<!-- Three.jsのcanvasを差し込むための領域 -->
<div ref="containerRef" class="three-container"></div>
<!-- canvasの上に重ねる説明用UI -->
<div class="overlay-panel">
<div class="label">STEP 5</div>
<h1>Terrain Mesh with Surface Grid</h1>
<p>地形表面に沿ったグリッド線を重ねて表示しています。</p>
</div>
</div>
</template>
<style scoped>
.viewer-root {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #f2f2f2;
}
.three-container {
position: absolute;
inset: 0;
}
.overlay-panel {
position: absolute;
left: 50%;
bottom: 48px;
transform: translateX(-50%);
width: min(560px, calc(100vw - 48px));
padding: 20px 24px;
border-radius: 18px;
text-align: center;
color: rgba(20, 24, 28, 0.86);
background: rgba(255, 255, 255, 0.48);
backdrop-filter: blur(10px);
/* UIがマウス操作を奪わないようにする */
pointer-events: none;
}
.label {
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
}
h1 {
margin: 0;
font-size: 28px;
line-height: 1.2;
}
p {
margin: 10px 0 0;
font-size: 14px;
}
</style>
実行する
以下のコマンドで実行します。
npm run tauri dev
地形メッシュの表面に、薄いグリッド線が表示されれば成功です。
グリッド線は地形の凸凹に沿って表示されます。
単なる水平グリッドではなく、地形メッシュと同じ高さ計算を使っているため、表面に貼り付いたように見えるはずです。
まとめ
今回は、地形メッシュの表面にグリッド線を重ねて表示しました。
これで、地形の色だけでなく、メッシュの分割構造も視覚的に分かりやすくなりました。
次の記事では、マウスドラッグやスワイプ操作で地形を回転できるようにしていきます。
次回
記事一覧
参考
