如何在Three.js与GLSL中实现粒子推离鼠标及重力回弹效果
问题根因定位
原有实现存在三个核心硬伤,直接导致异常效果:
- 推离方向逻辑完全错误:代码中直接用
position * dist做位置缩放,本质是把粒子往坐标系原点拉扯,根本没有计算「从鼠标指向粒子」的推离向量,因此当鼠标移动到球面内部时,距离差缩小,粒子自然会收缩到原点附近,完全没有向外推离的效果。 - 无状态缓存机制:每帧计算都直接从粒子原始位置出发硬切到目标位置,既没有留存粒子被推开后的位移值,也没有做阻尼过渡,因此效果是瞬间跳转,没有渐变和回弹感。
- 着色器语法错误:原有代码存在嵌套
main函数的硬语法问题,lerp函数定义位置不对,本身就不符合GLSL的编译规范。
正确实现方案
1. 推离逻辑的正确计算方式
推离效果的核心是保持方向正确、力度随距离衰减,不管鼠标在球面内还是球面外都统一计算:
- 方向计算:取「粒子位置 - 鼠标位置」的向量并归一化,作为推离的方向向量,这个方向永远是从鼠标指向粒子,不会反过来拉扯粒子。
- 范围判定:设定固定的有效推离半径(可根据场景尺寸调整),只有粒子和鼠标的距离小于这个半径时,才施加推力,超出范围推力直接为0。
- 力度衰减:距离鼠标越近,推离力度越大,公式为
推离力度 = (1 - 当前距离/有效推离半径) * 最大推离力度,刚好在鼠标位置时力度最大,到半径边缘时力度降到0。
2. 渐变与回弹效果的实现
要实现平滑过渡和重力回弹的效果,需要给每个粒子留存当前的位移偏移值,配合帧率无关的阻尼系数做插值:
- 给BufferGeometry添加自定义属性
aOffset,存储每个粒子当前被推开的偏移向量,初始值全为0(代表无偏移)。 - 每帧计算粒子应该达到的目标偏移值:如果在推离范围内,目标偏移就是推离方向乘以当前推离力度;不在范围内则目标偏移为0(回到原始位置)。
- 用指数插值做平滑过渡:推离状态下用稍快的插值系数让粒子快速跟上推力,没有推力时用稍慢的插值系数往原始位置回弹,配合帧间隔时间计算插值系数,保证不同帧率下动画速度一致。
3. 修正后的代码参考
JS 侧初始化补充
// 给粒子添加偏移属性,初始全为0 const particleCount = this.particleGeometry.attributes.position.count; const offsets = new Float32Array(particleCount * 3); this.particleGeometry.setAttribute('aOffset', new THREE.BufferAttribute(offsets, 3)); // 把可调参数加到uniform里 Object.assign(this.particleMaterial.uniforms, { pushRadius: { value: 2.0 }, // 有效推离半径 maxPushForce: { value: 1.2 }, // 最大推离力度 deltaTime: { value: 0 } // 帧间隔时间 });
顶点着色器修正
去掉嵌套main的语法错误,直接读取偏移值计算最终粒子位置:
uniform vec3 uMousePosition; attribute vec3 aOffset; void main() { // 粒子最终位置 = 原始位置 + 当前偏移 vec3 finalPos = position + aOffset; vec4 mvPosition = modelViewMatrix * vec4(finalPos, 1.0); gl_PointSize = 30.0 * (1.0 / -mvPosition.z); gl_Position = projectionMatrix * mvPosition; }
渲染循环中的偏移更新逻辑
const clock = new THREE.Clock(); // 每帧渲染循环里执行 const deltaTime = clock.getDelta(); this.particleMaterial.uniforms.deltaTime.value = deltaTime; const positionAttr = this.particleGeometry.attributes.position; const offsetAttr = this.particleGeometry.attributes.aOffset; const mousePos = this.mousePosition; const pushRadius = this.particleMaterial.uniforms.pushRadius.value; const maxPush = this.particleMaterial.uniforms.maxPushForce.value; for (let i = 0; i < positionAttr.count; i++) { // 取粒子原始位置 const px = positionAttr.getX(i); const py = positionAttr.getY(i); const pz = positionAttr.getZ(i); // 计算到鼠标的向量和距离 const dx = px - mousePos.x; const dy = py - mousePos.y; const dz = pz - mousePos.z; const dist = Math.sqrt(dx*dx + dy*dy + dz*dz); // 计算目标偏移 let targetX = 0, targetY = 0, targetZ = 0; if (dist < pushRadius && dist > 0.0001) { // 加极小值避免除0 const strength = (1 - dist / pushRadius) * maxPush; targetX = (dx / dist) * strength; targetY = (dy / dist) * strength; targetZ = (dz / dist) * strength; } // 取当前偏移值 const curX = offsetAttr.getX(i); const curY = offsetAttr.getY(i); const curZ = offsetAttr.getZ(i); // 阻尼插值:推离时响应快,回弹时响应慢 const isPushing = dist < pushRadius; const rate = isPushing ? 8.0 : 3.0; const lerpFactor = 1 - Math.exp(-rate * deltaTime); // 计算新的偏移 const newX = curX + (targetX - curX) * lerpFactor; const newY = curY + (targetY - curY) * lerpFactor; const newZ = curZ + (targetZ - curZ) * lerpFactor; offsetAttr.setXYZ(i, newX, newY, newZ); } offsetAttr.needsUpdate = true;
常见调试注意点
- 保证坐标空间一致:确认鼠标交点的坐标和粒子
position的坐标处于同一空间,如果粒子被放在变换过的Group里,需要将粒子位置乘上模型矩阵转到世界空间,再和鼠标坐标做距离计算,避免出现方向反向、距离偏差的问题。 - 不要随意截断距离值:之前代码里用
min(distance(...), 1.)的操作会把所有大于阈值的距离强制设为1,直接破坏推离范围的判定逻辑。 - 方向不要搞反:推离方向必须是「粒子减鼠标」的向量,反过来的话效果会变成粒子被吸向鼠标,和预期完全相反。
内容的提问来源于stack exchange,提问作者marks
相关产品推荐
相关产品推荐

