ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 오브젝트 피킹 (Object Picking)
    개인 프로젝트 2024. 7. 13. 22:21

    목차

    1. 개요
    2. 구현 방식
      1. 객체에 Id 부여
      2. Id 렌더링
      3. 객체 선택
    3. 마치며

    개요

    게임 엔진의 에디터 등에서 화면에 표시된 게임 객체를 선택할 때 사용하는 Object Picking을 가볍게 살펴보도록 하겠습니다. 여기서 소개할 방식은 언리얼 엔진의 HHitProxy를 통해 구현된 방식으로 여기서는 제 개인 프로젝트의 코드를 통해서 간략화된 구현을 살펴보겠습니다.

    구현 방식

    우선 아주 고전적인 Object Picking 구현 방식을 살펴보겠습니다. 해당 방식은 다음과 같은 순서로 진행됩니다.

    https://www.cppstories.com/2012/06/select-mouse-opengl.html/

    1. 마우스의 X, Y 위치를 얻어서 카메라 공간의 좌표로 변경.
    2. 카메라의 위치에서 마우스의 카메라 공간 좌표를 향하는 광선을 생성
    3. 해당 광선으로 게임 객체에 대한 충돌 검사
    4. 광선에 충돌한 객체를 거리순으로 정렬하여 가장 가까운 객체를 선택

    옛날부터 널리 알려진 방식이지만 구현을 위해서 상당히 고려해야 할 게 많은 방식입니다. 스크린 좌표의 2D 마우스 위치를 3D 좌표로 바꿔야 하고 다양한 모양의 객체에 대한 광선 충돌 검사도 구현하면서 많은 객체를 처리하기 위한 공간 분할 최적화도 필요합니다.

    반면 Fast Object Picking이라는 제목으로 https://gamedevforever.com/261에서 소개된 Object Picking 방식은 구현이 용이하면서 준수한 성능으로 화면에 표시된 개임 객체를 선택할 수 있게 합니다. 해당 방식은 다음과 같은 순서로 진행됩니다.

    1. 객체에 Id 값을 부여하고 해당 Id 값을 색상으로 전환하여 렌더링
    2. Id가 렌더링된 렌더 타겟을 CPU에서 읽을 수 있는 버퍼로 복사
    3. 마우스의 X, Y 위치로 버퍼의 Id를 얻어서 Id를 통해 객체를 선택

    객체의 Id를 렌더링하는 것을 통해서 마우스 위치의 변경도 광선에 대한 충돌 검사와 공간 분할 최적화도 할 필요가 없어져 구현이 쉬워졌습니다. 이제 각 과정을 차근차근 소개하겠습니다.

    객체에 Id 부여

    렌더타겟에 렌더링할 객체의 Id와 색상의 변환을 용이하게 하기 위해 다음과 같은 클래스를 작성하였습니다.

    class HitProxyId
    {
    public:
    	uint32 GetId() const;
    	Color GetColor() const;
    
    	HitProxyId() = default;
    	explicit HitProxyId( uint32 id );
    	RENDERCORE_DLL explicit HitProxyId( Color color );
    
    	std::strong_ordering operator<=>( const HitProxyId& other ) const = default;
    
    private:
    	uint32 m_id;
    };
    
    Color HitProxyId::GetColor() const
    {
    	// Id를 색상으로 변경
    	uint8 r = m_id & 0xFF;
    	uint8 g = ( m_id >> 8 ) & 0xFF;
    	uint8 b = ( m_id >> 16 ) & 0xFF;
    
    	return Color( r, g, b, 0 );
    }
    
    // 색상을 Id로 변경
    HitProxyId::HitProxyId( Color color )
    	: m_id( color.R() | color.G() << 8 | color.B() << 16 )
    {
    }
    

    그리고 다음과 같이 Id를 관리할 클래스를 만들었습니다.

    class HitProxy : public RefCounter
    {
    	GENERATE_CLASS_TYPE_INFO( HitProxy )
    
    public:
    	HitProxyId GetId() const;
    
    	HitProxy();
    	~HitProxy();
    
    private:
    	void InitHitProxy();
    
    	HitProxyId m_id;
    };
    
    HitProxy::HitProxy()
    {
    	InitHitProxy();
    }
    
    HitProxy::~HitProxy()
    {
    	// HitProxyArray에서 받은 Id를 반환한다.
    	HitProxyArray::GetInstance().Remove( m_id.GetId() );
    }
    
    void HitProxy::InitHitProxy()
    {
    	// HitProxyArray에서 Id를 받는다.
    	m_id = HitProxyId( HitProxyArray::GetInstance().Add( this ) );
    }
    

    객체 선택을 위한 Id가 부여된 객체를 관리하는 HitProxyArray는 다음과 같이 구현되어 있습니다.

    class HitProxyArray
    {
    public:
    	static HitProxyArray& GetInstance()
    	{
    		static HitProxyArray hitProxyArray;
    		return hitProxyArray;
    	}
    
    	uint32 Add( HitProxy* hitProxy )
    	{
    		return static_cast<uint32>( m_hitProxies.Add( hitProxy ) );
    	}
    
    	void Remove( uint32 index )
    	{
    		m_hitProxies.RemoveAt( index );
    	}
    
    	HitProxy* GetHitProxy( uint32 index )
    	{
    		if ( index < m_hitProxies.Size() && m_hitProxies.IsAllocated( index ) )
    		{
    			return m_hitProxies[index];
    		}
    		else
    		{
    			return nullptr;
    		}
    	}
    
    private:
    	SparseArray<HitProxy*> m_hitProxies;
    };
    

    객체와 Id의 연결은 HitObject라는 클래스를 통해 다음과 같이 이뤄집니다.

    class HitObject : public rendercore::HitProxy
    {
    	GENERATE_CLASS_TYPE_INFO( HitObject )
    public:
    	LOGIC_DLL CGameObject* GetObject();
    
    	HitObject() = default;
    	HitObject( CGameObject* gameObject, PrimitiveComponent* component )
    		: m_gameObject( gameObject )
    		, m_component( component )
    	{}
    
    private:
    	CGameObject* m_gameObject = nullptr;
    	PrimitiveComponent* m_component = nullptr;
    };
    

    렌더링될 객체가 추가되면 다음과 같이 HitProxy를 생성하여 객체에 Id를 부여하게 됩니다.

    PrimitiveSceneInfo::PrimitiveSceneInfo( logic::PrimitiveComponent* component, Scene& scene ) : m_sceneProxy( component->m_sceneProxy ), m_scene( scene )
    {
    	if ( engine::DefaultApp::IsEditor() )
    	{
    		m_hitProxy = m_sceneProxy->CreateHitProxy( component );
    	}
    }
    
    HitProxy* PrimitiveProxy::CreateHitProxy( logic::PrimitiveComponent* component ) const
    {
    	return new logic::HitObject( component->GetOwner(), component );
    }
    

    Id 렌더링

    이제 앞에서 부여받은 Id를 렌더 타겟에 렌더링합니다. 렌더링을 위한 셰이더 코드는 다음과 같습니다.

    // -------------------------------------------------------------------------------------
    // Vertex Shader
    // -------------------------------------------------------------------------------------
    #include "Common/ViewConstant.fxh"
    #include "Common/VsCommon.fxh"
    
    struct VS_INPUT
    {
    	float3 position : POSITION;
    	uint primitiveId : PRIMITIVEID;
    };
    
    struct VS_OUTPUT
    {
    	float4 position : SV_POSITION;
    };
    
    VS_OUTPUT main( VS_INPUT input )
    {
    	VS_OUTPUT output = (VS_OUTPUT)0;
    
    	PrimitiveSceneData primitiveData = GetPrimitiveData( input.primitiveId );
    	float4 worldPosition = mul( float4( input.position, 1.0f ), primitiveData.m_worldMatrix );
    	float4 viewPosition = mul( worldPosition, ViewMatrix );
    	float4 projectionPosition = mul( viewPosition, ProjectionMatrix );
    	
    	output.position = projectionPosition;
    	
    	return output;
    }
    
    // -------------------------------------------------------------------------------------
    // Pixel Shader
    // -------------------------------------------------------------------------------------
    struct PS_INPUT
    {
    	float4 position : SV_POSITION;
    };
    
    cbuffer HitProxyParameters : register( b0 )
    {
    	float4 HitProxyId;
    };
    
    float4 main( PS_INPUT input ) : SV_Target0
    {
    	return float4( HitProxyId.rgb, 1.f ); 
    }
    

    상수 버퍼를 통해 전달된 색상을 출력하는 단순한 셰이더 코드입니다. 렌더링 과정도 매우 간단한데 다음과 같이 HitProxyId를 색상으로 변경하여 상수 버퍼에 전달하고 렌더링하면 됩니다.

    HitProxyId hitProxyId = primitives[snapshots[i].m_primitiveId]->GetHitProxyId();
    
    /*
    * HitProxyId에 색상을 업데이트
    */
    SetShaderValue( commandList, HitProxyIdShaderParam, hitProxyId.GetColor().ToColorF() );
    CommitDrawSnapshot( commandList, snapshots[i], primitiveIds );

    렌더링 결과로 다음과 같은 렌더 타겟을 얻을 수 있습니다.

    객체 선택

    이제 객체 Id가 렌더링된 렌더 타겟을 CPU가 접근할 수 있는 텍스쳐에 복사합니다.

    void RenderCore::GetRawHitProxyData( Viewport& viewport, std::vector<Color>& outHitProxyData )
    {
    	HitProxyMap& hitProxyMap = viewport.GetHitPorxyMap();
    
    	TaskHandle handle = EnqueueThreadTask<ThreadType::RenderThread>(
    		[&hitProxyMap, &outHitProxyData]()
    		{
    			auto commandList = GetCommandList();
    			commandList.CopyResource( hitProxyMap.CpuTexture(), hitProxyMap.Texture(), true );
    
    			commandList.Commit();
    			
    			/*
    			* 복사가 끝날 때까지 대기
    			*/
    			GetInterface<agl::IAgl>()->WaitGPU();
    
    			if ( hitProxyMap.CpuTexture() == nullptr )
    			{
    				return;
    			}
    
    			agl::LockedResource lockedResource = GraphicsInterface().Lock( hitProxyMap.CpuTexture(), agl::ResourceLockFlag::Read );
    
    			const agl::TextureTrait& cpuTextureTrait = hitProxyMap.CpuTexture()->GetTrait();
    			uint32 width = cpuTextureTrait.m_width;
    			uint32 height = cpuTextureTrait.m_height;
    
    			size_t rowSize = sizeof( Color ) * width;
    			auto dest = outHitProxyData.data();
    			auto src = (uint8*)lockedResource.m_data;
    
    			for ( uint32 i = 0; i < height; ++i )
    			{
    				std::memcpy( dest, src, rowSize );
    				dest += width;
    				src += lockedResource.m_rowPitch;
    			}
    
    			GraphicsInterface().UnLock( hitProxyMap.CpuTexture() );
    		} );
    	GetInterface<ITaskScheduler>()->Wait( handle );
    }

    그리고 마우스의 X, Y 위치로 Id를 얻어 HitProxy에 대한 포인터를 얻습니다.

    rendercore::HitProxy* GameClientViewport::GetHitProxy( uint32 x, uint32 y )
    {
    	if ( GetViewport() == nullptr )
    	{
    		return nullptr;
    	}
    
    	const std::vector<Color>& cachedData = GetRawHitProxyData();
    
    	const auto& [width, height] = GetViewport()->Size();
    	uint32 index = width * y + x;
    
    	if ( index < cachedData.size() )
    	{
    		rendercore::HitProxyId hitProxyId( cachedData[index] );
    		return GetHitProxyById( hitProxyId );
    	}
    
    	return nullptr;
    }
    
    HitProxy* GetHitProxyById( HitProxyId id )
    {
    	return HitProxyArray::GetInstance().GetHitProxy( id.GetId() );
    }

    Id에 해당하는 객체는 HitObject에 담겨 있기 때문에 다음과 같이 형변환해서 얻을 수 있습니다.

    /*
    * 리플렉션을 통한 안전한 형변환, 리플렉션이 없다면 dynamic_cast으로 변환 가능
    */
    if ( auto hitObject = Cast<logic::HitObject>( hitProxy ) )
    {
        sharedCtx.SelectObject( hitObject->GetObject() );
    }
    

    마치며

    준비된 내용은 여기까지입니다. 제 개인 프로젝트 구현의 전체 코드는 아래 github 변경점을 참고 부탁드립니다.

    https://github.com/xtozero/SSR/commit/30fd43139d8654f9dbfef53f7dff3d9a1e229822

    또한 글의 서두에서 언급했던 것처럼 지금까지 소개한 내용은 언리얼 엔진에 HHitProxy를 통해 구현되어 있습니다. 언리얼 엔진의 코드를 살펴보고 싶으신 분들을 위해서 몇 가지 진입점을 남겨두도록 하겠습니다.

    • HitProxies.h : Object Picking에 관련된 기반 클래스가 선언된 파일
      • FHitProxyId : Object Picking을 위한 객체의 Id를 저장한 클래스
      • HHitProxy : Object Picking을 위한 기반 클래스
      • HObject : Id에 대응하는 객체에 대한 포인터를 가지고 있는 클래스
    • SceneHitProxyRendering.h : 객체 Id 렌더링에 관련된 코드가 모여있는 파일
      • DoRenderHitProxies() : 객체 Id 렌더링 함수
    • UnrealClient.cpp : Viewport에 관련된 코드가 모여있는 파일
      • GetRawHitProxyData() : 객체 Id가 저장된 배열을 얻어오는 함수
Designed by Tistory.