threejs-interaction について
three.js r186 のシーンにインタラクションを加える開発者向けのリファレンススキルです。ピッキング、カメラ操作、オブジェクト編集、選択、座標変換を扱い、WebGPU 向けの説明も含みます。
主な機能
- 01Raycasting とヒット検出
- 02マウス・タッチ・ペンのポインターイベント
- 03カメラナビゲーション操作
- 04オブジェクトの変形とドラッグ
- 05選択と座標変換
- 06WebGPU 対応のインタラクション解説
活用シーン
- 01ポインター入力によるシーンオブジェクトの選択
- 02オービットまたは一人称ナビゲーションの追加
- 03オブジェクトの移動・回転・拡大縮小
- 04画面上の矩形によるオブジェクト選択
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ cloudai-x/ threejs-skills --skill threejs-interactionインストール後、Claude Code で/threejs-interactionを入力して読み込まれたか確認します。
詳細
Three.js のインタラクション
three.js r186 を対象としています。例では WebGLRenderer を使用し、WebGPU と TSL のセクションでは WebGPURenderer を使用します。
クイックスタート
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111318);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 5);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x3a86ff }),
);
scene.add(mesh);
const light = new THREE.DirectionalLight(0xffffff, 2);
light.position.set(3, 5, 4);
scene.add(light, new THREE.AmbientLight(0xffffff, 0.4));
const controls = new OrbitControls(camera, renderer.domElement); // connects itself
controls.enableDamping = true; // requires controls.update() every frame
const timer = new THREE.Timer();
timer.connect(document); // freezes the delta while the tab is hidden
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function setPointer(event) {
// NDC coordinates relative to the canvas, so the page can scroll or embed it
const rect = renderer.domElement.getBoundingClientRect();
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
}
renderer.domElement.addEventListener("pointerdown", (event) => {
setPointer(event);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObject(mesh); // also tests children (recursive by default), as a loaded model needs
if (hits.length > 0) console.log("clicked", hits[0].object);
});
function animate() {
timer.update();
controls.update(timer.getDelta());
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
ポインターイベント(pointerdown、pointermove、pointerup)を使えば、マウス、タッチ、ペンを1組のリスナーで処理できるため、タッチ専用のハンドラーは不要です。
レイキャスティング
レイの始点から遠ざかる向きの面にはヒットしません。両面を検出するには、material.side = THREE.DoubleSide を設定します。2つの面が共有する辺をかすめるレイは、同じ位置に重なるヒットを2つ生成することがあります。不可視のメッシュもレイキャストの対象になります。Raycaster は可視性ではなくレイヤーを検査するため、複雑なモデルの代わりに単純な不可視のプロキシを使用できます。
交差情報に含まれる内容
const raycaster = new THREE.Raycaster();
const clickables = [mesh]; // the smallest list you can: the meshes, not scene.children
const hits = raycaster.intersectObjects(clickables, false); // sorted, closest first
// Each hit:
// {
// distance: number, // from the ray origin, world units
// point: THREE.Vector3, // hit point in world space
// face: { a, b, c, normal, materialIndex }, // plain object; a, b, c are vertex indices; normal in object space
// faceIndex: number,
// object: THREE.Object3D,
// uv: THREE.Vector2, // undefined when the geometry has no uv
// uv1: THREE.Vector2, // second UV set, when the geometry has one
// normal: THREE.Vector3, // interpolated vertex normal in object space, flipped to face the ray
// barycoord: THREE.Vector3, // barycentric coordinates of the hit point
// instanceId: number, // set on InstancedMesh hits
// batchId: number, // set on BatchedMesh hits
// distanceToRay: number // set on Points hits
// }
Raycaster の設定
const raycaster = new THREE.Raycaster();
// From any origin and direction (normalize the direction first)
raycaster.set(new THREE.Vector3(0, 2, 0), new THREE.Vector3(1, 0, 0).normalize());
// Range: hits closer than near or farther than far are discarded
raycaster.near = 0.1;
raycaster.far = 10;
// Precision for Points and Line, in world units (both default to 1)
raycaster.params.Points.threshold = 0.2;
raycaster.params.Line.threshold = 0.2;
// Restrict picking to layer 1. Objects join with enable; set would also remove
// them from layer 0, where the default camera looks, so use set only on a pick-only proxy.
mesh.layers.enable(1); // stays on layer 0 too, so the camera still renders the mesh
raycaster.layers.set(1); // tests layer 1 only; raycaster.layers.enable(1) would test layers 0 and 1
平面上でのドラッグ
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // normal, constant
const hit = new THREE.Vector3();
renderer.domElement.addEventListener("pointermove", (event) => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
// null when the ray is parallel to the plane or points away from it
if (event.buttons !== 0 && raycaster.ray.intersectPlane(groundPlane, hit) !== null) {
mesh.position.copy(hit); // while a button is held, drag the mesh along the ground
}
});
カメラコントロール
すべてのコントロールクラスは Controls を継承し、共通の基本 API を備えています。カメラと DOM 要素を渡して構築します(DragControls ではオブジェクトの配列を最初に渡します)。コンストラクターが独自のリスナーを設定します。入力を無視するには enabled = false、リスナーの切断や移動には disconnect() / connect(element) を使い、破棄時には dispose() を呼び出します。OrbitControls、FlyControls、FirstPersonControls では update() に秒単位の差分時間を渡します。timer.getDelta() を渡してください。それ以外では引数なしで呼び出します。
OrbitControls
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // requires update() every frame
controls.dampingFactor = 0.05;
controls.minDistance = 2;
controls.maxDistance = 50;
controls.minPolarAngle = 0.1; // vertical limits, radians
controls.maxPolarAngle = Math.PI / 2; // stop at the horizon
controls.minAzimuthAngle = -Math.PI / 4;
controls.maxAzimuthAngle = Math.PI / 4;
controls.target.set(0, 1, 0); // the point orbited around
controls.zoomToCursor = true; // wheel zooms toward the pointer
controls.autoRotate = true; // requires update() every frame; stops at an azimuth limit
controls.autoRotateSpeed = 2.0;
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
controls.update(timer.getDelta()); // the delta makes autoRotate frame-rate independent
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
OrbitControls は change、start、end イベントを発生させます。MapControls は地図のナビゲーション向けに調整された OrbitControls のサブクラスです。
import { MapControls } from "three/addons/controls/MapControls.js";
// Left-drag pans, right-drag (or left-drag with ctrl/meta/shift) orbits,
// middle button or wheel zooms. screenSpacePanning is false, so panning
// stays parallel to the ground. Every OrbitControls option applies.
const controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true;
FlyControls
import { FlyControls } from "three/addons/controls/FlyControls.js";
const controls = new FlyControls(camera, renderer.domElement);
controls.movementSpeed = 10;
controls.rollSpeed = Math.PI / 24;
controls.dragToLook = true; // steer only while a mouse button is held
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
controls.update(timer.getDelta()); // movement scales by the delta
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
FirstPersonControls
FirstPersonControls はドラッグベースの視点操作モデルを備えています。押したままドラッグすると視点を動かします(視点の移動速度はドラッグ距離に応じて大きくなり、デフォルト値が 0.1 の dampingFactor によって滑らかになります。1 にすると滑らかさが無効になります)。WASD キーまたは矢印キーで移動し、R キーと F キーで上昇・下降します。左ボタンを押し続けると前方に飛行し、右ボタンを押し続けると後方に飛行します。タッチ操作はポインターイベントで行えます。1本指で前進し、2本指で後退します。
import { FirstPersonControls } from "three/addons/controls/FirstPersonControls.js";
const controls = new FirstPersonControls(camera, renderer.domElement);
controls.movementSpeed = 10;
controls.lookSpeed = 0.005; // default
controls.dampingFactor = 0.1; // default; 1 disables the easing
controls.constrainVertical = true;
controls.verticalMin = Math.PI / 4;
controls.verticalMax = (Math.PI * 3) / 4;
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
controls.update(timer.getDelta()); // the delta is required; without it the position becomes NaN
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
PointerLockControls
import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
const controls = new PointerLockControls(camera, document.body);
controls.pointerSpeed = 1.0; // mouse sensitivity
controls.addEventListener("lock", () => console.log("locked"));
controls.addEventListener("unlock", () => console.log("unlocked"));
document.body.addEventListener("click", () => controls.lock()); // needs a user gesture
const velocity = new THREE.Vector3();
let moveForward = false; // let: the key handlers reassign these
let moveBackward = false;
document.addEventListener("keydown", (event) => {
if (event.code === "KeyW") moveForward = true;
if (event.code === "KeyS") moveBackward = true;
});
document.addEventListener("keyup", (event) => {
if (event.code === "KeyW") moveForward = false;
if (event.code === "KeyS") moveBackward = false;
});
const timer = new THREE.Timer();
timer.connect(document);
function animate() {
timer.update();
const delta = timer.getDelta();
if (controls.isLocked) {
// ease toward the target speed, then move the camera forward parallel to the ground (xz-plane)
velocity.z = THREE.MathUtils.damp(
velocity.z,
10 * (Number(moveForward) - Number(moveBackward)),
10,
delta,
);
controls.moveForward(velocity.z * delta);
// controls.moveRight(distance) does the same along the camera's right axis
}
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
このコントロールはコンストラクターに渡されたカメラ(controls.object)を動かします。シーンに追加しなくても描画されるため、カメラに子要素がある場合に限り scene.add(controls.object) が必要です。
TrackballControls
import { TrackballControls } from "three/addons/controls/TrackballControls.js";
const controls = new TrackballControls(camera, renderer.domElement);
controls.rotateSpeed = 2.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.staticMoving = true; // no inertia; otherwise dynamicDampingFactor (default 0.2)
function animate() {
controls.update(); // no delta argument; call controls.handleResize() after the canvas resizes
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
TransformControls
オブジェクトの移動、回転、拡大縮小を行うギズモです。コントロールオブジェクトは Object3D ではないため、描画されるギズモは別途シーンに追加する必要があります。
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { TransformControls } from "three/addons/controls/TransformControls.js";
const orbitControls = new OrbitControls(camera, renderer.domElement);
const transformControls = new TransformControls(camera, renderer.domElement);
scene.add(transformControls.getHelper()); // the gizmo; adding the controls themselves only logs an error
transformControls.attach(mesh); // show the gizmo on this object
transformControls.setMode("translate"); // "translate" | "rotate" | "scale"
transformControls.setSpace("local"); // "local" | "world"; scale always acts locally
transformControls.setSize(0.8);
transformControls.setTranslationSnap(0.5);
transformControls.setRotationSnap(THREE.MathUtils.degToRad(15));
transformControls.setScaleSnap(0.25);
transformControls.addEventListener("dragging-changed", (event) => {
orbitControls.enabled = !event.value; // pause orbiting while the gizmo is dragged
});
transformControls.addEventListener("objectChange", () => {
console.log(mesh.position); // fires as the gizmo moves the object
});
window.addEventListener("keydown", (event) => {
if (event.key === "g") transformControls.setMode("translate");
if (event.key === "r") transformControls.setMode("rotate");
if (event.key === "s") transformControls.setMode("scale");
if (event.key === "Escape") transformControls.detach(); // hide the gizmo
});
DragControls
オブジェクトを直接ドラッグします。左ボタンまたは中ボタンでドラッグすると画面に平行な平面上でオブジェクトが移動し、右ボタンでドラッグするとカメラの上軸と右軸を中心に回転します。
import { DragControls } from "three/addons/controls/DragControls.js";
const dragControls = new DragControls([mesh], camera, renderer.domElement);
dragControls.rotateSpeed = 1; // default
dragControls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE }; // the default map
// transformGroup = true drags the outermost Group above the hit instead (pass objects = [group]); event.object is then that Group, which has no material, so use event.object.material?.emissive in the handlers
dragControls.addEventListener("dragstart", (event) => {
event.object.material.emissive?.set(0x333333);
});
dragControls.addEventListener("drag", (event) => {
event.object.position.y = 0; // clamp to the ground mid-drag
});
dragControls.addEventListener("dragend", (event) => {
event.object.material.emissive?.set(0x000000);
});
// The built-in picker: dragControls.raycaster. The dragged objects: dragControls.objects.
選択
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const selectable = [mesh];
let selected = null;
function setPointerFromEvent(event) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
}
renderer.domElement.addEventListener("pointerdown", (event) => {
setPointerFromEvent(event);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(selectable, false)[0];
if (selected) selected.material.emissive.set(0x000000); // deselect the previous mesh
selected = hit ? hit.object : null;
if (selected) selected.material.emissive.set(0x444444);
});
renderer.domElement.addEventListener("pointermove", (event) => {
setPointerFromEvent(event);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(selectable, false)[0];
document.body.style.cursor = hit ? "pointer" : "default"; // hover feedback
});
破棄時にリスナーを削除できるよう、名前付きのハンドラー参照を保持して removeEventListener を呼び出し、すべてのコントロールに対して controls.dispose()、タイマーに対して timer.dispose() を呼び出します。
矩形選択
SelectionBox は、バウンディングスフィアの中心が画面上の矩形内にあるオブジェクトを選択します。SelectionHelper は矩形を描画し、ドラッグを追跡します。
import { SelectionBox } from "three/addons/interactive/SelectionBox.js";
import { SelectionHelper } from "three/addons/interactive/SelectionHelper.js";
const selectionBox = new SelectionBox(camera, scene);
const helper = new SelectionHelper(renderer, "selectBox"); // a div with this class: give .selectBox position: fixed, a border and a background in CSS, or nothing shows
function ndc(event) {
return new THREE.Vector3(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
0.5,
);
}
document.addEventListener("pointerdown", (event) => {
selectionBox.startPoint.copy(ndc(event));
});
document.addEventListener("pointermove", (event) => {
if (helper.isDown) selectionBox.endPoint.copy(ndc(event));
});
document.addEventListener("pointerup", (event) => {
selectionBox.endPoint.copy(ndc(event));
const selected = selectionBox.select(); // InstancedMesh and BatchedMesh hits go to selectionBox.instances and .batches
console.log(selected);
});
// on teardown: helper.dispose() removes the helper's listeners and its rectangle element
スクリーン座標とワールド座標
// World to screen: project, then map NDC (-1..1) to CSS pixels
const ndc = mesh.getWorldPosition(new THREE.Vector3()).project(camera);
const screenX = ((ndc.x + 1) / 2) * window.innerWidth;
const screenY = ((-ndc.y + 1) / 2) * window.innerHeight;
// ndc.z outside [-1, 1] means the point is outside the near-to-far range
// Screen to world: unproject into a ray, then walk it to the plane z = 0
const worldPoint = new THREE.Vector3(0.1, 0.2, 0.5).unproject(camera);
const direction = worldPoint.sub(camera.position).normalize();
const distance = -camera.position.z / direction.z; // the ray misses the plane when direction.z is 0
const world = camera.position.clone().addScaledVector(direction, distance);
// The same result comes from a Raycaster built with setFromCamera and intersectPlane
WebGPU と TSL
コントロールと Raycaster はレンダラーに依存しません。上記のピッキングとカメラコントロールのコードは、WebGPURenderer でもそのまま動作します。Raycaster は CPU 側のジオメトリを検査するため、positionNode による変位は検出できません。代わりに、変位を適用していないプロキシメッシュを対象にピッキングしてください。
import * as THREE from "three/webgpu";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const renderer = new THREE.WebGPURenderer({ antialias: true }); await renderer.init(); // required before any other use
renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x111318); // WebGPU canvas is transparent by default
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 1.5, 5);
scene.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x3a86ff }))); // unlit: no light needed
const controls = new OrbitControls(camera, renderer.domElement);
const timer = new THREE.Timer(); timer.connect(document);
renderer.setAnimationLoop(function animate() {
timer.update();
controls.update(timer.getDelta());
renderer.render(scene, camera);
});
よくある間違い
scene.add(transformControls)→scene.add(transformControls.getHelper()):TransformControls は Controls を継承しています。Controls は Object3D ではなく EventDispatcher なので、エラーが記録され、ギズモも表示されません。new OrbitControls(camera)→new OrbitControls(camera, renderer.domElement):コンストラクターはイベントリスナーを要素に設定します。要素を指定しないと、コントロールは入力を受け取れません。controls.update(clock.getDelta())→timer.update(); controls.update(timer.getDelta()):Clock は r183 で非推奨になりました。Timer を作成して接続し、各フレームの最初に更新してください。import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"→import { OrbitControls } from "three/addons/controls/OrbitControls.js":ドキュメントに記載されたインポートマップではthree/addons/が examples/jsm/ を参照するよう設定されており、three/examples/のキーは定義されていないため、この指定子はブラウザーで解決できません。requestAnimationFrame(animate)→renderer.setAnimationLoop(animate):両レンダラーのドキュメントでは、手動ループよりこちらが推奨されています。また、WebXR セッションが開始すると、フレームがそのセッションに渡されます。flyControls.update()→flyControls.update(timer.getDelta()):FlyControls と FirstPersonControls はデルタに応じて移動量を調整するため、デルタを指定しないと位置が NaN になります。orbitControls.update()→orbitControls.update(timer.getDelta()):デルタを指定しないと、autoRotate の速度がディスプレイのリフレッシュレートに左右されます。mesh.layers.set(1)→mesh.layers.enable(1):setはマスクを置き換えてメッシュをレイヤー 0 から取り除くため、デフォルトのカメラでメッシュが描画されなくなります。hit.face instanceof THREE.Face3→hit.face.normal:交差結果の face は{ a, b, c, normal, materialIndex }という通常のオブジェクトです。Face3 は存在しません。const moveForward = false→let moveForward = false:keydown ハンドラーはこのフラグを再代入するため、const への代入は TypeError をスローします。controls.getObject()→controls.object:PointerLockControls.getObject() は r168 で削除されました。コントロールはコンストラクターに渡されたカメラを管理します。dragControls.getObjects()→dragControls.objects:これは r168 で削除されました。同様に、getRaycaster() は raycaster プロパティになりました。dragControls.activate()→dragControls.connect(renderer.domElement):r168 で activate() から名前が変更されました(deactivate() は disconnect() に変更)。connect() には要素が必要です。要素を指定しないと、以前の名前と同様に TypeError がスローされます。firstPersonControls.activeLook = false→firstPersonControls.lookSpeed = 0:FirstPersonControls に activeLook プロパティはないため、代入しても何も起こらず、ドラッグによる視点移動は有効なままです。
関連項目
threejs-fundamentals- シーン、カメラ、レンダラーの設定、アニメーションループthreejs-animation- インタラクションに応じたオブジェクトのアニメーションthreejs-postprocessing- 選択ハイライト用のアウトラインおよびブルームエフェクトthreejs-loaders- インタラクティブにする glTF モデルの読み込みthreejs-react-three-fiber- シーンを React で記述する場合のポインターイベント用 props と drei コントロール