返回知识库

渲染

3D 渲染基础

3D 渲染基础 封面
WebGPU/WebGLShaderGLSL/WGSL3D渲染MVP矩阵相机投影深度缓冲

直觉问题:如何把三维世界画在二维屏幕上?

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 表示向量(不可平移)。
(x,y,z,w)(xw,yw,zw)(x, y, z, w) \rightarrow \left(\frac{x}{w}, \frac{y}{w}, \frac{z}{w}\right)

深度缓冲(Depth Buffer)

深度缓冲是一个与屏幕尺寸相同的纹理,存储每个像素的深度值(通常为 z 坐标)。

深度测试(Depth Test):渲染每个像素时,先读取深度缓冲中的值。如果当前像素的深度值更小(更近),则渲染该像素,并更新深度缓冲。

TIP

深度缓冲通常使用 24 位或 32 位浮点数存储,精度问题可能导致深度冲突(Z-fighting)。解决方法:启用 glPolygonOffset 或使用反向深度(Reverse Z)。

背面剔除(Backface Culling)

只渲染面向相机的三角形,剔除背向相机的三角形。判断方法:计算三角形法线与视线的点积。

frontface=dot(normal,viewDirection)<0\text{frontface} = \text{dot}(\text{normal}, \text{viewDirection}) < 0

NOTE

  • CCW(Counter-Clockwise): 逆时针为正面(GLSL 默认)
  • CW(Clockwise): 顺时针为正面(DX/WGSL 默认)

原理与数学机制

透视投影矩阵推导

假设:

  • 相机在原点,朝向 -z 方向
  • 视锥体由 fovYaspectnearfar 定义

透视投影矩阵为:

P=[1aspecttan(fovY/2)00001tan(fovY/2)0000far+nearfarnear2farnearfarnear0010]P = \begin{bmatrix} \frac{1}{\text{aspect} \cdot \tan(\text{fovY}/2)} & 0 & 0 & 0 \\ 0 & \frac{1}{\tan(\text{fovY}/2)} & 0 & 0 \\ 0 & 0 & -\frac{\text{far} + \text{near}}{\text{far} - \text{near}} & -\frac{2 \cdot \text{far} \cdot \text{near}}{\text{far} - \text{near}} \\ 0 & 0 & -1 & 0 \end{bmatrix}

推导过程

  1. 根据相似三角形,计算近平面上的投影点坐标
  2. 将 x、y 映射到 [-1, 1] 范围(NDC 空间)
  3. 将 z 映射到 [-1, 1] 范围,并保留非线性深度(提高深度精度)
  4. 最后一行 -1, 0 确保透视除法除以 -z

深度缓冲精度分析

深度值在 NDC 空间中的分布:

zndc=(far+near)+(farnear)zviewfarnear1zviewz_{\text{ndc}} = -\frac{(\text{far} + \text{near}) + (\text{far} - \text{near}) \cdot z_{\text{view}}}{\text{far} - \text{near}} \cdot \frac{1}{-z_{\text{view}}}

简化后:

zndc=zview+nearzviewfar+nearfarnear+2farnearfarnearz_{\text{ndc}} = \frac{z_{\text{view}} + \text{near}}{z_{\text{view}}} \cdot \frac{\text{far} + \text{near}}{\text{far} - \text{near}} + \frac{2 \cdot \text{far} \cdot \text{near}}{\text{far} - \text{near}}

问题:深度值是非线性分布的,近距离精度高,远距离精度低。

解决方案

  1. 减小 far/near 比值(例如:far = 1000, near = 0.1 比值 10000,改为 near = 1 比值 1000)
  2. 使用反向深度(Reverse Z):将深度值从 [0, 1] 反转为 [1, 0],提高近处精度
  3. 使用对数深度缓冲(Logarithmic Depth Buffer)

法线与切线空间

法线空间(Tangent Space): 以三角形表面的切线(T)、副切线(B)、法线(N)为基的局部坐标系。

TBN 矩阵:将法线从局部空间转换到世界空间的矩阵。

TBN=[TxBxNxTyByNyTzBzNz]\text{TBN} = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix}

用途:法线贴图(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_Position
  • mat3x3<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 支持 NoneFrontBack 三种模式
  • front_face 默认为 Ccw,与 DX 一致

常见误区与陷阱

  1. MVP 矩阵顺序错误

    • 陷阱:model * view * projectionprojection * view * model 结果完全不同
    • 解决:牢记”局部 → 世界 → 相机 → 裁剪”顺序,矩阵乘法从右到左应用
  2. 法线变换错误

    • 陷阱:直接用 Model 矩阵变换法线,导致缩放后法线不归一化
    • 解决:使用 mat3(transpose(inverse(model))) 变换法线
  3. 深度冲突(Z-fighting)

    • 陷阱:两个物体深度值过于接近,产生闪烁
    • 解决:启用 glPolygonOffset,或调整 near/far 值,或使用反向深度
  4. 透视除法后的坐标范围错误

    • 陷阱:NDC 坐标超出 [-1, 1] 范围导致裁剪
    • 解决:确保 MVP 矩阵正确,投影矩阵的 near/far 值合理
  5. 宽高比不匹配

    • 陷阱:渲染的物体被拉伸或压缩
    • 解决:投影矩阵的宽高比 aspect = width / height 必须与屏幕宽高比一致
  6. 反向 Z 在某些设备失效

    • 陷阱:WebGPU 的反向 Z 支持取决于硬件
    • 解决:查询 wgpu::Limits::max_depth_bias_clamp 或回退到传统深度缓冲

延伸阅读与自测

权威资料

开源实现参考

  • webgpu-samples - WebGPU 官方示例,包含相机、深度测试
  • bevy - Rust 游戏引擎,内置 3D 渲染系统

自测题

  1. 思考题: 为什么透视投影矩阵的最后一行是 [0, 0, -1, 0] 而不是 [0, 0, 1, 0]

  2. 实践题: 如何实现轨道相机(Orbit Camera),支持鼠标拖拽旋转和滚轮缩放?

  3. 对比题: 正交投影与透视投影在矩阵构造上的主要区别是什么?各适用于什么场景?

  4. 优化题: 如何在渲染大量物体时优化深度测试?是否可以使用分层深度缓冲(Hi-Z)

  5. 扩展题: 法线贴图存储的是切线空间的法线,为什么不能直接转换到世界空间?TBN 矩阵如何计算?


参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。