0
1

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地形ビューアを作る⑨:UIボタンで表示をリセットする

0
Last updated at Posted at 2026-06-24

はじめに

前回の記事では、簡易ノイズを使って、ざらざらした地形メッシュを生成しました。

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

ここまでで、地形ビューアには以下の機能が入りました。

  • プログラム内生成メッシュ
  • 頂点カラー
  • 表面グリッド
  • ドラッグ・スワイプ回転
  • ホイールズーム
  • 簡易ノイズによるざらざらした地形表現

今回は、3Dビューの上に重ねているUIを少し拡張し、表示を初期状態に戻すリセットボタン を追加します。

これにより、地形を回転・ズームしたあとでも、ワンクリックで最初の見た目に戻せるようになります。

今回やること

内容 説明
初期カメラ位置を定数化 リセット時に同じ位置へ戻せるようにする
resetView() を追加 カメラ位置と地形回転を初期状態に戻す
UIにボタンを追加 canvas上のオーバーレイから操作する
CSSを調整 UIは表示しつつ、ボタンだけクリック可能にする

今回は terrainMesh.ts は変更しません。
変更するのは TerrainViewer.vue のみです。

Step9: UIボタンで表示をリセットする

TerrainViewer.vue を更新する

初期表示用の定数を追加する

まず、カメラの初期位置やズーム範囲を定数としてまとめます。

現在は、カメラ位置やズーム範囲を直接数値で書いています。

camera.position.set(0, 24, 42);
camera.position.z = clamp(
  camera.position.z + event.deltaY * 0.03,
  24,
  80,
);

このままだと、リセット処理を書くときに同じ数値を複数箇所へ書くことになります。

そこで、animationFrameId などの変数宣言の下に、以下を追加してください。

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;

それぞれの役割は以下です。

定数 内容
INITIAL_CAMERA_POSITION カメラの初期位置
CAMERA_LOOK_AT カメラが見る中心点
MIN_CAMERA_Z ズーム時の最短距離
MAX_CAMERA_Z ズーム時の最長距離

カメラ初期化部分を書き直す

次に、カメラ位置を設定している箇所を書き直します。

現在は以下のようになっています。

camera.position.set(0, 24, 42);
camera.lookAt(0, 0, 0);

これを以下のように書き直してください。

camera.position.copy(INITIAL_CAMERA_POSITION);
camera.lookAt(CAMERA_LOOK_AT);

copy() を使うことで、INITIAL_CAMERA_POSITION の値をカメラ位置に反映しています。

ホイールズームの範囲指定を書き直す

次に、handleWheel() のズーム範囲指定も定数を使う形にします。

現在は以下のようになっています。

camera.position.z = clamp(
  camera.position.z + event.deltaY * 0.03,
  24,
  80,
);

camera.lookAt(0, 0, 0);

これを以下のように書き直してください。

camera.position.z = clamp(
  camera.position.z + event.deltaY * 0.03,
  MIN_CAMERA_Z,
  MAX_CAMERA_Z,
);

camera.lookAt(CAMERA_LOOK_AT);

これで、カメラの初期位置やズーム範囲を後から調整しやすくなります。

resetView() を追加する

次に、表示を初期状態へ戻す関数を追加します。

handleWheel() の下あたりに、以下を追加してください。

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);
  }
}

この関数では、以下の2つを行っています。

処理 内容
カメラ位置を戻す ズーム状態を初期化する
地形回転を戻す ドラッグ回転を初期化する

terrainGroup.rotation.set(0, 0, 0) によって、地形メッシュとグリッドをまとめて初期角度へ戻しています。

前回までに地形関連のオブジェクトを terrainGroup にまとめていたので、ここでも簡単にリセットできます。

UIにリセットボタンを追加する

次に、template 側を変更します。

現在は以下のようになっています。

<div class="overlay-panel">
  <div class="label">STEP 8</div>
  <h1>Rough Procedural Terrain</h1>
  <p>簡易ノイズを使って、ざらざらした地形メッシュを生成しています。</p>
</div>

これを以下のように書き直してください。

<div class="overlay-panel">
  <div class="label">STEP 9</div>
  <h1>Terrain Viewer Controls</h1>
  <p>ドラッグ・スワイプで回転、ホイールでズームできます。</p>

  <div class="control-row">
    <button class="reset-button" type="button" @click.stop="resetView">
      Reset View
    </button>
  </div>
</div>

@click.stop="resetView" によって、ボタンをクリックしたときに resetView() を呼び出します。

.stop は、クリックイベントの伝播を止めるための指定です。
今回のように3Dビューの上にUIを重ねる場合、UI操作と3D操作が混ざらないようにしておくと安心です。

CSSを追加する

最後に、ボタン用のCSSを追加します。

TerrainViewer.vue の <style scoped> 内に、以下を追加してください。

.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);
}

ここで重要なのは、以下です。

pointer-events: auto;

これまで .overlay-panel には以下を指定していました。

pointer-events: none;

これは、説明用UIが3D操作を邪魔しないようにするためです。

ただし、今回はボタンだけはクリックできる必要があります。
そのため、ボタン側に pointer-events: auto; を指定しています。

これにより、オーバーレイ全体は3D操作を邪魔しにくいまま、ボタンだけクリック可能になります。

更新後の 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;

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">STEP 9</div>
      <h1>Terrain Viewer Controls</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 ボタンを押して初期表示に戻れば成功です。

image.png

まとめ

今回は、3Dビューの上に重ねたUIにリセットボタンを追加しました。
これで、地形ビューアとしての基本操作はかなり揃いました。

次の記事では、ここまで作成したコードを最終版として整理し、ファイル構成や完成コード、今後の拡張案をまとめます。

次回

記事一覧

参考

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?