WebGPU Cubemaps(立方体贴图)

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


本文是“向着色器提供数据的不同方式”系列文章之一。每篇文章都基于前一篇,按顺序阅读更容易理解。本文假设你已阅读纹理文章和导入图像到纹理的文章,并使用了方向光照文章中的概念。

在之前的文章中,我们介绍了如何使用纹理、如何通过从 0 到 1 的纹理坐标跨纹理进行引用,以及如何使用 mip 进行可选过滤。

另一种纹理类型是立方体贴图(cubemap)。立方体贴图由 6 个面组成,代表立方体的 6 个面。与传统的二维纹理坐标不同,立方体贴图使用法线,或者说一个 3D 方向。根据法线指向的方向选择立方体的 6 个面之一,然后在该面内采样像素以产生颜色。

让我们做一个简单的例子:使用 2D canvas 为 6 个面生成图像。

以下代码填充 canvas 并居中显示一条消息:

function generateFace(size, {faceColor, textColor, text}) {
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = faceColor;
  ctx.fillRect(0, 0, size, size);
  ctx.font = `${size * 0.7}px sans-serif`;
  ctx.fillStyle = textColor;
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  const m = ctx.measureText(text);
  ctx.fillText(
    text,
    (size - m.actualBoundingBoxRight + m.actualBoundingBoxLeft) / 2,
    (size - m.actualBoundingBoxDescent + m.actualBoundingBoxAscent) / 2
  );
  return canvas;
}

调用它以生成 6 张图像:

const faceSize = 128;
const faceCanvases = [
  { faceColor: '#F00', textColor: '#0FF', text: '+X' },
  { faceColor: '#FF0', textColor: '#00F', text: '-X' },
  { faceColor: '#0F0', textColor: '#F0F', text: '+Y' },
  { faceColor: '#0FF', textColor: '#F00', text: '-Y' },
  { faceColor: '#00F', textColor: '#FF0', text: '+Z' },
  { faceColor: '#F0F', textColor: '#0F0', text: '-Z' },
].map(faceInfo => generateFace(faceSize, faceInfo));

// 显示结果
for (const canvas of faceCanvases) {
  document.body.appendChild(canvas);
}

(你可以点击原链接查看生成的面图像示例)

现在,我们使用立方体贴图将这些图像应用到一个立方体上。我们从导入纹理文章中的纹理图集示例的代码开始。

首先修改着色器以使用立方体贴图:

struct Uniforms {
  matrix: mat4x4f,
};

struct Vertex {
  @location(0) position: vec4f,
-  @location(1) texcoord: vec2f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
-  @location(0) texcoord: vec2f,
+  @location(0) normal: vec3f,
};

...

@vertex fn vs(vert: Vertex) -> VSOutput {
  var vsOut: VSOutput;
  vsOut.position = uni.matrix * vert.position;
-  vsOut.texcoord = vert.texcoord;
+  vsOut.normal = normalize(vert.position.xyz);
  return vsOut;
}

我们移除了纹理坐标,改为将法线作为阶段间变量传递给片段着色器。由于立方体的顶点位置完美地以原点为中心,我们可以直接使用它们作为法线。

回想光照文章,法线是一个方向,通常用于指定某个顶点表面的朝向。因为我们使用归一化的位置作为法线,如果对这个立方体进行光照,会得到平滑的光照效果。

(此处原文有一张对比图:标准立方体法线与本立方体法线的对比)

由于不再使用纹理坐标,可以删除所有与设置纹理坐标相关的代码:

  const vertexData = new Float32Array([
-     // front face     select the top left image
-    -1,  1,  1,        0   , 0  ,
-    -1, -1,  1,        0   , 0.5,
-     1,  1,  1,        0.25, 0  ,
-     1, -1,  1,        0.25, 0.5,
-     // right face     select the top middle image
-     1,  1, -1,        0.25, 0  ,
-     1,  1,  1,        0.5 , 0  ,
-     1, -1, -1,        0.25, 0.5,
-     1, -1,  1,        0.5 , 0.5,
-     // back face      select to top right image
-     1,  1, -1,        0.5 , 0  ,
-     1, -1, -1,        0.5 , 0.5,
-    -1,  1, -1,        0.75, 0  ,
-    -1, -1, -1,        0.75, 0.5,
-    // left face       select the bottom left image
-    -1,  1,  1,        0   , 0.5,
-    -1,  1, -1,        0.25, 0.5,
-    -1, -1,  1,        0   , 1  ,
-    -1, -1, -1,        0.25, 1  ,
-    // bottom face     select the bottom middle image
-     1, -1,  1,        0.25, 0.5,
-    -1, -1,  1,        0.5 , 0.5,
-     1, -1, -1,        0.25, 1  ,
-    -1, -1, -1,        0.5 , 1  ,
-    // top face        select the bottom right image
-    -1,  1,  1,        0.5 , 0.5,
-     1,  1,  1,        0.75, 0.5,
-    -1,  1, -1,        0.5 , 1  ,
-     1,  1, -1,        0.75, 1  ,
+     // front face
+    -1,  1,  1,
+    -1, -1,  1,
+     1,  1,  1,
+     1, -1,  1,
+     // right face
+     1,  1, -1,
+     1,  1,  1,
+     1, -1, -1,
+     1, -1,  1,
+     // back face
+     1,  1, -1,
+     1, -1, -1,
+    -1,  1, -1,
+    -1, -1, -1,
+    // left face
+    -1,  1,  1,
+    -1,  1, -1,
+    -1, -1,  1,
+    -1, -1, -1,
+    // bottom face
+     1, -1,  1,
+    -1, -1,  1,
+     1, -1, -1,
+    -1, -1, -1,
+    // top face
+    -1,  1,  1,
+     1,  1,  1,
+    -1,  1, -1,
+     1,  1, -1,
  ]);

  ...

  const pipeline = device.createRenderPipeline({
    label: '2 attributes',
    layout: 'auto',
    vertex: {
      module,
      buffers: [
        {
-          arrayStride: (3 + 2) * 4, // (3+2) floats 4 bytes each
+          arrayStride: (3) * 4, // (3) floats 4 bytes each
          attributes: [
            {shaderLocation: 0, offset: 0, format: 'float32x3'},  // position
-            {shaderLocation: 1, offset: 12, format: 'float32x2'},  // texcoord
          ],
        },
      ],
    },
    fragment: {
      module,
      targets: [{ format: presentationFormat }],
    },
    primitive: {
      cullMode: 'back',
    },
    depthStencil: {
      depthWriteEnabled: true,
      depthCompare: 'less',
      format: 'depth24plus',
    },
  });

在片段着色器中,需要使用 texture_cube 而不是 texture_2d,并且 textureSample 与 texture_cube 一起使用时接受一个 vec3f 方向,因此我们传递法线。由于法线是阶段间变量并被插值,需要对其进行归一化:

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

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
-  return textureSample(ourTexture, ourSampler, vsOut.texcoord);
+  return textureSample(ourTexture, ourSampler, normalize(vsOut.normal));
}

要创建一个立方体贴图,我们创建一个具有 6 个层的 2D 纹理。让我们修改所有辅助函数,使它们能够处理多个来源。

让纹理辅助函数处理多个层

首先,将 createTextureFromSource 改为 createTextureFromSources,接受一个来源数组:

-  function createTextureFromSource(device, source, options = {}) {
+  function createTextureFromSources(device, sources, options = {}) {
+    // 假设所有来源尺寸相同,仅用第一个来源的宽高
+    const source = sources[0];
    const texture = device.createTexture({
      format: 'rgba8unorm',
      mipLevelCount: options.mips ? numMipLevels(source.width, source.height) : 1,
-      size: [source.width, source.height],
+      size: [source.width, source.height, sources.length],
      usage: GPUTextureUsage.TEXTURE_BINDING |
             GPUTextureUsage.COPY_DST |
             GPUTextureUsage.RENDER_ATTACHMENT,
    });
-    copySourceToTexture(device, texture, source, options);
+    copySourcesToTexture(device, texture, sources, options);
    return texture;
  }

上面的代码创建了一个多层的纹理,每层对应一个来源。同时假设所有来源尺寸相同——这是合理的,因为同一纹理的不同层尺寸不同会很罕见。

现在更新 copySourceToTexture 以处理多个来源:

-  function copySourceToTexture(device, texture, source, {flipY} = {}) {
+  function copySourcesToTexture(device, texture, sources, {flipY} = {}) {
+    sources.forEach((source, layer) => {
*      device.queue.copyExternalImageToTexture(
*        { source, flipY, },
-        { texture },
+        { texture, origin: [0, 0, layer] },
*        { width: source.width, height: source.height },
*      );
+  });

    if (texture.mipLevelCount > 1) {
      generateMips(device, texture);
    }
  }

以上主要区别是增加了一个循环遍历所有来源,并设置 origin,将每个来源复制到对应的层。

接下来更新 generateMips 以处理多个层:

  const generateMips = (() => {
    let sampler;
    let module;
    const pipelineByFormat = {};

    return function generateMips(device, texture) {
      if (!module) {
        module = device.createShaderModule({
          label: 'textured quad shaders for mip level generation',
          code: /* wgsl */ `
            struct VSOutput {
              @builtin(position) position: vec4f,
              @location(0) texcoord: vec2f,
            };

            @vertex fn vs(
              @builtin(vertex_index) vertexIndex : u32
            ) -> VSOutput {
              let pos = array(
                // 1st triangle
                vec2f( 0.0,  0.0),  // center
                vec2f( 1.0,  0.0),  // right, center
                vec2f( 0.0,  1.0),  // center, top

                // 2nd triangle
                vec2f( 0.0,  1.0),  // center, top
                vec2f( 1.0,  0.0),  // right, center
                vec2f( 1.0,  1.0),  // right, top
              );

              var vsOutput: VSOutput;
              let xy = pos[vertexIndex];
              vsOutput.position = vec4f(xy * 2.0 - 1.0, 0.0, 1.0);
              vsOutput.texcoord = vec2f(xy.x, 1.0 - xy.y);
              return vsOutput;
            }

            @group(0) @binding(0) var ourSampler: sampler;
            @group(0) @binding(1) var ourTexture: `texture_2d<f32>`;

            @fragment fn fs(fsInput: VSOutput) -> @location(0) vec4f {
              return textureSample(ourTexture, ourSampler, fsInput.texcoord);
            }
          `,
        });

        sampler = device.createSampler({
          minFilter: 'linear',
          magFilter: 'linear',
        });
      }

      if (!pipelineByFormat[texture.format]) {
        pipelineByFormat[texture.format] = device.createRenderPipeline({
          label: 'mip level generator pipeline',
          layout: 'auto',
          vertex: {
            module,
          },
          fragment: {
            module,
            targets: [{ format: texture.format }],
          },
        });
      }
      const pipeline = pipelineByFormat[texture.format];

      const encoder = device.createCommandEncoder({
        label: 'mip gen encoder',
      });

      for (let baseMipLevel = 1; baseMipLevel < texture.mipLevelCount; ++baseMipLevel) {
+        for (let layer = 0; layer < texture.depthOrArrayLayers; ++layer) {
*          const bindGroup = device.createBindGroup({
*            layout: pipeline.getBindGroupLayout(0),
*            entries: [
*              { binding: 0, resource: sampler },
-              { binding: 1, resource: texture.createView({baseMipLevel, mipLevelCount: 1}) },
+              {
+                binding: 1,
+                resource: texture.createView({
+                  dimension: '2d',
+                  baseMipLevel: baseMipLevel - 1,
+                  mipLevelCount: 1,
+                  baseArrayLayer: layer,
+                  arrayLayerCount: 1,
+                }),
*              },
*            ],
*          });
*
*          const renderPassDescriptor = {
*            label: 'our basic canvas renderPass',
*            colorAttachments: [
*              {
-                view: texture.createView({baseMipLevel, mipLevelCount: 1}),
+                view: texture.createView({
+                  dimension: '2d',
+                  baseMipLevel: baseMipLevel,
+                  mipLevelCount: 1,
+                  baseArrayLayer: layer,
+                  arrayLayerCount: 1,
+                }),
*                loadOp: 'clear',
*                storeOp: 'store',
*              },
*            ],
*          };
*
*          const pass = encoder.beginRenderPass(renderPassDescriptor);
*          pass.setPipeline(pipeline);
*          pass.setBindGroup(0, bindGroup);
*          pass.draw(6);  // call our vertex shader 6 times
*          pass.end();
+        }
+      }

      const commandBuffer = encoder.finish();
      device.queue.submit([commandBuffer]);
    };
  })();

我们添加了一个循环来处理纹理的每一层。同时修改视图以选择单个层,并显式指定 dimension: '2d',因为默认情况下具有多个层的 2D 纹理视图会得到 dimension: '2d-array',这在生成 mipmap 时不是我们想要的。

注意: 兼容模式文章提供了在兼容模式下工作的 generateMips 版本。

虽然这里不会用到,但我们原始的 createTextureFromSource 和 copySourceToTexture 函数可以轻松替换为:

  function copySourceToTexture(device, texture, source, options = {}) {
    copySourcesToTexture(device, texture, [source], options);
  }

  function createTextureFromSource(device, source, options = {}) {
    return createTextureFromSources(device, [source], options);
  }

现在我们可以使用文章开头生成的面:

  const texture = await createTextureFromSources(
      device, faceCanvases, {mips: true, flipY: false});

最后,在绑定组中修改纹理视图:

  const bindGroup = device.createBindGroup({
    label: 'bind group for object',
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: uniformBuffer },
      { binding: 1, resource: sampler },
-      { binding: 2, resource: texture },
+      { binding: 2, resource: texture.createView({dimension: 'cube'}) },
    ],
  });

这样就完成了。

(原链接中有实时示例显示旋转的立方体贴图)

注意面作为纹理层的顺序:

  • 层 0 = 正 X
  • 层 1 = 负 X
  • 层 2 = 正 Y
  • 层 3 = 负 Y
  • 层 4 = 正 Z
  • 层 5 = 负 Z

另一种思考方式是,如果你调用 textureSample 并传入对应方向,它将返回该纹理层中心像素的颜色:

  • textureSample(tex, sampler, vec3f( 1, 0, 0)) → 层 0 的中心
  • textureSample(tex, sampler, vec3f(-1, 0, 0)) → 层 1 的中心
  • textureSample(tex, sampler, vec3f( 0, 1, 0)) → 层 2 的中心
  • textureSample(tex, sampler, vec3f( 0,-1, 0)) → 层 3 的中心
  • textureSample(tex, sampler, vec3f( 0, 0, 1)) → 层 4 的中心
  • textureSample(tex, sampler, vec3f( 0, 0,-1)) → 层 5 的中心

使用立方体贴图对立方体进行纹理贴图并不是立方体贴图的常规用途。正确或标准的做法是使用纹理图集。本文旨在介绍立方体贴图的概念,展示如何传入方向(法线)并返回该方向上立方体的颜色。

我们已经了解了立方体贴图是什么以及如何设置,那么它真正的用途是什么?可能最常见的用途是作为环境贴图。


3 个核心优化要点

  1. 使用 mipmap 提升采样质量与性能
    在创建立方体贴图时启用 mips: true 并调用 generateMips。这为每个面生成多级 mip 链,在远处或倾斜视角下采样时能有效减少锯齿和摩尔纹,同时提高纹理缓存效率。

  2. 逐层生成 mipmap 并显式指定 dimension: '2d'
    由于立方体贴图实际是包含 6 层的纹理数组,生成 mipmap 时必须循环处理每一层,并为每个层创建单独的 2D 视图(dimension: '2d', baseArrayLayer, arrayLayerCount: 1)。默认的多层视图会变为 '2d-array',导致 mipmap 生成错误。显式设置 dimension: '2d' 确保每个层被正确作为独立 2D 纹理处理。

  3. 使用立方体贴图视图(dimension: 'cube')简化着色器采样
    通过 texture.createView({dimension: 'cube'}) 创建立方体贴图视图,使得 WGSL 着色器中可以直接声明 texture_cube 并调用 textureSample 传入 3D 方向向量。这避免了手动选择面和计算 UV 坐标的繁琐,同时让 GPU 硬件高效处理面选择和过滤。


WebGL / WebGPU 专业术语检查

本文涉及 WebGPU 和 WGSL 术语(部分概念与 WebGL/GLSL 类似但也有差异)。以下列出关键术语及其中文对照,确保翻译准确:

英文术语中文翻译说明
Cubemap立方体贴图由 6 个面组成的纹理类型,通过 3D 方向采样
Texture纹理GPU 中存储图像数据的对象
Sampler采样器定义纹理过滤、寻址模式等采样参数
Mipmap / Mip levelMipmap / Mip 级别纹理的预缩放链,用于远距离采样
Inter-stage variable阶段间变量顶点着色器输出、片段着色器输入的可插值变量
Fragment shader片段着色器处理像素着色的可编程阶段
Vertex shader顶点着色器处理顶点变换的可编程阶段
Render pipeline渲染管线包含着色器、混合、深度等状态的管线对象
Bind group绑定组一组绑定资源(缓冲、纹理、采样器)的集合
texture_cube<f32>WGSL 立方体贴图纹理类型等价于 GLSL 中的 samplerCube
texture_2d<f32>WGSL 2D 纹理类型等价于 GLSL 中的 sampler2D
textureSampleWGSL 纹理采样函数等价于 GLSL 中的 texture 函数
GPUTextureUsage.TEXTURE_BINDING纹理绑定用法标志允许纹理被绑定为着色器资源
GPUTextureUsage.COPY_DST纹理复制目标用法标志允许纹理作为复制操作的目标
GPUTextureUsage.RENDER_ATTACHMENT纹理渲染附件用法标志允许纹理作为渲染目标
copyExternalImageToTexture将外部图像复制到纹理WebGPU API,类似 WebGL 的 texImage2D
createView({dimension: 'cube'})创建立方体贴图视图将多层 2D 纹理视为立方体贴图
dimension: '2d' vs '2d-array'纹理视图维度显式指定单个 2D 层视图,避免默认的数组视图
depthOrArrayLayers深度或数组层数纹理的深度或层数(立方体贴图为 6)
baseArrayLayer / arrayLayerCount基础数组层 / 数组层数量创建视图时选择特定的层范围
cullMode: 'back'背面剔除剔除背对相机的三角形
depthStencil深度模板深度和模板测试状态

这些术语在 WebGPU 与 WebGL/GLSL 中概念相通,但 API 名称和语法有所不同。本文代码基于 WebGPU 和 WGSL,需注意与 GLSL 的区别(如 WGSL 使用 @location 而非 layout(location),textureSample 对应 texture 函数等)。建议读者结合 WebGPU 规范理解具体用法。