book/43-tech-temporal-stability-taa-velocity.md

43. 기술 심화(실전): Temporal Stability (TAA + Velocity)

NPR 캐릭터에서 TAA/리프로젝션 고스팅을 줄이기 위한 velocity 기반 안정화 패턴

43. 기술 심화(실전): Temporal Stability (TAA + Velocity)

[A/B] temporal은 서브컬쳐 렌더링에서 “나중에 붙이는 후처리”가 아니라, 라인/램프/하이라이트 같은 hard signal이 존재하는 이상 처음부터 함께 설계해야 하는 계약입니다. 특히 velocity가 부정확하면 어떤 필터를 붙여도 잔상/번짐이 남습니다.

목적

  • [A/B] 캐릭터 라인/하이라이트의 시간축 흔들림과 잔상을 줄인다.
  • [A/B] URP MotionVectors 계약을 전제로 실전형 셰이더 + RendererFeature 예시를 제공한다.

증거 등급 요약(A/B/C)

  • [A] 공개 인터뷰에서 TAAU/리프로젝션 안정화 중요성이 반복된다.
  • [B] 역분석 문서에서 velocity 기반 history 재투영/클램프 패턴이 공통으로 등장한다.
  • [C] 정확한 클램프 임계값과 히스토리 가중치는 타이틀별 내부 튜닝 항목이다.

핵심 개념

이론 배경

  • [A/B] NPR는 hard step과 얇은 라인이 많아 일반 PBR보다 TAA 히스토리 오염에 더 민감하다.
  • [B] 모션 크기뿐 아니라 색/깊이 불연속을 함께 본 히스토리 신뢰도 판정이 필요하다.
  • [C] 목표는 완전 정지감이 아니라 잔상 최소화와 디테일 유지의 균형이며 장르별 최적점이 다르다.

Temporal 안정화는 "재투영 + 히스토리 신뢰도 판정 + 국소 클램프" 3단계를 분리해야 안전하다. [A/B]

HLSL
TEXTURE2D_X(_HistoryTex);
TEXTURE2D_X(_MotionVectorTexture);
TEXTURE2D_X(_CurrentTex);
SAMPLER(sampler_LinearClamp);

float3 TemporalResolve(float2 uv, float2 invSize)
{
    float2 motion = SAMPLE_TEXTURE2D_X(_MotionVectorTexture, sampler_LinearClamp, uv).rg;
    float2 prevUv = uv - motion;

    float3 curr = SAMPLE_TEXTURE2D_X(_CurrentTex, sampler_LinearClamp, uv).rgb;
    float3 hist = SAMPLE_TEXTURE2D_X(_HistoryTex, sampler_LinearClamp, prevUv).rgb;

    float3 n1 = SAMPLE_TEXTURE2D_X(_CurrentTex, sampler_LinearClamp, uv + float2(invSize.x, 0)).rgb;
    float3 n2 = SAMPLE_TEXTURE2D_X(_CurrentTex, sampler_LinearClamp, uv + float2(0, invSize.y)).rgb;
    float3 lo = min(curr, min(n1, n2)) - _ClipEps;
    float3 hi = max(curr, max(n1, n2)) + _ClipEps;
    hist = clamp(hist, lo, hi);

    float motionLen = length(motion);
    float histWeight = saturate(_HistoryWeight * (1.0 - motionLen * _MotionRejectScale));
    return lerp(curr, hist, histWeight);
}
C#
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public sealed class TemporalStabilityFeature : ScriptableRendererFeature
{
    [System.Serializable]
    public sealed class Settings
    {
        public Material resolveMaterial;
        [Range(0f, 1f)] public float historyWeight = 0.9f;
        [Range(0f, 4f)] public float motionRejectScale = 1.5f;
    }

    public Settings settings = new();
    TemporalStabilityPass _pass;

    public override void Create()
    {
        _pass = new TemporalStabilityPass(settings)
        {
            renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing
        };
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (!settings.resolveMaterial) return;
        renderer.EnqueuePass(_pass);
    }

    sealed class TemporalStabilityPass : ScriptableRenderPass
    {
        readonly Settings _s;
        RTHandle _historyA;
        RTHandle _historyB;
        bool _swap;

        public TemporalStabilityPass(Settings settings) => _s = settings;

        public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
        {
            var desc = renderingData.cameraData.cameraTargetDescriptor;
            desc.depthBufferBits = 0;
            RenderingUtils.ReAllocateIfNeeded(ref _historyA, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_TemporalHistoryA");
            RenderingUtils.ReAllocateIfNeeded(ref _historyB, desc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_TemporalHistoryB");
            ConfigureInput(ScriptableRenderPassInput.Motion);
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            var cmd = CommandBufferPool.Get("Temporal Stability Resolve");
            var src = renderingData.cameraData.renderer.cameraColorTargetHandle;
            var prev = _swap ? _historyA : _historyB;
            var next = _swap ? _historyB : _historyA;

            _s.resolveMaterial.SetTexture("_HistoryTex", prev);
            _s.resolveMaterial.SetFloat("_HistoryWeight", _s.historyWeight);
            _s.resolveMaterial.SetFloat("_MotionRejectScale", _s.motionRejectScale);

            Blitter.BlitCameraTexture(cmd, src, next, _s.resolveMaterial, 0);
            Blitter.BlitCameraTexture(cmd, next, src);
            _swap = !_swap;

            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }
    }
}

디버깅 포인트

  • [A/B] Frame Debugger에서 MotionVectors pass가 실제로 생성되는지 먼저 확인한다.
  • [B] 캐릭터 외곽선만 떨릴 때는 line 렌더 단계에서 history weight를 별도로 낮춘다.
  • [B] 정지 화면에서 번짐이 남으면 _ClipEps를 줄이고 모션 거부 임계값을 올린다.

결론: temporal은 “한 번에 해결”이 아니라 “분리해서 통제”한다

  • [B] 재투영(uv) / 신뢰도 판정(weight) / 클램프(범위) 3단계를 분리하면, 어떤 항이 문제인지 빠르게 격리할 수 있다.
  • [A/B] 최종 목표는 ‘완전 정지감’이 아니라, 잔상을 줄이면서 디테일(라인/하이라이트)을 유지하는 균형점이다.

URP 매핑 포인트

설계 해석

  • [A/B] MotionVectors 품질이 낮으면 필터를 바꿔도 잔상이 남으므로 메시/스킨 벡터 정확도부터 점검한다.

  • [B] history RT 해상도는 본해상도 대비 0.75~1.0 범위에서 플랫폼별로 조정해 대역폭을 통제한다.

  • [A] 필수 계약: LightMode=MotionVectors pass 미구현 상태에서 TAA를 켜지 않는다.

  • [B] 권장 순서: Opaque/Character -> Post -> Temporal Resolve -> UI.

  • [B] 캐릭터 선택 보정(T010)과 함께 사용할 때는 마스크 경계에 별도 clamp를 둔다.

실패 패턴/오해

  • [A/B] velocity 텍스처를 카메라 모션만 반영해 스킨드 메시 잔상이 남는다.
  • [B] 히스토리를 깊이 불연속 경계에서 그대로 누적해 edge ghost가 커진다.
  • [C] "TAA 품질은 후처리 문제"로만 보고 셰이더 단의 hard step 제어를 생략한다.

실무 체크리스트

  • 캐릭터 셰이더에 MotionVectors

Sources (섹션 단위 인용)

엔진/공식 단서

재현/역분석 패턴