UI、屏幕、世界坐标来回转换:Unity 常见矩阵应用

如果说矩阵在 Shader 里属于“你迟早要懂”,那在客户端业务里,最常见的高频落点其实是坐标转换。

这也是我自己觉得最值得早点学的一块。因为很多客户端问题表面上像 UI 逻辑,实际上最后都会追到“这个点现在到底在哪个空间”上。

尤其是这些需求:

  • 3D 角色头顶挂血条
  • 点击屏幕落到地面
  • 世界物件映射到 UI 红点
  • 小地图图标和场景位置同步

这些本质上都在做一件事:跨坐标空间翻译。

一、先分清几个常见空间

在 Unity 里,最容易混的通常有这几种:

  • 世界空间
  • 屏幕空间
  • 视口空间
  • UI 本地空间

如果不先分清语义,后面 API 调得再多也只是碰运气。

1. 世界空间

场景里物体真实存在的位置。

2. 屏幕空间

通常是像素坐标,左下或左上为原点要看具体上下文。

3. 视口空间

归一化后的屏幕比例坐标,一般在 [0,1] 范围。

4. UI 本地空间

相对于某个 RectTransform 的局部二维坐标。

二、为什么“世界点挂 UI”本质上不是一个步骤

很多人第一次做头顶血条,会以为只需要把 3D 位置塞给 UI。

实际上至少经历两段:

  1. 世界空间 -> 屏幕空间
  2. 屏幕空间 -> 某个 Canvas 或 RectTransform 的局部空间

这两段背后其实都和相机投影、矩阵变换有关。

三、WorldToScreenPoint 是最常见入口

例如:

1
Vector3 screenPos = camera.WorldToScreenPoint(worldPos);

它做的不是简单减法,而是把世界点走过相机的观察和投影过程,最终得到屏幕位置。

所以如果结果不对,问题不一定在 UI,也可能在:

  • 相机选错了
  • 点在相机后方
  • 使用了错误的渲染相机

四、为什么拿到屏幕坐标后还不能直接给 UI

因为 UI 不是天然就和屏幕像素一一对应。

特别是 Canvas 处于不同模式时:

  • Screen Space Overlay
  • Screen Space Camera
  • World Space

处理方式都可能不同。

很多时候你还要进一步做:

1
RectTransformUtility.ScreenPointToLocalPointInRectangle(...)

把屏幕点换成某个 UI 根节点的局部点。

一个常见写法大概会像这样:

1
2
3
4
5
6
7
8
9
Vector3 screenPos = camera.WorldToScreenPoint(target.position + offset);

RectTransformUtility.ScreenPointToLocalPointInRectangle(
uiRoot,
screenPos,
canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : camera,
out Vector2 localPoint);

marker.anchoredPosition = localPoint;

这段代码看起来只是几行转换,但它其实已经把“世界 -> 屏幕 -> UI 本地”这条链走完了。

五、点击屏幕打到世界,也是反向变换

另一个高频需求是:

玩家点屏幕某个位置,我想知道场景里对应哪里。

这通常不是“直接得到 3D 点”,而是:

先从屏幕点发射一条射线,再和场景中的平面、碰撞体相交。

比如:

1
Ray ray = camera.ScreenPointToRay(screenPos);

这背后其实就是把二维屏幕输入重新解释回三维世界方向。

六、小地图、本地雷达、战斗提示都在做类似的空间投影

很多业务系统表面看起来不一样,但底层都类似:

  • 小地图把世界平面位置压到一张 2D 贴图坐标里
  • 战斗指示器把世界范围映射到屏幕角标
  • 任务箭头把 3D 方向转成 UI 旋转

本质都是:

找对中间空间,然后做稳定的坐标变换。

七、为什么 UI 跟随经常“抖”或“飘”

常见原因通常有这些:

  1. 更新时机不对,角色已经动了,UI 还没跟上
  2. 使用了错误的相机
  3. 没处理物体在相机后方的情况
  4. 直接拿屏幕坐标硬塞局部坐标
  5. Canvas 缩放规则没有考虑进去

所以这种问题不要只盯数值,先把空间链路画出来更有效。

八、一个很实用的排查顺序

如果世界点映射 UI 有问题,我一般会按这个顺序查:

  1. 世界点本身是不是对的
  2. WorldToScreenPoint 输出是不是合理
  3. 目标 Canvas 模式是什么
  4. UI 根节点的本地坐标系是不是理解对了
  5. 最后是不是又叠加了额外偏移

这比一上来疯狂调 offset 高效很多。

九、矩阵知识在这里真正帮到你的地方

你不一定需要手推 VP 矩阵,但你要知道:

  • 世界到屏幕不是魔法,是投影变换
  • 屏幕到 UI 不是直接赋值,是另一个空间映射
  • 相机决定了中间变换规则

只要这个认知有了,很多坐标转换问题都会从“玄学”变成“可拆解流程”。

十、总结

UI、屏幕、世界坐标的来回转换,是 Unity 客户端里最实用的一类矩阵应用。

它看起来不像 Shader 那么“图形学”,但本质上用的是同一套空间变换思想。

下一篇我会继续讲更偏工程排查的一面:InverseTransformPointMultiplyPoint、Gizmos 这些工具怎么组合起来定位矩阵问题。

非等比缩放为什么会搞坏法线:normal matrix 与渲染细节

很多人学矩阵时,前面都还挺顺。

但我自己观察下来,真正把很多人拦住的不是 MVP,而是法线。因为前面的空间变换大多还像“位置搬家”,到法线这里你第一次会碰到“看起来像方向,处理方式却不能照抄”的情况。

旋转、缩放、平移都能理解,MVP 也能接受。

但一到法线,就开始出现一种很典型的疑惑:

顶点位置明明用 ObjectToWorld 变过去就行,为什么法线不能也这样直接乘?

答案通常在非等比缩放这里被彻底暴露出来。

一、法线不是普通方向向量

从形式上看,法线像一个方向向量。

但它承担的语义不是“朝哪里走”,而是“这个面垂直于哪个方向”。

也就是说,法线最重要的特征不是长度,而是它和切平面的垂直关系。

这决定了法线在变换时,不能简单地照抄顶点位置的处理方式。

二、为什么纯旋转时问题不明显

如果一个物体只做旋转,法线直接跟着旋转通常没问题。

因为旋转会保持角度关系,垂直关系不会被破坏。

等比缩放时,问题也不算突出,因为各轴缩放比例一致,整体几何关系相对稳定。

但一旦出现非等比缩放,比如:

1
scale = (2, 1, 0.5)

事情就变了。

三、非等比缩放为什么会破坏法线直觉

假设一个表面原本是斜着的。

你把模型在 x、y、z 三个轴上按不同倍数拉伸后,表面切线方向变了,但如果你还把法线只当普通向量同样去乘,它就不一定还和新表面保持垂直。

这就会直接影响光照结果:

  • 高光位置不对
  • 明暗过渡怪异
  • Lambert 或 PBR 结果不稳定

所以问题的根本不是“数值不漂亮”,而是物理含义被破坏了。

四、normal matrix 的本质

法线要保持和表面切平面的垂直关系,正确的处理方式通常不是直接乘 Model 矩阵,而是乘它左上 3x3 部分的逆转置。

也就是常说的 normal matrix。

写法上常见表达是:

$$
N = (M^{-1})^T
$$

更准确地说,是针对线性部分来处理,而不是把整个位移也拿进去。

五、为什么是逆转置

这里如果硬推导,文章会变得太学术。

工程上你可以先抓住一句话:

它是在修正非等比缩放带来的“垂直关系失真”。

顶点位置关心的是点怎么落位。

法线关心的是面法向关系怎么保持正确。

这两件事不是一回事,所以对应的矩阵处理也不一样。

六、Unity 里什么时候你会明显撞上这个问题

至少有几类场景特别容易中招:

1. 角色或道具被父节点非等比缩放

模型位置没问题,但光照开始怪。

2. Shader 里自己写世界空间法线

如果你偷懒直接拿对象空间法线乘 unity_ObjectToWorld,在非等比缩放下很容易错。

3. 自定义网格或程序化模型

如果法线是你自己算、自己传,后续变换链稍微处理错一点,画面就会有明显缝隙或阴影错误。

七、Unity Shader 里通常怎么避免自己踩这个坑

很多内置宏或常见写法已经帮你做了处理。

比如一些世界空间法线转换宏,本质上就是在替你做正确的矩阵变换。

所以如果只是常规表面着色,尽量复用成熟宏比自己乱写稳定得多。

但前提是你得知道:

这些宏不是魔法,它们是在替你处理法线专用的变换规则。

如果你是自己写顶点到世界空间法线,最少要保证思路像下面这样,而不是直接拿位置那套乘法照抄:

1
2
float3 worldNormal = normalize(mul((float3x3)unity_WorldToObject, v.normal));
worldNormal = normalize(worldNormal);

或者直接走 Unity 已有的辅助函数:

1
float3 worldNormal = UnityObjectToWorldNormal(v.normal);

后者在工程里通常更稳,因为它已经把很多容易忘的细节封装掉了。

八、一个很实用的排查方法

如果你怀疑光照异常跟法线有关,可以先做这几步:

  1. 暂时把父节点非等比缩放改成 (1,1,1)
  2. 观察异常是否立刻消失。
  3. 在 Shader 里把法线可视化输出成颜色。
  4. 对比对象空间法线、世界空间法线是否连续合理。

如果一改缩放就恢复正常,十有八九就是法线变换链有问题。

九、客户端工程里一个常见误区

很多人会把“法线问题”归类成纯美术或纯 Shader 问题。

其实不完全是。

因为运行时层级、挂点、骨骼、程序化缩放策略,都是客户端逻辑在定。

如果业务代码里大量引入非等比缩放,却没有意识到它会影响渲染表现,那最后排查成本会非常高。

十、一个更务实的团队建议

如果不是特别必要:

  • 逻辑层尽量少用非等比缩放
  • 美术资源层尽量在 DCC 工具里把比例处理好
  • Shader 层优先使用成熟法线转换宏

这样会比等到线上出现光照怪异再追矩阵链省事得多。

十一、总结

法线之所以难,不是因为概念多,而是它逼着你意识到:

“看起来都是方向向量”的东西,在不同语义下,矩阵处理方式并不相同。

理解了这一点,你后面写光照、自定义材质、程序化模型时会稳很多。

下一篇我会把话题拉回客户端更高频的地方:UI、屏幕、世界坐标之间的来回换算。

Shader 里的 MVP:顶点从模型空间到裁剪空间经历了什么

前面几篇主要站在客户端和 Transform 的角度讲矩阵,这一篇换一个视角:从 Shader 看顶点变换。

我自己第一次真正把这条链想明白,不是在看教材,而是在改 Shader 的时候发现“宏虽然能跑,但只要要接世界空间法线、屏幕空间效果或者深度效果,脑子里没这条空间链就一定会乱”。

很多 Unity 程序员第一次真正意识到矩阵的重要性,往往不是在 Transform,而是在 Shader 里看到一长串空间名:

  • Object Space
  • World Space
  • View Space
  • Clip Space

如果不把这条链串起来,写 Shader 很容易变成“宏能跑就行”。

一、MVP 其实就是一条连续的坐标转换链

MVP 分别是:

  • Model
  • View
  • Projection

它们描述的不是三种孤立矩阵,而是顶点要经过的三段路:

  1. 模型空间到世界空间
  2. 世界空间到观察空间
  3. 观察空间到裁剪空间

一个顶点从模型自己的局部坐标出发,最终能不能落到屏幕上,就靠这条链。

二、模型空间:顶点最初的家

模型空间就是美术资源自身的局部坐标系。

比如一个立方体资源,它的顶点可能天然围绕 (0, 0, 0) 排布。

这个阶段,顶点只知道自己在模型里的相对位置,不知道:

  • 这个物体被摆到场景哪里了
  • 它朝什么方向
  • 摄像机站在哪看它

所以模型空间的顶点,必须先经过 Model 变换。

三、Model 矩阵:把局部顶点带进世界

Model 矩阵本质上就是 localToWorldMatrix

它把模型自己的顶点,从局部坐标转换到场景世界坐标。

在 Unity 语境里,你可以把它理解成:

这个物体在场景中的 TRS 总结果。

如果没有这一步,顶点永远只能待在资源自己的小世界里。

四、View 矩阵:把世界重新换成“相机眼中的世界”

世界空间看起来很自然,但 GPU 真正做投影前,更关心的是“相机视角下”的位置。

所以接下来要用 View 矩阵,把世界空间转换成观察空间。

你可以把它理解成:

不是相机去看世界,而是把整个世界搬到相机坐标系里。

做完这一步之后,相机就像站在原点,朝固定方向看出去。

五、Projection 矩阵:把三维体积压到可裁剪的空间里

到了观察空间,顶点已经相对于相机定位好了,但还没完成“投影”。

Projection 矩阵做的事情,是把三维观察体映射到一个规范裁剪空间。

这个阶段会引入:

  • 透视缩小
  • 近平面和远平面范围
  • 屏幕最终可见区域

如果是透视相机,远处物体会变小;如果是正交相机,则不会有透视缩放。

这就是 Projection 的核心区别。

六、为什么 Shader 里经常直接写 MVP

因为对顶点来说,这三段变换是连续的。

所以完全可以把它们预先合成一个总矩阵:

$$
MVP = P \cdot V \cdot M
$$

然后一次把模型顶点送到裁剪空间。

这对 GPU 很友好,也符合渲染流水线的处理方式。

七、Unity 里最常见的对应接口和宏

在 Unity Shader 里,你通常会碰到这些:

  • unity_ObjectToWorld
  • UNITY_MATRIX_V
  • UNITY_MATRIX_P
  • UNITY_MATRIX_MVP

如果你只把它们当神秘常量,后面写世界空间法线、屏幕空间效果、深度相关逻辑时会很吃力。

但如果你知道它们分别在做哪段空间转换,就会清楚很多。

例如顶点着色器里最常见的最小路径,其实就长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct appdata
{
float4 vertex : POSITION;
};

struct v2f
{
float4 pos : SV_POSITION;
};

v2f vert(appdata v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
return o;
}

如果你想把这段再拆开看,也完全可以显式走三步:

1
2
3
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
float4 viewPos = mul(UNITY_MATRIX_V, worldPos);
o.pos = mul(UNITY_MATRIX_P, viewPos);

这两种写法本质一样,只是后一种更适合调试“我到底在哪一段空间出了问题”。

八、为什么顶点着色器常常输出 SV_POSITION

因为顶点最终要交给光栅化阶段,必须先进入裁剪空间语义。

在 HLSL 里,通常会把经过 MVP 变换后的结果输出到 SV_POSITION

这不是随便起的字段名,而是图形管线约定好的“顶点最终裁剪位置”。

九、一个最常见的实战问题:明明世界坐标对了,屏幕上却不对

这类问题很常见,特别是在做:

  • 屏幕特效
  • 轮廓线
  • 深度相关着色
  • 世界空间贴图

根因通常是空间搞混。

比如:

  • 你以为自己在用世界坐标,实际还在模型坐标
  • 你以为法线在观察空间,实际还是对象空间
  • 你把裁剪空间值拿去和世界空间值直接比较

矩阵知识在这里的作用,不是让你手推公式,而是让你能快速定位:

我现在这份数据,究竟处在哪个空间。

十、为什么做 UI 跟随、屏幕投影时也会回到这条链

把 3D 物体头顶血条投到屏幕上,本质上也离不开这条变换链。

它只是把“模型顶点走进裁剪空间”的逻辑,换成“世界点走进屏幕坐标”的逻辑而已。

换句话说,MVP 不只是 Shader 内部知识,它其实和客户端很多屏幕空间功能是连着的。

十一、调试 Shader 空间问题时最实用的思路

不要一上来怀疑平台、精度、宏定义。

先问自己三个问题:

  1. 我当前这份坐标在哪个空间?
  2. 我要比较或使用的另一份数据在哪个空间?
  3. 这两份数据是否经过了同一条矩阵链?

大多数空间错乱问题,都是这三问里的某一个没对齐。

十二、总结

MVP 不只是图形学教材里的缩写,它是 Unity 渲染里最核心的一条坐标转换链。

理解它之后,你再看模型空间、世界空间、观察空间、裁剪空间,就不会是四个散词,而是一条连续流程。

下一篇我会继续讲一个更容易踩坑的地方:非等比缩放为什么会把法线搞坏,以及 normal matrix 到底解决了什么问题。

TRS 的顺序为什么重要:Matrix4x4.TRS 在项目中的用法

Unity 已经帮我们把大多数坐标变换都封装在 Transform 里了,但只要开始写工具、程序化生成、特殊动画或者自定义网格,Matrix4x4.TRS 很快就会出现。

我自己在项目里最常碰到它的地方,其实不是“我要秀矩阵”,而是普通 Transform 节点一多,点位生成和调试就开始变得很脏的时候。

这时候最容易犯的错是:

知道有 T、R、S,却不知道顺序为什么不能随便换。

这篇就专门讲这个问题。

一、TRS 不是三个标签,而是一条变换流水线

T 是 Translation,R 是 Rotation,S 是 Scale。

它们都可以写成矩阵,然后连乘成一个总矩阵。

关键在于:

矩阵乘法有顺序。

先缩放再旋转,和先旋转再缩放,结果不一样。

所以 TRS 不能被理解成“把三个属性装在一起”,而是“这三个动作按某个顺序串起来”。

二、为什么一般会先 Scale,再 Rotate,再 Translate

最常见的直觉是:

  1. 先在物体自己的局部空间里做缩放
  2. 再把这个局部空间旋转到目标方向
  3. 最后整体搬到世界某个位置

这个顺序最符合大多数内容生产和运行时逻辑。

因为缩放通常希望沿局部轴发生,旋转通常希望围绕局部原点发生,平移最后只是把整个结果放到目标位置。

如果你把平移放前面,很多操作就会变成“绕世界原点打转”。

三、一个特别典型的错误:本来想原地旋转,结果绕远点转圈

这类 bug 常见于:

  • 自己拼矩阵
  • 自定义 gizmo
  • 程序化摆放物件
  • 特效跟随系统

症状通常是:

物体看起来不是原地变换,而是绕某个奇怪的点公转。

本质原因就是:

你把平移和旋转的乘法顺序写反了。

四、Matrix4x4.TRS 帮你封装了常规顺序,但你要知道它在帮你做什么

Unity 提供:

1
Matrix4x4 matrix = Matrix4x4.TRS(pos, rot, scale);

它很方便,但不能只停留在“会调”。

你要知道这个矩阵最终表达的是:

一个局部空间中的点,先经过缩放、再经过旋转、最后平移到目标位置。

这样后续你再用:

1
Vector3 worldPos = matrix.MultiplyPoint(localPos);

就知道自己在做什么,而不是把它当黑盒。

五、项目里什么时候会手动用 Matrix4x4.TRS

我自己觉得至少有这几类场景很常见:

1. 程序化摆放物件

比如一条道路、一个棋盘、一个阵列、一个地块拼接系统,需要根据规则批量生成位置和方向。

如果你先在局部空间定义模板点位,再乘一个 TRS,总体会比每次手写 position/rotation 偏移更稳定。

2. 自定义编辑器工具

比如场景里批量预览刷点、绘制本地包围盒、编辑器下做布局辅助,这时你常常有“局部模板 + 世界实例化”的需求。

3. 顶点或网格处理

某些运行时网格变形、顶点缓存、批量合并逻辑里,直接乘矩阵比绕很多 Transform 节点要直接得多。

4. 特效和挂点偏移

如果一个技能轨迹、命中特效、提示框位置不是简单地跟某个节点完全重合,而是相对于局部轴有一段可配置偏移,用 TRS 表达会更清楚。

六、MultiplyPointMultiplyVector 在这里再强调一次

用 TRS 矩阵做变换时,最常见的两种数据是:

  • 一个局部位置点
  • 一个局部方向向量

它们不能混用。

1
2
Vector3 p = matrix.MultiplyPoint(localPoint);
Vector3 v = matrix.MultiplyVector(localDir);

前者会吃进平移,后者不会。

如果你把方向拿去做点变换,经常会得到一个“方向看起来对,数值却带偏移”的诡异结果。

七、非等比缩放下,TRS 会把问题放大

如果 scale = (2, 1, 0.5) 这种非等比缩放参与进来,很多直觉会开始失灵。

尤其是当你后续还把这个结果继续喂给别的系统,比如:

  • 法线计算
  • 物理近似
  • 子节点朝向
  • 自定义包围盒

这时就要非常谨慎。

因为 TRS 不是错了,而是它忠实地保留了非等比缩放对坐标系的影响。

八、一个很实用的写法:先在局部定义,再统一乘矩阵

例如你要在角色前方摆 5 个预警点,可以这样写:

1
2
3
4
5
6
7
var trs = Matrix4x4.TRS(transform.position, transform.rotation, Vector3.one);

for (int i = 0; i < 5; i++)
{
Vector3 local = new Vector3(0f, 0f, 1.5f * i);
Vector3 world = trs.MultiplyPoint(local);
}

这个思路的优点是:

逻辑先在局部空间表达,很干净;真正落到场景时,再统一映射到世界。

对于技能、阵型、道路、棋盘格,这类写法都很稳。

如果换成二合或棋盘类项目,通常可以进一步写成“本地模板 + 根节点 TRS”的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Vector3[] localSlots =
{
new Vector3(-1f, 0f, -1f),
new Vector3( 0f, 0f, -1f),
new Vector3( 1f, 0f, -1f),
new Vector3(-1f, 0f, 0f),
new Vector3( 0f, 0f, 0f),
new Vector3( 1f, 0f, 0f)
};

Matrix4x4 boardMatrix = Matrix4x4.TRS(boardRoot.position, boardRoot.rotation, boardRoot.lossyScale);

foreach (Vector3 localSlot in localSlots)
{
Vector3 worldSlot = boardMatrix.MultiplyPoint3x4(localSlot);
Debug.DrawLine(worldSlot, worldSlot + Vector3.up, Color.green, 5f);
}

这种写法的价值,不只是能算出点位,而是后面整个棋盘搬家、旋转甚至切换朝向时,槽位规则都不用重写。

九、什么时候不必强行手写矩阵

也别反过来走极端。

如果只是普通挂点、普通跟随、普通父子关系,直接用 Transform API 更清晰。

矩阵适合的是:

  • 你需要显式控制变换链
  • 你要批量处理大量点位
  • 你不想创建太多临时节点
  • 你在写底层工具或渲染相关逻辑

否则一上来就全改成矩阵代码,只会让维护成本变高。

十、总结

Matrix4x4.TRS 最重要的价值,不只是“把位移旋转缩放拼起来”,而是让你可以显式、稳定地控制一条空间变换流水线。

只要你真正理解顺序问题,很多“为什么位置差一点”“为什么绕错点旋转”的 bug 都会好查很多。

下一篇我会把话题切到 Shader,讲顶点从模型空间一路走到裁剪空间时,MVP 矩阵链到底在干什么。

Unity 里的局部坐标和世界坐标:Transform 背后的矩阵

如果说前面几篇还偏“基础知识”,那从这一篇开始,矩阵就彻底进入 Unity 日常开发现场了。

我自己觉得,矩阵对客户端开发最先产生价值的地方,不是在 Shader,而是在你第一次认真排查父子层级、挂点和骨骼跟随的时候。

平时我们最常写的是:

  • transform.position
  • transform.localPosition
  • transform.rotation
  • transform.localScale

这些字段看起来很直观,但真正把它们串起来的,是矩阵。

一、局部空间和世界空间的差别,本质上是参照系不同

这是所有问题的起点。

一个子物体的 localPosition = (0, 1, 0),意思不是“它在世界里 y=1”。

它的意思是:

相对于父节点的局部坐标系,它离父节点原点向上 1 个单位。

如果父节点旋转了、缩放了、平移了,这个子物体在世界里的最终位置就会一起变。

所以:

  • localPosition 是局部描述
  • position 是世界描述

它们不是两个独立真相,只是同一个点在不同坐标系下的两种写法。

二、Transform 本质上就是一个 TRS 变换

Unity 里单个节点最核心的三组信息就是:

  • Translation 位移
  • Rotation 旋转
  • Scale 缩放

把它们合起来,就是一个局部到父级空间的变换矩阵。

如果继续往上乘父节点、祖先节点的矩阵,就会得到最终的 localToWorldMatrix

也就是说,一个物体的世界位置不是“单独算出来的”,而是整条父子层级矩阵连乘的结果。

三、为什么父节点一动,子节点世界坐标全变

因为子节点的局部坐标,不是直接挂在世界空间里,而是先挂在父节点坐标系里。

父节点的局部 x 轴、y 轴、z 轴一旦转了、缩了、挪了,子节点的解释方式也会跟着变。

这就是层级系统最核心的地方:

父节点定义了子节点的参考坐标系。

所以你看到的不是“子节点自己在乱动”,而是它所依附的坐标空间在变化。

四、TransformPoint 为什么这么常用

假设你有一个局部挂点:

1
Vector3 localOffset = new Vector3(0.2f, 1.1f, 0f);

你要得到这个挂点在世界里的实际位置,最稳的方式通常就是:

1
Vector3 worldPos = transform.TransformPoint(localOffset);

它本质上等价于:

用当前物体的局部到世界矩阵,把一个“点”乘到世界空间。

这也是为什么它会受平移、旋转、缩放共同影响。

五、TransformDirectionTransformVector 不要混用

这两个 API 很多人会随手用,但语义上还是有区别。

一般理解上:

  • TransformPoint 处理位置点
  • TransformDirection 更强调方向
  • TransformVector 更强调长度也参与缩放

最常见的 bug 是:

你本来在处理“朝向”,却用成了点变换,结果把平移也算进去了。

或者你本来想保留纯方向,却无意间吃进了缩放影响。

所以一旦你在做射线、武器发射方向、特效朝向、骨骼挂点时出现偏差,先检查这里。

六、worldToLocalMatrix 是排查问题的利器

很多时候我们不是把局部点转世界,而是反过来:

我有一个世界坐标,想知道它相对于某个物体的局部坐标是多少。

例如:

  • 判断敌人是否进入角色前方扇形区域
  • 计算点击点在某个局部网格里的坐标
  • 把世界里的命中点映射回模型局部空间

这个时候 InverseTransformPointworldToLocalMatrix 就很好用。

因为它做的正是逆变换。

例如做“目标是不是在角色前方”的判断时,我一般不会先上点乘,而是先转回局部空间:

1
2
3
4
Vector3 localTarget = transform.InverseTransformPoint(target.position);

bool isInFront = localTarget.z > 0f;
bool isOnRight = localTarget.x > 0f;

这类写法的好处是语义很直接,后面要改成扇形范围、矩形攻击框或者棋盘局部坐标判断,也很容易往下扩。

七、一个父子层级的典型心智模型

可以把每个 Transform 理解成“在父坐标系里再搭一层新坐标系”。

比如:

  • 角色是第一层
  • 手臂骨骼是第二层
  • 武器挂点是第三层
  • 枪口火焰位置是第四层

最终枪口点位为什么能一直跟手臂、身体、朝向保持一致?

不是因为每一帧都在人工修正,而是因为这一连串矩阵关系天然保证了它们同步。

八、非等比缩放为什么在层级里很危险

如果父节点有非等比缩放,子节点的局部轴往往会变得不那么“干净”。

在工程里,这会引出很多连锁问题:

  • 朝向判断怪怪的
  • 圆形碰撞看起来被拉扁
  • 特效跟随位置没错,但方向不对
  • 法线或光照表现异常

所以很多团队会约束:

运行时骨架层、逻辑层尽量避免乱用非等比缩放。

因为这不是不能用,而是用了以后,你就必须更清楚矩阵里到底发生了什么。

九、Unity 中几个很值得背后的接口

我建议不要只背用法,而是连同语义一起记:

1
2
3
4
5
6
transform.localToWorldMatrix
transform.worldToLocalMatrix
transform.TransformPoint(localPoint)
transform.InverseTransformPoint(worldPoint)
transform.TransformDirection(localDir)
transform.InverseTransformDirection(worldDir)

这几组接口基本覆盖了大部分坐标转换场景。

十、一个很实用的调试建议

当你怀疑坐标不对时,不要只打印 position

建议一起打印:

1
2
3
Debug.Log(transform.localPosition);
Debug.Log(transform.position);
Debug.Log(transform.localToWorldMatrix);

再配合 Scene 视图里的 Gizmos 画出局部轴方向,通常很快就能定位问题到底出在:

  • 父节点层级
  • 旋转顺序
  • 缩放污染
  • 局部/世界空间搞混

十一、总结

Transform 从表面上看只是几个字段,实际上它是 Unity 空间系统最核心的封装。

只要你把“每个节点都在定义一个局部坐标系”这个认知建立起来,很多层级、挂点、骨骼、朝向问题都会清楚很多。

下一篇继续往下走,专门讲 Matrix4x4.TRS 的顺序问题,以及项目里什么时候值得自己手组矩阵。

从 3x3 到 4x4:齐次坐标如何把平移装进矩阵

前两篇把线性变换讲到了旋转、缩放、错切,但还差一个最常用的动作没统一进来:平移。

这也是我觉得很多 Unity 初学者第一次看 Matrix4x4 会突然断层的原因。前面 2x2 还在讲“轴怎么变”,一到 4x4 就像突然多出一堆和项目代码很近、但又说不清为什么存在的数据。

这件事如果只站在线性代数的严格定义上看,会比较尴尬。

因为线性变换要求原点不动,而平移天生会把原点搬走。

可图形学又必须把旋转、缩放、平移放进一套统一流程,否则渲染管线和引擎实现会变得很碎。

解决方案就是齐次坐标。

一、为什么二维里要从 2x2 升到 3x3

二维点 (x, y),如果只用 2x2 矩阵,平移做不到。

因为 2x2 乘法本质上只能做线性组合,没法凭空加一个常量位移项。

比如你想把点变成:

$$
(x’, y’) = (x + t_x, y + t_y)
$$

这件事不是纯粹依赖 xy 的线性组合,它额外需要一个常量“1”。

所以图形学里把二维点扩展成:

$$
\begin{bmatrix}
x \
y \
1
\end{bmatrix}
$$

于是平移就能写进 3x3 矩阵:

$$
\begin{bmatrix}
1 & 0 & t_x \
0 & 1 & t_y \
0 & 0 & 1
\end{bmatrix}
$$

这样一乘,位移项就自然加进去了。

二、这个“多出来的 1”到底有什么用

它的作用不是为了数学形式好看,而是给平移留了一个入口。

你可以把它理解成:

前两维还是原来的空间坐标,最后那一维负责把“仿射变换”里的常量项也纳入矩阵乘法。

有了这个常量维度之后,旋转、缩放、平移都能统一用矩阵连乘来描述。

这对引擎非常重要,因为:

  • 数据结构统一
  • 乘法链统一
  • GPU 顶点处理统一

三、三维世界为什么会变成 4x4

同理,三维空间里的点 (x, y, z) 在图形学中通常扩展成:

$$
\begin{bmatrix}
x \
y \
z \
1
\end{bmatrix}
$$

于是三维里的平移、旋转、缩放,都能用 4x4 矩阵来表示。

这就是 Unity 里 Matrix4x4 的来源。

所以不要把 4x4 看成“为了复杂而复杂”,它的本质只是:

三维空间 + 一维齐次坐标 = 统一表达所有常见空间变换。

四、点和向量在齐次坐标里要区别对待

这个地方特别关键。

点通常写成:

$$
(x, y, z, 1)
$$

向量通常写成:

$$
(x, y, z, 0)
$$

为什么?

因为向量表示的是方向和偏移,它不应该受平移影响。

举个很实在的 Unity 对应:

  • 一个顶点位置,应该跟着平移一起动
  • 一个朝向向量,不应该因为物体整体平移就改变方向

这也正是 MultiplyPointMultiplyVector 行为不同的根本原因。

五、Unity 里怎么理解 4x4 矩阵的每一部分

如果把 Matrix4x4 当成 16 个数字,阅读成本很高。

更工程化的理解方式是:

  • 前三列:局部坐标系三个基向量在目标空间中的样子
  • 最后一列:原点平移到目标空间后的坐标

所以一个 localToWorldMatrix 可以同时回答两件事:

  1. 这个物体的轴朝哪儿了
  2. 这个物体被搬到哪儿了

旋转、缩放影响前三列。

平移主要影响最后一列。

六、为什么 TRS 能被合成一个矩阵

TRS 就是 Translation、Rotation、Scale。

单看它们像是三种不同操作,但因为都能写成 4x4 矩阵,所以可以连乘合成一个总矩阵。

也就是说:

$$
M = T \cdot R \cdot S
$$

或者在具体实现里是别的顺序,但本质上都是:

先准备好几段变换,再合成一个总规则。

这个总规则可以一次性把局部点变换到目标空间。

七、为什么引擎这么喜欢“统一成矩阵”

因为统一之后,很多流程都简单了:

  • 父子节点变换可以直接矩阵相乘
  • CPU 侧和 GPU 侧的顶点变换可以共享同一套表达
  • 相机、物体、投影都能接在一个矩阵链里

从实现角度看,这是一种极高性价比的抽象。

八、一个很容易踩的坑:把点和方向都拿去做同一种乘法

很多自定义工具、程序化生成代码里,会出现这种 bug:

本来要转换一个方向,却用了点变换;或者本来要转换一个位置,却当成向量去乘。

后果通常是:

  • 方向多了一段莫名其妙的偏移
  • 位置少了平移项
  • 射线起点和方向都不对

所以只要你遇到“坐标差一点但总不对”的问题,第一件事就该回头检查:

我现在处理的,到底是点还是向量?

九、在 Unity 里最直接对应的几个 API

这篇学完之后,再看这些接口会非常顺:

  • transform.localToWorldMatrix
  • transform.worldToLocalMatrix
  • Matrix4x4.TRS
  • matrix.MultiplyPoint
  • matrix.MultiplyVector
  • matrix.MultiplyPoint3x4

这些都不是孤立 API,而是在共享同一套齐次坐标和仿射变换框架。

如果你想把“点吃平移、向量不吃平移”这件事一次看明白,可以直接跑下面这段:

1
2
3
4
5
6
7
8
9
10
Matrix4x4 matrix = Matrix4x4.TRS(
new Vector3(10f, 0f, 0f),
Quaternion.identity,
Vector3.one);

Vector3 point = new Vector3(1f, 2f, 3f);
Vector3 dir = new Vector3(1f, 2f, 3f);

Debug.Log(matrix.MultiplyPoint(point));
Debug.Log(matrix.MultiplyVector(dir));

第一行结果会整体往 x 正方向多出一段平移,第二行不会。项目里只要把这两者混了,很多“怎么总差一点”的 bug 就会出现。

十、总结

齐次坐标不是“数学家为了炫技加的一维”。

它解决的是图形学里一个非常现实的问题:怎么把平移和其他变换统一成一套矩阵乘法流程。

理解了这一点,后面再看 Unity 的 Matrix4x4,你会更容易看出每一块数据的含义。

下一篇开始,就正式落回到 Unity 的 Transform,把局部空间和世界空间的关系讲透。

二维旋转、缩放、错切:为什么矩阵能表示线性变换

上一篇把点、向量、坐标系这些基础概念捋了一遍,这一篇开始真正进入“矩阵到底在变什么”。

如果说上一篇是在清理术语,这一篇更像是在补 Unity 开发里一条很关键的底层直觉:你对轴的理解一旦错了,后面看旋转、缩放和顺序就只会越看越乱。

如果只停留在“矩阵乘向量会得到一个新向量”,其实还是比较空。

更重要的问题是:

  • 为什么旋转能写成矩阵?
  • 为什么缩放也能写成矩阵?
  • 为什么矩阵乘法顺序一换,结果就完全不同?

把这些问题弄清楚,你后面再看 Unity 里的 TRS、相机矩阵、MVP 链,就不会只是在背 API。

一、线性变换的关键特征是什么

所谓线性变换,可以先粗暴理解成两件事:

  1. 直线还是直线
  2. 原点保持不动

所以像旋转、缩放、错切,都满足这个特点。

但平移不满足,因为平移会把原点从 (0, 0) 挪走。

这也是为什么二维的 2x2 矩阵,能很好地表示旋转、缩放、错切,却不能直接表示平移。

二、缩放矩阵最好理解

如果一个二维点 (x, y),我们想让它在 x 方向放大 sx 倍,在 y 方向放大 sy 倍,那么目标结果就是:

$$
(x’, y’) = (sx \cdot x, sy \cdot y)
$$

写成矩阵就是:

$$
\begin{bmatrix}
sx & 0 \
0 & sy
\end{bmatrix}
\begin{bmatrix}
x \
y
\end{bmatrix}

\begin{bmatrix}
sx \cdot x \
sy \cdot y
\end{bmatrix}
$$

这说明矩阵并不神秘,它只是把“每个轴怎么变”写成统一形式。

sx = sy 时,就是等比缩放。

sx != sy 时,就是非等比缩放。

这个区别在 Unity 里很重要,因为非等比缩放后,很多方向、法线、碰撞和子节点表现都会开始变微妙。

三、旋转矩阵为什么长那个样子

二维旋转通常是很多人第一次觉得“公式开始抽象”的地方。

绕原点逆时针旋转角度 $\theta$ 后,结果写成矩阵是:

$$
\begin{bmatrix}
\cos\theta & -\sin\theta \
\sin\theta & \cos\theta
\end{bmatrix}
$$

如果硬背,很快就忘。

更好的记法还是上一篇提过的那条:看矩阵的列向量。

原来的 x 轴 (1, 0) 旋转后,会变成:

$$
(\cos\theta, \sin\theta)
$$

原来的 y 轴 (0, 1) 旋转后,会变成:

$$
(-\sin\theta, \cos\theta)
$$

把这两个新基向量按列摆进去,就是旋转矩阵。

这个理解方式比背公式稳得多,因为它直接告诉你:

旋转矩阵,本质上是在重定义一组新的坐标轴。

四、错切矩阵最容易在 UI 和 2D 工具里见到

错切不像旋转和缩放那么常见,但它很适合帮助理解“矩阵不一定只是转和缩”。

比如一个简单的 x 方向错切:

$$
\begin{bmatrix}
1 & k \
0 & 1
\end{bmatrix}
$$

它会让点 (x, y) 变成:

$$
(x + ky, y)
$$

也就是说,y 越大,x 被推得越多。

图形会像被侧向拉斜一样。

在 Unity 日常业务里你不一定直接手写错切矩阵,但当你处理一些自定义 UI 网格、2D 变形、特殊动效时,这种思路是会用到的。

五、矩阵乘法顺序为什么不能乱

这是工程里最容易出错的点之一。

先旋转再缩放,和先缩放再旋转,结果通常不一样。

原因很简单:

前一个变换的输出,会成为后一个变换的输入。

如果你先把物体拉宽,再去旋转,旋转的是“已经被拉宽过的局部基向量”。

如果你先旋转,再缩放,缩放发生在旋转后的轴方向上。

这两个过程不是一回事。

所以矩阵乘法虽然看起来只是把几个矩阵连乘起来,但它其实是在描述一条有先后顺序的变换流水线。

六、Unity 里最常见的误解:以为旋转永远绕世界轴发生

很多人一开始看 transform.Rotate,或者自己做插值动画时,会把“旋转”理解成一个单一动作。

但真正影响结果的是:

  • 你绕的是局部轴还是世界轴
  • 变换是在父节点之前还是之后
  • 当前对象是否已经有缩放

只要把矩阵乘法的顺序意识带进来,就会知道:

同样叫“旋转 30 度”,放在不同变换链位置里,结果可以完全不同。

七、用一个二维例子把顺序差异看直观

假设一个点先执行:

  1. x 方向放大 2 倍
  2. 再逆时针旋转 90 度

和先:

  1. 逆时针旋转 90 度
  2. 再 x 方向放大 2 倍

结果不会一样。

因为“x 方向”这个概念,在旋转前后本来就变了。

这个例子对应到 Unity 里,就是为什么父节点缩放 + 子节点旋转,经常让人觉得“不符合直觉”。

其实不是引擎错,是你正在对一组已经变化过的坐标轴继续操作。

如果你想把这个差异在 Unity 里直接打出来,可以写一个最小实验:

1
2
3
4
5
6
7
8
9
10
Vector3 point = new Vector3(1f, 0f, 0f);

Matrix4x4 scaleFirst = Matrix4x4.Rotate(Quaternion.Euler(0f, 0f, 90f))
* Matrix4x4.Scale(new Vector3(2f, 1f, 1f));

Matrix4x4 rotateFirst = Matrix4x4.Scale(new Vector3(2f, 1f, 1f))
* Matrix4x4.Rotate(Quaternion.Euler(0f, 0f, 90f));

Debug.Log(scaleFirst.MultiplyPoint3x4(point));
Debug.Log(rotateFirst.MultiplyPoint3x4(point));

哪怕只是看两行输出,也会比背“顺序不同结果不同”这句话更有体感。

八、学到这里,应该建立什么工程直觉

这篇最重要的不是记住所有矩阵形式,而是形成下面几条直觉:

  1. 矩阵描述的是基向量怎么变。
  2. 缩放、旋转、错切都是在线性框架里重排坐标轴。
  3. 变换顺序不同,结果通常不同。
  4. 只要原点移动了,就已经不再是单纯的线性变换。

这最后一点,正好会把我们带到下一篇。

九、为什么下一步一定要学齐次坐标

你会发现,到目前为止,我们能描述很多事情,但还有一个最常见的动作没纳入统一框架:平移。

而游戏开发里,平移又偏偏是最高频的。

所以图形学里才会引入齐次坐标和 3x3、4x4 矩阵,把位移也装进一套统一表达方式里。

下一篇就专门讲这个桥是怎么搭起来的。

十、总结

如果只看公式,旋转矩阵、缩放矩阵、错切矩阵很容易越学越散。

但如果你始终盯着“坐标轴怎么变、顺序怎么串”,这些矩阵就会开始统一起来。

后面进入 Unity 的 Matrix4x4 之前,这个理解是必须有的。否则你看到 4x4 只会更晕,不会更清楚。

矩阵和线性变换入门:先把点、向量和坐标系说清楚

很多 Unity 程序员第一次碰矩阵,往往是在 Shader、相机或者 Matrix4x4.TRS 这里。

API 能背,代码也能抄,但只要一遇到“为什么乘法顺序一变结果就错了”“为什么局部坐标和世界坐标对不上”,脑子就会乱。

根因通常不是不会写代码,而是前面的基础概念没真正连起来。

我自己最早也是先在项目里撞墙,后面才倒回来看这些基础概念。尤其是做挂点、技能范围和 UI 跟随时,只要点、向量、坐标系三件事没分开,后面查问题会非常慢。

这篇先不急着上 4x4,也不急着讲公式推导,先把最关键的三件事捋顺:点、向量、坐标系。

一、矩阵本质上是在描述“坐标如何变化”

如果只记一句话,我建议记这个:

矩阵不是一张表,它是一个“变换规则”。

同一个点,用不同坐标系描述,会得到不同数字;同一个向量,在旋转、缩放之后,也会得到新的数字。矩阵做的事情,就是把“旧坐标”稳定地映射成“新坐标”。

在图形学和游戏开发里,这件事太常见了:

  • 把模型局部顶点变到世界空间
  • 把世界坐标变到相机空间
  • 把一个方向从角色本地前方变成世界中的真实朝向
  • 把 UI 点位从屏幕空间转换到世界空间

所以,矩阵不是数学课里的额外负担,它其实就是坐标空间之间的翻译器。

二、先分清点和向量

这两个词在代码里经常混着用,但在矩阵语境下必须分清。

1. 点是什么

点表示“位置”。

比如 (3, 2) 表示二维平面上的一个位置,(1, 5, -2) 表示三维空间里的一个位置。

点最重要的特征是:它依赖原点。

原点换了,同一个点的坐标数值就会变化。

2. 向量是什么

向量表示“方向和长度”。

比如“向右走 3 个单位,向上走 2 个单位”,这就是向量 (3, 2)

向量不关心自己从哪里出发,它只关心偏移量本身。

这也是为什么在 Unity 里:

  • transform.position 更像点
  • transform.forwardtransform.rightVector3.up 更像向量

当你理解“点是位置,向量是偏移”,后面再看 MultiplyPointMultiplyVector 的差别,就不会只靠死记硬背了。

三、坐标系决定了数字的含义

很多初学者会误以为一个坐标数字是绝对真实的。

其实不是。

坐标一定是相对于某个坐标系而言的。

例如角色脚下有个武器挂点,它的本地坐标可能是 (0.2, 1.1, 0)。这个数字只在角色自己的局部坐标系里有意义。一旦角色转身、缩放、移动,这个挂点在世界空间里的真实位置就会变化。

于是就有了一个最常见的需求:

把局部坐标转换成世界坐标。

这件事背后用的就是矩阵。

四、为什么 2x2 矩阵已经足够解释线性变换

先看二维情况最直观。

一个二维向量:

$$
\begin{bmatrix}
x \
y
\end{bmatrix}
$$

乘上一个二维矩阵:

$$
\begin{bmatrix}
a & b \
c & d
\end{bmatrix}
\begin{bmatrix}
x \
y
\end{bmatrix}
$$

得到的新结果,其实就是这个向量被“重新组合”之后的坐标。

更实用的理解方式是:

矩阵的每一列,代表原坐标系基向量变换后的去向。

也就是说,矩阵其实在回答两个问题:

  • 原来的 x 轴现在指向哪里?
  • 原来的 y 轴现在指向哪里?

只要这两个基向量确定了,整个平面里所有向量的新位置也就都确定了。

这就是为什么矩阵特别适合描述旋转、缩放、错切这种线性变换。

五、Unity 里最值得建立的一个直觉:列向量就是“新坐标轴”

这是我觉得最有用的一条工程直觉。

在 Unity 里看 localToWorldMatrix 时,不要把它当成一堆数字。你可以先把它理解成:

  • 第一列:局部 x 轴在世界里变成了什么方向
  • 第二列:局部 y 轴在世界里变成了什么方向
  • 第三列:局部 z 轴在世界里变成了什么方向
  • 第四列:物体原点在世界里的位置

一旦这么看,很多问题会突然变简单:

  • 为什么物体旋转后 right/up/forward 变了
  • 为什么父节点缩放会影响子节点方向长度
  • 为什么非等比缩放会让局部坐标系“看起来歪掉”

这些都不是引擎在“偷偷做魔法”,而是矩阵把局部基向量映射到了新空间。

六、线性变换为什么不包含平移

这个点在图形学里很重要。

严格来说,线性变换要求原点不动。

所以旋转、缩放、错切都属于线性变换,但平移不属于。

这也是为什么后面讲 Unity 的 TRS 矩阵时,会引出齐次坐标和 4x4 矩阵。因为我们需要一种办法,把“本来不属于线性变换”的平移也放进统一的矩阵框架里处理。

先记住结论就够了:

  • 纯旋转、缩放、错切可以用线性变换理解
  • 平移需要更进一步的表示方式

下一篇就接着讲这个过渡是怎么发生的。

七、学矩阵时,Unity 里最常见的三个落地点

如果你是做客户端开发,不一定天天手写矩阵,但下面这些地方一定绕不过去:

1. Transform 坐标转换

TransformPointInverseTransformPointTransformDirection 这些接口,本质上都是矩阵乘法的封装。

2. Shader 顶点变换

模型空间到世界空间、观察空间、裁剪空间的连续变换,背后就是 MVP 矩阵链。

3. 相机和 UI 坐标换算

从屏幕点击位置反推世界坐标,或者把 3D 点投影到 UI 上,本质上都是空间变换问题。

也就是说,学矩阵不是为了考试,而是为了以后调这些问题时知道自己到底在调什么。

八、现阶段不用死磕推导,先把这几个认知钉牢

我建议先记住下面 4 条:

  1. 矩阵描述的是变换规则,不只是数字表格。
  2. 点是位置,向量是偏移,两者在变换里不能混看。
  3. 坐标永远依赖坐标系,局部和世界只是两个不同描述方式。
  4. 矩阵的列,可以理解为变换后的基向量。

只要这 4 条稳了,后面再看旋转矩阵、4x4、TRS、MVP,理解速度会快很多。

九、一个很实用的练习方法

如果你现在就在做 Unity 项目,我建议你开一个空场景做这几个实验:

  1. 建一个父物体和一个子物体。
  2. 分别修改父物体的位移、旋转、缩放。
  3. 实时打印子物体的 localPositionpositionlocalToWorldMatrix
  4. 对照 Scene 视图看 right/up/forward 的变化。

如果你懒得自己搭太多对象,可以先用一段最小代码直接感受“点”和“方向”进入世界空间之后的区别:

1
2
3
4
5
6
7
8
Vector3 localPoint = new Vector3(0f, 0f, 2f);
Vector3 localDir = Vector3.forward;

Vector3 worldPoint = transform.TransformPoint(localPoint);
Vector3 worldDir = transform.TransformDirection(localDir);

Debug.Log($"worldPoint = {worldPoint}");
Debug.Log($"worldDir = {worldDir}");

这个例子很简单,但很适合拿来建立第一层直觉:

  • 点会跟着物体位置一起走
  • 方向会跟着朝向变化,但不是一个“有落点的位置”

只做一遍,你对“矩阵在改什么”会比看半天公式更有感觉。

十、总结

矩阵最难的地方,不是算,而是抽象。

但一旦你把它和 Unity 的空间变换、Transform、Shader 顶点流转对应起来,它就不再是纯理论,而是非常实用的工程工具。

这篇先打地基。

下一篇我会继续讲二维旋转、缩放、错切分别是怎么落到矩阵上的,以及为什么“矩阵乘法顺序”会直接决定结果对不对。

2D游戏相关学习

2D渲染
Camera相机设置
Projection(投影方式):修改为Orthographic(正交)
Size:改变相机的视野大小

Sprite
“精灵图”一词首次作为图形术语出现,是在德州仪器的9918(A)视频显示处理器上。使用“精灵图”作为术语,是因为精灵图并不是帧缓冲中位图数据的一部分,而是“悬浮”于帧缓冲中数据之上,不影响其中数据,就像精灵飘在上面一样。

在Unity中:png / jpg等常规图片,一般不在Unity中直接使用,而是转为Sprite

SpriteRender
顾名思义,用来渲染Sprite的渲染器

属性 功能
Sprite 定义该组件应渲染的精灵纹理。单击右侧的小圆点可打开对象选择器窗口,然后从可用精灵资源列表中进行选择。
Color 定义精灵的顶点颜色,用于对精灵的图像进行着色或重新着色。使用拾色器设置渲染的精灵纹理的顶点颜色。请参阅此表下方的颜色部分以查看示例。
Flip 沿选定的轴翻转精灵纹理。这不会翻转游戏对象的变换位置。
Sorting Layer 设置精灵的排序图层(Sorting Layer),此图层用于控制渲染期间的精灵优先级。从下拉框中选择现有的排序图层,或创建新的排序图层。
Order In Layer 设置精灵在其排序图层中的渲染优先级。首先渲染编号较低的精灵,编号较高的精灵叠加在前者之上。
Sprite Sort Point 在计算精灵和摄像机之间的距离时,在精灵中心(Center)或其轴心点(Pivot Point)之间进行选择。
Unity输入系统
这里调用的Input类是由命名空间UnityEngine提供的,如果有using Input = UnityEngine.Windows.Input;的话将会导致这里的Input不能直接调用

监听键盘输入
键盘输入有三种状态:

按下的瞬间
持续按下中
弹起的瞬间
下方三个函数返回都是bool类型,参数都是KeyCode枚举类型

Input.GetKeyDown(KeyCode.A);
Input.GetKey(KeyCode.A);
Input.GetKeyUp(KeyCode.A);
监听键盘输入
和键盘差不多,不过0是鼠标左键、1是鼠标右键、2是鼠标中键

Input.GetMouseButtonDown(0);
Input.GetMouseButton(0);
Input.GetMouseButtonUp(0);
Input Manager
Unity提供了一种常见且方便的使用方式:

大多数游戏中使用键盘A和D来控制玩家在水平方向上的移动,这里提供一个函数:

按下A,函数的返回值会偏向-1;按下D,函数的返回值会偏向1;如果不按A或D则偏向0(使用“偏向”是因为会缓动效果,例如按下A后,数值会慢慢减到-1,按下D后,数值会慢慢加到1,不按会慢慢回到0,可以模拟出启动、惯性的效果)

Input.GetAxis(“Horizontal”);
Input.GetAxisRaw(“Horizontal”);//不进行缓动,只有三种结果:-1,0,1
Horizontal是水平方向轴(X轴),Vertical是纵向方向轴(Y轴)

他们都在Unity界面中的Edit -> Project Settings -> Input Manager中配置

Unity2D动画概览
Unity的2D动画和3D动画在操作层面上用的是同一个组件,操作方式也类似,但是相关知识点有所不同,所以不能武断地认为2D动画等于3D动画

Animator:动画组件(组件)
用来持有AnimatorController的组件,注意可以多个Aniamtor持有一份AnimatorController

AnimatorController:动画控制器(文件)
动画控制器相当于是一个配置文件,配置的是一个角色有哪些动画,动画和动画之间如何跳转

状态:当前角色处于的状态,状态会持有具体的AnimationClip

条件:动画之间的切换基于什么条件

参数:条件基于参数来判断是否满足,满足则切换

int、float、bool和代码中的类似
Trigger:触发一次,bool相当于是一种状态,但是Trigger是一个一次性使用的条件
AnimationClip:动画片段(文件)
动画片段,就是动画文件,记录了每一帧的信息。Unity内可以制作动画,但是从其他建模类软件导入的情况也比较多(尤其是3D动画)

2D动画也分为帧动画和骨骼动画

帧动画就是通过切换图片形成动画

而骨骼是通过旋转、缩放、移动关节点进行变化的动画

动画切换
Unity动画组件本质上是引用了一个“动画状态机”(AnimationController)的配置文件

状态:当前角色处于的状态,状态会持有具体的AnimationClip
条件:参数在“发生何等变化”时会进行状态切换
Has Exit Time:即使条件满足也需要等待当前状态的播放完毕才能进行切换(后摇)
参数:提供给脚本去访问、修改的“值”,这些值变化则状态变化,则动画发生改变
无视参数、条件的动画播放
CrossFadenInFixedTime:此函数

//参数1:状态名称
//参数2:过度时间(秒)
animator.CrossFadenInFixedTime(“Move”, 0.25f);
角色移动动画
想要角色移动,就要搞定三个基本点:

角色待机与移动动画的切换
角色向不同方向移动时的贴图方向
角色实现物理位移
要解决第一个问题,就要在Animator中选中两个动画直接的切换连线,添加判断变量(Parameters)和合适的条件(Conditions)(因为是角色移动所以这里设置的是bool变量和真假判断)

然后在脚本中获取Animator组件并为变量Move判断赋值

第二个问题直接在脚本中调用localScale,并根据不同方向将X轴的数值置正数或负数即可实现

第三个问题前面有提

脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class TestAnimator : MonoBehaviour
{
// Start is called before the first frame update
public float moveSpace = 5;
public Animator animator;
void Start()
{

}

// Update is called once per frame
void Update()
{
float inputX = UnityEngine.Input.GetAxis("Horizontal");
float inputY = UnityEngine.Input.GetAxis("Vertical");
//动画切换
bool isMoving = (inputX!= 0 || inputY!= 0);
animator.SetBool("Move", isMoving);
//移动方向
if (inputX > 0) transform.localScale = new Vector3(10, 10, 1);
else if (inputX < 0) transform.localScale = new Vector3(-10, 10, 1);
//角色移动
Vector3 moveDirection = new Vector3(inputX, inputY, 0);
transform.Translate(moveDirection * moveSpace * Time.deltaTime);
}
}

动画事件
可以在动画中的某一帧插入一个事件来执行想要的效果(如落地时播放音效)

动画事件可以调用脚本中的函数或方法来执行效果

2D物理系统
碰撞体(Collider)
决定物理单位作为物体碰撞时的形状,可以是矩形、圆形亦或是自定义的多边形

参数解释

Edit Collider:编辑碰撞体大小
Material:物理材质,决定物体的碰撞特性
Is Trigger:是否为触发器
Auto Tiling:碰撞体大小自动贴合物理单位边框
刚体(Rigidbody 2D)
物理单位的物理特性,也就是定义物理单位怎样受到物理的控制,如重力等

参数解释

Body Type:
Dynamic:动力学体。默认选项,受物理引擎影响,会主动响应重力、碰撞和其他外力。用于需要物理交互和自然运动的物体(玩家、敌人或可以被推动的物体)
Kinematic:运动体。运动体不会主动受到外力的影响,但可以通过代码控制其位置和旋转。这意味着你可以手动设置其速度和方向,但它不会响应重力或碰撞力。运动体可以推动其他动力学刚体,但自身不会因碰撞而移动。
Static:静态体。当设置为静态体时,物体不会受到物理力的影响,它不会移动或旋转,除非通过代码直接改变其位置或旋转。这通常用于不需要物理交互的物体,例如背景元素或固定结构。

如果你想要一个物体能够自然地掉落并在碰撞时反弹,你会选择Dynamic。如果你想要一个物体保持固定位置,但仍然能够通过代码进行移动,你会选择Kinematic。如果你完全不希望物体参与物理计算,你会选择Static。

Material:物理材质,决定物体的碰撞特性
Friction:摩擦力
Bounciness:弹力

Simulated:模拟效果(编辑时展示模拟效果)

Mass:质量,影响着物体的惯性和加速度

Gravity Scale:重力,影响重力加速度(0 = 无重力,1 = 标准重力,2 = 两倍标准重力)

Linear Drag:线性阻力,影响位置移动时的阻力参数

Angular Drag:角阻力,影响旋转运动时的阻力参数

Collider Detection:碰撞检测,用于确定物体是否互相碰撞的一个关键机制
Discrete:离散,计算成本较低。在离散碰撞检测中,物理引擎会在固定的时间步长(通常是FixedUpdate周期)检查物体的位置并确定他们是否发生碰撞。如果物体移动的速度过快将会穿越应该与之碰撞的物体,俗称“隧穿”。
Continuous:连续,计算成本较高。连续碰撞检测适用于处理快速移动的物体,以免出现“隧穿”问题。在连续碰撞检测中,物理引擎会考虑物体在时间步长内的运动,并尝试找到第一个接触点。这种检测方式比离散碰撞检测消耗更多的CPU资源,因此通常只对那些需要它的物体使用。

constraints:定义对 2D 刚体运动的任何限制。
Freeze Position:选择性地阻止2D刚体沿世界X和Y轴移动。这不会阻止刚体的旋转,但会锁定其在特定轴上的平移运动 。
Freeze Rotation:选择性地阻止2D刚体围绕Z轴旋转。刚体仍然可以自由移动,但旋转将被限制

事件
碰撞事件
当两个物理单位相撞时会调用的逻辑

前提条件:双方都有碰撞体,其中一个有刚体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//发生碰撞
private void OnCollisionEnter2D(Collision2D collision)
{
Debug.Log("发生碰撞");
if(cllision.gameObject.name == "S1")
{
Destory(gameObject);
}
}
//持续碰撞
private void OnCollisionStay(Collision collision)
{
Debug.Log("持续碰撞");
}
//结束碰撞
private void OnCollisionExit2D(Collision2D collision)
{
Debug.Log("结束碰撞");
}

触发事件
当两个物理单位其中一个碰撞体设置为触发器时调用的逻辑

前提条件:双方都有碰撞体,一方为触发器,另一方有刚体

//发生触发
private void OnTriggerEnter2D(Collider2D collision)
{
Debug.Log(“发生触发”);
}
//持续触发
private void OnTriggerStay2DStay(Collision collision)
{
Debug.Log(“持续触发”);
}
//结束触发
private void OnTriggerExit2D(Collision2D collision)
{
Debug.Log(“结束触发”);
}
碰撞事件与触发事件的区别
碰撞事件和触发事件不能同时发生
碰撞事件:会导致物理力的计算和应用,例如动量转移和碰撞响应。触发事件:不会导致物理力的计算,它们仅作为检测机制,用于检测物体是否进入或离开特定区域。
碰撞事件:因为涉及到物理计算,可能会对性能有更高的要求。触发事件:通常用于优化,因为它们避免了物理力的计算,特别是在需要频繁检测物体是否穿过区域的场景中。
碰撞事件:适用于需要物理交互和响应的场景,如球撞击地面或车辆碰撞。触发事件:适用于不需要物理力但需要检测物体进入特定区域的场景,如触发门的开关、检测玩家进入特定区域等。

粒子系统基础

一、粒子系统基础属性
Unity 的粒子系统(Particle System)包含许多属性和功能,下面是一些常见的属性及其用途:

发射模块(Emission Module):控制粒子的发射速率、发射角度、初始速度等,可以用来控制粒子的产生方式和速度。

形状模块(Shape Module):定义了粒子发射的区域形状,可以是球体、盒子、圆锥等,也可以通过贴图来定义不规则的发射形状。

大小模块(Size over Lifetime):控制粒子大小随时间变化的曲线,可以实现粒子从产生到消失过程中大小渐变的效果。

颜色模块(Color over Lifetime):控制粒子颜色随时间变化的曲线,可以实现粒子从产生到消失过程中颜色渐变的效果。

旋转模块(Rotation over Lifetime):控制粒子旋转角度随时间变化的曲线,可以实现粒子自旋转的效果。

纹理动画(Texture Sheet Animation):通过设置纹理帧数和播放模式,实现粒子的纹理帧动画效果。

碰撞模块(Collision Module):定义了粒子与碰撞器之间的交互效果,如碰撞后的反弹、消失等行为。

外部力场(External Forces):允许外部力场对粒子系统施加影响,如重力、风等。

Start Lifetime(开始寿命):定义了粒子的初始生命周期,即粒子被发射后存在的时间。

Start Speed(初始速度):确定了粒子被发射时的初速度,影响了粒子移动的距离和速度。

Start Size(初始大小):设定了粒子被发射时的初始大小,可以影响粒子的显示效果。

Start Color(初始颜色):确定了粒子被发射时的初始颜色。

Start Rotation(初始旋转):定义了粒子被发射时的初始旋转角度。

这些属性共同决定了粒子的初始状态,而在粒子系统中,还可以通过曲线或颜色过渡等方式来调整粒子在生命周期内的变化情况,比如:

Size over Lifetime(大小随生命周期变化):控制了粒子在生命周期内大小的变化,可以实现粒子渐变消失的效果。

Color over Lifetime(颜色随生命周期变化):控制了粒子在生命周期内颜色的变化,可以实现颜色渐变消失的效果。

二、自定义脚本操作粒子系统
1.自定义脚本获取组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    using UnityEngine;

public class ParticleController : MonoBehaviour
{
private ParticleSystem particleSystem; // 粒子系统组件

private void Start()
{
particleSystem = GetComponent<ParticleSystem>(); // 获取粒子系统组件
}

private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
// 在空格键按下时播放粒子效果
PlayParticleEffect();
}
}

private void PlayParticleEffect()
{
particleSystem.Play(); // 播放粒子效果
}
}

创建了一个名为 ParticleController 的脚本类,用于控制粒子系统。在 Start 方法中,通过 GetComponent 方法获取当前对象上的 ParticleSystem 组件,以便在后续代码中对其进行操作。

在 Update 方法中,检测到用户按下空格键时,调用 PlayParticleEffect 方法来播放粒子效果。PlayParticleEffect 方法中,我们调用 ParticleSystem 的 Play 方法来启动粒子效果。

使用以上代码,可以将该脚本组件添加到场景中的一个游戏对象上,并将粒子系统组件拖拽到脚本组件的相应字段中。然后,在运行游戏时按下空格键,就能播放粒子效果了。

2.脚本实现火焰效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Frame : MonoBehaviour
{
public float maxParticles = 1000f; // 最大粒子数
public float emissionRate = 300f; // 发射速率
public float lifetime = 1f; // 粒子生命周期
public float startSize = 0.2f; // 初始大小
public float endSize = 0.01f; // 结束大小
public float startSpeed = 1f; // 初始速度
public float endSpeed = 0f; // 结束速度
public Color startColor = new Color(1f, 0.5f, 0f); // 初始颜色
public Color endColor = new Color(1f, 0f, 0f); // 结束颜色

private ParticleSystem particleSystem; // 粒子系统组件

private void Start()
{
particleSystem = GetComponent<ParticleSystem>(); // 获取粒子系统组件

// 设置粒子系统属性
var main = particleSystem.main;
main.maxParticles = (int)maxParticles;
main.startLifetime = lifetime;
main.startSize = startSize;
main.startSpeed = startSpeed;

// 设置粒子发射器属性
var emission = particleSystem.emission;
emission.rateOverTime = emissionRate;

// 设置粒子形状属性
var shape = particleSystem.shape;
shape.shapeType = ParticleSystemShapeType.Cone;
shape.angle = 30f;
shape.radius = 0.1f;

// 设置粒子渲染器属性
var renderer = particleSystem.GetComponent<ParticleSystemRenderer>();
renderer.material = new Material(Shader.Find("Particles/Additive"));
renderer.material.color = startColor;
}

private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
// 在空格键按下时播放粒子效果
PlayParticleEffect();
}
}

private void PlayParticleEffect()
{
particleSystem.Play(); // 播放粒子效果

// 使用协程在一定时间后停止粒子效果
StartCoroutine(StopParticleEffect());
}

private IEnumerator StopParticleEffect()
{
yield return new WaitForSeconds(lifetime); // 等待一段时间

// 停止粒子效果并清空粒子
particleSystem.Stop();
particleSystem.Clear();
}
}

上述代码中,我们创建了一个名为 FlameEffect 的脚本类,用于控制火焰粒子效果。我们定义了一些公共的属性变量,可以在 Inspector 界面中进行设置,以便调整粒子系统的各项属性。

在 Start 方法中,我们通过 GetComponent 方法获取当前对象上的 ParticleSystem 组件,并设置其各项属性,包括最大粒子数、粒子生命周期、大小、速度、颜色等。

在 Update 方法中,我们检测到用户按下空格键时,调用 PlayParticleEffect 方法来播放粒子效果。PlayParticleEffect 方法中,我们调用 ParticleSystem 的 Play 方法来启动粒子效果,并使用协程在一定时间后停止粒子效果。

StopParticleEffect 方法中,我们使用 WaitForSeconds 方法等待粒子生命周期的时间后,停止粒子效果并清空粒子。