WebGPU Picking(WebGPU 拾取)
原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-picking.html
本文是对《WebGPU Picking》一文的深度解读与编译,非全盘翻译。原文是 3D 编辑器系列文章的第 3 篇,前两篇分别介绍了高亮(Highlighting)与相机控制(Camera Controls)。本文聚焦于拾取(Picking)——在屏幕上点击并判断哪个物体被选中的技术。
一、CPU 拾取:逆向投影与射线求交
1.1 基本思路
在 3D 数学系列中,我们使用矩阵将顶点从模型空间投影到裁剪空间。拾取则反过来:将屏幕点击位置转换为裁剪空间坐标,再用视图投影矩阵的逆矩阵将其变换回模型空间。这样就能在模型空间中构造一条从近裁剪面到远裁剪面的射线,再检测该射线与哪些物体的三角形相交。
CPU 拾取的核心流程:
- 监听
pointerup,若指针未移动则触发拾取; - 将屏幕坐标转换为裁剪空间
clipX、clipY; - 使用视图投影矩阵的逆矩阵,将裁剪空间近点(z=0)和远点(z=1)变换到模型空间;
- 遍历每个 mesh 的所有三角形,使用 Möller–Trumbore 算法检测线段与三角形的交点;
- 将所有命中结果按深度排序,返回最近命中或循环拾取。
1.2 关键代码:getIntersectingMeshes
function getIntersectingMeshes(clipX, clipY, viewProjection) {
const clipNear = [clipX, clipY, 0];
const clipFar = [clipX, clipY, 1];
// 临时数学变量
const worldViewProjection = mat4.identity();
const mat = mat4.identity();
const near = vec3.create();
const far = vec3.create();
const verts = [
vec3.create(),
vec3.create(),
vec3.create(),
];
const intersectingMeshes = [];
for (const mesh of meshes) {
// 将视图投影矩阵与模型矩阵组合
mat4.multiply(viewProjection, mesh.node.worldMatrix, worldViewProjection);
// 求逆,用于将裁剪空间坐标变换到模型空间
mat4.inverse(worldViewProjection, mat);
// 将裁剪空间近、远点变换到模型空间
vec3.transformMat4(clipNear, mat, near);
vec3.transformMat4(clipFar, mat, far);
const { vertexData, numVertices, aabb } = mesh.vertices;
// 先进行 AABB 快速剔除(优化)
if (!intersectSegmentAABB(near, far, aabb)) {
continue;
}
const numTriangles = numVertices / 3;
let closest;
for (let t = 0; t < numTriangles; ++t) {
// 获取三角形三个顶点
verts.forEach((v, i) => {
const offset = (t * 3 + i) * 4;
v[0] = vertexData[offset + 0];
v[1] = vertexData[offset + 1];
v[2] = vertexData[offset + 2];
});
const result = intersectLineSegmentAndTriangle(near, far, ...verts);
if (result) {
// 变换回裁剪空间以比较深度
vec3.transformMat4(result, worldViewProjection, result);
if (closest === undefined || result[2] < closest[2]) {
closest = result;
}
}
}
if (closest !== undefined) {
intersectingMeshes.push({
position: closest,
mesh,
});
}
}
return intersectingMeshes;
}
1.3 射线-三角形相交:Möller–Trumbore 算法
// https://en.wikipedia.org/wiki/M%C3%B6ller%E2%80%93Trumbore_intersection_algorithm
function intersectLineSegmentAndTriangle(p0, p1, v0, v1, v2) {
const edge1 = vec3.subtract(v1, v0);
const edge2 = vec3.subtract(v2, v0);
const dir = vec3.subtract(p1, p0); // 线段方向
const h = vec3.cross(dir, edge2);
const a = vec3.dot(edge1, h);
// 若 a 接近 0,直线与三角形平面平行
if (Math.abs(a) < 0.00001) {
return undefined;
}
const f = 1 / a;
const s = vec3.subtract(p0, v0);
const u = f * vec3.dot(s, h);
// 检查交点在三角形的 U 参数范围 [0,1] 之外
if (u < 0.0 || u > 1.0) {
return undefined;
}
const q = vec3.cross(s, edge1);
const v = f * vec3.dot(dir, q);
// 检查交点在 V 参数范围 [0,1] 或 U+V 范围 [0,1] 之外
if (v < 0.0 || u + v > 1.0) {
return undefined;
}
// 此时交点位于无限直线上且在三角形内部
const t = f * vec3.dot(edge2, q);
// 检查交点是否在线段 T 参数范围 [0,1] 内
if (t < 0.0 || t > 1.0) {
return undefined;
}
// 返回交点
return vec3.addScaled(p0, dir, t);
}
1.4 AABB 加速检测
为了加速,在遍历三角形之前先检测射线是否与 mesh 的轴对齐包围盒(AABB)相交。若不相交,直接跳过该 mesh。
// 分支式 slab 射线/AABB 相交(Williams et al.)
// 注意:未针对 JS 优化
const kEpsilon = 1e-12;
function intersectSegmentAABB(p0, p1, aabb) {
const delta = vec3.subtract(p1, p0);
const invDelta = delta.map(v =>
1 / (Math.abs(v) > kEpsilon ? v : Math.sign(v) * kEpsilon));
const t0 = vec3.multiply(vec3.subtract(aabb.min, p0), invDelta);
const t1 = vec3.multiply(vec3.subtract(aabb.max, p0), invDelta);
const min = vec3.min(t0, t1);
const max = vec3.max(t0, t1);
const tMin = Math.max(0, ...min);
const tMax = Math.min(1, ...max);
for (let c = 0; c < 3; ++c) {
if (Math.abs(delta[c]) <= kEpsilon &&
(p0[c] < aabb.min[c] || p0[c] > aabb.max[c])) {
return undefined;
}
}
return tMin > tMax
? undefined
: { tMin, tMax };
}
CPU 拾取的优势是实现相对直观,但缺点是需要将顶点数据保留在 CPU 侧,且无法自动跟随 GPU 上的顶点变换(如蒙皮、变形目标等),除非在 CPU 上重现这些逻辑。
二、GPU 拾取:渲染 ID 纹理
2.1 原理
GPU 拾取的核心思路是:不渲染物体的颜色,而是渲染每个物体的整数 ID。渲染完成后,读取鼠标指针下方像素对应的纹素(texel),该值即为被点击物体的 ID。
为此需要:
- 在 uniform 中添加
id字段; - 创建一个新的片段着色器
fsPicking,输出vec4u格式的 ID(使用r32uint纹理格式); - 手动创建
bindGroupLayout和pipelineLayout,以便同一个绑定组可用于颜色渲染和 ID 渲染两个管线; - 将渲染逻辑重构为
renderToTexture,以便复用同一套绘制代码输出到普通画布纹理或拾取纹理; - 使用
pick函数读取指定像素的 ID,并实现点击循环拾取。
2.2 着色器修改
struct Uniforms {
matrix: mat4x4f,
color: vec4f,
id: u32, // 新增 ID
};
struct Vertex {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = uni.matrix * vert.position;
vsOut.color = vert.color;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vsOut.color * uni.color;
}
@fragment fn fsPicking(vsOut: VSOutput) -> @location(0) vec4u {
return vec4u(uni.id);
}
注意:由于颜色渲染和 ID 渲染的片段着色器不同,但需要共享相同的 uniform 缓冲区,因此必须手动创建 bind group layout 和 pipeline layout,而不能依赖 layout: 'auto'。
2.3 手动绑定组布局与拾取管线
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { minBindingSize: 96 },
},
],
});
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout],
});
const pickPipeline = device.createRenderPipeline({
label: '2 attributes with id for picking',
layout: pipelineLayout,
vertex: {
module,
buffers: [
{
arrayStride: (4) * 4, // (3) floats 4 bytes each + one 4 byte color
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x3'}, // position
{shaderLocation: 1, offset: 12, format: 'unorm8x4'}, // color
],
},
],
},
fragment: {
module,
entryPoint: 'fsPicking',
targets: [{ format: 'r32uint' }], // 使用整数纹理格式
},
primitive: {
cullMode: 'back',
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus',
},
});
2.4 Uniform 缓冲区增加 ID 字段
const objectInfos = [];
function createObjectInfo() {
// matrix, color, id, padding
const uniformBufferSize = (16 + 4 + 1 + 3) * 4;
const uniformBuffer = device.createBuffer({
label: 'uniforms',
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const uniformValues = new Float32Array(uniformBufferSize / 4);
const asU32 = new Uint32Array(uniformValues.buffer);
// 在 float32 索引中的偏移
const kMatrixOffset = 0;
const kColorOffset = 16;
const kIdOffset = 20;
const matrixValue = uniformValues.subarray(kMatrixOffset, kMatrixOffset + 16);
const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4);
const idValue = asU32.subarray(kIdOffset, kIdOffset + 1);
const bindGroup = device.createBindGroup({
label: 'bind group for object',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: uniformBuffer },
],
});
return {
uniformBuffer,
uniformValues,
colorValue,
matrixValue,
idValue,
bindGroup,
};
}
2.5 渲染逻辑重构:renderToTexture
let depthTexture;
let postTexture;
let objectNdx = 0;
function drawObject(ctx, vertices, matrix, color) {
const { pass, viewProjectionMatrix } = ctx;
const { vertexBuffer, numVertices } = vertices;
if (objectNdx === objectInfos.length) {
objectInfos.push(createObjectInfo());
}
const {
matrixValue,
colorValue,
idValue,
uniformBuffer,
uniformValues,
bindGroup,
} = objectInfos[objectNdx++];
mat4.multiply(viewProjectionMatrix, matrix, matrixValue);
colorValue.set(color);
idValue[0] = objectNdx;
// 上传 uniform 值
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup);
pass.draw(numVertices);
}
function renderToTexture(
encoder, target, pipeline, viewProjectionMatrix, meshes) {
objectNdx = 0;
renderPassDescriptor.colorAttachments[0].view = target.createView();
depthTexture = makeNewTextureIfSizeDifferent(
depthTexture,
target, // for size
'depth24plus',
GPUTextureUsage.RENDER_ATTACHMENT,
);
renderPassDescriptor.depthStencilAttachment.view = depthTexture.createView();
{
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
const ctx = { pass, viewProjectionMatrix };
for (const mesh of meshes) {
drawMesh(ctx, mesh);
}
pass.end();
}
}
2.6 读取像素 ID:pick 函数
const pickBuffer = device.createBuffer({
size: 4,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
let pickTexture;
async function pick(clipX, clipY, viewProjectionMatrix, pickableMeshes) {
const x = Math.floor((clipX * 0.5 + 0.5) * canvas.width);
const y = Math.floor((clipY * -0.5 + 0.5) * canvas.height);
const encoder = device.createCommandEncoder();
pickTexture = makeNewTextureIfSizeDifferent(
pickTexture,
canvas, // for size
'r32uint',
GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
);
renderToTexture(
encoder,
pickTexture,
pickPipeline,
viewProjectionMatrix,
pickableMeshes,
);
// 将指针下的纹素复制到 pickBuffer
encoder.copyTextureToBuffer(
{ texture: pickTexture, origin: [x, y] },
{ buffer: pickBuffer },
[1, 1]
);
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
// 从 pickBuffer 读取值
await pickBuffer.mapAsync(GPUMapMode.READ);
const id = new Uint32Array(pickBuffer.getMappedRange())[0];
pickBuffer.unmap();
return id;
}
2.7 循环拾取:逐个排除已选对象
GPU 拾取默认只能得到最前面的物体。为了模拟 CPU 版本中的循环拾取(即同一位置多次点击依次选中后面的物体),我们可以维护一个 pickableMeshes 数组,每次拾取后从数组中移除被选中的 mesh,下次渲染拾取纹理时就不绘制它,这样就能得到其后面的物体。如果某次没有命中,则重置回完整 mesh 列表再尝试一次。
let lastPickX;
let lastPickY;
let pickableMeshes;
async function pickMeshes(e, cam) {
// 如果还没有可用列表,或者指针移动了
if (!pickableMeshes ||
lastPickX !== e.clientX ||
lastPickY !== e.clientY) {
lastPickX = e.clientX;
lastPickY = e.clientY;
// 获取所有 mesh
pickableMeshes = meshes.slice();
}
const rect = e.target.getBoundingClientRect();
const clipX = (e.clientX - rect.left) / e.target.clientWidth * 2 - 1;
const clipY = (e.clientY - rect.top ) / e.target.clientHeight * -2 + 1;
const viewProjectionMatrix = getViewProjectionMatrix(cam, canvas);
// 从可用 mesh 中拾取
let id = await pick(clipX, clipY, viewProjectionMatrix, pickableMeshes);
if (id === 0) {
// 若没找到,重置所有 mesh 再试一次
pickableMeshes = meshes.slice();
id = await pick(clipX, clipY, viewProjectionMatrix, pickableMeshes);
// 仍没找到则说明指针下没有物体
if (id === 0) {
setCurrentSceneGraphNode(undefined);
return;
}
}
// 移除被选中的 mesh 并获取其节点
let node = pickableMeshes.splice(id - 1, 1)[0].node;
if (!settings.showMeshNodes) {
while (node.name.includes('mesh')) {
node = node.parent;
}
}
setCurrentSceneGraphNode(node);
}
三、GPU 拾取的优点
与 CPU 拾取相比,GPU 拾取具有以下优势:
-
自动应用所有 GPU 顶点特效
例如蒙皮(Skinning)、变形目标(Blend Targets)通常仅在 GPU 上计算,CPU 拾取需要重现这些逻辑,而 GPU 拾取天然支持。 -
可结合透明度
若物体使用了带透明区域的纹理,GPU 拾取可以在片段着色器中检查 alpha 值并discard掉透明部分,从而实现“穿透”拾取,符合用户直觉。 -
无需在 CPU 侧保留顶点数据
拾取完全在 GPU 上完成,不必关心顶点格式、步长等,也避免了 CPU 内存占用。
四、3 个核心优化要点
原文在文末给出了三个相对简单的优化方向,当前作为读者练习。这里提炼并解读:
优化 1:使用裁剪矩形(Scissor Rect)限定渲染区域
在渲染拾取纹理时,调用 pass.setScissorRect(x, y, 1, 1) 只渲染指针下的一个像素。这样 GPU 无需渲染整幅画布大小的 ID 纹理,极大减少光栅化和片段着色器的负载。
优化 2:视锥剔除(Frustum Culling)减少不必要的绘制
在渲染拾取纹理之前,判断物体是否位于相机视锥体内(或使用更精细的“潜在可见集” culling)。若物体完全不在视锥内,则跳过该物体。这不仅对拾取有益,对正常渲染同样重要。配合优化 1,可以进一步减少无效绘制调用。
优化 3:使用 1×1 纹理和自定义投影矩阵
可以为指针所在像素构造一个只包含该像素对应视锥的投影矩阵,然后渲染到 1×1 的拾取纹理。这样做有两个好处:
- 内存占用极低(仅 1 像素的纹理,而非画布大小);
- 配合视锥剔除,该微型视锥能剔除更多物体,进一步减少渲染工作量。
五、GLSL/WebGL 专业术语检查
本文涉及的技术栈为 WebGPU / WGSL,并非 WebGL / GLSL。在术语翻译和解释中,需注意以下对应关系与标准译名:
| 原文术语 | 标准中文译名 | 备注 |
|---|---|---|
| WebGPU | WebGPU | 新一代 Web 图形/计算 API,与 WebGL 不同 |
| WGSL | WGSL | WebGPU Shading Language,WebGPU 专用着色语言 |
| GLSL | GLSL | OpenGL/WebGL 着色语言,本文未使用 |
| Fragment Shader | 片段着色器 | WGSL 中为 @fragment 阶段 |
| Vertex Shader | 顶点着色器 | WGSL 中为 @vertex 阶段 |
| Bind Group | 绑定组 | WebGPU 中组织资源绑定的对象 |
| Bind Group Layout | 绑定组布局 | 定义绑定组结构的对象 |
| Pipeline Layout | 管线布局 | 定义管线使用哪些绑定组布局 |
| Render Pass | 渲染通道 | WebGPU 的渲染执行单元 |
| Render Pipeline | 渲染管线 | 包含顶点/片段着色器、混合状态等 |
| Texture Format | 纹理格式 | 如 r32uint、depth24plus 等 |
| Texel | 纹素 | 纹理中的单个像素 |
| Scissor Rect | 裁剪矩形 | 限定渲染输出区域的矩形 |
| Frustum Culling | 视锥剔除 | 剔除视锥外物体的技术 |
| AABB | 轴对齐包围盒 | Axis-Aligned Bounding Box |
| Clip Space | 裁剪空间 | 投影后的坐标空间 |
| Möller–Trumbore algorithm | Möller–Trumbore 射线-三角形相交算法 | 一种快速三角形相交测试算法 |
| Uniform Buffer | 统一缓冲区 | GPU 常量数据缓冲区 |
| Storage Buffer | 存储缓冲区 | 可读写的 GPU 缓冲区 |
注意:由于本文基于 WebGPU,所有代码均为 JavaScript + WGSL,并不涉及 GLSL。若读者从 WebGL 背景迁移,需注意 WGSL 与 GLSL 语法差异,例如
@location、@builtin(position)、var<uniform>等声明方式。
结语
WebGPU Picking 提供了两种可行方案:CPU 拾取和 GPU 拾取。CPU 拾取实现简单,适合原型验证,但在复杂场景中容易出现性能瓶颈且难以跟上 GPU 顶点效果;GPU 拾取更加现代、高效,能天然支持 GPU 上的顶点变换与透明度,是生产级 3D 编辑器的推荐做法。通过应用文末提出的三个优化,可以进一步提升拾取性能。
希望这篇深度解读能帮助你理解 WebGPU 拾取的实现精髓。如需实践,请参考原文链接中的交互示例,并结合本文代码进行实验。
