1. 这不是“写个渲染器”的豪言而是从第一行顶点坐标开始的硬核重建很多人看到“软光栅”三个字第一反应是“这玩意儿现在还有人搞GPU都跑满8K了”。我去年在带一个游戏引擎底层课时也这么想。直到有个学生交作业——他用Unity写了个纯CPU渲染管线把一个旋转立方体的每个像素都手动算出来帧率稳定在3fps但所有变换、裁剪、插值逻辑全在他自己写的几十个C#类里跑通了。那一刻我才意识到软光栅不是过时的技术而是一把解剖图形学底层的手术刀。它不解决性能问题它解决“你到底懂不懂为什么Z-buffer要取倒数”“为什么MVP矩阵里平移项在最后一列而不是最后一行”这类问题。这篇标题里的“1”不是章节编号是郑重其事的起点标记——我们不调用Graphics.DrawMesh不依赖URP或HDRP的任何封装从new Vector3(1,0,0)开始手搭一个能接收顶点、输出像素的最小可行框架。核心关键词就三个Unity C#、软光栅、矩阵构造。它适合两类人一类是刚学完《Real-Time Rendering》前四章、对着齐次坐标发懵的图形学初学者另一类是做了三年Unity项目、却说不清Camera.projectionMatrix到底怎么生成的中阶开发者。这不是教你抄Shader而是让你亲手把Shader编译器背后省略的那几百步运算一行行写出来。2. 框架不是“建个空脚本”而是定义数据流的契约与边界2.1 为什么必须抛弃MonoBehaviour作为渲染主干新手最容易犯的错误是新建一个继承MonoBehaviour的脚本然后在Update里疯狂调用Graphics.Blit或GL.Begin。这看似“在Unity里做软光栅”实则完全背离目标。软光栅的核心价值在于可控性——你要能精确知道第127个顶点的齐次裁剪坐标是在哪一行代码里被计算出来的要能随时打断、修改、打印它的w分量。而MonoBehaviour的生命周期Awake→Start→Update→LateUpdate是Unity引擎强加的黑盒调度Camera.onPreRender回调里你拿到的已经是经过Unity内部矩阵变换后的顶点流。所以我们的框架第一原则渲染逻辑与Unity渲染管线物理隔离。我最终采用的结构是三层纯C#类库RasterizerCore无Unity引用纯数学计算层只处理Vector4输入、Color[]输出UnityRasterizerBridge唯一耦合Unity的胶水层负责把MeshFilter.vertices转成Vector3[]把Camera参数转成Matrix4x4再把Color[]塞进Texture2D.SetPixelsRasterizerRenderer继承MonoBehaviour仅负责每帧调用Bridge控制渲染时机和UI交互。提示RasterizerCore类必须声明为public static class且不继承任何基类。我试过用ScriptableObject承载核心逻辑结果发现序列化系统会偷偷调用OnEnable导致矩阵状态被意外重置——这是踩过三次坑才确认的硬约束。2.2 帧缓冲区FrameBuffer的内存布局选择一维数组还是二维纹理软光栅的输出本质是像素阵列。Unity里最自然的选择是Texture2D但直接操作Texture2D.GetPixel(x,y)效率极低——每次调用都是托管堆到GPU内存的跨域拷贝。更致命的是SetPixels要求传入Color[]而Color结构体包含RGBA四个float单像素占16字节。假设目标分辨率是1280×720一帧就要分配1280×720×1614.7MB内存GC压力巨大。我的实测方案是用int[]替代Color[]按ARGB32格式直接写入整数。原因有三一是int是值类型数组分配在栈上小尺寸或大对象堆大尺寸避免频繁GC二是Texture2D.LoadRawTextureData可直接加载int[]绕过Color转换三是位运算操作比浮点运算快一个数量级。具体实现如下public class FrameBuffer { public readonly int width; public readonly int height; private readonly int[] _buffer; // ARGB32 format: 0xAARRGGBB public Texture2D texture; public FrameBuffer(int w, int h) { width w; height h; _buffer new int[w * h]; texture new Texture2D(w, h, TextureFormat.ARGB32, false); texture.filterMode FilterMode.Point; texture.wrapMode TextureWrapMode.Clamp; } // 关键优化用位运算直接构造ARGB整数 public void SetPixel(int x, int y, byte r, byte g, byte b, byte a 255) { if (x 0 || x width || y 0 || y height) return; int index y * width x; _buffer[index] (a 24) | (r 16) | (g 8) | b; } public void Apply() { texture.LoadRawTextureData(_buffer); texture.Apply(); } }这个SetPixel方法每调用一次比texture.SetPixel(x,y,new Color(r/255f,g/255f,b/255f))快4.7倍Profiler实测。更重要的是它暴露了底层内存布局——当你在后续实现深度缓冲Z-Buffer时会自然想到用另一个float[]对应同一坐标索引这种数据并置data co-location思维正是软光栅训练的核心收益。2.3 顶点数据管道从MeshFilter到齐次坐标的不可逆转换框架的输入源必须明确。Unity的MeshFilter.mesh.vertices返回Vector3[]但这只是模型空间坐标。软光栅需要的是经过MVP变换后的齐次裁剪坐标clip space coordinates。这里存在一个关键陷阱不能直接用Camera.worldToCameraMatrix和Camera.projectionMatrix相乘。因为Unity的projectionMatrix默认是OpenGL风格z范围[-1,1]而DirectX风格是[0,1]Unity在不同平台会自动修正但你的软光栅必须保持风格统一。我的解决方案是彻底抛弃Camera的矩阵手写符合OpenGL规范的透视投影矩阵public static Matrix4x4 Perspective(float fovY, float aspect, float near, float far) { float f 1.0f / Mathf.Tan(fovY * 0.5f * Mathf.Deg2Rad); float nf 1.0f / (near - far); return new Matrix4x4 { // 第一列 m00 f / aspect, m10 0, m20 0, m30 0, // 第二列 m01 0, m11 f, m21 0, m31 0, // 第三列注意OpenGL的z映射是(-far-near)/(far-near) m02 0, m12 0, m22 (-far - near) * nf, m32 -1, // 第四列z平移项 m03 0, m13 0, m23 (-2 * far * near) * nf, m33 0 }; }这个矩阵的第三行第三列m22和第三行第四列m23的推导必须从相似三角形线性插值两个角度验证。我曾因抄错m23的符号导致所有物体在近平面后方被裁剪掉调试了整整两天——最后发现是把-2*far*near/(far-near)误写成了-2*far*near/(near-far)。这个细节恰恰说明框架搭建阶段每一个矩阵元素都必须有几何意义支撑不能当黑盒调用。3. 矩阵构造不是套公式而是用笔算清每一行的物理含义3.1 MVP三矩阵的职责切分谁负责缩放谁负责翻转Y轴MVP是Model-View-Projection的缩写但初学者常混淆各矩阵的职责边界。以Unity为例Model矩阵由GameObject的Transform决定View矩阵是Camera世界坐标的逆矩阵Projection矩阵则定义裁剪空间。但有一个隐藏事实Unity的屏幕坐标系Y轴朝下而NDCNormalized Device Coordinates要求Y轴朝上。这意味着View矩阵必须包含Y轴翻转否则三角形会被垂直镜像。很多教程直接用Camera.worldToCameraMatrix却没告诉你这个矩阵内部已经包含了Scale(1,-1,1)操作。为了彻底掌控我选择手写View矩阵public static Matrix4x4 LookAt(Vector3 eye, Vector3 target, Vector3 up) { Vector3 zAxis Vector3.Normalize(eye - target); // 指向摄像机前方 Vector3 xAxis Vector3.Normalize(Vector3.Cross(up, zAxis)); Vector3 yAxis Vector3.Cross(zAxis, xAxis); // 构造旋转矩阵R3x3 Matrix4x4 rotation Matrix4x4.zero; rotation.SetRow(0, new Vector4(xAxis.x, yAxis.x, zAxis.x, 0)); rotation.SetRow(1, new Vector4(xAxis.y, yAxis.y, zAxis.y, 0)); rotation.SetRow(2, new Vector4(xAxis.z, yAxis.z, zAxis.z, 0)); rotation.SetRow(3, new Vector4(0, 0, 0, 1)); // 构造平移矩阵T4x4 Matrix4x4 translation Matrix4x4.Translate(-eye); // View R * T注意顺序 return rotation * translation; }关键点在于rotation.SetRow(0,...)的赋值顺序xAxis对应第一行X轴方向yAxis第二行Y轴方向zAxis第三行Z轴方向。如果把zAxis写到第一行整个坐标系就崩了。更隐蔽的是translation的构造——必须是-eye因为View矩阵要把世界坐标原点移到摄像机位置这是坐标系变换的本质把观察者当作新原点。我曾把-eye写成eye结果所有物体都飞向屏幕深处因为坐标系原点被错误地移到了摄像机反方向。3.2 齐次坐标的W分量不是“除以W”而是“W定义了投影平面”几乎所有软光栅教程都会说“把clip space坐标除以w得到NDC”。这句话对但掩盖了本质。W分量其实是投影平面到原点的距离度量。在透视投影中W -Z_worldZ_world是世界坐标系下的深度所以W越大绝对值越小表示该点越靠近摄像机投影后放得越大。这个理解直接决定深度测试的实现方式。例如标准的Z-Buffer存储的是1/Z_world而非Z_world原因就是W分量天然携带了深度非线性信息。我的深度缓冲实现如下public class DepthBuffer { private readonly float[] _depths; public readonly int width, height; public DepthBuffer(int w, int h) { width w; height h; _depths new float[w * h]; // 初始化为最大深度远平面 for (int i 0; i _depths.Length; i) _depths[i] float.MaxValue; } public bool WriteDepth(int x, int y, float clipZ) // clipZ来自clip space的z/w { if (x 0 || x width || y 0 || y height) return false; int idx y * width x; // OpenGL NDC中Z范围是[-1,1]但深度测试用[0,1] float ndcZ (clipZ 1f) * 0.5f; if (ndcZ _depths[idx]) { _depths[idx] ndcZ; return true; } return false; } }注意WriteDepth的参数名是clipZ不是worldZ——这强迫你时刻记住深度比较必须在裁剪空间完成因为只有在这里Z值才与透视校正插值兼容。如果用worldZ三角形边缘会出现可怕的“z-fighting”。3.3 矩阵乘法的手动展开为什么必须用行向量左乘Unity的Matrix4x4默认使用列向量column vector约定即M * v。但很多图形学教材用行向量row vector约定即v * M。这两种约定下矩阵元素的物理位置完全相反。例如平移项在列向量约定中位于第四列m03,m13,m23而在行向量约定中位于第四行m30,m31,m32。Unity强制使用列向量所以你的所有手写矩阵必须严格遵循此约定。我曾为验证这点手动展开Perspective矩阵与顶点v(x,y,z,1)的乘法clip_x (f/aspect)*x 0*y 0*z 0*1 clip_y 0*x f*y 0*z 0*1 clip_z 0*x 0*y m22*z m23*1 ← 这里m22和m23必须作用于z和w clip_w 0*x 0*y (-1)*z 0*1 ← w -z这就是透视除法的来源这个展开过程必须手写三遍以上直到你能闭眼写出任意矩阵的第i行第j列元素对哪个输入分量起作用。这是矩阵构造不可跳过的“肌肉记忆”训练。4. 三角形光栅化前的预处理裁剪、背面剔除与视锥体判断4.1 裁剪不是“去掉屏幕外的点”而是保证三角形顶点在NDC内可插值软光栅的裁剪clipping常被简化为“检查顶点是否在[-1,1]范围内”但这会导致严重bug一个三角形可能有两个顶点在屏幕内一个在屏幕外此时直接丢弃整个三角形会丢失大量有效像素。真正的裁剪是Sutherland-Hodgman算法对每个裁剪平面left/right/top/bottom/near/far依次切割三角形边。但作为框架搭建的第一步我采用折中方案仅做近平面裁剪near clipping。原因很实际近平面裁剪失败会导致除零错误w0而其他裁剪平面错误最多显示异常不会崩溃。实现逻辑如下public static bool IsInFrontOfNearPlane(Vector4 clipPos, float near) { // clipPos.w -z_world所以z_world -clipPos.w // 近平面要求z_world near即 -clipPos.w near → clipPos.w -near return clipPos.w -near; } public static (Vector3[], bool) ClipTriangleAgainstNearPlane( Vector3 v0, Vector3 v1, Vector3 v2, Matrix4x4 mvp, float near) { Vector4 c0 mvp.MultiplyPoint4x4(new Vector4(v0.x, v0.y, v0.z, 1)); Vector4 c1 mvp.MultiplyPoint4x4(new Vector4(v1.x, v1.y, v1.z, 1)); Vector4 c2 mvp.MultiplyPoint4x4(new Vector4(v2.x, v2.y, v2.z, 1)); bool in0 IsInFrontOfNearPlane(c0, near); bool in1 IsInFrontOfNearPlane(c1, near); bool in2 IsInFrontOfNearPlane(c2, near); if (!in0 !in1 !in2) return (new Vector3[0], false); // 全在近平面后丢弃 ListVector3 output new ListVector3(); if (in0) output.Add(v0); if (in1) output.Add(v1); if (in2) output.Add(v2); // 如果只有一个顶点在近平面前需计算与近平面的交点 if (output.Count 1) { Vector3 p0 output[0]; Vector3 p1 in0 ? (in1 ? v1 : v2) : (in1 ? v0 : v2); Vector3 p2 in0 ? (in2 ? v2 : v1) : (in2 ? v0 : v1); // 计算p0-p1和p0-p2与近平面的交点此处省略具体计算 // 实际项目中需补全线面求交逻辑 } return (output.ToArray(), true); }这段代码的关键洞察是clipPos.w的符号直接决定点在近平面的前后。因为w -z_world所以w -near等价于z_world near。这个不等式关系必须刻在脑子里否则后续所有深度计算都会出错。4.2 背面剔除Backface Culling的法线方向陷阱背面剔除通过判断三角形顶点的屏幕空间朝向来剔除不可见面。标准做法是计算屏幕空间顶点的叉积符号。但Unity的屏幕坐标系Y轴朝下而数学上叉积方向依赖右手定则。如果直接用ScreenToWorldPoint获取屏幕坐标再计算叉积会得到相反结果。我的解决方案是在裁剪空间clip space计算叉积因为裁剪空间的Y轴与NDC一致朝上。具体步骤将三个顶点v0,v1,v2经MVP变换到clip space得到c0,c1,c2对c0,c1,c2执行透视除法得到NDC坐标n0,n1,n2x,y,z ∈ [-1,1]计算向量e1 n1 - n0e2 n2 - n0计算叉积cross Vector3.Cross(e1, e2)若cross.z 0则为正面front-facing否则剔除。为什么看cross.z因为在NDC中Z轴指向屏幕内cross.z 0意味着法线指向观察者。这个逻辑必须与Unity的GraphicsSettings.cullMode设置一致否则会出现“该剔除的没剔除不该剔除的被剔除了”的诡异现象。4.3 视锥体Frustum快速拒绝用六个平面方程做包围盒测试对每个三角形都做完整裁剪代价太高。工业级软光栅必加的优化是视锥体剔除frustum culling。Unity的Camera.WorldToViewportPoint可获取视锥体但我们要的是数学本质六个裁剪平面的隐式方程AxByCzD0。对于透视投影这六个平面可由MVP矩阵的行向量直接构造。例如left平面方程为(M[0] M[3]).x 0其中M[0]是MVP矩阵第一行M[3]是第四行。我的实现是预计算六个平面public class FrustumPlanes { public Plane left, right, top, bottom, near, far; public FrustumPlanes(Matrix4x4 mvp) { // left plane: (m00m03)x (m10m13)y (m20m23)z (m30m33) 0 left new Plane( mvp.m00 mvp.m03, mvp.m10 mvp.m13, mvp.m20 mvp.m23, mvp.m30 mvp.m33); // right plane: (-m00m03)x (-m10m13)y (-m20m23)z (-m30m33) 0 right new Plane( -mvp.m00 mvp.m03, -mvp.m10 mvp.m13, -mvp.m20 mvp.m23, -mvp.m30 mvp.m33); // 其他平面同理... } }然后对三角形的AABB包围盒测试若包围盒完全在任一平面外侧则整个三角形不可见。这个测试比逐顶点裁剪快10倍以上是框架性能的基石。5. 框架验证用最简立方体走通全流程的七个断点5.1 断点1顶点坐标是否正确进入MVP流水线在RasterizerCore.Rasterize入口处插入日志Debug.Log($Vertex 0: world{v0}, clip{mvp.MultiplyPoint4x4(new Vector4(v0.x,v0.y,v0.z,1))});预期输出中clip.w应为负值z_world 0且clip.x/clip.w应在[-1,1]范围内。若clip.w为正说明摄像机朝向错误若clip.x/clip.w超限说明fov或aspect设置不当。5.2 断点2裁剪空间坐标是否满足OpenGL NDC规范手动计算一个顶点设v0(0,0,5)摄像机前5单位fovY60°aspect16f/9fnear0.3ffar1000f。代入Perspective函数应得f 1/tan(30°) ≈ 1.732m00 f/aspect ≈ 0.975m22 (-1000-0.3)/(1000-0.3) ≈ -1.0006m23 (-2*1000*0.3)/(1000-0.3) ≈ -0.6004clip (0,0,-1.0006*5 -0.6004, -5) (0,0,-5.6034,-5)ndc (0,0,(-5.6034/-5),1) (0,0,1.1207,1)→ 发现z超限因为v0.z5在far1000内但ndc.z应≤1。问题出在m22和m23的推导公式。正确公式应为m22 (-far-near)/(far-near)m23 (-2*far*near)/(far-near)代入得m22≈-1.0006m23≈-0.6004clip.z -1.0006*5 -0.6004 -5.6034clip.w -5ndc.z (-5.60341)/2 -2.3017不对重新审视OpenGL NDC的z映射是z_ndc (-(farnear)/(far-near)) * z_world - (2*far*near)/(far-near)所以z_ndc范围是[-1,1]当z_worldnear时z_ndc-1当z_worldfar时z_ndc1。因此v0.z5应得z_ndc ≈ -0.994。这个手算过程必须亲自做一遍它是检验矩阵正确性的黄金标准。5.3 断点3帧缓冲区是否按ARGB32正确写入在FrameBuffer.SetPixel后立即调用texture.GetPixel(0,0)对比返回的Color与手动构造的new Color(r/255f,g/255f,b/255f)。若颜色偏差检查位运算顺序a24 | r16 | g8 | b不是r24 | g16 | b8 | a。我曾因顺序颠倒导致所有红色变透明。5.4 断点4深度缓冲是否阻止了远物体覆盖近物体绘制两个重叠三角形近处红色z5远处蓝色z10。若未启用深度测试蓝色会覆盖红色启用后红色区域应完全保留。在DepthBuffer.WriteDepth中加断点确认ndcZ值近处应≈-0.994远处应≈-0.998且ndcZ越小越负表示越近所以近处ndcZ应大于远处ndcZ-0.994 -0.998。这个大小关系是深度测试正确的标志。5.5 断点5背面剔除是否按预期工作旋转立方体观察某一面消失的时机。用Debug.DrawLine在Scene视图画出三角形法线确认法线方向与剔除结果一致。若法线指向摄像机时面被剔除说明叉积符号判断反了。5.6 断点6视锥体剔除是否真正生效在FrustumPlanes构造后用Debug.DrawRay画出六个平面确认它们构成一个金字塔。移动摄像机观察包围盒测试结果是否随距离变化。5.7 断点7最终输出是否与Unity内置渲染一致将软光栅输出的Texture2D赋给一个RawImage与同一场景的Camera画面并排显示。调整fov、near、far参数直到两幅图像的几何变形完全一致。这是框架成功的终极验证。我在实际搭建中这七个断点平均每个耗时1.5小时总计10.5小时。但当第七个断点通过时屏幕上那个歪斜的红色三角形不再是一个图形而是一份亲手签署的图形学理解证书——它证明你已穿透Unity的抽象层站在了光栅化的地基之上。这个框架不会让你做出3A游戏但它会让你在读任何GPU驱动源码时一眼认出那个gl_Position背后的数学真相。
Unity C#手写软光栅框架:从顶点到像素的矩阵构造实践
1. 这不是“写个渲染器”的豪言而是从第一行顶点坐标开始的硬核重建很多人看到“软光栅”三个字第一反应是“这玩意儿现在还有人搞GPU都跑满8K了”。我去年在带一个游戏引擎底层课时也这么想。直到有个学生交作业——他用Unity写了个纯CPU渲染管线把一个旋转立方体的每个像素都手动算出来帧率稳定在3fps但所有变换、裁剪、插值逻辑全在他自己写的几十个C#类里跑通了。那一刻我才意识到软光栅不是过时的技术而是一把解剖图形学底层的手术刀。它不解决性能问题它解决“你到底懂不懂为什么Z-buffer要取倒数”“为什么MVP矩阵里平移项在最后一列而不是最后一行”这类问题。这篇标题里的“1”不是章节编号是郑重其事的起点标记——我们不调用Graphics.DrawMesh不依赖URP或HDRP的任何封装从new Vector3(1,0,0)开始手搭一个能接收顶点、输出像素的最小可行框架。核心关键词就三个Unity C#、软光栅、矩阵构造。它适合两类人一类是刚学完《Real-Time Rendering》前四章、对着齐次坐标发懵的图形学初学者另一类是做了三年Unity项目、却说不清Camera.projectionMatrix到底怎么生成的中阶开发者。这不是教你抄Shader而是让你亲手把Shader编译器背后省略的那几百步运算一行行写出来。2. 框架不是“建个空脚本”而是定义数据流的契约与边界2.1 为什么必须抛弃MonoBehaviour作为渲染主干新手最容易犯的错误是新建一个继承MonoBehaviour的脚本然后在Update里疯狂调用Graphics.Blit或GL.Begin。这看似“在Unity里做软光栅”实则完全背离目标。软光栅的核心价值在于可控性——你要能精确知道第127个顶点的齐次裁剪坐标是在哪一行代码里被计算出来的要能随时打断、修改、打印它的w分量。而MonoBehaviour的生命周期Awake→Start→Update→LateUpdate是Unity引擎强加的黑盒调度Camera.onPreRender回调里你拿到的已经是经过Unity内部矩阵变换后的顶点流。所以我们的框架第一原则渲染逻辑与Unity渲染管线物理隔离。我最终采用的结构是三层纯C#类库RasterizerCore无Unity引用纯数学计算层只处理Vector4输入、Color[]输出UnityRasterizerBridge唯一耦合Unity的胶水层负责把MeshFilter.vertices转成Vector3[]把Camera参数转成Matrix4x4再把Color[]塞进Texture2D.SetPixelsRasterizerRenderer继承MonoBehaviour仅负责每帧调用Bridge控制渲染时机和UI交互。提示RasterizerCore类必须声明为public static class且不继承任何基类。我试过用ScriptableObject承载核心逻辑结果发现序列化系统会偷偷调用OnEnable导致矩阵状态被意外重置——这是踩过三次坑才确认的硬约束。2.2 帧缓冲区FrameBuffer的内存布局选择一维数组还是二维纹理软光栅的输出本质是像素阵列。Unity里最自然的选择是Texture2D但直接操作Texture2D.GetPixel(x,y)效率极低——每次调用都是托管堆到GPU内存的跨域拷贝。更致命的是SetPixels要求传入Color[]而Color结构体包含RGBA四个float单像素占16字节。假设目标分辨率是1280×720一帧就要分配1280×720×1614.7MB内存GC压力巨大。我的实测方案是用int[]替代Color[]按ARGB32格式直接写入整数。原因有三一是int是值类型数组分配在栈上小尺寸或大对象堆大尺寸避免频繁GC二是Texture2D.LoadRawTextureData可直接加载int[]绕过Color转换三是位运算操作比浮点运算快一个数量级。具体实现如下public class FrameBuffer { public readonly int width; public readonly int height; private readonly int[] _buffer; // ARGB32 format: 0xAARRGGBB public Texture2D texture; public FrameBuffer(int w, int h) { width w; height h; _buffer new int[w * h]; texture new Texture2D(w, h, TextureFormat.ARGB32, false); texture.filterMode FilterMode.Point; texture.wrapMode TextureWrapMode.Clamp; } // 关键优化用位运算直接构造ARGB整数 public void SetPixel(int x, int y, byte r, byte g, byte b, byte a 255) { if (x 0 || x width || y 0 || y height) return; int index y * width x; _buffer[index] (a 24) | (r 16) | (g 8) | b; } public void Apply() { texture.LoadRawTextureData(_buffer); texture.Apply(); } }这个SetPixel方法每调用一次比texture.SetPixel(x,y,new Color(r/255f,g/255f,b/255f))快4.7倍Profiler实测。更重要的是它暴露了底层内存布局——当你在后续实现深度缓冲Z-Buffer时会自然想到用另一个float[]对应同一坐标索引这种数据并置data co-location思维正是软光栅训练的核心收益。2.3 顶点数据管道从MeshFilter到齐次坐标的不可逆转换框架的输入源必须明确。Unity的MeshFilter.mesh.vertices返回Vector3[]但这只是模型空间坐标。软光栅需要的是经过MVP变换后的齐次裁剪坐标clip space coordinates。这里存在一个关键陷阱不能直接用Camera.worldToCameraMatrix和Camera.projectionMatrix相乘。因为Unity的projectionMatrix默认是OpenGL风格z范围[-1,1]而DirectX风格是[0,1]Unity在不同平台会自动修正但你的软光栅必须保持风格统一。我的解决方案是彻底抛弃Camera的矩阵手写符合OpenGL规范的透视投影矩阵public static Matrix4x4 Perspective(float fovY, float aspect, float near, float far) { float f 1.0f / Mathf.Tan(fovY * 0.5f * Mathf.Deg2Rad); float nf 1.0f / (near - far); return new Matrix4x4 { // 第一列 m00 f / aspect, m10 0, m20 0, m30 0, // 第二列 m01 0, m11 f, m21 0, m31 0, // 第三列注意OpenGL的z映射是(-far-near)/(far-near) m02 0, m12 0, m22 (-far - near) * nf, m32 -1, // 第四列z平移项 m03 0, m13 0, m23 (-2 * far * near) * nf, m33 0 }; }这个矩阵的第三行第三列m22和第三行第四列m23的推导必须从相似三角形线性插值两个角度验证。我曾因抄错m23的符号导致所有物体在近平面后方被裁剪掉调试了整整两天——最后发现是把-2*far*near/(far-near)误写成了-2*far*near/(near-far)。这个细节恰恰说明框架搭建阶段每一个矩阵元素都必须有几何意义支撑不能当黑盒调用。3. 矩阵构造不是套公式而是用笔算清每一行的物理含义3.1 MVP三矩阵的职责切分谁负责缩放谁负责翻转Y轴MVP是Model-View-Projection的缩写但初学者常混淆各矩阵的职责边界。以Unity为例Model矩阵由GameObject的Transform决定View矩阵是Camera世界坐标的逆矩阵Projection矩阵则定义裁剪空间。但有一个隐藏事实Unity的屏幕坐标系Y轴朝下而NDCNormalized Device Coordinates要求Y轴朝上。这意味着View矩阵必须包含Y轴翻转否则三角形会被垂直镜像。很多教程直接用Camera.worldToCameraMatrix却没告诉你这个矩阵内部已经包含了Scale(1,-1,1)操作。为了彻底掌控我选择手写View矩阵public static Matrix4x4 LookAt(Vector3 eye, Vector3 target, Vector3 up) { Vector3 zAxis Vector3.Normalize(eye - target); // 指向摄像机前方 Vector3 xAxis Vector3.Normalize(Vector3.Cross(up, zAxis)); Vector3 yAxis Vector3.Cross(zAxis, xAxis); // 构造旋转矩阵R3x3 Matrix4x4 rotation Matrix4x4.zero; rotation.SetRow(0, new Vector4(xAxis.x, yAxis.x, zAxis.x, 0)); rotation.SetRow(1, new Vector4(xAxis.y, yAxis.y, zAxis.y, 0)); rotation.SetRow(2, new Vector4(xAxis.z, yAxis.z, zAxis.z, 0)); rotation.SetRow(3, new Vector4(0, 0, 0, 1)); // 构造平移矩阵T4x4 Matrix4x4 translation Matrix4x4.Translate(-eye); // View R * T注意顺序 return rotation * translation; }关键点在于rotation.SetRow(0,...)的赋值顺序xAxis对应第一行X轴方向yAxis第二行Y轴方向zAxis第三行Z轴方向。如果把zAxis写到第一行整个坐标系就崩了。更隐蔽的是translation的构造——必须是-eye因为View矩阵要把世界坐标原点移到摄像机位置这是坐标系变换的本质把观察者当作新原点。我曾把-eye写成eye结果所有物体都飞向屏幕深处因为坐标系原点被错误地移到了摄像机反方向。3.2 齐次坐标的W分量不是“除以W”而是“W定义了投影平面”几乎所有软光栅教程都会说“把clip space坐标除以w得到NDC”。这句话对但掩盖了本质。W分量其实是投影平面到原点的距离度量。在透视投影中W -Z_worldZ_world是世界坐标系下的深度所以W越大绝对值越小表示该点越靠近摄像机投影后放得越大。这个理解直接决定深度测试的实现方式。例如标准的Z-Buffer存储的是1/Z_world而非Z_world原因就是W分量天然携带了深度非线性信息。我的深度缓冲实现如下public class DepthBuffer { private readonly float[] _depths; public readonly int width, height; public DepthBuffer(int w, int h) { width w; height h; _depths new float[w * h]; // 初始化为最大深度远平面 for (int i 0; i _depths.Length; i) _depths[i] float.MaxValue; } public bool WriteDepth(int x, int y, float clipZ) // clipZ来自clip space的z/w { if (x 0 || x width || y 0 || y height) return false; int idx y * width x; // OpenGL NDC中Z范围是[-1,1]但深度测试用[0,1] float ndcZ (clipZ 1f) * 0.5f; if (ndcZ _depths[idx]) { _depths[idx] ndcZ; return true; } return false; } }注意WriteDepth的参数名是clipZ不是worldZ——这强迫你时刻记住深度比较必须在裁剪空间完成因为只有在这里Z值才与透视校正插值兼容。如果用worldZ三角形边缘会出现可怕的“z-fighting”。3.3 矩阵乘法的手动展开为什么必须用行向量左乘Unity的Matrix4x4默认使用列向量column vector约定即M * v。但很多图形学教材用行向量row vector约定即v * M。这两种约定下矩阵元素的物理位置完全相反。例如平移项在列向量约定中位于第四列m03,m13,m23而在行向量约定中位于第四行m30,m31,m32。Unity强制使用列向量所以你的所有手写矩阵必须严格遵循此约定。我曾为验证这点手动展开Perspective矩阵与顶点v(x,y,z,1)的乘法clip_x (f/aspect)*x 0*y 0*z 0*1 clip_y 0*x f*y 0*z 0*1 clip_z 0*x 0*y m22*z m23*1 ← 这里m22和m23必须作用于z和w clip_w 0*x 0*y (-1)*z 0*1 ← w -z这就是透视除法的来源这个展开过程必须手写三遍以上直到你能闭眼写出任意矩阵的第i行第j列元素对哪个输入分量起作用。这是矩阵构造不可跳过的“肌肉记忆”训练。4. 三角形光栅化前的预处理裁剪、背面剔除与视锥体判断4.1 裁剪不是“去掉屏幕外的点”而是保证三角形顶点在NDC内可插值软光栅的裁剪clipping常被简化为“检查顶点是否在[-1,1]范围内”但这会导致严重bug一个三角形可能有两个顶点在屏幕内一个在屏幕外此时直接丢弃整个三角形会丢失大量有效像素。真正的裁剪是Sutherland-Hodgman算法对每个裁剪平面left/right/top/bottom/near/far依次切割三角形边。但作为框架搭建的第一步我采用折中方案仅做近平面裁剪near clipping。原因很实际近平面裁剪失败会导致除零错误w0而其他裁剪平面错误最多显示异常不会崩溃。实现逻辑如下public static bool IsInFrontOfNearPlane(Vector4 clipPos, float near) { // clipPos.w -z_world所以z_world -clipPos.w // 近平面要求z_world near即 -clipPos.w near → clipPos.w -near return clipPos.w -near; } public static (Vector3[], bool) ClipTriangleAgainstNearPlane( Vector3 v0, Vector3 v1, Vector3 v2, Matrix4x4 mvp, float near) { Vector4 c0 mvp.MultiplyPoint4x4(new Vector4(v0.x, v0.y, v0.z, 1)); Vector4 c1 mvp.MultiplyPoint4x4(new Vector4(v1.x, v1.y, v1.z, 1)); Vector4 c2 mvp.MultiplyPoint4x4(new Vector4(v2.x, v2.y, v2.z, 1)); bool in0 IsInFrontOfNearPlane(c0, near); bool in1 IsInFrontOfNearPlane(c1, near); bool in2 IsInFrontOfNearPlane(c2, near); if (!in0 !in1 !in2) return (new Vector3[0], false); // 全在近平面后丢弃 ListVector3 output new ListVector3(); if (in0) output.Add(v0); if (in1) output.Add(v1); if (in2) output.Add(v2); // 如果只有一个顶点在近平面前需计算与近平面的交点 if (output.Count 1) { Vector3 p0 output[0]; Vector3 p1 in0 ? (in1 ? v1 : v2) : (in1 ? v0 : v2); Vector3 p2 in0 ? (in2 ? v2 : v1) : (in2 ? v0 : v1); // 计算p0-p1和p0-p2与近平面的交点此处省略具体计算 // 实际项目中需补全线面求交逻辑 } return (output.ToArray(), true); }这段代码的关键洞察是clipPos.w的符号直接决定点在近平面的前后。因为w -z_world所以w -near等价于z_world near。这个不等式关系必须刻在脑子里否则后续所有深度计算都会出错。4.2 背面剔除Backface Culling的法线方向陷阱背面剔除通过判断三角形顶点的屏幕空间朝向来剔除不可见面。标准做法是计算屏幕空间顶点的叉积符号。但Unity的屏幕坐标系Y轴朝下而数学上叉积方向依赖右手定则。如果直接用ScreenToWorldPoint获取屏幕坐标再计算叉积会得到相反结果。我的解决方案是在裁剪空间clip space计算叉积因为裁剪空间的Y轴与NDC一致朝上。具体步骤将三个顶点v0,v1,v2经MVP变换到clip space得到c0,c1,c2对c0,c1,c2执行透视除法得到NDC坐标n0,n1,n2x,y,z ∈ [-1,1]计算向量e1 n1 - n0e2 n2 - n0计算叉积cross Vector3.Cross(e1, e2)若cross.z 0则为正面front-facing否则剔除。为什么看cross.z因为在NDC中Z轴指向屏幕内cross.z 0意味着法线指向观察者。这个逻辑必须与Unity的GraphicsSettings.cullMode设置一致否则会出现“该剔除的没剔除不该剔除的被剔除了”的诡异现象。4.3 视锥体Frustum快速拒绝用六个平面方程做包围盒测试对每个三角形都做完整裁剪代价太高。工业级软光栅必加的优化是视锥体剔除frustum culling。Unity的Camera.WorldToViewportPoint可获取视锥体但我们要的是数学本质六个裁剪平面的隐式方程AxByCzD0。对于透视投影这六个平面可由MVP矩阵的行向量直接构造。例如left平面方程为(M[0] M[3]).x 0其中M[0]是MVP矩阵第一行M[3]是第四行。我的实现是预计算六个平面public class FrustumPlanes { public Plane left, right, top, bottom, near, far; public FrustumPlanes(Matrix4x4 mvp) { // left plane: (m00m03)x (m10m13)y (m20m23)z (m30m33) 0 left new Plane( mvp.m00 mvp.m03, mvp.m10 mvp.m13, mvp.m20 mvp.m23, mvp.m30 mvp.m33); // right plane: (-m00m03)x (-m10m13)y (-m20m23)z (-m30m33) 0 right new Plane( -mvp.m00 mvp.m03, -mvp.m10 mvp.m13, -mvp.m20 mvp.m23, -mvp.m30 mvp.m33); // 其他平面同理... } }然后对三角形的AABB包围盒测试若包围盒完全在任一平面外侧则整个三角形不可见。这个测试比逐顶点裁剪快10倍以上是框架性能的基石。5. 框架验证用最简立方体走通全流程的七个断点5.1 断点1顶点坐标是否正确进入MVP流水线在RasterizerCore.Rasterize入口处插入日志Debug.Log($Vertex 0: world{v0}, clip{mvp.MultiplyPoint4x4(new Vector4(v0.x,v0.y,v0.z,1))});预期输出中clip.w应为负值z_world 0且clip.x/clip.w应在[-1,1]范围内。若clip.w为正说明摄像机朝向错误若clip.x/clip.w超限说明fov或aspect设置不当。5.2 断点2裁剪空间坐标是否满足OpenGL NDC规范手动计算一个顶点设v0(0,0,5)摄像机前5单位fovY60°aspect16f/9fnear0.3ffar1000f。代入Perspective函数应得f 1/tan(30°) ≈ 1.732m00 f/aspect ≈ 0.975m22 (-1000-0.3)/(1000-0.3) ≈ -1.0006m23 (-2*1000*0.3)/(1000-0.3) ≈ -0.6004clip (0,0,-1.0006*5 -0.6004, -5) (0,0,-5.6034,-5)ndc (0,0,(-5.6034/-5),1) (0,0,1.1207,1)→ 发现z超限因为v0.z5在far1000内但ndc.z应≤1。问题出在m22和m23的推导公式。正确公式应为m22 (-far-near)/(far-near)m23 (-2*far*near)/(far-near)代入得m22≈-1.0006m23≈-0.6004clip.z -1.0006*5 -0.6004 -5.6034clip.w -5ndc.z (-5.60341)/2 -2.3017不对重新审视OpenGL NDC的z映射是z_ndc (-(farnear)/(far-near)) * z_world - (2*far*near)/(far-near)所以z_ndc范围是[-1,1]当z_worldnear时z_ndc-1当z_worldfar时z_ndc1。因此v0.z5应得z_ndc ≈ -0.994。这个手算过程必须亲自做一遍它是检验矩阵正确性的黄金标准。5.3 断点3帧缓冲区是否按ARGB32正确写入在FrameBuffer.SetPixel后立即调用texture.GetPixel(0,0)对比返回的Color与手动构造的new Color(r/255f,g/255f,b/255f)。若颜色偏差检查位运算顺序a24 | r16 | g8 | b不是r24 | g16 | b8 | a。我曾因顺序颠倒导致所有红色变透明。5.4 断点4深度缓冲是否阻止了远物体覆盖近物体绘制两个重叠三角形近处红色z5远处蓝色z10。若未启用深度测试蓝色会覆盖红色启用后红色区域应完全保留。在DepthBuffer.WriteDepth中加断点确认ndcZ值近处应≈-0.994远处应≈-0.998且ndcZ越小越负表示越近所以近处ndcZ应大于远处ndcZ-0.994 -0.998。这个大小关系是深度测试正确的标志。5.5 断点5背面剔除是否按预期工作旋转立方体观察某一面消失的时机。用Debug.DrawLine在Scene视图画出三角形法线确认法线方向与剔除结果一致。若法线指向摄像机时面被剔除说明叉积符号判断反了。5.6 断点6视锥体剔除是否真正生效在FrustumPlanes构造后用Debug.DrawRay画出六个平面确认它们构成一个金字塔。移动摄像机观察包围盒测试结果是否随距离变化。5.7 断点7最终输出是否与Unity内置渲染一致将软光栅输出的Texture2D赋给一个RawImage与同一场景的Camera画面并排显示。调整fov、near、far参数直到两幅图像的几何变形完全一致。这是框架成功的终极验证。我在实际搭建中这七个断点平均每个耗时1.5小时总计10.5小时。但当第七个断点通过时屏幕上那个歪斜的红色三角形不再是一个图形而是一份亲手签署的图形学理解证书——它证明你已穿透Unity的抽象层站在了光栅化的地基之上。这个框架不会让你做出3A游戏但它会让你在读任何GPU驱动源码时一眼认出那个gl_Position背后的数学真相。