24、C++项目实战:简易3D渲染器(软件渲染)

说实话,做软件渲染器这件事,我大学时就干过。那时候显卡还没这么猛,想看看3D模型长啥样,只能自己用CPU一笔一笔画。现在回过头来看,虽然性能比不上GPU的万分之一,但理解图形学的底层逻辑,这绝对是最扎实的一条路。

这一章,我们就手撸一个简易的3D软件渲染器。不依赖OpenGL、DirectX,纯C++代码,从顶点到像素,全流程自己控制。

渲染管线概览:从模型到屏幕

先看整体流程。我习惯把渲染管线拆成四个阶段:

  1. 顶点变换:把3D坐标变成屏幕上的2D坐标
  2. 光栅化:把三角形变成一堆像素点
  3. 着色:给每个像素算颜色
  4. 输出:把颜色写到帧缓冲区

说白了,就是把一个三维世界“拍扁”到二维屏幕上。你想想看,这个过程跟摄影师拍照其实很像——只是我们用的是数学公式。

核心思想:软件渲染器不依赖任何图形API,所有计算都在CPU上完成。虽然慢,但每一步你都能看到、能控制、能优化。

简易3D软件渲染管线 顶点变换 模型→视图→投影 光栅化 三角形→像素覆盖 像素着色 颜色计算+光照 帧缓冲输出 写入像素 输入:3D模型顶点 → 输出:2D屏幕像素 每个阶段都是纯CPU计算,不依赖GPU 顶点数据 片段数据 颜色数据 像素数据 每帧重复执行

第一步:数学基础与坐标变换

没有矩阵运算,3D渲染就是空中楼阁。我当年第一次写渲染器时,就在矩阵乘法上栽了跟头——顺序搞反了,模型直接飞到了屏幕外面。

你需要实现这几个核心数学工具:

  • 向量类:Vec3f,支持加减、点乘、叉乘
  • 矩阵类:Mat4f,4x4齐次坐标矩阵
  • 变换函数:平移、旋转、缩放、透视投影

嗯,这里要注意:矩阵乘法不满足交换律。变换顺序是:先缩放,再旋转,最后平移。顺序错了,结果完全不一样。

我的经验:调试矩阵变换时,先在纸上画一个简单的立方体,手动算一遍坐标。等代码跑出来的结果跟手算一致,再上复杂模型。这招帮我省了不少调试时间。

第二步:光栅化——把三角形变成像素

光栅化说白了就是:给定三个顶点,找出屏幕上哪些像素属于这个三角形。最经典的方法是扫描线算法重心坐标法

我个人偏爱重心坐标法,因为它不光能判断像素是否在三角形内,还能顺便做插值——颜色、深度、纹理坐标都能一起算出来。

// 判断点P是否在三角形ABC内(重心坐标法)
bool insideTriangle(Vec2f A, Vec2f B, Vec2f C, Vec2f P) {
    Vec2f v0 = C - A;
    Vec2f v1 = B - A;
    Vec2f v2 = P - A;
    
    float dot00 = v0.dot(v0);
    float dot01 = v0.dot(v1);
    float dot02 = v0.dot(v2);
    float dot11 = v1.dot(v1);
    float dot12 = v1.dot(v2);
    
    float invDenom = 1.0f / (dot00 * dot11 - dot01 * dot01);
    float u = (dot11 * dot02 - dot01 * dot12) * invDenom;
    float v = (dot00 * dot12 - dot01 * dot02) * invDenom;
    
    return (u >= 0) && (v >= 0) && (u + v <= 1);
}

这段代码看起来简单,但效率其实不高。我曾经在一个项目里用这个版本渲染了10万个三角形,结果一帧要算好几秒。后来改成边界函数法,速度提升了将近5倍。

第三步:深度缓冲与遮挡处理

没有深度缓冲,后面的三角形会覆盖前面的,画面就乱了。深度缓冲的原理很简单:每个像素存一个深度值,新像素的深度更小(离相机更近)才覆盖旧的。

注意:深度缓冲的精度问题很坑。我曾经在渲染一个很大的场景时,远处的地面出现了严重的“深度闪烁”——两个三角形深度值太接近,导致像素来回切换。解决办法是用更高精度的浮点数,或者调整远近裁剪面的距离。

实现时,我建议用std::vector<float>作为深度缓冲区,大小跟屏幕分辨率一致。每帧开始前初始化为无穷大,然后逐像素比较更新。

第四步:简单光照与着色

没有光照的3D渲染,看起来就是一堆纯色块。我们至少要实现一个漫反射光照

// 计算漫反射颜色
Vec3f computeDiffuse(Vec3f normal, Vec3f lightDir, Vec3f lightColor, Vec3f baseColor) {
    float intensity = std::max(0.0f, normal.dot(lightDir));
    return baseColor * lightColor * intensity;
}

这里的关键是法线方向。每个三角形的法线可以通过两条边的叉乘得到。但注意:法线要归一化,否则光照强度会出问题。我见过有人忘了归一化,结果模型亮一块暗一块,查了半天才发现。

如果你想更进一步,可以加上环境光(ambient)和镜面高光(specular)。不过对于这个简易渲染器,漫反射已经够用了。

完整代码结构

我把整个渲染器拆成了几个文件,方便管理:

文件 作用
geometry.h Vec3f、Mat4f 等数学工具
model.h/cpp 加载 .obj 模型文件
renderer.h/cpp 核心渲染管线:变换、光栅化、着色
main.cpp 初始化窗口、循环渲染、保存图片

main.cpp 里的主循环大概长这样:

int main() {
    Model model("cube.obj");
    Renderer renderer(800, 600);
    
    for (int frame = 0; frame < 60; ++frame) {
        renderer.clear();          // 清空帧缓冲和深度缓冲
        renderer.setModelMatrix(rotationY(frame * 0.01f));
        renderer.setViewMatrix(cameraPos, targetPos);
        renderer.setProjectionMatrix(45.0f, 800.0f/600.0f, 0.1f, 100.0f);
        
        for (auto& face : model.faces) {
            renderer.drawTriangle(face);
        }
        
        renderer.saveToFile("frame_" + std::to_string(frame) + ".ppm");
    }
    return 0;
}

看到没?整个流程就这几步。你想想看,GPU 内部其实也是这么干的,只不过人家是并行处理,我们是串行。

性能优化小技巧

软件渲染器慢是正常的。但我们可以做一些优化:

  • 背面剔除:法线背对相机的三角形直接跳过,省一半计算量
  • 包围盒裁剪:只处理屏幕范围内的像素
  • 整数运算代替浮点:光栅化时用定点数,速度能快不少

我曾经在一个嵌入式项目里做软件渲染,CPU只有200MHz。靠着背面剔除和定点数优化,硬是把帧率从2帧提到了15帧。虽然还是卡,但至少能看了。

写在最后

软件渲染器虽然在实际项目中很少直接使用,但它是理解图形学的最佳实践。你亲手写过一遍光栅化,就知道GPU到底在帮你做什么;你调过一遍深度缓冲的bug,就再也不会忘记初始化。

这一章的内容就到这里。代码量不大,但每一步都值得你亲手敲一遍。相信我,跑通第一个三角形的那一刻,你会觉得一切都值了。


公众号:蓝海资料掘金营,微信 deep3321