渲染
3D 渲染基础
直觉问题:如何把三维世界画在二维屏幕上?
Q1: 为什么游戏里的物体近大远小?GPU 是如何模拟这种透视效果的?
Q2: 为什么相机会被物体遮挡?GPU 怎么知道哪个像素应该画在前面?
核心概念白话讲
3D 渲染的核心挑战是:如何把三维空间中的点投影到二维屏幕上。这个流程由MVP 矩阵(Model-View-Projection)完成。
MVP 矩阵三连击
graph LR
A[局部空间<br/>Local Space] -->|Model Matrix| B[世界空间<br/>World Space]
B -->|View Matrix| C[相机空间<br/>Camera Space]
C -->|Projection Matrix| D[裁剪空间<br/>Clip Space]
D -->|透视除法| E[归一化设备坐标<br/>NDC]
E -->|视口变换| F[屏幕空间<br/>Screen Space]
Model 矩阵(模型变换)
将物体从局部坐标(相对于自身的中心)转换到世界坐标(场景中的绝对位置)。包含旋转、缩放、平移。
View 矩阵(视图变换)
将世界坐标转换到相机坐标(以相机为原点的坐标系)。相当于”把相机移动到原点,并旋转朝向 -z 方向”。
TIP
View 矩阵本质上是相机变换的逆矩阵。如果相机在世界位置 P,旋转 R,那么 View 矩阵 = translate(-P) * inverse(R)。
Projection 矩阵(投影变换)
将 3D 相机坐标投影到 2D 屏幕坐标,同时定义视锥体(Frustum)。有两种投影方式:
- 正交投影(Orthographic): 平行线保持平行,不产生近大远小效果。适用于 CAD、工程图纸。
- 透视投影(Perspective): 近大远小,符合人眼视觉。适用于游戏、VR。
相机与投影
视锥体(Frustum): 相机能看到的区域,由六个平面构成:
- Near Plane(近平面):最小渲染距离
- Far Plane(远平面):最大渲染距离
- Left/Right/Top/Bottom Plane(左右上下平面):视野范围
视野角(FOV, Field of View): 相机能看到的场景角度,通常指垂直 FOV(fovY)。
宽高比(Aspect Ratio): 屏幕宽度 / 高度。
齐次坐标(Homogeneous Coordinates)
3D 渲染使用四维坐标 (x, y, z, w) 表示三维点。为什么需要第四维 w?
- 支持透视投影:在透视除法中,将坐标除以
w,实现近大远小效果。 - 统一表示点与向量:
w=1表示点(可平移),w=0表示向量(不可平移)。
深度缓冲(Depth Buffer)
深度缓冲是一个与屏幕尺寸相同的纹理,存储每个像素的深度值(通常为 z 坐标)。
深度测试(Depth Test):渲染每个像素时,先读取深度缓冲中的值。如果当前像素的深度值更小(更近),则渲染该像素,并更新深度缓冲。
TIP
深度缓冲通常使用 24 位或 32 位浮点数存储,精度问题可能导致深度冲突(Z-fighting)。解决方法:启用 glPolygonOffset 或使用反向深度(Reverse Z)。
背面剔除(Backface Culling)
只渲染面向相机的三角形,剔除背向相机的三角形。判断方法:计算三角形法线与视线的点积。
NOTE
- CCW(Counter-Clockwise): 逆时针为正面(GLSL 默认)
- CW(Clockwise): 顺时针为正面(DX/WGSL 默认)
原理与数学机制
透视投影矩阵推导
假设:
- 相机在原点,朝向 -z 方向
- 视锥体由
fovY、aspect、near、far定义
透视投影矩阵为:
推导过程:
- 根据相似三角形,计算近平面上的投影点坐标
- 将 x、y 映射到 [-1, 1] 范围(NDC 空间)
- 将 z 映射到 [-1, 1] 范围,并保留非线性深度(提高深度精度)
- 最后一行
-1, 0确保透视除法除以-z
深度缓冲精度分析
深度值在 NDC 空间中的分布:
简化后:
问题:深度值是非线性分布的,近距离精度高,远距离精度低。
解决方案:
- 减小
far/near比值(例如:far = 1000, near = 0.1比值 10000,改为near = 1比值 1000) - 使用反向深度(Reverse Z):将深度值从 [0, 1] 反转为 [1, 0],提高近处精度
- 使用对数深度缓冲(Logarithmic Depth Buffer)
法线与切线空间
法线空间(Tangent Space): 以三角形表面的切线(T)、副切线(B)、法线(N)为基的局部坐标系。
TBN 矩阵:将法线从局部空间转换到世界空间的矩阵。
用途:法线贴图(Normal Map)存储的是切线空间的法线,需要 TBN 矩阵转换到世界空间。
GLSL vs WGSL 代码对照
MVP 矩阵变换
GLSL 版本
#version 300 es
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texcoord;
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec3 v_position;
out vec3 v_normal;
out vec2 v_texcoord;
void main() {
vec4 world_position = u_model * vec4(a_position, 1.0);
vec4 view_position = u_view * world_position;
gl_Position = u_projection * view_position;
// 传递世界坐标和法线到片段着色器
v_position = world_position.xyz;
v_normal = mat3(transpose(inverse(u_model))) * a_normal;
v_texcoord = a_texcoord;
}
WGSL 版本
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) texcoord: vec2<f32>,
}
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) world_position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) texcoord: vec2<f32>,
}
@group(0) @binding(0)
var<uniform> model: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> view: mat4x4<f32>;
@group(0) @binding(2)
var<uniform> projection: mat4x4<f32>;
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
let world_position = model * vec4<f32>(input.position, 1.0);
let view_position = view * world_position;
output.position = projection * view_position;
output.world_position = world_position.xyz;
// 计算 TBN 矩阵(法线变换)
let normal_matrix = transpose(inverse(mat3x3<f32>(model)));
output.normal = normal_matrix * input.normal;
output.texcoord = input.texcoord;
return output;
}
差异点:
- WGSL 需要显式定义输入输出结构体
@builtin(position)替代gl_Positionmat3x3<f32>()用于从 4×4 矩阵提取 3×3 矩阵
深度测试配置
GLSL 版本(在 CPU 端配置)
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS); // 小于则渲染
gl.clearDepth(1.0); // 清除深度值为 1.0
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
WGSL 版本(在 Render Pipeline 描述中配置)
let depth_stencil_state = wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
};
let pipeline_descriptor = wgpu::RenderPipelineDescriptor {
label: Some("3D Render Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader_module,
entry_point: "vs_main",
buffers: &vertex_buffers,
},
fragment: Some(wgpu::FragmentState {
module: &shader_module,
entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(depth_stencil_state),
multisample: wgpu::MultisampleState::default(),
multiview: None,
};
差异点:
- GLSL 在运行时配置
gl.DEPTH_TEST - WGSL 在创建 Pipeline 时配置
depth_stencil_state - WGSL 提供更多配置选项(stencil、bias 等)
背面剔除
GLSL 版本
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK); // 剔除背面
gl.frontFace(gl.CCW); // 逆时针为正面
WGSL 版本
let primitive_state = wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
};
差异点:
- WGSL 的
cull_mode支持None、Front、Back三种模式 front_face默认为Ccw,与 DX 一致
常见误区与陷阱
-
MVP 矩阵顺序错误
- 陷阱:
model * view * projection与projection * view * model结果完全不同 - 解决:牢记”局部 → 世界 → 相机 → 裁剪”顺序,矩阵乘法从右到左应用
- 陷阱:
-
法线变换错误
- 陷阱:直接用 Model 矩阵变换法线,导致缩放后法线不归一化
- 解决:使用
mat3(transpose(inverse(model)))变换法线
-
深度冲突(Z-fighting)
- 陷阱:两个物体深度值过于接近,产生闪烁
- 解决:启用
glPolygonOffset,或调整near/far值,或使用反向深度
-
透视除法后的坐标范围错误
- 陷阱:NDC 坐标超出 [-1, 1] 范围导致裁剪
- 解决:确保 MVP 矩阵正确,投影矩阵的
near/far值合理
-
宽高比不匹配
- 陷阱:渲染的物体被拉伸或压缩
- 解决:投影矩阵的宽高比
aspect = width / height必须与屏幕宽高比一致
-
反向 Z 在某些设备失效
- 陷阱:WebGPU 的反向 Z 支持取决于硬件
- 解决:查询
wgpu::Limits::max_depth_bias_clamp或回退到传统深度缓冲
延伸阅读与自测
权威资料
- WebGL Fundamentals - WebGL 3D Perspective - 透视投影详解
- LearnOpenGL - Camera - 相机系统教程
- The Matrix and Quaternions FAQ - 矩阵与四元数 FAQ
- Reverse Z - Nathan Reed - 反向深度缓冲深度分析
- Normal Mapping - LearnOpenGL - 法线贴图与 TBN 矩阵
开源实现参考
- webgpu-samples - WebGPU 官方示例,包含相机、深度测试
- bevy - Rust 游戏引擎,内置 3D 渲染系统
自测题
-
思考题: 为什么透视投影矩阵的最后一行是
[0, 0, -1, 0]而不是[0, 0, 1, 0]? -
实践题: 如何实现轨道相机(Orbit Camera),支持鼠标拖拽旋转和滚轮缩放?
-
对比题: 正交投影与透视投影在矩阵构造上的主要区别是什么?各适用于什么场景?
-
优化题: 如何在渲染大量物体时优化深度测试?是否可以使用分层深度缓冲(Hi-Z)?
-
扩展题: 法线贴图存储的是切线空间的法线,为什么不能直接转换到世界空间?TBN 矩阵如何计算?
参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。