WebGPU计算着色器实现3D纹理实时粒子流体碰撞的技术方案
在 Web 端的粒子流体模拟(如 SPH 或 PBF)中,高频、大规模的粒子与复杂三维场景的碰撞检测一直是性能瓶颈。传统的基于三角网格的碰撞检测算法复杂度高,很难在 GPU 上实现实时的并行处理。
利用 WebGPU 的 Compute Shader(计算着色器) 与 3D 纹理(Texture3D),我们可以将复杂的场景静态障碍物转化为 有向距离场(Signed Distance Field, SDF) 并存储在 3D 纹理中。通过这种方式,粒子与障碍物的碰撞检测可以降维到 $O(1)$ 的时间复杂度。
本文将详细介绍这一方案的实现原理,并提供完整的 WGSL(WebGPU Shading Language)计算着色器实现代码。
核心技术架构
整个碰撞检测与响应流程可以分为三个核心步骤:
- 场景体素化与 SDF 生成(离线或初始化时):将 3D 网格模型转换为 SDF 数据。每个体素存储一个浮点数,表示该点到最近网格表面的距离(负值在内部,正值在外部)。
- 3D 纹理载入:在 WebGPU 中创建
dimension: '3d'的纹理,将 SDF 数据写入其中。 - 计算着色器寻址与碰撞响应:在计算着色器中,读取粒子当前的三维世界坐标,将其映射到 3D 纹理的 UVW 坐标系,采样获取距离值。若距离小于粒子半径,则利用中心差分法计算 SDF 的梯度作为碰撞法线,并修正粒子的位置与速度。
[粒子物理更新] -> [世界坐标映射为UVW] -> [采样3D SDF纹理] -> [距离 d < 半径r ?]
| (是)
v
[计算位置修正与反射速度] <- [计算SDF梯度得到法线N] <------------------------+
WebGPU 端 3D 纹理初始化
在 JavaScript 端,我们需要配置一个 3D 纹理。由于计算着色器(Compute Stage)需要采样该纹理,我们需要为其设置合适的用法。
需要注意的是,在 WebGPU 中,并非所有的纹理格式都支持在计算着色器中进行双线性/三线性过滤。如果使用 r32float 格式存储高精度 SDF,在 Compute Shader 中通常只能进行点采样(Point Sample)。如果需要平滑的碰撞响应,我们可以在着色器中手动实现三线性插值(Trilinear Interpolation),或者采用支持过滤的 rgba8unorm 格式将距离归一化编码。
以下是创建 3D SDF 纹理的典型配置:
const sdfTexture = device.createTexture({
size: [64, 64, 64], // SDF 体素分辨率
dimension: '3d',
format: 'r32float', // 存储单精度浮点数距离
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
WGSL 计算着色器实现
以下是实现粒子物理更新与 3D 纹理碰撞检测的 WGSL 核心代码。
我们使用**中心差分法(Central Differences)**在 3D 纹理中实时重建碰撞法线,这种方法不需要预先存储法线向量,极大节省了显存带宽。
struct Particle {
position: vec3<f32>,
velocity: vec3<f32>,
radius: f32,
mass: f32,
}
struct SimParams {
deltaTime: f32,
boundaryMin: vec3<f32>,
boundaryMax: vec3<f32>,
restitution: f32, // 弹性碰撞系数
friction: f32, // 摩擦系数
}
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@group(0) @binding(1) var<uniform> params: SimParams;
// 绑定 3D SDF 纹理。此处使用无过滤的纹理,在着色器内手动或通过特定格式采样
@group(0) @binding(2) var sdfTex: texture_3d<f32>;
@group(0) @binding(3) var texSampler: sampler;
// 将世界坐标映射到 3D 纹理的 [0, 1] UVW 空间
fn worldToUVW(pos: vec3<f32>) -> vec3<f32> {
let size = params.boundaryMax - params.boundaryMin;
return (pos - params.boundaryMin) / size;
}
// 计算 SDF 梯度(即碰撞法线向量)
// 使用中心差分法,对当前点周围进行 6 次采样
fn getSdfNormal(uvw: vec3<f32>, delta: f32) -> vec3<f32> {
let dx = vec3<f32>(delta, 0.0, 0.0);
let dy = vec3<f32>(0.0, delta, 0.0);
let dz = vec3<f32>(0.0, 0.0, delta);
let nx = textureSampleLevel(sdfTex, texSampler, uvw + dx, 0.0).r -
textureSampleLevel(sdfTex, texSampler, uvw - dx, 0.0).r;
let ny = textureSampleLevel(sdfTex, texSampler, uvw + dy, 0.0).r -
textureSampleLevel(sdfTex, texSampler, uvw - dy, 0.0).r;
let nz = textureSampleLevel(sdfTex, texSampler, uvw + dz, 0.0).r -
textureSampleLevel(sdfTex, texSampler, uvw - dz, 0.0).r;
return normalize(vec3<f32>(nx, ny, nz));
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
let index = global_id.x;
// 防止越界
if (index >= arrayLength(&particles)) {
return;
}
var p = particles[index];
// 1. 基础物理积分更新
p.position += p.velocity * params.deltaTime;
// 2. 转换坐标并采样 SDF
let uvw = worldToUVW(p.position);
// 边界安全检查,防止粒子飞出 SDF 范围
if (all(uvw >= vec3<f32>(0.0)) && all(uvw <= vec3<f32>(1.0))) {
// 采样当前的有向距离值
let sdfValue = textureSampleLevel(sdfTex, texSampler, uvw, 0.0).r;
// 如果距离值小于粒子半径,说明发生碰撞(SDF 负值代表物体内部)
if (sdfValue < p.radius) {
// 设定采样差分步长(对应体素大小,此处假设体素分辨率为 64)
let delta = 1.0 / 64.0;
let normal = getSdfNormal(uvw, delta);
// 穿透深度
let penetration = p.radius - sdfValue;
// 3. 碰撞响应:位置修正(Push-out),防止粒子卡在障碍物内部
p.position += normal * penetration;
// 4. 碰撞响应:速度修正
// 沿法线方向的分量
let vNormal = dot(p.velocity, normal);
if (vNormal < 0.0) { // 确保粒子正在向内运动时才进行响应
// 拆分速度为法向和切向
let velN = normal * vNormal;
let velT = p.velocity - velN;
// 应用弹性和摩擦力
// 法向反弹,切向受摩擦力衰减
p.velocity = -velN * params.restitution + velT * (1.0 - params.friction);
}
}
}
// 将更新后的粒子状态写回 Buffer
particles[index] = p;
}
核心工程细节与优化
1. 显存对齐与 Padding 限制
在 WGSL 中,struct Particle 的定义必须严格遵守布局对齐法则。例如,vec3<f32> 会被自动对齐到 16 字节(等同于 vec4)。
为了防止计算着色器读取数据发生偏移错位,建议显式补齐结构体:
struct Particle {
position: vec3<f32>,
radius: f32, // 刚好把 position 的第 4 个分量填满(16字节对齐)
velocity: vec3<f32>,
mass: f32, // 填满 velocity 的第 4 个分量
}
对应的 JS 端 Float32Array 数据流也应当按照每 8 个 float(32 字节)为一个粒子进行打包和传输。
2. 手动三线性插值(Trilinear Interpolation)
若 WebGPU 设备不支持 r32float 格式的纹理过滤,默认的 textureSampleLevel 会退化为最近邻采样(Nearest Neighbor),导致粒子在物体表面移动时出现明显的“锯齿状”碰撞颤抖。
为了获得平滑的法线和距离反馈,可在 WGSL 中手动实现采样插值:
fn sampleSDFTrilinear(uvw: vec3<f32>) -> f32 {
let texSize = vec3<f32>(textureDimensions(sdfTex));
let texelCoord = uvw * texSize - vec3<f32>(0.5);
let i0 = vec3<u32>(floor(texelCoord));
let i1 = i0 + vec3<u32>(1u);
let f = frac(texelCoord);
// 采样相邻的 8 个体素节点
let c000 = textureLoad(sdfTex, i0, 0).r;
let c100 = textureLoad(sdfTex, vec3<u32>(i1.x, i0.y, i0.z), 0).r;
let c010 = textureLoad(sdfTex, vec3<u32>(i0.x, i1.y, i0.z), 0).r;
let c110 = textureLoad(sdfTex, vec3<u32>(i1.x, i1.y, i0.z), 0).r;
let c001 = textureLoad(sdfTex, vec3<u32>(i0.x, i0.y, i1.z), 0).r;
let c101 = textureLoad(sdfTex, vec3<u32>(i1.x, i0.y, i1.z), 0).r;
let c011 = textureLoad(sdfTex, vec3<u32>(i0.x, i1.y, i1.z), 0).r;
let c111 = textureLoad(sdfTex, i1, 0).r;
// 进行三线性插值
let c00 = mix(c000, c100, f.x);
let c01 = mix(c001, c101, f.x);
let c10 = mix(c010, c110, f.x);
let c11 = mix(c011, c111, f.x);
let c0 = mix(c00, c10, f.y);
let c1 = mix(c01, c11, f.y);
return mix(c0, c1, f.z);
}
在 GPU 中,手动插值虽然增加了指令数,但由于规避了硬件过滤限制,能在更广泛的 Web 端设备上保持平滑的物理反馈。
3. 时间步长(Time Stepping)与子步(Sub-stepping)
在高速粒子碰撞中,若 deltaTime 过大,粒子可能会在单帧内直接穿透整个 SDF 屏障(即隧道效应,Tunneling)。
为了保证系统的稳定性,建议在 Compute pipeline 中实行子步迭代(Sub-stepping)。即每一帧在 CPU 端提交 Compute Pass 时,将物理循环拆解为 2~4 次微小子步执行,能够极大地改善高频振荡和穿透漏怪的问题。
总结
利用 WebGPU 3D 纹理存储有向距离场,可以使海量流体粒子获得低成本、高精度的实时环境碰撞响应能力。在具体工程实践中,开发者需要特别注意 WebGPU 的内存对齐要求,并根据目标平台的硬件兼容性灵活选择内置采样器或手动三线性插值,以确保物理模拟画面的平滑与稳定。