TECHARTNOMAD | TECHARTFLOWIO.COM

MAZELINE TOPIC

GPU-Driven Renderer에서의 Heterogeneous AoS Instance Encoding

jplee 2025. 10. 23. 15:44

메이즈라인 velog 미러.


오늘도 지하철을 저 처럼 한 시간씩 타는 직장동료분들을 위한 읽을 거리를 추가 했습니다.

엔지니어 Zino의 글을 읽고 추가로 정리한 내용입니다.

Heterogenous AoS instance encoding for a GPU-driven renderer


대규모 오픈 월드 게임을 만들다 보면 한 가지 딜레마에 부딪힙니다. 수만 개의 나무, 건물, 캐릭터를 화면에 렌더링해야 하는데, 각 오브젝트마다 필요한 데이터가 전부 다릅니다. 어떤 오브젝트는 단순한 정적 메시만 있으면 되지만, 어떤 것은 스키닝 데이터, 복잡한 머티리얼, LOD 정보 등 방대한 데이터를 요구합니다.

이런 상황에서 Heterogeneous AoS(Array of Structures) instance encoding이 GPU-driven rendering 파이프라인의 핵심 해결책으로 자리잡고 있습니다. Ubisoft Montreal의 Assassin's Creed Unity는 이 기법으로 이전 작품 대비 10배 많은 오브젝트를 렌더링하면서도 CPU 사용량을 75% 수준으로 유지했습니다. Epic Games의 Unreal Engine 5 Nanite 시스템은 수억 개의 폴리곤을 실시간으로 처리하며 이 개념을 한 단계 더 발전시켰습니다.

GPU-driven rendering의 패러다임 전환

전통적인 렌더링 파이프라인에서는 CPU가 어떤 오브젝트를 그릴지 결정하고, 각 오브젝트마다 draw call을 발행했습니다. 그런데 이 방식은 근본적인 한계가 있었습니다. CPU는 순차적으로 작동하기 때문에, 오브젝트가 수만 개가 되면 draw call overhead만으로도 프레임 레이트가 바닥을 칩니다.

GPU-driven rendering은 이 문제를 근본적으로 해결합니다. GPU 자체가 어떤 오브젝트를 렌더링할지 결정하고, culling과 LOD 선택, 심지어 draw command 생성까지 모두 GPU compute shader에서 처리합니다. CPU-GPU 왕복 지연을 제거하고 GPU의 대규모 병렬 처리 능력을 최대한 활용하는 것이죠.

핵심은 간접 그리기(indirect drawing)입니다. DirectX의 DrawIndexedInstancedIndirect나 Vulkan의 vkCmdDrawIndexedIndirect를 사용하면, draw call 파라미터를 GPU 버퍼에 저장하고 GPU가 직접 읽어서 실행합니다. Vulkan 1.2의 DrawIndirectCount는 GPU가 draw call 개수까지 결정할 수 있게 해줍니다. CPU는 단순히 "이 버퍼를 읽어서 그려"라고만 지시하면 되고, 실제로 몇 개를 그릴지는 GPU의 culling shader가 결정합니다.

SIGGRAPH 2015의 Ubisoft 발표를 보면, Assassin's Creed Unity는 25만 개의 오브젝트를 단일 Jaguar 코어에서 0.2ms만에 처리하면서도 GPU에서 20-40%의 삼각형을 culling할 수 있었습니다. 이게 실무에서 GPU-driven이 얼마나 강력한지 보여주는 증거입니다.

Heterogeneous instance encoding의 핵심 아이디어

전통적인 uniform instancing에서는 모든 인스턴스가 동일한 구조를 가집니다. 같은 메시, 같은 버텍스 레이아웃, 고정된 크기의 attribute 구조체를 사용하죠. 그런데 실제 게임 세계는 훨씬 복잡합니다.

정적인 바위는 transform matrix만 있으면 되지만, 애니메이션되는 캐릭터는 스키닝 데이터가 필요하고, 복잡한 머티리얼을 가진 오브젝트는 다수의 텍스처 참조가 필요합니다. Heterogeneous instance encoding은 이런 다양한 요구사항을 효율적으로 처리하는 데이터 구조 패턴입니다.

먼저 AoS(Array of Structures)와 SoA(Structure of Arrays)의 차이를 알아야 합니다. AoS는 각 오브젝트의 모든 데이터를 하나의 구조체로 묶어서 배열로 저장합니다:

struct Particle {
    vec3 position;
    vec3 velocity;
    float mass;
};
Particle particles[10000];

반면 SoA는 각 필드를 별도의 배열로 분리합니다:

struct ParticleSystem {
    vec3 positions[10000];
    vec3 velocities[10000];
    float masses[10000];
};

GPU 아키텍처 관점에서 이 둘의 성능 차이는 memory coalescing에 달려 있습니다. NVIDIA 문서를 보면, GPU의 warp(32개 스레드)가 연속된 메모리 주소에 접근할 때 하나의 128바이트 캐시 라인으로 coalescing되어 메모리 트랜잭션이 최소화됩니다.

실험 결과를 보면 stride-1 접근(coalesced)은 206 GB/s 처리량을 달성한 반면, stride-32 접근은 15.2 GB/s로 93%나 감소했습니다. 이게 메모리 접근 패턴이 얼마나 중요한지 보여줍니다.

그런데 instance 데이터의 경우 어떤 레이아웃이 좋을까요? 핵심은 접근 패턴입니다. GPU의 각 스레드가 하나의 인스턴스를 처리할 때 그 인스턴스의 모든 데이터를 함께 사용한다면 AoS가 유리합니다. 연속된 스레드들이 연속된 인스턴스 구조체를 읽으면서 자연스럽게 coalescing이 발생하기 때문입니다.

여기서 "heterogeneous"의 의미가 드러납니다. 인스턴스 구조체 자체는 고정된 크기의 uniform AoS layout을 유지하지만, 각 인스턴스가 참조하는 데이터의 종류와 크기는 다양합니다.

Melba 엔진이 보여준 접근 방식이 좋은 예입니다. 64바이트 크기의 uniform instance descriptor를 사용하면서도, 각 인스턴스가 서로 다른 render item, material, geometry를 가리키도록 설계했습니다:
Melba 엔진은 배틀그라운드(PUBG)의 창시자인 Brendan Greene(PLAYERUNKNOWN)이 설립한 게임 스튜디오에서 개발 중인 차세대 게임 엔진입니다.

이 글에서는 Melba 엔진이 GPU-driven rendering에서 heterogeneous instance encoding을 구현한 방식을 예시로 다루고 있습니다. 특히 64바이트 크기의 uniform instance descriptor를 사용하면서도, 각 인스턴스가 서로 다른 render item, material, geometry를 가리킬 수 있도록 설계한 점이 핵심입니다.

Melba 엔진은 대규모 오픈 월드 게임을 목표로 개발되고 있으며, 메모리 효율성과 렌더링 성능 최적화에 중점을 두고 있는 것으로 보입니다.

struct sb_render_instance_t {
    float4x3 m_transform;        // 48 bytes
    uint m_render_item_id;       // 4 bytes - indirection
    uint m_entity_id;            // 4 bytes
    uint m_user_data;            // 4 bytes
    float m_world_scale;         // 4 bytes
};  // Total: 64 bytes

이 구조체의 핵심은 m_render_item_id입니다. 이 ID로 실제 geometry, material, culling data가 담긴 render item 구조체를 간접 참조합니다. Render item 자체는 100-200바이트로 훨씬 크지만, 여러 인스턴스가 공유할 수 있습니다. 최종적으로는 계층적인 indirection 구조가 됩니다:

Instance (64B) → Render Item (200B) → Material (100B) → Textures
                              ↓
                          Geometry Buffers

메모리 대역폭과 캐시 효율성

이런 heterogeneous 구조가 해결하는 첫 번째 문제는 메모리 대역폭 낭비입니다. 전통적인 uniform batching에서는 모든 인스턴스가 동일한 크기의 데이터를 가져야 하므로, 가장 복잡한 인스턴스에 맞춰 구조체 크기가 결정됩니다. 단순한 정적 메시도 스키닝 데이터를 위한 빈 공간을 차지하게 됩니다.

Ubisoft Kiev의 Trials Rising GDC 2019 발표를 보면, 이들은 인스턴스를 immobile(64.21%), mobile(25.09%), mutable(3.74%), skinned(6.96%)로 분류하고 각 카테고리마다 다른 업데이트 주기와 데이터 크기를 사용했습니다. 결과적으로 static 인스턴스는 71%, skinned 인스턴스는 59%의 메모리를 절약했고, CPU synchronization 비용은 7.18ms에서 5.89ms로 감소했습니다.

캐시 효율성 측면에서도 중요한 이점이 있습니다. 현대 GPU의 L1 캐시 라인은 128바이트이고, L2 캐시는 수 MB 규모입니다. 64바이트 instance descriptor는 L1 캐시 라인 하나에 2개가 딱 들어가므로, 32개 스레드로 구성된 warp가 32개 인스턴스를 읽을 때 정확히 16개 캐시 라인만 필요합니다.

더 중요한 것은 render item과 material 데이터가 여러 인스턴스 간에 공유되므로 L2 캐시 재사용률이 극도로 높아진다는 점입니다. 1000개의 나무 인스턴스가 모두 같은 render item을 참조한다면, render item은 한 번만 읽으면 되고 이후로는 L2 캐시에서 바로 가져올 수 있습니다.

실제 게임 엔진 구현 사례

Assassin's Creed Unity

Assassin's Creed Unity의 구현은 GPU-driven rendering의 이정표입니다. 이들은 mesh cluster rendering이라는 개념을 도입했는데, 전체 메시를 64개 삼각형 단위의 클러스터로 분해하고 각 클러스터마다 독립적으로 culling을 수행했습니다.

각 클러스터는 bounding cone 정보를 가지고 있어서 backface culling을 GPU에서 수행할 수 있었고, 이는 10-30%의 삼각형을 제거하는 효과를 냈습니다. 더 인상적인 것은 static triangle backface culling입니다. 클러스터 중심에서 본 cubemap의 각 픽셀에 대해 어떤 삼각형이 보이는지 미리 계산해두고, 렌더링 시에는 카메라 방향에 따라 cubemap을 lookup해서 삼각형 가시성을 판단합니다. Shadow rendering에서는 무려 30-80%의 삼각형이 culling됩니다.

Frostbite 엔진

Ubisoft의 Frostbite 엔진 GDC 2016 발표를 보면 per-triangle culling을 한 단계 더 발전시켰습니다. Orientation culling, depth culling, small triangle culling, frustum culling을 모두 조합해서 삼각형 수준의 세밀한 culling을 수행했고, parallel prefix sum을 사용해 barrier 없이 index buffer를 compaction했습니다.

특히 주목할 점은 software Z-buffer 활용입니다. CPU에서 다음 GPU 프레임을 위해 미리 depth buffer를 렌더링해두고 이를 occlusion culling과 Hi-Z pyramid의 초기값으로 사용했습니다. 1프레임 latency가 생기지만 bounding volume을 약간 키우는 것으로 false negative를 최소화할 수 있습니다.

Unreal Engine 5 Nanite

Unreal Engine 5의 Nanite 시스템은 virtual geometry의 완성형입니다. Nanite는 128개 삼각형으로 구성된 클러스터를 기본 단위로 사용하고, 각 메시를 DAG(Directed Acyclic Graph) 구조의 LOD hierarchy로 표현합니다.

런타임에 GPU는 이 DAG를 순회하면서 화면상 크기에 따라 적절한 LOD 레벨의 클러스터들을 선택하고, 최종적으로는 화면 전체에서 약 2500만 개의 삼각형을 일정하게 유지합니다. Valley of the Ancients 데모에서는 약 2.5ms만에 모든 Nanite 메시를 culling하고 rasterization할 수 있었습니다. 100만 개 이상의 인스턴스를 처리할 수 있다는 것도 놀랍습니다.

Unity GPU Instancing

Unity의 GPU instancing은 비교적 전통적인 접근 방식을 유지하면서도 실용적입니다. GPU instancing을 활성화하면 같은 메시와 머티리얼을 사용하는 GameObject들을 하나의 draw call로 렌더링합니다.

Per-instance 데이터는 UNITY_INSTANCING_CBUFFER에 정의하고 UNITY_ACCESS_INSTANCED_PROP 매크로로 접근합니다:

UNITY_INSTANCING_BUFFER_START(Props)
    UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
UNITY_INSTANCING_BUFFER_END(Props)

// Fragment Shader
fixed4 color = UNITY_ACCESS_INSTANCED_PROP(Props, _Color);

GPU 아키텍처별 특성

NVIDIA Ampere

NVIDIA의 최신 Ampere 아키텍처는 SM(Streaming Multiprocessor)당 128KB의 L1/Shared Memory를 제공하고, 최대 64개의 warp(2048 스레드)를 동시에 실행할 수 있습니다.

Compute Capability 3.5 이상부터는 global memory load가 기본적으로 L1 캐시를 우회하고 32바이트 단위로 coalescing합니다. 이는 scattered access pattern에서 over-fetch를 줄이기 위함입니다. 128바이트 stride로 접근하면 L1을 사용할 때는 warp당 4096바이트를 읽어야 하지만, L2만 사용하면 1024바이트만 읽으면 되므로 4배의 bandwidth 절약이 가능합니다.

AMD RDNA 2

AMD의 RDNA 2 아키텍처는 상당히 다른 접근을 취합니다. 가장 큰 차이는 128MB Infinity Cache입니다. 이 on-die L3 캐시는 1986.6 GB/s의 대역폭을 제공하며, 256비트 메모리 버스로도 384비트 버스에 준하는 성능을 낼 수 있게 합니다.

RDNA는 Wave32(32개 스레드)를 네이티브로 지원하는데, 이는 NVIDIA의 warp와 크기가 같아서 많은 알고리즘이 양쪽 플랫폼에서 유사하게 작동합니다. 다만 GCN 아키텍처는 여전히 Wave64를 사용하므로, 레거시 플랫폼 지원이 필요하다면 이를 고려해야 합니다.

Occupancy 최적화

Occupancy 관점에서 보면, 높은 occupancy가 반드시 높은 성능을 의미하지는 않습니다. 일반적으로 60-80% occupancy가 최적입니다. 100% occupancy를 달성하려고 레지스터 사용을 극단적으로 줄이면 오히려 instruction dispatch가 비효율적이 될 수 있습니다.

실측 결과를 보면, NVIDIA RTX A6000는 17.6 TB/s의 L1 bandwidth를 달성했고, AMD RX 6900 XT는 23.4 TB/s를 기록했습니다. 이는 이론적 최대치의 80-90%에 해당하는 우수한 효율입니다.

Quantization과 데이터 압축

Quaternion Compression

Instance data의 크기를 줄이는 것은 메모리 대역폭을 절약하는 가장 직접적인 방법입니다. Quaternion compression은 그중에서도 가장 효과적인 기법입니다.

"Smallest three" 기법은 quaternion의 4개 컴포넌트 중 절댓값이 가장 큰 것을 찾아서 인덱스로 저장하고, 나머지 3개만 quantize합니다. x²+y²+z²+w²=1 제약을 이용해 마지막 컴포넌트를 재구성합니다.

각 컴포넌트를 9비트로 인코딩하면 총 29비트(3×9 + 2비트 인덱스)로 압축할 수 있어서, 128비트에서 77% 절약이 가능합니다:

struct PackedQuat {
    uint16_t a : 9;
    uint16_t b : 9;
    uint16_t c : 9;
    uint16_t index : 2;
    uint16_t sign : 1;
};

void PackQuaternion(float x, float y, float z, float w, PackedQuat* out) {
    // Find largest component
    float abs_vals[4] = {fabs(x), fabs(y), fabs(z), fabs(w)};
    int largest = 0;
    for(int i = 1; i < 4; i++) {
        if(abs_vals[i] > abs_vals[largest]) largest = i;
    }

    // Make positive and pack smallest three
    float sign = ((&x)[largest] >= 0) ? 1.0f : -1.0f;
    const float scale = 511.0f / 0.707107f;
    int j = 0;
    for(int i = 0; i < 4; i++) {
        if(i != largest) {
            float val = (&x)[i] * sign;
            (&out->a)[j++] = (int)(val * scale + 0.5f) + 511;
        }
    }
    out->index = largest;
}

Matrix Decomposition

Matrix decomposition은 transform을 Scale-Rotation-Translation(SRT)로 분해해서 저장하는 기법입니다. 4×3 matrix는 48바이트가 필요하지만 SRT로 분해하면 rotation(quaternion 8바이트), translation(FP16 vec3 6바이트), scale(logarithmic encoding 2바이트)로 총 16바이트면 충분합니다. 66%의 절약입니다.

Vertex shader에서 재구성할 때는 FMA(Fused Multiply-Add) 한 번으로 충분하므로 compute overhead도 무시할 수 있습니다. 실제로 이 기법은 1400개 캐릭터와 1억 개 버텍스를 60fps로 렌더링하는 Toy Renderer에서 L1 캐시 병목을 완전히 제거했습니다.

Normal과 Tangent 압축

QTangent는 전체 tangent space(Normal, Tangent, Bitangent)를 하나의 quaternion으로 인코딩합니다. 16비트 SNORM으로 인코딩하면 64비트(8바이트)가 되어 원래 36바이트에서 78% 절약됩니다.

대안으로는 octahedral mapping이 있습니다. Normal vector를 octahedron에 projection하고 다시 2D 평면으로 펼치면, 2개의 값만으로 모든 방향을 표현할 수 있습니다. SNORM8로 인코딩하면 겨우 2바이트로 normal을 표현할 수 있어 83% 절약입니다:

// Encode
vec2 EncodeOctahedral(vec3 n) {
    float L1 = abs(n.x) + abs(n.y) + abs(n.z);
    vec2 res = n.xy / L1;
    if(n.z < 0) res = (1 - abs(res.yx)) * sign(res);
    return res;
}

// Decode (vertex shader)
vec3 DecodeOctahedral(vec2 e) {
    vec3 v = vec3(e, 1 - abs(e.x) - abs(e.y));
    float t = max(-v.z, 0);
    v.xy += t * -sign(v.xy);
    return normalize(v);
}

Ray Tracing과 Acceleration Structure

Ray tracing을 지원하려면 instance data를 acceleration structure에도 반영해야 합니다. TLAS(Top-Level Acceleration Structure)의 각 인스턴스는 3×4 transform matrix와 함께 24비트 custom index를 가질 수 있습니다. 이 custom index에 material ID를 저장하면, closest hit shader에서 즉시 material을 fetch할 수 있습니다:

// Closest hit shader
void main() {
    uint materialID = gl_InstanceCustomIndexEXT;
    uint instanceID = gl_InstanceID;
    InstanceData instance = instanceBuffer[instanceID];
    Material material = materialBuffer[materialID];
    // Shading...
}

Ray tracing에서 가장 중요한 최적화는 acceleration structure build flags입니다. Static geometry는 PREFER_FAST_TRACE 플래그를 사용해 build 시간이 오래 걸리더라도 traversal 성능을 최대화해야 합니다. Dynamic geometry는 PREFER_FAST_BUILD로 빠른 rebuild를 지원하고, deforming geometry는 ALLOW_UPDATE로 refit만 가능하게 합니다.

특히 static geometry에는 ALLOW_COMPACTION을 사용해 빌드 후 compaction을 수행하면 메모리를 30-50% 절약할 수 있습니다. 전체 AS 빌드는 프레임당 2ms 이내로 제한하고, 가능하면 async compute queue로 분리해서 graphics queue와 병렬 실행하는 것이 좋습니다.

Ray payload 최적화도 중요합니다. Payload는 TraceRay 호출 간에 유지되는 데이터인데, 32바이트를 초과하면 register spill이 발생해 성능이 급격히 떨어집니다. FP16, UNORM8 등을 적극 활용하고, boolean 값들은 bitfield로 묶어야 합니다.

Mesh Shader와 Modern Pipeline

Mesh shader는 전통적인 vertex input assembler를 완전히 대체하는 compute-based geometry pipeline입니다. Compute programming model을 사용하면서도 rasterizer로 직접 primitive를 출력할 수 있습니다. 이는 heterogeneous instance encoding과 완벽하게 결합됩니다.

Meshlet은 mesh shader의 기본 단위입니다. NVIDIA의 권장사항은 64개의 unique vertex와 126개의 삼각형입니다. 이는 384바이트 캐시 라인에 딱 들어가는 크기입니다:

struct MeshletDesc {
    uint32_t vertexCount, primCount;
    uint32_t vertexBegin, primBegin;
    vec3 boundsMin, boundsMax;
    vec4 normalCone;  // For backface culling
};

Task shader는 meshlet 단위로 early culling을 수행합니다. Subgroup intrinsic을 활용하면 warp 내에서 visible meshlet만 compact하게 출력할 수 있습니다:

taskNV out Task { uint baseID; uint8_t subIDs[32]; } OUT;

void main() {
    bool render = !EarlyCull(meshletDescs[gl_GlobalInvocationID.x]);
    uvec4 vote = subgroupBallot(render);

    if (gl_LocalInvocationID.x == 0) {
        gl_TaskCountNV = subgroupBallotBitCount(vote);
    }

    if (render) {
        uint idx = subgroupBallotExclusiveBitCount(vote);
        OUT.subIDs[idx] = gl_LocalInvocationID.x;
    }
}

Virtual Geometry와 Nanite의 교훈

Unreal Engine 5의 Nanite는 virtual geometry의 정점입니다. 핵심 아이디어는 간단합니다. 모든 메시를 cluster 단위로 분해하고, LOD hierarchy를 DAG로 구성한 뒤, 런타임에 GPU가 적절한 cluster들을 선택해서 렌더링하는 것입니다.

하지만 Nanite의 모든 기능을 구현하지 않아도 60-70% 정도의 이점은 얻을 수 있습니다. 필수적인 것은 meshlet 기반 cluster rendering, visibility buffer, two-pass occlusion culling, GPU-driven indirect draw, 기본적인 LOD selection입니다.

Visibility buffer는 전통적인 G-buffer와 다릅니다. G-buffer는 각 픽셀에 material properties(albedo, normal, roughness 등)를 저장하지만, visibility buffer는 단순히 cluster ID와 triangle ID만 저장합니다. 이후 compute shader에서 필요한 픽셀들만 material을 evaluate합니다:

// Visibility buffer pass
out uint visBuffer;
void main() {
    uint clusterID = gl_DrawID;
    uint triangleID = gl_PrimitiveID;
    visBuffer = (clusterID << 7) | triangleID;
}

// Material evaluation compute shader
void main() {
    uint packed = visBuffer[pixel];
    uint clusterID = packed >> 7;
    uint triangleID = packed & 0x7F;

    // Fetch triangle vertices
    Cluster cluster = clusters[clusterID];
    Triangle tri = GetTriangle(cluster, triangleID);

    // Interpolate attributes
    vec3 barycentrics = ComputeBarycentrics(pixel);
    VertexData vData = InterpolateVertex(tri, barycentrics);

    // Evaluate material
    Material mat = materials[cluster.materialID];
    GBuffer gbuffer = EvaluateMaterial(mat, vData);
    WriteGBuffer(pixel, gbuffer);
}

이 접근법의 장점은 overdraw가 cheap하다는 것입니다. Visibility buffer는 uint 하나만 쓰므로 fill rate가 매우 높고, material evaluation은 최종적으로 visible한 픽셀에 대해서만 수행됩니다.

실무에서의 함정들

GPU-driven rendering을 구현하다 보면 여러 함정에 빠지기 쉽습니다.

Acceleration structure build blocking

AS build가 graphics queue를 block하는 문제입니다. 매 프레임 BLAS를 rebuild하면 2-3ms가 소요되는데, 이를 graphics queue에서 동기적으로 수행하면 rendering이 멈춥니다. 해결책은 async compute queue를 사용하는 것입니다.

Indirection overhead

Heterogeneous instance encoding은 여러 단계의 pointer chasing을 요구합니다. Instance → Render Item → Material → Textures 순으로 참조하면 각 단계마다 memory latency가 발생합니다. 다행히 GPU는 massive parallelism으로 latency를 hiding할 수 있고, render item과 material은 여러 인스턴스가 공유하므로 L2 캐시 hit rate가 매우 높습니다.

Warp divergence

Heterogeneous batch에서 다른 타입의 인스턴스들이 섞여 있으면 shader code에서 branching이 발생합니다. 해결책은 type별로 batching하고, shader는 compile-time constant로 특화되도록 하는 것입니다.

Small instance overhead

화면상 1픽셀 미만의 작은 인스턴스가 많으면 culling과 setup overhead가 이득을 초과할 수 있습니다. UE 5.1에서 추가된 "Preserve Area" 플래그가 이를 해결합니다.

Any-hit shader 남용

Alpha-tested geometry를 any-hit shader로 처리하면 ray traversal 성능이 크게 떨어집니다. 가능하면 geometry를 OPAQUE로 mark하고, opacity micromap(OMM)을 사용하는 것이 2-3배 빠릅니다.

구현 로드맵

실제로 heterogeneous AoS instance encoding을 구현한다면 이런 순서를 추천합니다.

1단계: Indirect drawing infrastructure

DrawIndirectCount 지원을 확인하고, fallback으로 일반 DrawIndirect를 준비합니다. Instance buffer와 ObjectID system을 설계하고, draw command generation compute shader를 작성합니다. 간단한 frustum culling만 구현해도 충분합니다.

2단계: Advanced culling

Depth pyramid generator를 구현하고 Hi-Z occlusion culling을 적용합니다. Async compute queue로 분리해서 culling과 rendering을 overlap시킵니다.

3단계: Ray tracing (필요시)

BLAS/TLAS builder를 구현하고, instance descriptor에 custom data를 설정합니다. Build flag를 content type별로 최적화하고, static geometry에 compaction을 적용합니다.

4단계: Advanced features

Mesh shader 지원을 평가하고, meshlet generation pipeline을 구축합니다. Task shader culling을 추가하고, virtual geometry 도입을 검토합니다.

5단계: Streaming과 LOD

LOD selection system을 구현하고, texture streaming을 추가합니다. Memory budget을 설정하고 streaming latency를 profile합니다.

측정 가능한 성능 목표

실무에서 기대할 수 있는 성능 목표입니다:

  • Draw call count: 프레임당 100개 미만
  • GPU culling time: rasterization 1ms 미만, ray tracing AS build 2ms 미만
  • Instance count per batch: 수백 개 이상
  • Occlusion culling: 30-50% elimination
  • Quantization으로 instance data 50-75% 절약
  • Ray tracing AS compaction으로 30-50% 메모리 절약

마치며

Heterogeneous AoS instance encoding은 단순한 데이터 구조가 아니라 현대 GPU-driven rendering의 철학을 담고 있습니다. GPU의 massive parallelism과 compute shader의 유연성을 활용하면서도, 메모리 coalescing과 cache hierarchy를 존중하는 것입니다.

64바이트의 uniform instance descriptor는 hardware-friendly하면서도, indirection을 통해 heterogeneous한 render item, material, geometry를 가리킬 수 있습니다. 이는 flexibility와 performance의 절묘한 균형입니다.

실제 구현 사례들을 보면 이 기법이 얼마나 강력한지 알 수 있습니다. Assassin's Creed Unity는 2014년에 이미 25만 개 오브젝트를 렌더링했고, Unreal Engine 5 Nanite는 100만 개 이상의 인스턴스를 처리합니다. 이런 성과들은 단순히 더 빠른 하드웨어가 아니라, 똑똑한 알고리즘과 데이터 구조 설계에서 나옵니다.

앞으로의 발전 방향도 명확합니다. DirectX 12의 Work Graphs는 GPU-driven pipeline을 더욱 유연하게 만들 것이고, mesh shader는 점점 더 표준이 될 것입니다. Virtual geometry는 Nanite를 넘어 더 많은 엔진에서 채택될 것이며, ray tracing의 대중화는 acceleration structure 최적화를 더욱 중요하게 만들 것입니다.

하지만 근본적인 원칙은 변하지 않습니다. GPU 아키텍처를 이해하고, 메모리 대역폭을 존중하며, 적절한 abstraction을 선택하는 것입니다. 실제 프로덕션에 적용할 때는 항상 profile first, optimize second를 기억하세요.

레퍼런스