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에서MotionVectorspass가 실제로 생성되는지 먼저 확인한다. - [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=MotionVectorspass 미구현 상태에서 TAA를 켜지 않는다. -
[B] 권장 순서:
Opaque/Character -> Post -> Temporal Resolve -> UI. -
[B] 캐릭터 선택 보정(
T010)과 함께 사용할 때는 마스크 경계에 별도 clamp를 둔다.
실패 패턴/오해
- [A/B] velocity 텍스처를 카메라 모션만 반영해 스킨드 메시 잔상이 남는다.
- [B] 히스토리를 깊이 불연속 경계에서 그대로 누적해 edge ghost가 커진다.
- [C] "TAA 품질은 후처리 문제"로만 보고 셰이더 단의 hard step 제어를 생략한다.
실무 체크리스트
- 캐릭터 셰이더에
MotionVectors