1. 为什么 Mathf 值得单独拎出来聊
很多人刚接触 Unity 的时候,对Mathf这个类的态度基本就是"哦,一个数学工具类",然后继续埋头写transform.position += speed * Time.deltaTime。等到某天需要做一个平滑转向、一个弹性回弹、一个角度插值,或者处理一个"物体绕某点旋转到指定角度"的需求时,才开始满世界搜"Unity 平滑旋转怎么做""Unity 角度插值"。
其实这些问题的答案,八成都在Mathf里躺着。
Mathf是 Unity 提供的一个静态数学工具类,位于UnityEngine命名空间下,里面封装了大量在游戏开发中高频使用的数学函数和常量。它和 C# 自带的System.Math最大的区别在于:Mathf是围绕float 类型设计的,而System.Math主要围绕 double。Unity 的整个引擎底层大量使用 float,所以你在游戏逻辑里用Mathf能避免很多 double 和 float 之间的隐式转换开销,也更贴合引擎的数据流。
这篇文章我打算把Mathf里真正高频、真正容易踩坑、真正能提升代码质量的部分拆开讲。不是把官方文档翻译一遍,而是结合我这些年做项目时实际用到的场景,把每个函数的"什么时候用""为什么这么用""用错了会怎样"讲清楚。适合已经能写基本 Unity 脚本、但对数学工具类还停留在"知道有但不太会用"阶段的开发者,也适合想回头系统梳理一遍的老手。
先说一个我自己的判断标准:如果你在写游戏逻辑时,代码里出现了手写的if (a > b) a = b;这种钳制逻辑,或者自己用Math.Sqrt算距离,或者用一堆三角函数手动算角度,那大概率是Mathf里有现成的、更安全、更高效的方法你没用上。
2. Mathf 的常量与钳制函数:最不起眼但用得最多
2.1 那几个常量到底什么时候用
Mathf里定义了几个常量,看起来简单,但用对了能省不少事:
Mathf.PI:圆周率,等于 3.14159274f(注意是 float 精度,不是 double 的 3.14159265358979)Mathf.Deg2Rad:角度转弧度的系数,等于 PI / 180Mathf.Rad2Deg:弧度转角度的系数,等于 180 / PIMathf.Infinity:正无穷Mathf.NegativeInfinity:负无穷Mathf.Epsilon:最小正浮点数,注意它不是浮点误差容忍度
这里有个特别容易踩的坑:很多人以为Mathf.Epsilon可以用来做浮点数比较的容差,比如if (Mathf.Abs(a - b) < Mathf.Epsilon)。这是错的。Mathf.Epsilon是 float 能表示的最小正数,大约是 1.4e-45,这个值小到几乎没有实际意义。两个浮点数即使逻辑上相等,由于精度误差,差值通常远大于这个值。
正确的做法是自己定义一个容差常量,比如:
const float EPSILON = 0.0001f; if (Mathf.Abs(a - b) < EPSILON) { /* 认为相等 */ }至于Deg2Rad和Rad2Deg,只要你涉及Mathf.Sin、Mathf.Cos、Mathf.Atan2这些三角函数,就一定会用到。因为 Unity 的三角函数接收的是弧度,而 Inspector 里显示的角度、Transform.eulerAngles返回的都是角度。我见过不少新手直接Mathf.Sin(30)然后纳闷结果为什么不对——因为 30 弧度早就绕了好几圈了。
2.2 Clamp、Clamp01、Repeat、PingPong 的分工
钳制类函数是Mathf里使用频率最高的一类,没有之一。
Mathf.Clamp(value, min, max)把值限制在 [min, max] 区间内。这个函数我几乎每个项目都会用到,典型场景是限制角色血量、限制相机距离、限制输入范围。它的实现逻辑很简单,但自己手写容易出 bug,比如 min 和 max 写反了,或者忘了处理边界。用现成的更稳。
Mathf.Clamp01(value)是Clamp(value, 0, 1)的快捷方式。当你处理归一化数值、插值系数、进度条百分比时特别顺手。比如float t = Mathf.Clamp01(elapsedTime / duration);这一行就能保证插值系数永远在合法范围内,不会因为时间溢出导致插值结果飞出天际。
Mathf.Repeat(t, length)是循环取值,返回 t 在 [0, length) 区间内的循环值。想象一个长度为 length 的跑道,t 一直增大,Repeat 会让它到终点后从起点重新开始。做循环滚动背景、循环 UV 动画时很好用。
Mathf.PingPong(t, length)则是来回弹,从 0 到 length 再回到 0,像乒乓球一样。做往复运动的平台、呼吸灯效果、来回摆动的钟摆,用这个一行搞定。
这四个函数的关系可以用一个表说清楚:
| 函数 | 行为 | 典型场景 |
|---|---|---|
| Clamp | 超出范围就卡在边界 | 血量、距离、输入限制 |
| Clamp01 | 卡在 0 到 1 | 插值系数、进度百分比 |
| Repeat | 循环,到终点回起点 | 循环滚动、UV 动画 |
| PingPong | 往返,到终点折返 | 往复运动、呼吸效果 |
我个人的经验是:当你发现自己在写if (x > max) x = max;或者x = x % length;这类逻辑时,先想想Mathf里是不是有现成的。用现成的不仅代码短,而且边界处理更严谨,尤其是Repeat对负数的处理,自己写取模很容易在负数上翻车。
3. 插值三兄弟:Lerp、LerpUnclamped、SmoothStep 的取舍
3.1 Lerp 的第三个参数不是"进度"那么简单
Mathf.Lerp(a, b, t)返回 a 和 b 之间按 t 插值的结果,t 会被钳制在 [0, 1]。公式是a + (b - a) * t。
很多人对 Lerp 的理解停留在"t 从 0 变到 1,结果从 a 变到 b",这没错,但有个关键点经常被忽略:Lerp 的 t 是钳制的。如果你传进去的 t 是 1.5,它会被当成 1;传 -0.3,会被当成 0。这个特性在大多数情况下是保护,但有时候你恰恰需要超出范围的插值,比如做外推效果。
Mathf.LerpUnclamped(a, b, t)就是不做钳制的版本,t 可以任意大或任意小,结果会超出 a 到 b 的范围。做弹性回弹、过冲效果时,这个函数是刚需。
还有一个更隐蔽的坑:不要用 Lerp 做"逐渐逼近"。我见过太多这样的代码:
// 错误示范 transform.position = Vector3.Lerp(transform.position, target.position, 0.1f);这段代码放在Update里,看起来像是"每帧向目标移动 10%",实际上它确实能逼近目标,但永远到不了,而且逼近速度会随着距离减小而变慢,呈现出一种"越来越慢、永远差一点"的诡异手感。原因是这里的 t 是固定的 0.1,而起点每帧都在变,本质上是无限逼近。
正确的做法有两种:要么用MoveTowards做匀速逼近,要么用SmoothDamp做平滑逼近,要么把 t 改成基于时间的动态值:
float t = Mathf.Clamp01(elapsedTime / duration); transform.position = Vector3.Lerp(startPos, targetPos, t);这里的关键是起点和终点固定,t 随时间从 0 变到 1,这才是 Lerp 的正确打开方式。
3.2 SmoothStep 和它的"平滑"到底平滑在哪
Mathf.SmoothStep(from, to, t)和 Lerp 类似,但它对 t 做了一次平滑处理,用的是经典的 smoothstep 公式t * t * (3 - 2 * t)。这个公式的特点是:在 t=0 和 t=1 附近变化缓慢,中间变化快,整体呈现 S 形曲线。
这意味着什么?意味着用 SmoothStep 做插值时,起始和结束都是"缓入缓出"的,运动看起来更自然,没有 Lerp 那种"突然启动、突然停止"的机械感。做 UI 动画、相机过渡、物体淡入淡出时,SmoothStep 通常比 Lerp 观感更好。
不过要注意,Mathf.SmoothStep内部也会钳制 t。如果你需要不钳制的平滑插值,得自己实现 smoothstep 公式再配合 LerpUnclamped。
还有一个容易混淆的点:Mathf.SmoothStep和Vector3.SmoothDamp不是一回事。前者是纯数学插值,后者是基于阻尼的物理逼近,用途完全不同。SmoothStep 需要你提供 t,SmoothDamp 只需要你提供目标和一个速度引用,它会自己算。
3.3 插值函数的选型决策
我把这几个函数的选型逻辑整理成一张表,方便对照:
| 需求 | 推荐函数 | 理由 |
|---|---|---|
| 固定时长、固定起终点的过渡 | Lerp + 动态 t | 可控、可预测 |
| 需要缓入缓出的过渡 | SmoothStep | 观感自然 |
| 需要过冲/回弹 | LerpUnclamped | 允许超出范围 |
| 每帧逼近一个移动目标 | MoveTowards 或 SmoothDamp | 避免无限逼近 |
| 匀速直线逼近 | MoveTowards | 速度恒定 |
| 带阻尼的平滑逼近 | SmoothDamp | 有惯性感 |
我自己的习惯是:只要涉及"从 A 到 B 的过渡",先问自己三个问题——起点终点固定吗?需要缓动吗?需要过冲吗?三个问题的答案基本就能定位到该用哪个函数。
4. 角度与三角函数:绕不开的弧度陷阱
4.1 角度和弧度的转换必须形成肌肉记忆
Unity 里角度和弧度的混用是新手翻车重灾区。记住一条铁律:Transform.eulerAngles、Inspector 里显示的角度、Quaternion.Euler接收的都是角度;Mathf.Sin、Mathf.Cos、Mathf.Atan2、Mathf.Asin接收和返回的都是弧度。
转换就两个系数:
float rad = deg * Mathf.Deg2Rad; float deg = rad * Mathf.Rad2Deg;我建议在代码里养成习惯:变量名带Deg后缀的就是角度,带Rad后缀的就是弧度。这样 review 代码时一眼就能看出有没有混用。
4.2 Atan2 为什么比 Atan 好用
Mathf.Atan(y / x)返回的是弧度,但它有个致命问题:它只能返回 -90° 到 90° 之间的角度,因为y/x把符号信息丢了。比如点 (1, 1) 和点 (-1, -1),它们的 y/x 都是 1,Atan 返回的角度一样,但实际上这两个点方向差了 180°。
Mathf.Atan2(y, x)接收两个参数,保留了符号信息,能返回 -180° 到 180° 的完整角度。计算"从 A 指向 B 的方向角"时,必须用 Atan2:
float angle = Mathf.Atan2(target.y - self.y, target.x - self.x) * Mathf.Rad2Deg;这行代码在 2D 游戏里做朝向、做追踪弹、做雷达指向时是标配。我见过有人用 Atan 然后手动判断象限补角度,写了一堆 if-else,其实一个 Atan2 就解决了。
4.3 角度差计算:DeltaAngle 和 Repeat 的组合
计算两个角度之间的最短差值,是另一个高频需求。比如角色当前朝向 350°,目标朝向 10°,实际差值应该是 20° 而不是 -340°。
Mathf.DeltaAngle(current, target)就是干这个的,它返回 -180° 到 180° 之间的最短角度差。做平滑转向时:
float delta = Mathf.DeltaAngle(currentAngle, targetAngle); currentAngle += delta * turnSpeed * Time.deltaTime;这样转向永远走最短路径,不会出现"绕一大圈"的尴尬。
如果你需要自己处理角度循环,Mathf.Repeat(angle, 360f)可以把任意角度归一化到 [0, 360) 区间,配合 DeltaAngle 使用效果更好。
这里分享一个我踩过的坑:早期做 2D 角色朝向时,我直接用targetAngle - currentAngle然后 Clamp 转向速度,结果角色在跨越 0°/360° 边界时会突然反向旋转一大圈。排查了半天才意识到是角度环绕问题,换成 DeltaAngle 后立刻正常。这个坑在 3D 里用四元数反而不容易遇到,但在 2D 和 UI 旋转里非常常见。
5. 取整、绝对值与符号:细节里藏着性能
5.1 四种取整方式的区别
Mathf提供了四种取整函数,行为各不相同:
Mathf.Round:四舍五入,返回 floatMathf.RoundToInt:四舍五入,返回 intMathf.Floor/Mathf.FloorToInt:向下取整Mathf.Ceil/Mathf.CeilToInt:向上取整
这里有个经典陷阱:Mathf.Round用的是"银行家舍入法"(round half to even),也就是 0.5 会舍入到最近的偶数。Mathf.Round(0.5f)返回 0,Mathf.Round(1.5f)返回 2。这和很多人直觉里的"四舍五入"不一样。如果你需要传统的四舍五入,得自己处理或者用Mathf.Floor(x + 0.5f)。
做格子对齐、瓦片地图坐标转换时,FloorToInt 用得最多。做分页、分组时,CeilToInt 更合适,因为"3.1 页"应该算 4 页。
5.2 Abs、Sign 和它们的组合用法
Mathf.Abs取绝对值,Mathf.Sign返回符号(正数返回 1,负数返回 -1,0 返回 1)。这两个单独用很直白,但组合起来能解决不少问题。
比如做"数值向目标逼近但不超过"的逻辑:
float diff = target - current; float step = Mathf.Min(Mathf.Abs(diff), maxStep) * Mathf.Sign(diff); current += step;这段代码保证 current 每次最多移动 maxStep,且不会越过 target。比一堆 if-else 清爽得多。
还有一个细节:Mathf.Sign(0)返回的是 1 而不是 0,这个行为在官方文档里有说明,但很容易被忽略。如果你的逻辑依赖 0 的符号,得自己判断。
5.3 性能上的取舍
有人会问:Mathf.Abs和Math.Abs哪个快?Mathf系列因为直接操作 float,省去了 double 转换,在 Unity 里通常更快。而且 Unity 的 IL2CPP 编译后,很多Mathf函数会被内联成 CPU 指令,性能非常接近原生。
不过我要泼一盆冷水:在绝大多数游戏逻辑里,这些函数的性能差异可以忽略不计。真正影响性能的是每帧调用次数和算法复杂度,而不是 Abs 比 Math.Abs 快了几纳秒。我见过有人为了"优化"把 Lerp 换成手写公式,结果代码可读性暴跌,性能提升却测不出来。这种优化是负收益。
我的建议是:优先用Mathf保证类型一致和可读性,性能问题等 Profiler 告诉你哪里是瓶颈再针对性处理。
6. 随机数与噪声:Mathf 里被低估的部分
6.1 Random 的边界行为
Mathf本身没有 Random,随机数在UnityEngine.Random里。但既然聊到 Mathf 的实用函数,随机数这块绕不开,因为很多人会把两者混着用。
Random.Range(int min, int max)返回 [min, max) 的整数,不包含 max。Random.Range(float min, float max)返回 [min, max] 的浮点数,包含 max。这个差异是官方设计如此,但坑了无数人。做数组随机索引时用 int 版本没问题,但做概率判断时如果用 int 版本,边界会少一个值。
Random.value返回 [0, 1] 的浮点数,做概率判断很方便:
if (Random.value < dropRate) { /* 掉落 */ }6.2 Perlin 噪声的实际用途
Mathf.PerlinNoise(x, y)返回 [0, 1] 的柏林噪声值,输入相同的坐标永远返回相同的值。这个特性让它特别适合做程序化生成:地形高度、云层分布、纹理扰动、相机抖动。
用 Perlin 噪声做相机抖动是个很实用的技巧:
float shakeX = (Mathf.PerlinNoise(Time.time * freq, 0f) - 0.5f) * amplitude; float shakeY = (Mathf.PerlinNoise(0f, Time.time * freq) - 0.5f) * amplitude;比纯随机抖动更平滑,观感更舒服。注意 Perlin 噪声在整数坐标处返回 0.5,所以输入要乘一个非整数频率,否则会得到规律性的结果。
6.3 一个容易被忽略的细节
Mathf.PerlinNoise的输入如果太大,精度会下降,噪声会变得有块状感。做无限地形时,通常要把世界坐标缩放到一个合理范围再采样,比如除以 100 或 1000。这个缩放系数直接决定了地形的"粗糙程度",需要根据项目调。
7. 那些年我在 Mathf 上踩过的坑
7.1 浮点数相等判断
前面提过Mathf.Epsilon不能用来做容差,这里再强调一次。浮点数运算几乎不可能得到精确相等的结果,判断相等必须用容差:
bool Approximately(float a, float b) { return Mathf.Abs(a - b) < 0.0001f; }Unity 其实提供了Mathf.Approximately(a, b),它内部用的是相对容差,比固定容差更科学。但要注意,Approximately对接近 0 的数和很大的数行为不同,极端情况下可能不符合预期。如果对精度有严格要求,还是自己写容差判断更可控。
7.2 Lerp 的 t 用错导致的手感问题
前面讲过用 Lerp 做逼近的坑,这里补充一个变体:用Time.deltaTime直接当 t。
// 错误示范 transform.position = Vector3.Lerp(a, b, Time.deltaTime);这段代码的问题在于,Time.deltaTime在 60 帧下约等于 0.0167,在 30 帧下约等于 0.0333。这意味着帧率越高,插值越慢,不同设备上的表现不一致。正确的做法是把 deltaTime 累积成归一化的 t,或者用1 - Mathf.Exp(-speed * Time.deltaTime)这种帧率无关的逼近公式。
7.3 角度插值直接 Lerp 的灾难
两个角度之间直接 Lerp 是另一个经典错误。比如从 350° 插值到 10°,直接 Lerp 会走 350→180→10 的长路径,而不是 350→360/0→10 的短路径。正确做法是用 DeltaAngle 算最短差值,再累加:
float delta = Mathf.DeltaAngle(current, target); current = Mathf.Repeat(current + delta * t, 360f);这个坑我在做 UI 指针旋转时踩过,指针会莫名其妙绕一大圈,排查了很久才定位到角度环绕问题。
7.4 整数除法的隐形陷阱
这个不算 Mathf 的坑,但和数学计算强相关。C# 里两个 int 相除结果还是 int,1 / 2等于 0 而不是 0.5。计算百分比、比例时,必须保证至少有一个操作数是 float:
float ratio = (float)current / total; // 正确 float ratio = current / total; // 错误,整数除法这个坑太常见了,我几乎每个带新人的项目都要提醒一遍。
8. 把 Mathf 用出花:几个实战组合技
8.1 平滑跟随相机的完整实现
把前面讲的函数组合起来,做一个平滑跟随相机:
public class SmoothFollow : MonoBehaviour { public Transform target; public float smoothTime = 0.3f; public Vector3 offset = new Vector3(0, 2, -5); private Vector3 velocity = Vector3.zero; void LateUpdate() { Vector3 desiredPos = target.position + offset; transform.position = Vector3.SmoothDamp( transform.position, desiredPos, ref velocity, smoothTime); } }这里用SmoothDamp而不是 Lerp,是因为相机跟随的目标是移动的,用 Lerp 会无限逼近。SmoothDamp内部用弹簧阻尼模型,跟随有惯性感,观感更自然。LateUpdate保证在目标移动之后再更新相机,避免抖动。
8.2 数值滚动显示的缓动
做 UI 时经常需要数字从 0 滚动到目标值,用 SmoothStep 做缓动:
IEnumerator CountUp(int from, int to, float duration) { float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; float t = Mathf.Clamp01(elapsed / duration); t = Mathf.SmoothStep(0f, 1f, t); int display = Mathf.RoundToInt(Mathf.Lerp(from, to, t)); scoreText.text = display.ToString(); yield return null; } scoreText.text = to.ToString(); }注意最后要强制设置为终值,避免因为浮点误差停在 to-1 上。这个细节不做的话,偶尔会出现"数字差 1"的 bug,很难复现但很烦人。
8.3 圆形范围检测
判断一个点是否在圆形范围内,用平方距离避免开方:
bool InRange(Vector2 center, Vector2 point, float radius) { float sqrDist = (point - center).sqrMagnitude; return sqrDist <= radius * radius; }这里虽然没直接用 Mathf,但思路是数学优化:开方是相对昂贵的运算,能避免就避免。Unity 的Vector3.sqrMagnitude就是为此设计的。如果非要用 Mathf,Mathf.Sqrt也能用,但没必要。
8.4 弹性回弹效果
用 LerpUnclamped 配合缓动曲线做过冲回弹:
float ElasticEase(float t) { if (t < 0.5f) return 2 * t * t; else return -1 + (4 - 2 * t) * t; }这个函数在 t=0.5 附近会超过 1,产生过冲。配合 LerpUnclamped 使用,物体到达目标后会稍微冲过头再弹回来,手感很 Q 弹。做 UI 弹窗、按钮反馈时特别好用。
9. 关于 Mathf 的一些零散心得
写到这里,把一些零散但有用的经验集中说一下。
第一,Mathf的函数基本都是静态的,调用时不需要实例化,但要注意它们都是纯函数,不修改传入的参数。这一点和某些语言里"原地修改"的 API 不同,别指望Mathf.Clamp(x, 0, 1)会改变 x 的值,必须x = Mathf.Clamp(x, 0, 1)。
第二,Mathf里没有Pow的整数版本,Mathf.Pow接收和返回都是 float。做整数幂运算时,小指数直接乘更快,大指数才考虑 Pow。
第三,Mathf.Max和Mathf.Min支持可变参数,Mathf.Max(a, b, c)是合法的,不用嵌套调用。这个特性在找一组数里的极值时很方便。
第四,Mathf的很多函数在 IL2CPP 下会被内联优化,但在 Mono 编辑器下可能有函数调用开销。不过还是那句话,除非 Profiler 指出问题,否则不用管。
第五,做跨平台项目时要注意,不同平台的浮点精度可能有细微差异。涉及精确计算的逻辑(比如帧同步、确定性物理),要避免依赖浮点数的精确相等,全部用容差判断。
第六,Mathf和Vector3、Quaternion的静态方法配合使用效果最好。比如Vector3.Lerp内部就是调Mathf.Lerp,Quaternion.Slerp内部也用了大量 Mathf 函数。理解 Mathf 有助于理解这些高级 API 的行为。
最后分享一个我自己的习惯:每当我要写一个数学相关的逻辑,先花 30 秒想想Mathf里有没有现成的。这 30 秒通常能省下后面 30 分钟的调试。数学函数的边界条件、精度处理、符号问题,官方实现都考虑过了,自己手写十有八九会漏掉某个 corner case。用现成的不是偷懒,是把精力留给真正需要创造力的部分。