渲染
2D 渲染基础
直觉问题:如何在屏幕上画一张会动的图片?
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 是为了支持平移操作。
其中:
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 实现平滑的抗锯齿,甚至添加描边、阴影、发光等效果。
瓦片地图(Tile Map)
瓦片地图是 2D 游戏中常见的场景管理方式。它将大地图拆分成固定大小的瓦片,每个瓦片是一个纹理。渲染时通过瓦片索引纹理或瓦片 ID 数组决定每个位置绘制哪个瓦片。
NOTE
瓦片地图可以结合四叉树或空间哈希进行视锥剔除,只渲染屏幕内的瓦片。
Canvas2D vs WebGL 2D
| 特性 | Canvas2D | WebGL 2D |
|---|---|---|
| API 复杂度 | 简单,接近 Canvas API | 复杂,需要管理管线和 shader |
| 性能 | 适合少量元素,CPU 瓶颈 | 适合大量元素,GPU 加速 |
| 渲染能力 | 2D 绘图 API | 完整的 GPU 管线,支持自定义效果 |
| 字体渲染 | 原生支持 | 需要自己实现(SDF/MSDF) |
| 适用场景 | 简单图形、数据可视化 | 游戏、粒子系统、高级效果 |
WARNING
WebGL 虽然性能更强,但内存占用更大(通常是 Canvas2D 的 5-10 倍)。对于简单场景,Canvas2D 可能更合适。
原理与数学机制
2D 变换矩阵的数学原理
平移矩阵:
旋转矩阵:
缩放矩阵:
组合变换:先旋转、再缩放、最后平移
TIP
矩阵乘法不满足交换律,顺序很重要!通常顺序是:局部坐标 → 旋转 → 缩放 → 平移 → 世界坐标
SDF 字体渲染原理
在 vertex shader 中,我们传递纹理坐标;在 fragment shader 中,从距离场纹理读取距离值,通过 smoothstep 函数计算 alpha 值:
对于 MSDF,我们需要分别读取 RGB 三个通道,并取中位数:
瓦片地图渲染管线
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_2d和sampler) - 使用
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;
}
常见误区与陷阱
-
坐标系混乱
- 陷阱:WebGL 的 y 轴向上,而 Canvas2D 的 y 轴向下
- 解决:在投影矩阵中翻转 y 轴,或在 shader 中手动处理
-
矩阵乘法顺序错误
- 陷阱:
T * S * R与R * S * T结果完全不同 - 解决:牢记”局部 → 世界”顺序:先局部变换,再世界变换
- 陷阱:
-
SDF 字体边缘锯齿
- 陷阱:直接使用
step(distance, 0.5)导致锐利边缘 - 解决:使用
smoothstep实现平滑过渡
- 陷阱:直接使用
-
瓦片地图纹理精度问题
- 陷阱:纹理坐标计算浮点误差导致瓦片接缝
- 解决:在 shader 中添加
0.5 / texture_size的偏移
-
Sprite 批处理失效
- 陷阱:每个 Sprite 使用不同纹理导致无法批处理
- 解决:使用纹理图集或数组纹理
延伸阅读与自测
权威资料
- WebGL Fundamentals - 2D Matrices - 详细的 2D 矩阵教程
- The Book of Shaders - 2D Matrices - 通过视觉化理解矩阵变换
- Signed Distance Field Fonts - Red Blob Games - SDF 字体的交互式教程
- Techniques for Rendering Text with WebGL - CSS-Tricks - WebGL 文字渲染技术对比
- WebGPU Fundamentals - WebGPU 入门教程
开源实现参考
- astiopin/webgl_fonts - WebGL 字体渲染示例
- msdf-bmfont-xml - MSDF 字体生成工具
自测题
-
思考题: 为什么 2D 渲染也需要齐次坐标(3×3 矩阵)?不能只用 2×2 矩阵?
-
实践题: 如何在 shader 中实现 Sprite 的九宫格缩放(只拉伸中间区域,边缘保持不变)?
-
对比题: MSDF 相比 SDF 有哪些优势和劣势?在什么情况下应该选择 MSDF?
-
优化题: 瓦片地图中如何实现视锥剔除(只渲染屏幕内的瓦片)?CPU 剔除和 GPU 剔除各有什么优劣?
-
扩展题: 如何在 WebGL 2D 中实现粒子系统?需要考虑哪些性能优化?
参考资料获取时间: 2026-07-06,通过 web-search-prime_web_search_prime 工具检索。