WebGPU SkyBox(天空盒)
原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-skybox.html
核心优化要点
-
用全屏三角形替代巨大立方体
通过顶点着色器硬编码一个覆盖整个裁剪空间的大三角形,并利用逆视图投影矩阵从像素反算视线方向,完全避免传统立方体天空盒的远裁剪面与视锥体角落问题,同时省去顶点缓冲区的创建与绑定。 -
巧设深度测试:
less-equal+ 最后绘制天空盒
将深度比较函数改为depthCompare: 'less-equal',并在绘制完场景物体后渲染天空盒。这样 GPU 可以利用深度纹理跳过被遮挡的天空盒像素,避免无效片元着色器计算,获得传统“先画天空盒并关闭深度测试”方案所没有的性能收益。 -
移除视图矩阵平移分量,仅保留旋转方向
将视图矩阵的平移元素view[12]、view[13]、view[14]置零,使天空盒始终以摄像机为中心,保证无限远视觉效果。该做法与立方体贴图采样结合,无需为天空盒单独传递模型矩阵或顶点数据,降低带宽和状态切换开销。
正文编译
本文承接自环境映射(Environment Maps)一文。
**天空盒(Skybox)**是一个带有纹理的盒子,用来模拟所有方向上的天空,或者说模拟包括地平线在内的远处景象。想象你站在一个房间里,每面墙上都贴着一幅全景海报,天花板上是天空,地板上是地面——这就是天空盒。
许多 3D 游戏通过创建一个巨大的立方体并贴上天空纹理来实现。这种方法虽然有效,但存在问题。其中一个问题是:无论摄像机朝向哪个方向,你希望所有物体都绘制在远处,但不希望立方体的角落超出裁剪平面。更复杂的是,出于性能考虑,你希望先绘制近处物体再绘制远处物体,因为 GPU 使用深度纹理可以跳过那些已知会失败的像素。所以理想情况下你应当最后绘制天空盒并开启深度测试。但如果使用实际的立方体,随着摄像机朝不同方向观察,盒子的角落会比侧面更远,从而引发裁剪问题。
如上图所示,我们需要确保立方体最远点位于视锥体内部,但这样一来立方体的某些边缘可能会覆盖我们不想被遮挡的物体。
常见的解决方案是关闭深度测试并最先绘制天空盒,但这样我们就无法获得深度测试跳过像素(被场景物体遮挡)的性能优势。
更好的方案:不再使用立方体,而是绘制一个覆盖整个画布的大三角形,并使用立方体贴图(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> | uniform | uniform 变量声明 |
texture_cube<f32> | samplerCube | 立方体贴图纹理类型 |
textureSample(tex, samp, dir) | texture(samplerCube, dir) | 立方体贴图采样(GLSL 中) |
mat4x4f / vec4f / vec2f | mat4 / 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 标注。
