WebGPU SkyBox(天空盒)

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

原文配图

核心优化要点

  1. 用全屏三角形替代巨大立方体
    通过顶点着色器硬编码一个覆盖整个裁剪空间的大三角形,并利用逆视图投影矩阵从像素反算视线方向,完全避免传统立方体天空盒的远裁剪面与视锥体角落问题,同时省去顶点缓冲区的创建与绑定。

  2. 巧设深度测试:less-equal + 最后绘制天空盒
    将深度比较函数改为 depthCompare: 'less-equal',并在绘制完场景物体后渲染天空盒。这样 GPU 可以利用深度纹理跳过被遮挡的天空盒像素,避免无效片元着色器计算,获得传统“先画天空盒并关闭深度测试”方案所没有的性能收益。

  3. 移除视图矩阵平移分量,仅保留旋转方向
    将视图矩阵的平移元素 view[12]、view[13]、view[14] 置零,使天空盒始终以摄像机为中心,保证无限远视觉效果。该做法与立方体贴图采样结合,无需为天空盒单独传递模型矩阵或顶点数据,降低带宽和状态切换开销。


正文编译

本文承接自环境映射(Environment Maps)一文。

**天空盒(Skybox)**是一个带有纹理的盒子,用来模拟所有方向上的天空,或者说模拟包括地平线在内的远处景象。想象你站在一个房间里,每面墙上都贴着一幅全景海报,天花板上是天空,地板上是地面——这就是天空盒。

许多 3D 游戏通过创建一个巨大的立方体并贴上天空纹理来实现。这种方法虽然有效,但存在问题。其中一个问题是:无论摄像机朝向哪个方向,你希望所有物体都绘制在远处,但不希望立方体的角落超出裁剪平面。更复杂的是,出于性能考虑,你希望先绘制近处物体再绘制远处物体,因为 GPU 使用深度纹理可以跳过那些已知会失败的像素。所以理想情况下你应当最后绘制天空盒并开启深度测试。但如果使用实际的立方体,随着摄像机朝不同方向观察,盒子的角落会比侧面更远,从而引发裁剪问题。

skybox-issues

如上图所示,我们需要确保立方体最远点位于视锥体内部,但这样一来立方体的某些边缘可能会覆盖我们不想被遮挡的物体。

常见的解决方案是关闭深度测试并最先绘制天空盒,但这样我们就无法获得深度测试跳过像素(被场景物体遮挡)的性能优势。

更好的方案:不再使用立方体,而是绘制一个覆盖整个画布的大三角形,并使用立方体贴图(cubemap)。通常我们使用视图投影矩阵将 3D 几何体投影到裁剪空间;这里我们反其道而行之——使用视图投影矩阵的逆矩阵,从每个像素反算出摄像机对应的观察方向,然后用该方向查询立方体贴图。

我们以环境映射示例为基础(它已经加载了立方体贴图并生成了 mipmap),使用一个硬编码的大三角形。片元与顶点着色器如下(WGSL):

struct Uniforms {
  viewDirectionProjectionInverse: mat4x4f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
  @location(0) pos: vec4f,
};

@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@group(0) @binding(1) var ourSampler: sampler;
@group(0) @binding(2) var ourTexture: `texture_cube<f32>`;

@vertex fn vs(@builtin(vertex_index) vNdx: u32) -> VSOutput {
  let pos = array(
    vec2f(-1, 3),
    vec2f(-1,-1),
    vec2f( 3,-1),
  );
  var vsOut: VSOutput;
  vsOut.position = vec4f(pos[vNdx], 1, 1);
  vsOut.pos = vsOut.position;
  return vsOut;
}

上面代码中,@builtin(position) 被设置为顶点位置,并显式将 z 设为 1,使三角形绘制在最远的深度值处。顶点位置同时传递给片元着色器。

在片元着色器中,我们将位置乘以逆视图投影矩阵,再除以 w 分量,从 4D 裁剪空间变换到 3D 世界方向。这与顶点着色器中对 @builtin(position) 自动执行的透视除法相同,但这里我们手动完成。

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  let t = uni.viewDirectionProjectionInverse * vsOut.pos;
  return textureSample(ourTexture, ourSampler, normalize(t.xyz / t.w) * vec3f(1, 1, -1));
}

注意:我们将 z 方向乘以 -1,原因已在前一篇环境映射文章中说明。

渲染管线在顶点阶段不需要任何缓冲区:

  const pipeline = device.createRenderPipeline({
    label: 'no attributes',
    layout: 'auto',
    vertex: {
      module,
    },
    fragment: {
      module,
      targets: [{ format: presentationFormat }],
    },
    depthStencil: {
      depthWriteEnabled: true,
      depthCompare: 'less-equal',
      format: 'depth24plus',
    },
  });

注意这里将 depthCompare 设为 less-equal 而非默认的 less。因为我们把深度纹理清为 1.0,而天空盒也绘制在深度 1.0。1.0 不小于 1.0,所以若不改为 less-equal,天空盒将完全不可见。

接着需要创建 uniform 缓冲区:

  // viewDirectionProjectionInverse
  const uniformBufferSize = (16) * 4;
  const uniformBuffer = device.createBuffer({
    label: 'uniforms',
    size: uniformBufferSize,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

  const uniformValues = new Float32Array(uniformBufferSize / 4);

  // offsets to the various uniform values in float32 indices
  const kViewDirectionProjectionInverseOffset = 0;

  const viewDirectionProjectionInverseValue = uniformValues.subarray(
      kViewDirectionProjectionInverseOffset,
      kViewDirectionProjectionInverseOffset + 16);

渲染时更新 uniform:

    const aspect = canvas.clientWidth / canvas.clientHeight;
    const projection = mat4.perspective(
        60 * Math.PI / 180,
        aspect,
        0.1,      // zNear
        10,      // zFar
    );
    // Camera going in circle from origin looking at origin
    const cameraPosition = [Math.cos(time * .1), 0, Math.sin(time * .1)];
    const view = mat4.lookAt(
      cameraPosition,
      [0, 0, 0],  // target
      [0, 1, 0],  // up
    );
    // We only care about direction so remove the translation
    view[12] = 0;
    view[13] = 0;
    view[14] = 0;

    const viewProjection = mat4.multiply(projection, view);
    mat4.inverse(viewProjection, viewDirectionProjectionInverseValue);

    // upload the uniform values to the uniform buffer
    device.queue.writeBuffer(uniformBuffer, 0, uniformValues);

注意,摄像机绕原点旋转,计算 cameraPosition。创建 view 矩阵后,我们将平移分量清零,因为我们只关心摄像机朝向,不关心其位置。之后将投影矩阵与视图矩阵相乘,取逆,并上传矩阵数据。

(在线示例可点击原文链接查看)

接下来我们将环境映射立方体合并回该示例。首先做变量重命名:

从天空盒示例:

module        -> skyBoxModule
pipeline      -> skyBoxPipeline
uniformBuffer -> skyBoxUniformBuffer
uniformValues -> skyBoxUniformValues
bindGroup     -> skyBoxBindGroup

从环境映射示例:

module        -> envMapModule
pipeline      -> envMapPipeline
uniformBuffer -> envMapUniformBuffer
uniformValues -> envMapUniformValues
bindGroup     -> envMapBindGroup

重命名后,更新渲染代码。首先更新两套 uniform 值:

    const aspect = canvas.clientWidth / canvas.clientHeight;
    mat4.perspective(
        60 * Math.PI / 180,
        aspect,
        0.1,      // zNear
        10,      // zFar
        projectionValue,
    );
    // Camera going in circle from origin looking at origin
    cameraPositionValue.set([Math.cos(time * .1) * 5, 0, Math.sin(time * .1) * 5]);
    const view = mat4.lookAt(
      cameraPositionValue,
      [0, 0, 0],  // target
      [0, 1, 0],  // up
    );
    // Copy the view into the viewValue since we're going
    // to zero out the view's translation
    viewValue.set(view);

    // We only care about direction so remove the translation
    view[12] = 0;
    view[13] = 0;
    view[14] = 0;
    const viewProjection = mat4.multiply(projectionValue, view);
    mat4.inverse(viewProjection, viewDirectionProjectionInverseValue);

    // Rotate the cube
    mat4.identity(worldValue);
    mat4.rotateX(worldValue, time * -0.1, worldValue);
    mat4.rotateY(worldValue, time * -0.2, worldValue);

    // upload the uniform values to the uniform buffers
    device.queue.writeBuffer(envMapUniformBuffer, 0, envMapUniformValues);
    device.queue.writeBuffer(skyBoxUniformBuffer, 0, skyBoxUniformValues);

然后依次渲染两个对象:先画环境映射立方体,再画天空盒,验证后绘制天空盒同样能正常工作。

    // Draw the cube
    pass.setPipeline(envMapPipeline);
    pass.setVertexBuffer(0, vertexBuffer);
    pass.setIndexBuffer(indexBuffer, 'uint16');
    pass.setBindGroup(0, envMapBindGroup);
    pass.drawIndexed(numVertices);

    // Draw the skyBox
    pass.setPipeline(skyBoxPipeline);
    pass.setBindGroup(0, skyBoxBindGroup);
    pass.draw(3);

(在线示例可点击原文链接查看)

最后,希望这两篇文章能帮助你了解如何使用立方体贴图。例如,常见做法是将光照计算代码与环境映射采样结果相结合,制作汽车引擎盖、抛光地板等材质效果。


GLSL/WebGL 专业术语检查

原文使用 WebGPU 着色语言 WGSL,而非 GLSL。但为了方便熟悉 WebGL/GLSL 的读者对照,特整理关键术语对应关系如下:

WebGPU (WGSL)WebGL/GLSL 对应概念说明
@builtin(position)gl_Position顶点着色器输出裁剪空间坐标
@builtin(vertex_index)gl_VertexIndex顶点索引
@location(0)layout(location=0)片元着色器输入/输出位置
var<uniform>uniformuniform 变量声明
texture_cube<f32>samplerCube立方体贴图纹理类型
textureSample(tex, samp, dir)texture(samplerCube, dir)立方体贴图采样(GLSL 中)
mat4x4f / vec4f / vec2fmat4 / vec4 / vec2矩阵/向量类型
depthCompare: 'less-equal'glDepthFunc(GL_LEQUAL)深度比较函数
normalize()normalize()向量归一化,同名相同语义
@group(0) @binding(0)无直接对应WebGPU 资源绑定模型,类似 Vulkan descriptor set

检查结论:原文在技术术语使用上准确,符合 WebGPU 规范。唯一值得注意的小问题是原文 HTML 中将片段着色器代码块标记为 lang-glsl,实际上应为 lang-wgsl,因为代码是 WGSL 语法。本文编译时已修正为 WGSL 标注。