24、C++项目实战:简易3D渲染器(软件渲染)
说实话,做软件渲染器这件事,我大学时就干过。那时候显卡还没这么猛,想看看3D模型长啥样,只能自己用CPU一笔一笔画。现在回过头来看,虽然性能比不上GPU的万分之一,但理解图形学的底层逻辑,这绝对是最扎实的一条路。
这一章,我们就手撸一个简易的3D软件渲染器。不依赖OpenGL、DirectX,纯C++代码,从顶点到像素,全流程自己控制。
渲染管线概览:从模型到屏幕
先看整体流程。我习惯把渲染管线拆成四个阶段:
- 顶点变换:把3D坐标变成屏幕上的2D坐标
- 光栅化:把三角形变成一堆像素点
- 着色:给每个像素算颜色
- 输出:把颜色写到帧缓冲区
说白了,就是把一个三维世界“拍扁”到二维屏幕上。你想想看,这个过程跟摄影师拍照其实很像——只是我们用的是数学公式。
核心思想:软件渲染器不依赖任何图形API,所有计算都在CPU上完成。虽然慢,但每一步你都能看到、能控制、能优化。
第一步:数学基础与坐标变换
没有矩阵运算,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,就再也不会忘记初始化。
这一章的内容就到这里。代码量不大,但每一步都值得你亲手敲一遍。相信我,跑通第一个三角形的那一刻,你会觉得一切都值了。