1. 项目概述从零构建一个C 3D渲染器最近在社区里看到不少朋友对图形学感兴趣但一提到用C写3D渲染总觉得门槛太高被DirectX、OpenGL那些复杂的API和管线概念吓退了。其实用最基础的C和现代的开发环境比如Visual Studio 2022实现一个能画出立体图形的“软渲染器”是理解3D图形核心原理绝佳的敲门砖。这就像学开车不一定非要先精通发动机原理从方向盘、油门、刹车开始同样能上路。这个项目要做的就是一个“软渲染器”。它不依赖任何现成的图形API如OpenGL或DirectX而是完全由我们自己用C代码从数学计算到像素绘制一步步把三维空间的点、线、面“画”到二维的屏幕上。听起来很底层没错但它的价值正在于此。通过亲手实现从三维坐标变换、三角形光栅化到简单的着色计算这一整套流程你会对矩阵、向量、投影、深度缓冲这些图形学基石有刻骨铭心的理解。这远比直接调用glDrawArrays()收获大得多。我们将使用VS2022作为开发环境它强大的C编译器、直观的调试器和集成的NuGet包管理器能让我们的开发过程顺畅不少。最终目标是渲染出一个旋转的3D立方体并为其添加基础的漫反射光照效果。虽然最终效果比不上游戏引擎但这个过程所揭示的原理是通用的。2. 核心原理与项目架构设计在动手写代码之前我们必须把核心的图形管线Graphics Pipeline流程想清楚。所谓“软渲染”就是用自己的代码模拟一遍标准图形硬件GPU所做的事情。我们的迷你管线主要包含以下几个核心阶段2.1 顶点处理阶段从三维世界到二维屏幕这是所有3D渲染的起点。我们有一个定义在三维空间中的模型比如立方体的8个顶点。但屏幕是二维的如何把三维的点映射上去这需要一系列数学变换模型变换 (Model Transformation)物体通常定义在自己的“模型空间”里。我们需要通过平移、旋转、缩放矩阵将其放置到我们设定的“世界空间”中的某个位置和姿态。视图变换 (View Transformation)想象我们有一台摄像机。视图变换就是将整个世界连同其中的物体一起进行变换使得摄像机位于坐标原点并且看向Z轴负方向或正方向约定不同。这相当于把世界坐标系转换到了“摄像机坐标系”。投影变换 (Projection Transformation)这是产生3D感的关键。我们采用最经典的透视投影。其原理类似于小孔成像离摄像机近的物体看起来大远的看起来小。通过一个特定的透视投影矩阵我们可以将摄像机视锥体一个平头截体内的三维坐标变换到一个标准的立方体空间称为归一化设备坐标NDC。在这个空间里坐标范围在[-1, 1]之间。视口变换 (Viewport Transformation)最后我们将NDC空间中的坐标映射到我们实际的屏幕像素坐标上。例如将x从[-1, 1]映射到[0, 屏幕宽度-1]y从[-1, 1]映射到[0, 屏幕高度-1]。所有这些变换都可以通过顶点坐标左乘一个接一个的4x4矩阵来完成。最终我们得到一个屏幕空间下的二维坐标x, y和一个表示深度的z值。2.2 三角形光栅化阶段将几何体转化为像素经过顶点处理我们得到了构成模型表面的三角形在立方体中是12个三角形的每个顶点在屏幕上的位置。但屏幕是由离散的像素组成的。光栅化Rasterization的任务就是确定哪些像素被这个三角形覆盖。我们不会实现复杂的最优算法而是采用最直观的“边界盒扫描法”找出三角形三个顶点在屏幕坐标中x和y的最大最小值确定一个能包围它的矩形区域边界盒。遍历这个矩形区域内的每一个像素点。对于每个像素点计算其重心坐标Barycentric Coordinates。重心坐标α, β, γ表示该点相对于三角形三个顶点的权重且满足 α β γ 1。如果该像素的重心坐标全部在[0, 1]范围内或大于等于0则说明该像素位于三角形内部需要被着色。在这个过程中我们还需要进行深度测试Z-Buffering。每个像素除了颜色还有一个深度值Z值。在光栅化时我们同样用重心坐标插值计算出当前像素的深度。在绘制该像素前检查当前深度是否比深度缓冲区Z-Buffer中对应位置存储的深度值更小离相机更近。如果是则更新该像素的颜色并更新深度缓冲区否则说明这个像素被前面的物体挡住了直接丢弃。这是解决物体前后遮挡关系的核心手段。2.3 片段着色阶段为像素赋予颜色确定了像素属于某个三角形后就要计算它的颜色。我们实现一个最简单的漫反射光照模型Lambertian Diffuse。法线插值首先我们需要知道这个像素点所在三角形表面的朝向法线向量。我们在三角形的每个顶点定义好法线对于立方体每个面的法线方向一致。在光栅化时同样利用重心坐标对三个顶点的法线向量进行插值得到当前像素点的插值后法线。注意直接插值得到的法线需要重新归一化使其长度变为1否则会影响光照计算。光照计算我们设定一个光源方向例如从右上角照射过来的平行光。漫反射的光强遵循兰伯特余弦定律光强 max(0, dot(N, L))。其中N是归一化的像素法线L是归一化的指向光源的方向向量dot是点积运算。点积结果就是两个向量夹角的余弦值当法线朝向光源时最亮垂直时光照为0背向时光照为负值我们通过max函数将其截断为0。颜色混合将计算出的漫反射光强一个0到1之间的系数与物体本身的基色例如立方体的某个面是红色相乘就得到了该像素最终的颜色。2.4 项目代码结构规划为了让代码清晰可维护我们提前规划好模块数学库 (Math)实现Vec3三维向量、Vec4、Mat44x4矩阵等基础类以及点乘、叉乘、矩阵乘法、矩阵求逆用于视图矩阵等操作。这是我们一切计算的基础。几何体 (Geometry)定义Vertex包含位置、法线、颜色等属性、Triangle、Mesh模型的集合等结构。渲染器核心 (Renderer Core)包含Camera摄像机参数、Light光源信息类以及最重要的SoftwareRenderer类。这个类将封装帧缓冲区颜色缓冲、深度缓冲区Z-Buffer并提供DrawTriangle、RenderMesh等核心方法。主循环与显示 (Main Loop)在Windows上我们可以创建一个窗口并处理消息循环。在每次循环中更新模型旋转角度清空缓冲区执行渲染最后将帧缓冲区的内容显示到窗口上。为了简化我们可以先将结果保存为图片文件或者使用一个简单的位图显示库。注意在真正的软渲染器中矩阵运算和像素遍历是性能瓶颈。我们首要目标是清晰正确因此不会做复杂的SIMD优化。但在关键循环中避免动态内存分配、将计算移出内层循环等基础优化是必要的。3. 开发环境搭建与基础数学库实现工欲善其事必先利其器。一个稳定高效的开发环境能让我们更专注于算法本身。3.1 Visual Studio 2022 项目配置创建新项目打开VS2022选择“创建新项目” - “控制台应用(C)”命名为SoftRenderer。选择C20或C17标准以获得更好的语言特性支持。调整运行时库在项目属性 - “C/C” - “代码生成” - “运行时库”中选择“多线程调试(/MTd)”用于Debug模式“多线程(/MT)”用于Release模式。这会将C运行时静态链接到你的程序生成独立的exe避免目标机器缺少运行库的问题。启用警告与优化在“C/C” - “常规” - “警告等级”设置为“等级4(/W4)”并将“将警告视为错误”设置为“是(/WX)”。这能帮助我们在早期发现许多潜在问题。在Release配置下将“优化”设置为“最大化速度(/O2)”。处理Windows窗口由于我们要创建窗口并显示图像需要链接Windows图形子系统。在项目属性 - “链接器” - “系统” - “子系统”中选择“窗口(/SUBSYSTEM:WINDOWS)”。同时在“高级” - “入口点”中清空内容因为我们将使用WinMain而不是main作为入口。或者为了快速验证算法前期我们可以先不创建窗口而是将渲染结果输出为.ppm或.bmp图片文件这样更纯粹。3.2 核心数学类实现向量与矩阵所有3D图形计算都建立在向量和矩阵之上。我们将实现一个最小化但功能完备的数学库。Vec3 类// Math/Vec3.h #pragma once #include cmath #include cassert class Vec3 { public: float x, y, z; Vec3(float x_ 0, float y_ 0, float z_ 0) : x(x_), y(y_), z(z_) {} // 基础运算 Vec3 operator(const Vec3 other) const { return Vec3(x other.x, y other.y, z other.z); } Vec3 operator-(const Vec3 other) const { return Vec3(x - other.x, y - other.y, z - other.z); } Vec3 operator*(float scalar) const { return Vec3(x * scalar, y * scalar, z * scalar); } Vec3 operator/(float scalar) const { assert(scalar ! 0); float inv 1.0f / scalar; return Vec3(x * inv, y * inv, z * inv); } // 点积、叉积 float Dot(const Vec3 other) const { return x * other.x y * other.y z * other.z; } Vec3 Cross(const Vec3 other) const { return Vec3(y * other.z - z * other.y, z * other.x - x * other.z, x * other.y - y * other.x); } // 长度、归一化 float Length() const { return std::sqrt(x*x y*y z*z); } Vec3 Normalized() const { float len Length(); return (len 0) ? (*this / len) : Vec3(); } };Mat4 类 4x4矩阵是实现所有空间变换的核心。我们将其存储为一个16个float的一维数组采用**列优先Column-Major**存储。这是OpenGL和数学库如GLM的常用方式在进行矩阵乘法时逻辑更清晰变换向量时向量是列向量右乘矩阵。// Math/Mat4.h #pragma once #include Vec3.h #include Vec4.h // 需要实现一个Vec4类用于齐次坐标 class Mat4 { public: float m[16]; // 索引顺序: m[0], m[1], m[2], m[3] 是第一列以此类推。 Mat4(float diag 1.0f) { // 默认构造为单位矩阵 for (int i 0; i 16; i) m[i] 0.0f; m[0] m[5] m[10] m[15] diag; } // 矩阵乘法 (this * other) Mat4 operator*(const Mat4 other) const { Mat4 result(0.0f); for (int col 0; col 4; col) { for (int row 0; row 4; row) { float sum 0.0f; for (int k 0; k 4; k) { sum m[k * 4 row] * other.m[col * 4 k]; // 注意索引计算 } result.m[col * 4 row] sum; } } return result; } // 矩阵乘以向量 (Mat4 * Vec4)返回Vec4 Vec4 operator*(const Vec4 v) const { return Vec4( m[0]*v.x m[4]*v.y m[8]*v.z m[12]*v.w, m[1]*v.x m[5]*v.y m[9]*v.z m[13]*v.w, m[2]*v.x m[6]*v.y m[10]*v.z m[14]*v.w, m[3]*v.x m[7]*v.y m[11]*v.z m[15]*v.w ); } // 静态方法创建变换矩阵 static Mat4 Translate(float tx, float ty, float tz); static Mat4 RotateX(float angle); // 弧度制 static Mat4 RotateY(float angle); static Mat4 RotateZ(float angle); static Mat4 Scale(float sx, float sy, float sz); static Mat4 Perspective(float fovY, float aspect, float zNear, float zFar); static Mat4 LookAt(const Vec3 eye, const Vec3 target, const Vec3 up); };以Translate和Perspective为例Mat4 Mat4::Translate(float tx, float ty, float tz) { Mat4 mat(1.0f); mat.m[12] tx; mat.m[13] ty; mat.m[14] tz; return mat; } Mat4 Mat4::Perspective(float fovY, float aspect, float zNear, float zFar) { Mat4 mat(0.0f); float tanHalfFovy std::tan(fovY / 2.0f); mat.m[0] 1.0f / (aspect * tanHalfFovy); mat.m[5] 1.0f / tanHalfFovy; mat.m[10] -(zFar zNear) / (zFar - zNear); mat.m[11] -1.0f; mat.m[14] -(2.0f * zFar * zNear) / (zFar - zNear); return mat; }LookAt矩阵的推导稍复杂其目标是生成一个矩阵将世界坐标系变换到以eye为原点看向targetup方向为上的坐标系。核心是构造三个相互正交的单位向量前向(F)、右向(R)、上向(U)然后组合成矩阵。网上有标准公式实现时务必注意方向。实操心得在实现矩阵乘法时一定要写一个小测试程序用几个简单的变换如先平移后旋转验证结果是否正确。矩阵乘法的顺序行优先 vs 列优先左乘 vs 右乘是图形学新手最容易混淆的地方之一。我们这里采用列向量、列优先存储、变换矩阵左乘的约定。即Vec4 result ProjectionMatrix * ViewMatrix * ModelMatrix * VertexPosition;。4. 渲染管线核心流程的C实现有了数学基础我们就可以开始搭建渲染器的骨架了。我们将创建一个SoftwareRenderer类它管理着帧缓冲区、深度缓冲区并提供了最核心的DrawTriangle函数。4.1 数据结构定义顶点、三角形与网格首先定义顶点结构体它携带了后续所有阶段需要的信息// Geometry/Vertex.h #pragma once #include ../Math/Vec3.h #include ../Math/Vec4.h struct Vertex { Vec4 position; // 齐次坐标 (x, y, z, w) Vec3 normal; // 法线向量 Vec3 color; // RGB颜色 // 后续可扩展纹理坐标等 Vertex() default; Vertex(const Vec4 pos, const Vec3 nrm, const Vec3 col) : position(pos), normal(nrm), color(col) {} };三角形由三个顶点构成同时我们还需要一个简单的网格Mesh结构来管理一个模型的所有三角形。// Geometry/Triangle.h #pragma once #include Vertex.h struct Triangle { Vertex v0, v1, v2; Triangle(const Vertex a, const Vertex b, const Vertex c) : v0(a), v1(b), v2(c) {} }; // Geometry/Mesh.h #pragma once #include vector #include Triangle.h class Mesh { public: std::vectorTriangle triangles; // 可以添加加载简单模型如.obj格式的方法 void AddTriangle(const Triangle tri) { triangles.push_back(tri); } };4.2 渲染器类与缓冲区管理SoftwareRenderer类是核心// Renderer/SoftwareRenderer.h #pragma once #include vector #include ../Math/Mat4.h #include ../Geometry/Mesh.h class SoftwareRenderer { private: int m_width, m_height; std::vectorVec3 m_frameBuffer; // 颜色缓冲区每个像素一个Vec3(R,G,B) std::vectorfloat m_depthBuffer; // 深度缓冲区 (Z-Buffer) Mat4 m_viewMatrix; Mat4 m_projMatrix; Vec3 m_lightDir; // 光源方向归一化 public: SoftwareRenderer(int width, int height); void Clear(const Vec3 clearColor, float depth 1.0f); // 清空缓冲区 void SetViewMatrix(const Mat4 view) { m_viewMatrix view; } void SetProjMatrix(const Mat4 proj) { m_projMatrix proj; } void SetLightDirection(const Vec3 lightDir) { m_lightDir lightDir.Normalized(); } // 核心渲染函数 void RenderMesh(const Mesh mesh, const Mat4 modelMatrix); void DrawTriangle(const Vertex v0, const Vertex v1, const Vertex v2); // 获取结果 const std::vectorVec3 GetFrameBuffer() const { return m_frameBuffer; } void SaveToPPM(const char* filename) const; // 输出为PPM图片格式 };在构造函数中初始化缓冲区SoftwareRenderer::SoftwareRenderer(int width, int height) : m_width(width), m_height(height) { m_frameBuffer.resize(width * height, Vec3(0,0,0)); m_depthBuffer.resize(width * height, 1.0f); // 深度初始化为远平面值1.0 }Clear函数用于在每一帧开始前重置缓冲区void SoftwareRenderer::Clear(const Vec3 clearColor, float depth) { std::fill(m_frameBuffer.begin(), m_frameBuffer.end(), clearColor); std::fill(m_depthBuffer.begin(), m_depthBuffer.end(), depth); }4.3 顶点着色与坐标变换在RenderMesh函数中我们需要遍历网格的每一个三角形对每个顶点应用MVPModel-View-Projection变换。void SoftwareRenderer::RenderMesh(const Mesh mesh, const Mat4 modelMatrix) { Mat4 mvp m_projMatrix * m_viewMatrix * modelMatrix; // 注意乘法顺序 for (const auto tri : mesh.triangles) { // 对每个顶点进行变换 Vertex v0_trans tri.v0; Vertex v1_trans tri.v1; Vertex v2_trans tri.v2; // 应用MVP矩阵变换顶点位置 v0_trans.position mvp * tri.v0.position; v1_trans.position mvp * tri.v1.position; v2_trans.position mvp * tri.v2.position; // 透视除法将齐次坐标的x, y, z除以w得到归一化设备坐标(NDC) v0_trans.position.PerspectiveDivide(); v1_trans.position.PerspectiveDivide(); v2_trans.position.PerspectiveDivide(); // 变换法线注意法线变换需要用模型矩阵的逆转置矩阵以保持垂直关系。 // 这里为了简单假设模型矩阵只包含旋转和均匀缩放可以用模型矩阵的左上3x3部分。 // 如果包含非均匀缩放则需要逆转置。本例先假设只有旋转。 Mat3 normalMatrix modelMatrix.GetUpper3x3(); // 需要从Mat4中提取3x3旋转部分 v0_trans.normal (normalMatrix * tri.v0.normal).Normalized(); v1_trans.normal (normalMatrix * tri.v1.normal).Normalized(); v2_trans.normal (normalMatrix * tri.v1.normal).Normalized(); // 将NDC坐标转换到屏幕空间坐标 // NDC的x,y范围是[-1,1]需要映射到[0, width-1]和[0, height-1] // 注意屏幕坐标系通常y轴向下而NDC的y轴向上所以y坐标需要翻转。 auto ToScreenSpace [this](Vec4 ndcPos) { float screenX (ndcPos.x 1.0f) * 0.5f * (m_width - 1); float screenY (1.0f - ndcPos.y) * 0.5f * (m_height - 1); // Y翻转 // Z值保留用于深度测试。NDC的Z范围通常是[-1,1]或[0,1]取决于投影矩阵。 // 我们Perspective矩阵生成的是[-1,1]这里先保留。 return Vec3(screenX, screenY, ndcPos.z); }; v0_trans.position Vec4(ToScreenSpace(v0_trans.position), v0_trans.position.w); v1_trans.position Vec4(ToScreenSpace(v1_trans.position), v1_trans.position.w); v2_trans.position Vec4(ToScreenSpace(v2_trans.position), v2_trans.position.w); // 调用三角形绘制函数 DrawTriangle(v0_trans, v1_trans, v2_trans); } }关键细节PerspectiveDivide()函数是Vec4的一个方法将x, y, z分别除以w。经过透视投影后w分量存储了视图空间中的深度信息即-z进行透视除法后就得到了具有透视效果的NDC坐标。另外法线变换是一个易错点。如果模型矩阵包含非均匀缩放直接用法线左乘模型矩阵会破坏法线与表面的垂直关系必须使用模型矩阵的逆转置矩阵的左上3x3部分。在我们的简单例子中如果只做旋转直接用旋转部分矩阵即可。4.4 三角形光栅化与深度测试实现这是最核心的DrawTriangle函数。我们采用边界盒扫描线算法并加入重心坐标计算和深度插值。void SoftwareRenderer::DrawTriangle(const Vertex v0, const Vertex v1, const Vertex v2) { // 1. 计算三角形的屏幕空间边界盒 float minX std::min({v0.position.x, v1.position.x, v2.position.x}); float maxX std::max({v0.position.x, v1.position.x, v2.position.x}); float minY std::min({v0.position.y, v1.position.y, v2.position.y}); float maxY std::max({v0.position.y, v1.position.y, v2.position.y}); // 将边界盒限制在屏幕范围内 int startX static_castint(std::max(0.0f, std::floor(minX))); int endX static_castint(std::min(static_castfloat(m_width-1), std::ceil(maxX))); int startY static_castint(std::max(0.0f, std::floor(minY))); int endY static_castint(std::min(static_castfloat(m_height-1), std::ceil(maxY))); // 2. 预计算一些用于重心坐标计算的值 Vec2 p0(v0.position.x, v0.position.y); Vec2 p1(v1.position.x, v1.position.y); Vec2 p2(v2.position.x, v2.position.y); // 计算整个三角形的面积的两倍用于重心坐标归一化 float area EdgeFunction(p0, p1, p2); // EdgeFunction计算由两点构成的向量与第三点的有符号面积 // 如果面积为0或反向背面剔除则跳过。这里我们简单处理不进行背面剔除。 if (area 0) return; // 或者可以取绝对值来处理双面渲染 // 3. 遍历边界盒内的每个像素 for (int y startY; y endY; y) { for (int x startX; x endX; x) { Vec2 p(static_castfloat(x) 0.5f, static_castfloat(y) 0.5f); // 取像素中心 // 计算重心坐标 (alpha, beta, gamma) float alpha EdgeFunction(p1, p2, p) / area; float beta EdgeFunction(p2, p0, p) / area; float gamma EdgeFunction(p0, p1, p) / area; // 4. 判断像素是否在三角形内包括边上 if (alpha 0 beta 0 gamma 0) { // 5. 深度值插值 (使用重心坐标) // 注意需要对深度值进行透视校正插值。直接使用重心坐标插值屏幕空间Z是错误的。 // 透视校正插值公式 interpolated(1/Z) α*(1/Z0) β*(1/Z1) γ*(1/Z2) // 然后 interpolated(attribute) (α*A0/Z0 β*A1/Z1 γ*A2/Z2) / interpolated(1/Z) // 其中A是任何需要在顶点间插值的属性如颜色、法线、纹理坐标等。 // 这里我们简化处理假设三角形很小或距离相机较远近似使用屏幕空间线性插值。 // 正确的实现应使用透视校正我们先实现简化版。 // 简化版屏幕空间线性插值深度不准确但代码简单 float depth alpha * v0.position.z beta * v1.position.z gamma * v2.position.z; // 6. 深度测试 int idx y * m_width x; if (depth m_depthBuffer[idx]) { // 我们假设NDC的Z越小离相机越近 // 通过深度测试更新深度缓冲 m_depthBuffer[idx] depth; // 7. 属性插值法线、颜色并进行着色计算 Vec3 normal (v0.normal * alpha v1.normal * beta v2.normal * gamma).Normalized(); Vec3 color v0.color * alpha v1.color * beta v2.color * gamma; // 漫反射光照计算 float diffuse std::max(0.0f, normal.Dot(m_lightDir)); Vec3 finalColor color * diffuse; // 8. 写入帧缓冲区 m_frameBuffer[idx] finalColor; } } } } } // 辅助函数计算有符号面积边函数 float EdgeFunction(const Vec2 a, const Vec2 b, const Vec2 c) { return (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x); }注意事项这里有两个非常重要的简化在实际项目中必须纠正背面剔除Backface Culling我们只是简单地在三角形面积为负时返回。更标准的做法是在屏幕空间或视图空间计算三角形的法线通过边叉乘如果法线指向屏幕内与视线方向点积为正则剔除。这能减少近一半的三角形绘制。透视校正插值Perspective-Correct Interpolation这是新手实现软渲染器最常见的错误。在透视投影下屏幕空间的重心坐标不能直接用于在三维空间中对属性如颜色、纹理坐标、法线进行线性插值否则会导致明显的失真尤其是纹理。必须使用上面注释中提到的透视校正公式。正确的插值需要对每个顶点的属性除以其裁剪空间下的w值即v.position.w在透视除法前在屏幕空间插值这些attribute/w和1/w最后在像素处将插值后的(attribute/w)除以插值后的(1/w)来得到正确的属性值。这是实现高质量渲染的关键一步。4.5 图像输出与主循环为了看到结果我们需要将帧缓冲区保存为图片。PPM格式是一种最简单的无损位图格式易于读写。void SoftwareRenderer::SaveToPPM(const char* filename) const { std::ofstream file(filename, std::ios::binary); if (!file) return; file P6\n m_width m_height \n255\n; for (const auto color : m_frameBuffer) { // 将[0,1]的浮点颜色值转换到[0,255]的整数 unsigned char r static_castunsigned char(std::clamp(color.x, 0.0f, 1.0f) * 255); unsigned char g static_castunsigned char(std::clamp(color.y, 0.0f, 1.0f) * 255); unsigned char b static_castunsigned char(std::clamp(color.z, 0.0f, 1.0f) * 255); file.write(reinterpret_castconst char*(r), 1); file.write(reinterpret_castconst char*(g), 1); file.write(reinterpret_castconst char*(b), 1); } file.close(); }最后在main函数中我们设置场景、创建模型、配置摄像机并执行渲染循环。int main() { const int width 800; const int height 600; SoftwareRenderer renderer(width, height); // 1. 创建摄像机 Vec3 eye(3, 3, 5); Vec3 target(0, 0, 0); Vec3 up(0, 1, 0); Mat4 view Mat4::LookAt(eye, target, up); renderer.SetViewMatrix(view); // 2. 创建透视投影矩阵 float fovY 45.0f * 3.14159265f / 180.0f; // 转弧度 float aspect static_castfloat(width) / height; Mat4 proj Mat4::Perspective(fovY, aspect, 0.1f, 100.0f); renderer.SetProjMatrix(proj); // 3. 设置光源方向 renderer.SetLightDirection(Vec3(1, 1, 1).Normalized()); // 4. 创建一个立方体网格手动定义8个顶点和12个三角形面 Mesh cubeMesh CreateCubeMesh(); // 需要实现这个函数 // 5. 渲染循环这里简化为渲染一帧 renderer.Clear(Vec3(0.2f, 0.2f, 0.2f)); // 灰色背景 // 让立方体绕Y轴旋转 static float angle 0.0f; angle 0.01f; Mat4 model Mat4::RotateY(angle) * Mat4::Scale(1.0f, 1.0f, 1.0f); renderer.RenderMesh(cubeMesh, model); // 6. 保存结果 renderer.SaveToPPM(output.ppm); std::cout Rendering finished. Image saved to output.ppm std::endl; return 0; }CreateCubeMesh函数需要手动定义立方体的8个顶点坐标、每个面的法线6个方向并将立方体的6个面分解为12个三角形每个面2个设置每个顶点的颜色例如每个面一种颜色。5. 常见问题、调试技巧与优化方向即使按照步骤实现了所有代码第一次运行时很可能看不到任何图形或者图形严重扭曲。别灰心这是学习图形学的必经之路。下面是一些排查问题的思路和技巧。5.1 渲染结果异常排查清单现象可能原因排查步骤全黑/无图像1. 三角形被裁剪或位于视锥体外。2. 深度缓冲区初始值设置不当所有像素深度测试失败。3. 光照计算错误漫反射系数为0。4. 顶点坐标定义错误三角形面积为零或为负被背面剔除。1. 打印1-2个变换后的顶点屏幕坐标确认其在[0, width/height]范围内。2. 将深度测试条件暂时注释掉强制绘制所有像素。3. 将光照计算diffuse固定为1.0看是否有颜色。4. 在DrawTriangle开头打印三角形面积检查顶点顺序。图像扭曲、拉伸1. 宽高比Aspect Ratio计算错误。2. 透视投影矩阵参数FOV, Near, Far设置极端。3. 屏幕空间坐标映射公式错误Y轴方向。1. 检查aspect width / height是否为浮点数除法。2. 尝试使用正交投影矩阵进行对比测试。3. 检查ToScreenSpace函数中Y坐标的1.0f - ndcPos.y操作。颜色插值明显错误如三角形内部颜色突变1. 重心坐标计算错误EdgeFunction符号。2. 属性颜色、法线插值前未进行透视校正。1. 在三角形中心点重心验证重心坐标之和是否为1且每个分量约为1/3。2.实现透视校正插值这是解决此问题的根本方法。深度测试失效后面物体挡住前面1. 深度值插值错误未使用透视校正。2. 深度缓冲区比较符号错误还是。3. 投影矩阵生成的NDC的Z范围是[-1,1]还是[0,1]与深度缓冲区初始化值不匹配。1. 实现正确的透视校正深度插值。2. 确认你的坐标系约定是右手系Z朝屏幕外还是左手系Z朝屏幕内NDC的Z哪个方向代表近。3. 将深度缓冲区初始值设为远平面值透视投影下通常是1.0比较时用。三角形边缘有锯齿没有进行抗锯齿Anti-Aliasing。这是正常现象。软渲染器中实现抗锯齿可以通过超采样SSAA例如在每个像素内采样4个点2x2计算覆盖率然后取平均颜色。这会增加4倍计算量。5.2 实用的调试技巧单步调试与数据监视在VS2022中对DrawTriangle函数内部设置断点监视变换后的顶点坐标、计算出的重心坐标、插值后的深度和颜色值。这是最直接的定位问题的方法。渲染线框模式修改DrawTriangle只绘制三角形的三条边使用Bresenham画线算法。这可以帮你快速确认三角形的顶点位置和连接关系是否正确排除光栅化内部逻辑的问题。输出中间结果将MVP变换后的顶点坐标裁剪空间或NDC空间打印到控制台或日志文件。与手动计算或使用已知正确的库如GLM的结果进行对比。使用简单几何体不要一开始就渲染复杂的立方体。先从单个三角形开始最好是一个在屏幕中心、大小适中的三角形。确认它能正确渲染后再尝试两个有重叠的三角形测试深度缓冲。最后再上立方体。可视化深度缓冲区将深度缓冲区的值归一化后作为灰度图输出。你可以看到场景的深度分布这对于调试深度相关的问题非常有用。5.3 性能优化与扩展方向当你的软渲染器能正确工作后可以考虑以下优化和扩展这能极大提升你的理解和工程能力算法优化包围盒优化当前的边界盒遍历了所有像素。可以先判断三角形是否完全在屏幕外进行快速剔除。扫描线光栅化比边界盒法更高效。先对三角形顶点按Y排序然后计算左右两条边的递增量逐行填充。增量式计算在扫描线算法中许多值如深度、属性可以在相邻像素间通过加法更新避免每像素都进行重心坐标计算。功能扩展纹理映射为Vertex添加纹理坐标(uv)在片段着色阶段进行纹理采样。注意必须使用透视校正插值来插值uv坐标。更复杂的光照实现冯氏Phong光照模型包含环境光、漫反射和高光镜面反射分量。模型加载实现一个简单的.obj文件加载器可以渲染更复杂的模型。简单的着色器系统将顶点变换和片段着色抽象成可配置的函数向可编程管线迈进一小步。显示优化实时窗口显示使用Windows GDI、Direct2D或者简单的如stb_image_write配合一个GUI框架来实时显示帧缓冲区而不是每次都保存为文件。双缓冲在实时显示时使用双缓冲避免画面撕裂。实现一个软渲染器的过程就像亲手搭建了一台简易的图形计算机。你会对每个像素的由来、每一次矩阵乘法的意义、深度测试如何解决遮挡有前所未有的具体认知。虽然它很慢无法用于实际应用但这份理解是后续学习OpenGL、Vulkan乃至游戏引擎渲染架构最坚实的基石。当你再看到着色器、渲染管线这些术语时脑海中浮现的不再是黑盒而是你曾亲手实现过的那一串串代码和数据流。