15.1 帧序列与动画输出

离线渲染的成片是一串图,不是一张图。本节把共用相机参数接上一条纯整数相机路径,16 帧逐帧渲染并落盘 PNG 序列,用 ffmpeg 合成 GIF/MP4;实测单帧约 1.6-2.0 s、总耗时 29.1 s,逐帧重跑 16/16 帧 sha256 相同。

15.1 帧序列与动画输出

站内 /graphics-mobile-rendering-optimization/ 那篇是移动端实时渲染优化:它盯的是带宽、功耗与帧率,每一毫秒都要抢回来,因为目标是每秒 60 帧跑在手机上。本节的目标正好相反——离线渲染,一帧愿意等上几秒,换来的是「这一帧的每个像素都可以被断言」。于是「时间」在这里不是帧率,而是一段动画要跑多久;「质量」不是画得漂不漂亮,而是换一个参数之后画面变了多少。这一节先解决第一件事:把一张图变成一串图。

从一帧到一串:账怎么算

前 14 章渲染的都是同一帧:render_trace(spec, width, height, ...) 用 spec["camera"] 发一遍主光线,产出一张 320×240 的图。要做动画,只需在每次调用前换掉相机——几何、光源、材质一字不动。因为 render_trace 每次都从 spec 里现取相机参数并重建视图矩阵,所以「改相机再渲染」这条路不需要动底座一行代码。

代价的账也一目了然:

帧序列的墙钟 = 单帧墙钟 × 帧数。

卷三基线在 320×240 下单帧约 6.6–6.9 s(§7 的实测值)。如果照搬这个分辨率,16 帧就要近两分钟,而且这只是第一趟。所以本节把动画的分辨率收到 160×120(像素数是基线的 1/4),单帧降到 1.6–2.0 s,16 帧约 29 s——分辨率是帧序列里最先被牺牲的那颗旋钮,这一取舍在 15.3 会被量化。

整数相机路径

相机路径必须是可复现的:同一份规格、同一条路径,S 层(Python int)与 P 层(BigInt)要产出逐字节相同的帧。所以路径不能写浮点三角函数,只能由整数关键点 + 线性插值构成:

  • 眼睛在一个半径 24 世界单位的圆上,每 45° 一个关键点(24 → 1572864 Q16.16,24/√2 ≈ 17 → 1114112),高度固定 6。
  • 目标点固定在 (0, 2, 0)。
  • 第 frame 帧落在第 seg = frame × 8 / 16 段上,段内比例 num/16,插值走 floor_div。

floor_div 是这里唯一需要小心的地方:Python 的 // 是向下取整,而 JS BigInt 的 / 是向零截断。路径下半圈的坐标为负,两者会分叉——所以两侧都用同一个向下取整语义(TS 侧走底座新增的 floorDiv)。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_15_1_frame_sequence.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_15_1_frame_sequence.py
"""15.1 帧序列与动画输出:沿相机路径逐帧渲染,落盘 PNG 序列并合成 GIF/MP4。

离线的成片不是「一张图」,而是「一串图」。本节把卷一卷二卷三共用的那份相机参数
(`eyeQ16` / `targetQ16` / `upQ16` / `tanHalfFovYQ16`)接上一条**整数相机路径**:
眼睛在一个半径 24 世界单位的圆上走一圈,目标点固定在 (0, 2, 0)。路径只由 8 个整数
关键点(圆周上的 45° 步进,取整到 Q16.16)与**线性插值**构成,没有三角函数、没有
浮点常量——这是为了让同一串帧能在 S 层(Python `int`)与 P 层(`BigInt`)里逐字节复现。

每一帧都是同一份 `config/scene_spec.json`,只改 `camera`:几何、光源、材质一字不动。
着色器直接用卷三基线的**直接光照 + 硬阴影**(`render_trace.make_shader`),于是
「帧与帧之间的差别」只可能来自相机——这正是逐帧确定性要断言的东西。

产出:
  out/frames/cg_trace_15_1_f00.png .. f15.png   PNG 序列
  out/cg_trace_15_1_frames.json                 每帧耗时/摘要、总耗时、逐帧确定性
  out/cg_trace_15_1_anim.gif                    8 fps GIF
  out/cg_trace_15_1_anim.mp4                    libx264 MP4
"""

import copy
import json
import os
import shutil
import subprocess
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cgtrace  # noqa: E402
import render_trace  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
FRAMES_DIR = os.path.join(OUT, "frames")

# 动画参数:16 帧、160x120。分辨率取基线的 1/4,是「帧序列的代价 = 单帧代价 x 帧数」
# 这条账的直接后果——把帧数乘上去以后,单帧分辨率必须往回收。
FRAMES = 16
WIDTH, HEIGHT = 160, 120
FPS = 8

# 整数相机路径:眼睛在 x-z 平面上半径 24 世界单位的圆上,每 45° 一个关键点。
# 24 -> 1572864 Q16.16;17 是 24/sqrt(2) 的整数近似 -> 1114112。
UNIT = cglib.ONE
EYE_Y = 6 * UNIT
KEY = [
    (24 * UNIT, 0), (17 * UNIT, 17 * UNIT), (0, 24 * UNIT), (-17 * UNIT, 17 * UNIT),
    (-24 * UNIT, 0), (-17 * UNIT, -17 * UNIT), (0, -24 * UNIT), (17 * UNIT, -17 * UNIT),
]
TARGET = (0, 2 * UNIT, 0)


def floor_div(a, b):
    """向下取整除法:与 TS 侧 `floorDiv` 同义(BigInt 的 `/` 是向零截断)。"""
    return a // b


def camera_at(frame):
    """第 `frame` 帧的相机(eye, target),全部 Q16.16,逐位可复现。

    在关键点之间线性插值:`seg` 是落在第几段,`num/FRAMES` 是段内比例。插值走
    `floor_div`,故负坐标(路径下半圈)在两侧也一致。
    """
    n = len(KEY)
    scaled = frame * n
    seg = floor_div(scaled, FRAMES)
    num = scaled - seg * FRAMES
    a, b = KEY[seg % n], KEY[(seg + 1) % n]
    ex = a[0] + floor_div((b[0] - a[0]) * num, FRAMES)
    ez = a[1] + floor_div((b[1] - a[1]) * num, FRAMES)
    return (ex, EYE_Y, ez), TARGET


def render_frame(base, frame):
    """渲染一帧,返回 `(color, ms)`。只改相机,其余规格一字不动。"""
    spec = copy.deepcopy(base)
    spec["width"], spec["height"] = WIDTH, HEIGHT
    eye, target = camera_at(frame)
    spec["camera"]["eyeQ16"] = list(eye)
    spec["camera"]["targetQ16"] = list(target)
    ctx = {"stats": {"shadow_rays": 0, "shadow_hits": 0}}
    t0 = time.perf_counter()
    color, stats = cgtrace.render_trace(spec, WIDTH, HEIGHT, 1, render_trace.make_shader, ctx)
    ms = (time.perf_counter() - t0) * 1000.0
    return color, ms, stats["rays"]


def run_pass(base):
    """跑一整趟帧序列,落盘 PNG 并收集每帧摘要。"""
    os.makedirs(FRAMES_DIR, exist_ok=True)
    rows = []
    for f in range(FRAMES):
        color, ms, rays = render_frame(base, f)
        path = os.path.join(FRAMES_DIR, f"cg_trace_15_1_f{f:02d}.png")
        size = cglib.save_png(path, color)
        rows.append({"frame": f, "ms": round(ms, 1), "pngBytes": size,
                     "primaryRays": rays, "sha256": cgtrace.digest(color)})
    return rows


def compose(rows):
    """用 ffmpeg 把 PNG 序列合成 GIF 与 MP4(本机 ffmpeg 8.1.2)。"""
    pattern = os.path.join(FRAMES_DIR, "cg_trace_15_1_f%02d.png")
    gif = os.path.join(OUT, "cg_trace_15_1_anim.gif")
    mp4 = os.path.join(OUT, "cg_trace_15_1_anim.mp4")
    ffmpeg = shutil.which("ffmpeg")
    if ffmpeg is None:
        return {"ffmpeg": False, "gifBytes": 0, "mp4Bytes": 0}
    # GIF:先做调色板再映射,避免 256 色抖动糊掉地形渐变
    subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-framerate", str(FPS),
                    "-i", pattern, "-vf",
                    "split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer",
                    gif], check=True)
    # MP4:libx264 + yuv420p,尺寸已是偶数(160x120)
    subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-framerate", str(FPS),
                    "-i", pattern, "-c:v", "libx264", "-pix_fmt", "yuv420p",
                    "-crf", "20", mp4], check=True)
    return {"ffmpeg": True, "gifBytes": os.path.getsize(gif), "mp4Bytes": os.path.getsize(mp4)}


def main():
    base = cglib.load_spec()

    t0 = time.perf_counter()
    rows = run_pass(base)
    total_ms = (time.perf_counter() - t0) * 1000.0

    # 逐帧确定性:同一条路径、同一个 seed 再跑一遍,逐帧 sha256 必须相同
    t1 = time.perf_counter()
    rows2 = run_pass(base)
    rerun_ms = (time.perf_counter() - t1) * 1000.0
    same = [r["sha256"] == s["sha256"] for r, s in zip(rows, rows2)]
    for r, s in zip(rows, rows2):
        r["rerunSha256"] = s["sha256"]
        r["deterministic"] = r["sha256"] == s["sha256"]

    media = compose(rows)
    per_frame = [r["ms"] for r in rows]
    report = {
        "frames": FRAMES, "width": WIDTH, "height": HEIGHT, "fps": FPS,
        "pathKeyframes": [[k[0], k[1]] for k in KEY], "target": list(TARGET),
        "totalMs": round(total_ms, 1), "rerunMs": round(rerun_ms, 1),
        "perFrameMsMin": min(per_frame), "perFrameMsMax": max(per_frame),
        "perFrameMsMean": round(sum(per_frame) / len(per_frame), 1),
        "frameTable": rows,
        "allFramesDeterministic": all(same),
        "framesDeterministic": sum(1 for s in same if s),
        "gifBytes": media["gifBytes"], "mp4Bytes": media["mp4Bytes"],
        "ffmpegAvailable": media["ffmpeg"],
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "cg_trace_15_1_frames.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print("== 15.1 帧序列与动画输出 ==")
    print(f"画布 {WIDTH}x{HEIGHT} / {FRAMES} 帧 / {FPS} fps / 路径 {len(KEY)} 个关键点")
    print(f"{'帧':>3} {'耗时ms':>8} {'主光线':>7} {'PNG 字节':>9}  sha256")
    for r in rows:
        print(f"{r['frame']:>3} {r['ms']:>8.1f} {r['primaryRays']:>7} {r['pngBytes']:>9}"
              f"  {r['sha256'][:24]}…")
    print(f"单帧耗时   : min {report['perFrameMsMin']} / mean {report['perFrameMsMean']}"
          f" / max {report['perFrameMsMax']} ms")
    print(f"总耗时     : {report['totalMs']} ms({FRAMES} 帧一趟)"
          f";确定性核对第二趟 {report['rerunMs']} ms")
    print(f"逐帧确定性 : {report['framesDeterministic']}/{FRAMES} 帧重跑 sha256 相同"
          f"(全部相同 = {report['allFramesDeterministic']})")
    print(f"合成       : GIF {report['gifBytes']} 字节 / MP4 {report['mp4Bytes']} 字节"
          f"(ffmpeg={report['ffmpegAvailable']})")
    return 0


if __name__ == "__main__":
    sys.exit(main())
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_15_1_frame_sequence.py
== 15.1 帧序列与动画输出 ==
画布 160x120 / 16 帧 / 8 fps / 路径 8 个关键点
  帧     耗时ms     主光线    PNG 字节  sha256
  0   1795.5   19200     11302  ac56151418642f2489b41ccb…
  1   1921.5   19200     11268  5463e222099a0da5be7d7352…
  2   1872.4   19200     11411  f7d96448c143a2323b990d88…
  3   1909.6   19200     11237  7d408d107b709541340bbb6b…
  4   1719.0   19200     10953  603f79e37915c9a1ddb0ab65…
  5   1755.4   19200     10794  05f4a4218103cefdeecc615a…
  6   1617.6   19200     10780  9b75a6c689e00df0c7abf394…
  7   1671.0   19200     10546  0b9164efef85e237cc5db51f…
  8   1667.6   19200     10250  0f39e7a8e243fb157210a5e9…
  9   2003.1   19200     10287  3203f9c469840b8cdb6df2c8…
 10   2012.8   19200      9521  5a1bb64379e03f407129111a…
 11   1880.4   19200      9505  bcbf3113ede4f010782d7153…
 12   1665.0   19200      9475  b3aa9a69f94a43f2e0380267…
 13   1887.9   19200      9988  c42595c1b0dd818437a93e25…
 14   1793.8   19200     10584  b5e9a2df558028e469396989…
 15   1821.6   19200     11190  7c81f349d4078d550d8c0b9c…
单帧耗时   : min 1617.6 / mean 1812.1 / max 2012.8 ms
总耗时     : 29118.5 ms(16 帧一趟);确定性核对第二趟 28965.0 ms
逐帧确定性 : 16/16 帧重跑 sha256 相同(全部相同 = True)
合成       : GIF 148857 字节 / MP4 25021 字节(ffmpeg=True)

逐帧读表

量值读法
帧数168 个关键点,每段插一帧
主光线19200 / 帧160 × 120,逐帧恒定
单帧耗时1617.6–2012.8 ms极差 395.2 ms,纯属机器噪声
总耗时(一趟)29118.5 ms≈ 16 × 1812.1 ms
第二趟(核对)28965.0 ms与第一趟差 0.53%,在噪声内
GIF / MP4148857 / 25021 字节MP4 只有 GIF 的 1/6

单帧耗时在 1617.6–2012.8 ms 之间抖动,极差 395.2 ms(相对均值约 22%)。这个抖动不是路径造成的——16 帧的像素数完全相同(每帧都是 19200 条主光线),几何与光源也一字不动,唯一变的是相机位置。所以抖动只能来自操作系统调度与 Python 解释器的状态。这条观察很重要:在 ~2 s 这个量级上,墙钟的分辨率不足以支撑 1% 级别的结论,15.3 会再碰到一次同样的问题。

PNG 字节数随帧变化(11302 → 9475 → 11190):帧 0 面向山谷中心,画面细节最多;帧 10–12 转到地形边缘,大片背景色压缩率更高。文件大小本身就是一个粗糙的「画面复杂度」代理指标,但它不是质量指标——这一点必须说清楚。

逐帧确定性

最后一栏是本节最硬的一条:16/16 帧重跑 sha256 相同。同一条路径跑第二趟,每一帧的像素缓冲 sha256 与第一趟逐字节相同。判据对象是像素缓冲(cgtrace.digest 对 uint8 数组取 sha256),不是 PNG 容器字节——PNG 的压缩参数可以让同样的像素产出不同的文件,那不能证明渲染确定。

为什么这条值得单独断言?因为帧序列里每一帧的相机都不同,于是每一帧都要重建视图矩阵、重算 19200 条主光线方向。如果路径插值里有一处依赖浮点、或者相机基取错了行/列(§7 记的那个真 bug),那么某些帧的 sha256 会分叉——而单看一张图是看不出来的。把「确定性」放在一串图上检验,比放在一张图上严格得多。

合成:GIF 与 MP4

帧序列落盘为 out/frames/cg_trace_15_1_f00.png .. f15.png,再用 ffmpeg 8.1.2 合成两个容器:

# snippet: 等价的合成命令(脚本内以 subprocess 调用)
# GIF:先 palettegen 再 paletteuse,避免 256 色抖动糊掉地形渐变
ffmpeg -y -framerate 8 -i out/frames/cg_trace_15_1_f%02d.png \
  -vf "split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer" \
  out/cg_trace_15_1_anim.gif
# MP4:libx264 + yuv420p
ffmpeg -y -framerate 8 -i out/frames/cg_trace_15_1_f%02d.png \
  -c:v libx264 -pix_fmt yuv420p -crf 20 out/cg_trace_15_1_anim.mp4

GIF 148857 字节、MP4 25021 字节。GIF 大 6 倍是因为它按调色板逐帧存整幅;MP4 的帧间预测把它压下去了。这里不展开编码,只需记住:帧序列是一个中间产物,容器是最后一公里——渲染器只负责产出确定性的 PNG,怎么封装是外部工具的事。

P 层

P 层重写同一条路径与同一个直接光照着色器,断言整串 16 帧的 sha256:

// snippet: 节选自 lantern/core/test/cg_trace_15_1_frame_sequence.test.ts
// 与 S 侧 camera_at 逐字同式:关键点之间线性插值,除法走 floorDiv
function cameraAt(frame: number, key: [number, number][]): { eye: number[]; target: number[] } {
  const n = key.length;
  const scaled = frame * n;
  const seg = floorDiv(BigInt(scaled), BigInt(golden.frames));
  const num = BigInt(scaled) - seg * BigInt(golden.frames);
  const a = key[Number(seg) % n];
  const b = key[(Number(seg) + 1) % n];
  const ex = BigInt(a[0]) + floorDiv((BigInt(b[0]) - BigInt(a[0])) * num, BigInt(golden.frames));
  const ez = BigInt(a[1]) + floorDiv((BigInt(b[1]) - BigInt(a[1])) * num, BigInt(golden.frames));
  return { eye: [Number(ex), EYE_Y, Number(ez)], target: golden.target };
}

node --test 'core/test/cg_trace_15_1_frame_sequence.test.ts' 跑出 17 项全绿(1 项规格 + 16 帧),每一帧都断言 主光线数 == 19200 且 sha256(rgb) == 金标准。帧 4 的相机恰好回到原始规格的 eyeQ16 = (0, 393216, 1572864)(关键点 2 是 (0, 24)),所以帧 4 的 sha256 603f79e37915c9a1ddb0ab6596e139ebde77c8936adcb4251d1a6a64a93bd4af 与「把基线规格渲到 160×120」完全相同——这一帧是路径的锚点,也是 15.3 里两个参考解的同一个哈希。

小结

  • 帧序列的墙钟 = 单帧墙钟 × 帧数;16 帧 × 160×120 实测 29118.5 ms。
  • 相机路径必须是整数关键点 + 线性插值(走 floor_div),才能跨语言逐字节复现。
  • 逐帧确定性:16/16 帧重跑 sha256 相同,判据是像素缓冲而非 PNG 字节。
  • 单帧耗时抖动 395.2 ms(相对均值约 22%),说明 ~2 s 量级上墙钟不足以支撑 1% 级结论。
  • ffmpeg 把 PNG 序列合成 GIF(148857 字节)与 MP4(25021 字节)。

帧序列把「一次渲染」变成「一条时间轴上的很多次渲染」。下一节换一个角度问同一个问题:这一帧的时间到底花在哪了? 要回答它,不能靠盯着秒表,得把时间拆到可以归因的几块上。

阅读导航:上一节:14.3 一致性的判据 · 下一节:15.2 性能的度量与剖析 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配