开场
你给相机写了个跟随逻辑。测试的时候在自己机器上手感很好,交给同事试,他说"有点飘"。你们俩的代码一模一样。
你给相机画了条样条路径让它平滑飞过。曲线画得很漂亮,跑起来相机却忽快忽慢。
你把路径上的点摆得更密一些,期待它更平滑。结果某个点上相机猛地甩了一下头。
这三件事看起来各不相干,实际上是同一个概念的三个侧面。而且这个概念在高中数学里就学过,叫导数。
只是当年没人告诉我们它和相机有什么关系。
导数就是"变化有多快"
先把这件事说清楚,然后就能用了。
位置是你在哪。速度是位置变化有多快。加速度是速度变化有多快。
后面一个是前面一个的导数。就这么简单。
位置 →(求导)→ 速度 →(求导)→ 加速度这不是三个不相干的量,是同一条轨迹被看了三遍。看第一遍得到你在哪,看第二遍得到你跑多快,看第三遍得到你油门踩多重。
游戏里最常见的那行代码,就是这个关系的直接应用:
position+=velocity*dt;意思是:速度告诉我位置每秒变多少,现在过了dt秒,那位置就变了velocity * dt。
物理引擎的核心就是这一行。它在做的事,就是拿着导数反推原来的量。
第一个问题:相机甩头
回到开头那个甩头的相机。
你用折线连接了一串点。每一段都是直线,直线本身很平滑,问题全在接头处。
上一段朝东北走,下一段突然朝东南走。方向在那一瞬间跳了。
相机的"朝向"就是它运动的方向,而方向就是位置的导数。所以用导数的语言来说:这里位置是连续的(曲线没断开),但导数不连续(方向跳变了)。
这种情况有个名字,叫C⁰:只保证位置接得上。
要修它,就得让方向也接得上。这个级别叫C¹。
样条插值做的正是这件事——用曲线段代替直线段,在接头处约定好方向要一致。Catmull-Rom 这类算法的全部工作,就是帮你算出每个点该用什么方向,让接缝对得上。
第二个问题:方向顺了,但还是顿
换成样条之后,甩头消失了。但你可能发现某些地方相机还是会"顿"一下。
曲线看起来完全光滑,没有任何折角。问题在哪?
方向对上了,速度没对上。
导数不只有方向,还有大小。方向告诉你往哪走,大小告诉你走多快。接缝处两边可以方向一致但速度不同——视觉上看不出来,运动起来就是一次突兀的变速。
再往上一级,是C²:加速度也要连续。
C² 的症状更微妙:你不会看到折角,也不会感到明显的变速,但高速移动时会有一种"被猛拽了一下"的感觉。不是方向错了,是变化率本身变得太突然。
三级对照表
把这三级和实际症状对上,就变成一张很实用的查错表:
| 症状 | 哪一级断了 | 断的是什么 |
|---|---|---|
| 看得见折角,相机甩头 | C⁰ | 方向跳变 |
| 曲线很顺,但移动时顿一下 | C¹ | 速度跳变 |
| 高速时感觉被拽了一下 | C² | 加速度跳变 |
这就是这套记号的全部价值:症状能对上级别,你就知道该修哪一层。
甩头去看方向,顿挫去看速度,被拽去看加速度。
顺便说一句,VR 对 C² 的要求比普通游戏高得多。人的前庭系统对加速度变化极其敏感,加速度跳变是晕动症的直接诱因。VR 里的相机运动基本是必须做到 C² 的。
第三个问题:开头那个"同事说飘"
这个最隐蔽,而且几乎每个项目都踩过。
相机跟随通常这么写:
cameraPos=lerp(cameraPos,targetPos,0.1f);逻辑很直白:每帧往目标挪十分之一。看起来没毛病。
毛病在**“每帧”**这两个字上。
144 帧的机器一秒挪 144 次,30 帧的机器一秒挪 30 次。同样的一秒,前者追得紧,后者追得松。
你的手感是在自己机器的帧率上调出来的。换一台机器,帧率变了,手感跟着变。
为什么这是导数问题
因为0.1f这个系数描述的是"每帧变化多少",而我们真正想控制的是"每秒变化多少"。
前者依赖帧率,后者不依赖。这两个是不同的变化率,混用就出 bug。
正确的写法把dt纳进来:
floatt=1.0f-expf(-k*dt);cameraPos=lerp(cameraPos,targetPos,t);k是速度参数,自己调。关键是现在不管帧率多少,相机在同样的时间里走同样的距离。
这条影响的范围比你想的大
所有的平滑跟随都适用:
- 相机跟随
- UI 元素的回弹
- 瞄准辅助的吸附
- 血条的缓动
- 音量淡入淡出
任何写成a = lerp(a, b, 常数)的地方,都有这个问题。
这可能是整篇文章里最值得立刻去改的一条。改动量很小,收益是全平台一致的手感。
第四个问题:沿曲线走忽快忽慢
最后一个坑,也是最容易误判的。
你有一条样条,让相机沿着它走。参数t从 0 均匀加到 1:
for(floatt=0;t<=1.0f;t+=0.01f){cameraPos=evaluate(spline,t);}结果相机忽快忽慢。控制点密集的地方慢,稀疏的地方快。
很多人的第一反应是"插值算法有问题"或者"缓动没调好"。都不是。
问题在于t只是个编号,不是距离。
t = 0.5不代表走了一半路程,只代表走到了第二段的开头。如果第一段短第二段长,那在t = 0.5的时候实际路程可能只走了三分之一。
为什么会这样
因为曲线上每一点的"速率"是不同的。速率就是导数的大小,而样条的导数大小在整条曲线上不是常数——控制点密的地方小,疏的地方大。
参数均匀前进,速率不均匀,走出来的距离自然不均匀。
解法:建一张弧长表
做法很直接:提前把曲线切成很多小段,量一下累积长度,存成表。
// 建表:细分 + 累加floatlen=0;for(inti=0;i<N;i++){floatt0=(float)i/N;floatt1=(float)(i+1)/N;len+=distance(evaluate(spline,t0),evaluate(spline,t1));table[i]=len;}// 用表:想走 5 米,查表反查出对应的 tfloatt=lookup(table,5.0f);vec3 pos=evaluate(spline,t);每段切 32 到 64 份通常就够用。
做完这一步,你才真正拥有"匀速沿路径移动"的能力。而且有了弧长之后,缓动才有意义——否则你是在对一个和距离无关的编号做缓动,效果当然不对。
这件事值得在项目早期就做掉。等到后面发现相机"感觉怪"再去排查,往返成本比一开始建表高得多。
顺便说说法线
既然讲到导数,顺手提一个最直观的应用。
地形是高低起伏的。你想知道某处是平地还是陡坡,看的就是高度变化有多快。
变化慢 = 平地,变化快 = 陡坡。这个"变化有多快"就是导数。
// 看左右两边的高度差,就知道这个方向的坡度floatslopeX=(height(x+e,z)-height(x-e,z))/(2*e);floatslopeZ=(height(x,z+e)-height(x,z-e))/(2*e);vec3 normal=normalize(vec3(-slopeX,1.0f,-slopeZ));算出坡度就能算出法线,有了法线就能算光照。
所以地形的明暗,本质上是高度函数的导数在起作用。这也解释了为什么高度图分辨率不够的时候,光照会显得"块状"——导数算得太粗了。
一张速查表
| 你遇到的 | 实际原因 | 怎么修 |
|---|---|---|
| 相机在拐点甩头 | 方向跳变(C⁰) | 换成样条,让方向连续 |
| 曲线很顺但运动顿挫 | 速度跳变(C¹) | 检查接缝处切线长度 |
| 高速时被猛拽一下 | 加速度跳变(C²) | 换 C² 连续的样条 |
| 不同机器手感不同 | lerp 没考虑 dt | 用1 - exp(-k*dt) |
| 沿路径忽快忽慢 | 参数不等于距离 | 建弧长表 |
| 地形光照发块 | 导数采样太粗 | 提高高度图精度 |
收束
这几个问题看起来分散在不同模块——有的在相机,有的在路径,有的在渲染。但它们共享同一个底层概念。
导数就是变化有多快。位置的导数是速度,速度的导数是加速度。
手感问题,基本都是某一级的变化率突然跳了。方向跳就甩头,速度跳就顿挫,加速度跳就被拽。
所以下次遇到"感觉不对但说不清哪里不对"的情况,可以试着问三遍:
- 方向是连续的吗?
- 速度是连续的吗?
- 加速度是连续的吗?
从第一个往下问,通常到第二个就能定位。
而如果问题是"换台机器手感就变",那基本不用问,去找lerp里那个没乘dt的常数。