ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

3D渲染的本质是坐标系变换:从模型空间到屏幕的完整推导

3D渲染的本质是坐标系变换:从模型空间到屏幕的完整推导 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而是带你亲手用PythonNumPy从零构建一个最小可行的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]补上w1得到的结果就是该顶点在世界空间中的坐标。此时顶点不再属于“正方体内部”而属于“整个虚拟世界”。注意世界空间是引擎的“全局参考系”。所有物体的位置、光照、物理模拟都基于此空间计算。这也是为什么大型开放世界游戏要做“世界分块”——本质上是在管理世界空间坐标的精度衰减问题。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 SpaceGPU的“法定语言”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透视投影矩阵为例fov60°, aspect1, near0.1, far100[ 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值的映射曲线。这个设计直接导致Z0.1near映射到-1Z100far映射到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(fWorld Space: {world_pos[:3]}) # 输出前三维运行这段代码你会看到World Space: [-1.732 -1. -2.268]这和我们心算一致X被旋转影响-1*cos30 (-1)sin30 ≈ -1.732Y不变5Z被旋转影响-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(fView Space: {view_pos[:3]})输出View Space: [-1.732 -1. -7.732]解释X,Y未变因为摄像机在Z轴旋转对XY无影响Z从-2.268变成-7.732是因为世界坐标Z-2.268摄像机在Z10所以相对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 LookAtdef 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]Z7.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, (farnear)*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(fClip Space (before w-div): {clip_pos}) ndc_pos clip_pos / clip_pos[3] # 透视除法 print(fNormalized 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]等等Z12.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摄像机Z10相对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空间Z13大于far100不13 100应该在视锥体内。重新检查Projection矩阵公式。标准OpenGL透视矩阵的第三行是[0, 0, -(farnear)/(far-near), -2*far*near/(far-near)]代入near0.1, far100得-(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仍超限。这是因为Z13已接近far100但NDC Z范围是[-1,1]1.01741所以被裁剪。这恰恰验证了Projection矩阵的压缩效果Z13被映射到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 (01)*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] ], dtypefloat) # 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,(farnear)*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-, linewidth2) plt.title(Minimal 3D Pipeline: Model - World - View - Clip - Screen) plt.xlabel(Screen X) plt.ylabel(Screen Y) plt.grid(True, alpha0.3) plt.show()运行此脚本你将看到一个旋转、平移后的正方体线框图精准地投射在800×600的窗口中。每一个坐标点都是你亲手用矩阵乘法算出来的。关键技巧代码中np.dot(vertices, model_mat.T)用了转置是因为vertices是N×4矩阵而矩阵乘法要求M×N所以必须转置model_mat。这是初学者最容易犯的维度错误。记住口诀“点在左边矩阵在右边矩阵要转置”。这个脚本的价值不在于它能渲染多炫酷的画面而在于它把抽象的“3D渲染流水线”变成了可触摸、可调试、可修改的代码。你可以随意修改model_angle观察正方体如何旋转修改eye感受摄像机移动修改fov体验焦距变化。每一次修改你都在和空间变换对话。6. 后续可扩展的方向从入门到深入的自然路径
返回列表