DirectX11/Eternal Return 모작

[DirectX 11 Eternal Return 모작] 3. 인스턴싱

Vfly 2025. 9. 2. 04:16

전체 코드 : https://github.com/HyangRim/DirectX11-Engine-Client

 

GPU 인스턴싱을 통한 렌더링 최적화

GPU 인스턴싱이란?

GPU 인스턴싱은 동일한 Mesh 와 Material을 사용하는 여러 오브젝트를 한 번의 드로우 콜로 렌더링하는 기술이다.

 

기존의 개별 렌더링 방식과 비교해 보면

 

기존 방식의 문제점

  • 100개의 나무 → 100번의 드로우 콜
  • CPU - GPU 간 통신 오버헤드 증가
  • CPU 병목 현상 발생

GPU 인스턴싱의 장점

  • 100개의 나무 → 1번의 드로우 콜
  • GPU 활용도 극대화
  • CPU 부하 감소

 

실제 구현

RenderManager에서의 인스턴싱

이번 프로젝트에서 구현한 RenderManager에서는 다음과 같이 동일한 모델들을 그룹화하여 인스턴싱을 처리한다.

void RenderManager::RenderMeshRendererForward(vector<shared_ptr<GameObject>>& _gameObjects)
{
    map<InstanceID, vector<shared_ptr<GameObject>>> cache;
    
    // 1. 분류 단계: 동일한 메시와 머티리얼별로 그룹화
    for (shared_ptr<GameObject>& gameObject : _gameObjects) {
        if (gameObject->GetMeshRenderer() == nullptr)
            continue;
            
        const InstanceID instanceID = gameObject->GetMeshRenderer()->GetInstanceID();
        cache[instanceID].push_back(gameObject);
    }
    
    // 2. 그룹별 인스턴싱 렌더링
    for (auto& pair : cache) {
        const vector<shared_ptr<GameObject>>& vec = pair.second;
        const InstanceID instanceID = pair.first;
        
        // 각 인스턴스의 월드 변환 행렬 수집
        for (int32 idx = 0; idx < vec.size(); ++idx) {
            const shared_ptr<GameObject>& gameObject = vec[idx];
            InstancingData data;
            data.m_world = gameObject->GetTransform()->GetWorldMatrix();
            AddData(instanceID, data);
        }
        
        // 인스턴싱 버퍼로 한 번에 렌더링
        shared_ptr<InstancingBuffer>& buffer = m_buffers[instanceID];
        vec[0]->GetMeshRenderer()->RenderInstancing(buffer, m_isShadowTech);
    }
}

void RenderManager::AddData(InstanceID _instanceID, InstancingData& _data)
{
	if (m_buffers.find(_instanceID) == m_buffers.end()) {
		m_buffers[_instanceID] = make_shared<InstancingBuffer>();
	}

	m_buffers[_instanceID]->AddData(_data);
}

 

핵심 최적화 포인트

1. 그룹화

const InstanceID instanceID = gameObject->GetMeshRenderer()->GetInstanceID();

...

InstanceID ModelAnimator::GetInstanceID()
{
    return make_pair((uint64)m_model.get(), (uint64)m_shader.get());
}
  • InstanceID는 Mesh와 셰이더 포인터의 조합으로 생성
  • 동일한 렌더링 상태를 가진 오브젝트들을 효율적으로 분류

 

2. 데이터 구조

struct InstancingData {
    Matrix m_world;
};
  • 각 인스턴스마다 필요한 최소한의 데이터만 전송
  • GPU로의 데이터 전송량 최소화

 

애니메이션 인스턴싱의 최적화

void RenderManager::RenderAnimRendererForward(vector<shared_ptr<GameObject>>& _gameObjects)
{
    map<InstanceID, vector<shared_ptr<GameObject>>> cache;
    
    for (auto& pair : cache) {
        const vector<shared_ptr<GameObject>>& vec = pair.second;
        shared_ptr<InstancedTweenDesc> tweenDesc = make_shared<InstancedTweenDesc>();
        
        for (int32 idx = 0; idx < vec.size(); ++idx) {
            const shared_ptr<GameObject>& gameObject = vec[idx];
            
            // 그림자 패스가 아닐 때만 애니메이션 업데이트
            if (!m_isShadowTech) {
                gameObject->GetModelAnimator()->UpdateTweenData();
            }
            
            // 각 인스턴스의 애니메이션 상태 수집
            tweenDesc->tweens[idx] = gameObject->GetModelAnimator()->GetTweenDesc();
        }
        
        // 애니메이션 데이터를 GPU에 일괄 전송
        vec[0]->GetModelAnimator()->GetShader()->PushTweenData(*tweenDesc.get());
        
        shared_ptr<InstancingBuffer>& buffer = m_buffers[instanceID];
        vec[0]->GetModelAnimator()->RenderInstancing(buffer, m_isShadowTech);
    }
}

 

GPU 애니메이션 데이터 최적화

ModelAnimator에서는 애니메이션 데이터를 텍스쳐 형태로 GPU에 업로드하여 성능을 극대화

void ModelAnimator::CreateTexture()
{
    uint32 maxFrameCount = 0;
    for (const auto& pair : m_model->GetAnimations()) {
        maxFrameCount = max(maxFrameCount, pair.second->m_frameCount);
        CreateAnimationTransform(pair.first);
    }
    
    // 2D 텍스처 배열로 모든 애니메이션 데이터 저장
    D3D11_TEXTURE2D_DESC desc;
    desc.Width = MAX_BONE_TRANSFORMS * 4;  // 행렬 4개 컬럼
    desc.Height = maxFrameCount;           // 프레임 수
    desc.ArraySize = m_model->GetAnimationCount(); // 애니메이션 개수
    desc.Format = DXGI_FORMAT_R32G32B32A32_FLOAT;
    
    // 모든 애니메이션 데이터를 한 번에 업로드
    const uint32 dataSize = MAX_BONE_TRANSFORMS * sizeof(Matrix);
    const uint32 pageSize = dataSize * maxFrameCount;
    void* mallocPtr = ::malloc(pageSize * m_model->GetAnimationCount());
    
    // ... 데이터 패킹 및 업로드
    
    HRESULT hr = DEVICE->CreateTexture2D(&desc, subResources.data(), m_texture.GetAddressOf());
}

 

텍스쳐 기반 애니메이션의 장점

  1. 메모리 접근 패턴 최적화
    • 텍스쳐 캐시 활용으로 빠른 데이터 접근
    • GPU에 최적화된 메모리 레이아웃
  2. 병렬 처리 최적화
    • 모든 Bone 변환을 병렬로 처리
    • 셰이더에서 효율적인 행렬 연산

 

셰이더에서의 인스턴싱 구현

// 인스턴스별 데이터 구조
struct VertexModel
{
    float4 position : POSITION;
    float2 uv : TEXCOORD;
    float3 normal : NORMAL;
    float3 tangent : TANGENT;
    float4 blendIndices : BLEND_INDICES;
    float4 blendWeights : BLEND_WEIGHTS;
    
    // 인스턴싱 데이터
    uint instanceID : SV_INSTANCEID;
    matrix world : INST;
};

// 애니메이션 변환 행렬 계산
matrix GetAnimationWorldMatrix(VertexModel input)
{
    float indices[4] = { input.blendIndices.x, input.blendIndices.y, input.blendIndices.z, input.blendIndices.w };
    float weights[4] = { input.blendWeights.x, input.blendWeights.y, input.blendWeights.z, input.blendWeights.w };

    // 인스턴스별 애니메이션 상태 가져오기
    int animIndex = TweenFrames[input.instanceID].curr.animIndex;
    int currFrame = TweenFrames[input.instanceID].curr.currFrame;
    int nextFrame = TweenFrames[input.instanceID].curr.nextFrame;
    float ratio = TweenFrames[input.instanceID].curr.ratio;
    
    matrix transform = 0;
    
    // 각 본별 가중치 적용된 변환 계산
    for (int i = 0; i < 4; i++) {
        // 텍스처에서 본 변환 행렬 로드
        float4 c0 = TransformMap.Load(int4(indices[i] * 4 + 0, currFrame, animIndex, 0));
        float4 c1 = TransformMap.Load(int4(indices[i] * 4 + 1, currFrame, animIndex, 0));
        float4 c2 = TransformMap.Load(int4(indices[i] * 4 + 2, currFrame, animIndex, 0));
        float4 c3 = TransformMap.Load(int4(indices[i] * 4 + 3, currFrame, animIndex, 0));
        
        matrix curr = matrix(c0, c1, c2, c3);
        
        // 다음 프레임과 보간
        // ... 보간 계산
        
        transform += mul(weights[i], result);
    }

    return transform;
}

 

 

 

디퍼드 렌더링과의 결합

디퍼드 렌더링 파이프라인에서도 인스턴싱을 활용했음.

void RenderManager::RenderAnimRendererDeferred(vector<shared_ptr<GameObject>>& _gameObjects)
{
    // 투명 객체는 제외하고 불투명 객체만 처리
    for (shared_ptr<GameObject>& gameObject : _gameObjects) {
        auto modelAnimator = gameObject->GetModelAnimator();
        if (auto material = modelAnimator->GetMaterial()) {
            if (material->IsTransparent())
                continue; // 포워드 렌더링으로 별도 처리
        }
        
        const InstanceID instanceID = gameObject->GetModelAnimator()->GetInstanceID();
        cache[instanceID].push_back(gameObject);
    }
    
    // G-Buffer에 데이터 쓰기
    vec[0]->GetModelAnimator()->RenderInstancingDeferred(buffer, m_isShadowTech);
}

 

 

결론

GPU 인스턴싱은 현대 게임 개발에서 필수라고 볼 수 있다. 특히 다음과 같은 경우에는 매우 큰 효과를 얻을 수 있다.

  • 대량의 동일 오브젝트가 많은 경우 ( 나무 , 풀 , 건물 등 )
  • 반복적인 파티클 시스템
  • 군중 시뮬레이션