37 · 可定向的喷泉与粒子发射器
需求与局部基底
喷嘴可以朝任意方向,发出的粒子在世界重力下运动。最清晰的设计是先在喷嘴局部坐标中生成速度,再转换到世界空间。设单位发射轴 f、切向轴 r、b,两两垂直,则:
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。
练习(暂不提供答案)
- 实现半径为 0.2 的均匀圆盘喷口,并映射到发射器基底。
- 比较均匀 θ 与均匀 cosθ 的粒子密度差异。
- 让喷嘴方向动态变化,保证已经发出的粒子不跟着旋转。
- 推导粒子的保守包围范围,重新启用视锥裁剪。