返回知识库

渲染

2D 渲染基础

2D 渲染基础 封面
WebGPU/WebGLShaderGLSL/WGSL2D渲染矩阵变换SDF字体瓦片地图

直觉问题:如何在屏幕上画一张会动的图片?

Q1: 为什么我们能把一张静态图片在屏幕上旋转、缩放、移动,还能一次性渲染成千上万张?

Q2: GPU 在渲染 2D 文字时,为什么能保持清晰的边缘而不糊掉?


核心概念白话讲

2D 渲染表面看起来是在平面上画画,但底层依赖的是和 3D 一样的 GPU 渲染管线。核心区别在于:我们通常将 z 坐标固定为 0,只操作 x、y 坐标,通过仿射变换矩阵控制位置、旋转、缩放。

仿射变换(Affine Transformation)

仿射变换是 2D 图形学的基石。它包含三种基本操作:

  • 平移(Translation): 将图形从一个位置移动到另一个位置
  • 旋转(Rotation): 围绕某个中心点旋转图形
  • 缩放(Scale): 放大或缩小图形

这三个操作可以通过一个 3×3 矩阵统一表示。为什么需要 3×3?因为 2D 齐次坐标用 (x, y, 1) 表示,第三维 1 是为了支持平移操作。

[xy1]=[xy1][sxcosθsysinθ0sxsinθsycosθ0txty1]\begin{bmatrix} x' & y' & 1 \end{bmatrix} = \begin{bmatrix} x & y & 1 \end{bmatrix} \cdot \begin{bmatrix} sx \cdot \cos\theta & sy \cdot \sin\theta & 0 \\ -sx \cdot \sin\theta & sy \cdot \cos\theta & 0 \\ tx & ty & 1 \end{bmatrix}

其中:

  • sx, sy 是 x、y 方向的缩放系数
  • θ 是旋转角度
  • tx, ty 是平移偏移

Sprite 渲染

Sprite 是游戏引擎中最常见的 2D 渲染单元。本质上就是一个矩形(通常由两个三角形组成),贴上一张纹理图片。每个 Sprite 有自己的变换矩阵,独立控制位置、旋转、缩放。

TIP

Sprite 渲染的精髓在于批处理。成千上万个 Sprite 可以合并为一个 Draw Call,通过 Instance Buffer 传递每个 Sprite 的变换矩阵,极大提升性能。

字体渲染:从位图到 SDF/MSDF

传统字体渲染依赖位图字体(每个字符保存为一张图片),缩放时会模糊。现代游戏引擎采用距离场字体(Distance Field Font)

  • SDF(Signed Distance Field): 每个像素存储到字符边缘的有符号距离,内部为正,外部为负
  • MSDF(Multi-Channel Signed Distance Field): SDF 的升级版,使用 RGB 三个通道存储不同方向的距离场

通过距离场字体,我们可以用 shader 实现平滑的抗锯齿,甚至添加描边、阴影、发光等效果。

α=smoothstep(width,width,distance)\alpha = \text{smoothstep}(-\text{width}, \text{width}, \text{distance})

瓦片地图(Tile Map)

瓦片地图是 2D 游戏中常见的场景管理方式。它将大地图拆分成固定大小的瓦片,每个瓦片是一个纹理。渲染时通过瓦片索引纹理瓦片 ID 数组决定每个位置绘制哪个瓦片。

NOTE

瓦片地图可以结合四叉树空间哈希进行视锥剔除,只渲染屏幕内的瓦片。

Canvas2D vs WebGL 2D

特性Canvas2DWebGL 2D
API 复杂度简单,接近 Canvas API复杂,需要管理管线和 shader
性能适合少量元素,CPU 瓶颈适合大量元素,GPU 加速
渲染能力2D 绘图 API完整的 GPU 管线,支持自定义效果
字体渲染原生支持需要自己实现(SDF/MSDF)
适用场景简单图形、数据可视化游戏、粒子系统、高级效果

WARNING

WebGL 虽然性能更强,但内存占用更大(通常是 Canvas2D 的 5-10 倍)。对于简单场景,Canvas2D 可能更合适。


原理与数学机制

2D 变换矩阵的数学原理

平移矩阵:

T=[100010txty1]T = \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ tx & ty & 1 \end{bmatrix}

旋转矩阵:

R=[cosθsinθ0sinθcosθ0001]R = \begin{bmatrix} \cos\theta & \sin\theta & 0 \\ -\sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix}

缩放矩阵:

S=[sx000sy0001]S = \begin{bmatrix} sx & 0 & 0 \\ 0 & sy & 0 \\ 0 & 0 & 1 \end{bmatrix}

组合变换:先旋转、再缩放、最后平移

M=TSRM = T \cdot S \cdot R

TIP

矩阵乘法不满足交换律,顺序很重要!通常顺序是:局部坐标 → 旋转 → 缩放 → 平移 → 世界坐标

SDF 字体渲染原理

在 vertex shader 中,我们传递纹理坐标;在 fragment shader 中,从距离场纹理读取距离值,通过 smoothstep 函数计算 alpha 值:

distance=texture(sdfTexture,uv).rα=1.0smoothstep(0.5,0.5,distance)\begin{aligned} \text{distance} &= \text{texture}(sdfTexture, uv).r \\ \alpha &= 1.0 - \text{smoothstep}(-0.5, 0.5, \text{distance}) \end{aligned}

对于 MSDF,我们需要分别读取 RGB 三个通道,并取中位数:

dr=texture(msdfTexture,uv).rdg=texture(msdfTexture,uv).gdb=texture(msdfTexture,uv).bdistance=median(dr,dg,db)\begin{aligned} d_r &= \text{texture}(msdfTexture, uv).r \\ d_g &= \text{texture}(msdfTexture, uv).g \\ d_b &= \text{texture}(msdfTexture, uv).b \\ \text{distance} &= \text{median}(d_r, d_g, d_b) \end{aligned}

瓦片地图渲染管线

graph TD
    A[CPU: 瓦片 ID 数组] --> B[GPU: Vertex Shader]
    B --> C[计算每个瓦片的世界坐标]
    C --> D[Rasterization]
    D --> E[Fragment Shader]
    E --> F[根据瓦片 ID 采样纹理]
    F --> G[输出颜色]

GLSL vs WGSL 代码对照

2D 变换矩阵应用

GLSL 版本

#version 300 es
layout(location = 0) in vec2 a_position;
layout(location = 1) in vec2 a_texcoord;

uniform mat3 u_transform;

out vec2 v_texcoord;

void main() {
  vec2 position = (u_transform * vec3(a_position, 1.0)).xy;
  gl_Position = vec4(position, 0.0, 1.0);
  v_texcoord = a_texcoord;
}

WGSL 版本

struct VertexInput {
  @location(0) position: vec2<f32>,
  @location(1) texcoord: vec2<f32>,
}

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) texcoord: vec2<f32>,
}

@group(0) @binding(0)
var<uniform> transform: mat3x3<f32>;

@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;
  let pos = transform * vec3<f32>(input.position, 1.0);
  output.position = vec4<f32>(pos.xy, 0.0, 1.0);
  output.texcoord = input.texcoord;
  return output;
}

差异点

  • WGSL 使用 struct 明确定义输入输出
  • @location(0) 替代 GLSL 的 layout(location = 0)
  • @builtin(position) 替代 gl_Position
  • WGSL 的矩阵乘法顺序与 GLSL 一致,但需要显式使用 vec3<f32>()

SDF 字体渲染

GLSL 版本

#version 300 es
precision mediump float;

in vec2 v_texcoord;
uniform sampler2D u_sdf_texture;
uniform float u_edge; // 0.5
uniform float u_width; // 0.1

out vec4 fragColor;

void main() {
  float distance = texture(u_sdf_texture, v_texcoord).r;
  float alpha = 1.0 - smoothstep(u_edge - u_width, u_edge + u_width, distance);
  vec3 color = vec3(alpha);
  fragColor = vec4(color, alpha);
}

WGSL 版本

struct FragmentOutput {
  @location(0) color: vec4<f32>,
}

@group(0) @binding(0)
var sdf_texture: texture_2d<f32>;

@group(0) @binding(1)
var sdf_sampler: sampler;

@group(0) @binding(2)
var<uniform> params: Uniforms {
  edge: f32,
  width: f32,
};

@fragment
fn fs_main(@location(0) texcoord: vec2<f32>) -> FragmentOutput {
  var output: FragmentOutput;
  let distance = textureSample(sdf_texture, sdf_sampler, texcoord).r;
  let alpha = 1.0 - smoothstep(params.edge - params.width, params.edge + params.width, distance);
  let color = vec3<f32>(alpha);
  output.color = vec4<f32>(color, alpha);
  return output;
}

差异点

  • WGSL 将纹理和采样器分开声明(texture_2dsampler
  • 使用 textureSample() 而非 texture()
  • uniform 需要包装在 struct

瓦片地图渲染

GLSL 版本

#version 300 es
layout(location = 0) in vec2 a_position;

uniform sampler2D u_tilemap;
uniform sampler2D u_texture;
uniform vec2 u_tile_size;
uniform vec2 u_texture_size;

out vec2 v_texcoord;

void main() {
  gl_Position = vec4(a_position, 0.0, 1.0);

  // 从瓦片 ID 纹理读取瓦片 ID
  float tile_id = texture(u_tilemap, a_position).r;

  // 计算瓦片在纹理中的 UV
  vec2 tile_uv = fract(a_position * u_tile_size);
  vec2 tile_pos = vec2(mod(tile_id, u_texture_size.x), floor(tile_id / u_texture_size.x));
  v_texcoord = (tile_pos + tile_uv) / u_texture_size;
}

WGSL 版本

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) texcoord: vec2<f32>,
}

@group(0) @binding(0)
var tilemap_texture: texture_2d<f32>;

@group(0) @binding(1)
var tilemap_sampler: sampler;

@group(0) @binding(2)
var texture: texture_2d<f32>;

@group(0) @binding(3)
var texture_sampler: sampler;

@group(0) @binding(4)
var<uniform> params: Uniforms {
  tile_size: vec2<f32>,
  texture_size: vec2<f32>,
};

@vertex
fn vs_main(@location(0) position: vec2<f32>) -> VertexOutput {
  var output: VertexOutput;
  output.position = vec4<f32>(position, 0.0, 1.0);

  let tile_id = textureSample(tilemap_texture, tilemap_sampler, position).r;
  let tile_uv = fract(position * params.tile_size);
  let tile_pos = vec2<f32>(
    fract(tile_id / params.texture_size.x),
    floor(tile_id / params.texture_size.x)
  );
  output.texcoord = (tile_pos + tile_uv) / params.texture_size;
  return output;
}

常见误区与陷阱

  1. 坐标系混乱

    • 陷阱:WebGL 的 y 轴向上,而 Canvas2D 的 y 轴向下
    • 解决:在投影矩阵中翻转 y 轴,或在 shader 中手动处理
  2. 矩阵乘法顺序错误

    • 陷阱:T * S * RR * S * T 结果完全不同
    • 解决:牢记”局部 → 世界”顺序:先局部变换,再世界变换
  3. SDF 字体边缘锯齿

    • 陷阱:直接使用 step(distance, 0.5) 导致锐利边缘
    • 解决:使用 smoothstep 实现平滑过渡
  4. 瓦片地图纹理精度问题

    • 陷阱:纹理坐标计算浮点误差导致瓦片接缝
    • 解决:在 shader 中添加 0.5 / texture_size 的偏移
  5. Sprite 批处理失效

    • 陷阱:每个 Sprite 使用不同纹理导致无法批处理
    • 解决:使用纹理图集数组纹理

延伸阅读与自测

权威资料

开源实现参考

自测题

  1. 思考题: 为什么 2D 渲染也需要齐次坐标(3×3 矩阵)?不能只用 2×2 矩阵?

  2. 实践题: 如何在 shader 中实现 Sprite 的九宫格缩放(只拉伸中间区域,边缘保持不变)?

  3. 对比题: MSDF 相比 SDF 有哪些优势和劣势?在什么情况下应该选择 MSDF?

  4. 优化题: 瓦片地图中如何实现视锥剔除(只渲染屏幕内的瓦片)?CPU 剔除和 GPU 剔除各有什么优劣?

  5. 扩展题: 如何在 WebGL 2D 中实现粒子系统?需要考虑哪些性能优化?


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