WebGPU 高亮效果:描边与选中态实现

原作者: webgpufundamentals Contributors
原始链接: https://webgpufundamentals.org/webgpu/lessons/webgpu-highlighting.html


本文是“制作 3D 编辑器”系列的第一篇。每篇文章都建立在上一篇的基础上,因此按顺序阅读会更容易理解。本文假设你已经阅读过场景图(Scene Graphs)和后处理(Post Processing)两篇文章。

系列目录:

  1. 高亮(Highlighting) ⬅ 你在这里
  2. 相机控制(Camera Controls)
  3. 拾取(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 按钮。


核心优化要点

  1. 离屏渲染选中对象生成 alpha 遮罩
    只将选中的网格渲染到一个独立的纹理(postTexture),而不是对整个场景进行全屏后处理或重复绘制所有对象。这种做法大幅减少了第二遍渲染的开销,因为只有少数选中对象参与绘制,同时生成的 alpha 遮罩可以直接用于轮廓检测。

  2. 边缘检测中的边界安全处理
    在 isOnEdge 函数中,使用 max(pos - 2, vec2i(0)) 和 min(pos + 2, size) 将采样范围钳制在纹理有效区域内。这是因为 textureLoad 在访问越界纹素时不同 GPU 可能返回不同结果,显式钳制确保了跨平台的一致性和稳定性。

  3. 纹理复用与按需重建
    通过 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,绑定点 0WebGPU 的资源绑定模型,类似 WebGL 的 uniform/texture unit 但更统一
bind group绑定组一组绑定的资源集合,对应 WebGL 中多个 uniform/texture 的组合
render pass渲染通道WebGPU 的渲染流程单元,类似 WebGL 的一次 draw call 集合
render pipeline渲染管线包含着色器、混合状态、深度模板等完整配置
GPUTextureUsage.RENDER_ATTACHMENTGPU 纹理用途:渲染附件表示纹理可作为渲染目标
GPUTextureUsage.TEXTURE_BINDINGGPU 纹理用途:纹理绑定表示纹理可作为着色器资源绑定
loadOp: 'load'加载操作:加载渲染通道开始时保留附件已有内容,对应 WebGL 中不清除画布
storeOp: 'store'存储操作:存储渲染通道结束后将结果写回纹理
vec2i / vec4f二维整数向量 / 四维浮点向量WGSL 类型,对应 GLSL 的 ivec2 / vec4
u32 / f3232 位无符号整数 / 32 位浮点数WGSL 标量类型

以上术语在编译中均保持了英文原名或采用行业通用中文译法,确保技术准确性。如有需要,读者可对照 WebGPU 规范进一步查阅。