0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tauri + Three.jsで3D地形ビューアを作る⑩:完成コードとまとめ

0
Last updated at Posted at 2026-06-25

はじめに

前回の記事では、3Dビューの上に重ねたUIに Reset View ボタンを追加し、回転やズーム後の表示を初期状態に戻せるようにしました。

前回の記事はこちらです。

このシリーズでは、Tauri + Vue + TypeScript + Three.js を使って、デスクトップアプリ内に3D地形ビューアを作成してきました。

今回は最終回として、ここまで作成したコードを整理し、完成版の構成としてまとめます。

完成したもの

最終的に、以下のような3D地形ビューアを作成しました。

image.png

実装した主な機能は以下です。

機能 内容
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ビューアとして発展させられそうです。

記事一覧

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?