ARTICLE DETAIL

资讯详情

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

Unity Shader入门:向量、矩阵与MVP坐标变换详解

Unity Shader入门:向量、矩阵与MVP坐标变换详解 第一次在Unity里写Shader我盯着Transform面板上的数值又看了看Game视图里的三角形脑子里只有一个问题这个顶点是怎么从模型面板上的坐标一步步变成屏幕上那个像素的后来才明白答案就是数学——向量、矩阵、MVP这三个词看起来像大学线性代数课本的目录其实是渲染管线的真正地基。Unity Shader把大部分变换封装成了UnityObjectToClipPos这样一个函数你项目做久了很可能天天在用却不知道它在干什么可一旦渲染出问题比如模型拉伸、位置错乱、阴影翻转不懂矩阵的人连排查入口都找不到。1. 一个顶点从模型空间走到屏幕先画一张全局地图1.1 渲染管线的“传送带”渲染管线听起来很高大上本质就是一条流水线CPU把顶点数据准备好GPU把它们送进顶点着色器完成坐标变换、简单的光照计算然后把结果交给裁剪、光栅化最后在片元着色器里决定每个像素的颜色。顶点着色器里最重要的任务就是坐标变换这也是Shader里最绕不开的下半场。建模软件里的Cube顶点坐标是相对于模型中心的比如一个位置在(0.5, 0.5, 0.5)的顶点它的“本地坐标”就是它自己。但场景里把Cube放在(10, 0, 5)单个顶点还在模型空间里它必须先经过一个矩阵变换把自己的本地坐标换算成场景里的世界坐标再经过相机的矩阵变换把世界坐标换算成相机视角里的观察坐标最后经过投影矩阵变成屏幕上的裁剪坐标。任何一步算错了物体就会出现在完全错误的地方——所以你看到的“渲染坏了”大多数不是美术资源问题而是坐标变换问题。有些初学者一上来想把整条渲染管线全部弄懂结果被一堆名字劝退。我的建议是先只盯住坐标变换这条主线光照、纹理、阴影都可以后面再补。坐标变换是管线里第一个、也是唯一一个完全由数学决定的环节如果你这辈子在Shader里只学一类数学MVP就是最值得先啃的那块。1.2 为什么是向量和矩阵而不是别的数学工具GPU和CPU不一样它不擅长跑复杂的if-else分支擅长的是大量并行地做同样的算术运算。一个游戏场景里有几万个顶点每个顶点都要经历同一套位置变换。如果给每个顶点都写一堆三角函数、角度判断性能会很差代码也没法维护。矩阵加向量的写法把整个变换变成了一次乘法硬件自然也喜欢这种计算模式。向量用来描述位置和方向矩阵用来描述对这个位置和方向的操作。一个二维数组一样的东西为什么能代表“操作”因为它乘上一个向量时输出的向量就是变换后的结果。更直白地说一个矩阵可以看成多个向量的组合体这些向量就是原始坐标系的基向量在变换后各自到达的新位置。矩阵里每一列往哪儿走整个坐标系就跟着往哪儿变。理解到这个层面矩阵就不再是抽象的数字表格了。这里还要提一下齐次坐标。很多人第一次看到float4(pos, 1.0)会困惑为什么要补一个1因为3x3矩阵表达不了平移图形学把维度升到4用w分量区分位置向量和方向向量。位置向量的w设为1方向向量的w设为0。这也是为什么Shader代码里常见的顶点输入是float4 vertex : POSITION而法线却是float3 normal : NORMAL。是否带w在矩阵变换里是有本质区别的。2. 向量的几何直觉与Shader里的实际用法2.1 先别管公式把向量当成方向和距离向量是什么放在Shader里它就是一组数字通常是float3(x, y, z)。它既可以表示一个点也可以表示一个方向。很多教材喜欢画箭头我更愿意把它想成一张地图上的坐标你从原点出发沿x轴走多少再沿y轴走多少最后沿z轴走多少终点就是向量的位置。不过在实际渲染里我们很少只关心一个向量“在哪里”更关心它的方向和它跟另一个向量有多大夹角。模长就是终点到原点的距离公式是sqrt(x² y² z²)。好在Shader里不需要自己算length函数直接返回模长normalize函数把一个向量变成纯方向长度变为1。这在光照计算里几乎是常规操作因为很多公式要求输入的单位向量点积结果才等于夹角的余弦值。比如漫反射光照核心就一句话dot(N, L)N是法线方向L是光源方向。点积大于0说明这个表面朝光小于0说明背光配合saturate把负值截断就得到一个基础的漫反射系数。不需要背公式只需要知道点积的几何直觉一个向量在另一个向量方向上的投影长度。有了这个直觉你看到任何光照代码都不会觉得它神秘。2.2 叉积在Shader里用来生成垂直向量叉积比点积少见但同样重要。给定两个不平行的向量叉积的结果是同时垂直于这两个向量的第三个向量。比如拿到三角形三个顶点用两条边做叉积就能得到这个三角形表面的法线。这个操作在Shader里非常常见尤其是动态生成网格、做程序化地形的时候。Unity里可以用Vector3.Cross在C#里算在Shader里用cross(a, b)函数用法几乎一模一样。需要特别注意的是顺序左手坐标系环境下cross(v1, v2)和cross(v2, v1)方向正好相反。把法线方向弄反光照就会从亮的变成暗的甚至会看到模型表面黑一块亮一块。叉积还有一个用途是判断朝向。在编辑器工具里如果你想判断一个点在一个平面的哪一侧叉积的正负号能直接告诉你结果。虽然这部分不是Shader渲染的主线但对做编辑器扩展、做碰撞检测的同事来说几乎是每天的活。2.3 新手最容易踩的向量坑法线不能随便乘模型矩阵很多人学会法线向量之后顺手就把它当成普通顶点一样去乘模型矩阵结果发现光照在非等比缩放的物体上完全不对。原因是这样的顶点坐标是位置可以用模型矩阵直接变换法线是方向而且它依赖的是当前表面的局部信息。物体被拉伸之后表面的垂直方向并不会跟着坐标一样线性拉伸尤其是“旋转 非等比缩放”组合起来的时候法线会偏离真实垂直方向一大截。正确做法是用逆转置矩阵或者在Unity里直接用UnityObjectToWorldNormal函数。这个函数内部帮你做了统一处理比手动乘UNITY_MATRIX_M安全得多。说这是新手最常踩的向量坑之一一点不过分。另一个常见问题是忘记normalize。点积结果受向量长度影响如果法线和光源方向都不是单位向量光照强度会错还会出现各种奇怪的斑点。进入片元着色器之后由于插值会改变向量长度经常需要重新归一化。养成习惯凡是用法线、视角方向参与点积先normalize再说。3. 矩阵变换的本质左乘就是“套一次动作”3.1 矩阵的每一列就是变换后的基向量矩阵为什么能表示“操作”以单位矩阵为例它的第一列是(1,0,0)第二列是(0,1,0)第三列是(0,0,1)。这正好就是x轴、y轴、z轴的方向。用单位矩阵去乘任何一个向量向量不变因为基向量没有发生任何变化。如果某个矩阵的第一列变成了(2,0,0)说明原本的x轴被拉长了两倍。任何向量乘上这个矩阵它的x分量形状也会被拉伸。所以矩阵乘向量可以这样理解矩阵告诉一个顶点“x轴变成了哪个方向y轴变成了哪个方向z轴变成了哪个方向”顶点在原本坐标系里的分量数值不变只是被放进了新的基底里。这也是为什么很多图形学教材说矩阵是坐标系变换。矩阵的列向量拼接在一起就是变换后的坐标系向量在这个新坐标系里的坐标值和它在旧坐标系里的分量一致。理解了这个你再去看旋转矩阵、缩放矩阵会发现它们一点也不难记每一列就是一个轴被变换后的结果。3.2 平移、旋转、缩放矩阵长什么样3x3矩阵能表达的变换叫作线性变换包括缩放、旋转、错切。但它表达不了平移因为平移没法通过线性映射把原点挪走。图形学的解决办法就是升到4维把位置向量的w补成1把方向向量的w补成0。这样4x4矩阵的最后一列就可以用来存放平移量。简化一下矩阵结构大概是这样左上3x3区域负责旋转和缩放右上3x1区域存放平移的x、y、z左下1x3区域一般是0右下角通常是1旋转矩阵在左上3x3区域会出现cos和sin缩放矩阵在左上对角线写缩放值平移矩阵则只在右边平移列里写数值。三者乘在一起就叫TRS矩阵。实际项目中你很少需要在Shader里手工拼矩阵Unity会帮你根据Transform生成objectToWorld矩阵。但如果你理解了矩阵结构调试时就能判断某个轴被意外拉长或者物体在绕着错误中心旋转。3.3 组合顺序和mul方向必须从右往左读Shader里最常见的写法是mul(UNITY_MATRIX_MVP, position)。Unity按列向量存储矩阵在左边向量在右边。如果同一个顶点要经过缩放、旋转、平移这三个步骤那组合矩阵从左到右是T * R * S。为什么顺序是反过来的因为向量在最右边距离向量最近的矩阵最先作用所以S先缩放再R旋转最后T平移。很多新手会把顺序写成S * R * T结果物体不按自己的轴转而是绕世界原点公转或者平移完又被缩放位置全乱套。打个比方先穿袜子再穿鞋和先穿鞋再穿袜子是完全不同的两个动作。变换顺序也一样顺序就是操作本身。Unity的UnityObjectToClipPos这个宏内部其实就是连续几次矩阵乘法的封装先把本地坐标乘ObjectToWorld再把世界坐标乘WorldToClip。它掩盖了很多细节但对新手来说是一把双刃剑——用起来方便却也容易让你忘记背后真正的MVP组合关系。4. MVP矩阵逐个拆解模型、视图、投影4.1 M模型矩阵从模型空间到世界空间MVP里的M就是模型矩阵在Unity里通常对应unity_ObjectToWorld。每个物体Transform组件上的Position、Rotation、Scale会被Unity换算成一个4x4矩阵。顶点着色器里最常用的UnityObjectToClipPos(v.vertex)第一步做的事情就是mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0))。这里把位置向量的w设为1意义就是“这是一个点不是方向”。如果w设成0平移会失效顶点就会在世界空间里乱飘。很多新手把顶点坐标和法线坐标混在一起处理只改xyz不改w就会出现“模型位置对但效果完全不对”的诡异问题。尤其是从C#侧传入自定义顶点数据时一定要明确w是0还是1。还有一点容易被忽略M矩阵不是只有平移旋转。如果Transform里有非等比缩放法线就不能直接用mul(unity_ObjectToWorld, v.normal)而要使用UnityObjectToWorldNormal。这部分在短线光照和阴影计算里尤其重要因为法线一旦歪了接下来的光照全都会跟着歪。4.2 V视图矩阵把整个世界掰到相机面前场景里的相机本身也在世界空间里它有自己的位置和朝向。为什么还需要视图矩阵因为投影计算最方便的前提是相机位于原点而且看向它的正前方。视图矩阵的任务就是把整个场景做一次平移加旋转让相机固定在原点其他所有物体相对相机运动。在Unity里视图矩阵对应UNITY_MATRIX_V。它的效果是世界空间里的一个点经过V矩阵变成观察空间里的坐标。观察空间里的坐标系通常是右手系相机看向负Z方向这也是很多资料里“Z轴为什么是反的”这一困惑的来源。新手写Shader时极少会手动乘UNITY_MATRIX_V因为UnityObjectToClipPos已经做了。但一旦你开始做后处理、做屏幕空间反射、做各种视口计算就会频繁和视图矩阵打交道。到那时候你回头再看这里的解释会觉得V矩阵其实就是“把相机当原点”的参考系翻译器。4.3 P投影矩阵把3D压成2D但保留深度P矩阵是投影矩阵分透视投影和正交投影。透视投影模拟人眼效果近大远小正交投影则保留平行关系常用于UI、俯视图、2D游戏。投影矩阵里写入的参数包括FOV、近裁剪面、远裁剪面、屏幕宽高比等实际使用中Unity会根据Camera组件自动生成。顶点经过P * V * M * position之后得到的是裁剪空间坐标它依然是四维的包含x、y、z、w。GPU接下来会先按裁剪空间做可见性判断然后执行透视除法把x、y、z全部除以w变成标准的NDC坐标最后映射到屏幕像素。你平时看到的近大远小本质上就是x和y被w除了一次。很多人第一次学这里会问既然裁剪空间都已经齐次了为什么还要w因为w在透视投影中承载了深度信息除法前的深度和除法后的深度关系不是线性的这也是为什么阴影贴图和深度缓冲里会有各种精度问题。新手阶段不用把公式背下来但一定要知道顶点着色器输出的一定是裁剪空间坐标而且它的x,y,z,w四个分量都有意义。4.4 在Unity里看实际矩阵比背公式有用抽象名词看十遍不如输出一遍。新建一个C#脚本挂在相机上运行时用Debug.Log把Camera.projectionMatrix和Camera.worldToCameraMatrix打印出来你会看到一堆具体数字。第一次看可能觉得没头绪但至少心里有底投影矩阵右下角有和远近裁剪面相关的负数透视矩阵在第三行第二列通常有一个-1这个-1就是用来给w做透视处理的关键。如果想在Shader侧验证MVP可以在顶点着色器里把mul(UNITY_MATRIX_MVP, v.vertex)的结果直接输出到SV_POSITION然后打开Frame Debugger看网格顶点的坐标落在哪个范围。不需要把每个数字都解析出来只要学会对照“世界坐标变了clipPos怎么变”就行。看得多了矩阵就不再是一团乱码。5. 在Unity Shader里亲手实践让顶点变换变得可见5.1 一个看得到顶点变换效果的Shader讲再多不如做一个。下面这个Shader基于内置渲染管线新建一个材质挂给Cube就能看到顶点变换给光照带来的影响。Shader Custom/VertexTransformDemo { Properties { _MainColor (Main Color, Color) (1, 1, 1, 1) } SubShader { Tags { RenderType Opaque } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc fixed4 _MainColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 clipPos : SV_POSITION; float3 worldPos : TEXCOORD0; float3 worldNormal : TEXCOORD1; }; v2f vert (appdata v) { v2f o; o.clipPos UnityObjectToClipPos(v.vertex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag (v2f i) : SV_Target { float3 N normalize(i.worldNormal); float3 L normalize(float3(0.5, 0.8, 0.3)); float diff saturate(dot(N, L)); return _MainColor * diff; } ENDCG } } }这个Shader的顶点着色器做了三件事第一把模型顶点转到裁剪空间这是GPU真正使用的输出第二把模型顶点转到世界空间供片元着色器做位置相关计算第三把法线转到世界空间保证光照在物体旋转后仍然正确。如果你把UnityObjectToClipPos换成别的函数或者把w改成0立刻就能在Game视图里看到各种位置错乱——这就是最好的实验。5.2 Surface Shader和Vertex/Fragment Shader怎么选很多老教程一开始就讲Surface Shader因为它自动处理光照和变换写起来舒服。但Shader的数学和变换恰恰需要你手动接触矩阵Surface Shader把这些细节都遮住了反而不好学。我的建议是学习阶段直接用Vertex/Fragment Shader也就是上面这种写法。虽然代码多几行但每一行都在告诉你坐标是怎么来的。如果你用的是URP或者HDRPUnityObjectToClipPos可能在部分版本中不再直接可用取而代之的是TransformObjectToHClip。别慌本质没有变只是函数名变了内部依然是mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, pos))。新项目里建议直接用SRP但如果是为了学矩阵和MVP开一个内置渲染管线的空工程能少踩不少Shader API差异的坑。5.3 用坐标变换排查渲染问题的思路遇到模型显示错乱第一步不是改Shader而是区分问题出在哪个环节先把物体Transform重置到原点旋转归零缩放设成1看问题是否消失如果重置后正常说明M矩阵相关配置有误检查Transform数值和代码里对unity_ObjectToWorld的使用如果模型仍然不在屏幕里把Shader的顶点输出临时改成float4(v.vertex.xyz, 1)确认顶点数据本身正常如果物体有影子但高光位置不对重点查法线变换是否用了UnityObjectToWorldNormal而不是直接用mul(unity_ObjectToWorld, v.normal)如果近大远小表现不对检查Camera的FOV、近裁剪面、远裁剪面是否被改过。这套排查顺序不需要多高深只要你脑子里有“模型空间→世界空间→观察空间→裁剪空间→屏幕空间”这条链路就能把问题从“乱七八糟的渲染坏了”一步步归因到具体的空间坐标错误。6. 新手阶段最容易踩的坑与我的学习路线建议6.1 最容易混淆的坐标空间一栏表很多渲染问题最后都归结为坐标空间搞混。我整理了一张表放在手边非常有用。空间原点在哪主要用途Shader里的典型产物模型/对象空间模型自身原点建模软件里定顶点位置v.vertex世界空间场景原点摆物体、摆相机mul(unity_ObjectToWorld, v.vertex)观察空间相机位置投影计算前的坐标mul(UNITY_MATRIX_V, worldPos)裁剪空间原点在视锥中心附近判断顶点是否可见UnityObjectToClipPos的输出屏幕空间屏幕左下或左上最终像素位置光栅化阶段自动完成空间不是客观存在只是你选的一个参考系。世界空间是场景里所有物体共享的参考系观察空间是以相机为中心的参考系裁剪空间是投影后的齐次空间。矩阵的作用就是在这几个参照系之间做翻译。6.2 矩阵乘法的顺序陷阱和列主存储Unity和OpenGL这派习惯用列向量矩阵在左向量在右读组合顺序时从右往左。而很多数学教材习惯行向量向量在左矩阵在右组合顺序从左往右。如果你把两个流派混着用代码结果就会莫名其妙。最经典的坑是TRS顺序想让物体“先缩放、再旋转、最后平移”应该写成T * R * S而不是反过来。写成S * R * T物体会在缩放前就被平移效果就是沿着世界轴扭曲。调试时可以在C#里手工构造几个矩阵分别按两种顺序乘同一个顶点对比结果立刻就能明白顺序的意义。Unity编辑器里没有直接显示矩阵的可视化面板但你可以通过Transform组件、Frame Debugger和C#打印三者对照。不需要背任何乘法口诀只要记住一点离向量最近的那个矩阵最先被应用到向量上。6.3 我的学习路线先做实验再回头理解公式如果让我给新手画一条学习路线我不会让你先去啃线性代数教材而是先做三个能看见结果的实验。第一个实验写一个C#脚本拿到Cube的8个顶点用transform.localToWorldMatrix手动乘一遍再和场景里Cube的世界坐标对照。你会看到所谓矩阵变换就是“把本地坐标按Transform的设置搬到世界坐标”。第二个实验写一个Shader在顶点着色器里给顶点位置加上一个随时间变化的偏移比如做波浪效果。你会发现只需要在v.vertex上动手脚整个模型就会跟着变形这就是向量的力量。第三个实验打开Frame Debugger找到任意一个DrawCall把Transform从(0,0,0)拖到(5,0,0)观察矩阵里的数字怎么变化。看几次之后M矩阵到底是什么你就不会忘记了。顺便说我最开始学的时候走了不少弯路把矩阵当成二维数组硬背公式。直到某一天我把缩放矩阵单独打印出来手动用几个顶点乘了一遍才真正看清“矩阵的每一列是基向量变形后的位置”。这个顿悟比看十篇教程都值。所以这篇文章不是让你背公式而是希望你在Unity里多动手拆开看。屏幕上的三角形从模型空间一路走到屏幕空间这条路值得你亲自走一遍。
返回列表