はじめに
前回の記事では、簡易ノイズを使って、ざらざらした地形メッシュを生成しました。
前回の記事はこちらです。
ここまでで、地形ビューアには以下の機能が入りました。
- プログラム内生成メッシュ
- 頂点カラー
- 表面グリッド
- ドラッグ・スワイプ回転
- ホイールズーム
- 簡易ノイズによるざらざらした地形表現
今回は、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 ボタンを押して初期表示に戻れば成功です。
まとめ
今回は、3Dビューの上に重ねたUIにリセットボタンを追加しました。
これで、地形ビューアとしての基本操作はかなり揃いました。
次の記事では、ここまで作成したコードを最終版として整理し、ファイル構成や完成コード、今後の拡張案をまとめます。
次回
記事一覧
参考
