はじめに
前回の記事では、Tauri + Vue + TypeScript + Three.js を使って、地形メッシュの表面にグリッド線を重ねて表示しました。
前回の記事はこちらです。
前回までで、以下の表示ができるようになりました。
- 地形メッシュの生成
- 高さに応じた頂点カラー
- 地形表面に沿ったグリッド線
- 3Dビュー上へのUI重ね表示
今回は、表示した地形メッシュを マウスドラッグやタッチスワイプで回転 できるようにします。
これまでは固定カメラで地形を見るだけでしたが、操作できるようになると、3Dビューアらしさが一気に出てきます。
今回やること
今回は以下を実装します。
| 内容 | 説明 |
|---|---|
PointerEvent の追加 |
マウス・タッチ操作をまとめて扱う |
| ドラッグ状態の管理 | 操作中かどうかを判定する |
| 移動量から回転量を計算 | ポインターの移動差分で地形を回転する |
| 回転角度の制限 | 上下方向に回りすぎないようにする |
| イベントの解除 | コンポーネント破棄時にイベントを外す |
今回は terrainMesh.ts は変更しません。
主に TerrainViewer.vue を更新します。
Step6: ドラッグ・スワイプで地形を回転する
TerrainViewer.vue を更新する
ドラッグ状態を管理する変数を追加する
まず、TerrainViewer.vue にドラッグ操作用の変数を追加します。
現在は以下のようになっています。
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;
これを以下のように書き直してください。
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;
let isDragging = false;
let previousPointerX = 0;
let previousPointerY = 0;
追加した変数の役割は以下です。
| 変数 | 役割 |
|---|---|
isDragging |
現在ドラッグ中かどうか |
previousPointerX |
前回のポインターX座標 |
previousPointerY |
前回のポインターY座標 |
ポインターの移動量を計算するために、前回位置を保存しておきます。
回転制限用の clamp 関数を追加する
上下方向に回転しすぎると、地形が裏返ったり見づらくなったりします。
そのため、値を指定範囲内に収める clamp 関数を追加します。
変数宣言の下あたりに、以下を追加してください。
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
clamp は、値を最小値と最大値の範囲内に収める関数です。
たとえば以下のような動きになります。
| 入力 | 結果 |
|---|---|
clamp(2, -1, 1) |
1 |
clamp(-2, -1, 1) |
-1 |
clamp(0.5, -1, 1) |
0.5 |
今回は、地形の上下回転を制限するために使います。
canvasにポインター操作用のスタイルを設定する
renderer を作成している部分に、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);
これを以下のように書き直してください。
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);
touchAction = "none" は、タッチ操作時にブラウザ側のスクロールやジェスチャーが優先されるのを防ぐための指定です。
Tauriアプリ内では通常のWebページほどスクロール問題は出にくいですが、タッチやトラックパッド操作を扱う場合は入れておくと安心です。
ポインターイベントを登録する
次に、canvasにポインターイベントを登録します。
container.appendChild(renderer.domElement); の下に、以下を追加してください。
// マウスドラッグ・タッチスワイプで回転できるようにする
addPointerEvents(renderer.domElement);
追加後は以下のようになります。
// Vueの要素内にThree.jsのcanvasを追加する
container.appendChild(renderer.domElement);
// マウスドラッグ・タッチスワイプで回転できるようにする
addPointerEvents(renderer.domElement);
ここで呼び出している addPointerEvents() は、次に作成します。
イベント登録・解除用の関数を追加する
initializeScene() の下あたりに、以下の関数を追加してください。
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);
}
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);
}
PointerEvent を使うと、マウス・タッチ・ペン操作をまとめて扱えます。
| イベント | 役割 |
|---|---|
pointerdown |
操作開始 |
pointermove |
操作中 |
pointerup |
操作終了 |
pointercancel |
操作キャンセル |
pointerleave |
canvas外に出たとき |
今回は、ドラッグ操作が終わる可能性のあるイベントをまとめて handlePointerUp で処理します。
pointerdown の処理を追加する
次に、ドラッグ開始時の処理を追加します。
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";
}
pointerdown では、現在ドラッグ中であることを isDragging に記録します。
isDragging = true;
また、現在のポインター位置を保存します。
previousPointerX = event.clientX;
previousPointerY = event.clientY;
この値を次の pointermove で使い、前回位置との差分を計算します。
setPointerCapture() は、ドラッグ中にポインターがcanvas外へ少し出ても、同じcanvasでイベントを受け取り続けるための処理です。
pointermove の処理を追加する
次に、ドラッグ中の処理を追加します。
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;
}
ここが今回の中心です。
const deltaX = event.clientX - previousPointerX;
const deltaY = event.clientY - previousPointerY;
前回のポインター位置と現在のポインター位置の差分を計算しています。
横方向の移動量 deltaX は、地形の横回転に使います。
terrainGroup.rotation.y += deltaX * 0.01;
縦方向の移動量 deltaY は、地形の上下回転に使います。
terrainGroup.rotation.x = clamp(
terrainGroup.rotation.x + deltaY * 0.005,
-Math.PI / 3,
Math.PI / 3,
);
上下回転には clamp を使い、回りすぎないようにしています。
| 操作 | 回転 |
|---|---|
| 左右ドラッグ |
rotation.y を変更 |
| 上下ドラッグ |
rotation.x を変更 |
最後に、現在位置を次回用に保存します。
previousPointerX = event.clientX;
previousPointerY = event.clientY;
pointerup の処理を追加する
最後に、ドラッグ終了時の処理を追加します。
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";
}
ここでは、ドラッグ状態を解除します。
isDragging = false;
また、setPointerCapture() で取得していたポインターを releasePointerCapture() で解放しています。
コンポーネント破棄時にイベントを解除する
onBeforeUnmount() では、登録したポインターイベントも解除します。
現在は以下のようになっています。
if (renderer !== null) {
renderer.dispose();
renderer.domElement.remove();
}
これを以下のように書き直してください。
if (renderer !== null) {
removePointerEvents(renderer.domElement);
renderer.dispose();
renderer.domElement.remove();
}
イベントリスナーを登録したら、不要になったタイミングで解除しておくのが安全です。
小さなサンプルでは問題になりにくいですが、コンポーネントの再生成があるアプリでは、イベントが残ると意図しない動作につながることがあります。
UIの文言を変更する
画面下部の説明も、今回の内容に合わせて変更します。
現在は以下のようになっています。
<div class="overlay-panel">
<div class="label">STEP 5</div>
<h1>Terrain Mesh with Surface Grid</h1>
<p>地形表面に沿ったグリッド線を重ねて表示しています。</p>
</div>
これを以下のように書き直してください。
<div class="overlay-panel">
<div class="label">STEP 6</div>
<h1>Interactive Terrain Mesh</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;
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.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);
// 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.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 addPointerEvents(canvas: HTMLCanvasElement): void {
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerUp);
canvas.addEventListener("pointercancel", handlePointerUp);
canvas.addEventListener("pointerleave", handlePointerUp);
}
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);
}
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 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 6</div>
<h1>Interactive Terrain Mesh</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
画面上の地形メッシュをドラッグして、左右や上下に回転できれば成功です。
マウス操作ではドラッグ、タッチ対応端末ではスワイプで操作できます。
まとめ
今回は、地形メッシュをマウスドラッグやタッチスワイプで回転できるようにしました。
これで、固定表示だった地形ビューアが、少し触れる3Dビューアになりました。
次の記事では、ホイール操作によるズームを追加し、カメラ操作をもう少しビューアらしくしていきます。
次回
記事一覧
参考
