본문 바로가기
그래픽스

ID값을 기록한 텍스쳐를 사용하여 3D오브젝트 피킹하기

by greenherb 2021. 8. 13.

모델로딩에서 Sponza Scene을 불러왔었습니다. 노드와 노드아래있는 분할된 메시까지 엔티티로 만드느라 수백개의 오브젝트가 생성되고 하이라키뷰에서는 해당 오브젝트들을 띄워주었는데요. 이럴경우 하이라키뷰에서 직접클릭하면 어떤 오브젝트인지도 모르고 지금 당장 눈앞에보이는 오브젝트에 대한 정보를 얻고 싶어도 얻을수 없었습니다.

그래서 이제 피킹기능을 추가해볼것인데요. 사실 이전에 피킹기능은 있었습니다. 

https://www.youtube.com/watch?v=wYVaIOUhz6s&t=2s 

OpenGL을 사용하시는분은 위 동영상 튜토리얼을 그대로 따라하시면 바로 구현할 수 있습니다. 해당 튜토리얼을 간략하게 요약하자면 멀티렌더타겟을 이용하여 오브젝트 ID를 기록하는 텍스쳐를 따로 준비하고 화면을 마우스로 클릭했을때 기록된 텍스쳐에 접근하여 클릭한위치의 ID값을 얻어와 오브젝트에 접근하는 방식입니다.

보통 3D picking하면 저도 처음에 배운것이 마우스위치에서 레이를 쏘고 해당 레이가 오브젝트의 바운딩 박스던지 메쉬범위던지 교차하게되면 해당 오브젝트를 클릭하는 원리를 배웠습니다. 하지만 만약 씬에 700개의 오브젝트가 있고 이 700개의 오브젝트의 메쉬정보는 오브젝트마다 다를것입니다. 만약 삼각형과 레이의 교차를 진행하게되면 메쉬별로 하나씩 진행하게되면 오브젝트 하나에 1000개의 삼각형이있다면 700,000번의 교차검증을 해야할수도 있겠지요 물론 극단적인 경우입니다. 

그렇다면 이렇게 하나하나씩 교차검증을하는대신 지금 카메라 기준에서 보이는 오브젝트를 렌더링하면서 남는 여분의 렌더타겟을 사용하여 오브젝트의 ID값을 기록하고 화면을 클릭했을때 마우스위치를 텍스쳐좌표로 변환하여 해당 좌표의 ID값을 얻어오는 계산은 어떨까요?

사실 그렇게 큰 연산량은 앞에서 설명한 교차검사보다는 훨씬 적을거라고 생각합니다. 단순히 오브젝트를 쉐이딩하는 과정에서 ID값도 기록해주면되니까요 렌더링이 끝나고 텍스쳐에서 값을 가져오는부분이 느릴수도 있다고생각하지만 수십개의 메쉬를 검사하는것보단 낫다고 생각합니다.

그렇다면 텍스쳐기반 피킹을 하기위해 준비해야할것은 무엇일까요?

1. ID값을 기록할 텍스쳐 (정수 혹은 부호없는정수형식의)

2. 해당 텍스쳐를 붙일 프레임버퍼나 렌더타겟

3.멀티타겟에 기록할 수 있는 쉐이더코드

4.마우스좌표를 얻어와 텍스쳐에서 값읽기.

1-3 까지는 간단하게 진행할수 있으며 4번도 그렇게 까다로운 작업은아닙니다.

1번째로 ID값을 기록할 텍스쳐입니다.

튜토리얼 영상에도 설명하듯이 정수값을 저장할것인데요 부호없는 정수값이 아닌 정수값을 이용하여 저장하는이유는 정수값을 이용하여 -1 값이 아무런 오브젝트가 기록되지않음을 표현하기 위해서입니다.

그리하여 필요한형식이 32bit를 모두다 사용하는 R32_integer 형식의 텍스쳐 포멧이 필요합니다. GL에서는 포맷은 GL_RED_INTEGER, 내부포맷은 GL_R32I , 데이터타입은 GL_INT가 되겠네요. DX는 간단하게 DXGI_FORMAT_R32_SINT 입니다.

위에서 언급한 포맷으로 텍스쳐크기는 현재 보여지는 화면해상도에 맞춰서 2d 텍스쳐를 생성하시면됩니다. 그리고 ID값을기록하기전에 GL은 glClear, DX는 ClearRenderTarget을 호출하여 텍스쳐를 초기화하는데 우리가 원하는 값으로는 초기화 되지않습니다. 고로 직접 텍스쳐의 모든값을 CPU->GPU로 데이터를 전달해야합니다. 이것은 4번에서 같이 설명하겠습니다.

2번째는 프레임버퍼,렌더타겟준비

이부분은 간단합니다. 그냥 기존 쉐이딩하던 프레임버퍼에 새로 위에서 준비한 텍스쳐를 추가해주시면됩니다. 다만 주의하실점이 렌더링시작시 프레임버퍼 초기화를통해 ID텍스쳐에 어떤값으로 초기화될지가 GL과 DX의 작동방법이 다릅니다. 직접 확인해봤는데. 초기화를하고 텍스쳐값을 살펴보면 GL의경우 무작위값이들어가고 DX는 0의값이 들어가더라구요 그리고 하나의 프레임버퍼를 이용하여 다른 쉐이더코드로 여러번 렌더링할 경우도 주의하셔야합니다!

ID값을 기록하는 쉐이더코드가 있는 렌더링 구간에서만 ID텍스쳐에 기록해야합니다. 예를들어 첫번째 렌더링 구간에서 멀티타겟으로 오브젝트와 ID값을 기록하고 두번째 구간에서는 다른 쉐이더코드로 빌보드나 파티클을 렌더링한다고 생각해봅시다. 만약 기존의 프레임버퍼에있는 첫번째 컬러버퍼에 기록하기위해 동일한 프레임버퍼를 사용하고 있다고 가정하면 다른 쉐이더 코드에서 ID값을 기록하는 코드가 없지만 프레임버퍼에서는 2개의 텍스쳐에 값을 받도록 되어있으므로 렌더링할때 GL에서는 임의의값 DX에서는 위의 경우처럼 0의 값이 들어오더라구요

고로 ID값을 기록하는 쉐이더코드에서만 ID텍스쳐를 붙이고 그외에는 텍스쳐를 떼시거나 GL의경우 glDrawBuffer을 사용하여 기록할 부착물들을 제한하는것도 좋은방법입니다.

3.ID값을 기록하는 쉐이더 코드

이부분도 정말 간단합니다 ID값은 위 튜토리얼영상에서는 배치렌더링을통해 정점레이아웃에 ID값을 추가하는 방식으로 진행하는데 저는 그냥 간단하게 ConstantBuffer,UniformBuffer로 ID값을 전달하겠습니다. 매번 갱신되는 데이터를 모아놓은 버퍼에 저장하는게 좋겠죠? 저는 Transform Buffer에 ID 변수를 추가해서 전달하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Transform
{
    glm::mat4 transform;
    glm::mat4 invtrnasform;
    int    id = -1;
};
 
struct PS_OUT
{
    float4 color :SV_TARGET0;
    int    id : SV_TARGET1;
};
 
layout(location = 0) out vec4 color;
layout(location = 1) out int id;
cs

이런식으로 하나더 추가해주시면 될것같습니다. 그렇다면 이렇게 버퍼를 통해 전달받은 ID는 DX에서는 SV_TARGET1을 지정해준 변수에 저장하시면되고 맨아래는 GL코드입니다.

4.마우스 좌표를 얻어와 텍스쳐에서 값읽기

이제 쉐이더에서 텍스쳐에 ID값도 기록했다면 화면에 보이는 오브젝트를 클릭했을때 ID값을 얻어오는 과정입니다.

GL 먼저 설명하겠습니다. 튜토리얼에서는 glReadPixels를 사용하지만 이경우 프레임버퍼가 필요하며 프레임버퍼에 붙여진 컬러버퍼에대한 픽셀읽기를 진행합니다. 하지만 저는 텍스쳐에서 직접 가져오고싶습니다 매번 픽셀값을 얻고싶을때마다 프레임버퍼에 붙이기는 싫거든요.

glGetTextureSubImage(textureID,miplevel,xoffset,yoffset,zoffset,width,height,depth,format,datatype,buffersize,data);

위함수를 사용하면 텍스쳐의 일정부분에대한 정보를 얻어올수있습니다. mip은 해당 텍스쳐의 어떤 밉맵수준에 접근할것인지 그리고 offset은 텍스쳐의 어느 부분부터 시작해서 width,height,depth 만큼 가져올지 정합니다. format과 데이터타입은 가져온 데이터를 어떻게 해석할지 정해줍니다. bufferSize는 받는 데이터의 크기입니다. 이크기가 텍스쳐 크기보다 크면안되겠죠? data에는 void*로 데이터를 받게됩니다.

이렇게 텍스쳐 값을 가져올수있음을 압니다. 그다음에는 첫번째에서 언급한 텍스쳐를 일정값으로 초기화하는 코드는 어떻게 될 까요?

glClearTexSubImage(textureId,miplevel,xoffset,yoffset,zoffest,width,height,depth,format,dataType,data);

위함수와 파라매터는 비슷합니다. 이함수도 텍스쳐의 부분을 data값으로 초기화해주는것인데 data의 형식은 format과 datatype이 정합니다. 위두함수에대한 정확한정보는 glwiki를 확인하시길 바랍니다.

gl에서는 프레임버퍼에 텍스쳐를 붙이고 -1값으로 초기화해준다음 렌더링진행후 마우스클릭시 텍스쳐를 가져오는 모든 준비가 되었습니다.

DX에서는 준비해야할 과정들이좀더 많은데요 위두함수로 간단하게 텍스쳐값을  가져오고 초기화하는 gl과 다르게 텍스쳐의 용도를 구분해야할 필요가있습니다.

Gpu -> Cpu로 값을 읽기위해서는 어떻게 해야할까요? 첫번째로 텍스쳐의 용도가 Staging 이어야합니다.

자원의 용도가 Default,Immutable,Dynmaic,Staging 이렇게 구분이되는데요.

Default의경우 GPU가 자원을 읽고 쓰기를 해야한다면 해당 용도를 정하면됩니다. CPU에서는 해당 용도를 가진 자원을 읽거나 쓸수는 없지만 UpdateSubResource 함수를 사용하여 업데이트는 할 수 있습니다. (map ,unmap은 불가능)

Immutable의경우 GPU가 자원을 읽기는 가능하지만 쓰기는 불가능합니다. (물론 CPU도 둘다불가능) 자원을 처음 생성하고 이후에 전혀 변경하지 않을것이라면 이 용도를 정하면되며 최적화의 여지가 있는 용도입니다.

Dynamic의 경우 CPU에서 자원의 내용을 자주 기록해야한다면 (읽기는 불가, 매프레임마다 기록할경우) 이용도를 지정합니다. 해당 용도의 자원은 GPU가 읽을수있으며 CPU에서는 Map,UnMap을 통해(Directx9의 Lock,UnLock) 기록 할 수 있습니다. GPU안의 자원을 CPU에서 갱신하게되면 성능의 하락이 있을 수 있습니다.

Staging의 경우 CPU가 GPU안의 자료를 읽어야 할 경우 사용하는 용도입니다. 즉 GPU의 VRAM에서 CPU의 RAM으로 전송하는 경우이며 GPU->CPU로 전송하는 연산은 느리므로 웬만하면 피해야합니다. 이러한 자원을 복사하는 함수는 CopyResource와 CopySubResourceRegion이 있습니다.

첫번째로 텍스쳐에서 값을 가져올때 입니다. 위에서 설명한것처럼 GPU->CPU데이터전송은 Staging용도로만 가능하다고 말씀드렸습니다. 하지만 우리가 현재사용하는 ID텍스쳐는 RenderTarget전용 즉 GPU가 읽고 쓰기가 가능한용도이므로 Default 용도의 텍스쳐입니다. Default용도는 CPU에서 값을 읽을 수 없고 갱신만 가능한데요.. 어떻게해야할까요? 그래서 새로 Staging용도의 텍스쳐를 만든후 ID텍스쳐를 복사하여 사용하는것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
D3D11_TEXTURE2D_DESC textureDesc;
textureDesc.Width = width;
textureDesc.Height = height;
textureDesc.MipLevels = 1;
textureDesc.ArraySize = 1;
textureDesc.Format = Utils::GetDirectDataType(format);
textureDesc.SampleDesc.Count = this->desc.SampleCount;
textureDesc.SampleDesc.Quality = 0;
textureDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
textureDesc.Usage = D3D11_USAGE_STAGING;
textureDesc.BindFlags = 0;
textureDesc.MiscFlags = 0;
wrl::ComPtr<ID3D11Texture2D> pTexTemp;
 
//텍스처 생성
gfx.GetDevice()->CreateTexture2D(&textureDesc, nullptr, &pTexTemp);
 
// 텍스처 복사
D3D11_BOX srcBox;
srcBox.left = xoffset;
srcBox.right = xoffset + width;
srcBox.bottom = yoffset + height;
srcBox.top = yoffset;
srcBox.front = 0;
srcBox.back = 1;
gfx.GetContext()->CopySubresourceRegion(pTexTemp.Get(), 0000, pTexture.Get(), 0&srcBox);
 
D3D11_MAPPED_SUBRESOURCE msr = {};
gfx.GetContext()->Map(pTexTemp.Get(), 0, D3D11_MAP::D3D11_MAP_READ, 0&msr);
if (xoffset >= 0 && xoffset <= this->desc.Width && yoffset >= 0 && yoffset <= this->desc.Height)
{
    if(dataType == TextureDataType::INT)
        *reinterpret_cast<int*>(pixels) = *reinterpret_cast<int*>(msr.pData);
}
gfx.GetContext()->Unmap(pTexTemp.Get(), 0);
cs

코드를 보시면 처음에 원본 ID텍스쳐와 똑같은 포멧을 가진 텍스쳐를 만듭니다. 다만 다른점은 width,height는 달라야합니다 우리가 원하는 영역의 크기만큼 width,height를 지정합니다 staging 용도의 텍스쳐를 만드셨다면 (D3D11_CPU_ACCESS_READ 를 하는것을 잊지마세요 !)

이제 CopySubresourceRegion 함수를 이용하여 D3D11_BOX 구조체로 정의한 만큼의 영역을 복사해옵니다. BOX 구조체를 정의할때 오프셋과 크기를 정해줍니다. 이제 임시 텍스쳐가 복사가되었다면 해당 텍스쳐의 값을 읽어올 차례입니다. Map,UnMap을 사용하여 해당텍스쳐를 가져올것인데요 여기서도 중요한점이 읽기이므로 D3D11_MAP_READ로 지정해야한다는것입니다.  우리가 원하는 정보의 첫주소를 반환합니다. 이 주소를 원하는 데이터의 형식으로 바꾸시면됩니다. (msr.pData 는 void* 이므로 우리가 원하는 int*로 바꾸어주었습니다) 그리고 참조연산을통해 실제값을 넣어주는 코드입니다. 만약 배열의 경우에는 여기서 데이터처리를 할것이아니라 첫주소만 전달해줘야 할 것같네요

이제 텍스쳐의 텍셀값을 읽어왔으니 텍스쳐를 초기화하는 방법에 대해서 알아보도록하겠습니다.

이경우도 텍스쳐의 용도에따라 초기화방법이 달라지는데요 일단 읽기전용인 Staging과 Immutable은 빼도록 하겠습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
    template<typename T>
    void MapProcessing(uint32_t miplevel, uint32_t xoffset, uint32_t yoffset, uint32_t width, uint32_t height,void* dstData,const void* srcdata)
    {
        for (int x = xoffset; x < width; ++x)
        {
            for (int y = xoffset; y < height; ++y)
            {
                T* ptr = reinterpret_cast<T*>(dstData);
                ptr += width * height * sizeof(T);
                *ptr = *reinterpret_cast<T*>(const_cast<void*>(srcdata));
            }
        }
    }
    void DX11Texture2D::ClearData(uint32_t miplevel, uint32_t xoffset, uint32_t yoffset, uint32_t width, uint32_t height, TextureFormat format, TextureDataType dataType, const void* data)
    {
        namespace wrl = Microsoft::WRL;
        QGfxDeviceDX11& gfx = *QGfxDeviceDX11::GetInstance();
        uint32_t sizeData = Utils::GetDataTypeSize(dataType);
        D3D11_MAPPED_SUBRESOURCE msr = {};
 
        switch(textureDesc.Usage)
        {
            case D3D11_USAGE_DYNAMIC:
            {
                gfx.GetContext()->Map(pTexture.Get(), miplevel, D3D11_MAP::D3D11_MAP_WRITE_DISCARD, 0&msr);
                if (xoffset >= 0 && xoffset <= this->desc.Width && yoffset >= 0 && yoffset <= this->desc.Height)
                {
                    if (dataType == TextureDataType::INT)
                        MapProcessing<int>(miplevel, xoffset, yoffset, width, height, msr.pData, data);
                }
                gfx.GetContext()->Unmap(pTexture.Get(), 0);
            }
                break;
            case D3D11_USAGE_STAGING:
            case D3D11_USAGE_DEFAULT:
            {
                if (dataType == TextureDataType::INT)
                {
                    int* pdata = new int[width * height];
                    int value = *reinterpret_cast<int*>(const_cast<void*>(data));
                    for (int i = 0; i < width * height; ++i)
                    {
                        pdata[i] = value;
                    }
                    D3D11_BOX srcBox;
                    srcBox.left = xoffset;
                    srcBox.right = xoffset + width;
                    srcBox.bottom = yoffset + height;
                    srcBox.top = yoffset;
                    srcBox.front = 0;
                    srcBox.back = 1;
                    gfx.GetContext()->UpdateSubresource(pTexture.Get(), miplevel, &srcBox, pdata, width * sizeData, 0);
                    delete[] pdata;
                }
            }
            break;
            case D3D11_USAGE_IMMUTABLE:
                QCAT_CORE_ERROR("Texture Error : Immutable Texture can be changed! ");
            break;
        }
    }
cs

Dynamic 텍스쳐 용도의 경우 Map,UnMap을 이용하여 텍스쳐 크기만큼 반복문을돌면서 값을 기록할것입니다. MapProcessing 템플릿하수를 확인해보면 단순히 오프셋에서 width,height만큼 반복문을 돌면서 데이터를 기록하는 것입니다. 중요한점은 어떤데이터를 기록하야에따라 한 텍셀을 넘어가는 크기가 달라지겠죠. 간단한코드입니다.

Default 텍스쳐의 경우 UpdateSubResource 함수가 있습니다 CPU에서 준비한 데이터를 텍스쳐에 기록할 수 있는데요 코드에서 보듯이 원하는 데이터 형식의 배열을 통해 해당 배열의 전체값에 원하는 값을 입력하고 그데이터의 주소로 UpdateSubresource로 넘깁니다. 중요한점은 함수의5번째 파라메터인 rowpitchbytes부분인데요 즉 가로줄에있는 요소들의 크기를 의미합니다. (이부분도 템플릿을통해 작성해도 나쁘진 않았을것같은데 추후에 리팩토링 해봐야겠네요.)

이렇게 초기화도 진행해주시면됩니다. 마우스의 위치를 얻는방법은 각자 다르므로 따로 설명은하지 않겠습니다. glfw를 사용하시는분이라면 내부함수를통해하셔도되고 winapi를 직접 사용하시면 이벤트에서 직접 받으셔도됩니다. 다만 중요한점은 Window 환경에서는 좌측상단이 0,0의 마우스 위치이므로 만약 window 환경에서 gl을 사용하시는분은 마우스좌표값을 y축을 반대로 해주셔야합니다. 좌측하단이 0.0이되야 얻어온 마우스좌표를 통해 제대로된 텍스쳐위치를 읽을수 있기때문입니다.

얻어온 ID값을 이용하여 해당 위치에 ImGuizmo를 사용하여  Manipulation ui를 띄우셔도 좋고 오브젝트에대한 정보를 따로 콘솔에 띄우셔도 좋습니다. 아래 결과화면입니다.

 

피킹을통해 ID값을 얻어옵니다.

이렇게 ID값을 얻어오면 복잡한 장면에서도 피킹을통해 오브젝트를 선택할 수 있습니다.

외곽선 효과를 넣어볼까?

흠.. 클릭되는건 좋은데 뭔가 클릭됬다는 표시가 있으면 좋겠네요! 다음에는 클릭된 오브젝트 주변에 외곽선을표시하여 클릭이 되었음을 알리는 효과를 줘보도록하겠습니다.!