Skip to content

在浏览器里造开放世界,第 30 部分:一台尊重墙壁的相机

作者:Oleg Sidorkin,Cinevva CTO 和联合创始人

刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。

第 29 部分给了我们一个能驱动任何身体的 controller。现在身体动得很对。它走路、滑行、游泳、滑翔、爬进洞穴、低头钻过悬垂。问题出在看着它的那个东西上。整整二十九个部分里,相机一直是一套现成的轨道装置,它跟着玩家,并且只做了一件聪明事来避免出丑,它拒绝俯到地平线以下,这样就不会滑到平地底下去。那个钳制就是破绽。它之所以存在,是因为相机根本不知道世界的几何到底在哪,所以唯一能防穿模的办法就是禁掉最可能穿模的那些角度。走到一座山丘前,相机就坐在山丘里面。踏进第 7 部分的某个 marching cubes 洞穴,你看到的是一块岩石的内壁。用第 16 部分的创作工具盖一座房子,站在一个房间里,相机就飘在墙外看着外墙板。这一部分让相机对几何有了身体早就有的那份尊重。

问题的形状,和解法的形状

第三人称相机有一件难做的工作,还有十几件容易做的。容易的那些是跟随、平滑和轨道输入,这些我们早就有了。难的那件,学术文献里叫可见性约束,而每个人都引用的那篇综述,Christie 和 Olivier 的 Camera Control in Computer Graphics,把整个领域都围绕它来构建:在尊重世界的同时,让主体保持在画面里且不被遮挡。在运行时这归结为一个每帧都要问、看似简单得骗人的问题。玩家是支点。用户已经旋转并缩放到他们身后某段距离处一个想要的相机位置。沿着那条线,相机到底能往后坐多远,才会戳进某个实心的东西?老老实实回答它,相机就会贴到山丘前面收住,在你退进一个角落时沿着吊臂往下滑,并停在洞穴顶上而不是直接捅穿过去。

回答它的那套模式又老又靠谱。Unreal 管它叫弹簧臂,Godot 自带一个 SpringArm3D 节点,Unity 的 Cinemachine 则把它拆成一个第三人称跟随装置和一个去遮挡扩展。思路始终一样。把相机挂在一根锚定在支点上的吊臂末端。路通畅时把它保持在想要的长度,有东西挡路时朝支点缩回,路重新通畅时再弹回去。Mark Haigh-Hutchinson 的 Real-Time Cameras,作者是 Metroid Prime 的相机负责人,整章整章地讲那些把这套东西的幼稚版本变成会让玩家晕的失败模式。我们拿了这套模式造了自己的版本,小到能一口气读完,就在 public/world/src/camera-rig.mjs 里。

支点(玩家头部)想要的位置(用户缩放)相机 + 探针半径 rℓ(被钳制)
吊臂把相机保持在用户想要的缩放处,直到障碍物闯进来,然后沿着同一条线缩回,这样探针球贴着表面停住,而不是让近平面捅穿过去。

一根只管自己长度的吊臂

让这套装置保持小巧的设计准则是从第 29 部分的 controller 工作里借来的:彻底管好一件事,其余的全拒绝。这套装置只管吊臂的长度,别的都不管。偏航、俯仰、轨道的阻尼、触摸和滚轮手势,全都留给 OrbitControls,它早就把这些解得很好,我们也无意重写它。所以这套装置不是一个相机 controller。它是一个后处理,在轨道运算之后运行,只修正一个数字,从支点到相机的距离。

这个决定听起来很整洁,而它差点立刻就崩了,原因在于 OrbitControls 的思考方式。在每次 update 的开头,它读取相机当前的位置,并据此推导出自己的轨道半径。这通常是看不见的。但就在我们的装置把相机缩短以躲开一面墙的那一刻,下一帧 OrbitControls 读到那个缩短后的位置,断定用户一定是放大了,于是把这个缩短烤进了用户想要的缩放里。这样持续几帧,相机就塌缩到了玩家头上,再也出不来了。修法是两个把轨道 update 夹在中间的调用,这就是全部的整合。在 OrbitControls 运行之前,beforeControls() 把相机恢复到上一帧那个完整的、未缩短的距离,这样轨道运算永远读到用户真正的缩放。在 OrbitControls 运行之后,afterControls(dt) 读取那个刚刚轨道运算出的想要位置,解算碰撞,对长度做阻尼,并把相机写到它实际该渲染的地方。用户的意图和碰撞修正从不互相触碰,推拉的响应也跟这套装置出现之前一模一样。我们为这套装置写的 headless 测试把这一点钉得很准:把吊臂顶进一面墙六十帧,离开那面墙,长度会弹回到用户那个十米的完整缩放,而不是卡在碰撞距离上。

一份契约,任何碰撞体

这套装置从不问世界是用什么做的。一个碰撞体只不过是一个带 probe 方法的对象,装置交给它一条射线、一个最大距离和相机的探针半径,拿回来一个数字,就是在那个碰撞体挡住相机之前,相机最远能走多远。装置查询每一个注册过的碰撞体,取最近的那次命中。这就是契约的全部,跟 character controller 把移动变成可插拔行为时所做的是同一招。地形插进来,道具插进来,建筑外壳插进来,每个都藏在同一个 probe 后面,而装置对它们一概不知。加一种新障碍物,就是往一个列表里加一个碰撞体,而不是改相机。

契约里有一个细节物有所值,就是探针半径。我们不是从支点到相机投一条细射线,而是投一个大到足以容纳相机近平面的球。单条射线会把相机中心停在墙上,但近平面是有宽度的,所以早在中心那条射线报告命中之前,它的角就已经埋进墙里了。改成扫掠一个小球而不是一条射线,正是每个出货实现都在做的事,Unreal 把它暴露为探针尺寸,这就是一台干净地贴着表面停住的相机,和一台让你在屏幕边缘看穿过去的相机之间的区别。

我们不靠猜那个半径,我们推导它。近平面上离相机最远的点是它的某个角,到它的距离直接从投影里掉出来。设近平面为 n、竖直视场角为 θ,则半高为 h=ntan(θ/2),半宽为 w=haspect,角点位于

rnear=n2+w2+h2

探针半径就是那个角距离乘以一个小小的安全余量,再加一个固定下限,这样在非常窄的视锥上它也永远不会掉到一个合理的最小值以下。一旦视场角、宽高比或近平面改变,半径就重新计算,所以窗口缩放或一次动到投影的缩放,都不会悄悄地把探针留得太小,盖不住它本该保护的那些角。

那个懂洞穴的碰撞体

地形碰撞体是这件事变得有意思的地方,因为我们引擎里的地形不是高度图。从第 7 部分起它就一直是有符号距离场,一个函数,返回空间中任意一点离最近实心表面有多远,以及它在岩石内还是岩石外。正的是空气,负的是岩石,正是这一个事实,让我们的相机能做到一台高度图相机在结构上做不到的事。高度图知道某个 x、z 处的地面高度。它没有天花板的概念,因为任何一点上方永远只有一个表面。所以高度图相机能挡住你走进一座山丘,但它根本不知道一个悬垂的边沿或一个洞顶正悬在支点上方,于是它直接从这两者中穿过去。距离场知道三维空间里的每一个表面,所以让相机贴住山坡的那同一个探针,不需要任何特例就能让它贴住洞顶。

洞穴实心岩石玩家高度图:穿模 ✗SDF:停在洞顶 ✓
高度图每一列只存一个表面,所以它永远看不见玩家上方那块岩石板,让吊臂往上捅穿洞顶。距离场在那块板内是负的,所以探针碰到它,相机就停在洞顶下方一点的地方。

让探针沿着距离场行进,是一种有名字、背后还有论文的技术。John Hart 1996 年的 Sphere Tracing,是让一条射线对着距离场行进的标准做法,诀窍在于距离场不只告诉你有没有撞到东西,它还告诉你一个能安全前进而不会撞到任何东西的距离。所以你不是用一个个微小的固定步长慢慢挪,而是采样距离场,按它报告的余量往前跨一步,然后重复,在空旷处大步跨,在逼近表面时小步而谨慎地走。

不过我们的地形给我们加了一个坑。一个真正的距离场报告的是到最近表面的真实欧几里得距离,按那个余量跨满一步永远是安全的。但在地形还是高度图而不是雕刻出的体素的地方,我们能廉价采样到的那个场不是真实距离,而是竖直净空,就是笔直向下到地面的那段间隙。在斜坡上,这个数会高报相机实际能动多远,因为最近的岩石在侧边,而不在正下方。诚实的距离要小一个随梯度增大的因子,1+h2,所以按报告余量来定大小的一步会跨过头,可能干净利落地跳过一道山脊。修法是欠松弛,只按报告余量的一个分数前进,而不是全部,这在大约六十度的斜坡以内都保持安全,而在真实距离的体素区域里,除了多采几次样之外几乎没有代价。我们再配上一个步长下限,这样一面只有一两个格子厚的洞壁永远不会被干净地跨过去,还有一个更紧的天花板,让行进保持廉价。当球终于碰到表面,一次短促的二分会收紧接触点,相机退后自己的一个半径,停在那里。

写出来,吊臂就是一条从支点 p 沿单位方向 d 出发的射线 r(t)=p+td,行进按场余量的一个欠松弛分数前进直到接触,两端都被钳制:

tn+1=tn+clamp(λ(Φ(r(tn))r),smin,smax)

这里 Φ 是有符号距离,在空气中为正,在岩石中为负,r 是探针半径,λ(0,1] 是欠松弛因子,让高报的高度图场不会跳过一道斜坡,smin,smax 是步长钳制,让一面薄墙不被跳过、行进也不会跑得太长。接触是第一个满足 Φ(r(t))rt,意思是球面已经到达岩石,相机就停在那个弧长减去它半径的地方。

碰撞体还带着一个方法作为安全网,去穿透。碰撞本该一开始就把相机挡在实心之外,但有几种情况会溜过去,支点跨在一面薄到吊臂一开始就在它里面的墙上、地形工具刚刚雕出一个洞而相机正坐在那块现已消失的岩石里、一块建筑构件落到了相机周围。对于这些,碰撞体检查相机是不是在这一帧结束时落在了实心里,即 Φ(c)<r,如果是,就读取场的梯度,它笔直指向空旷处,因为你离开岩石时 Φ 在变大,然后沿着它把相机推出去:

cc+(rΦ(c))Φ(c)Φ(c)

几次这样的迭代就收敛到 r 等值面上,正是这个东西意味着一把雕刻工具可以把相机脚下的地挖空,而画面在下一帧就恢复,而不是变黑。

收进去,缓出来,并且不被栅栏柱吓得一抖

一根每帧都直接跳到碰撞距离的吊臂比没有吊臂还糟,因为世界里满是相机会在单单一帧里从背后掠过的细长东西,一根栅栏柱、一盏灯、一根树干,而一台为了躲开每一个都猛冲、再猛冲回来的相机让人作呕。Haigh-Hutchinson 的书和 Itay Keren 那场广受喜爱的相机运动讲座都落在同一个直觉上,那就是相机对威胁和危险的反应应该比它从中放松要快。所以阻尼是刻意不对称的。当一个遮挡物出现、吊臂需要缩短时,它几乎瞬间就收进去,因为一帧的穿模很难看,而玩家会原谅一次快速的收进。当遮挡物清开、吊臂想要伸长时,它缓慢地缓出去,并且只在一段连续清空的短暂驻留计时器走完之后才动。那段驻留就是杀掉那一抖的滞后。让相机猛地从一根细柱旁掠过,那根柱子永远清空不到足够长来触发那段缓慢伸长,所以相机像它不存在一样滑过去,这正是你的眼睛想要的。Cinemachine 把同一个想法暴露为收进碰撞和缓出碰撞两个分开的阻尼值,而那份不对称正是让它感觉像一位摄影师而不是一根弹簧的部分。

在代码里这是一行指数平滑,速率按变化的符号来切换。设 是当前吊臂长度,a 是碰撞这一帧允许的长度,Δt 是帧时,那么

+(a)(1ekΔt),k={kinakouta>,kinkout

而缓出那一支只在清空保持了驻留时间 τ 之后才运行。1ekΔt 这个形式不只是看着整洁。它把响应时间常数固定在 1/k,与帧率无关,所以相机在每秒 30 帧和 144 帧时感觉完全一致,而那个幼稚的常数混合 +α(a) 会在快机器上收得更快、在慢机器上变得拖沓。

最后一个行为是给那些一开始就引出这整个部分的逼仄室内的。当吊臂塌缩到短得相机就压在玩家头上时,我们把玩家自己的 avatar 藏起来,让画面坐在接近第一人称的位置。这就是 Breath of the Wild 在一个狭窄神庙里做的事,也是大多数第三人称游戏在角落里退回去的做法,因为另一个选择,一台卡在墙上盯着后脑勺的相机,毫无用处。装置为它暴露一个标志,世界循环读这个标志,切换本地 avatar 的可见性。低于一米的吊臂你实际上就在第一人称里,墙被尊重,而你一退进一个有空间的房间,avatar 就淡回来,吊臂也伸长。

接下来要插什么进来

地形碰撞体今天就出货了,它是难的那一半,因为地形无处不在,而距离场是个别扭的探测对象。来自创作 spike 的道具和建筑外壳是容易的那一半,契约已经在等它们了。第二个碰撞体,一个 raycast 碰撞体,从支点朝相机对着一个网格列表投射,并和地形碰撞体一样报告最近的命中。廉价版本投一条单射线,在道具数量爬上来之前都没问题,升级是把射线换成用 three-mesh-bvh 做的扫掠球,那是 Garrett Johnson 的库,它把一个网格包进一个包围体层级里,让空间查询以对数时间运行而不是暴力遍历。无论哪种方式装置都不变。它查询一个更长的碰撞体列表,取最近的命中,这正是先造契约、后造碰撞体的全部意义。

本章引用的技术

一根只管一个数字的吊臂。 相机装置是 OrbitControls 之上的后处理,不是它的替代品。它管吊臂的长度,把偏航、俯仰、缩放和手势处理留给我们早已信任的那个轨道 controller。整合是两个夹在两头的调用:beforeControls() 恢复上一帧的完整距离,让轨道运算读到用户真正的缩放,而不是把一次碰撞缩短误认成一次拉近;afterControls(dt) 解算碰撞并写入渲染位置。没有这一对,相机会在几帧里塌缩到玩家身上。

一份可插拔的碰撞体契约。 一个碰撞体是任何带 probe 的对象,它回答"沿着这条射线,相机从支点能走多远你才挡住它"。装置查询每一个碰撞体,取最近的命中,而不管障碍是地形、道具还是一面墙,这跟可插拔 character controller 给移动用的是同一种只管一件事的纪律。探针是一个大小足以容纳近平面的扫掠球,而不是一条细射线,所以视锥的角永远不会戳进一个中心射线会漏掉的表面。它的半径从投影推导而来,是到近平面一个角的距离 n2+w2+h2 乘以一个安全余量,并在视场角、宽高比或近平面改变时重新计算。

一个照顾悬垂和洞穴的有符号距离地形探针。 因为地形是一个有符号距离场而不是高度图,让相机贴住山坡的那同一个探针也能让它贴住洞顶,那是高度图射线在结构上看不见的情况。行进是 Hart 风格的 sphere tracing,按场报告余量的一个欠松弛分数前进,两端都钳制,因为高度图区域报告的是竖直净空而不是真实距离,会高报相机在斜坡上能动多远,所以跨满一步会跳过一道山脊。一个由梯度驱动的去穿透过程是安全网,当地被从相机脚下雕掉时让画面恢复。

带驻留计时器的不对称阻尼。 遮挡物出现时吊臂快速收进,清开后缓慢缓出,而且只在一段连续清空的短窗口之后,所以猛掠过一根栅栏柱永远不会让相机猛冲。低于一个塌缩阈值时装置标记接近第一人称,世界循环就把本地 avatar 藏起来,这是逼仄室内的标准退路,而不是一台埋在墙里的相机。

参考资料

把相机控制构建为可见性约束这个框架来自 Marc Christie 和 Patrick Olivier,Camera Control in Computer Graphics(Computer Graphics Forum, 2008)。距离场行进出自 John C. Hart,Sphere Tracing: A Geometric Method for the Antialiased Ray Tracing of Implicit Surfaces(The Visual Computer, 1996)。弹簧臂模式及其探针球碰撞记录在 Epic 的 Spring Arm Component 和 Unity 的 Cinemachine DeoccluderThird Person Follow 里。运动和阻尼的直觉来自 Mark Haigh-Hutchinson,Real-Time Cameras(Morgan Kaufmann, 2009),以及 Itay Keren,Scroll Back: The Theory and Practice of Cameras in Side-Scrollers(GDC 2015)。网格碰撞体的升级路径是 Garrett Johnson 的 three-mesh-bvh


第 30 部分,共 30 部分。 上一篇:第 29 部分 - 一个 controller,任何身体 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide