关于 threejs-interaction
面向 three.js r186 开发者的交互参考技能,讲解拾取、相机控制、对象操作、选择和坐标转换,并包含适用于 WebGPU 的说明。
主要功能
- 01Raycasting 与命中检测
- 02鼠标、触控和触笔事件
- 03相机导航控制
- 04对象变换与拖动控制
- 05选择和坐标转换
- 06兼容 WebGPU 的交互说明
适用场景
- 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)用一组监听器即可覆盖鼠标、触控和触控笔,因此无需单独处理触摸事件。
射线投射
背向射线原点的面不会被命中;将 material.side = THREE.DoubleSide 设为双面,即可命中两侧。射线擦过两个面的共用边时,可能会产生两个重合的命中结果。不可见网格仍会参与射线投射——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 采用基于拖动的视角控制方式:按下并拖动即可调整视角(视角速度会随拖动距离增大,并通过 dampingFactor 平滑变化,默认值为 0.1;设为 1 则禁用平滑),使用 WASD 或方向键移动,按 R 和 F 向上或向下移动,按住鼠标左键向前飞行,按住右键向后飞行。触控通过指针事件实现:单指向前移动,双指向后移动。
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,后者是 EventDispatcher 而不是 Object3D,因此这会记录一条错误,且控件小部件不会显示。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() 已更名为 connect()(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 编写场景时的指针事件属性和 drei 控件