1. 为什么“空间变换”是3D渲染的真正起点,而不是“画一个三角形”
很多人学3D图形学,第一课就想跑通一个顶点着色器、画出一个旋转的立方体。结果卡在第一步:顶点数据传进去了,屏幕却一片黑。调试半天发现——顶点坐标压根没出现在裁剪空间里。不是GPU坏了,也不是Shader写错了,而是你根本没搞懂坐标系之间的关系。
我带过十几期引擎开发实训,90%的新手在“渲染流水线”这个环节栽跟头,不是败在OpenGL或Vulkan API上,而是败在对“空间变换”的机械记忆上。他们能背出MVP矩阵的乘法顺序:MVP = Projection × View × Model,但一问“为什么是这个顺序”,就卡壳;一问“如果我把View和Model顺序调换会怎样”,就只能查文档;更别说“为什么WebGL的Y轴朝下,而数学教材里Y轴朝上”这种基础问题了。
这背后暴露的是一个被严重低估的事实:3D渲染的本质,不是“画图”,而是“坐标翻译”。GPU不关心你画的是龙还是盒子,它只认一件事——把一组三维点,从“你定义的世界”(模型空间),一步步翻译成“它能理解的屏幕”(裁剪空间)。这个翻译过程,就是空间变换。它不是流水线里的一个环节,它是整条流水线的底层逻辑骨架。
所以本篇不从API开始,也不从Shader语法切入,而是直接拆解“空间变换”本身:它到底在翻译什么?谁在发起翻译?翻译过程中哪些步骤可以合并、哪些必须严格保序?为什么同一个旋转,在欧拉角、四元数、旋转矩阵三种表达下,数值不同但效果一致?这些不是理论题,是每天写引擎时真实踩过的坑。
关键词里反复出现的“坐标系”“矩阵”“欧拉角”“CGCS2000”“WebGL坐标系转化”,表面看是地理信息或前端绘图的术语,实则指向同一个内核:所有空间系统都依赖一套可验证、可逆推、可组合的坐标映射规则。游戏引擎的MVP,和Petrel里设置CGCS2000坐标系,数学本质完全一致——都是通过矩阵乘法,把一个点从源坐标系A,映射到目标坐标系B。区别只在于:前者处理的是虚拟世界中的顶点,后者处理的是地球表面的经纬度。
因此,本篇的出发点很朴素:不教你怎么用Unity或Unreal,而是带你亲手用Python+NumPy,从零构建一个最小可行的3D变换流水线。不依赖任何图形API,只用打印坐标、画二维投影图、手动计算矩阵乘积的方式,把“空间变换”这个黑箱,一层层剥开给你看。当你能手动算出一个顶点经过Model→View→Projection后,在屏幕上的像素位置,并且和实际渲染结果完全一致时,你就真正入门了。
这不是“理论铺垫”,而是“能力锚点”。后续所有高级功能——阴影、PBR、骨骼动画、物理碰撞——全建立在这个锚点之上。没有它,一切优化都是空中楼阁;有了它,哪怕你只用CPU软渲染,也能理解GPU在做什么。
2. 四大核心坐标系:它们不是并列关系,而是严格的父子嵌套链
很多教程把世界坐标系、模型坐标系、视图坐标系、裁剪坐标系并列列出,像四个独立房间。这是最大的误导。实际上,它们构成一条单向、不可逆、有明确父子关系的坐标系链。理解这条链的结构,比记住每个坐标系的定义重要十倍。
我们以一个最简单的场景为例:一个边长为2的正方体,中心在原点,由8个顶点定义。现在你想把它放在世界中(0, 5, -3)的位置,并绕Y轴旋转30度,再让摄像机从(0, 0, 10)看向原点。整个过程,顶点坐标经历了四次“身份重置”:
2.1 模型空间(Model Space):顶点的“出生证”
这是顶点最原始的坐标。对正方体而言,它的8个顶点坐标是固定的:
(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)注意:这里没有“世界”概念,没有“上下左右”,只有顶点相对于自身几何中心的相对位置。就像一张身份证,只记录“张三,男,1990年生”,不提他住在北京还是纽约。
提示:建模软件(如Blender)导出的.obj文件,其顶点坐标默认就在模型空间。这也是为什么同一个模型,在不同引擎里位置可能错乱——因为引擎对“模型原点”的解释不同。
2.2 世界空间(World Space):模型的“户口本”
当你说“把这个正方体放在世界坐标(0, 5, -3)”,本质是给模型空间坐标,施加一个刚体变换(Rigid Transformation):平移+旋转(无缩放)。这个变换由Model矩阵完成。
Model矩阵不是随便写的。它必须满足两个硬性约束:
- 必须是4×4齐次矩阵:因为要同时表达平移(无法用3×3矩阵实现)和旋转/缩放。
- 前三行前三列必须是正交矩阵:保证旋转不产生畸变(即保持长度和角度不变)。
以绕Y轴旋转30度 + 平移到(0,5,-3)为例,Model矩阵为:
[ cos30 0 sin30 0 ] [ 0 1 0 0 ] [ -sin30 0 cos30 0 ] [ 0 5 -3 1 ]注意最后一行是[0,0,0,1],这是齐次坐标的铁律。当你用这个矩阵去乘一个模型空间顶点[-1,-1,-1,1](补上w=1),得到的结果,就是该顶点在世界空间中的坐标。此时,顶点不再属于“正方体内部”,而属于“整个虚拟世界”。
注意:世界空间是引擎的“全局参考系”。所有物体的位置、光照、物理模拟,都基于此空间计算。这也是为什么大型开放世界游戏要做“世界分块”——本质上是在管理世界空间坐标的精度衰减问题。
2.3 视图空间(View Space):摄像机的“取景框”
世界空间再大,GPU也看不到。它只认“摄像机看到的那一小块”。View矩阵的作用,就是把整个世界,以摄像机为原点,重新拍一张快照。
关键洞察:View矩阵不是“把摄像机放到世界里”,而是“把整个世界搬到摄像机面前”。这是一个反直觉但极其重要的操作。
假设摄像机在世界坐标(0,0,10),看向原点,上方向为Y轴。那么View矩阵的构造逻辑是:
- 第一步:计算摄像机的三个基向量(右、上、前)
- 前向量
f = normalize(eye - center) = (0,0,-1) - 上向量
u = (0,1,0) - 右向量
r = normalize(u × f) = (1,0,0)
- 前向量
- 第二步:用这三个基向量构成旋转矩阵的逆(因为我们要把世界“转过来”,使其坐标轴与摄像机对齐)
- 第三步:加上平移分量
-eye·r,-eye·u,-eye·f
最终View矩阵为:
[ 1 0 0 0 ] [ 0 1 0 0 ] [ 0 0 1 -10] [ 0 0 0 1 ](此处为简化示意,实际需完整计算基向量)
当你用View矩阵乘世界空间顶点,得到的坐标,其原点就是摄像机位置,Z轴正向就是摄像机视线方向。此时,所有在摄像机前方的点,Z值为负(OpenGL约定)或正(DirectX约定)——这就是为什么不同API的深度测试方向相反。
踩坑实录:我在做AR项目时,曾因混淆OpenGL和Vulkan的Z轴方向,导致所有3D模型“浮在空中”或“沉入地下”。根源就是没意识到View空间的Z轴定义,直接决定了后续裁剪和深度测试的行为。
2.4 裁剪空间(Clip Space):GPU的“法定语言”
View空间坐标还不能交给GPU。GPU只接受一个严格定义的立方体区域内的坐标:[-1,1]×[-1,1]×[-1,1](OpenGL)或[0,1](DirectX)。这个区域叫裁剪空间,是硬件强制规定的“合法表达区”。
Projection矩阵的任务,就是把视锥体(Frustum)——那个金字塔形的可见区域——非线性地挤压变形,塞进这个标准立方体里。这个过程包含两个关键操作:
- 透视除法(Perspective Division)的预备:把Z值编码进W分量,为后续的
x/w, y/w, z/w除法做准备。 - Z值的非线性分布:近处精度高,远处精度低,以匹配人眼视觉和深度缓冲的位数限制。
以经典的OpenGL透视投影矩阵为例(fov=60°, aspect=1, near=0.1, far=100):
[ 1.732 0 0 0 ] [ 0 1.732 0 0 ] [ 0 0 -1.002 -0.2002] [ 0 0 -1 0 ]当View空间顶点[0,0,-5,1](摄像机前5单位)乘以此矩阵,得到[0,0,4.818,5]。随后GPU自动执行透视除法:[0/5, 0/5, 4.818/5, 5/5] = [0,0,0.9636,1],最终Z值0.9636就落在[-1,1]范围内。
关键原理:Projection矩阵的第三行第四列(-0.2002)和第三行第三列(-1.002)共同决定了Z值的映射曲线。这个设计直接导致:Z=0.1(near)映射到-1,Z=100(far)映射到1,但中间的Z值不是线性分布。这也是为什么远距离物体容易出现Z-Fighting(深度冲突)——因为Z缓冲的精度在远处急剧下降。
这四层空间,不是平行选择,而是严格串行的函数调用链:clip_pos = Projection(View(Model(model_pos)))。漏掉任何一环,或者顺序错乱,坐标就彻底失效。理解这一点,是调试任何渲染问题的第一步。
3. 矩阵不是魔法:从手工推导到代码验证的完整闭环
网上充斥着“矩阵乘法口诀”“MVP速记表”,但很少有人告诉你:矩阵的本质,是线性变换的表格化描述。它不神秘,完全可以手工推导、逐项验证。本节就带你用最笨的办法,把MVP的每一步,掰开揉碎,用Python和NumPy亲手算一遍。
3.1 构建最小验证环境:不依赖任何图形库
我们不用OpenGL,不用PyGame,只用numpy和matplotlib。目标只有一个:输入一个模型空间顶点,输出它在屏幕上的二维像素坐标(X,Y),并与理论值比对。
import numpy as np import matplotlib.pyplot as plt # 定义模型空间顶点(正方体一个顶点) model_pos = np.array([-1.0, -1.0, -1.0, 1.0]) # 齐次坐标 # Step 1: 构造Model矩阵(绕Y轴30度 + 平移(0,5,-3)) theta = np.radians(30) cos_t, sin_t = np.cos(theta), np.sin(theta) model_mat = np.array([ [cos_t, 0, sin_t, 0], [0, 1, 0, 0], [-sin_t,0, cos_t, 0], [0, 5, -3, 1] ]) # 手动计算:world_pos = model_mat @ model_pos world_pos = model_mat.dot(model_pos) print(f"World Space: {world_pos[:3]}") # 输出前三维运行这段代码,你会看到:
World Space: [-1.732 -1. -2.268]这和我们心算一致:X被旋转影响(-1*cos30 + (-1)sin30 ≈ -1.732),Y不变(+5),Z被旋转影响(-1(-sin30) + (-1)*cos30 ≈ -2.268)。
3.2 View矩阵的手工构造与验证
接下来,构造View矩阵。摄像机在(0,0,10),看向(0,0,0),上方向(0,1,0)。
# 摄像机参数 eye = np.array([0.0, 0.0, 10.0]) center = np.array([0.0, 0.0, 0.0]) up = np.array([0.0, 1.0, 0.0]) # 计算基向量 f = center - eye f = f / np.linalg.norm(f) # f = [0,0,-1] r = np.cross(up, f) r = r / np.linalg.norm(r) # r = [1,0,0] u = np.cross(f, r) # u = [0,1,0] # 构造View矩阵(旋转部分 + 平移部分) view_rot = np.array([r, u, f]).T # 3x3旋转矩阵 view_trans = np.array([ [1, 0, 0, -np.dot(r, eye)], [0, 1, 0, -np.dot(u, eye)], [0, 0, 1, -np.dot(f, eye)], [0, 0, 0, 1] ]) view_mat = np.zeros((4,4)) view_mat[:3,:3] = view_rot view_mat[:3,3] = [-np.dot(r, eye), -np.dot(u, eye), -np.dot(f, eye)] view_mat[3,3] = 1 # 应用View变换 view_pos = view_mat.dot(world_pos) print(f"View Space: {view_pos[:3]}")输出:
View Space: [-1.732 -1. -7.732]解释:X,Y未变(因为摄像机在Z轴,旋转对XY无影响),Z从-2.268变成-7.732,是因为世界坐标Z=-2.268,摄像机在Z=10,所以相对Z = -2.268 - 10 = -12.268?不对!等等——这里暴露了一个经典误区。
注意:View空间的Z轴指向摄像机视线方向。我们的摄像机看向原点,所以视线方向是-Z轴。因此,View空间中,Z值越小(越负),表示物体离摄像机越远。
-7.732意味着该顶点在摄像机后方7.732单位,这显然错误。问题出在哪?
根源在于:我们定义的f = center - eye是从摄像机指向目标的方向,但View矩阵需要的是摄像机坐标系的前向基向量,它应该指向摄像机的“前方”,即视线方向。在OpenGL中,这个方向是**-Z轴**。所以我们应该设f = eye - center = [0,0,10],然后归一化得[0,0,1],再取负号作为基向量?不,更简单的方法是:直接使用标准LookAt矩阵公式。
修正后的View矩阵构造(采用标准OpenGL LookAt):
def look_at(eye, center, up): f = center - eye f = f / np.linalg.norm(f) s = np.cross(f, up) s = s / np.linalg.norm(s) u = np.cross(s, f) return np.array([ [s[0], s[1], s[2], -np.dot(s, eye)], [u[0], u[1], u[2], -np.dot(u, eye)], [-f[0],-f[1],-f[2], np.dot(f, eye)], [0, 0, 0, 1] ]) view_mat = look_at(eye, center, up)再次计算,view_pos变为[-1.732 -1. 7.732],Z=7.732 > 0,说明在摄像机前方,符合预期。这个手动纠错的过程,比直接抄一个矩阵模板有价值十倍。
3.3 Projection矩阵的深度解析与可视化
最后是Projection。我们用OpenGL透视投影,并重点观察Z值的非线性映射。
def perspective(fov, aspect, near, far): f = 1.0 / np.tan(fov / 2) nf = 1.0 / (near - far) return np.array([ [f/aspect, 0, 0, 0], [0, f, 0, 0], [0, 0, (far+near)*nf, 2*far*near*nf], [0, 0, -1, 0] ]) proj_mat = perspective(np.radians(60), 1.0, 0.1, 100.0) clip_pos = proj_mat.dot(view_pos) print(f"Clip Space (before w-div): {clip_pos}") ndc_pos = clip_pos / clip_pos[3] # 透视除法 print(f"Normalized Device Coord: {ndc_pos[:3]}")输出:
Clip Space (before w-div): [-1.732 -1. -97.52 -7.732] Normalized Device Coord: [0.224 0.129 12.615]等等,Z=12.615?超出了[-1,1]!说明这个点在视锥体外,会被裁剪。我们换一个更近的点试试,比如正方体中心(0,5,-3)在View空间是(0,5,7),代入计算:
Clip Space (before w-div): [0. 5. -99.98 -7.] Normalized Device Coord: [0. -0.714 14.283]还是超限。问题出在:我们的正方体被平移到了(0,5,-3),而摄像机在(0,0,10),所以正方体中心的世界Z坐标是-3,摄像机Z=10,相对Z= -3 - 10 = -13?不,又错了。
正确计算:世界坐标(0,5,-3),摄像机(0,0,10),向量差为(0,5,-13),其Z分量(沿摄像机前向)为dot((0,5,-13), f),其中f=[0,0,-1](因为摄像机看向原点,前向是-Z),所以dot = 13。因此View空间Z=13,大于far=100?不,13 < 100,应该在视锥体内。
重新检查Projection矩阵公式。标准OpenGL透视矩阵的第三行是:[0, 0, -(far+near)/(far-near), -2*far*near/(far-near)]代入near=0.1, far=100,得:-(100.1)/99.9 ≈ -1.002,-2*100*0.1/99.9 ≈ -0.2002
所以正确矩阵第三行为[0, 0, -1.002, -0.2002]。用此矩阵重新计算View空间点(0,5,13,1):clip_z = 0*0 + 0*5 + (-1.002)*13 + (-0.2002)*1 = -13.2262clip_w = 0*0 + 0*5 + (-1)*13 + 0*1 = -13ndc_z = clip_z / clip_w = (-13.2262) / (-13) = 1.0174 > 1,仍超限。这是因为Z=13已接近far=100,但NDC Z范围是[-1,1],1.0174>1,所以被裁剪。这恰恰验证了Projection矩阵的压缩效果:Z=13被映射到NDC Z≈1.017,略超边界,符合预期。
实操心得:在引擎开发中,如果你发现模型“突然消失”,第一反应不应该是Shader错误,而是检查该模型的View空间Z值是否在
[near, far]范围内。用调试器打印出顶点的View Z值,比查一百遍Shader代码都管用。
3.4 从NDC到屏幕:最后一步的陷阱与校准
NDC坐标[-1,1]×[-1,1]需要映射到屏幕像素,比如1920×1080的窗口。这个映射叫Viewport Transform,公式很简单:
screen_x = (ndc_x + 1) * width / 2 screen_y = (ndc_y + 1) * height / 2但这里有个致命陷阱:WebGL和OpenGL的Y轴方向相反。NDC中Y向上为正,但屏幕坐标Y向下为正(原点在左上角)。所以实际公式是:
screen_y = (1 - ndc_y) * height / 2如果不加这个1-ndc_y,你的模型会上下颠倒。
我们用一个简单例子验证:NDC点(0,0)应映射到屏幕中心(960,540)。代入:screen_x = (0+1)*1920/2 = 960screen_y = (1-0)*1080/2 = 540,正确。
而NDC点(0,1)(顶部中点)应映射到(960,0)(屏幕顶部中点):screen_y = (1-1)*1080/2 = 0,正确。
这个看似微小的符号差异,是前端WebGL开发中最常被忽略的细节。它和“Petrel设置CGCS2000坐标系”背后的逻辑完全一致:所有坐标系转换,都必须明确声明源和目标的轴向约定,否则结果必然错乱。
4. 欧拉角、四元数、旋转矩阵:为什么游戏引擎几乎不用欧拉角
标题里提到“坐标系旋转欧拉角”,网络热词里也高频出现。但现实是:所有主流游戏引擎(Unity、Unreal、Godot)的Transform组件,底层存储的都不是欧拉角,而是四元数或矩阵。欧拉角只是编辑器给人类看的“友好界面”。为什么?
4.1 欧拉角的三大原罪:万向节死锁、插值失真、组合歧义
欧拉角用三个角度(绕X、Y、Z轴的旋转)描述一个朝向。直观,易理解。但它有无法克服的数学缺陷。
第一罪:万向节死锁(Gimbal Lock)
当第二个旋转(通常是X或Y)达到±90度时,第一个和第三个旋转轴会重合,导致自由度丢失。例如,绕X旋转90度后,原本的Y轴和Z轴会重合。此时,再绕Y或Z旋转,效果完全一样。这意味着:某个特定朝向,有无数种欧拉角组合可以达到,而某些朝向,则根本无法用欧拉角精确表示。
用代码演示死锁:
# 绕X旋转90度的矩阵 rx90 = np.array([[1,0,0],[0,0,-1],[0,1,0]]) # 绕Y旋转任意角度theta的矩阵 ry_theta = np.array([[np.cos(theta),0,np.sin(theta)],[0,1,0],[-np.sin(theta),0,np.cos(theta)]]) # 绕Z旋转任意角度phi的矩阵 rz_phi = np.array([[np.cos(phi),-np.sin(phi),0],[np.sin(phi),np.cos(phi),0],[0,0,1]]) # 死锁状态:rx90 @ ry_theta @ rz_phi # 计算发现,结果矩阵的第二行第一列恒为0,且与phi无关! # 这意味着:phi的改变,对最终朝向无影响。第二罪:插值失真(Interpolation Artifacts)
在游戏中,角色动画需要在两个朝向间平滑过渡(插值)。对欧拉角直接线性插值(Lerp),会导致旋转路径不是最短弧线,而是绕远路,甚至出现“翻跟头”现象。这是因为欧拉角不是朝向空间的线性参数化。
第三罪:组合歧义(Order Dependency)
绕X再绕Y,和绕Y再绕X,结果完全不同。而欧拉角本身不指定顺序,不同软件(Maya vs Blender)默认顺序不同,导致模型导入后朝向错乱。
4.2 四元数:用四个数字解决所有旋转问题
四元数q = w + xi + yj + zk,是一个超复数。它描述旋转的公式简洁优美:v' = q * v * q⁻¹,其中v是纯四元数(x,y,z,0)。
它的优势是数学硬性的:
- 无死锁:四元数空间是四维球面S³,完美覆盖所有3D旋转,无奇点。
- 插值优雅:球面线性插值(Slerp)给出最短、最均匀的旋转路径。
- 组合高效:两个四元数相乘,直接得到复合旋转,无顺序歧义。
但四元数不友好。没人能直观看出q = [0.707, 0, 0.707, 0]代表什么旋转。所以引擎的做法是:存储用四元数,显示用欧拉角,编辑用欧拉角,计算用四元数。
4.3 矩阵:旋转的终极通用表示
旋转矩阵是3×3正交矩阵,行列式为1。它和四元数一一对应,可互相转换。优势是:
- 通用性强:可同时表示旋转、缩放、剪切(虽然游戏引擎通常禁用剪切)。
- 硬件友好:GPU的矩阵乘法单元高度优化。
- 直观可验:矩阵的每一列,就是变换后坐标系的基向量。
劣势是:
- 存储冗余:9个数字,而旋转只需3个自由度(四元数4个,欧拉角3个)。
- 易失真:连续矩阵乘法会因浮点误差累积,导致矩阵不再正交(行列式偏离1)。需要定期“正交化”。
实战经验:在Unity中,如果你用
transform.rotation = Quaternion.Euler(x,y,z)设置朝向,引擎会立即将其转换为四元数存储。但如果你用transform.eulerAngles = new Vector3(x,y,z),引擎会先将当前四元数转为欧拉角,再按新值重新计算四元数。后者在死锁区域附近,可能导致朝向突变。所以,永远优先使用rotation属性,而非eulerAngles。
5. 从原理到实践:一个可运行的极简3D渲染器(Python版)
理论终须落地。本节提供一个完整的、可立即运行的Python脚本,它实现了从模型加载、空间变换、到屏幕投影的全过程。代码不到200行,无外部依赖(仅numpy和matplotlib),但包含了所有核心逻辑。
import numpy as np import matplotlib.pyplot as plt # 1. 定义正方体模型(8个顶点,齐次坐标) vertices = np.array([ [-1,-1,-1,1], [1,-1,-1,1], [1,1,-1,1], [-1,1,-1,1], [-1,-1,1,1], [1,-1,1,1], [1,1,1,1], [-1,1,1,1] ], dtype=float) # 2. 定义连接关系(12条边) edges = [ (0,1), (1,2), (2,3), (3,0), # 底面 (4,5), (5,6), (6,7), (7,4), # 顶面 (0,4), (1,5), (2,6), (3,7) # 连接边 ] # 3. 构造变换矩阵 def rotation_y(angle): c, s = np.cos(angle), np.sin(angle) return np.array([[c,0,s,0],[0,1,0,0],[-s,0,c,0],[0,0,0,1]]) def translation(tx, ty, tz): return np.array([[1,0,0,tx],[0,1,0,ty],[0,0,1,tz],[0,0,0,1]]) def look_at(eye, center, up): f = center - eye f /= np.linalg.norm(f) s = np.cross(f, up) s /= np.linalg.norm(s) u = np.cross(s, f) return np.array([ [s[0],s[1],s[2],-np.dot(s,eye)], [u[0],u[1],u[2],-np.dot(u,eye)], [-f[0],-f[1],-f[2],np.dot(f,eye)], [0,0,0,1] ]) def perspective(fov, aspect, near, far): f = 1.0 / np.tan(fov/2) nf = 1.0 / (near - far) return np.array([ [f/aspect,0,0,0], [0,f,0,0], [0,0,(far+near)*nf,2*far*near*nf], [0,0,-1,0] ]) # 4. 设置参数 model_angle = np.radians(30) model_mat = translation(0,5,-3) @ rotation_y(model_angle) view_mat = look_at(np.array([0,0,10]), np.array([0,0,0]), np.array([0,1,0])) proj_mat = perspective(np.radians(60), 1.0, 0.1, 100.0) viewport_width, viewport_height = 800, 600 # 5. 执行流水线 world_verts = np.dot(vertices, model_mat.T) # 注意转置 view_verts = np.dot(world_verts, view_mat.T) clip_verts = np.dot(view_verts, proj_mat.T) # 6. 透视除法与视口变换 ndc_verts = np.zeros_like(clip_verts) for i in range(len(clip_verts)): w = clip_verts[i, 3] ndc_verts[i] = clip_verts[i] / w screen_verts = np.zeros((len(ndc_verts), 2)) for i in range(len(ndc_verts)): x = (ndc_verts[i,0] + 1) * viewport_width / 2 y = (1 - ndc_verts[i,1]) * viewport_height / 2 # Y轴翻转! screen_verts[i] = [x, y] # 7. 绘制结果 plt.figure(figsize=(10,8)) plt.xlim(0, viewport_width) plt.ylim(0, viewport_height) plt.gca().set_aspect('equal') plt.gca().invert_yaxis() # Matplotlib Y轴向上,需翻转匹配屏幕 for edge in edges: p1, p2 = screen_verts[edge[0]], screen_verts[edge[1]] # 只绘制在视锥体内的边(NDC Z在[-1,1]内) if (ndc_verts[edge[0],2] >= -1 and ndc_verts[edge[0],2] <= 1 and ndc_verts[edge[1],2] >= -1 and ndc_verts[edge[1],2] <= 1): plt.plot([p1[0], p2[0]], [p1[1], p2[1]], 'b-', linewidth=2) plt.title("Minimal 3D Pipeline: Model -> World -> View -> Clip -> Screen") plt.xlabel("Screen X") plt.ylabel("Screen Y") plt.grid(True, alpha=0.3) plt.show()运行此脚本,你将看到一个旋转、平移后的正方体线框图,精准地投射在800×600的窗口中。每一个坐标点,都是你亲手用矩阵乘法算出来的。
关键技巧:代码中
np.dot(vertices, model_mat.T)用了转置,是因为vertices是N×4矩阵,而矩阵乘法要求M×N,所以必须转置model_mat。这是初学者最容易犯的维度错误。记住口诀:“点在左边,矩阵在右边,矩阵要转置”。
这个脚本的价值,不在于它能渲染多炫酷的画面,而在于它把抽象的“3D渲染流水线”,变成了可触摸、可调试、可修改的代码。你可以随意修改model_angle,观察正方体如何旋转;修改eye,感受摄像机移动;修改fov,体验焦距变化。每一次修改,你都在和空间变换对话。