TECHARTNOMAD | TECHARTFLOWIO.COM

TECH.ART.FLOW.IO

[번역] Unity에서 스킨드 메시의 GPU Driven 렌더링 구현

jplee 2026. 1. 3. 13:30

저자: 乐只人

서문

최근 프로젝트에서 SkinnedMeshGPU Driven 렌더링을 지원해야 하는 요구사항이 있었는데, 마침 얼마 전 Digital Dragons에서 발표된 Erik Jansson의 강연을 보게 되었습니다. 그중 Alan Wake 2의 GPU Driven SkinnedMesh 렌더링 소개가 있어, 아이디어를 정리하고 시도해 본 뒤 전체 구현 과정을 기록하게 되었습니다. 이 글에서는 GPU Driven 파이프라인의 기초 지식이나 관련 구축 구현에 대해서는 구체적으로 다루지 않습니다. 이러한 선행 지식이 없다면 다른 관련 기술 문서를 먼저 참고하여 이해한 후 이 글을 읽는 것을 추천합니다.

아래는 Alan Wake 2의 GPU Driven 렌더링 Meshlet 시각화 및 실제 효과 이미지입니다. 이미지 속 식생은 모두 GPU Bone으로 구동되며, 캐릭터 또한 GPU Driven SkinnedMesh입니다.

Meshlet 시각화

실제 렌더링 효과

GPU Skinning

Unity 내장 GPU Skinning은 GetVertexBuffer를 통해 스키닝 계산 후의 버텍스 데이터를 가져올 수 있지만, 제어 및 스케줄링을 마음대로 하기 어렵습니다. 따라서 먼저 자체적인 GPU Skinning 솔루션을 구현해야 합니다. Unity 공식 웹사이트에서 해당 버전의 스키닝 및 BlendShape 계산을 위한 Compute Shader를 다운로드할 수 있으며, 여기서는 간단한 계산 흐름을 먼저 소개하겠습니다.

Skinning 부분

먼저 골격 애니메이션(Skeletal Animation)의 스키닝 계산을 보겠습니다. 각 SkinnedMesh에 대해 3개의 ComputeBuffer를 유지 관리합니다.

  • vertexBuffer (각 버텍스 데이터 저장: position, normal, tangent)
  • skinInfluenceBuffer (각 버텍스의 스키닝 데이터 저장: 가중치, 본 인덱스)
  • boneMatrixBuffer (각 본의 변환 행렬 저장)

매 프레임마다 먼저 boneMatrixBuffer를 업데이트해야 합니다.

for (var i = 0; i < boneMatrices.Length; i++)
{
  boneMatrices[i] = rootBone.worldToLocalMatrix * bones[i].localToWorldMatrix * bindposes[i];
}
boneMatrixBuffer.SetData(boneMatrices);

그다음 이 세 개의 ComputeBuffer를 통해 스키닝 후의 버텍스 데이터를 계산할 수 있습니다. 아래는 해당 단순화된 Compute Shader 코드입니다.

[numthreads(64, 1, 1)]
void main(uint3 threadID : SV_DispatchThreadID, StructuredBuffer inVertices, StructuredBuffer inSkin, RWStructuredBuffer outVertices, StructuredBuffer inMatrices)
{
    const uint t = threadID.x;
    if (t >= g_VertCount) return;

    SkinInfluence si = inSkin[t];
    const MeshVertex vert = inVertices[t];
    float3 vPos = [vert.pos.xyz](<http://vert.pos.xyz>);
    float3 vNorm = [vert.norm.xyz](<http://vert.norm.xyz>);
    float3 vTang = [vert.tang.xyz](<http://vert.tang.xyz>);

    const float4x4 blendedMatrix = inMatrices[si.index0] * si.weight0 +
      inMatrices[si.index1] * si.weight1 +
      inMatrices[si.index2] * si.weight2 +
      inMatrices[si.index3] * si.weight3;

    outVertices[t].pos = mul(blendedMatrix, float4(vPos, 1)).xyz;
    outVertices[t].norm = mul(blendedMatrix, float4(vNorm, 0)).xyz;
    outVertices[t].tang = float4(mul(blendedMatrix, float4(vTang, 0)).xyz, vert.tang.w);
}

사실 구체적으로 언급하지 않은 디테일이 많습니다. 예를 들면:

  • 스키닝 계산 품질(4 Bones, 2 Bones, 1 Bone)을 동적으로 선택하여 연산 비용 절감.
  • Normal 및 Tangent 데이터 계산 필요 여부 구분 (일부 경우에는 법선이나 탄젠트 데이터가 필요하지 않음).
  • 거리나 중요도에 따라 스키닝 데이터를 분산 프레임(分帧, Time-slicing)으로 계산하여 일정 수준의 Animation LOD 구현.

이러한 구현 디테일은 이 글에서 과도하게 다루지 않겠습니다. 관심 있는 독자는 직접 구현해 보시기를 권장합니다.

BlendShape 부분

BlendShape 계산을 위해서는 모든 BlendShape Frame의 버텍스 변환 정보(vertexIdx, deltaPos, deltaNormal, deltaTangent)를 저장하는 inBlendShapeVertices 버퍼를 유지 관리해야 합니다. 매 프레임마다 전체 BlendShape를 순회하며 inBlendShapeVertices를 통해 각 BlendShape의 변환을 계산할 수 있습니다. 단순화된 Compute Shader 코드는 다음과 같습니다.

[numthreads(64, 1, 1)]
void main(uint3 threadID : SV_DispatchThreadID, RWStructuredBuffer inOutMeshVertices, StructuredBuffer inBlendShapeVertices)
{
    const uint t = threadID.x;
    if (t >= g_VertCount) return;

    BlendShapeVertex blendShapeVert = inBlendShapeVertices[t + g_FirstVert];
    const uint vertIndex = blendShapeVert.index;

    inOutMeshVertices[vertIndex].pos += blendShapeVert.pos * g_Weight;
    inOutMeshVertices[vertIndex].norm += blendShapeVert.norm * g_Weight;
    inOutMeshVertices[vertIndex].[tang.xyz](<http://tang.xyz>) += blendShapeVert.tang * g_Weight;
}

GPU BlendShape 계산에도 구체적으로 설명하지 않은 디테일이 있습니다.

  • Normal 및 Tangent 데이터 계산 필요 여부 구분.
  • BlendShape Frame 간의 보간(Transition) 계산.
  • 거리에 따라 동적으로 BlendShape 계산 수량 감소.

위의 작업들을 통해 매 프레임 SkinnedMesh의 스키닝 계산과 BlendShape 계산을 완료할 수 있으며, 절두체 선별(Frustum Culling), 분산 프레임 계산, 거리 LOD(본 개수 또는 BlendShape 수 감소) 등의 방법으로 성능을 최적화할 수도 있습니다.

Meshlet

GPU Driven 파이프라인에서 스킨드 메시와 정적 메시(Static Mesh)의 가장 큰 차이점은 고정된 Meshlet 데이터(바운딩 박스와 Normal Cone)를 미리 계산할 수 없다는 점입니다. SkinnedMesh는 애니메이션에 따라 매 프레임 변화하며, 각 Meshlet에 대응하는 바운딩 박스와 Normal Cone도 함께 변화하므로 미리 계산된 데이터를 사용하여 GPU 선별(Culling)을 수행할 수 없습니다. 이 문제를 해결하기 위해 두 가지 방안이 있습니다.

  1. 오프라인에서 미리 생성: 모든 애니메이션의 매 프레임마다 각 Meshlet의 바운딩 박스와 Normal Cone을 무식하게 전수 조사(Brute-force)하여 생성.
  2. 실시간으로 매 프레임마다 각 Meshlet의 바운딩 박스와 Normal Cone을 계산.

첫 번째 방안은 애니메이션 수가 적고 이미 알려진 경우에 적합하지만 범용적이지 않아 고려하지 않습니다. 두 번째 방안에서는 각 Meshlet의 바운딩 박스와 Normal Cone 데이터를 실시간으로 계산해야 합니다. 바운딩 박스와 Normal Cone은 Compute Shader를 통해 Meshlet의 모든 버텍스를 순회하여 계산할 수 있습니다. 예를 들어 64개의 삼각형과 64개의 버텍스를 가진 Meshlet을 사용할 때, 대응하는 Meshlet 바운딩 박스 계산 방법(단순화 버전)은 다음과 같습니다.

[numthreads(64, 1, 1)]
void ComputeMeshletBounds(uint3 threadID : SV_DispatchThreadID)
{
  // 버텍스 데이터 계산
  ......

  float3 boundMin = float3(0,0,0);
  float3 boundMax = float3(0,0,0);
  boundMin = WaveActiveMin(vertexPos);
  boundMax = WaveActiveMax(vertexPos);
  if (WaveIsFirstLane())// 첫 번째 Lane만 기록
  {
      meshlet.min = boundMin;
      meshlet.max = boundMax;
      _MeshletBuffer[meshletID] = meshlet;
  }

  // 기타 작업
  ......
}

위 코드에서는 Wave Intrinsics의 몇 가지 내장 함수를 사용했습니다. Wave Intrinsics는 D3D12의 Shader Model 6.0에서 도입된 것으로, Warp 내의 Lane(Thread로 근사하여 볼 수 있음) 간의 데이터 공유 및 동기화를 제어하는 데 사용됩니다. 예를 들어 WaveIsFirstLane은 현재 Lane이 Wave의 첫 번째 Active Lane(즉, 인덱스가 가장 작은 Lane)인지 판단할 수 있고, WaveActiveMin과 WaveActiveMax는 현재 Wave 내 모든 Active Lane의 최소값과 최대값을 가져올 수 있습니다. 버텍스 수만큼 Thread를 Dispatch하고, Group 내 Thread 수(현재 상황에서는 Wave 내 Lane 수)를 Meshlet의 버텍스 수로 설정하여 Wave 함수를 통해 현재 Meshlet의 바운딩 박스를 계산할 수 있습니다. Normal Cone의 계산도 유사하게 구현할 수 있으며, 이 글에서는 상세 설명을 생략합니다. 관심 있는 독자는 직접 구현해 보시기 바랍니다. Wave Intrinsics에 대한 자세한 내용은 Microsoft HLSL 문서를 참고하시기 바랍니다.

Bounds와 Normal Cone 데이터가 있으면 GPU Culling을 수행할 수 있습니다. 아래 이미지는 제가 Unity에서 절두체 선별(Frustum Culling)을 사용한 결과로, 절두체 밖의 Meshlet이 애니메이션 실행 중에도 올바르게 선별되는 것을 볼 수 있습니다.

Scene 뷰에서 Main Camera의 절두체 선별 표시:

Main Camera에서의 표시:

Unity에서 Wave Intrinsics를 사용하려면 먼저 Compute Shader에 #pragma use_dxc 코드를 추가하고, DX12로 Unity 에디터를 실행해야 합니다(Hub에서 프로젝트의 명령줄 인수에 -force-d3d12 추가). 이러한 구현은 다른 플랫폼과의 호환성이 떨어질 수 있습니다. Wave Intrinsics를 지원하지 않는 장치에서는 InterlockedMin과 InterlockedMax를 사용하여 WaveActiveMin과 WaveActiveMax를 대체하는 것을 고려할 수 있습니다. 현재 버텍스 데이터를 계산한 후 GroupMemoryBarrierWithGroupSync 함수로 Thread 간 데이터를 동기화하고, 다시 InterlockedMin과 InterlockedMax로 바운딩 박스 데이터를 계산합니다. 대응하는 바운딩 박스 데이터는 groupshared 배열에 저장하며, 단순화된 코드는 다음과 같습니다.

groupshared int minMax[6];
[numthreads(64, 1, 1)]
void ComputeMeshletBounds(uint3 threadID : SV_DispatchThreadID)
{
  // 버텍스 데이터 계산
  ......

  // Group 동기화
  GroupMemoryBarrierWithGroupSync();

  // InterlockedMin과 InterlockedMax는 uint만 지원하므로 변환 필요, factor 값은 임의 예시
  uint factor = 100000;
  InterlockedMin(minMax[0], factor * oPos.x);
  InterlockedMin(minMax[1], factor * oPos.y);
  InterlockedMin(minMax[2], factor * oPos.z);
  InterlockedMax(minMax[3], factor * oPos.x);
  InterlockedMax(minMax[4], factor * oPos.y);
  InterlockedMax(minMax[5], factor * oPos.z);
  if (threadIndexInGroup == 0)// 첫 번째 스레드만 기록
  {
      meshlet.min = float3(minMax[0] / 100000.0, minMax[1] / 100000.0, minMax[2] / 100000.0);
      meshlet.max = float3(minMax[3] / 100000.0, minMax[4] / 100000.0, minMax[5] / 100000.0);
      _MeshletBuffer[meshletID] = meshlet;
  }

  // 기타 작업
  ......
}

다량의 InterlockedMin과 InterlockedMax는 성능을 크게 저하시킬 수 있으므로, 저사양 기기나 모바일에서는 이를 계산하지 않고 대신 Instance의 바운딩 박스 데이터를 사용하는 대체 방안을 사용할 수 있습니다. 예시 의사 코드는 다음과 같습니다.

...
InstanceData data = _InstanceDataBuffer[instanceID];
meshlet.min = data.min;
meshlet.max = data.max;
_MeshletBuffer[meshletID] = meshlet;

UE5.5에서도 Nanite Skeletal Mesh를 지원하는데, 관련 소스 코드를 찾아보니 UE5.5에서도 유사한 구현 방식을 사용하고 있음을 확인했습니다. 해당 부분의 소스 코드도 첨부합니다.

if ((PrimitiveData.Flags & PRIMITIVE_SCENE_DATA_FLAG_SKINNED_MESH) != 0)
{
    // TODO: Nanite-Skinning - Fun hack to temporarily "fix" broken cluster culling and VSM
    // Set the cluster bounds for skinned meshes equal to the skinned instance local bounds
    // for clusters and also node hierarchy slices. This satisfies the constraint that all
    // clusters in a node hierarchy have bounds fully enclosed in the parent bounds (monotonic).
    // Note: We do not touch the bounding sphere in Bounds because that would break actual
    // LOD decimation of the Nanite mesh. Instead we leave these in the offline computed ref-pose
    // so that we get reasonable "small enough to draw" calculations driving the actual LOD.
    // This is not a proper solution, as it hurts culling rate, and also causes VSM to touch far
    // more pages than necessary. But it's decent in the short term during R&D on a proper calculation.
    Bounds.BoxExtent = InstanceData.LocalBoundsExtent;
    Bounds.BoxCenter = InstanceData.LocalBoundsCenter;
}

언리얼 공식 주석에서도 이것이 적절한 해결책은 아니며, 선별율(Culling rate) 감소 및 기타 문제를 야기할 수 있어 추후 더 나은 구현으로 대체될 가능성이 있다고 언급하고 있습니다.

이제 우리는 런타임 계산을 통해 Meshlet의 Bounds와 Normal Cone 데이터를 확보했습니다. 이 데이터를 통해 GPU에서 실시간으로 선별(후면 선별, 기여도 선별, 절두체 선별 및 가림 선별(Occlusion Culling))을 수행할 수 있습니다. 또한 스키닝 시 분산 프레임 계산과 Animation LOD 작업을 수행함으로써, 스킨드 메시로 인한 성능 소모를 대폭 줄일 수 있습니다. 심지어 Alan Wake 2처럼 씬에 고폴리곤의 골격 구동 식생을 대량으로 배치하는 것도 가능합니다. 아래 이미지처럼 말이죠.

요약

이 솔루션은 Unity에서 커스텀 GPU Skinning, BlendShape 및 Meshlet 바운딩 박스와 Normal Cone의 실시간 계산을 포함한 GPU Driven SkinnedMesh를 구현했습니다. 데이터 구성, GPU Culling 및 Draw Call 등은 다루지 않았으므로, 이 부분에 관심 있는 독자는 다른 관련 기술 문서를 참고하시기 바랍니다. 이 솔루션의 상세 코드는 현재 작업 중인 프로젝트에 포함되어 있어 공개할 수 없지만, 전체적인 아이디어는 동일하므로 관심 있는 독자는 직접 구현해 보시기를 권장합니다. 글 내용 중 오류가 있다면 연락 주시면 수정하겠습니다~

참고 자료


원문: (56 封私信 / 50 条消息) Unity实现蒙皮网格的GPU Driven渲染 - 知乎