셰이더 최적화: 분기문 없이 버텍스 컬러 채널 선택하기

Mazeline·4일 전
0

TA 가이드

목록 보기
3/6
post-thumbnail

GPU에서 분기문은 비용이 비쌉니다. 특히 모바일이나 타일 기반 렌더러에서는 더욱 그렇습니다. 이번 글에서는 if/else 분기문을 원-핫 마스크와 dot product를 활용해 완전히 제거하는 최적화 기법을 소개합니다.

문제 상황

아웃라인 셰이더를 작성할 때 버텍스 컬러의 특정 채널(R, G, B, A)을 선택해야 하는 경우가 있습니다. 예를 들어, 아티스트가 버텍스 컬러의 R 채널에는 아웃라인 두께를, G, B, A 채널에는 다른 속성을 저장했다면, 머티리얼 프로퍼티로 어떤 채널을 사용할지 선택할 수 있어야 합니다.

기존 방식: 분기문 사용

가장 직관적인 방법은 if/else 분기문을 사용하는 것입니다.

프로퍼티 선언

[Enum(R,0,G,1,B,2,A,3)]_OutlineVertexColorChannel("Outline Vertex Color Channel", Float) = 0

분기문을 사용한 채널 선택

예제는 이해를 돕기 위해 하드 코딩된 것입니다.

float outlineVertexColorMask = 0.0;
if (_OutlineVertexColorChannel == 0)
    outlineVertexColorMask = vertexColor.r;
else if (_OutlineVertexColorChannel == 1)
    outlineVertexColorMask = vertexColor.g;
else if (_OutlineVertexColorChannel == 2)
    outlineVertexColorMask = vertexColor.b;
else if (_OutlineVertexColorChannel == 3)
    outlineVertexColorMask = vertexColor.a;

문제점

런타임 분기(동적 분기)는 GPU 파이프라인에서 큰 성능 비용을 발생시킵니다. GPU는 여러 쓰레드를 묶어서(Warp) 같은 명령어를 실행하는데, 분기문이 있으면 각 쓰레드가 다른 경로를 실행할 수 있어 성능이 저하됩니다. 이를 Warp Divergence라고 합니다. 또한 분기 조건을 평가하고 점프하는 과정에서 파이프라인이 정지될 수 있으며, 특히 타일 기반 렌더러를 사용하는 모바일 GPU에서는 분기문의 비용이 더 큽니다.


최적화 방법: 원-핫 마스크 + Dot Product

분기문 없이 벡터 연산만으로 채널을 선택할 수 있습니다.

// _OutlineVertexColorChannel: float 또는 int (0,1,2,3)
float c = (float)_OutlineVertexColorChannel;

// 0,1,2,3과의 거리를 이용해 원-핫 마스크 생성 (분기 없음)
float4 mask = saturate(1.0 - abs(float4(0.0, 1.0, 2.0, 3.0) - c));

// vertexColor: float4/half4 (r,g,b,a)
float outlineVertexColorMask = dot(vertexColor, mask);

동작 원리

1. 채널 인덱스

c 변수는 선택할 채널을 나타냅니다. c 값이 0이면 빨강(Red) 채널을, 1이면 초록(Green) 채널을, 2이면 파랑(Blue) 채널을, 3이면 알파(Alpha) 채널을 선택합니다.

2. 원-핫 마스크 생성

float4 mask = saturate(1.0 - abs(float4(0.0, 1.0, 2.0, 3.0) - c));

이 한 줄의 코드가 핵심입니다. 하나의 성분만 1.0이고 나머지는 0.0인 마스크를 생성합니다.

단계별 계산 예시 (c = 1일 때)

Step 1: 각 인덱스와의 차이 계산

float4(0.0, 1.0, 2.0, 3.0) - 1.0 = float4(-1.0, 0.0, 1.0, 2.0)

Step 2: 절댓값 계산

abs(float4(-1.0, 0.0, 1.0, 2.0)) = float4(1.0, 0.0, 1.0, 2.0)

Step 3: 1에서 빼기

1.0 - float4(1.0, 0.0, 1.0, 2.0) = float4(0.0, 1.0, 0.0, -1.0)

Step 4: saturate로 0~1 범위로 클램핑

saturate(float4(0.0, 1.0, 0.0, -1.0)) = float4(0.0, 1.0, 0.0, 0.0)

결과적으로 c = 1일 때 mask = (0, 1, 0, 0)이 생성됩니다. 같은 방식으로 c = 0일 때는 (1, 0, 0, 0), c = 2일 때는 (0, 0, 1, 0), c = 3일 때는 (0, 0, 0, 1)의 마스크가 만들어집니다.

3. 채널 선택

float outlineVertexColorMask = dot(vertexColor, mask);

내적(dot product)을 통해 각 성분을 곱하고 더합니다.

dot(vertexColor, mask) = vertexColor.r * mask.r 
                        + vertexColor.g * mask.g 
                        + vertexColor.b * mask.b 
                        + vertexColor.a * mask.a

마스크에서 하나의 성분만 1.0이므로, 결과적으로 해당 채널의 값만 추출됩니다.

예를 들어 mask = (0, 1, 0, 0)이면:

= vertexColor.r * 0 + vertexColor.g * 1 + vertexColor.b * 0 + vertexColor.a * 0
= vertexColor.g

장점

GPU 친화적

분기문(if/else)이 없어서 Warp Divergence를 완전히 회피합니다. 모든 쓰레드가 동일한 명령어를 실행하기 때문에 GPU의 병렬 처리 효율이 최대화됩니다.

벡터화 연산

GPU는 벡터 연산에 최적화되어 있습니다. 이 기법은 SIMD(Single Instruction Multiple Data) 연산을 효율적으로 활용하여 하드웨어의 성능을 최대한 끌어낼 수 있습니다.

유연성

런타임에 _OutlineVertexColorChannel 값만 바꾸면 쉽게 채널을 변경할 수 있습니다. 컴파일 타임 분기나 shader variant를 생성할 필요 없이 동적으로 채널을 선택할 수 있어 메모리 효율도 좋습니다.

간결함

4개의 if/else 분기가 단 3줄의 벡터 연산으로 대체됩니다. 코드가 간결해지면서도 성능은 오히려 향상됩니다.


활용 사례

이 기법은 버텍스 컬러 채널에서 아웃라인 두께나 디졸브 마스크, AO 같은 데이터를 선택할 때 유용합니다. 또한 멀티채널 마스크 텍스처에서 특정 채널을 추출하거나, 런타임에 여러 옵션 중 하나를 선택해야 할 때도 활용할 수 있습니다.


정리

구분분기문 방식원-핫 마스크 방식
코드 길이8줄+3줄
분기문4개0개
GPU 효율낮음 (Warp Divergence)높음 (벡터 연산)
가독성직관적수학적

GPU 셰이더 최적화의 핵심은 "분기를 피하고 벡터 연산을 활용하는 것"입니다. 원-핫 마스크 기법은 이 원칙을 잘 보여주는 실용적인 예시입니다.

성능 특성 분석

원-핫 마스크 방식이 분기문 방식보다 우수한 성능을 보이는 이유는 다음과 같습니다.

Warp Divergence 제거

분기문 방식에서는 서로 다른 쓰레드가 서로 다른 분기를 실행할 때 GPU가 각 분기를 순차적으로 처리해야 합니다. 예를 들어 Warp 내 32개의 쓰레드 중 8개는 if (_OutlineVertexColorChannel == 0)을, 다른 8개는 == 1을 실행한다면, GPU는 4개의 분기를 모두 순차적으로 실행하고 각 쓰레드에 해당하는 결과만 활성화해야 합니다. 이로 인해 실제 실행 시간이 최대 4배까지 늘어날 수 있습니다.

SIMD 연산 최대 활용

원-핫 마스크 방식은 벡터 연산(abs, saturate, dot)만을 사용하므로 모든 쓰레드가 동일한 명령어를 동시에 실행합니다. 예를 들어 Mali GPU는 하나의 클럭 사이클에 최대 16개의 FP32 연산을 병렬로 처리할 수 있는데, 분기문이 없으면 이 하드웨어 성능을 온전히 활용할 수 있습니다.

레지스터 효율성

분기문 방식에서는 각 분기의 결과를 저장하기 위해 추가 레지스터가 필요하지만, 원-핫 마스크 방식은 중간 결과(mask)를 즉시 소비하므로 레지스터 사용량이 적습니다. 이는 특히 복잡한 셰이더에서 occupancy(동시 실행 가능한 warp 수)를 높이는 데 도움이 됩니다.

주의사항 및 제약

입력 값 범위

ChannelIndex 값은 반드시 0에서 3 사이의 정수여야 합니다. 범위를 벗어나는 값이 입력되면 예상하지 못한 결과가 나올 수 있습니다. ShaderGraph에서는 프로퍼티 범위를 0-3으로 제한하여 이를 방지할 수 있습니다.

컴파일러 최적화 고려사항

현대 셰이더 컴파일러는 uniform 변수 기반 분기를 어느 정도 최적화할 수 있습니다. 하지만 이러한 최적화는 GPU 벤더, 드라이버 버전, 셰이더 복잡도에 따라 달라질 수 있습니다. 원-핫 마스크 방식은 컴파일러 최적화에 의존하지 않고 명시적으로 분기를 제거하므로 더 예측 가능한 성능을 제공합니다.

명령어 수와 실제 성능

원-핫 마스크 방식은 abs, saturate, dot 등 여러 명령어를 사용하므로 단순 명령어 수만 보면 분기문 방식보다 많을 수 있습니다. 그러나 GPU에서는 명령어 수보다 병렬 실행 효율이 더 중요합니다. 분기문으로 인한 직렬화 비용이 추가 벡터 연산 비용보다 훨씬 크기 때문에 전체적으로는 원-핫 마스크 방식이 더 빠릅니다.

성능 향상 정도

실제 성능 향상 정도는 GPU 아키텍처, 셰이더 컴파일러, 전체 셰이더 복잡도, 그리고 Warp 내에서 얼마나 다양한 채널 값이 사용되는지에 따라 달라집니다. 모바일 GPU에서는 일반적으로 더 큰 성능 차이를 보이며, 데스크톱 GPU에서는 상대적으로 차이가 작을 수 있습니다.

실제 성능 향상 정도는 GPU 아키텍처, 셰이더 컴파일러, 전체 셰이더 복잡도, 그리고 Warp 내에서 얼마나 다양한 채널 값이 사용되는지에 따라 달라집니다. 모바일 GPU에서는 일반적으로 더 큰 성능 차이를 보이며, 데스크톱 GPU에서는 상대적으로 차이가 작을 수 있습니다.


GPU 벤더별 동작 특성

원-핫 마스크 최적화는 모든 GPU 아키텍처에서 효과적이지만, 벤더별로 특성이 다릅니다.

NVIDIA GPU (Warp 기반)

NVIDIA GPU는 32개의 쓰레드를 하나의 Warp로 묶어 처리합니다. 분기문이 있을 때 Warp 내에서 서로 다른 경로를 실행하는 쓰레드가 있다면 각 경로를 순차적으로 실행해야 합니다. 예를 들어 Warp 내 8개 쓰레드가 각각 다른 채널을 선택한다면 4개의 분기를 모두 실행해야 하므로 최악의 경우 4배의 시간이 걸립니다.

원-핫 마스크 방식은 모든 쓰레드가 동일한 벡터 연산을 수행하므로 Warp Divergence가 발생하지 않습니다. NVIDIA의 최신 Ampere와 Ada 아키텍처는 FP32 연산에 매우 최적화되어 있어 abs, saturate, dot 같은 벡터 연산을 효율적으로 처리합니다.

AMD GPU (Wavefront 기반)

AMD GPU는 64개(RDNA 아키텍처는 32개)의 쓰레드를 하나의 Wavefront로 묶어 처리합니다. NVIDIA와 유사하게 Wavefront Divergence 문제가 발생할 수 있으며, 원-핫 마스크 방식을 통해 이를 회피할 수 있습니다.

AMD의 GCN과 RDNA 아키텍처는 벡터 ALU가 잘 발달되어 있어 SIMD 연산에 강점이 있습니다. 특히 dot 연산은 하드웨어 수준에서 최적화된 명령어로 컴파일되므로 매우 빠르게 실행됩니다.

ARM Mali GPU (타일 기반)

ARM Mali GPU는 타일 기반 렌더링을 사용하며, Warp 크기가 벤더에 따라 다릅니다(Mali-G78은 16 레인). 모바일 GPU는 전력 효율이 중요하므로 분기문으로 인한 추가 명령어 실행이 배터리 소모에 직접적인 영향을 미칩니다.

Mali GPU는 FP16(half) 연산에 특히 최적화되어 있습니다. 원-핫 마스크 방식을 half 정밀도로 구현하면 메모리 대역폭과 전력 소비를 더욱 줄일 수 있습니다.

Intel GPU (EU 기반)

Intel GPU는 Execution Unit(EU) 기반 아키텍처를 사용하며, 각 EU는 여러 쓰레드를 SIMD 방식으로 처리합니다. Intel의 셰이더 컴파일러는 uniform 분기를 어느 정도 최적화하지만, 동적 분기에서는 여전히 성능 저하가 발생합니다.

최신 Arc GPU는 벡터 연산 처리 능력이 크게 향상되었으며, 원-핫 마스크 같은 수학적 기법을 효율적으로 실행할 수 있습니다.


컴파일된 어셈블리 비교

셰이더가 실제로 어떻게 컴파일되는지 확인하면 최적화 효과를 더 명확히 이해할 수 있습니다.

분기문 방식의 어셈블리 (Mali GPU 예시)

// if (_OutlineVertexColorChannel == 0) 분기
TEQ      r0.x, #0.0
BEQ      .L_channel_0
TEQ      r0.x, #1.0
BEQ      .L_channel_1
TEQ      r0.x, #2.0
BEQ      .L_channel_2
B        .L_channel_3

.L_channel_0:
MOV      r1.x, r2.x    // vertexColor.r
B        .L_end

.L_channel_1:
MOV      r1.x, r2.y    // vertexColor.g
B        .L_end

.L_channel_2:
MOV      r1.x, r2.z    // vertexColor.b
B        .L_end

.L_channel_3:
MOV      r1.x, r2.w    // vertexColor.a

.L_end:
// 계속...

분기문 방식은 여러 비교 명령어(TEQ)와 분기 명령어(BEQ, B)를 사용합니다. Warp 내에서 다른 경로를 실행하는 쓰레드가 있으면 모든 경로를 순차적으로 실행해야 합니다.

원-핫 마스크 방식의 어셈블리 (Mali GPU 예시)

// float4(0.0, 1.0, 2.0, 3.0) - c
VMOV     r3, {0.0, 1.0, 2.0, 3.0}
VSUB     r3, r3, r0.xxxx

// abs(...)
VABS     r3, r3

// 1.0 - abs(...)
VMOV     r4, {1.0, 1.0, 1.0, 1.0}
VSUB     r3, r4, r3

// saturate(...)
VMAX     r3, r3, #0.0
VMIN     r3, r3, #1.0

// dot(vertexColor, mask)
VDOT     r1.x, r2, r3

원-핫 마스크 방식은 분기 없이 벡터 연산만을 사용합니다. 모든 명령어가 SIMD로 실행되며, Warp 내 모든 쓰레드가 동일한 경로를 따릅니다.

명령어 수 비교

단순 명령어 수만 보면 분기문 방식(약 10-15개)과 원-핫 마스크 방식(약 7-8개)이 비슷하거나 원-핫이 적을 수도 있습니다. 하지만 실제 실행 시간은 크게 다릅니다. 분기문 방식은 Warp Divergence 발생 시 최악의 경우 4배까지 늘어날 수 있지만, 원-핫 마스크 방식은 항상 일정한 시간에 실행됩니다.

RenderDoc 또는 Nsight Graphics로 확인하기

실제 프로젝트에서 어셈블리 코드를 확인하려면 다음 도구를 사용할 수 있습니다.

NVIDIA (Nsight Graphics): 셰이더 디버거에서 SASS(실제 GPU 어셈블리) 코드를 볼 수 있습니다. Warp 실행 통계와 함께 분기로 인한 divergence를 시각적으로 확인할 수 있습니다.

AMD (RenderDoc + Radeon GPU Profiler): RenderDoc으로 셰이더를 캡처하고 RGP에서 Wavefront occupancy와 ALU 사용률을 분석할 수 있습니다.

Mali (Mali Offline Compiler): 커맨드라인 도구로 셰이더를 컴파일하여 명령어 수, 레지스터 사용량, 사이클 추정치를 확인할 수 있습니다.

malisc --core Mali-G78 --vertex shader.vert --fragment shader.frag

참고: 이 기법은 Unity URP/HDRP, Unreal Engine의 커스텀 셰이더 노드, ShaderGraph의 커스텀 함수 노드 등 어디에서나 활용할 수 있습니다. 특히 모바일 프로젝트나 VR/AR 프로젝트처럼 성능이 중요한 경우 이러한 미세 최적화가 누적되어 큰 차이를 만들어냅니다.


ShaderGraph 커스텀 함수 예제

Unity ShaderGraph에서 커스텀 함수 노드를 사용하여 이 최적화 기법을 적용할 수 있습니다. 먼저 ShaderGraph에서 우클릭한 후 Create Node → Custom Function을 선택하여 새로운 커스텀 함수 노드를 생성합니다.

함수 기본 설정

생성된 커스텀 함수 노드의 이름을 SelectVertexColorChannel로 지정하고, Type은 String으로 설정합니다.

입력 및 출력 파라미터 설정

입력 파라미터로는 VertexColor(Vector4 타입)와 ChannelIndex(Float 타입)가 필요합니다. VertexColor는 버텍스 컬러 데이터를 받아오고, ChannelIndex는 선택할 채널의 인덱스를 나타냅니다(0은 R, 1은 G, 2는 B, 3은 A 채널). 출력 파라미터는 Out(Float 타입)으로 설정하여 선택된 채널의 값을 반환합니다.

함수 본문 코드

void SelectVertexColorChannel_float(float4 VertexColor, float ChannelIndex, out float Out)
{
    float c = ChannelIndex;
    float4 mask = saturate(1.0 - abs(float4(0.0, 1.0, 2.0, 3.0) - c));
    Out = dot(VertexColor, mask);
}

이 코드는 앞서 설명한 원-핫 마스크 기법을 그대로 구현한 것입니다. ChannelIndex 값에 따라 자동으로 해당하는 채널만 1.0으로 설정된 마스크를 생성하고, 내적 연산을 통해 원하는 채널의 값을 추출합니다.

모바일 최적화 버전

모바일 플랫폼에서 더 나은 성능을 원한다면 half 정밀도 버전을 추가로 작성할 수 있습니다. half 정밀도는 float보다 메모리와 연산량이 적어 모바일 GPU에서 더 효율적입니다.

void SelectVertexColorChannel_half(half4 VertexColor, half ChannelIndex, out half Out)
{
    half c = ChannelIndex;
    half4 mask = saturate(1.0 - abs(half4(0.0, 1.0, 2.0, 3.0) - c));
    Out = dot(VertexColor, mask);
}

ShaderGraph에서 노드 연결하기

커스텀 함수를 생성한 후 ShaderGraph에서 실제로 사용하려면 몇 가지 노드를 연결해야 합니다. 먼저 Vertex Color 노드를 추가하고 그 출력을 커스텀 함수의 VertexColor 입력에 연결합니다. 그다음 Float 타입의 머티리얼 프로퍼티를 생성하여(예를 들어 "Outline Channel"이라는 이름으로) 커스텀 함수의 ChannelIndex 입력에 연결합니다. 마지막으로 커스텀 함수의 Out 출력을 원하는 곳에 연결하면 됩니다. 예를 들어 아웃라인 두께를 조절하는 Multiply 노드에 연결할 수 있습니다.


이 글은 메이즈라인 프러덕션의 실제 프로젝트 경험과 기술 연구를 토대로 작성되었습니다.

profile
테크아트 컨설팅 전문 회사 "메이즈라인" 입니다.

0개의 댓글