WebGPU 环境贴图(反射)
原文作者:webgpufundamentals Contributors
原文链接:WebGPU Environment Maps (reflections)

一、什么是环境贴图?
环境贴图(environment map)用于表示被绘制物体所处的环境。绘制户外场景时,它代表天空和周围景物;绘制舞台人物时,它代表场馆;绘制太空场景时,它代表星空。如果有一个立方体贴图,其六个面分别记录了从空间中某一点向六个方向观察到的环境影像,那么就可以用这个立方体贴图来实现环境贴图。
本文使用伦敦 Leadenhall Market 大厅的六张环境照片作为立方体贴图。
图片来源:Poly Haven(CC0),作者:Andreas Mischok

二、加载立方体贴图
基于上一篇文章(立方体贴图)的代码,我们不再使用程序生成的画布,而是直接加载六张图片。使用以下两个函数:一个加载图片为 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 },
);

三、反射原理与公式推导
片元着色器中,我们需要计算从眼睛(摄像机)到物体表面某点的向量,再根据表面法线计算反射方向,最后用该方向采样立方体贴图。
反射公式为:
reflectionDir = eyeToSurfaceDir – 2 × dot(surfaceNormal, eyeToSurfaceDir) × surfaceNormal
其中 dot 表示点积,它返回两个向量夹角的余弦值。对于完全相反的两个向量,点积为 -1;当表面法线与视线方向垂直时,反射方向恰好沿着逆视线方向返回。如果表面旋转 45°,点积为 -0.707,代入公式即可得到正确的反射向量。
下图展示了不同旋转角度下反射向量的变化:

四、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 的内置函数,等价于上面推导的公式。

五、顶点数据与法线
立方体需要每个面具有正确的法线,才能让反射看起来是平的。之前为了显示立方体贴图,我们仅使用了位置数据;现在需要同时提供法线数据。
顶点数据每一行包含 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
],
},
],
},
// ...
});

六、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));
经过修正后,反射就像真正的镜子一样,左右颠倒了。
八、核心优化要点提炼
-
利用 GPU 内置函数
reflect计算反射向量
WGSL 提供了reflect内置函数,直接对应反射公式R = I - 2 * dot(N, I) * N。这样可以避免手动编写向量运算,提高代码可读性,并且 GPU 会对内置函数进行优化。 -
通过插值变量传递世界坐标和法线,减少片元着色器计算量
在顶点着色器中计算出世界位置和世界法线,通过插值变量(inter-stage variables)传递给片元着色器。由于顶点数量远少于片元数量,这样只需在顶点着色器中执行一次矩阵乘法,而在片元着色器中仅需进行归一化和反射计算,大幅提升了执行效率。 -
使用多级纹理(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 | 立方体贴图纹理类型 |
sampler | sampler2D / samplerCube 的采样器 | 采样器对象,独立于纹理 |
textureSample | texture | 执行纹理采样 |
var<uniform> | uniform 块 | 统一缓冲区变量(数据来自 uniform buffer) |
@group / @binding | layout / binding | 资源绑定组和绑定索引 |
@builtin(position) | gl_Position | 片元着色器的输出位置 |
@location | layout(location = ...) | 顶点属性位置 |
inter-stage variables | varying | 着色器阶段之间传递的插值变量 |
vec3f / mat4x4f | vec3 / mat4 | 向量和矩阵类型,WGSL 显式指定浮点位宽 |
GPUBufferUsage.UNIFORM | UNIFORM_BUFFER | 缓冲区用途标志 |
device.createRenderPipeline | gl.createProgram + 状态设置 | 渲染管线封装了着色器、混合、深度等状态 |
若要在 GLSL/WebGL 中实现相同的反射效果,概念和公式完全相同,但需将纹理采样和 uniform 声明改为 GLSL 语法,例如使用 texture(samplerCube, direction) 替代 textureSample。
十、查找与制作立方体贴图
可以在 Poly Haven 免费下载数百张 HDR 全景图。下载任意一张 JPG/PNG 后,使用 Panorama to Cubemap 工具将其转换为六张立方体贴图面。
本文基于 webgpufundamentals.org 的《WebGPU Environment Maps (reflections)》编译整理,旨在深度解读反射原理与实现细节,适合具备一定 WebGPU 基础的读者。
