はじめに
前回の記事では、3Dビューの上に重ねたUIに Reset View ボタンを追加し、回転やズーム後の表示を初期状態に戻せるようにしました。
前回の記事はこちらです。
このシリーズでは、Tauri + Vue + TypeScript + Three.js を使って、デスクトップアプリ内に3D地形ビューアを作成してきました。
今回は最終回として、ここまで作成したコードを整理し、完成版の構成としてまとめます。
完成したもの
最終的に、以下のような3D地形ビューアを作成しました。
実装した主な機能は以下です。
| 機能 | 内容 |
|---|---|
| 3Dビュー表示 | Tauri上でThree.jsのcanvasを表示 |
| プログラム内生成メッシュ | TypeScriptで地形メッシュを生成 |
| 頂点カラー | 高さに応じて赤、黄、緑で可視化 |
| 表面グリッド | 地形の凸凹に沿ったグリッド線を表示 |
| ドラッグ・スワイプ回転 |
PointerEvent で地形を回転 |
| ホイールズーム |
wheel イベントでカメラ距離を変更 |
| リセットボタン | カメラ位置と地形回転を初期化 |
| 簡易ノイズ | 不規則な地形の凸凹を生成 |
最終的なファイル構成
最終的な src 配下は以下の構成です。
src/
├─ App.vue
├─ main.ts
├─ style.css
├─ components/
│ └─ TerrainViewer.vue
└─ lib/
└─ terrainMesh.ts
主な役割は以下です。
| ファイル | 役割 |
|---|---|
App.vue |
TerrainViewer を表示する |
main.ts |
Vueアプリの起点 |
style.css |
全体の余白やスクロールバーを調整 |
TerrainViewer.vue |
Three.jsの初期化、描画、操作UI |
terrainMesh.ts |
地形メッシュとグリッド線の生成 |
最終コード
src/App.vue
App.vue は、TerrainViewer を表示するだけのシンプルな構成です。
App.vue
<script setup lang="ts">
import TerrainViewer from "./components/TerrainViewer.vue";
</script>
<template>
<TerrainViewer />
</template>
src/style.css
Tauriの初期テンプレートに含まれる余白や中央寄せ設定を無効化し、3Dビューをウィンドウ全体に表示できるようにしています。
style.css
:root {
/* アプリ全体で使う基本フォントを指定する */
font-family:
Inter,
Avenir,
Helvetica,
Arial,
sans-serif;
color: #1f2933;
background: #f2f2f2;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
html,
body,
#app {
/* Tauriのウィンドウ全体をVueアプリの表示領域にする */
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
body {
/* 3Dビューを画面全体に表示するため、スクロールバーを出さない */
overflow: hidden;
}
#app {
/* 初期テンプレート由来の中央寄せや余白を無効化する */
max-width: none;
text-align: initial;
}
* {
/* paddingやborderを含めてサイズ計算する */
box-sizing: border-box;
}
src/lib/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;
}
src/components/TerrainViewer.vue
Three.jsの初期化、地形表示、回転、ズーム、リセットUIをまとめたコンポーネントです。
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;
const INITIAL_CAMERA_POSITION = new THREE.Vector3(0, 24, 42);
const CAMERA_LOOK_AT = new THREE.Vector3(0, 0, 0);
const MIN_CAMERA_Z = 24;
const MAX_CAMERA_Z = 80;
let isDragging = false;
let previousPointerX = 0;
let previousPointerY = 0;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
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.copy(INITIAL_CAMERA_POSITION);
camera.lookAt(CAMERA_LOOK_AT);
// Three.jsの描画結果をcanvasとして出力する
renderer = new THREE.WebGLRenderer({
antialias: true,
});
// 高DPIディスプレイでも粗く見えないようにする
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
// canvasをブロック要素として扱い、タッチ操作時の既定動作を抑制する
renderer.domElement.style.display = "block";
renderer.domElement.style.touchAction = "none";
renderer.domElement.style.cursor = "grab";
// Vueの要素内にThree.jsのcanvasを追加する
container.appendChild(renderer.domElement);
// マウスドラッグ・タッチスワイプ・ホイール操作を登録する
addPointerEvents(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.4,
maxHeight: 1.4,
};
// プログラム内で生成した地形メッシュを作成する
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 addPointerEvents(canvas: HTMLCanvasElement): void {
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerUp);
canvas.addEventListener("pointercancel", handlePointerUp);
canvas.addEventListener("pointerleave", handlePointerUp);
// ホイール操作でズームできるようにする
canvas.addEventListener("wheel", handleWheel, { passive: false });
}
function removePointerEvents(canvas: HTMLCanvasElement): void {
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerUp);
canvas.removeEventListener("pointercancel", handlePointerUp);
canvas.removeEventListener("pointerleave", handlePointerUp);
canvas.removeEventListener("wheel", handleWheel);
}
function handlePointerDown(event: PointerEvent): void {
isDragging = true;
previousPointerX = event.clientX;
previousPointerY = event.clientY;
const canvas = event.currentTarget as HTMLCanvasElement;
canvas.setPointerCapture(event.pointerId);
canvas.style.cursor = "grabbing";
}
function handlePointerMove(event: PointerEvent): void {
if (!isDragging || terrainGroup === null) {
return;
}
event.preventDefault();
const deltaX = event.clientX - previousPointerX;
const deltaY = event.clientY - previousPointerY;
terrainGroup.rotation.y += deltaX * 0.01;
terrainGroup.rotation.x = clamp(
terrainGroup.rotation.x + deltaY * 0.005,
-Math.PI / 3,
Math.PI / 3,
);
previousPointerX = event.clientX;
previousPointerY = event.clientY;
}
function handlePointerUp(event: PointerEvent): void {
isDragging = false;
const canvas = event.currentTarget as HTMLCanvasElement;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
canvas.style.cursor = "grab";
}
function handleWheel(event: WheelEvent): void {
if (camera === null) {
return;
}
event.preventDefault();
camera.position.z = clamp(
camera.position.z + event.deltaY * 0.03,
MIN_CAMERA_Z,
MAX_CAMERA_Z,
);
camera.lookAt(CAMERA_LOOK_AT);
}
function resetView(): void {
if (camera !== null) {
camera.position.copy(INITIAL_CAMERA_POSITION);
camera.lookAt(CAMERA_LOOK_AT);
}
if (terrainGroup !== null) {
terrainGroup.rotation.set(0, 0, 0);
}
}
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) {
removePointerEvents(renderer.domElement);
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">COMPLETE</div>
<h1>Procedural Terrain Viewer</h1>
<p>ドラッグ・スワイプで回転、ホイールでズームできます。</p>
<div class="control-row">
<button class="reset-button" type="button" @click.stop="resetView">
Reset View
</button>
</div>
</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全体は3D操作を邪魔しないようにする */
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;
}
.control-row {
margin-top: 14px;
display: flex;
justify-content: center;
}
.reset-button {
/* ボタンだけクリック可能にする */
pointer-events: auto;
border: 1px solid rgba(31, 41, 55, 0.22);
border-radius: 999px;
padding: 8px 18px;
color: rgba(31, 41, 55, 0.9);
background: rgba(255, 255, 255, 0.58);
font-weight: 700;
cursor: pointer;
}
.reset-button:hover {
background: rgba(255, 255, 255, 0.82);
}
.reset-button:active {
transform: translateY(1px);
}
</style>
動作確認
以下のコマンドで起動します。
npm run tauri dev
起動後、以下を確認します。
| 確認項目 | 内容 |
|---|---|
| 地形が表示される | ノイズで生成された地形メッシュが表示される |
| 頂点カラーが表示される | 高さに応じて赤、黄、緑で表示される |
| グリッド線が表示される | 地形表面に沿った線が表示される |
| ドラッグで回転できる | マウスドラッグで地形が回転する |
| スワイプで回転できる | タッチ操作でも回転できる |
| ホイールでズームできる | カメラ距離が変わる |
| Reset Viewが動く | カメラ位置と回転が初期状態に戻る |
今回の実装で学んだこと
Tauri上でもThree.jsは通常のWebアプリに近い感覚で使える
Tauriのフロントエンド部分はWeb技術で構成されているため、Vueアプリ内にThree.jsのcanvasを配置できます。
今回は renderer.domElement をVueの要素に追加することで、Tauriアプリ内に3Dビューを表示しました。
container.appendChild(renderer.domElement);
BufferGeometry を使うとメッシュを自分で生成できる
今回は BufferGeometry を使って、頂点座標や三角形面を自分で作成しました。
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
geometry.setIndex(indices);
これにより、単なる既成の形状ではなく、プログラムで自由に地形メッシュを生成できます。
頂点カラーで高さを可視化できる
各頂点に color attribute を追加し、マテリアル側で vertexColors を有効化することで、頂点ごとの色を表示できました。
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colors, 3),
);
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
});
高さ情報を色に変換することで、地形の凹凸を視覚的に分かりやすくできます。
3Dオブジェクトを Group にまとめると操作しやすい
地形メッシュとグリッド線を terrainGroup にまとめました。
terrainGroup.add(terrainMesh);
terrainGroup.add(terrainGrid);
scene.add(terrainGroup);
これにより、ドラッグ操作では terrainGroup を回転させるだけで、地形メッシュとグリッド線をまとめて操作できます。
UIはcanvasの上に重ねられる
Three.jsのcanvasを画面全体に表示し、その上にVueのHTML要素を absolute 配置することで、3Dビュー上にUIを重ねられました。
.overlay-panel {
position: absolute;
left: 50%;
bottom: 48px;
}
また、表示用UIと操作ボタンで pointer-events を使い分けることで、3D操作とUI操作を共存させました。
まとめ
このシリーズでは、Tauri + Vue + TypeScript + Three.js を使って、簡易的な3D地形ビューアを作成しました。
最初は空の3Dシーンだけでしたが、最終的には以下の機能を持つビューアになりました。
- Tauri上でThree.jsを表示
- プログラム内で地形メッシュを生成
- 高さに応じた頂点カラーを表示
- 地形表面にグリッド線を表示
- ドラッグ・スワイプで回転
- ホイールでズーム
- UIボタンで表示をリセット
- 簡易ノイズでざらざらした地形を生成
Tauri上でも、Three.jsを使えばかなり自然に3D表示を扱えることが確認できました。
今回の実装はまだ小さな検証ですが、地形データの読み込みやUI操作を追加していけば、デスクトップ向けの3Dビューアとして発展させられそうです。
記事一覧
