· 4 分钟阅读 · 1736 字

静止物体为何写出半像素速度:TAA 的两种坐标合同

两张轻微错位的采样网格与保持原位的棋子

场景和相机都没有移动,速度缓冲里却出现了半像素位移。这个数值本身不能判定为错误:当前帧与上一帧使用不同的亚像素抖动时,直接相减两份抖动投影,本来就会把采样格移动写进速度。若历史回投直接用这份含抖动速度,恰好能回到上一帧的抖动采样格。错误发生在接口两端选择了不同合同:生产者交付去抖速度,消费者却没有补采样格差;或生产者已经保留抖动,消费者又补了一次。

本文的最小探针采用焦距系数为 1 的对称透视投影,只验证运动矢量与历史 UV 的坐标合同,不模拟完整 Unity、Godot 或图形 API 管线。视空间中相机前方为正 Z,屏幕 Y 向上;速度符号固定为 current - previous。渲染尺寸为 1920×1080,上一帧抖动为 (0.25,-0.25) px,当前帧为 (-0.25,0.25) px。

同一投影位置包含两种位移

视空间点 p=(x,y,z) 的未抖动 NDC 为 (x/z,y/z)。像素抖动 (jx,jy) 换算为 NDC 后是:

j_ndc = (2*jx/W, 2*jy/H)
n_jittered = (x/z, y/z) + j_ndc

直接计算 n_current-n_previous,得到物体、相机真实运动与 j_current-j_previous 的合量。固定静止点 (2,1,10) 在两帧之间产生 (-0.499992,0.500001) px,数值正好对应两份抖动的差。它是“含抖动投影位移”,不是纯物体速度,也不是天然无效的数据。

探针同时保留含抖动与去抖两条路径,并让历史回投显式选择对应合同。完整实现位于 .tmp/daily-labs/2026-11-09-1930/MotionVector.cs,$WORK_DIR 在命令中指向 $PROJECT_ROOT/.tmp/daily-labs/2026-11-09-1930:

using System.Numerics;

public readonly record struct ProjectionSample(Vector2 Ndc, Vector2 JitterNdc);

public static class MotionVector
{
    public static ProjectionSample Project(
        Vector3 viewPosition,
        Vector2 jitterPixels,
        Vector2 renderSize)
    {
        if (viewPosition.Z <= 0f)
        {
            throw new ArgumentOutOfRangeException(
                nameof(viewPosition),
                "观察点必须位于相机前方");
        }

        if (renderSize.X <= 0f || renderSize.Y <= 0f)
        {
            throw new ArgumentOutOfRangeException(
                nameof(renderSize),
                "渲染尺寸必须为正数");
        }

        Vector2 jitterNdc = new(
            2f * jitterPixels.X / renderSize.X,
            2f * jitterPixels.Y / renderSize.Y);
        Vector2 unjittered = new(
            viewPosition.X / viewPosition.Z,
            viewPosition.Y / viewPosition.Z);
        return new ProjectionSample(unjittered + jitterNdc, jitterNdc);
    }

    public static Vector2 IncludingJitter(
        ProjectionSample current,
        ProjectionSample previous) =>
        current.Ndc - previous.Ndc;

    public static Vector2 Unjittered(
        ProjectionSample current,
        ProjectionSample previous) =>
        (current.Ndc - current.JitterNdc)
        - (previous.Ndc - previous.JitterNdc);

    public static Vector2 NdcToPixels(Vector2 ndc, Vector2 renderSize) =>
        new(ndc.X * renderSize.X * 0.5f, ndc.Y * renderSize.Y * 0.5f);

    public static Vector2 NdcToUv(Vector2 ndc) =>
        ndc * 0.5f + new Vector2(0.5f);

    public static Vector2 ReprojectHistoryUv(
        ProjectionSample current,
        Vector2 unjitteredVelocityNdc,
        Vector2 previousJitterNdc)
    {
        Vector2 currentUv = NdcToUv(current.Ndc);
        Vector2 velocityUv = unjitteredVelocityNdc * 0.5f;
        Vector2 jitterDeltaUv = (previousJitterNdc - current.JitterNdc) * 0.5f;
        return currentUv - velocityUv + jitterDeltaUv;
    }
}

静止点经过 Unjittered 后速度为 (0,0) px。另一个样本把当前点的 X 从 2 移到 2.1:含抖动路径得到 (9.099998,0.500001) px,去抖路径得到 (9.599991,0) px。两者都可以正确回投,前提是消费者知道速度中是否已经包含采样格位移。

去抖速度不等于回投时忘掉抖动

历史颜色存在哪个坐标格,决定回投公式。本文假定当前颜色按当前抖动投影写入,历史颜色仍位于上一帧抖动采样格。含抖动速度已经携带两帧采样格之差,因此合同 A 不再追加补偿:

historyUV = currentUV - velocityIncludingJitterPixels/renderSize

合同 B 让速度缓冲只表达真实投影运动,消费者再显式跨越采样格。以像素单位写出,renderSize=(W,H):

historyUV = currentUV
          - velocityPixels/renderSize
          + (jitterPreviousPixels-jitterCurrentPixels)/renderSize

固定移动样本的上一帧目标 UV 为 (0.6001302,0.5497685),两份公式都精确回到同一值。若使用去抖速度却漏掉最后一项,偏移为 (-0.499992,0.499985) px;若使用含抖动速度后再追加最后一项,偏移反向变成 (0.499992,-0.499985) px。问题不是必须选择哪一份速度,而是生产者和消费者必须共享同一份坐标语义。

两种等价速度合同和两种混用失败的固定数据

如果渲染器保存的是未抖动历史、运动矢量采用 previous-current、UV 的 Y 轴向下,或后端已在公共重投影函数中加入 jitter delta,公式必须随接口约定调整,不能再次补偿。需要进入材质与 Pass 合同的字段至少包括速度方向、速度空间、当前与上一帧 jitter、NDC 到 UV 的 Y 翻转规则,以及历史纹理所属采样格。

同一张速度纹理被多个后处理复用时,去抖合同通常更容易审计:动态模糊只消费真实运动,TAA 在自己的回投边界加入采样格差,遮挡拒绝也能分别比较几何位移与采样偏移。若采用含抖动速度,则资源描述必须把 jitter 归属写入格式版本,避免新的消费者把半像素分量当成物体运动。两种组织方式的算术结果可以相同,依赖关系却不同;选择应由消费者集合决定,而不是由速度纹理的名称决定。

固定输出锁住两个错误分支

Program.cs 中的测试符号通过 TestCounter.Near 与 TestCounter.True 固定四类行为:静止点去抖后归零;真实位移与独立未抖动投影差一致;currentUV-IncludingJitter/2 直接命中上一帧 UV;ReprojectHistoryUv 以去抖速度加 jitter delta 命中同一 UV。一万组固定种子输入检查前两项性质,两个固定历史样本检查后两项。当天执行如下:

dotnet build $WORK_DIR/TaaMotionVectorLab.csproj -c Release
dotnet run --project $WORK_DIR/TaaMotionVectorLab.csproj -c Release --no-build
Build succeeded. 0 Warning(s), 0 Error(s)
build_exit=0 run_exit=0
tests: passed=20 failed=0 skipped=0
samples=10000 elapsed_ms=20.1673
max_static_error_pixels=0.00012874603
max_motion_delta_error_pixels=0.00005722046

最大残差来自单精度浮点运算,不支持图像质量或 GPU 性能结论。这个切片也没有覆盖非对称投影、动态分辨率、立体眼、蒙皮顶点、透明物体和遮挡剔除;这些路径必须复用同一坐标合同,并用目标后端的裁剪空间约定复验。

含抖动速度把采样格转换封装在速度里,接口更短;去抖速度把真实运动与采样策略分开,更适合被动态模糊、反应式遮罩等其他消费者复用,但 TAA 回投必须另拿两帧 jitter。静止物体写出半像素速度并不能单独证明渲染错误。能够指导排查的是闭合合同:速度的生产空间、符号与历史纹理采样格必须一起检查,任意一项被隐式猜测,半像素误差就会在速度或 UV 阶段出现。