一帧画面是怎么来的

UNIT 10引擎约 25 分钟

虚幻 5 凭什么又快又好看?

同一条沙面古榕大道,广州片用路径追踪,一帧要算 16 秒左右;《拾光》在同一张 4080 上,1080p 跑满每秒 60 帧,显卡每帧只用 13.9 毫秒 C47。画面也不寒碜:太阳按 9 月 26 日广州的时刻从早走到晚,日落以后 240 盏路灯一盏盏亮起 C32。第 8 课算过游戏的账是倒着算的:先定一帧最多给多久,60 fps 就是 16.7 毫秒。这一课看虚幻引擎 5(Unreal Engine 5,下面叫 UE)怎么把「好看」塞进这一格,塞进去又要守哪些规矩。

10 · 1每帧都照离线那样算一遍,不行吗?

离线的画面好,好在每个像素算很多遍再平均(第 7 课)。MotionLib 那几部短片用 Blender 的实时渲染器 EEVEE 渲,每帧算 64 遍再平均,一帧约 4 秒,是 60 fps 预算的 240 倍 C47。那游戏把 64 遍减成 1 遍,不就行了?

MotionLib 的那种一帧,只算 1 遍而不是 64 遍,大约要多久?进得了 16.7 毫秒吗?

  1. 进得了:少算 64 倍,绰绰有余
  2. 正好卡在 16 毫秒上下
  3. 进不了:还要 60 毫秒上下,差三四倍
  4. 进不了:还是要好几秒

4 秒 ÷ 64 ≈ 62 毫秒,还是预算的近 4 倍。当时算过这笔账:约 240 倍的差距里,64 倍来自多遍采样;剩下的约 4 倍,要算到别的头上——这些场景是一分钟内用脚本搭起来的,光照没提前算好存起来(烘焙,下一节讲),远处的模型也没换成面数少的简化版,Blender 每一帧还在 CPU 上重算骨骼、表情、草和头发 C47。人越多,这一块越大:同一套设置,1 个角色的镜头约 1 秒一帧,54 个角色约 27 秒,多出来的时间主要花在 CPU 摆场景上(每根骨头、跟着变形的几万个顶点、描边外壳),不在采样 C48。

猜「绰绰有余」和「正好」的,把整笔账都算在了采样头上。游戏不是把同一件事做快了一点,而是每一项都换了做法。下面的实验台把一帧的账摊开。

实验台 B-216.7 毫秒里装什么
来自材料
60 Hz 屏幕每 16.7 毫秒刷新一次;实测的整帧:《拾光》1080p 正式版显卡每帧 13.9 毫秒(C47),广州网页版在 M4 芯片的 Mac 上约 17 毫秒(C10);MotionLib 每帧 64 遍再平均、约 4 秒,广州片用路径追踪约 16 秒(C47);Lumen 那一块的 8 毫秒出自 UE 文档(UE 的最高画质档 Epic、新一代游戏主机、放大之前按 1080p 算;文档说这一档是按 30 fps 设计的,60 fps 用低一档的 High,这里只借它的量级)。
本实验的设定
除 Lumen 外,各块的毫秒数是为了看清规矩挑的整数,不是《拾光》的分项实测——它只测过整帧。一帧按各块相加算,只画显卡这一条线;屏幕按垂直同步:这一帧没赶上这次刷新,就等下一次。「算 64 遍」按整帧乘 64。
它能证明
预算是加法,超出一点,帧率就掉一半;每帧多算几十遍,离实时差的是一百倍上下,不是几成。
它不能证明
在你的场景里哪一项最贵,得用引擎自己的性能面板量。真实引擎里 CPU 和 GPU 同时在干前后两帧的活,一帧的时间不是简单相加。C10 的汇报里同时写着「约 31–36 fps」和「压到 30」,口径不清,这里只用「17 毫秒超出了 16.7」这一条。

实验台默认停在 16.0 毫秒,60 fps。把「运动模糊」那一块放进去,一帧 17.0 毫秒,只超出 0.3,帧率就掉到 30:屏幕只在刷新的那一刻换画面,这一帧没赶上,就得等下一次。广州网页版在 M4 上就是这样,一帧约 17 毫秒,被垂直同步压到了 30 fps C10。换成 120 Hz 的高刷屏,一格只剩 8.3 毫秒。预算是加法,每多开一样,都得从别处省回来。

再打开 03「每帧算 64 遍再平均」:同一帧乘 64,一秒上下才出一帧。第三条对数刻度上,它和 MotionLib、广州片的实测排在一起,离「预算」那条线隔着两格——每一格是十倍。

10 · 2少算,还要看着不差

UE 换的做法,归起来是四招。每一招都是拿一样东西换时间,看清楚它拿什么换的,就知道它在哪里会露馅。

一、每个像素只算一次,缺的向前几帧借

每像素只算一次,噪点和锯齿都很重。可前后两帧的画面差不多:把前几帧算过的结果,按物体这一帧挪了多少挪到对应的位置,再叠进来,一个像素就等于算了好几次,时间只花一次(时域累积;用来抗锯齿时叫 TAA,用来放大分辨率时叫超分)。《拾光》接 4K 屏幕时,先按 1440p 算,再放大到 4K:1440p 是 2560×1440 ≈ 369 万像素,4K 是 3840×2160 ≈ 829 万,真正算的只有 44%。它拿「这一刻」换时间:借来的都是过去的画面,刚从后面露出来的东西、动得太快的东西,前几帧里没有或者对不上,这一块就借不到。

二、不会变的,提前算好

墙角、屋檐下那一圈暗(环境光遮蔽,AO),只看几何挡住了多少,和太阳在哪无关。广州网页版就没在网页里算它,而是事先用 Cycles 在 4080 上把 AO 烘进了每个顶点的颜色:约 25 万个三角面的一片城区,烘一次约 2 秒(工作机笔记);打开网页以后,每一帧只是把这个颜色读出来,几乎不花时间。这叫烘焙(baking)。代价是烘进去的东西就不能动了:《拾光》的太阳从早走到晚,阳光照出来的明暗每一刻都不一样,这一部分没法提前烘好。

三、会变的,存起来慢慢更新

阳光打在墙上、又弹进树荫底下的那一点亮(间接光),归 Lumen 管:UE 默认的全局光照和反射,太阳一动,间接光跟着变。它不是每帧从头追一遍光线,而是把场景里各处表面的光照存在缓存里,每帧只刷新其中一小部分,一大片光照分到很多帧里慢慢刷(UE 文档)。官方给的量级:UE 的最高画质档 Epic,在新一代游戏主机上、放大之前按 1080p 算,全局光照加反射约 8 毫秒——这一档是按每秒 30 帧设计的,放进 60 fps 的一格,差不多占掉一半(要跑 60 帧,文档用的是低一档的 High)。它和烘焙不叠加:项目一开 Lumen,提前烘焙的静态光照就不再起作用,光照贴图全部隐藏(UE 文档)。要么烘焙,要么实时。

用缓存、慢慢更新,代价在哪?把太阳一下关掉,Lumen 算的那部分间接光(树荫下、墙缝里的那点亮)会怎样?

  1. 同一帧就暗下去
  2. 下一帧暗下去
  3. 要过好几秒,才慢慢暗下去
  4. 一直不变,得重新烘焙

要好几秒。UE 文档的原话大意是:Lumen 靠好几层缓存才做到实时,局部的光照变化传得很快,像关掉太阳这种全局的变化,可能要好几秒才传开。每帧只刷一小部分:一盏灯附近的变化,几帧就刷到了;关掉太阳,整个场景都要变,就得刷很多帧。缓存省下的时间,是拿「慢半拍」换的。

猜「同一帧」「下一帧」的,把 Lumen 当成了每帧从头算的路径追踪;猜「得重新烘焙」的,把它当成了烘焙——烘焙的光才是死的,Lumen 会跟上,只是慢。

反射也是这个思路,先快后稳:先在当前画面已有的像素里找反射到的东西(第 6 课的屏幕空间反射,快);找不到,或者光线钻到了物体背后,再换一个更慢、更可靠的办法补(UE 文档)。光在画面里找,画面外的、被挡住的就反射不出来,第 6 课那块红方块就是这么丢的。

另一个代价在发光材质上(材质本身亮,但它不是灯)。Lumen 会把发光材质的光传出去,但小而亮的发光面会出噪点(UE 文档)。《拾光》夜里的路灯灯罩就是小而亮的那种,于是回 Blender 把所有灯罩按距离聚成一团一团,一团一盏,共 240 盏,每盏放一个真的点光源(从一个点往四周照的灯);几百盏带阴影的灯同时亮,靠的是 UE 5 新加的 MegaLights C32。

四、只画看得见的,看得见的只画到看得清

看不见的,整个不画:引擎给每个物体套一个包围球,球整个在镜头外,就跳过(视锥剔除)。这一招网页里也有。投篮课是网页,用的是 three.js:场景、相机、渲染器三件套,浏览器每次刷新屏幕之前叫它画一帧(requestAnimationFrame;标签页切到后台,这个调用就暂停,它也就不画了),它也默认这样剔除。投篮课就撞过它的反面:教练走出几米后凭空消失。蒙皮角色(第 9 课)的包围球按静止姿势算、留在原点,人被骨骼带着走远了,球还在原地,一出镜头就把整个人剔掉了。修法是对整个角色关掉剔除 C44。

看得见的,只画到看得清为止:这是 Nanite,UE 5 管模型面数的办法。导入模型时,它把网格(模型的三角形面)切成一簇簇、分好层级;画的时候,只做屏幕上看得出来的那一层细节,数据也只把看得见的读进显存(UE 文档)。沙面五百多万个三角面,就是靠它跑得动的。它也有代价:导入时要预处理,《拾光》导模型的那几个小时,Windows 上忙的全是 CPU;它只认不透明和遮罩(镂空但不半透明,比如树叶贴片)两种材质,遇到不支持的,就换成默认材质、在日志里报一条警告(UE 文档)。

10 · 3编辑器里好好的,进游戏就坏了

四招都有规矩,规矩没守住,画面就坏。《拾光》能走的第一版沙面,楼的外墙上、窗户两边,一排排黑色的细三角;树冠也发黑 C31。

游戏画面:主角走在沙面的石板路上,左边粉色的楼上,每扇窗两侧都有黑色的尖三角,树冠发黑;屏幕上方有一行黄色的英文警告。右边是那栋楼窗户的放大。
第一版的沙面。游戏模式下行走(左),右边是左侧那栋粉楼的放大:窗户两边一排排黑色尖三角。屏幕上方那行黄字是引擎的警告,字面意思是「很多不是 Nanite 的网格盖住了阴影贴图的一大片」C31。

当时是一步步排除掉的,每一步都是「如果问题在这儿,我会看到什么」:

  1. 猜是导入时把带窗洞的墙拆成三角形拆错了:导出前自己拆好,106 个模型全部重导(半个多小时)。黑三角还在。
  2. 关掉光照,只看物体本身的颜色,放大。三角形还在:不是阴影,是形状坏了——百叶窗和窗玻璃从长方形变成了尖三角。
  3. 在 Blender 里,原场景和导出的文件放在同一个机位各渲一张。一模一样:模型没坏,导出也没坏。
  4. 在引擎的编辑器里,对着同一栋楼截一张。编辑器里是好的,只有进了游戏模式才坏。

排到这一步:模型没坏,导出没坏,编辑器里也是好的,只有进游戏才坏。下一步去哪找?

  1. 再重导一遍模型,换个导出格式
  2. 调灯光和阴影的参数
  3. 游戏启动时的日志
  4. 换显卡驱动

日志里有一百多行几乎一样的警告 C31:

LogMaterial: Display: Material /Game/MI/city_shamian/MI_lg_brick.MI_lg_brick needed to set usage flag Nanite

用在 Nanite 模型上的材质,要事先带一个「会被 Nanite 用到」的使用标记。编辑器里缺了它,引擎当场替你补上,所以一切正常;游戏模式和打包出来的正式版(导出给玩家的 exe)不补,按文档,改用默认材质。44 个材质补上标记,黑三角没了,树冠也从发黑变回了阳光下的绿;那时在一个很小的测试窗口里测,帧率从每秒 35 帧升到了 82 帧——截图上那行黄字,第二句正是「性能可能受影响」。文档里查得到的只有「改用默认材质并报警」,形状为什么会变成尖三角,没找到文档说明。

猜「重导」的,第 1、3 步已经排除了模型;猜「灯光」的,第 2 步关掉光照三角还在。只在游戏里坏,就去找编辑器和游戏不一样的地方,日志是最便宜的一步。

这是一类毛病:编辑器会替你补的,游戏里不补;只给人看的,打包以后就没了。《拾光》的正式版还撞过另一个:晚上九点半,太阳还挂着,画面白成一片。时间系统是按名字找太阳和月亮的(编辑器里给物体起的名字「Sun」「Moon」),打包时这种只给人看的信息被去掉了,找不到太阳就一直没动它,曝光却已经按夜里调亮了。改成认编号:UE 算天空颜色的「天空大气」里,0 号光源是太阳,1 号是月亮。同一批还有一个:新改的功能没进正式版——编译的时间戳比改过的代码还晚,被当成「没变」,没重新编进去 C34。

规矩也不只在材质和名字上。MetaHuman(UE 自带的写实数字人)骨头特别多,要在项目设置里把骨骼的编号放宽(16 位骨骼索引)、不限每个顶点受几根骨头影响,才用得起来;它默认只穿 T 恤短裤、光着脚,《拾光》自己做了 4 种鞋,穿鞋的人整体抬高 2.1 厘米,免得脚从鞋底穿出来 C52。许可也有规矩:MetaHuman 现在并在虚幻引擎的许可里,可以拿到别的软件里渲染,但年收入超过 100 万美元、又在 UE 之外渲染的要买席位,而且不许拿来训练 AI(MetaHuman 许可页)。

10 · 4按用途挑:实时、预览、成片

走到这里,同一条沙面古榕大道你见过三种算法:《拾光》里 UE 一帧 13.9 毫秒,广州片的 Cycles 一帧 16 秒,中间还有开渲前那一版动态分镜。它们不分高下,分的是用途。挑之前先问两件事:这一帧最晚多久要拿到?画面里有没有光栅化会露馅的东西,比如要映出画面外的镜面、影子里的颜色渗透?

实时网页、游戏:读者自己拖、自己走

挑什么
光栅化,加上一节那四招
凭什么
一帧只有 16.7 毫秒(高刷屏 8.3);路径追踪减到每像素 1 遍也还差近 4 倍(本课第 1 节)
代价在哪
反射只在画面里找(第 6 课);Lumen 的全局变化慢几秒;烘焙的不能动;超一点,帧率就掉一半

预览开渲前看机位、构图、节奏

挑什么
低分辨率、少采样再降噪;或者干脆用光栅化(Blender 的 EEVEE)
凭什么
要的是快,查的是大的:机位、穿帮、节奏
代价在哪
草稿省掉的它看不见:降噪抹细节、帧间会爬(第 7 课);没开运动模糊,拖糊就藏住了(第 13 课);固定开销还在,降档省不满(第 8 课)

成片片子、海报:等得起

挑什么
路径追踪,每像素上百次采样,必要时降噪
凭什么
反射、影子、间接光都在整个场景里找,不露馅(第 7 课)
代价在哪
时间:像素 × 采样 + 固定开销,噪点减半要四倍采样;先查显卡在不在算、显存装不装得下(第 8 课)

你的三个项目正好一样一种。《拾光》是实时:读者握着手柄,只能是 UE 这一套,13.9 毫秒里还留着一点余量 C47。广州片开渲前的动态分镜是预览:960 × 402、每像素 16 次采样再降噪,拿来查机位和节奏,陶塑的脸糊了不要紧 C03。正式版是成片:1080p · 128 次采样,2376 帧渲了 8 小时 42 分 C04。

用途和算法也不是死对应,第二个问题会改答案。「在网页里」不等于实时:网页上放一段开场动画,读者只看不动,它就是一段视频,完全可以先离线渲好再放。「成片」也不一定要路径追踪:MotionLib 的三渲二版用 EEVEE 出片,画风本来就不按物理算光,光栅化的近似不显眼 C42;红魔 v3 的镜面铬甲用 EEVEE 就露了馅,v4 换成 Cycles 才好 C20。挑哪一种,最后落在一句话上:这一帧里,有没有它算不对、观众又看得出来的东西。

带走一个问题

广州网页版想在夜里加 200 盏会动的车灯,每一盏都要照亮路面。这一课的四招里,哪一招帮不上忙,为什么?开做之前,你该先量什么?

不看上面,写下这一课撞上的几堵墙,再展开对照
  1. 把离线的做法减几遍采样就能实时 → 64 遍减到 1 遍还差近 4 倍:每一项都得换做法。
  2. 超一点没关系 → 垂直同步下,超出 0.3 毫秒,帧率就掉一半;预算是加法。
  3. 实时的光和离线一样准 → 借前几帧、烘焙、缓存都有代价:Lumen 的全局变化要几秒才跟上,小而亮的发光面会出噪点。
  4. 有了 Nanite,面数就没代价 → 它只画看得清的,但导入要预处理,材质也有规矩。
  5. 编辑器里好了就好了 → 编辑器替你补的,游戏里不补;只给人看的名字,打包以后就没了。
  6. 哪种算法更高级就用哪种 → 先问这一帧最晚多久要拿到(实时、预览、成片),再问画面里有没有它算不对、又看得出来的东西。