37 · 可定向的喷泉与粒子发射器

返回系列目录

需求与局部基底

喷嘴可以朝任意方向,发出的粒子在世界重力下运动。最清晰的设计是先在喷嘴局部坐标中生成速度,再转换到世界空间。设单位发射轴 f、切向轴 r、b,两两垂直,则:

vworld=r vx+b vy+f vz. v_{world}=r\,v_x+b\,v_y+f\,v_z.

Matrix3 的三列正好可以保存 r、b、f。选一个与 f 不平行的参考向量 a,令 r = normalize(a × f),b = f × r。如果 a 与 f 接近平行,叉积长度趋近零,因此根据点积切换参考轴。

随机分布不能只看“随机”二字

若需要在锥体立体角内均匀发射,不能让极角 θ 均匀随机,因为球面面积元为 sinθ dθ dφ。正确方法让 cosθ 在 [cosθmax, 1] 均匀,方位角 φ 在 [0,2π) 均匀。实心圆盘喷口的半径同理应取 R * sqrt(random()),而非 R * random(),否则粒子聚在中心。

完整核心代码

import { createDemo } from './runtime.js';

const { THREE, scene, onFrame } = createDemo();
const count = 600;
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
const ages = new Float32Array(count);
const geometry = new THREE.BufferGeometry();
const positionAttribute = new THREE.BufferAttribute(positions, 3);
positionAttribute.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute('position', positionAttribute);
const points = new THREE.Points(geometry, new THREE.PointsMaterial({ color: 0x66bbee, size: 0.07 }));
// 这个教学示例的粒子范围变化,关闭依赖静态包围球的裁剪。
points.frustumCulled = false;
scene.add(points, new THREE.GridHelper(20, 20));
const emitter = new THREE.Vector3(0, 0.2, 0);
const forward = new THREE.Vector3(0.4, 1, 0.2).normalize();
const reference = new THREE.Vector3(0, 1, 0);
if (Math.abs(reference.dot(forward)) > 0.95) reference.set(1, 0, 0);
const right = new THREE.Vector3().crossVectors(reference, forward).normalize();
const binormal = new THREE.Vector3().crossVectors(forward, right).normalize();
const basis = new THREE.Matrix3().set(
  right.x, binormal.x, forward.x,
  right.y, binormal.y, forward.y,
  right.z, binormal.z, forward.z
);
const velocity = new THREE.Vector3();
const lifetime = 2;
const cosineMin = Math.cos(THREE.MathUtils.degToRad(15));

function spawn(index) {
  const cosine = THREE.MathUtils.lerp(cosineMin, 1, Math.random());
  const sine = Math.sqrt(Math.max(0, 1 - cosine * cosine));
  const azimuth = Math.random() * Math.PI * 2;
  velocity.set(sine * Math.cos(azimuth), sine * Math.sin(azimuth), cosine);
  velocity.applyMatrix3(basis).multiplyScalar(5 + Math.random());
  const offset = index * 3;
  emitter.toArray(positions, offset);
  velocity.toArray(velocities, offset);
  ages[index] = 0;
}
for (let i = 0; i < count; i++) {
  spawn(i);
  // 负年龄表示尚未发射,启动时避免全部同时喷出。
  ages[i] = -Math.random() * lifetime;
}

onFrame((dt) => {
  for (let i = 0; i < count; i++) {
    const offset = i * 3;
    const previousAge = ages[i];
    ages[i] += dt;
    if (ages[i] < 0) continue;
    if (ages[i] > lifetime || positions[offset + 1] < 0) {
      spawn(i);
      continue;
    }
    const step = previousAge < 0 ? ages[i] : dt;
    // 恒定重力下的位置增量使用 v dt + 1/2 a dt²。
    positions[offset] += velocities[offset] * step;
    positions[offset + 1] += velocities[offset + 1] * step - 4.9 * step * step;
    positions[offset + 2] += velocities[offset + 2] * step;
    velocities[offset + 1] -= 9.8 * step;
  }
  positionAttribute.needsUpdate = true;
});

验证与性能取舍

将 forward 改成 (0,1,0),参考轴切换应防止 NaN。检查基底各列长度为 1、点积接近 0、行列式接近 +1;这确保随机速度长度不被意外缩放。扩大锥角,观察粒子仍受世界 −Y 重力影响,而不是喷嘴局部向下。

本例未发射的粒子暂时叠在喷口位置;若这影响视觉,可增加透明度 attribute 或把未激活粒子放在可裁剪区域。每帧循环没有创建 Vector3,缓冲数组一次创建重复使用。关闭裁剪避免错误消失但增加不可见绘制;已知速度、寿命和重力上限时可计算保守包围球替代。

几百个粒子适合 CPU 更新。大量粒子可把初始位置、速度、出生时间上传为 attribute,由顶点 Shader 解析计算 p0 + v0*t + 0.5*g*t²,避免每帧上传全部坐标。碰撞、交互和状态反馈较复杂时,需要额外数据结构或 GPU 状态模拟,不能只把循环机械搬进 Shader。

练习(暂不提供答案)

  1. 实现半径为 0.2 的均匀圆盘喷口,并映射到发射器基底。
  2. 比较均匀 θ 与均匀 cosθ 的粒子密度差异。
  3. 让喷嘴方向动态变化,保证已经发出的粒子不跟着旋转。
  4. 推导粒子的保守包围范围,重新启用视锥裁剪。