从零实现软光栅渲染器:架构分析、核心技术与重构路线
本文是对 SimpleRenderer 项目的全面复盘。该项目是一个基于 CPU 的软件光栅化渲染引擎,不依赖任何图形 API,从零实现了完整的渲染管线。本文将从项目架构、核心技术、关键实现细节三个维度进行分析,并给出后续的重构方向。
一、项目整体架构
各层职责一览
| 层次 | 核心职责 | 关键文件 |
|---|---|---|
| App | 启动、主循环、事件调度 | main.cpp, scene_manager, camera_controller |
| Core | 渲染器状态机、帧缓冲管理、场景遍历 | renderer.h, framebuffer.h, scene.h |
| Pipeline | 顶点变换、光栅化、插值、片段处理 | vertex.cpp, pipeline.cpp, fragment.cpp |
| Graphics | 相机模型、Mesh 加载与切线计算、材质描述 | camera.h, mesh.h, material.h |
| Shader | 可插拔光照模型 | shader.h, phong_shader.cpp, toon_shader.cpp |
| Lib | 数学、资源管理、纹理完整子系统 | maths.h, IResource.h, texture/ |
| Platform | 平台无关 C 接口,SDL2 实现 | platform.h, platform_sdl.cpp |
二、渲染管线全流程
flowchart TD
A[Scene::render] --> B{shadowMappingEnabled?}
B -- Yes --> C[shadowPass\n从光源视角渲染深度图]
C --> D
B -- No --> D[设置 Camera ViewMatrix / ProjMatrix]
D --> E[renderer.clear 清屏]
E --> F[for each SceneObject\n绑定 ShaderUniforms]
F --> G[drawMeshPass\nfor each Triangle]
G --> H[setupTriangle]
subgraph H [setupTriangle]
H1[vertexShader × 3\n→ clipPos + Varyings]
H2[透视除法 → NDC\nscreenMapping → 屏幕坐标]
H3[faceCull 背面剔除]
H4[calculateBoundingBox]
H5[setupEdgeFunctions]
H1 --> H2 --> H3 --> H4 --> H5
end
H --> I{像素数 > 1024?}
I -- Yes --> J[OMP 并行 traverseTriangle\nBLOCK_SIZE=16 分块]
I -- No --> K[串行扫描线遍历]
J --> L
K --> L
subgraph L [per-pixel]
L1[EdgeFunction 测试]
L2[computeBarycentric2D]
L3[透视正确权重\n1/w 插值]
L4[calculateFragmentDepth]
L5{depthTest Z-Buffer}
L6[interpolateVaryings\n位置/法线/UV/切线]
L7[fragmentShader\nBlinn-Phong + NormalMap + Shadow]
L8[frameBuffer.setPixel]
L1 --> L2 --> L3 --> L4 --> L5
L5 -- pass --> L6 --> L7 --> L8
L5 -- fail --> L9[discard]
end
L --> M[copyToPlatform\nSDL_UpdateTexture → 显示]
三、核心技术知识点
3.1 坐标变换链
关键细节:项目的投影矩阵使用右手坐标系,m32 = -1,这决定了背面剔除时叉积符号的判断方向(reverseFactor)。World Space 是光照计算的基础空间,顶点着色器中同时输出 positionWS 和 clipPos。
3.2 重心坐标 与 EdgeFunction 优化
项目对大三角形使用分块(BLOCK_SIZE=16)扫描,在块的起始点预计算 EdgeFunction 值,块内通过增量 Δe = dy(沿 x)/ Δe = dx(沿 y)避免重复乘法,这正是 GPU 硬件光栅化器的经典做法。
3.3 透视正确插值
直接对屏幕空间属性线性插值会产生透视畸变,必须对 1/w 插值后再还原:
代码路径:fragment.cpp → calculatePerspectiveWeights() → interpolateVaryings(),所有顶点属性(位置、法线、UV、切线)都经过此校正。
3.4 深度测试与 Z-Buffer
3.5 MSAA 多重采样抗锯齿
3.6 Blinn-Phong 光照模型
graph LR
N["N (法线)"] --> DIFF
L["L (光方向)"] --> DIFF
L --> H
V["V (视线方向)"] --> H
H["H = normalize(L+V)\n半角向量"] --> SPEC
N --> SPEC
DIFF["Diffuse\nKd × baseColor × max(N·L, 0)"]
SPEC["Specular\nKs × max(N·H, 0)^shininess"]
AMB["Ambient\nKa × La × ambientIntensity"]
DIFF --> SUM
SPEC --> SUM
AMB --> SUM
SHADOW["Shadow Factor\n0.5 or 1.0"] --> SUM
SUM["result = Ambient + (Diffuse+Specular)×Shadow"]
SUM --> SRGB["linearToSrgb(result)\n输出到 FrameBuffer"]
sRGB 正确性:纹理读取后先 srgbToLinear,光照计算在线性空间进行,输出前 linearToSrgb。忽略这一步会导致高光过曝、暗部偏暗。
3.7 法线贴图 与 TBN 矩阵
tangent.w 的 ±1 在 mesh.cpp::calculateTangents() 通过 Gram-Schmidt 正交化后检测手性得到,防止镜像模型的副切线方向翻转。
3.8 阴影映射(Shadow Map + PCF)
sequenceDiagram
participant S as Scene::render
participant R as Renderer
participant FB as ShadowFrameBuffer
participant SM as shadowMap (R32_FLOAT Texture)
participant MFB as MainFrameBuffer
S->>R: setViewMatrix(lightView)\nsetProjMatrix(lightProj)
S->>R: shadowPass(casters)
R->>FB: clear(1.0f)
loop for each shadow caster
R->>FB: rasterize → 写入 NDC-z
end
R->>SM: FB.getDepth(x,y) → SM.write(x,y,depth)
S->>R: setViewMatrix(cameraView)
S->>R: uniforms.textures[_ShadowMap] = SM
loop for each main object
note right of MFB: projCoords = lightSpacePos.xyz/w
uv = projCoords.xy*0.5+0.5
closestDepth = SM.sample(uv)
bias = max(0.005*(1-N·L), 0.005)
shadow = current-bias > closest ? 0.5 : 1.0
R->>MFB: fragmentShader 写入最终颜色
end
Shadow Acne(自遮挡条纹)由深度图精度有限导致。动态 Bias 的核心:掠射角(N·L 接近 0)时自遮挡更严重,故 bias 更大。
3.9 纹理采样系统
3.10 并行化策略
四、已实现功能 / 现存问题
| 状态 | 功能点 |
|---|---|
| ✅ | MVP 变换链、重心坐标、EdgeFunction 增量优化 |
| ✅ | 透视正确属性插值 |
| ✅ | Z-Buffer 深度测试、背面剔除 |
| ✅ | Blinn-Phong 光照 + sRGB 正确处理 |
| ✅ | 漫反射纹理 + 法线贴图(TBN 矩阵) |
| ✅ | Shadow Map + 动态 Bias PCF |
| ✅ | 4× MSAA |
| ✅ | Mipmap 生成 + 三线性过滤 |
| ✅ | OBJ 加载 + 切线空间计算 |
| ✅ | OpenMP 自适应并行(大/小三角形不同策略) |
| ✅ | 可插拔 Shader 接口(Phong / Toon / Basic / ShadowMap) |
| ⚠️ | MSAA 并行路径存在写入竞态 |
| ❌ | 缺少近平面(w=0)裁剪 —— 最影响正确性 |
| ❌ | 缺少 Early-Z,昂贵 Shader 在深度测试失败时白跑 |
| ❌ | Shadow Map 仅支持单透视光源,方向光应用正交投影 |
| ❌ | 无视锥体剔除(Frustum Culling) |
五、后续重构方向
5.1 正确性优先:近平面裁剪
当三角形顶点位于相机后方时,透视除法 ÷w 产生错误结果(w→0 或反转),导致几何扭曲。修复方式是 Sutherland-Hodgman 裁剪:
flowchart LR
A[clipPos × 3] --> B{任意顶点 w < ε?}
B -- No --> C[直接光栅化]
B -- Yes --> D[对 w=ε 平面裁剪\n线性插值出新顶点]
D --> E[重新三角化\n扇形分解]
E --> C
5.2 性能:Tile-based 并行
Tile-based 光栅化是同时解决并行竞态和提升缓存局部性的最佳方案:
5.3 功能:PBR 材质
从 Blinn-Phong 升级到 Cook-Torrance 微平面 BRDF,引入 Roughness 和 Metallic 两个新参数:
1 | |
5.4 功能:延迟渲染(Deferred Rendering)
多光源场景下 Forward Rendering 复杂度 O(物体 × 光源),延迟渲染将几何 Pass 和光照 Pass 分离:
graph TD
subgraph GeoPass ["Geometry Pass (写 G-Buffer)"]
G1["RT0: Albedo RGB + AO A"]
G2["RT1: WorldNormal RGB + Roughness A"]
G3["RT2: WorldPos RGB + Metallic A"]
G4["DepthBuffer"]
end
subgraph LightPass ["Lighting Pass (读 G-Buffer)"]
L1["for each pixel:\n读取 G-Buffer → 计算所有光源贡献"]
end
GeoPass --> LightPass --> O["HDR Buffer → Tonemap → sRGB → 显示"]
软渲染中”多渲染目标”即维护多张 FrameBuffer,Lighting Pass 直接遍历像素坐标而非三角形。
六、总结
| 方向 | 优先级 | 收益 |
|---|---|---|
| 近平面裁剪 | 🔴 高 | 修复大角度视锥下的渲染崩溃 |
| Early-Z | 🔴 高 | 减少 Fragment Shader 白跑 |
| Tile-based 并行 | 🟠 中 | 消除竞态 + 提升 Cache 命中 |
| PBR 材质 | 🟡 低 | 更真实的光照外观 |
| 延迟渲染 | 🟡 低 | 支持多光源场景 |
从零实现软光栅,最大的收获是:GPU 硬件的每一个设计决策背后,都藏着一个用 CPU 踩坑后才能真正理解的理由。近平面裁剪、Early-Z、Tile-based 光栅化、透视正确插值——这些都不是”优化”,而是正确性的必要条件。
项目地址:SimpleRenderer on GitHub | License: MIT