WIP 17 minute read

모션 블러는 호불호가 갈리는 효과입니다. 옵션 메뉴에서 가장 먼저 꺼지는 항목이면서, 동시에 30fps 게임이 “부드럽게” 보이는 데 가장 크게 기여하는 효과이기도 합니다. 그런데 이 효과가 어떻게 만들어지는지는 의외로 잘 정리된 자료가 적습니다. “속도 방향으로 블러한다”는 한 줄 설명은 맞지만, 구현에서 정말 어려운 부분은 그 한 줄 뒤에 숨어 있습니다. 움직이는 물체의 색이 멈춰 있는 배경 위로 번져야 하는데, 배경 픽셀은 움직이지 않았으니 “자기 속도 방향”이라는 게 없다는 문제 같은 것들입니다.

이 글은 세 부분으로 되어 있습니다. 1~3장은 원리입니다. 모션 블러가 물리적으로 무엇인지, 그걸 한 장의 프레임에서 복원하려면 어떤 재료와 어떤 트릭이 필요한지를 정리합니다. 4~5장은 Unreal Engine과 Unity HDRP가 그 원리를 각각 어떤 패스로 쪼개 구현했는지를 따라갑니다. 두 엔진은 같은 논문 계보에서 나왔기 때문에 골격이 같고, 그래서 오히려 다른 부분이 잘 보입니다. 6장은 그 차이를 나란히 놓고 봅니다.

1. 모션 블러란 무엇인가

1-1. 셔터가 열려 있는 동안의 평균

실제 카메라는 한 순간을 찍지 않습니다. 셔터가 열려 있는 시간 동안 센서에 들어온 빛을 쌓습니다. 그 사이에 물체가 움직이면 물체는 센서 위 여러 위치에 조금씩 흔적을 남기고, 그 평균이 번진 상으로 기록됩니다. 모션 블러는 효과가 아니라 노출 시간의 부산물입니다.

영화에서는 이 시간을 셔터 각도로 표현합니다. 24fps 영화의 표준인 180도 셔터는 프레임 간격(1/24초)의 절반, 즉 1/48초 동안 셔터가 열려 있다는 뜻입니다. 우리가 “영화 같다”고 느끼는 블러의 양은 대체로 이 값에서 옵니다.

셔터 시간과 모션 블러: 순간 샘플과 적분의 차이

게임 렌더러는 정반대 상황에 있습니다. 래스터라이저는 시간 t의 한 순간을 그립니다. 노출 시간이 0인 카메라인 셈입니다. 그래서 빠르게 움직이는 물체는 프레임마다 뚝뚝 끊어진 위치에 선명하게 찍히고, 눈은 이걸 스트로브(strobing)로 느낍니다. 프레임레이트가 낮을수록 간격이 벌어지니 더 거슬립니다. 모션 블러가 30fps에서 특히 효과적인 이유가 여기 있습니다. 끊어진 위치 사이를 번짐으로 메워서 뇌가 연속된 움직임으로 읽게 만듭니다.

1-2. 정답은 알지만 쓸 수 없다

정답은 간단합니다. 셔터 시간 안에서 시간을 여러 개 뽑아 그 순간마다 장면을 렌더링하고 평균내면 됩니다. 이걸 누적(accumulation) 모션 블러라고 하고, 오프라인 렌더러와 패스 트레이서는 실제로 이렇게 합니다. 레이마다 시간을 랜덤하게 뽑는 식으로요. 그러나 실시간에서 프레임당 장면을 8번, 16번 그릴 수는 없습니다.

그래서 실시간 모션 블러는 전부 근사입니다. 한 순간을 그린 프레임 한 장에, 픽셀마다 “이 픽셀이 이전 프레임에서는 어디 있었는가”라는 정보를 더해 셔터 동안 생겼을 번짐을 사후에 복원합니다. 이 글에서 다루는 모든 것은 이 근사를 얼마나 그럴듯하게, 얼마나 싸게 하느냐에 대한 이야기입니다.

모션 블러는 효과가 아니라 노출 시간의 결과다. 게임은 노출 시간 0으로 그린 뒤, 속도 정보로 그 시간을 되살린다.

2. 재료: 속도 버퍼

복원에 필요한 정보는 픽셀마다 하나입니다. 이 픽셀에 보이는 표면이 이전 프레임의 화면에서는 어디 있었는가. 현재 위치에서 그 위치를 뺀 2D 벡터를 속도(velocity) 또는 모션 벡터(motion vector)라고 부릅니다. 단위는 보통 NDC나 UV이고, 블러 단계에서 픽셀 단위로 바꿔 씁니다.

이 버퍼는 모션 블러만을 위한 것이 아닙니다. TAA, TSR, DLSS, FSR 같은 템포럴 기법이 전부 같은 버퍼로 이전 프레임을 재투영합니다. 두 엔진 모두 이미 이 버퍼를 갖고 있고, 모션 블러는 거기에 얹히는 소비자 중 하나입니다. 템포럴 업스케일러가 이 버퍼를 어떻게 쓰는지는 MetalFX 업스케일링 글에서 다뤘습니다.

속도는 출처에 따라 두 가지 방법으로 만듭니다.

속도 버퍼의 두 가지 출처: 오브젝트 변환과 뎁스 역투영

2-1. 움직이는 오브젝트: 정점을 두 번 변환한다

오브젝트가 움직였다면 그 정점이 이전 프레임에 어디 있었는지를 알아야 합니다. 방법은 단순합니다. 정점 셰이더에서 현재 변환과 이전 프레임 변환으로 각각 투영하고, 픽셀 셰이더에서 두 NDC 좌표의 차이를 씁니다.

// 정점 셰이더
o.clipNow  = mul(ViewProjNoJitter,     mul(LocalToWorld,     v.position));
o.clipPrev = mul(PrevViewProjNoJitter, mul(PrevLocalToWorld, v.prevPosition));

// 픽셀 셰이더
float2 ndcNow  = o.clipNow.xy  / o.clipNow.w;
float2 ndcPrev = o.clipPrev.xy / o.clipPrev.w;
velocity = ndcNow - ndcPrev;   // 이후 픽셀 단위로 변환해 기록

두 가지 주의점이 있습니다.

  • 지터를 뺀 행렬을 써야 합니다. TAA가 매 프레임 투영 행렬에 서브픽셀 지터를 섞는데, 이게 들어가면 가만히 있는 물체도 매 프레임 흔들리는 속도를 갖습니다.
  • 이전 프레임 정점 위치는 저장하거나 다시 계산해야 합니다. 강체는 이전 프레임의 월드 변환만 기억하면 됩니다. 스키닝은 이전 프레임의 본 행렬이 필요하고, 셰이더 안에서 시간으로 정점을 움직이는 경우(바람에 흔들리는 풀, 월드 포지션 오프셋)는 이전 프레임의 시간 값으로 정점 셰이더를 한 번 더 평가해야 합니다. 이 부분을 빠뜨리면 풀은 흔들리는데 속도는 0이라서 블러가 안 걸리거나, 반대로 TAA가 고스팅을 냅니다.

2-2. 정적 지오메트리: 뎁스로 되돌린다

벽, 바닥, 지형처럼 자기는 안 움직였지만 카메라가 움직여서 화면에서는 이동한 픽셀이 훨씬 많습니다. 이걸 전부 2-1 방식으로 그리면 모든 드로우에 비용이 붙습니다. 대신 뎁스 버퍼를 씁니다. 픽셀 좌표와 뎁스가 있으면 그 표면의 월드 위치를 알 수 있고, 그 위치를 이전 프레임의 카메라 행렬로 다시 투영하면 이전 화면 위치가 나옵니다.

float  z        = DepthTexture[pixel];
float4 clipNow  = float4(PixelToNdc(pixel), z, 1);
float4 clipPrev = mul(ClipToPrevClip, clipNow);   // 현재 클립 → 이전 프레임 클립 (행렬 하나로 합쳐 둠)
float2 ndcPrev  = clipPrev.xy / clipPrev.w;
velocity = clipNow.xy - ndcPrev;

이 계산은 풀스크린 패스 하나로 끝나고, 드로우콜과 무관합니다. 두 엔진 모두 이 방식을 쓰지만 어디서 하느냐가 다릅니다. Unreal은 모션 블러가 자기 패스 안에서 계산하고, HDRP는 전용 패스로 버퍼를 완성해 둡니다. 4장과 5장에서 다시 봅니다.

3. 핵심 알고리즘: 타일 기반 재구성 필터

재료는 준비됐습니다. 이제 “속도 방향으로 블러한다”를 실제로 해 보면 바로 벽에 부딪힙니다.

3-1. 흩뿌리기와 모으기

물리적으로 맞는 동작은 흩뿌리기(scatter) 입니다. 움직인 픽셀이 자기 색을 자기가 지나간 경로 위에 뿌립니다. 주황색 자동차가 회색 벽 앞을 지나가면 자동차의 주황색이 벽 위로 번져야 합니다. 벽 픽셀은 가만히 있었는데도 말입니다.

문제는 GPU가 흩뿌리기를 싫어한다는 점입니다. 여러 픽셀이 같은 목적지에 쓰려고 하면 순서와 충돌을 관리해야 하고, 픽셀 셰이더는 애초에 자기 위치 한 곳에만 쓸 수 있습니다. GPU가 좋아하는 것은 모으기(gather) 입니다. 각 픽셀이 주변을 읽어서 자기 값을 정하는 방식입니다.

그래서 모으기로 바꿔 봅니다. “각 픽셀이 자기 속도 방향으로 이웃을 읽어 평균낸다.” 자동차 안쪽 픽셀은 잘 됩니다. 그런데 벽 픽셀은 속도가 0입니다. 읽을 방향이 없으니 아무것도 안 합니다. 결과는 자동차 안쪽만 뭉개지고 자동차와 벽의 경계는 면도날처럼 날카로운 이상한 그림입니다. 실제 블러에서 가장 눈에 띄는 부분, 즉 물체 바깥으로 번지는 부분이 통째로 빠집니다.

흩뿌리기, 단순 모으기, 이웃 맥스를 쓴 모으기의 비교

해결책은 질문을 바꾸는 것입니다. “내가 어디로 가나”가 아니라 “누가 나한테까지 닿을 수 있나” 를 묻습니다. 벽 픽셀이 “내 근처에 길이 40픽셀짜리 속도를 가진 놈이 있고, 방향은 이쪽이다”를 알면, 그 방향으로 샘플을 뽑아서 자동차 색을 모아올 수 있습니다. 흩뿌리기를 모으기로 흉내 내는 것이고, 이 아이디어가 McGuire 등의 2012년 논문 “A Reconstruction Filter for Plausible Motion Blur”의 핵심입니다. 이후 Jimenez가 2014년 Call of Duty: Advanced Warfare 발표에서 가중치와 샘플링을 다듬었고, 두 엔진의 구현은 이 두 자료의 직계 후손입니다.

모션 블러의 핵심 문제는 “움직이지 않은 픽셀이 움직인 픽셀의 색을 받아야 한다”는 것이고, 해법은 각 픽셀이 “나한테 닿을 수 있는 가장 긴 속도”를 아는 것이다.

3-2. 타일 맥스와 이웃 맥스

“나한테 닿을 수 있는 가장 긴 속도”를 픽셀마다 정확히 구하려면 반경 수십 픽셀을 뒤져야 합니다. 대신 화면을 타일로 나눕니다. Unreal은 16픽셀, HDRP는 32픽셀입니다.

  1. 타일 맥스. 타일 안의 픽셀 속도 중 길이가 가장 긴 것을 하나 고릅니다. 타일당 벡터 하나이니 텍스처 해상도가 1/16 또는 1/32로 줄고, 비용은 거의 없습니다.
  2. 이웃 맥스. 어떤 픽셀에 닿을 수 있는 속도는 옆 타일에서 올 수도 있습니다. 그래서 각 타일을 3×3 이웃 중 가장 긴 속도로 팽창시킵니다. 이제 타일 하나만 읽으면 그 픽셀에 영향을 줄 수 있는 최대 범위와 방향을 알 수 있습니다.

픽셀 속도에서 타일 맥스, 이웃 맥스로 가는 과정

왜 평균이 아니라 최대인지는 분명합니다. 평균을 쓰면 가장 빠른 물체의 번짐이 잘립니다. 최대를 쓰면 “이보다 멀리서는 아무것도 못 온다”는 보장이 생기고, 샘플링 범위를 거기서 끊을 수 있습니다.

이 설계에는 눈에 띄는 비용이 하나 있습니다. 타일 안의 모든 픽셀이 같은 방향으로 샘플링한다는 점입니다. 타일 맥스 방향과 다르게 움직이는 픽셀은 엉뚱한 축으로 블러됩니다. Guertin 등은 2014년에 타일 맥스 방향과 픽셀 자신의 방향 두 축을 함께 쓰는 보완을 제안했고, HDRP가 이 방식을 채택했습니다. 타일 경계에서 블러 방향이 갑자기 바뀌어 보이는 아티팩트도 같은 뿌리에서 나옵니다.

3-3. 모으기 필터: 샘플에 무게를 매긴다

이제 각 픽셀은 이웃 맥스 방향으로 선을 긋고 그 위에서 N개 샘플을 읽습니다. 문제는 읽은 샘플을 얼마나 믿을 것인가입니다. 선 위에 있다고 다 섞으면 가만히 있는 벽 두 픽셀도 서로 섞여 화면 전체가 뿌옇게 됩니다. 샘플마다 두 가지를 묻습니다.

첫째, 이 샘플의 속도가 나까지 닿는가. 샘플 S가 중심 C에서 d픽셀 떨어져 있다면, S의 속도 길이가 d 이상이어야 S의 색이 C까지 번져올 수 있습니다. 논문에서는 이걸 cone 또는 spread 가중치라고 부릅니다. 이 글에서는 reach라고 쓰겠습니다.

둘째, 이 샘플이 나보다 앞인가 뒤인가. 뎁스를 비교합니다.

  • 샘플이 앞에 있고 움직인다면 그 색이 내 위로 번져옵니다. 자동차가 벽 위로 번지는 경우입니다. 가중치는 샘플의 속도가 나까지 닿는지로 정합니다.
  • 샘플이 뒤에 있다면 그 색은 내가 움직여서 자리를 비울 때만 보입니다. 자동차 가장자리 픽셀 사이로 벽이 비치는 경우입니다. 가중치는 내 속도가 샘플까지 닿는지로 정합니다.
  • 둘 다 가만히 있으면 두 조건 모두 0이고, 섞이지 않습니다.

샘플 가중치: 앞뒤 판정과 reach의 조합

뎁스 비교는 계단식이 아니라 부드럽게 합니다. 경계에서 가중치가 0과 1 사이를 뛰어다니면 그대로 노이즈가 됩니다. 이 경사의 기울기가 Unreal 셰이더의 depth scale, HDRP의 Depth Comparison Extent입니다.

전체를 의사 코드로 쓰면 이렇습니다. 두 엔진의 셰이더를 단순화한 것이고, 가중치 함수는 Jimenez 2014의 형태를 거의 그대로 따릅니다. 뎁스는 클수록 먼 값이라고 가정합니다.

float2 vMax   = NeighborMax[tileOf(p)];            // 나한테 닿을 수 있는 가장 긴 속도
float  lenMax = length(vMax);
if (lenMax < 0.5) return Color[p];                 // 주변이 다 멈춰 있으면 조기 종료

float2 dir = vMax / lenMax;
float2 vC  = Velocity[p];   float zC = Depth[p];
float  jitter = InterleavedGradientNoise(p);       // 샘플 위치를 픽셀마다 흔든다

float  wC   = 1.0 / max(length(vC), 1.0);          // 중심 샘플: 빨리 움직일수록 자기 색을 덜 믿는다
float3 sum  = Color[p] * wC;
float  wSum = wC;

for (int i = 0; i < N; i++)
{
    float  t  = ((i + jitter) / N - 0.5) * 2.0;    // -1 ~ +1, 양쪽으로
    float2 ps = p + dir * (t * lenMax);
    float  d  = abs(t) * lenMax;                   // 중심까지의 거리(픽셀)

    float2 vS = Velocity[ps];   float zS = Depth[ps];

    // (샘플이 뒤, 샘플이 앞) — 부드러운 뎁스 비교
    float2 depthCmp  = saturate(0.5 + float2(1, -1) * (zS - zC) * DepthScale);
    // (내 속도가 d까지 닿나, 샘플 속도가 d까지 닿나)
    float2 spreadCmp = saturate(float2(length(vC), length(vS)) - max(d - 1.0, 0.0));

    float w = dot(depthCmp, spreadCmp);            // 뒤×내속도 + 앞×샘플속도
    sum  += Color[ps] * w;
    wSum += w;
}
return sum / wSum;

몇 가지 짚을 점이 있습니다.

  • 조기 종료가 비용의 대부분을 아낍니다. 화면 대부분의 타일은 멈춰 있거나 아주 느립니다. 이웃 맥스가 1픽셀도 안 되는 타일은 루프에 들어가지도 않습니다. 모션 블러 비용이 “움직이는 영역의 넓이”에 비례하는 이유입니다.
  • 지터는 띠를 노이즈로 바꿉니다. N이 8이나 16밖에 안 되니 샘플 간격이 그대로 보이면 띠(banding)가 생깁니다. 픽셀마다 시작 위치를 흔들면 띠가 노이즈로 바뀌고, 노이즈가 띠보다 눈에 덜 띕니다. 다만 모션 블러는 TAA 뒤에서 돌기 때문에 이 노이즈를 TAA가 지워주지 않습니다. 그래서 두 엔진 모두 샘플 수나 2차 패스로 노이즈를 직접 다스리는 옵션을 둡니다.
  • 중심 가중치는 자기 속도 길이의 역수입니다. 빨리 움직이는 픽셀일수록 자기 색의 비중이 줄어 번짐이 자연스럽습니다.

3-4. 어디에 두는가

Scene Color ─▶ DOF ─▶ TAA / TSR ─▶ [Motion Blur] ─▶ Bloom ─▶ Tonemap ─▶ ...
                          ▲ 히스토리 누적          ▲ 여기

두 엔진 모두 TAA 뒤, 톤매핑 앞입니다. 이유는 두 가지입니다.

  • TAA 히스토리를 더럽히면 안 됩니다. 모션 블러는 프레임마다 다른 모양으로 번지는 효과입니다. 이 결과가 히스토리에 누적되면 다음 프레임의 재투영이 어긋나고 고스팅이 생깁니다. 같은 이유로 템포럴 업스케일러를 쓸 때도 모션 블러는 업스케일 뒤, 즉 출력 해상도에서 돕니다.
  • 선형 HDR에서 섞어야 합니다. 톤매핑 뒤에서 평균내면 밝은 하이라이트의 번짐이 눌립니다. 밝은 광원이 길게 끌리는 느낌은 선형 공간에서 평균을 내야 나옵니다.

4. Unreal Engine의 구현

Unreal의 모션 블러는 UE4 시절 PostProcessMotionBlur로 들어온 이후 골격이 거의 그대로입니다. UE5에서는 파일이 MotionBlur.cpp와 Shaders/Private/MotionBlur/ 아래로 정리되고 TSR과 엮이는 부분이 추가됐습니다. 네 개의 패스로 나뉩니다.

Unreal Engine 모션 블러 파이프라인: 네 개의 패스와 각 출력

4-1. Velocity 패스: 움직인 것만 그린다

Unreal은 속도 버퍼를 움직인 프리미티브만 그려서 만듭니다. 이전 프레임과 변환이 다른 것, 스켈레탈 메시, 월드 포지션 오프셋을 쓰는 머티리얼이 대상입니다. 그리고 화면에 투영된 크기가 Motion Blur Per Object Size 설정보다 작은 오브젝트는 드로우 자체를 건너뜁니다. 작은 물체의 블러는 어차피 티가 안 나고, 드로우콜을 아끼는 게 이득이라는 판단입니다.

정적 지오메트리는 이 패스에서 그리지 않습니다. 속도 텍스처는 “기록된 적 없음”을 구분할 수 있게 인코딩되어 있고, 비어 있는 픽셀의 속도는 다음 패스에서 뎁스로 복원합니다. Nanite 메시는 자체 패스에서 속도를 씁니다. 이 패스를 뎁스 프리패스 안에서 돌릴지, 베이스 패스 안에서 돌릴지, 베이스 패스 뒤에 따로 돌릴지는 r.VelocityOutputPass로 고릅니다.

4-2. Velocity Flatten: 한 패스에서 네 가지 일

16×16 스레드 그룹의 컴퓨트 셰이더 하나가 네 가지 일을 처리합니다.

  1. 속도 복원. 속도 텍스처에 값이 있으면 디코드하고, 비어 있으면 2-2의 방식으로 뎁스에서 복원합니다. 카메라 모션 블러가 여기서 생깁니다.
  2. 스케일. Motion Blur Amount를 곱하고, Target FPS가 설정되어 있으면 프레임 시간으로 정규화합니다. 그래야 60fps에서나 30fps에서나 블러 길이가 같아지고, 고정 셔터 속도를 가진 카메라처럼 보입니다. 기본값이 Target FPS 30, Amount 0.5이니 “30fps 기준 180도 셔터”에 해당합니다.
  3. 클램프. Motion Blur Max를 넘는 속도는 잘라냅니다. 단위는 화면 너비의 퍼센트입니다. 카메라를 휙 돌릴 때 화면이 통째로 뭉개지는 걸 막는 유일한 수단입니다.
  4. 타일 맥스. 속도와 뎁스를 한 텍스처로 패킹해 저장하고, 그룹 공유 메모리에서 리덕션해 16×16 타일의 최대 속도를 1/16 해상도 텍스처에 씁니다.

UE5에서는 TSR이 자기 용도로 같은 Flatten 결과를 만듭니다. 그래서 TSR이 켜져 있으면 모션 블러는 이 패스를 건너뛰고 TSR의 출력을 넘겨받을 수 있습니다.

4-3. 타일 팽창을 래스터라이저에 시킨다

이웃 맥스를 구하는 가장 단순한 방법은 타일 텍스처에서 3×3을 읽어 최대를 고르는 게더 패스입니다. Unreal은 기본적으로 다른 방법을 씁니다. 래스터라이저와 깊이 테스트에 시킵니다.

깊이 테스트로 이웃 맥스를 구하는 Velocity Scatter 패스

타일마다 쿼드 하나를 인스턴스로 그립니다. 버텍스 셰이더가 그 타일의 최대 속도를 읽어 쿼드를 속도 방향으로 길게 늘리고, 쿼드의 Z 값에 속도 길이를 씁니다. 깊이 테스트를 Greater로 두면 여러 쿼드가 겹친 타일에는 가장 긴 속도만 살아남습니다. max 연산을 셰이더 코드가 아니라 고정 기능 하드웨어에 떠넘긴 셈입니다.

3×3 게더와 비교하면 두 가지가 다릅니다. 속도가 긴 타일은 3타일 너머까지도 팽창되고, 속도가 닿지 않는 방향으로는 팽창되지 않습니다. 즉 필요한 곳에 필요한 만큼만 팽창됩니다. r.MotionBlur.Scatter를 끄면 3×3 게더로 폴백합니다.

4-4. Filter 패스와 품질 옵션

마지막 패스는 3-3의 루프 그대로입니다. 이웃 맥스 방향으로 양쪽 샘플링하고, 가중치는 뎁스 비교와 spread 비교의 곱입니다. 샘플 위치는 픽셀별 디더로 흔듭니다. 조정할 수 있는 축은 세 가지입니다.

  • 스텝 수. r.MotionBlurQuality 1~4에 따라 샘플 수가 늘어납니다.
  • 2차 패스. r.MotionBlurSeparable을 켜면 필터를 한 번 더 돌려 노이즈를 누릅니다. 비용이 두 배에 가까워지는 대신 샘플 수를 올리는 것보다 노이즈가 빨리 사라집니다.
  • 해상도. UE5의 r.MotionBlur.HalfResInput과 r.MotionBlur.HalfResGather로 입력이나 게더를 하프 해상도에서 돌릴 수 있습니다. 블러가 강한 영역은 어차피 고주파가 없으니 손해가 적습니다.

4-5. 조정 노브

설정 콘솔 변수 의미
Motion Blur Amount r.MotionBlur.Amount 속도 배율. 0.5가 기본, 180도 셔터에 해당
Motion Blur Max r.MotionBlur.Max 최대 블러 길이, 화면 너비의 %
Motion Blur Target FPS r.MotionBlur.TargetFPS 셔터 정규화 기준. 0이면 프레임레이트에 따라 블러 길이가 변함
Motion Blur Per Object Size — 투영 반경이 이보다 작은 오브젝트는 Velocity 패스에서 제외. 화면 너비의 %, 기본 4
— r.MotionBlurQuality 샘플 수 단계 (1~4), 0이면 끔
— r.MotionBlur.Scatter 래스터 scatter 팽창 / 3×3 게더
— r.MotionBlurSeparable 노이즈 억제용 2차 패스
— r.MotionBlur.HalfResInput, HalfResGather UE5 하프 해상도 옵션

오브젝트 단위로 모션 블러를 끄는 스위치는 없습니다. 1인칭 무기처럼 블러에서 빼고 싶은 것은 별도 처리가 필요합니다.

5. Unity HDRP의 구현

HDRP는 모션 벡터 버퍼를 먼저 완성된 상태로 만들어 두고, 모션 블러는 컴퓨트 셰이더 다섯 개로 그 버퍼를 소비합니다. 전부 Runtime/PostProcessing/ 아래에 있습니다.

Unity HDRP 모션 블러 파이프라인: 버퍼 생성과 네 개의 컴퓨트 패스

5-1. 모션 벡터 버퍼: 오브젝트 패스 + 카메라 패스

HDRP에서 버퍼는 두 패스가 나눠 채웁니다.

  • 오브젝트 모션 벡터 패스. Mesh Renderer의 Motion Vectors가 Per Object Motion인 렌더러가 전용 셰이더 패스로 2-1의 계산을 합니다. 스킨드 메시는 이전 프레임 본 행렬을 쓰고, 셰이더 그래프에서 정점을 움직이는 머티리얼은 이전 프레임 시간 상수로 정점 셰이더를 한 번 더 평가합니다. 이 오브젝트들은 뎁스 프리패스 대신 여기서 뎁스도 함께 씁니다.
  • 카메라 모션 벡터 패스. 풀스크린 패스가 스텐실로 “오브젝트 패스가 안 쓴 픽셀”만 골라 2-2의 뎁스 역투영을 합니다.

두 패스가 끝나면 버퍼는 모든 픽셀에 대해 완성되어 있습니다. TAA, SSR, 업스케일러가 전부 이 버퍼를 그대로 읽습니다. Unreal이 소비자마다 빈 픽셀을 각자 복원하는 것과 대비되는 지점입니다.

Motion Vectors를 Force No Motion으로 두면 그 렌더러는 속도 0을 씁니다. 블러도 안 걸리고 TAA도 정지한 것으로 봅니다. 1인칭 무기에 흔히 쓰는 설정입니다.

5-2. Motion Vector Prep: 카메라 클램프

첫 컴퓨트 패스는 버퍼를 블러용으로 가공합니다. 카메라 성분을 분리해 클램프하고(아래에서 설명), Intensity를 곱하고, Maximum Velocity를 넘는 길이는 자릅니다. Minimum Velocity 이하는 0으로 두되, 웨이브 안의 모든 픽셀이 임계값 미만이면 웨이브 전체가 일을 건너뛰는 조기 종료가 붙어 있습니다. 마지막으로 속도를 길이와 각도의 극좌표로 다시 인코딩해 선형 뎁스와 함께 패킹합니다. 극좌표로 바꾸는 이유는 다음 패스에서 “길이의 최대”를 원자 연산으로 구하기 위해서입니다.

이 패스에는 HDRP에만 있는 기능이 하나 들어 있습니다. Camera Clamp Mode입니다.

HDRP Camera Clamp: 카메라 성분만 따로 클램프

버퍼의 속도는 카메라 움직임과 오브젝트 움직임이 합쳐진 값입니다. 카메라를 빠르게 돌리면 카메라 성분이 커져서 화면 전체가 뭉개지는데, 이걸 전역 최대 길이 하나로 막으면 움직이는 오브젝트의 블러까지 같이 잘립니다. HDRP는 뎁스와 이전 프레임 뷰-프로젝션 행렬로 카메라 성분만 다시 계산해서 따로 클램프하고, 오브젝트 성분은 그대로 더합니다. 회전 성분과 이동 성분을 따로 클램프하는 모드도 있습니다. 카메라를 휙 돌릴 때의 블러와 달릴 때의 블러를 다른 세기로 두고 싶을 때 씁니다. 클램프 값의 단위는 화면 크기에 대한 비율이고, 기본값은 회전 0.03, 이동 0.05입니다.

같은 맥락의 스위치가 하나 더 있습니다. Camera Motion Blur 토글을 끄면 카메라 성분을 아예 빼고 오브젝트 블러만 남깁니다. 카메라 블러를 싫어하는 플레이어에게 옵션으로 주기 좋은 형태인데, Unreal에는 이에 해당하는 스위치가 없습니다.

5-3. 타일 패스: 원자 연산으로 흩뿌리기

타일 크기는 32픽셀입니다. 세 개의 커널이 이어집니다.

  1. GenTile. 타일마다 최대 속도의 길이와 방향, 그리고 최소 속도의 길이를 구합니다. 최소 길이는 마지막 패스에서 fast path 판정에 씁니다.
  2. Neighborhood. 이웃 맥스 팽창입니다. Unreal처럼 scatter와 gather 두 변형이 있지만 수단이 다릅니다. scatter 변형은 래스터라이저 대신 컴퓨트 원자 연산을 씁니다. 속도를 uint 하나로 패킹한 뒤, 타일 공간에서 최대 속도 방향으로 선을 긋고(DDA 또는 Bresenham) 그 선이 지나는 타일마다 InterlockedMax로 씁니다. 극좌표의 길이를 상위 비트에 두면 정수 비교가 곧 길이 비교가 됩니다. Unreal이 쿼드를 늘려 그리는 것과 같은 발상을 셰이더 안에서 직접 한 것입니다. uint 텍스처 원자 연산을 지원하지 않는 플랫폼에서는 3×3 게더로 폴백합니다.
  3. MergeTile. 패킹을 풀어 최종 타일 텍스처를 만들고, 최소 속도의 이웃값도 여기서 구합니다.

5-4. 최종 패스: fast path와 두 방향 샘플링

마지막 컴퓨트 패스는 풀 해상도에서 돕니다. Unreal과 다른 점이 둘 있습니다.

  • Fast path. 타일의 최소 속도가 최대 속도의 일정 비율 이상이면 그 타일은 거의 균일하게 움직이는 것이므로, 타일 맥스 방향 하나로만 샘플링하는 값싼 경로를 탑니다. 카메라가 움직일 때 배경 대부분이 여기에 해당합니다. 단, 최대 속도가 화면 크기의 1/3을 넘는 아주 넓은 블러는 게더한 최소값과 흩뿌린 최대값이 어긋날 수 있어서 일반 경로로 돌립니다.
  • 두 방향 샘플링. 3-2에서 말한 Guertin 방식입니다. 일반 경로에서는 샘플을 타일 맥스 방향과 픽셀 자신의 속도 방향 두 축에 번갈아 배치합니다. 셰이더 주석을 빌리면 “한 타일 안에 아주 다른 속도가 섞여 있을 때의 버그를 줄이기 위해서”입니다.
  • 샘플 수는 블러 길이에 맞춥니다. 실제 샘플 수는 이웃 맥스 길이(픽셀)와 Sample Count 중 작은 쪽입니다. 3픽셀짜리 블러에 8개를 쓰지 않습니다. Sample Count는 상한이지 고정값이 아닙니다.

뎁스 비교의 경사는 Depth Comparison Extent입니다. 하프 해상도 옵션은 없습니다.

5-5. 조정 노브

Volume 설정 기본값 의미
Intensity 0 속도 배율. Unreal의 Target FPS 같은 프레임 시간 정규화는 없다
Quality / Sample Count 8 픽셀당 샘플 수 상한. Quality 프리셋이 값을 정한다
Maximum Velocity 200 px 카메라 회전을 제외한 모든 소스의 최대 블러 길이
Minimum Velocity 2 px 이보다 짧은 속도는 블러하지 않음
Camera Motion Blur 켬 끄면 카메라 성분을 블러에서 제외
Camera Clamp Mode None None / Rotation / Translation / Separate / Full Camera Motion Vector
Rotation / Translation / Motion Vector Clamp 0.03 / 0.05 / 0.05 모드별 카메라 성분 상한, 화면 크기 비율
Depth Comparison Extent 1 앞뒤 판정의 부드러움. 문서의 속성 표에는 없고 인스펙터의 추가 속성으로 노출된다

Intensity가 단순 배율이라는 점은 실무에서 의미가 있습니다. 프레임레이트가 바뀌면 프레임당 이동량이 바뀌고, 블러 길이도 따라 바뀝니다. 60fps와 30fps에서 같은 룩을 원하면 Intensity를 프레임 시간에 맞춰 스크립트로 조정해야 합니다.

6. 나란히 놓고 보기

6-1. 설계 차이

항목 Unreal HDRP
정적 지오메트리 속도 모션 블러 안에서 뎁스 역투영 별도 카메라 MV 패스로 버퍼 완성
이웃 맥스 팽창 래스터 쿼드 + 깊이 테스트 컴퓨트에서 선 래스터 + 원자 연산
타일 크기 16 32
샘플 축 타일 맥스 방향 하나 타일 맥스 + 자기 방향
카메라 블러 제어 전역 Max 클램프 카메라 성분 분리 클램프
프레임레이트 정규화 Target FPS 없음
해상도 하프 해상도 옵션 풀 해상도
균일 타일 최적화 조기 종료만 fast path + 길이에 맞춘 샘플 수
오브젝트 단위 제외 없음 Force No Motion
카메라 블러만 끄기 없음 Camera Motion Blur 토글

차이의 대부분은 버퍼를 누가 책임지느냐에서 나옵니다. HDRP는 모션 벡터 버퍼를 렌더 파이프라인의 공식 산출물로 두고 모션 블러는 소비자로 머뭅니다. 그래서 카메라 성분 분리 같은 가공을 소비자 쪽에서 추가할 수 있었습니다. Unreal은 모션 블러가 속도 복원까지 떠안는 대신, TSR과 Flatten을 공유하는 식으로 중복을 줄였습니다.

6-2. 두 엔진이 공유하는 아티팩트

구조가 같으니 깨지는 방식도 같습니다.

  • 타일 경계. 타일마다 샘플 방향이 다르므로 경계에서 블러 모양이 끊깁니다. 빠른 물체 주변에서 격자 무늬처럼 보이기도 합니다. 타일을 작게 하면 줄지만 타일 텍스처 비용과 팽창 범위가 같이 늘어납니다.
  • 반투명과 파티클. 대부분 속도 버퍼를 쓰지 않습니다. 그 픽셀의 속도는 뒤에 있는 불투명 표면의 것이 되고, 파티클이 배경의 움직임을 따라 번지거나 반대로 멈춰 보입니다.
  • 한 타일 안의 반대 방향. 서로 반대로 움직이는 두 물체가 한 타일에 들어오면 한쪽은 틀린 축으로 번집니다. HDRP의 두 방향 샘플링은 완화책이지 해결책은 아닙니다.
  • 노이즈. 샘플이 적으면 지터 노이즈가 보이고, TAA가 지워주지 않습니다. 샘플 수, 2차 패스, 하프 해상도 사이에서 타협하게 됩니다.
  • 빠른 카메라 회전. 둘 다 클램프로 막습니다. 클램프에 걸린 영역은 물리적으로 틀린 길이로 번지지만, 안 막으면 화면이 통째로 사라지므로 어쩔 수 없는 거래입니다.

6-3. 디버깅

  • Unreal. 뷰포트의 Show → Visualize → Motion Blur가 타일 속도를 색으로 보여 줍니다. 특정 오브젝트에 블러가 안 걸리면 먼저 Velocity 패스에 그려졌는지(Per Object Size, WPO 설정)를 확인합니다.
  • HDRP. Rendering Debugger의 Fullscreen Debug Mode에서 Motion Vectors를 고르면 버퍼를 직접 봅니다. 검게 나오는 오브젝트는 Motion Vectors 설정이나 프레임 세팅의 Object Motion Vectors가 꺼져 있는 경우가 대부분입니다.

마치며

모션 블러를 처음 볼 때 어렵게 느껴지는 이유는 “속도 방향으로 블러한다”는 설명이 정작 어려운 부분을 건너뛰기 때문입니다. 어려운 부분은 움직이지 않은 픽셀이 움직인 픽셀의 색을 받아야 한다는 것이고, 타일 맥스와 이웃 맥스는 그 문제를 “누가 나한테 닿나”라는 질문으로 바꿔서 푸는 장치입니다. 이 한 가지를 이해하면 두 엔진의 패스 구조는 전부 그 질문에 답하는 순서로 읽힙니다.

두 엔진의 차이는 알고리즘이 아니라 역할 분담에서 나왔습니다. HDRP는 속도 버퍼를 완성해 두고 소비자가 가공하게 했고, Unreal은 소비자가 복원까지 하되 TSR과 결과를 나눠 쓰게 했습니다. 어느 쪽이 낫다기보다, 어느 쪽이든 그 결정이 조정 노브의 모양(Camera Clamp가 있느냐, Target FPS가 있느냐)까지 결정했다는 점이 재미있습니다.

모션 블러의 품질은 샘플 수가 아니라 “닿을 수 있는 속도”를 얼마나 정확히 아느냐에서 갈린다. 타일 맥스는 그 정보를 싸게 구하는 타협이고, 두 엔진의 차이는 그 타협을 어디서 하느냐의 차이다.

참고 자료

논문과 발표

Unreal Engine

  • Motion Blur — Unreal Engine 문서
  • 소스: Engine/Source/Runtime/Renderer/Private/PostProcess/MotionBlur.cpp, Engine/Shaders/Private/MotionBlur/ (UE5), PostProcessMotionBlur.cpp / PostProcessMotionBlur.usf (UE4)

Unity HDRP

  • Motion Blur — HDRP 매뉴얼
  • Motion Vectors — HDRP 매뉴얼
  • 소스: Graphics 저장소 Packages/com.unity.render-pipelines.high-definition/Runtime/PostProcessing/Shaders/ 아래 MotionBlurMotionVecPrep.compute, MotionBlurGenTilePass.compute, MotionBlurNeighborhoodTilePass.compute, MotionBlurMergeTilePass.compute, MotionBlur.compute, MotionBlurCommon.hlsl. 볼륨 파라미터는 Runtime/PostProcessing/Components/MotionBlur.cs