从零实现软光栅渲染器:架构分析、核心技术与重构路线

本文是对 SimpleRenderer 项目的全面复盘。该项目是一个基于 CPU 的软件光栅化渲染引擎,不依赖任何图形 API,从零实现了完整的渲染管线。本文将从项目架构、核心技术、关键实现细节三个维度进行分析,并给出后续的重构方向。


一、项目整体架构

App Layer main.cpp 主循环 / 命令行解析 SceneManager 场景类型初始化 CameraController WASD / 鼠标 / 滚轮 Core Layer Scene 对象 / 光源 / 资源 Renderer 矩阵 / 着色器状态 FrameBuffer Color / Depth / MSAA Pipeline vertex / raster / frag Graphics Layer Camera lookAt / perspective Mesh / OBJ 切线 / 三角化 Material Surface / 纹理 GUID Shader Layer IShader 接口 BasicShader (Unlit) PhongShader (Blinn-Phong) ToonShader / ShadowMapShader Lib Layer maths.h (Vec2/3/4, Matrix4x4) IResource / ResourceManager Texture (Mipmap / Sampler) TGA IO / texture_utils Utils Profiler (RAII 宏, 线程安全) utils.cpp (copyFrameBuffer / savePPM) Platform Layer platform.h (C 接口:窗口 / 输入 / 时间) platform_sdl.cpp (SDL2 实现) 依赖方向:上层依赖下层 ↓

各层职责一览

层次 核心职责 关键文件
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 坐标变换链

Model Space 模型局部坐标 World Space 光照计算空间 View Space 相机为原点 Clip Space 顶点着色器输出 NDC → Screen [-1,1]³ → 像素坐标 × M × V × P ÷w / map

关键细节:项目的投影矩阵使用右手坐标系,m32 = -1,这决定了背面剔除时叉积符号的判断方向(reverseFactor)。World Space 是光照计算的基础空间,顶点着色器中同时输出 positionWSclipPos


3.2 重心坐标 与 EdgeFunction 优化

v₀ v₁ v₂ P A₀ A₁ A₂ 重心坐标 λ₀ = A₀ / A_total λ₁ = A₁ / A_total λ₂ = 1 - λ₀ - λ₁ P在内部 ⟺ λᵢ ≥ 0 EdgeFunction e = dx·y + dy·x + c dx = y₀ - y₁ dy = x₁ - x₀ 沿x步进: Δe = dy

项目对大三角形使用分块(BLOCK_SIZE=16)扫描,在块的起始点预计算 EdgeFunction 值,块内通过增量 Δe = dy(沿 x)/ Δe = dx(沿 y)避免重复乘法,这正是 GPU 硬件光栅化器的经典做法。


3.3 透视正确插值

直接对屏幕空间属性线性插值会产生透视畸变,必须对 1/w 插值后再还原:

❌ 线性插值(错误) 网格线在屏幕空间均匀 → 透视畸变 ✅ 透视正确插值 Step 1: 对 1/w 插值 interp_invW = Σ λᵢ·(1/wᵢ) Step 2: 对 attr/w 插值 interp_attr = Σ λᵢ·(attrᵢ/wᵢ) Step 3: 还原 attr_correct = interp_attr / interp_invW correction = 1.0f / interp_invW

代码路径:fragment.cppcalculatePerspectiveWeights()interpolateVaryings(),所有顶点属性(位置、法线、UV、切线)都经过此校正。


3.4 深度测试与 Z-Buffer

FrameBuffer 内存布局 colorBuffer[W×H×4] uint8_t RGBA per pixel → SDL ARGB32 输出 depthBuffer[W×H] float NDC-z 初始化 1.0f(最远),通过 depth < stored msaaDepthBuffer[W×H×4] + msaaSampleCount[W×H] 当前执行顺序(存在优化空间) interpolateVaryings fragmentShader depthTest → setPixel ⚠ TODO: Early-Z 应在插值前先做 depthTest,避免 昂贵 shader 白跑 (Alpha Test 除外)

3.5 MSAA 多重采样抗锯齿

4× MSAA 采样点分布(单像素内) 三角形边缘 未覆盖采样点 已覆盖采样点 accumulateMSAAColor() 混合逻辑 每通过一个采样点的深度测试: msaaSampleCount[pixel]++ blendFactor = 1.0f / sampleCount color = old*(1-f) + new*f (运行平均) 最终像素颜色 = 所有通过样本的平均 ⚠ 并行路径存在竞态:多线程同时写入同一像素 需要原子操作或 Tile-based 分区来规避

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 矩阵

网格表面 T tangentWS B cross(N,T)×w N normalWS Normal Map RGB ∈ [0,1] 切线空间法线 n n.xyz = RGB × 2 - 1 (映射到 [-1, 1]) 世界空间法线 N = T·n.x + B·n.y + N·n.z tangent.w 存符号 ±1 处理UV镜像

tangent.w±1mesh.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 纹理采样系统

texture.sample(uv) SamplerState 描述 applyWrapMode Clamp / Repeat / Mirror Filter Mode POINT → samplePoint LINEAR → sampleBilinear Mipmap 选择 TRILINEAR: 插值相邻两级 float4 color 双线性插值(4点采样) c00(1-fx)(1-fy) + c10·fx(1-fy) + c01(1-fx)fy + c11·fx·fy fx, fy = 采样点在像素内的小数偏移

3.10 并行化策略

三角形像素数 AABB 内总像素 > 1024px → OMP 并行块遍历 #pragma omp parallel collapse(2) schedule(guided) ≤ 1024px → 串行扫描线 EdgeFunction 增量,无线程调度开销 BLOCK_SIZE = 16 提升 Cache 命中率 copyToPlatform() std::thread 按行分块 ARGB转换 shadowPass 深度复制 #pragma omp parallel collapse(2) ⚠ FrameBuffer 写入竞态 同一像素可能被多线程同时写入

四、已实现功能 / 现存问题

状态 功能点
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 光栅化是同时解决并行竞态和提升缓存局部性的最佳方案:

屏幕分块(Tile 8×8) Tile Worker 模型 Tile Queue per-tile 三角形列表 Thread Pool 各 Tile 独立处理 ✅ 无跨 Tile 写入竞态 每 Thread 独立写入自己 Tile 的 FrameBuffer ✅ Cache 局部性大幅提升 每 Tile 数据 fits in L2 Cache

5.3 功能:PBR 材质

从 Blinn-Phong 升级到 Cook-Torrance 微平面 BRDF,引入 Roughness 和 Metallic 两个新参数:

1
2
3
4
5
f_specular = D(h) · F(v,h) · G(l,v,h) / (4 · (N·L) · (N·V))

D: GGX/Trowbridge-Reitz 法线分布函数
F: Schlick Fresnel 近似 F₀ + (1-F₀)(1-cosθ)^5
G: Smith 遮蔽-阴影函数

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