WorldForge: 不训练,把 6-DoF 相机轨迹"注射"进 Wan2.1 视频扩散模型

WorldForge teaser
Fig. 1 — 左:单图输入,沿用户轨迹生成 3D 场景视频;右:已有动态视频沿新轨迹"re-filming"。所有结果均 zero-shot — 主干 Wan2.1 I2V-14B 不做任何微调。

1. 出发点 (Motivation)

视频扩散模型 (VDM) 像 Wan2.1、SVD、CogVideoX,在百万小时视频上预训出了非常丰富的时空先验。但**"把它当 3D / 4D 引擎用"几乎不可能**:

WorldForge 押的注:VDM 已经知道"视频应该长什么样",我们只需要在推理时"告诉它相机该怎么走" — 不重新训,不改权重,只在 50 步去噪的前 ~20 步插入三个 guidance 模块,把"目标轨迹"软性注入 latent 轨道里就够。

TL;DR (30 字): 训练免费、推理时分三层引导 (IRR + FLF + DSG),把 6-DoF 相机轨迹注入预训 VDM,既不掉画质又拿 SOTA 控制精度。

2. 方法 (Method)

核心思想 (类比)

把扩散模型的 50 步采样想成"画家从一片噪点反推一段视频":

结果是 — 模型仍然在自己熟悉的 video manifold 上采样,只是"被推往"用户指定的相机路径。

2.1 整体管线

WorldForge 整体管线
Fig. 2 — WorldForge framework。左侧:单张图或视频帧 → VGGT 重建点云 + 估计 camera/depth → 沿用户给的目标轨迹 warp 成"trajectory-warped frames" $I'_{tar}$ + 可见性 mask $M$。右侧:Wan2.1 I2V 的 50 步 UniPC 采样,前 ~20 步每步插入 IRR → FLF → DSG 三个模块,后 ~30 步退回 vanilla 去噪。整个过程不训练任何参数

底座是任意一个 latent video diffusion model (paper 用 Wan2.1 I2V-14B / SVD / LongCat-Video)。Wan2.1 的一步去噪:

\(\hat{\mathbf{x}}_0\) 是当前步对"最终干净 latent"的预测。**WorldForge 三个模块全部作用在 \(\hat{\mathbf{x}}_0\) 上,而非 \(\mathbf{x}_t\)。**这是和 RePaint / NVS-Solver 最本质的差异 — 后者在带噪 latent 空间融合,VAE 解码会把 warp 的硬边变成 ringing artifact;前者在干净 latent 空间融合,光流和 mask 行为可预测。

Warp 是一个标准的可微 splatting:

Warp 完的帧用 VAE encode 进 latent 空间,叫 \(\mathbf{x}_{\text{traj}}\)。从这里开始三个模块上场。

2.2 IRR — Intra-Step Recursive Refinement (步内递归精炼)

Plain English: 每一步去噪后,把 \(\hat{\mathbf{x}}_0\) 中"mask 可见区域"换成 trajectory latent,再用 scheduler 的 add_noise 把结果退回到当前 sigma 噪声水平,让 transformer 再去噪一次 (paper 称为"resample")。每个 outer step 跑 2~3 次这种"贴回-去噪"循环。本质是 per-step 预测器-校正器。

**与 RePaint 的差异:**RePaint 在 \(\mathbf{x}_{t-1}\) (带噪) 空间融合,WorldForge 在 \(\hat{\mathbf{x}}_0\) (干净) 空间。这一选择决定了 FLF 能否工作 — 因为只有在 clean latent 空间,VAE channel 的"运动语义"才是稳定的。

2.3 FLF — Flow-Gated Latent Fusion (光流门控潜变量融合)

Plain English: Wan2.1 的 VAE latent 有 16 个 channel,作者发现它们的"运动语义角色"稳定:某些 channel 主要编码运动方向,另一些主要编码外观纹理。如果 IRR 在所有 16 个 channel 上都贴 warp 帧,会把"外观 channel"也覆写掉,纹理就坏了。FLF 的做法:

  1. \(\hat{\mathbf{x}}_0\)\(\mathbf{x}_{\text{traj}}\)每个 channel分别跑 RAFT/Farnebäck 光流。
  2. 用三种 metric 算 channel 的"运动一致性分数" \(S^{(t,c)}\):Masked End-Point Error (M-EPE)、Masked Angular Error (M-AE)、Outlier % (Fl-all)。
  3. 分数低于自适应阈值 \(\delta^{(t)} = \mu_S - \lambda\sigma_S\) 的 channel不被覆写 (让模型自由发挥纹理);其它 channel 才接受 IRR 的 warp 注入。

Eq. 7 替换掉 Eq. 5 里的 \(\mathbf{F}\),所以 FLF 是 IRR 的插件

FLF 各 channel 被过滤的频率
Fig. 4 — 16 个 VAE channel 在 40+ 静态场景和 30+ 动态场景上"被 FLF 过滤掉" (= 不接受 warp 注入) 的频率。Channel 13 几乎 100% 被过滤 (编码运动无关的细节,如纹理),channel 8 几乎从不被过滤 (主管运动方向,该被 warp 控制)。这种角色分配在静态/动态场景上分布一致 → 证明 VAE latent 的 channel 语义稳定。

2.4 DSG — Dual-Path Self-Corrective Guidance (双路自校正引导)

Plain English: 即使 IRR + FLF 加上,warp 帧本身带 depth error / occlusion hole,模型很难完全"信任"它们。DSG 在每个 step 跑两次前向:

关键观察:作者实测两条速度场之间的余弦相似度 \(\alpha_t\) 通常落在0.3–0.6 (角度 50°–70°),远大于普通 CFG 的"prompt 改一改"的角度差。直接套 CFG (加权差分) 会因为方向差太大而崩 — 需要把"差异中跟 guided 方向平行的部分"投影掉,只用正交分量。

这是关键 design choice:消融里用普通 CFG 形式替换 DSG,FID 从 96 涨到 121 (比完全去掉 DSG 还烂),说明"方向差大时必须做正交投影"。

2.5 完整算法

Wan2.1 I2V-14B,50 步 UniPC scheduler:

输入: 单张图 / 视频帧 I_src + 用户相机轨迹 P_tar[1..N]
预处理:
  (1) VGGT 估计 camera P_src + depth D_src
  (2) warp_single_img(I_src, D_src, P_src, P_tar) → I'_tar, M  (PNG 落盘)
  (3) VAE encode I'_tar → x_traj (latent 空间,在每个 step 内重算)

主循环 for t in [T..1]:
  if t 在前 guide_steps (=20) 步:
    for r in [0 .. resample_steps-1]:   # IRR 内循环, 2~3 次
      noise_pred = transformer(x_t, condition, t)
      noise_pred += guidance_scale * (noise_pred - noise_uncond)  # CFG
      存入 derivative_history
      pred_x0 = scheduler.step(noise_pred, t, x_t,
                               video_ref=I'_tar, mask=M,
                               use_pca_channel_selection=True)
        # ↑ scheduler 内部: VAE.decode(pred_x0) → mask-blend I'_tar
        #                   → VAE.encode → FLF channel select 还原
      if r < resample_steps-1:
        x_t = scheduler.add_noise(pred_x0, ε, t)   # re-noise → 下一轮 IRR
    # IRR 内循环结束
    if len(derivative_history) ≥ 2:
      DSG: 取 history[0]=noise_pred_worse, history[-1]=noise_pred_good
           按 Eq. 8 算 noise_pred_better
           scheduler.step(noise_pred_better, ...) → x_{t-1}
  else:
    # 后 30 步: 标准 UniPC 去噪, 无 IRR/FLF/DSG
    x_{t-1} = scheduler.step(transformer(x_t, ..., t), t, x_t)

输出: VAE.decode(x_0) → 49 帧 720p 视频 @ 16fps

三个模块的角色:

3. 结论 (Key Findings)

3D / 4D 定性对比
Fig. 3 — 定性对比。左半部分:单图 → 3D 新视角合成,WorldForge 没有 "floating heads / flattened faces" 的典型 OOD 伪影;右半部分:动态视频 → 沿新轨迹重新拍摄,WorldForge 物体形状 / 表情都稳定保留。

Table 1 — 3D 静态场景 (70+ views from 40+ scenes):

MethodFID↓CLIP_sim↑ATE↓RPE-T↓RPE-R↓训练免费
See3D123.260.9410.0910.0890.250
ViewCrafter117.500.9300.2360.3150.728
ViewExtrapolator125.500.9300.1830.2600.882
TrajectoryAttention122.370.9200.1590.2380.532
TrajectoryCrafter111.490.9100.0900.1520.267
NVS-Solver118.640.9370.2240.2681.056
WorldForge96.080.9480.0770.0860.221

WorldForge 在每个指标上都领先,而且是唯一一个 zero-shot 方法把所有训练式 baseline 都打过的。

Table 2 — 4D 动态场景 (50+ videos from 30+ sources, DAVIS + 电影片段 + VDM 自生成):

MethodFVD↓CLIP-V_sim↑ATE↓RPE-T↓RPE-R↓训练免费
ViewExtrapolator108.480.9131.0401.2084.750
TrajectoryAttention106.940.9110.6051.2383.560
TrajectoryCrafter97.310.9230.4311.0788.950
WorldForge93.170.9380.5270.8262.690

4D 动态比 3D 静态难得多 — WorldForge 拿到 FVD / 感知 / 旋转 / 平移 误差 4/5 个最佳,ATE 上输给 TrajectoryCrafter 但旋转误差只有它的 1/3。

组件消融
Fig. 5 — 组件消融 (定性)。从左到右:输入 prompt → 去掉 IRR (退化成自由生成,跟 prompt 走但没轨迹控制) → 去掉 FLF (画质崩) → 去掉 DSG (轨迹有偏) → 去掉 FLF&DSG → DSG 换成标准 CFG 公式 (反而比无 DSG 还差) → 完整方法。红框/黄框是问题区域。

Table 3 — 数值消融 (FID/CLIP for 3D, FVD/CLIP-V for 4D):

VariantStatic FID↓Static CLIP↑Dyn FVD↓Dyn CLIP-V↑
w/o DSG109.430.94395.690.937
w/o FLF112.690.94599.790.932
w/o DSG & FLF113.120.943103.170.931
DSG using vanilla CFG120.910.936109.100.919
Complete96.080.94893.170.938

最有意思的一行:"DSG using vanilla CFG" 比"w/o DSG"还要差 (FID 121 vs 109)。*方向差太大时,简单加权 = 在错误方向加重错误。*这是 DSG 必须用正交投影的关键证据。

跨主干消融
Fig. 6 — 把 WorldForge 三模块原样搬到 SVD / Wan2.1 / LongCat-Video 三种主干上,全部 work。说明 framework 对主干的 latent shape / 训练 schedule 都不敏感 — 关键约束只是"latent 是 VAE encoded 且 channel 有运动语义"。

4. 实现细节 (Implementation Notes — paper 没说的 code-only 细节)

代码:github.com/Westlake-AGI-Lab/WorldForge(commit ee573a0)。主路径:wan_for_worldforge/infer_worldforge.pyutils/pipeline_wan_i2v_clean.pyutils/scheduling_unipc_multistep_clean.py。Code cross-read 揪出的 10+ 个细节:

4.1 IRR 主循环 (per-step resample)

repo/wan_for_worldforge/utils/pipeline_wan_i2v_clean.py:L562-L662 — IRR 外循环 + scheduler.step + add_noise re-noise

with self.progress_bar(total=num_inference_steps) as progress_bar:
    for i, t in enumerate(timesteps):
        self.scheduler.derivative_history = []
        for r in range(resample_steps):
            if r > 0:
                self.scheduler.set_resample_mode(True)
                resample_timestep = self.scheduler.get_resample_timestep(i)
                timestep_for_transformer = resample_timestep.expand(latents.shape[0])
            else:
                self.scheduler.set_resample_mode(False)
                timestep_for_transformer = t.expand(latents.shape[0])
            # CFG forward
            latent_model_input = torch.cat([latents, condition], dim=1)
            noise_pred = self.transformer(...)[0]
            if self.do_classifier_free_guidance:
                noise_uncond = self.transformer(...)[0]
                noise_pred = noise_pred + guidance_scale * (noise_pred - noise_uncond)
                if r < 1:
                    self.scheduler.derivative_history.append(noise_pred)  # for DSG
            # FLF fusion happens inside scheduler.step (see 4.2)
            scheduler_output = self.scheduler.step(noise_pred, t, latents,
                mask=mask, guided=guided and i < guide_steps,
                video_latents=video_ref, vae=self.vae, resampling=r > 0, ...)
            pred_original_sample = scheduler_output.pred_x0
            if r < resample_steps - 1:
                # IRR re-noise: 把 pred_x0 退回当前 sigma 的噪声水平
                noise = torch.randn(pred_original_sample.shape, device=device)
                latents = self.scheduler.add_noise(pred_original_sample, noise,
                    self.scheduler.get_resample_timestep(i), r, use_resample_sigma=True)

注意 derivative_history 只在 r < 1 时 append(代码里写的是 r < 1 不是 r == 0,等价但更脆弱)。如果 resample_steps = 1,DSG 静默 no-op。

4.2 FLF 在 scheduler 内部的实现

repo/wan_for_worldforge/utils/scheduling_unipc_multistep_clean.py:L1373-L1417 — VAE decode → mask blend → VAE encode → channel select 还原

video_latents = 2.0 * video_latents - 1.0          # 0..1 → -1..1
if mask.shape[1] != decoded_video.shape[1]:
    mask = mask.repeat(1, decoded_video.shape[1], 1, 1, 1)
fused_video = video_latents * mask + decoded_video * (1 - mask)  # pixel-space blend
encoded_video = vae.encode(fused_video).latent_dist.mode()
encoded_video = (encoded_video - latents_mean) * latents_std
if kwargs.get('use_pca_channel_selection') and not kwargs.get('resampling', False):
    if self._pca_selector is None:
        self._pca_selector = VideoMotionPCASelector()
    channels_to_replace = self._pca_selector.select_motion_related_channels(
        pred_original_sample=pred_original_sample,
        video_latents=encoded_video_aligned,
        keep_channels=12, current_step=current_step, total_steps=total_steps,
        use_optical_flow=use_optical_flow, static=static,
    )
    # 不接受 warp 的 channel: 把 pred_x0 的值贴回来
    for channel_idx in channels_to_replace:
        encoded_video[:, channel_idx, :, :, :] = pred_original_sample[:, channel_idx, :, :, :]
return encoded_video

这段是 FLF 的实际执行。两个 critical points:

4.3 DSG 投影公式

repo/wan_for_worldforge/utils/pipeline_wan_i2v_clean.py:L664-L708 — 从 derivative_history 取首末两个 noise_pred,正交投影

if len(self.scheduler.derivative_history) > 1:
    noise_pred_good  = self.scheduler.derivative_history[-1]  # IRR 收敛后的"好"预测
    noise_pred_worse = self.scheduler.derivative_history[0]   # IRR 起步时的"差"预测
    dot_product = torch.sum(noise_pred_good * noise_pred_worse, dim=[1,2,3,4], keepdim=True)
    norm_good  = torch.sqrt(torch.sum(noise_pred_good**2,  dim=[1,2,3,4], keepdim=True))
    norm_worse = torch.sqrt(torch.sum(noise_pred_worse**2, dim=[1,2,3,4], keepdim=True))
    cos_theta = dot_product / (norm_good * norm_worse + 1e-8)
    sin_theta = torch.sin(torch.acos(torch.clamp(cos_theta, -1.0, 1.0)))
    magnitude_ratio = norm_good / (norm_worse + 1e-8)
    if i >= guide_steps:
        omega = omega_resample
    # Eq. 8 的代码版: noise_pred_better = good + ω·sin(θ)·(good - (|good|/|worse|·cos(θ))·worse)
    noise_pred_better = noise_pred_good + omega * sin_theta * (
        noise_pred_good - (magnitude_ratio * cos_theta) * noise_pred_worse)
    # 倒回一步, 用 noise_pred_better 重新跑 multistep UniPC
    self.scheduler._step_index -= 1
    noise_pred_convert = self.scheduler.convert_model_output(noise_pred_better, sample=latents)
    self.scheduler.model_outputs[-1] = noise_pred_convert
    latents = self.scheduler.multistep_uni_p_bh_update(
        model_output=noise_pred_better, sample=latents, order=self.scheduler.this_order)

关键:noise_pred_worsenoise_pred_good 不是 conditional / unconditional (那是 CFG),而是同一个 IRR 内循环的"早期"和"晚期"两次 CFG 后预测。Paper 写 "unguided vs guided",代码里其实是"IRR 第一轮 vs 最后一轮"。是更 elegant 的设计 — 不需要额外跑一遍模型。

4.4 5+ 条 paper 没强调的实现细节

5. 批判性总结 (Critical Assessment)

5.1 优点

5.2 不足 / 疑点

5.3 适用 vs 不适用

5.4 进一步阅读

讨论 / Comments

评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。