☰
相机为什么总是手感不对:一篇不讲公式的导数入门
2026/10/7 10:11:50 网站建设 项目流程

开场

你给相机写了个跟随逻辑。测试的时候在自己机器上手感很好,交给同事试,他说"有点飘"。你们俩的代码一模一样。

你给相机画了条样条路径让它平滑飞过。曲线画得很漂亮,跑起来相机却忽快忽慢。

你把路径上的点摆得更密一些,期待它更平滑。结果某个点上相机猛地甩了一下头。

这三件事看起来各不相干,实际上是同一个概念的三个侧面。而且这个概念在高中数学里就学过,叫导数。

只是当年没人告诉我们它和相机有什么关系。


导数就是"变化有多快"

先把这件事说清楚,然后就能用了。

位置是你在哪。速度是位置变化有多快。加速度是速度变化有多快。

后面一个是前面一个的导数。就这么简单。

位置 →(求导)→ 速度 →(求导)→ 加速度

这不是三个不相干的量,是同一条轨迹被看了三遍。看第一遍得到你在哪,看第二遍得到你跑多快,看第三遍得到你油门踩多重。

游戏里最常见的那行代码,就是这个关系的直接应用:

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)
沿路径忽快忽慢参数不等于距离建弧长表
地形光照发块导数采样太粗提高高度图精度

收束

这几个问题看起来分散在不同模块——有的在相机,有的在路径,有的在渲染。但它们共享同一个底层概念。

导数就是变化有多快。位置的导数是速度,速度的导数是加速度。

手感问题,基本都是某一级的变化率突然跳了。方向跳就甩头,速度跳就顿挫,加速度跳就被拽。

所以下次遇到"感觉不对但说不清哪里不对"的情况,可以试着问三遍:

  1. 方向是连续的吗?
  2. 速度是连续的吗?
  3. 加速度是连续的吗?

从第一个往下问,通常到第二个就能定位。

而如果问题是"换台机器手感就变",那基本不用问,去找lerp里那个没乘dt的常数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询