Three.js 鼠标交互实战:Raycaster 地面拾取与拖拽实现
2026/9/15 21:35:47 网站建设 项目流程

搞 Three.js 的同学,大概率都经历过这个阶段:能照着文档搭出场景,放几个旋转的立方体,但一旦想“上手去摸”这些物体,比如鼠标拖一下、点一下、让物体在地面上滑来滑去,就开始抓瞎。这篇文章就是专门解决这个需求的:在 Three.js 里铺一块地面,放上几个几何体,然后用鼠标做完整的交互控制,包括悬浮高亮、点击选中、拖拽移动,以及把物体稳稳限制在地面上。适合刚跑通 Three.js 基础、但还没系统做过鼠标拾取和拖拽的人,也适合做数字孪生、3D 编辑器、工业布局类项目的同学当参考,里面涉及的 Raycaster 拾取和射线求交思路,迁移到其他三维引擎也完全通用。

1. 先想清楚:地面、几何体、鼠标控制这三件事怎么配合

1.1 地面在 Three.js 场景里到底是什么角色

很多教程会把地面当成一个可有可无的装饰,随便拉一个 PlaneGeometry 铺在场景底部就完事。但实际做交互的时候,地面承担的角色远不止“好看”。

第一,地面是空间参照物。没有地面,场景转两圈之后你就会彻底失去方向感,分不清物体到底在哪个方位、离相机多远。地面给了眼睛一个“基准面”,让整套坐标体系有了视觉锚点。

第二,地面是阴影的载体。物体在地面上投射阴影,才让几何体之间有真实的上下关系和距离感。工业数字孪生里,设备模型如果没有阴影,看起来就像飘在半空的贴图,非常影响真实度。

第三,也是容易被忽略的一点,地面可以成为交互计算的数学平面。鼠标拖拽物体时,本质上是用一条从相机发射出的射线,跟某个三维空间中的平面求交点。这个平面最自然的选择就是地面所在的 y=0 平面。也就是说,地面不光是视觉层,它还能直接参与鼠标控制的核心计算,“地面上的几何体”和“鼠标控制”本来就是一体的。

1.2 鼠标控制的两条路线:官方现成方案和自研方案

Three.js 官方提供了 DragControls,用它能很快实现“鼠标拖拽物体”。但实际项目里我用过几次之后就发现,它更适合快速验证 demo,离生产需求还有一段距离。DragControls 的问题主要有三个。

第一,它拖拽时只是把物体往相机前方的虚拟平面贴,物体不会自动贴到地面或者某个指定平面上,很难做出“物体在地面上滑动”的效果。第二,它默认和 OrbitControls 有手势冲突,拖拽的时候经常把相机一起带偏,你需要在事件里手动去关闭相机控制器。第三,它没法精细控制拖拽过程中的行为,比如拖到边界要停下、半透明预览、实时捕捉到网格点等,这些都得自己改源码或者重新实现。

所以我在这篇文章里选择自己手写一套交互逻辑,用 Raycaster 做拾取,用射线与 y=0 平面求交来实现拖拽。这样做的核心收益是:每一个环节都在自己掌控之中,拖拽行为完全可以定制。官方方案可以当作对照,但主线是自研方案。两种方案的差别我整理成了对比:

对比项DragControls自研 Raycaster + 射线求交
实现成本低,几行就能跑中,需要理解 Raycaster 原理
拖拽平面控制不易控制,默认在相机前方可精确指定任意平面
限制物体贴地运动难以实现天然支持
与 OrbitControls 配合需要手动处理可控性强
扩展能力弱,要改源码强,后续可加网格吸附、边界限制
适合场景快速 demo、工具原型生产项目、数字孪生、编辑器

1.3 技术选型复盘

如果你只是交个作业或者做个原型,用 DragControls 完全没问题。但如果你想做一个能长期迭代的交互功能,我还是建议把自研这条路线走一遍,哪怕最后回到 DragControls,你也知道底层到底发生了什么。

文章后面所有代码,我会按照“一个 HTML 文件直接跑”的方式写,不需要 Node.js 环境,不需要构建工具。Three.js 现在可以直接通过 CDN 上的 ESM 模块来用,用 importmap 就行。这样极大降低了上手门槛,你把代码存成一个 .html 文件双击打开,就能看到完整效果。

2. 搭建基础场景:地面的落地细节和几何体摆放

2.1 三件套与坐标体系

任何 Three.js 应用都从场景、相机、渲染器三件套开始。场景就是你的三维世界容器,相机决定从哪个角度观察世界,渲染器负责把这一切画到屏幕上。对应到代码上,最基础是这样:

const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(10, 8, 12); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.body.appendChild(renderer.domElement);

这里有一个容易忽略的细节:renderer.shadowMap.enabled必须开启,不然后面所有阴影相关的设置全部白搭。PCFSoftShadowMap能让阴影边缘更柔和,效果比默认值好不少,代价是略微多一点性能消耗,对这种小场景完全值得。

还要加一个环境光和方向光,不然物体看起来是黑的。方向光负责产生阴影,环境光负责把暗部照亮,让几何体不至于黑成一团。

const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(8, 12, 5); dirLight.castShadow = true; scene.add(dirLight);

相机的 z 和 y 方向都要设置,这样视角是斜着俯视地面,既能看出物体高度,也能看到地面上的阴影。坐标体系里,Three.js 默认 y 轴朝上,这也是我们后面所有计算的基础。

2.2 地面几何体实操:不要掉进旋转几何体的坑

PlaneGeometry 创建出来的时候,默认是垂直于 y 轴的,也就是平躺在 XY 平面上。我们需要它水平放置,也就是在 XZ 平面上,所以要做一次旋转。

很多人会直接写mesh.rotation.x = -Math.PI / 2,这样能让平面看起来水平,但坑就在这里:你旋转的是节点,而不是几何体本身。后续做射线求交的时候,平面的实际顶点坐标和矩阵变换会多一层计算,容易出问题。

更稳妥的做法是直接用几何体自带的旋转方法,把旋转信息烘焙进顶点坐标里:

const groundGeometry = new THREE.PlaneGeometry(20, 20); groundGeometry.rotateX(-Math.PI / 2); const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x2a2a3a, roughness: 0.9, metalness: 0.1, }); const ground = new THREE.Mesh(groundGeometry, groundMaterial); ground.receiveShadow = true; scene.add(ground);

groundGeometry.rotateX(-Math.PI / 2)是在几何体层面做的旋转,所有顶点坐标都被永久改写,你后续做射线求交时拿到的平面就是实实在在的水平平面,不会有坐标变换的干扰。这算是一个踩过坑的人才会注意到的细节。

地面尺寸我设成 20x20,是因为后面放几何体时要有一个明确的运动范围,也方便你做边界限制。

2.3 摆放几何体时的三个坑

有了地面之后,往上面放几个几何体。Box、Sphere、Cylinder 各来一个,这样交互起来有辨识度。

const box = new THREE.Mesh( new THREE.BoxGeometry(1.5, 1.5, 1.5), new THREE.MeshStandardMaterial({ color: 0xff6b6b, roughness: 0.3, metalness: 0.2 }) ); box.position.set(-4, 0.75, -2); box.castShadow = true; scene.add(box); const sphere = new THREE.Mesh( new THREE.SphereGeometry(0.9, 32, 32), new THREE.MeshStandardMaterial({ color: 0x4ecdc4, roughness: 0.25, metalness: 0.4 }) ); sphere.position.set(0, 0.9, 1); sphere.castShadow = true; scene.add(sphere); const cylinder = new THREE.Mesh( new THREE.CylinderGeometry(0.7, 0.7, 2, 32), new THREE.MeshStandardMaterial({ color: 0xffe66d, roughness: 0.5, metalness: 0.1 }) ); cylinder.position.set(4, 1, 3); cylinder.castShadow = true; scene.add(cylinder);

这里有三个坑需要说明。

第一个坑是 y 轴位置。BoxGeometry(1.5, 1.5, 1.5) 的几何中心在原点,所以如果直接放在 y=0 的位置,盒子有一半会陷进地面里。要让物体底部稳稳站着,就必须把 y 设置成物体高度的一半。所以盒子是 0.75,球体半径 0.9,圆柱体高度 2 所以是 1。这个计算虽然简单,但新手最容易漏,结果就是物体穿模。

第二个坑是 castShadow。只有castShadow = true的物体会投射阴影,只有receiveShadow = true的物体会接收阴影。如果忘了设置,阴影就会消失。

第三个坑是 SphereGeometry 的段数。默认 16x16 的球在边缘会看到明显的多边形棱角,放到 32x32 会平滑很多,代价是顶点数变多。对这种单个物体来说几乎可以忽略性能影响,但如果是大量物体,就要权衡了。

2.4 顺带解决法线问题

网上搜 Three.js 教程时经常能看到“计算顶点的法线”这个词,我在这里解释一下,因为它和几何体的显示效果直接相关。

法线就是垂直于表面每个顶点的方向向量。MeshStandardMaterial 这类基于物理的材质,计算光照时需要知道表面的朝向。如果法线丢失或者方向反了,物体表面会呈现黑色或者出现奇怪的明暗。

对 Three.js 内置的几何体,比如 BoxGeometry、SphereGeometry,法线是自动计算好的,你不需要额外处理。但如果你从外部加载模型,或者用 BufferGeometry 手动构造几何体,法线通常是缺失的。这时候就需要手动补算:

const geometry = new THREE.BufferGeometry(); // 假设这里填充了 position 属性... geometry.computeVertexNormals();

computeVertexNormals()会根据相邻三角形的面法线,自动计算出每个顶点的平滑法线。它适合大部分场景。唯一需要注意的是,如果你需要硬边效果(比如一个正方体的棱角分明,而不是被平滑过渡成圆角),光靠computeVertexNormals()是不够的,需要拆顶点,或者给每条硬边对应的面分开提供法线。对于地面的 PlaneGeometry,如果你用了几何体层面的rotateX,法线方向也会跟着旋转,不必担心光照方向出错。

这一块很多人理解不到位,遇到表面发黑就乱猜,希望这段能帮大家把“法线”这个抽象概念落到实际检查步骤里。

3. 鼠标交互核心:Raycaster 拾取和拖拽实现

3.1 从屏幕坐标到三维世界的换算

鼠标交互的前提,是把鼠标在屏幕上的二维坐标,换算成三维世界里的方向射线。这个过程看起来玄乎,其实核心就是 Raycaster。

屏幕坐标系的原点在左上角,x 向右,y 向下。而 Three.js 的归一化设备坐标(NDC)范围是 -1 到 1,x 向右为正,y 向上为正。所以第一步是换算:

const mouse = new THREE.Vector2(); const raycaster = new THREE.Raycaster(); function updateMouse(clientX, clientY) { mouse.x = (clientX / window.innerWidth) * 2 - 1; mouse.y = -(clientY / window.innerHeight) * 2 + 1; }

换算公式不用死记,理解它的意义就行:把屏幕像素点映射到 -1 到 1 的区间,同时翻转 y 方向。然后让 Raycaster 从相机位置朝这个点对应的方向发射一条射线:

raycaster.setFromCamera(mouse, camera);

设置完之后,你可以调用raycaster.intersectObjects(objects)来检测射线是否与物体相交,返回的数组会按距离排序,第一个交点就是离相机最近的物体。

这一步是整个鼠标控制的原理基础,后面所有的高亮、选中、拖拽,都是围绕这条射线展开的。

3.2 实现悬浮高亮和点击选中

悬浮高亮是很常见的交互反馈,鼠标移到几何体上时,物体会变色或者发光,提示用户可以操作。

我在 mousemove 事件里做实时检测:

const objects = [box, sphere, cylinder]; let hoveredObject = null; renderer.domElement.addEventListener('mousemove', (event) => { updateMouse(event.clientX, event.clientY); raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(objects); const current = intersects.length > 0 ? intersects[0].object : null; if (hoveredObject !== current) { if (hoveredObject) { hoveredObject.material.emissive.setHex(0x000000); } if (current) { current.material.emissive.setHex(0x222222); } hoveredObject = current; } });

这里我用了emissive属性来表示高亮。Emissive 是材质的自发光属性,设置之后物体会自己发光,不需要依赖灯光也能看到变亮的效果。让原来的高亮恢复,就是再把 emissive 设回 0x000000。

需要注意,我在判断里先检查hoveredObject !== current,只有当悬停目标变化时才去更新材质。如果不做这个判断,每次 mousemove 都会重复设置同样的值,白白浪费性能。这种“只在变化时更新”的习惯,在三维项目里非常关键。

点击选中的逻辑可以复用同样的思路,区别在于事件换成 mousedown,并且要在高亮的基础上再加一层视觉效果。我建议用颜色变化来表示选中,比如把选中物体的 emissive 设为红色调:

let selectedObject = null; renderer.domElement.addEventListener('mousedown', (event) => { updateMouse(event.clientX, event.clientY); raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(objects); if (selectedObject) { selectedObject.material.emissive.setHex(0x000000); } if (intersects.length > 0) { selectedObject = intersects[0].object; selectedObject.material.emissive.setHex(0x992222); } else { selectedObject = null; } });

这样点击空白区域会取消选中,点击物体会切换选中状态。

3.3 拖拽几何体的完整流程

拖拽是最核心的功能,也是标题里“鼠标控制”的重头戏。完整流程分为三个阶段:按下、移动、抬起。

按下瞬间,判断鼠标是否拾取到了物体。如果拾取到了,就记录一个关键数据:鼠标与物体的偏移量。

let isDragging = false; let dragObject = null; let dragOffset = new THREE.Vector3(); renderer.domElement.addEventListener('mousedown', (event) => { updateMouse(event.clientX, event.clientY); raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(objects); if (intersects.length > 0) { isDragging = true; dragObject = intersects[0].object; // 计算点击位置和物体中心的位置偏移 const intersectPoint = intersects[0].point; dragOffset.copy(dragObject.position).sub(intersectPoint); // 选中状态同步更新 selectedObject = dragObject; selectedObject.material.emissive.setHex(0x992222); } });

这里为什么要记偏移?因为鼠标点到物体的位置通常不是物体中心。比如一个盒子,你点到它的右上角,如果直接把物体位置设为鼠标交点,物体会瞬间跳到中心对准鼠标,非常别扭。记录偏移量之后,物体移动时始终保持“抓哪里就跟着哪里走”的手感。

移动阶段是整个拖拽逻辑的重头戏。每一帧或者每次 mousemove,都要计算当前鼠标射线与 y=0 水平面的交点。Three.js 提供了现成的数学工具:

const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); const targetPoint = new THREE.Vector3(); renderer.domElement.addEventListener('mousemove', (event) => { updateMouse(event.clientX, event.clientY); raycaster.setFromCamera(mouse, camera); // 高亮检测 // ... 之前的高亮逻辑 ... if (isDragging && dragObject) { // 射线与水平面的交点 const hit = raycaster.ray.intersectPlane(dragPlane, targetPoint); if (hit) { // 加上按下时记录的偏移量 const newX = targetPoint.x + dragOffset.x; const newZ = targetPoint.z + dragOffset.z; // 边界限制 const limit = 9; dragObject.position.x = Math.max(-limit, Math.min(limit, newX)); dragObject.position.z = Math.max(-limit, Math.min(limit, newZ)); } } });

这里绕不开的一个问题是:为什么用intersectPlane而不是直接intersectObjects?如果直接用intersectObjects求交,射线会命中物体本身,物体位置更新后交点也跟着变,会出现抖动的死循环。而射线与水平面求交,拿到的是一个稳定可靠的“地面坐标”,物体跟着这个坐标走,就是我们要的效果。

THREE.Plane的参数也很讲究。new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)表示法线朝上的无限大水平面,也就是地面所在的平面。因为它是数学平面,而不是可见物体,所以射线能跟它精确求交,这是我们拖拽计算的关键。

抬起瞬间,释放拖拽状态:

window.addEventListener('mouseup', () => { isDragging = false; dragObject = null; });

注意监听事件放在 window 上,因为鼠标按下后可能移出 canvas 区域,如果只监听 renderer.domElement 的 mouseup,会出现移出画布后无法松开的问题。

3.4 限制几何体只能在地面上移动

基于上面这套逻辑,“限制物体在地面上移动”几乎不需要额外处理,因为你的位置来自地面平面。但有一个细节要重点说明:这个限制针对的是 x 和 z,y 值需要单独处理。

物体在拖拽过程中,拖拽目标点是地面上的点,y 为 0,而物体本身有高度。如果你直接把 targetPoint 赋值给 position,物体会被压到地面以下。所以要保留物体原来的 y,或者根据几何体尺寸重新计算 y,让它始终立在地面上:

if (hit) { const newX = targetPoint.x + dragOffset.x; const newZ = targetPoint.z + dragOffset.z; // 约束边界 dragObject.position.x = Math.max(-limit, Math.min(limit, newX)); dragObject.position.z = Math.max(-limit, Math.min(limit, newZ)); // 保持 y 不变,物体不会陷入地面 // 注意:这里的 y 是物体初始摆放时的高度 }

如果你希望拖拽过程中物体还能稍微抬高一点,比如拾起来再放下,可以加一个可选的 y 偏移量。核心思路不变,只是把 y 加入计算。工业数字孪生里做设备布局就经常需要这种分步逻辑:先抬起、再移动、最后放下,本质上都是围绕地面的数学平面在算,所以地基打牢了,后面扩展都很顺手。

3.5 和其他控制器(OrbitControls)的配合

Three.js 里做场景浏览,OrbitControls 几乎必装。但拖拽和旋转视角天然冲突,必须处理两者的协作。

最直接的做法是拖拽时关闭 OrbitControls,拖拽结束再开启。比如在 mousedown 中被判定为拾取到物体时,设置controls.enabled = false,在 mouseup 时重新设为 true。

controls.enabled = !isDragging; // 根据拖拽状态动态控制

这里有个更细的坑:如果你只是简单地在 mousedown 里关掉,即使鼠标点到空白区域,也会导致相机无法旋转。所以要区分“点到物体”和“点到空白区域”。点到空白区域时,应该让 OrbitControls 正常工作。

我的做法是设置一个标志位,在 mousedown 里检测到物体才关闭 controls,否则不动。这样鼠标操作既不会有冲突,体验也很自然:

renderer.domElement.addEventListener('mousedown', (event) => { // ... 射线检测 ... if (intersects.length > 0) { isDragging = true; controls.enabled = false; // ... } // 如果 intersects 为空,controls 保持开启,可正常旋转 }); window.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; controls.enabled = true; } });

还有一个细节是拖拽结束后的“点击”判定。有时候用户只想点一下选中物体,不想拖拽。这时可以在 mouseup 时判断鼠标位移是否超过某个像素阈值,比如 3 像素。没超过就算点击,超过了就算拖拽。具体到代码里,就是按下时记录初始鼠标坐标,抬起时计算距离。

4. 常见问题与排查技巧实录

4.1 点击没反应,多半是这三处

鼠标拾取不生效是最常见的问题,基本上可以分三类排查。

第一类是坐标换算错了。前面提到过,屏幕坐标转 NDC 时,y 方向要取反。如果你忘了负号,鼠标在屏幕上方时射线会打到场景下方,怎么点都命中不了。这个只要在 updateMouse 函数里打个 log 验证一下即可。

第二类是射线检测的目标问题。raycaster.intersectObjects(objects)只会检测传入数组里的物体。如果你后续把几何体塞进了另一个 group,但检测时只传了 group,没有递归检查子物体,就会漏掉。此时需要设置raycaster.intersectObjects(objects, true)开启递归检测。

第三类是地面提前挡住了射线。地面也是 Mesh,也在场景里,如果你把 ground 也加到检测数组里,射线必然会先命中地面。既要检测地面又不能让它干扰物体拾取,我的做法是把地面单独管理,不加入可拾取物体数组。地面不参与拾取,只负责视觉和求交计算。

4.2 物体拖拽时乱飞或穿模

拖拽时物体乱飞,最常见的原因是射线与平面求交时传错了平面方向。比如THREE.Plane的法线如果设置成(0, -1, 0),交点的方向就会反过来,物体就会跑到对称的位置去。

另一个原因是鼠标事件绑错了对象。mousemove 如果绑在 window 上,而 mousedown 绑在 canvas 上,两者坐标基准是一致的,没问题。但如果你在 mousemove 里用了 event.offsetX,而 canvas 不是从页面左上角开始的,坐标就会整体偏移。建议统一用 clientX 和 clientY,再通过 getBoundingClientRect 做一次修正:

const rect = renderer.domElement.getBoundingClientRect(); const clientX = event.clientX - rect.left; const clientY = event.clientY - rect.top;

穿模问题多半是物体在拖拽过程中把其他物体压住了,或者物体之间互相穿插。如果需要防止物体互相穿透,就要加更复杂的碰撞检测,比如用 AABB 包围盒做碰撞。这块没有万能的轻量解法,可以用THREE.Box3检测两个物体包围盒是否相交,再在拖拽时拒绝会导致相交的位置更新。对小场景来说,计算量不大,值得加上。

4.3 地面阴影不正常

阴影相关的坑也集中。第一个是忘了开renderer.shadowMap.enabled,阴影直接消失。第二个是方向光的castShadow没开,或者方向光位置设置太低,导致阴影范围不对。第三个是阴影贴图范围问题,dirLight.shadow.camera的默认范围可能很小,物体稍微远一点就超出了阴影计算范围,阴影被裁切。

调试方法如下:

dirLight.shadow.mapSize.width = 1024; dirLight.shadow.mapSize.height = 1024; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 50; dirLight.shadow.camera.left = -15; dirLight.shadow.camera.right = 15; dirLight.shadow.camera.top = 15; dirLight.shadow.camera.bottom = -15;

把阴影相机的范围设置得比场景范围大一圈,能保证所有物体都在阴影计算范围内。如果性能允许,mapSize 可以提高到 2048,阴影细节会好很多。

4.4 性能优化:射线检测的隐藏成本

当场景里只有三五个物体时,每次 mousemove 做一次intersectObjects毫无压力。但如果场景里有几百个物体,频繁拾取就会造成明显的卡顿。

优化思路有两个方向。第一是降低检测频率,比如用节流函数控制 mousemove 中射线检测的执行次数,每 2-3 帧检测一次。第二是分层检测,先做一个粗略的包围球检测,筛选出可能命中的物体,再对这个候选集合做精确检测。如果你有大量复杂模型,可以考虑引入 Three-mesh-bvh 这类库,把拾取性能提升一个数量级。不过对大多数地面几何体交互场景,把检测目标控制在一小批物体里就够了。

4.5 经验速查表

我把这个项目里所有关键配置整理成一张速查表,方便你排查问题:

功能关键配置容易踩的坑
场景new THREE.Scene()
相机PerspectiveCamera 近裁面 0.1,远裁面 1000近裁面太大导致地面被裁剪
渲染器shadowMap.enabled = true忘记开导致阴影消失
地面PlaneGeometry + rotateX(-Math.PI / 2)用节点旋转导致后续求交异常
阴影物体 castShadow,地面 receiveShadow少设置一个就看不到阴影
高亮emissive 属性控制自发光忘记恢复原值导致一直高亮
拖拽Raycaster + Plane 求交忘记存储偏移导致手感跳动
OrbitControls拖拽时 controls.enabled = false空白区域点击时也禁用导致无法旋转

写在最后:一些实际操作中的体会

前前后后做过好几个 Three.js 项目,从最简单的单页面 demo 到带资产管理和复杂交互的数字孪生系统,我最大的感受是:别急着套用现成库,先把 Raycaster 拾取这个基本功练扎实。表面上看这只是一个数学计算,但它决定了你对整个三维交互模型的理解深度。你能不能在物体嵌套的情况下准确定位?能不能让拖拽限制在任意倾斜平面?能不能做多选、框选?这些问题全部建立在拾取和求交的底层逻辑之上。

文章里用的这套方案,说白了就是“一条射线、一个平面、一个偏移量”,但就是这三个东西组合起来,支撑起了大部分编辑器类场景的需求。最后再分享一个小技巧:如果你想让物体在拖拽过程中有“吸附感”,比如把它对齐到网格,只需要在更新位置前做一次取整操作。这个操作加在 3.3 小节的 newX 和 newZ 计算之后即可。

const gridSize = 1; const snappedX = Math.round(newX / gridSize) * gridSize; const snappedZ = Math.round(newZ / gridSize) * gridSize;

地面上的几何体和鼠标控制,学会了之后你会发现,很多看似高深的功能,本质都是这些基础操作的排列组合。代码就放在那儿,跑起来,多试几次,手感自然会告诉你答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询