DirectX11

[DirectX11] CPU Skinning, GPU Skinning 비교

yeoul0714 2025. 5. 16. 05:16

1. 개요


https://yeoul0714.tistory.com/56

 

[DirectX11] FBX, CPU Skinning

1. 개요https://yeoul0714.tistory.com/55 [DirectX11] FBX SDK적용, 컴파일 옵션 /Md /Mt1. 개요우리는 지금까지의 프로젝트에서 OBJ Parser를 만들고 그것을 Rendering Pipeline에 연결해서 OBJ를 읽고 Rendering하는 시스템

yeoul0714.tistory.com

 

우리는 저번 시간에 CPU Skinning에 대해서 알아보았습니다.

 

한마디로 본의 움직임에 대한 Mesh Vertex 계산을 CPU에서 해준다는 의미였습니다.

 

이번엔 Skinning을 GPU에서 처리하는 GPU Skinning에 대해 알아보고 CPU Skinning과 비교해보는 시간을 가지고자 합니다.


 

2. GPU/CPU Skinnning 비교


결론부터 말하면 GPU Skinning은 CPU Skinning에 비해 압도적인 성능을 자랑합니다.

 

CPU Skinning에서 병목이 발생하는 가장 큰 원인은 CPU Skinning의 계산과 CPU-GPU간의 통신입니다.

 

2-1. CPU Skinning계산

사람형체의 오브젝트는 수많은 Vertex로 구성되어 있습니다.

 

또한 FBX파일이기 때문에 Bone이 존재하고, 각 정점들은 영향을 받는 4개의 Bone이 존재합니다.

 

그러므로 CPU Skinning이 계산될때는 어마어마한 양의 Matrix계산이 일어나게 되고 이것이 성능을 떨어뜨리는 원인이 됩니다.

 

자세히 설명하자면 각 버텍스마다 4개의 본 영향을 계산하려면 4번의 4x4 행렬 변환이 필요합니다.

 

이 계산은 곧 하나의 버텍스당 64번의 곱셈과 48번의 덧셈 연산을 의미합니다.

 

[m11 m12 m13 m14]     [x]     [m11*x + m12*y + m13*z + m14*1]
[m21 m22 m23 m24] ×  [y] =  [m21*x + m22*y + m23*z + m24*1]
[m31 m32 m33 m34]     [z]     [m31*x + m32*y + m33*z + m34*1]
[m41 m42 m43 m44]     [1]     [m41*x + m42*y + m43*z + m44*1]

 

여기서 보면 행렬계산 한번에 곱셈은 총 16번, 덧셈은 12번이 진행됩니다.

 

그런데 한 vertex당 4개의 bone의 영향을 받기에 64번, 48번이라는 값이 나온것입니다.

 

CPU는 GPU와 다르게 병렬처리 면에서는 연약한 모습을 보여줍니다. 

 

CPU에게 이런 수많은 계산은 큰 부하를 줄 수밖에 없습니다.

 

2-2. GPU-CPU 통신코스트

  if (FEngineLoop::IsGPUSkinningEnabled())
  {
      BufferManager->CreateVertexBuffer(KeyName, RenderData->Vertices, VertexInfo,D3D11_USAGE_IMMUTABLE, 0);
  }
  else
  {
      BufferManager->CreateDynamicVertexBuffer(KeyName, RenderData->CPUSkinnedVertices, VertexInfo);
      BufferManager->UpdateDynamicVertexBuffer(KeyName, RenderData->CPUSkinnedVertices);
  }

 

이 코드는 GPU/CPU 스키닝 여부에 따라서 Vertex Buffer를 생성하고 Update해주는 부분입니다.

 

GPU Skinning같은 경우엔 같은 Vertex를 사용하고 Skinning연산은 전부 Shader에서 처리하기 때문에

 

매 프레임 Vertex를 Update를 해줄필 요가 없습니다.

 

반면에 CPU Skinning은 매프레임 계산된 Vertex를 새롭게 Update해주어야 합니다. 이부분에서 GPU와 CPU는 통신하게 되고

 

많은 코스트가 발생합니다.

 

아래는 Update하는 코드입니다. map, unmap하는 과정에서 CPU-GPU가 통신하게 됩니다.

template<typename T>
void FDXDBufferManager::UpdateDynamicVertexBuffer(const FString& KeyName, const TArray<T>& vertices) const
{
    if (!VertexBufferPool.Contains(KeyName))
    {
        UE_LOG(LogLevel::Error, TEXT("UpdateDynamicVertexBuffer 호출: 키 %s에 해당하는 버텍스 버퍼가 없습니다."), *KeyName);
        return;
    }
    FVertexInfo vbInfo = VertexBufferPool[KeyName];

    D3D11_MAPPED_SUBRESOURCE mapped;
    HRESULT hr = DXDeviceContext->Map(vbInfo.VertexBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
    if (FAILED(hr))
    {
        UE_LOG(LogLevel::Error, TEXT("VertexBuffer Map 실패, HRESULT: 0x%X"), hr);
        return;
    }

    memcpy(mapped.pData, vertices.GetData(), sizeof(T) * vertices.Num());
    DXDeviceContext->Unmap(vbInfo.VertexBuffer, 0);
}

 

추가적으로 설명하자면 D3D11_USAGE_IMMUTABLE은 GPU가 읽기만 가능하고 CPU는 수정하지 못하게 하는 Flag입니다.

 

GPU Skinning같은 경우엔 CPU가 접근할 필요가 없음으로 이 Flag를 넣어주어야 합니다.

 

만약에 D3D11_USAGE_ DYNAMIC같은 Flag를 사용하게 되면 GPU Skinning의 성능이 낮아지게 됩니다.

 

아래 gif를 보면 미묘한 차이이지만 IMMUTABLE쪽(상단)의 속도 값이 더 빠른것을 볼 수 있습니다. (1-2ms정도)

D3D11_USAGE_IMMUTABLE
D3D11_USAGE_ DYNAMIC

 


 

3. GPU Skinning 과정


3-1. GPU 스키닝의 기본 원리

GPU 스키닝은 캐릭터 애니메이션의 버텍스 변형 계산을 GPU에서 수행하는 기법입니다. 

  • FBX에서 Load한 Vertex는 변하지 않고 한 번만 GPU로 전송됩니다.
  • CPU에서는 오직 본 변환 행렬만 계산하여 매 프레임 GPU로 전송됩니다.
  • 모든 Vertex의 Skinning 계산은 GPU의 Vertex Shader에서 병렬로 처리합니다.
struct VS_INPUT_SkeletalMesh
{
    float3 Position : POSITION;
    float4 Color : COLOR;
    float3 Normal : NORMAL;
    float4 Tangent : TANGENT;
    float2 UV : TEXCOORD;
    uint MaterialIndex : MATERIAL_INDEX;
    int4 BoneIndices : BONEINDEX;
    float4 BoneWeights : BONEWEIGHT;
};

이것은 Vertex데이터가 들어오는 구조체의 구조입니다.

 

Bone의 Index와 가중치도 함께 들어옵니다.

 

cbuffer BoneWeightConstants : register(b2)
{
    row_major matrix BoneTransform[MAX_BONE_NUM];   // InvGlobal * GlobalTransform 한 값
}

상수버퍼로 Skinning Matrix를 넘겨줍니다.

 

C++코드에서는 아래와 같이 계산해서 넘겨주게 됩니다.

void FSkeletalMeshRenderPass::UpdateBoneConstant(FSkeletonPose* SkeletonPose) const
{
    FSkeleton* Skeleton = SkeletonPose->Skeleton;

    FBoneWeightConstants BoneWeightConstants;
    for (int i = 0; i < Skeleton->BoneCount; i++) 
    {
        if (i >= MAX_BONE_NUM) 
        {
            UE_LOG(LogLevel::Warning, TEXT("Bone Constant에서 제한한 갯수 초과 "));
            break;
        }

        BoneWeightConstants.BoneTransform[i] = SkeletonPose->GetSkinningMatrix(i);
    }

    BufferManager->UpdateConstantBuffer(TEXT("FBoneWeightConstants"), BoneWeightConstants);
}

 

 

아래는 GPU Skinning을 진행하는 hlsl코드입니다.

PS_INPUT_StaticMesh mainVS(VS_INPUT_SkeletalMesh Input)
{
    // Weight 계산을 진행해서 나온 Position을 기반으로 StaticMesh mainVS 실행시켜버리기
    float4 WeightedPosition = float4(0, 0, 0, 1);

    if (Input.BoneIndices.x >= 0)
    {
        WeightedPosition += Input.BoneWeights.x * mul(float4(Input.Position, 1.0f), BoneTransform[Input.BoneIndices.x]);
    }
    if (Input.BoneIndices.y >= 0)
    {
        WeightedPosition += Input.BoneWeights.y * mul(float4(Input.Position, 1.0f), BoneTransform[Input.BoneIndices.y]);
    }
    if (Input.BoneIndices.z >= 0)
    {
        WeightedPosition += Input.BoneWeights.z * mul(float4(Input.Position, 1.0f), BoneTransform[Input.BoneIndices.z]);
    }
    if (Input.BoneIndices.w >= 0)
    {
        WeightedPosition += Input.BoneWeights.w * mul(float4(Input.Position, 1.0f), BoneTransform[Input.BoneIndices.w]);
    }
    
    
    VS_INPUT_StaticMesh StaticMeshVertex;
    StaticMeshVertex.Position = WeightedPosition.xyz;
    StaticMeshVertex.Color = Input.Color;
    StaticMeshVertex.Normal = Input.Normal;
    StaticMeshVertex.Tangent = Input.Tangent;
    StaticMeshVertex.UV = Input.UV;
    StaticMeshVertex.MaterialIndex = Input.MaterialIndex;

    return mainVS(StaticMeshVertex);
}

 

 

현재는 콘솔 명령어를 통해서 bool값을 변경해서 그를 바탕으로 CPU/GPU Skinning을 전환하고 있습니다.

 

그부분에 해당하는 코드를 소개하겠습니다.

 

CPU Skinning을 계산하는 부분을 bool값으로 제한해 두었습니다.

void USkeletalMeshComponent::TickAnimation(float DeltaTime)
{
    if (!SkeletalMesh || !AnimInstance)
    {
        return;
    }
    // AnimInstance 업데이트 (시간 진행 등)
    if (!FEngineLoop::IsGPUSkinningEnabled()) 
    {
        PerformCPUSkinning();
    }

    AnimInstance->Update(DeltaTime);
}

 

 

이와 같이 GPU/CPU Skinning에 따라서 다른 Vertex Shader를 사용하도록 했습니다.

 

CPU Skinning의 경우엔 이미 Vertex가 전부 계산되었기 때문에 StaticMesh Vertex Shader를 사용하도록했고

 

GPU Skinning의 경우엔 Shader에서 Skinning계산을 해야해서 SkeletalMesh Vertex Shader를 사용하게 했습니다.

void FSkeletalMeshRenderPass::ChangeViewMode(EViewModeIndex ViewMode)
{
    ID3D11VertexShader* VertexShader = nullptr;
    ID3D11InputLayout* InputLayout = nullptr;
    ID3D11PixelShader* PixelShader = nullptr;

    bool bUseGPUSkinning = FEngineLoop::IsGPUSkinningEnabled();
    std::wstring VertexShaderPrefix = bUseGPUSkinning ? L"SkeletalMesh" : L"StaticMesh";

    switch (ViewMode)
    {
        case EViewModeIndex::VMI_Lit_Gouraud:
            VertexShader = ShaderManager->GetVertexShaderByKey(VertexShaderPrefix + L"VertexShader");
            InputLayout = ShaderManager->GetInputLayoutByKey(VertexShaderPrefix + L"VertexShader");
            PixelShader = ShaderManager->GetPixelShaderByKey(L"GOURAUD_StaticMeshPixelShader");
            UpdateLitUnlitConstant(1);
            break;
        case EViewModeIndex::VMI_Lit_Lambert:
            VertexShader = ShaderManager->GetVertexShaderByKey(VertexShaderPrefix + L"VertexShader");
            InputLayout = ShaderManager->GetInputLayoutByKey(VertexShaderPrefix + L"VertexShader");
            PixelShader = ShaderManager->GetPixelShaderByKey(L"LAMBERT_StaticMeshPixelShader");
            UpdateLitUnlitConstant(1);
            break;
            
            
            
            ...

 

4. 결론


GPU Skinning은 CPU Skinning에 비해 높은 성능을 자랑합니다.

 

아래는 GPU,CPU Skinning 전환 영상입니다.