mcpField
T
threejs-interaction

threejs-interaction

github.com/cloudai-x开发3.8k15.0k 次安装1 周前更新
代码仓库

关于 threejs-interaction

面向 three.js r186 开发者的交互参考技能,讲解拾取、相机控制、对象操作、选择和坐标转换,并包含适用于 WebGPU 的说明。

主要功能

  1. 01Raycasting 与命中检测
  2. 02鼠标、触控和触笔事件
  3. 03相机导航控制
  4. 04对象变换与拖动控制
  5. 05选择和坐标转换
  6. 06兼容 WebGPU 的交互说明

适用场景

  1. 01根据指针输入拾取场景对象
  2. 02添加轨道或第一人称导航
  3. 03移动、旋转或缩放对象
  4. 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 控件

评论

支持 Markdown