WebGPU 环境贴图(反射)

原文作者:webgpufundamentals Contributors
原文链接:WebGPU Environment Maps (reflections)


WebGPU Environment Maps (reflections)

一、什么是环境贴图?

环境贴图(environment map)用于表示被绘制物体所处的环境。绘制户外场景时,它代表天空和周围景物;绘制舞台人物时,它代表场馆;绘制太空场景时,它代表星空。如果有一个立方体贴图,其六个面分别记录了从空间中某一点向六个方向观察到的环境影像,那么就可以用这个立方体贴图来实现环境贴图。

本文使用伦敦 Leadenhall Market 大厅的六张环境照片作为立方体贴图。

图片来源:Poly Haven(CC0),作者:Andreas Mischok


WebGPU Environment Maps (reflections)

二、加载立方体贴图

基于上一篇文章(立方体贴图)的代码,我们不再使用程序生成的画布,而是直接加载六张图片。使用以下两个函数:一个加载图片为 ImageBitmap,另一个将 ImageBitmap 创建为纹理。

async function loadImageBitmap(url) {
  const res = await fetch(url);
  const blob = await res.blob();
  return await createImageBitmap(blob, { colorSpaceConversion: 'none' });
}

async function createTextureFromImage(device, url, options) {
  const imgBitmap = await loadImageBitmap(url);
  return createTextureFromSource(device, imgBitmap, options);
}

新增一个加载多张图片并创建立方体贴图的函数:

async function createTextureFromImages(device, urls, options) {
  const imgBitmaps = await Promise.all(urls.map(loadImageBitmap));
  return createTextureFromSource(device, imgBitmaps, options);
}

然后加载六张人脸:

const texture = await createTextureFromImages(
  device,
  [
    'resources/images/leadenhall_market/pos-x.jpg',
    'resources/images/leadenhall_market/neg-x.jpg',
    'resources/images/leadenhall_market/pos-y.jpg',
    'resources/images/leadenhall_market/neg-y.jpg',
    'resources/images/leadenhall_market/pos-z.jpg',
    'resources/images/leadenhall_market/neg-z.jpg',
  ],
  { mips: true, flipY: false },
);

WebGPU Environment Maps (reflections)

三、反射原理与公式推导

片元着色器中,我们需要计算从眼睛(摄像机)到物体表面某点的向量,再根据表面法线计算反射方向,最后用该方向采样立方体贴图。

反射公式为:

reflectionDir = eyeToSurfaceDir – 2 × dot(surfaceNormal, eyeToSurfaceDir) × surfaceNormal

其中 dot 表示点积,它返回两个向量夹角的余弦值。对于完全相反的两个向量,点积为 -1;当表面法线与视线方向垂直时,反射方向恰好沿着逆视线方向返回。如果表面旋转 45°,点积为 -0.707,代入公式即可得到正确的反射向量。

下图展示了不同旋转角度下反射向量的变化:

交互式示意图


WebGPU Environment Maps (reflections)

四、WGSL 着色器实现

1. 顶点着色器

我们需要将世界坐标和世界法线从顶点着色器传递到片元着色器,作为插值变量(inter-stage variables)。

struct Uniforms {
  projection: mat4x4f,
  view: mat4x4f,
  world: mat4x4f,
  cameraPosition: vec3f,
};

struct Vertex {
  @location(0) position: vec4f,
  @location(1) normal: vec3f,
};

struct VSOutput {
  @builtin(position) position: vec4f,
  @location(0) worldPosition: vec3f,
  @location(1) worldNormal: vec3f,
};

@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(vert: Vertex) -> VSOutput {
  var vsOut: VSOutput;
  vsOut.position = uni.projection * uni.view * uni.world * vert.position;
  vsOut.worldPosition = (uni.world * vert.position).xyz;
  vsOut.worldNormal = (uni.world * vec4f(vert.normal, 0)).xyz;
  return vsOut;
}

2. 片元着色器

片元着色器中,我们归一化插值后的世界法线,并用 reflect 内置函数计算反射方向,然后采样立方体贴图。

@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
  let worldNormal = normalize(vsOut.worldNormal);
  let eyeToSurfaceDir = normalize(vsOut.worldPosition - uni.cameraPosition);
  let direction = reflect(eyeToSurfaceDir, worldNormal);

  return textureSample(ourTexture, ourSampler, direction);
}

reflect 是 WGSL 的内置函数,等价于上面推导的公式。


WebGPU Environment Maps (reflections)

五、顶点数据与法线

立方体需要每个面具有正确的法线,才能让反射看起来是平的。之前为了显示立方体贴图,我们仅使用了位置数据;现在需要同时提供法线数据。

顶点数据每一行包含 6 个浮点数:位置(x, y, z)和法线(nx, ny, nz):

const vertexData = new Float32Array([
  // 前面(+z)
  -1,  1,  1,   0,  0,  1,
  -1, -1,  1,   0,  0,  1,
   1,  1,  1,   0,  0,  1,
   1, -1,  1,   0,  0,  1,
  // 右面(+x)
   1,  1, -1,   1,  0,  0,
   1,  1,  1,   1,  0,  0,
   1, -1, -1,   1,  0,  0,
   1, -1,  1,   1,  0,  0,
  // 后面(-z)
   1,  1, -1,   0,  0, -1,
   1, -1, -1,   0,  0, -1,
  -1,  1, -1,   0,  0, -1,
  -1, -1, -1,   0,  0, -1,
  // 左面(-x)
  -1,  1,  1,  -1,  0,  0,
  -1,  1, -1,  -1,  0,  0,
  -1, -1,  1,  -1,  0,  0,
  -1, -1, -1,  -1,  0,  0,
  // 底面(-y)
   1, -1,  1,   0, -1,  0,
  -1, -1,  1,   0, -1,  0,
   1, -1, -1,   0, -1,  0,
  -1, -1, -1,   0, -1,  0,
  // 顶面(+y)
  -1,  1,  1,   0,  1,  0,
   1,  1,  1,   0,  1,  0,
  -1,  1, -1,   0,  1,  0,
   1,  1, -1,   0,  1,  0,
]);

渲染管线需要添加法线属性:

const pipeline = device.createRenderPipeline({
  label: '2 attributes',
  layout: 'auto',
  vertex: {
    module,
    buffers: [
      {
        arrayStride: (3 + 3) * 4, // 每个顶点 6 个浮点数,共 24 字节
        attributes: [
          { shaderLocation: 0, offset: 0, format: 'float32x3' },  // position
          { shaderLocation: 1, offset: 12, format: 'float32x3' }, // normal
        ],
      },
    ],
  },
  // ...
});

WebGPU Environment Maps (reflections)

六、Uniform 缓冲区更新

Uniform 缓冲区现在需要存储投影矩阵、视图矩阵、世界矩阵和相机位置。由于 mat4x4 占用 16 个浮点数,vec3f 占用 3 个,但为了对齐,我们额外添加 1 个填充浮点数。

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

const uniformValues = new Float32Array(uniformBufferSize / 4);
const kProjectionOffset = 0;
const kViewOffset = 16;
const kWorldOffset = 32;
const kCameraPositionOffset = 48;

const projectionValue = uniformValues.subarray(kProjectionOffset, kProjectionOffset + 16);
const viewValue = uniformValues.subarray(kViewOffset, kViewOffset + 16);
const worldValue = uniformValues.subarray(kWorldOffset, kWorldOffset + 16);
const cameraPositionValue = uniformValues.subarray(kCameraPositionOffset, kCameraPositionOffset + 3);

渲染循环中更新这些值:

function render(time) {
  time *= 0.001;

  const aspect = canvas.clientWidth / canvas.clientHeight;
  mat4.perspective(60 * Math.PI / 180, aspect, 0.1, 10, projectionValue);
  cameraPositionValue.set([0, 0, 4]);

  const view = mat4.lookAt(cameraPositionValue, [0, 0, 0], [0, 1, 0], viewValue);

  mat4.identity(worldValue);
  mat4.rotateX(worldValue, time * -0.1, worldValue);
  mat4.rotateY(worldValue, time * -0.2, worldValue);

  device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
  // ...
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

七、修正反射方向(Z 轴翻转)

观察结果发现反射是左右颠倒的,就像镜子里应该看到的那样,但现在反射方向反了。

原因在于:我们生成立方体贴图时,假定观察者从外部看向立方体;而实际渲染时,摄像机位于立方体内部。这涉及到坐标系统的手性差异。

立方体贴图通常基于 右手坐标系(+z 向前),而我们的渲染使用的是 左手坐标系(-z 向前)。最简单的修正方法是在采样时翻转 Z 方向:

return textureSample(ourTexture, ourSampler, direction * vec3f(1, 1, -1));

经过修正后,反射就像真正的镜子一样,左右颠倒了。


八、核心优化要点提炼

  1. 利用 GPU 内置函数 reflect 计算反射向量
    WGSL 提供了 reflect 内置函数,直接对应反射公式 R = I - 2 * dot(N, I) * N。这样可以避免手动编写向量运算,提高代码可读性,并且 GPU 会对内置函数进行优化。

  2. 通过插值变量传递世界坐标和法线,减少片元着色器计算量
    在顶点着色器中计算出世界位置和世界法线,通过插值变量(inter-stage variables)传递给片元着色器。由于顶点数量远少于片元数量,这样只需在顶点着色器中执行一次矩阵乘法,而在片元着色器中仅需进行归一化和反射计算,大幅提升了执行效率。

  3. 使用多级纹理(mipmaps)提升远距离表面的采样性能
    创建立方体贴图时设置 mips: true,让硬件生成多级纹理链。当表面距离摄像机较远或反射方向快速变化时,GPU 可以使用较低分辨率的 mipmap 进行采样,不仅减少了纹理缓存压力,还能避免远处像素的闪烁(aliasing)。


九、GLSL / WebGL 专业术语检查

重要说明:本文使用的是 WGSL(WebGPU Shading Language) 和 WebGPU API,并非 GLSL 或 WebGL。虽然两者在概念上有相似之处,但语法和 API 存在显著差异。以下列出文中出现的核心术语及其在 GLSL/WebGL 中的对应关系,以确保理解准确。

本文(WGSL / WebGPU)对应 GLSL / WebGL说明
texture_cube<f32>samplerCube立方体贴图纹理类型
samplersampler2D / samplerCube 的采样器采样器对象,独立于纹理
textureSampletexture执行纹理采样
var<uniform>uniform 块统一缓冲区变量(数据来自 uniform buffer)
@group / @bindinglayout / binding资源绑定组和绑定索引
@builtin(position)gl_Position片元着色器的输出位置
@locationlayout(location = ...)顶点属性位置
inter-stage variablesvarying着色器阶段之间传递的插值变量
vec3f / mat4x4fvec3 / mat4向量和矩阵类型,WGSL 显式指定浮点位宽
GPUBufferUsage.UNIFORMUNIFORM_BUFFER缓冲区用途标志
device.createRenderPipelinegl.createProgram + 状态设置渲染管线封装了着色器、混合、深度等状态

若要在 GLSL/WebGL 中实现相同的反射效果,概念和公式完全相同,但需将纹理采样和 uniform 声明改为 GLSL 语法,例如使用 texture(samplerCube, direction) 替代 textureSample。


十、查找与制作立方体贴图

可以在 Poly Haven 免费下载数百张 HDR 全景图。下载任意一张 JPG/PNG 后,使用 Panorama to Cubemap 工具将其转换为六张立方体贴图面。


本文基于 webgpufundamentals.org 的《WebGPU Environment Maps (reflections)》编译整理,旨在深度解读反射原理与实现细节,适合具备一定 WebGPU 基础的读者。