WebGPU 高亮效果:描边与选中态实现
原作者: webgpufundamentals Contributors
原始链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-highlighting.html
本文是“制作 3D 编辑器”系列的第一篇。每篇文章都建立在上一篇的基础上,因此按顺序阅读会更容易理解。本文假设你已经阅读过场景图(Scene Graphs)和后处理(Post Processing)两篇文章。
系列目录:
- 高亮(Highlighting) ⬅ 你在这里
- 相机控制(Camera Controls)
- 拾取(Picking)
假设我们想制作一个简单的 3D 编辑器,灵感来自 Blender、Maya、Unity 或 Unreal,能够选择并操作 3D 对象。在场景图文章中我们已经有了节点,可以通过 UI 按钮选择某个节点并编辑其平移、旋转和缩放。如果能直观地看到哪个节点被选中就更好了,本文就来实现这个功能。
从首次添加节点选择能力的示例开始,我们有一个这样的场景:
为了高亮选中的对象,我们可以将选中的对象单独渲染到一个纹理中。
该纹理的 alpha 值会形成选中对象的剪影。
然后我们可以把这个 alpha 遮罩作为后处理阶段的输入,当遮罩的 alpha 为 0 但附近存在非零值时绘制高亮颜色,从而得到轮廓效果。
下面是一个类似后处理的着色器,它根据 alpha 遮罩绘制轮廓:
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) texcoord: vec2f,
};
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32,
) -> VSOutput {
var pos = array(
vec2f(-1.0, -1.0),
vec2f(-1.0, 3.0),
vec2f( 3.0, -1.0),
);
var vsOutput: VSOutput;
let xy = pos[vertexIndex];
vsOutput.position = vec4f(xy, 0.0, 1.0);
vsOutput.texcoord = xy * vec2f(0.5, -0.5) + vec2f(0.5);
return vsOutput;
}
@group(0) @binding(0) var mask: `texture_2d<f32>`;
fn isOnEdge(pos: vec2i) -> bool {
// 注意:我们需要确保不会使用超出边界的
// 纹理坐标调用 textureLoad,因为那样
// 不同 GPU 上会返回不同的结果
let size = vec2i(textureDimensions(mask, 0));
let start = max(pos - 2, vec2i(0));
let end = min(pos + 2, size);
for (var y = start.y; y <= end.y; y++) {
for (var x = start.x; x <= end.x; x++) {
let s = textureLoad(mask, vec2i(x, y), 0).a;
if (s > 0) {
return true;
}
}
}
return false;
};
@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
let pos = vec2i(fsInput.position.xy);
// 获取当前纹素。
// 如果不为 0,则位于被选中对象内部
let s = textureLoad(mask, pos, 0).a;
if (s > 0) {
discard;
}
let hit = isOnEdge(pos);
if (!hit) {
discard;
}
return vec4f(1, 0.5, 0, 1); // 橙色
}
着色器首先检查遮罩中当前像素是否大于 0。如果是,表示它在遮罩内部(即选中对象区域),因此我们不想绘制任何内容,于是调用 discard 丢弃该片段。
否则,调用 isOnEdge 检查邻近像素。如果附近没有任何像素大于 0,则说明不在边缘,也通过 discard 丢弃。
否则我们就在边缘,绘制橙色。
现在有了着色器,我们还需要来自后处理文章的后处理设置代码:
const postProcessModule = device.createShaderModule({
code: /* wgsl */ `
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) texcoord: vec2f,
};
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32,
) -> VSOutput {
var pos = array(
vec2f(-1.0, -1.0),
vec2f(-1.0, 3.0),
vec2f( 3.0, -1.0),
);
var vsOutput: VSOutput;
let xy = pos[vertexIndex];
vsOutput.position = vec4f(xy, 0.0, 1.0);
vsOutput.texcoord = xy * vec2f(0.5, -0.5) + vec2f(0.5);
return vsOutput;
}
@group(0) @binding(0) var mask: `texture_2d<f32>`;
fn isOnEdge(pos: vec2i) -> bool {
// 注意:我们需要确保不会使用超出边界的
// 纹理坐标调用 textureLoad,因为那样
// 不同 GPU 上会返回不同的结果
let size = vec2i(textureDimensions(mask, 0));
let start = max(pos - 2, vec2i(0));
let end = min(pos + 2, size);
for (var y = start.y; y <= end.y; y++) {
for (var x = start.x; x <= end.x; x++) {
let s = textureLoad(mask, vec2i(x, y), 0).a;
if (s > 0) {
return true;
}
}
}
return false;
};
@fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f {
let pos = vec2i(fsInput.position.xy);
// 获取当前纹素。如果不为 0,则位于被选中对象内部
let s = textureLoad(mask, pos, 0).a;
if (s > 0) {
discard;
}
let hit = isOnEdge(pos);
if (!hit) {
discard;
}
return vec4f(1, 0.5, 0, 1);
}
`,
});
const postProcessPipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module: postProcessModule },
fragment: {
module: postProcessModule,
targets: [ { format: presentationFormat }],
},
});
- const postProcessSampler = device.createSampler({
- minFilter: 'linear',
- magFilter: 'linear',
- });
const postProcessRenderPassDescriptor = {
label: 'post process render pass',
colorAttachments: [
- { loadOp: 'clear', storeOp: 'store' },
+ { loadOp: 'load', storeOp: 'store' },
],
};
- let renderTarget;
let postProcessBindGroup;
+ let lastPostProcessTexture;
function setupPostProcess(texture) {
- if (renderTarget?.width === canvasTexture.width &&
- renderTarget?.height === canvasTexture.height) {
- return;
- }
-
- renderTarget?.destroy();
- renderTarget = device.createTexture({
- size: canvasTexture,
- format: 'rgba8unorm',
- usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
- });
- const renderTargetView = renderTarget.createView();
- renderPassDescriptor.colorAttachments[0].view = renderTargetView;
+ if (!postProcessBindGroup || texture !== lastPostProcessTexture) {
+ lastPostProcessTexture = texture;
* postProcessBindGroup = device.createBindGroup({
* layout: postProcessPipeline.getBindGroupLayout(0),
* entries: [
- { binding: 0, resource: renderTargetView },
- { binding: 1, resource: postProcessSampler },
- { binding: 2, resource: postProcessUniformBuffer },
+ { binding: 0, resource: texture },
* ],
* });
+ }
}
function postProcess(encoder, srcTexture, dstTexture) {
- device.queue.writeBuffer(
- postProcessUniformBuffer,
- 0,
- new Float32Array([
- settings.affectAmount,
- settings.bandMult,
- settings.cellMult,
- settings.cellBright,
- ]),
- );
postProcessRenderPassDescriptor.colorAttachments[0].view = dstTexture.createView();
const pass = encoder.beginRenderPass(postProcessRenderPassDescriptor);
pass.setPipeline(postProcessPipeline);
pass.setBindGroup(0, postProcessBindGroup);
pass.draw(3);
pass.end();
}
渲染时还需要使用后处理对象:
+ let selectedMeshes = [];
function render() {
...
- const encoder = device.createCommandEncoder();
- const pass = encoder.beginRenderPass(renderPassDescriptor);
- pass.setPipeline(pipeline);
const aspect = canvas.clientWidth / canvas.clientHeight;
const projection = mat4.perspective(
degToRad(60), // fieldOfView,
aspect,
1, // zNear
2000, // zFar
);
// 从计算出的矩阵获取相机位置
const cameraMatrix = mat4.identity();
mat4.translate(cameraMatrix, [120, 100, 0], cameraMatrix);
mat4.rotateY(cameraMatrix, settings.cameraRotation, cameraMatrix);
mat4.translate(cameraMatrix, [60, 0, 300], cameraMatrix);
// 计算视图矩阵
const viewMatrix = mat4.inverse(cameraMatrix);
// 组合视图和投影矩阵
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
+ const encoder = device.createCommandEncoder();
+ {
+ const pass = encoder.beginRenderPass(renderPassDescriptor);
+ pass.setPipeline(pipeline);
* const ctx = { pass, viewProjectionMatrix };
* root.updateWorldMatrix();
* for (const mesh of meshes) {
* drawMesh(ctx, mesh);
* }
*
* pass.end();
+ }
+ // 将选中对象绘制到 postTexture
+ {
+ if (!postTexture ||
+ postTexture.width !== canvasTexture.width)
+ postTexture.height !== canvasTexture.height) {
+ postTexture?.destroy();
+ postTexture = device.createTexture({
+ format: canvasTexture.format,
+ canvasTexture, // 用于尺寸
+ usage: GPUTextureUsage.RENDER_ATTACHMENT |
+ GPUTextureUsage.TEXTURE_BINDING,
+ });
+ }
+ setupPostProcess(postTexture);
+
+ renderPassDescriptor.colorAttachments[0].view = postTexture.createView();
+ const pass = encoder.beginRenderPass(renderPassDescriptor);
+ pass.setPipeline(pipeline);
+
+ const ctx = { pass, viewProjectionMatrix };
+ for (const mesh of selectedMeshes) {
+ drawMesh(ctx, mesh);
+ }
+
+ pass.end();
+
+ // 基于 postTexture 的 alpha 在 canvasTexture 上绘制轮廓
+ postProcess(encoder, undefined, canvasTexture);
+ }
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
上面的代码先绘制原始场景,然后将 selectedMeshes 绘制到 postTexture。接着把 postTexture 传给后处理代码,最终将轮廓绘制到 canvasTexture。
由于有两处代码都在纹理尺寸变化时重新创建纹理,我们可以添加一个辅助函数来简化:
+ function makeNewTextureIfSizeDifferent(texture, size, format, usage) {
+ if (!texture ||
+ texture.width !== size.width ||
+ texture.height !== size.height) {
+ texture?.destroy();
+ texture = device.createTexture({
+ format,
+ size,
+ usage,
+ });
+ }
+ return texture;
+ }
...
function render() {
...
// 如果没有深度纹理或者尺寸与 canvasTexture 不同,则创建新的深度纹理
- if (!depthTexture ||
- depthTexture.width !== canvasTexture.width ||
- depthTexture.height !== canvasTexture.height) {
- if (depthTexture) {
- depthTexture.destroy();
- }
- depthTexture = device.createTexture({
- size: [canvasTexture.width, canvasTexture.height],
- format: 'depth24plus',
- usage: GPUTextureUsage.RENDER_ATTACHMENT,
- });
- }
+ depthTexture = makeNewTextureIfSizeDifferent(
+ depthTexture,
+ canvasTexture, // 用于尺寸
+ 'depth24plus',
+ GPUTextureUsage.RENDER_ATTACHMENT,
+ );
...
// 将选中对象绘制到 postTexture
{
- if (!postTexture ||
- postTexture.width !== canvasTexture.width)
- postTexture.height !== canvasTexture.height) {
- postTexture?.destroy();
- postTexture = device.createTexture({
- format: canvasTexture.format,
- canvasTexture, // 用于尺寸
- usage: GPUTextureUsage.RENDER_ATTACHMENT |
- GPUTextureUsage.TEXTURE_BINDING,
- });
- }
+ postTexture = makeNewTextureIfSizeDifferent(
+ postTexture,
+ canvasTexture, // 用于尺寸
+ canvasTexture.format,
+ GPUTextureUsage.RENDER_ATTACHMENT |
+ GPUTextureUsage.TEXTURE_BINDING,
+ );
setupPostProcess(postTexture);
剩下的事情就是如何填充 selectedMeshes。由于我们的场景全部由立方体组成,并且默认隐藏了部分节点,在设置 selectedMeshes 时需要把节点的子节点也考虑进去,检查所有子节点是否使用了某个网格:
+ function meshUsesNode(mesh, node) {
+ if (!node) {
+ return false;
+ }
+ if (mesh.node === node) {
+ return true;
+ }
+ for (const child of node.children) {
+ if (meshUsesNode(mesh, child)) {
+ return true;
+ }
+ }
+ return false;
+ }
const kUnelected = '\u3000'; // 全角空格
const kSelected = '➡️';
const prefixRE = new RegExp(`^(?:${kUnelected}|${kSelected})`);
function setCurrentSceneGraphNode(node) {
trsUIHelper.setTRS(node.source);
trsFolder.name(`orientation: ${node.name}`);
trsFolder.updateDisplay();
// 标记哪个节点被选中
for (const b of nodeButtons) {
const name = b.button.getName().replace(prefixRE, '');
b.button.name(`${b.node === node ? kSelected : kUnelected}${name}`);
}
+ selectedMeshes = meshes.filter(mesh => meshUsesNode(mesh, node));
+ render();
}
这样,选中的对象就被高亮显示了。
现在我们已经可以高亮选中对象,接下来就可以实现通过拖拽移动相机,而无需再依赖 UI 按钮。
核心优化要点
-
离屏渲染选中对象生成 alpha 遮罩
只将选中的网格渲染到一个独立的纹理(postTexture),而不是对整个场景进行全屏后处理或重复绘制所有对象。这种做法大幅减少了第二遍渲染的开销,因为只有少数选中对象参与绘制,同时生成的 alpha 遮罩可以直接用于轮廓检测。 -
边缘检测中的边界安全处理
在isOnEdge函数中,使用max(pos - 2, vec2i(0))和min(pos + 2, size)将采样范围钳制在纹理有效区域内。这是因为textureLoad在访问越界纹素时不同 GPU 可能返回不同结果,显式钳制确保了跨平台的一致性和稳定性。 -
纹理复用与按需重建
通过makeNewTextureIfSizeDifferent辅助函数检查纹理尺寸是否变化,仅在尺寸不匹配时才销毁并重建纹理。这避免了每一帧都创建/销毁纹理造成的显存分配和垃圾回收压力。同时,后处理渲染通道使用loadOp: 'load'而非clear,保留画布上已绘制的场景内容,避免不必要的清除操作。
术语检查(WebGPU/WGSL 专业术语)
本文涉及的核心 API 和着色器语言是 WebGPU 和 WGSL,而非 GLSL/WebGL,但部分概念可类比。以下列出关键术语及建议翻译:
| 英文术语 | 中文翻译 | 说明 |
|---|---|---|
texture_2d<f32> | 二维浮点纹理 | WGSL 中的纹理类型,与 GLSL 的 sampler2D 类似但更显式 |
textureLoad | 纹理加载 | WGSL 内置函数,直接读取指定纹素,无过滤 |
textureDimensions | 纹理尺寸 | 返回纹理的宽度和高度 |
discard | 丢弃片段 | 与 GLSL 的 discard 相同,终止当前片段 |
@builtin(position) | 内建变量(位置) | 对应 GLSL 的 gl_Position |
@location(0) | 位置索引 0 | 对应 GLSL 的 layout(location=0) |
@group(0) @binding(0) | 绑定组 0,绑定点 0 | WebGPU 的资源绑定模型,类似 WebGL 的 uniform/texture unit 但更统一 |
bind group | 绑定组 | 一组绑定的资源集合,对应 WebGL 中多个 uniform/texture 的组合 |
render pass | 渲染通道 | WebGPU 的渲染流程单元,类似 WebGL 的一次 draw call 集合 |
render pipeline | 渲染管线 | 包含着色器、混合状态、深度模板等完整配置 |
GPUTextureUsage.RENDER_ATTACHMENT | GPU 纹理用途:渲染附件 | 表示纹理可作为渲染目标 |
GPUTextureUsage.TEXTURE_BINDING | GPU 纹理用途:纹理绑定 | 表示纹理可作为着色器资源绑定 |
loadOp: 'load' | 加载操作:加载 | 渲染通道开始时保留附件已有内容,对应 WebGL 中不清除画布 |
storeOp: 'store' | 存储操作:存储 | 渲染通道结束后将结果写回纹理 |
vec2i / vec4f | 二维整数向量 / 四维浮点向量 | WGSL 类型,对应 GLSL 的 ivec2 / vec4 |
u32 / f32 | 32 位无符号整数 / 32 位浮点数 | WGSL 标量类型 |
以上术语在编译中均保持了英文原名或采用行业通用中文译法,确保技术准确性。如有需要,读者可对照 WebGPU 规范进一步查阅。
