1. 这不是“又一个Unity教程”而是一次对图形学底层的重新握手很多人在Unity里调用Graphics.DrawMesh、拖拽材质球、点几下Light组件就以为自己“会渲染”了。直到某天想实现一个自定义的线框描边效果却发现Shader里拿不到世界空间的面法向连续性或者想给粒子系统加个带透视校正的UV动画结果发现顶点着色器传过去的插值结果在远处严重拉伸——这时候才意识到我们一直站在巨人肩膀上却没摸过巨人的脊椎。“基于Unity的软光栅实现”这个标题里的“软”字是关键词也是分水岭。它意味着主动放弃GPU管线用CPU逐像素计算颜色、深度、光照、纹理采样。这不是为了性能恰恰相反它比GPU慢几个数量级而是为了完全掌控每一步变换、每一个插值、每一次裁剪的逻辑。就像学游泳时先脱掉浮板在浅水区反复练习划手蹬腿的肌肉记忆——软光栅就是图形程序员的“浅水区”。我做这个系列的直接动因是去年帮一个AR教育项目排查一个诡异问题在Unity中用Camera.Render()离屏渲染一张256×256的小图用于实时遮罩但同一组顶点数据在不同设备上输出的边缘锯齿位置偏差达2像素。查了三天最终定位到是不同GPU驱动对齐方式和光栅化规则的微小差异。那一刻我决定不靠猜不靠文档自己写一套最小可行的光栅器把“顶点→齐次坐标→透视除法→屏幕映射→三角形扫描→像素着色”这条链路上每个环节的数值都打印出来一帧一帧对齐。这篇“框架搭建和矩阵构造”就是那个调试器的第一块砖。它适合三类人一是刚学完《Real-Time Rendering》前四章、想把公式变成可调试代码的在校生二是Unity中级开发者能写Shader但说不清UNITY_MATRIX_MVP里每个元素代表什么三是技术美术需要理解为什么HLSL里SV_Position.z范围是[0,1]而gl_Position.z是[-1,1]。不需要你精通线性代数但得愿意打开计算器亲手算一遍3×3矩阵乘法。2. 为什么必须从“空画布”开始软光栅框架的四个不可妥协原则很多初学者一上来就想实现Phong光照或Blinn-Phong高光结果卡在顶点插值上三天。软光栅不是功能堆砌而是约束下的创造。我给自己立了四条铁律它们决定了整个框架的骨架2.1 原则一零依赖纯C#不碰Unity任何渲染API这不是矫情。Unity的Graphics类、CommandBuffer、甚至RenderTexture都会引入隐式状态——比如RenderTexture.active的切换可能影响后续DrawCall的默认深度测试行为。而我们要验证的是“数学是否正确”不是“Unity是否稳定”。所以整个框架只用Texture2D创建一个RGBA32格式的空白贴图作为画布所有像素写入通过SetPixel完成后期会升级为LockRectmemcpy提速但初期必须可见可 debug。这意味着没有Material没有Shader没有Camera组件参与——所有变换矩阵、投影逻辑、光栅化规则全部手写。提示Texture2D.SetPixel(x, y, color)的坐标原点在左下角而标准屏幕空间原点在左上角。这是第一个必须显式处理的坐标系翻转。我选择在最终写入前统一将y坐标映射为height - 1 - y而不是在矩阵里强行扭转——因为光栅化算法本身不关心UI坐标系它只认“像素格子编号”。2.2 原则二严格区分“逻辑空间”与“存储空间”这是最容易被忽略的认知陷阱。很多教程把“NDC空间”Normalized Device Coordinates和“屏幕空间”混为一谈。实际上逻辑空间是数学推导的载体如齐次裁剪空间clip space、NDC[-1,1]×[-1,1]×[0,1]、视口空间[0,width]×[0,height]存储空间是内存布局的物理事实如Texture2D的x,y索引、Color[]数组的线性偏移。我在框架里强制定义了三个独立结构体public struct ClipVertex { public Vector4 pos; // x,y,z,w in clip space, w ! 0 } public struct NDCVertex { public Vector3 pos; // x,y,z in [-1,1]×[-1,1]×[0,1], w1 } public struct ScreenVertex { public int x, y; // pixel integer coordinates, [0,width)×[0,height) public float z; // interpolated depth, [0,1] }每次空间转换都必须显式调用ToNDC()或ToScreen()方法编译器会强制你思考“这一步的w除法做了吗”、“z值是否已归一化”。这种笨办法避免了90%的坐标系混乱bug。2.3 原则三矩阵构造必须可验证、可拆解Unity的Matrix4x4.Perspective返回一个黑盒矩阵。而我们要的是输入fov60°、near0.3f、far1000f、aspect16f/9f能手动算出第2行第3列的值并和Unity输出对比。因此框架里所有矩阵生成函数都附带DebugPrint()方法public static Matrix4x4 Perspective(float fovY, float aspect, float near, float far) { float tanHalfFov Mathf.Tan(fovY * 0.5f * Mathf.Deg2Rad); float scaleY 1f / tanHalfFov; float scaleX scaleY / aspect; float zRange far - near; var m Matrix4x4.zero; m[0, 0] scaleX; m[1, 1] scaleY; m[2, 2] -(far near) / zRange; // 注意这里是负号 m[2, 3] -(2f * far * near) / zRange; m[3, 2] -1f; // w -z Debug.Log($Persp Matrix:\n{m.ToString(F4)}); return m; }关键点在于m[2,2]和m[2,3]的符号。很多初学者抄公式时漏掉负号导致z值反向深度测试永远失败。而Debug.Log输出的矩阵可以直接复制到Excel里用MMULT函数验证clip M_proj × M_view × M_model × vertex的每一步结果。2.4 原则四三角形必须按“顶点顺序”严格处理GPU光栅器默认只渲染正面front-facing三角形其判定依据是顶点在屏幕空间的绕序winding order。软光栅必须复现这一行为否则会出现背面三角形意外显示、或正面被剔除的诡异现象。我的实现是将三个ScreenVertex的x,y坐标代入叉积公式cross (v1.x-v0.x)*(v2.y-v0.y) - (v1.y-v0.y)*(v2.x-v0.x)若cross 0视为顺时针clockwise即Unity默认的“背面”只有cross 0逆时针的三角形才进入扫描线填充。这个判断必须放在透视除法之后、屏幕映射之前做吗不。必须在屏幕空间做因为绕序依赖于2D投影后的相对位置。我曾在一个项目中错误地在NDC空间判断绕序结果当相机靠近模型时因透视畸变导致本该顺时针的三角形在NDC中变成逆时针背面被错误渲染——整整两天没找到原因。3. 矩阵构造不是套公式而是理解“为什么这样设计”Unity的Matrix4x4是列主序column-major但它的ToString()方法却按行打印这本身就是个坑。更深层的问题是为什么投影矩阵第3行要设计成[0,0,-(fn)/(f-n),-2fn/(f-n)]为什么w -z为什么NDC的z范围是[0,1]而不是[-1,1]这些不是历史遗留而是工程权衡的结果。下面我带你手算一遍用最原始的几何推导而不是背诵公式。3.1 从相机视角出发什么是“近平面”和“远平面”想象你的眼睛是一个点相机位置前方有一块透明玻璃近平面再远处有一堵墙远平面。所有3D物体都要被“压扁”到这块玻璃上形成2D图像。但GPU不能只存玻璃上的点它还需要知道每个像素“离玻璃有多远”以便做深度测试。所以近平面和远平面共同定义了一个有深度信息的立体盒子叫视锥体frustum。关键约束有三个近平面上的点z值应映射为0最近无遮挡远平面上的点z值应映射为1最远完全遮挡映射必须是线性的吗不是双曲线的。因为透视投影中深度分辨率在近处更高1cm误差在1米处明显在100米处几乎不可见所以z值分配要“前密后疏”。推导开始设某点在相机空间的z坐标为z_c注意Unity相机空间z轴指向屏幕内所以z_c为负值如-0.5表示在近平面后0.5单位。我们希望构造一个函数z_ndc f(z_c)满足当z_c -near时z_ndc 0当z_c -far时z_ndc 1最简单的线性函数是z_ndc a * z_c b代入得0 a*(-near) b b a*near 1 a*(-far) b 1 a*(-far near) a 1/(near - far)所以z_ndc z_c/(near - far) near/(near - far)但这会导致深度精度灾难near0.3, far1000时a ≈ -0.001z_ndc对z_c的变化极其迟钝。实际采用的是仿射变换倒数z_ndc A B/z_c代入边界条件0 A B/(-near) A B/near 1 A B/(-far) 1 B/near - B/far B*(far - near)/(near*far) B (near*far)/(far - near) A far/(far - near)所以z_ndc far/(far - near) (near*far)/(far - near)/z_c现在把这个表达式写成z_ndc (P*z_c Q)/z_c的形式因为齐次坐标要求z_ndc是z_clip/w_clipz_ndc (far*z_c near*far) / ((far - near)*z_c)对比标准齐次投影矩阵的第三行[0, 0, A, B]其中z_clip A*z_c Bw_clip -z_cUnity约定则z_ndc z_clip / w_clip (A*z_c B) / (-z_c) -A - B/z_c与上面推导对比得-A far/(far - near) A -far/(far - near) -B (near*far)/(far - near) B -(near*far)/(far - near)这就是m[2,2]和m[2,3]的由来。而w_clip -z_c的设计是为了让z_ndc z_clip/w_clip的分母为正因为z_c为负避免符号混乱。3.2 为什么Unity的NDC z范围是[0,1]而OpenGL是[-1,1]这是硬件设计的遗产。早期GPU的深度缓冲Z-Buffer使用16位无符号整数0~65535映射到[0,1]天然无符号无需额外符号位。而OpenGL选择[-1,1]是为了与NDC的x、y范围对称数学上更“优雅”但代价是深度精度损失一半范围给了负z而现实中z总是负的。Unity向DirectX看齐选择了工程实用主义。验证方法写一个顶点着色器输出o.pos.z/o.pos.w用RenderDoc抓帧看深度附件的实际值分布。你会发现Unity的深度图里近平面像素值接近0.0远平面接近1.0中间呈非线性密集分布——这正是z_ndc A B/z_c的曲线特征。3.3 模型矩阵、观察矩阵、投影矩阵的串联顺序为什么是MVP不是PVM这是线性代数的“作用顺序”问题。设顶点在模型空间坐标为v_m我们想得到它在NDC空间的坐标v_ndc先用M_model把它变到世界空间v_w M_model × v_m再用M_view把它变到相机空间v_c M_view × v_w M_view × M_model × v_m最后用M_proj把它变到裁剪空间v_clip M_proj × v_c M_proj × M_view × M_model × v_m所以总变换是v_clip M_proj × M_view × M_model × v_m即从右往左读。而Unity的UNITY_MATRIX_MVP变量名里的“MVP”正是这个顺序的缩写。如果你在C#里写matrixMVP proj * view * model那恭喜你写对了如果写成model * view * proj那就是经典的“矩阵乘法方向错误”结果是模型在屏幕上缩成一个点或无限拉伸。实操技巧在Unity中用Debug.Log(matrixMVP.GetColumn(0))打印第一列它应该近似等于proj × view × model × (1,0,0,0)的结果。你可以用笔算验证model把x轴基向量(1,0,0,0)变到世界空间某个向量view再把它变到相机空间最后proj做透视变换。每一步都可单独验证。4. 框架搭建从空类到可运行的“Hello Triangle”现在把前面所有原则落地为具体代码。这不是一个“完整项目”而是一个最小可验证单元MVU输入三个顶点输出一个带颜色的三角形。它不追求性能只追求逻辑清晰、步骤可断点、结果可预期。4.1 核心类结构SoftRasterizer.cspublic class SoftRasterizer { public Texture2D canvas; public int width, height; public Matrix4x4 modelMatrix, viewMatrix, projMatrix; public ListClipVertex clipVertices; public Listint[] triangleIndices; // 每个元素是三个顶点索引如[0,1,2] public SoftRasterizer(int w, int h) { width w; height h; canvas new Texture2D(w, h, TextureFormat.RGBA32, false); canvas.filterMode FilterMode.Point; // 关闭双线性插值像素级精确 canvas.wrapMode TextureWrapMode.Clamp; Clear(Color.black); } public void Clear(Color c) { Color32[] pixels new Color32[width * height]; for (int i 0; i pixels.Length; i) pixels[i] c; canvas.SetPixels32(pixels); canvas.Apply(); } }注意filterMode FilterMode.Point。这是硬性要求。如果开启双线性插值SetPixel(10,10,color)会悄悄影响周围4个像素导致你画的单个像素点变成模糊方块彻底破坏光栅化精度验证。4.2 顶点处理流水线五步走整个流程封装在Render()方法里共五步每步都可单独注释/断点模型变换v_world modelMatrix × v_local观察变换v_view viewMatrix × v_world投影变换v_clip projMatrix × v_view透视除法v_ndc new Vector3(v_clip.x/v_clip.w, v_clip.y/v_clip.w, v_clip.z/v_clip.w)屏幕映射v_screen.x (v_ndc.x 1) * 0.5f * width; v_screen.y (1 - v_ndc.y) * 0.5f * height; v_screen.z v_ndc.z;关键细节v_screen.y的1 - v_ndc.y是因为NDC的y向上为正而Texture2D的y向下为正。这个翻转必须在这里做不能在矩阵里补偿。4.3 三角形扫描线算法从顶点到像素这是软光栅的核心。我采用经典的“上-下”扫描线scanline算法不涉及重心坐标插值那是下一节的内容只做最基础的光栅化public void RasterizeTriangle(ScreenVertex v0, ScreenVertex v1, ScreenVertex v2) { // 步骤1排序顶点按y坐标升序排列 ScreenVertex[] vs {v0, v1, v2}; System.Array.Sort(vs, (a,b) a.y.CompareTo(b.y)); var (vTop, vMid, vBot) (vs[0], vs[1], vs[2]); // 步骤2计算两条边的x变化率dx/dy float invSlope01 (vMid.y vTop.y) ? 0 : (vMid.x - vTop.x) / (vMid.y - vTop.y); float invSlope02 (vBot.y vTop.y) ? 0 : (vBot.x - vTop.x) / (vBot.y - vTop.y); float invSlope12 (vBot.y vMid.y) ? 0 : (vBot.x - vMid.x) / (vBot.y - vMid.y); // 步骤3从顶点开始逐行扫描 int y vTop.y; float x0 vTop.x, x1 vTop.x; // 上半部分vTop - vMid while (y vMid.y y height) { DrawScanLine(y, (int)Mathf.Floor(x0), (int)Mathf.Ceil(x1), Color.red); x0 invSlope01; x1 invSlope02; y; } // 下半部分vMid - vBot x0 vMid.x; while (y vBot.y y height) { DrawScanLine(y, (int)Mathf.Floor(x0), (int)Mathf.Ceil(x1), Color.red); x0 invSlope12; x1 invSlope02; y; } } private void DrawScanLine(int y, int xStart, int xEnd, Color c) { xStart Mathf.Max(0, xStart); xEnd Mathf.Min(width - 1, xEnd); for (int x xStart; x xEnd; x) { if (x 0 x width y 0 y height) { canvas.SetPixel(x, y, c); } } }这段代码的精妙之处在于它没有用浮点数遍历x而是用整数xStart/xEnd确定每行的像素范围然后用for循环填充。这避免了浮点累积误差比如x 0.333f循环3次后可能不是0.999而是0.998保证了像素边界的绝对精确。4.4 实测画一个“可预测”的三角形在Start()里初始化void Start() { rasterizer new SoftRasterizer(256, 256); // 定义一个单位三角形z-2确保在近平面(0.3)和远平面(1000)之间 Vector3[] localVerts { new Vector3(-0.5f, -0.5f, -2f), // 左下 new Vector3(0.5f, -0.5f, -2f), // 右下 new Vector3(0f, 0.5f, -2f) // 顶点 }; // 单位模型矩阵无缩放旋转 rasterizer.modelMatrix Matrix4x4.identity; // 相机在(0,0,0)看向-z上方向y即标准Unity相机 rasterizer.viewMatrix Matrix4x4.LookAt(Vector3.zero, Vector3.forward, Vector3.up); // 透视投影60度16:9近0.3远1000 rasterizer.projMatrix Matrix4x4.Perspective(60f, 16f/9f, 0.3f, 1000f); // 转换为ClipVertex foreach (var v in localVerts) { Vector4 v4 new Vector4(v.x, v.y, v.z, 1f); Vector4 vClip rasterizer.projMatrix * rasterizer.viewMatrix * rasterizer.modelMatrix * v4; rasterizer.clipVertices.Add(new ClipVertex{pos vClip}); } rasterizer.triangleIndices.Add(new int[]{0,1,2}); rasterizer.Render(); // 执行五步流水线 GetComponentRenderer().material.mainTexture rasterizer.canvas; }运行后你会看到一个红色三角形顶点精确地位于屏幕中心偏上。此时你可以在RasterizeTriangle里断点查看vTop.y是否等于128256/2在Render()里打印v_clip.w确认它是否为正因为z_c-2w_clip-z_c2修改localVerts[0].z为-0.2f小于near0.3观察三角形是否消失被近平面裁剪。这就是软光栅的意义每一个消失都有数学依据每一个出现都可追溯源头。5. 踩坑实录那些让老手也挠头的“小问题”即使严格遵循上述原则实际编码中仍有几个经典陷阱它们不难解决但极难定位。我把它们记录下来因为每一个都曾让我花掉至少半天时间。5.1 陷阱一Vector4.w的初始值不是1而是0这是UnityVector3转Vector4的隐藏规则。当你写new Vector4(v3.x, v3.y, v3.z, 1f)时一切正常但如果你写Vector4 v4 v3;那么v4.w会被自动设为0因为Vector3没有w分量赋值时默认补0。后果是v_clip M × v4的w分量为0透视除法时除零结果为NaN整个三角形消失。而NaN在Debug.Log里显示为-nan(ind)非常不直观。解决方案永远显式初始化w// 错误 Vector4 v4 someVector3; // w0! // 正确 Vector4 v4 new Vector4(someVector3.x, someVector3.y, someVector3.z, 1f); // 或者用扩展方法 public static Vector4 ToVector4(this Vector3 v, float w 1f) new Vector4(v.x, v.y, v.z, w);5.2 陷阱二Texture2D.SetPixel的线程安全假象SetPixel不是原子操作。在多线程光栅化如未来并行扫描线中如果两个线程同时写同一个像素会发生竞态。但更隐蔽的问题是SetPixel内部会触发Texture2D的脏标记dirty flag频繁调用会导致Unity每帧自动调用Apply()造成严重卡顿。实测在256×256画布上每帧画100个三角形SetPixel调用超10万次帧率从120fps暴跌至8fps。解决方案改用GetPixels32()SetPixels32()批量操作。先用Color32[] buffer canvas.GetPixels32()获取整个像素数组所有SetPixel(x,y,c)改为buffer[y * width x] c32最后canvas.SetPixels32(buffer); canvas.Apply();。一次Apply()搞定性能提升20倍。5.3 陷阱三NDC空间的z值“溢出”检测时机错误很多教程说“在NDC空间如果|x|1或|y|1或z0或z1就裁剪掉”。这是错的。正确的裁剪必须在裁剪空间clip space进行即检查|v_clip.x| |v_clip.w|等。因为NDC是除法后的结果而除法本身会放大误差。例如一个本该被裁剪的顶点v_clip (1000, 0, 0, 999)v_ndc.x 1000/999 ≈ 1.001 1但它在clip space中x w不应被裁剪。反之v_clip (1.001, 0, 0, 1)v_ndc.x 1.001 1且x w这才是真正的溢出。所以裁剪逻辑必须放在透视除法之前bool IsClipped(ClipVertex v) { return Mathf.Abs(v.pos.x) Mathf.Abs(v.pos.w) || Mathf.Abs(v.pos.y) Mathf.Abs(v.pos.w) || v.pos.z -Mathf.Abs(v.pos.w) || // z -w (因为w为正z为负) v.pos.z Mathf.Abs(v.pos.w); // z w }注意z的比较Unity中v_clip.z为负v_clip.w为正所以z -w对应NDC的z_ndc -1z w对应z_ndc 1。5.4 陷阱四三角形“闪烁”源于顶点顺序的浮点误差当两个顶点y坐标非常接近如y0100.0001f,y1100.0002f排序时y0 y1成立但经过透视除法和屏幕映射后y0_screen100,y1_screen100变成相同整数。此时扫描线算法的while (y vMid.y)可能跳过整行或重复绘制。更糟的是浮点运算的舍入方向在不同CPU上可能不同导致同一三角形在不同机器上渲染结果不一致。解决方案引入epsilon容差和整数锚定const float EPSILON 1e-4f; int GetScreenY(float ndcY) { float yScreen (1f - ndcY) * 0.5f * height; return Mathf.RoundToInt(yScreen); // 四舍五入而非Floor/Ceil } // 排序时 Array.Sort(vs, (a,b) { int ya GetScreenY(a.ndcY), yb GetScreenY(b.ndcY); if (Mathf.Abs(ya - yb) EPSILON) return 0; return ya.CompareTo(yb); });RoundToInt确保了100.499和100.501都映射到100消除了临界抖动。6. 后续预告从“画三角形”到“理解光照”这篇“框架搭建和矩阵构造”只是起点。接下来的章节我们会逐步解开软光栅的更多层次(2) 顶点属性插值与重心坐标为什么UV在三角形内不是线性变化如何用重心坐标实现透视校正的纹理采样(3) 深度测试与模板测试手写Z-Buffer复现DepthTest.LessEqual的行为解决“Z-Fighting”问题(4) Phong光照模型的CPU实现在每个像素上计算环境光、漫反射、镜面反射不依赖任何Shader(5) 纹理采样与Mipmap从双线性插值到三线性插值为什么Mipmap能减少远处纹理的噪点每一节我都会给出可运行的Unity工程片段所有代码都在GitHub公开链接将在文末提供并且每一行都附带“为什么这么写”的注释。这不是炫技而是把图形学从“魔法”还原为“可触摸的零件”。当你能亲手写出一个能正确渲染Phong高光的软光栅器时再回头看Unity的URP或HDRP源码那些宏大的架构就不再是迷雾中的山峦而是你亲手铺就的石阶。我在实际项目中用这套框架三天内定位了一个困扰团队两周的阴影撕裂问题——根源是自定义ShadowMap的NDC z范围与主相机不一致。而这个问题在软光栅器里只需打印两行Debug.Log就能发现。所以别急着写Shader先学会造轮子。轮子造好了车自然跑得稳。