본문 바로가기

Game/Graphics

Learn OpenGL - Advanced OpenGL : Instancing

link : https://learnopengl.com/Advanced-OpenGL/Instancing


Instancing


 대부분의 모델에 동일한 정점 데이터 세트가 포함되어 있지만 다른 세계 변형이 있는 많은 모델을 그리는 장면이 있다고 가정해보자.


잔디 잎으로 가득찬 장면을 생각해보아라. 각 잔디잎은 소수의 삼각형으로 구성된 작은 모형이다.


당신은 아마 그 중 몇개를 그리기를 원할 것이고, 당신의 장면은 수천 또는 수만 줄의 잔디 잎으로 끝날 것이며,


각 프레임을 렌더링해야 할 것이다. 각 잎은 단지 몇 개의 삼각형으로 구성되어 있기 때문에 잎은 거의 즉시 렌더링되지만,


수천 개의 렌더링 호출을 수행하면 성능이 크게 저하된다.



 실제로 많은 양의 객체를 렌더링한다면 코드에서 다음과 같이 보일 것이다:

for(unsigned int i = 0; i < amount_of_models_to_draw; i++)
{
    DoSomePreparations(); // bind VAO, bind textures, set uniforms etc.
    glDrawArrays(GL_TRIANGLES, 0, amount_of_vertices);
}

 이처럼 많은 모델 인스턴스를 드로잉 할 때 많은 드로잉 호출로 인해 성능 병목 현상이 발생한다. 실제 정점 렌더링에 비해


glDrawArrays 또는 glDrawElements와 같은 기능을 사용해 정점 데이터를 렌더링하도록 GPU에 알리는 것은 OpenGL이


정점 데이터를 그리기 전에 필요한 준비를 해야 하기 때문에 상당한 성능을 발휘한다.



 GPU에 데이터를 한 번 보내면 OpenGL에게 이 데이터를 사용해 단일 도면 호출로 여러 객체를 그리는 것이 훨씬 더 편리할 것이다.


인스턴싱을 입력해라.



 인스턴싱은 우리가 객체를 렌더링해야 할 때마다 모든 CPU -> GPU 통신을 절약하면서 단일 렌더링 호출로 많은 객체를 한 번에


그려주는 기술이다. 이것은 한 번만 해야합니다. 인스턴스화를 사용해 렌더링하려면 렌더 호출 glDrawArrays 및 glDrawElements를


각각 glDrawArraysInstanced 및 glDrawElementsInstanced로 변경해야한다. 이러한 인스턴스 렌더링된 버전의 클래식 렌더링 함수는


렌더링 할 인스턴스 수를 설정하는 인스턴스 카운트라는 추가 매개 변수를 사용한다. 우리는 필요한 모든 데이터를 GPU에 한 번만


전송한 다음 GPU에 단일 호출로 이 모든 인스턴스를 그려야하는 방법을 알려준다. 그런 다음 GPU는 CPU와 계속 통신하지 않고


이러한 모든 인스턴스를 렌더링한다.



 이 기능 자체만으로는 쓸모가 없다. 동일한 객체를 수천 번 렌더링하는 것은 렌더링 된 객체가 모두 동일하게 렌더링되고


동일한 위치에 렌더링되기 때문에 아무 소용이 없다. 우리는 단지 하나의 대상을 볼 것이다. 이러한 이유로 GLSL은 gl_InstanceID라


불리는 또 다른 built-in 변수를 정점 쉐이더에 임베드했다.



 인스턴스 드로잉에 대한 느낌을 얻기 위해 우리는 하나의 렌더 호출로 정규화된 장치 좌표에 100개의 2D 쿼드를 렌더링하는


간단한 예제를 보여준다. 100개의 오프셋 벡터의 일정한 배열을 인덱싱해 인스턴스화된 각 쿼드에 작은 오프셋을 추가해


이 작업을 수행한다. 그 결과 창 전체를 채우는 깔끔하게 정리된 사각형이 있다:


100 Quads drawn via OpenGL instancing.


각 쿼드는 총 6개의 정점이 있는 2개의 삼각형으로 구성된다. 각 정점에는 2D NDC 위치 벡터와 색상 벡터가 포함된다.


다음은 이 예제에서 사용된 정점 데이터이다. 삼각형은 화면을 대량으로 적절하게 맞추기에는 매우 작다:

float quadVertices[] = {
    // positions     // colors
    -0.05f,  0.05f,  1.0f, 0.0f, 0.0f,
     0.05f, -0.05f,  0.0f, 1.0f, 0.0f,
    -0.05f, -0.05f,  0.0f, 0.0f, 1.0f,

    -0.05f,  0.05f,  1.0f, 0.0f, 0.0f,
     0.05f, -0.05f,  0.0f, 1.0f, 0.0f,   
     0.05f,  0.05f,  0.0f, 1.0f, 1.0f		    		
};  

 쿼드의 색상은 정점 쉐이더에서 전달된 색상 벡터를 받고, 색상 출력으로 설정하는 조각 쉐이더로 수행된다:

#version 330 core
out vec4 FragColor;
  
in vec3 fColor;

void main()
{
    FragColor = vec4(fColor, 1.0);
}

 정점 쉐이더이다:

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;

out vec3 fColor;

uniform vec2 offsets[100];

void main()
{
    vec2 offset = offsets[gl_InstanceID];
    gl_Position = vec4(aPos + offset, 0.0, 1.0);
    fColor = aColor;
}  

 여기에서는 총 100개의 오프셋 벡터를 포함하는 오프셋이라는 균일한 배열을 정의했다. 정점 쉐이더 내에서 gl_InstanceID를 사용해


오프셋 배열을 인덱싱해 각 인스턴스에 대한 오프셋 벡터를 검색한다. 인스턴스 드로잉을 사용해 100개의 쿼드를 그리려면 이 정점 쉐이더만으로


100개의 쿼드를 다른 위치에 배치해야한다.



 우리는 게임 루프를 시작하기 전에 중첩된 for-loop에서 계산된 오프셋 위치를 실제로 설정해야한다:

glm::vec2 translations[100];
int index = 0;
float offset = 0.1f;
for(int y = -10; y < 10; y += 2)
{
    for(int x = -10; x < 10; x += 2)
    {
        glm::vec2 translation;
        translation.x = (float)x / 10.0f + offset;
        translation.y = (float)y / 10.0f + offset;
        translations[index++] = translation;
    }
}  

 여기에서는 10x10 격자의 모든 위치에 대한 translation 벡터가 포함된 100개의 translation 벡터 집합을 만든다. translation 배열을 생성하는


것 외에도 데이터를 정점 쉐이더의 유니폼 배열로 전송해야한다:

shader.use();
for(unsigned int i = 0; i < 100; i++)
{
    stringstream ss;
    string index;
    ss << i; 
    index = ss.str(); 
    shader.setVec2(("offsets[" + index + "]").c_str(), translations[i]);
}  

 이 코드의 단편에서 for 루프 카운터 i 를 문자열로 변환해 균일한 위치를 쿼리하기위한 위치 문자열을 동적으로 생성한다.


오프셋 유니폼 배열의 각 항목에 대해 해당 translation 벡터를 설정한다.



 이제 모든 준비가 끝났으므로 쿼드 렌더링을 시작할 수 있다. 인스턴스 렌더링을 통해 그리려면 glDrawArraysInstanced 또는


glDrawElementsInstanced를 호출한다. 요소 인덱스 버퍼를 사용하지 않으므로 glDrawArrays 버전을 호출한다:

glBindVertexArray(quadVAO);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100);  

 glDrawArraysInstanced의 매개 변수는 그릴 인스턴스의 수를 설정하는 마지막 매개 변수를 제외하고, glDrawArrays와 완전히 같다.


10x10 격자에 100개의 사각형을 표시하고자하므로 100으로 설정한다. 이제 코드를 실행하면 100개의 다채로우 사각형에 친숙한 이미지를 얻는다.





Instanced arrays


 이전 구현은 이 특정 유스 케이스에서 잘 작동하지만, 100개 이상의 인스턴스를 렌더링 할 때마다 우리는 결국 쉐이더에 보낼 수 있는 균일한


데이터의 양을 제한하게 된다. 정점 쉐이더가 새 인스턴스를 렌더링 할 때마다 업데이트되는 정점 속성으로 정의되는 인스턴스화된 배열을


사용하는 또 다른 방법이 있다.



 정점 속성을 사용하면 정점 쉐이더를 실행할 때마다 GLSL이 현재 정점에 속한 다음 정점 속성 세트를 검색하게 된다. 그러나 인스턴스화 된


배열로 정점 속성을 정의 할 때, 정점 쉐이더는 정점 대신 인스턴스당 정점 속성의 내용만 업데이트한다. 이를 통해 정점당 데이터에 표준


정점 속성을 사용하고 인스턴스별로 고유한 데이터를 저장하기 위해 인스턴스 배열을 사용할 수 있다.



 인스턴스화 된 배열의 예를 들어주기 위해 이전 예제를 사용하고 오프셋된 균일 배열을 인스턴스화 된 배열로 나타낸다. 또 다른 정점 속성을


추가해 정점 쉐이더를 업데이트해야한다:

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aOffset;

out vec3 fColor;

void main()
{
    gl_Position = vec4(aPos + aOffset, 0.0, 1.0);
    fColor = aColor;
}  

 우리는 더 이상 gl_InstanceID를 사용하지 않으며 큰 균일 배열로 인덱싱하지 않고 offset 특성을 직접 사용할 수 있다.



 인스턴스화 된 배열은 위치 및 색상 변수와 마찬가지로 정점 속성이기 때문에 해당 내용을 정점 버퍼 객체에 저장하고 속성 포인터를


구성해야 한다. 우리는 먼저 새로운 버퍼 객체에 변환 배열을 저장하려고 한다:

unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0); 

 그런 다음 정점 속성 포인터를 설정하고 정점 속성을 활성화해야한다:

glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
glBindBuffer(GL_ARRAY_BUFFER, 0);	
glVertexAttribDivisor(2, 1);  

 이 코드를 흥미롭게 만드는 것은 glVertexAttribDivisor라고 하는 마지막 행이다. 이 함수는 OpenGL이 정점 속성의 내용을 다음 요소로


업데이트 할 시기를 알려준다. 첫 번째 매개변수는 문제의 정점 특성이고, 두 번째 매개 변수는 특성 제수이다.


기본적으로 속성 divisor는 0이며 OpenGL에게 정점 쉐이더의 반복마다 정점 속성의 내용을 업데이트하도록 지시한다.


이 속성을 1로 설정하면 새로운 인스턴스를 렌더링하기 시작할 때 정점 속성의 내용을 업데이트하려고 한다는 것을 OpenGL에 알린다.


이 값을 2로 설정하면 2 인스턴스마다 내용이 업데이트된다. 속성 divisor를 1로 설정함으로써 속성 위치 2의 정점 속성이 인스턴스화된


배열임을 OpenGL에 효과적으로 알린다.



 glDrawArraysInstanced를 사용해 쿼드를 다시 렌더링하면 다음과 같은 결과가 나타난다:


Same image of OpenGL instanced quads, but this time using instanced arrays.


 이것은 이전 예제와 완전히 동일하지만 이번에는 인스턴스화 된 배열을 사용해 완성했다. 인스턴스화 된 드로잉을 위해 정점 쉐이더에


훨씬 많은 데이터를 전달할 수 있다.



 재미를 위해서 우리는 gl_InstanceID를 다시 사용해 오른쪽 위부터 아래 왼쪽으로 각 쿼드를 천천히 축소 할 수 있다.

void main()
{
    vec2 pos = aPos * (gl_InstanceID / 100.0);
    gl_Position = vec4(pos + aOffset, 0.0, 1.0);
    fColor = aColor;
} 

 결과적으로 쿼드의 첫 번째 인스턴스가 극히 작게 그려지고 인스턴스를 그리는 과정에서 gl_InstanceId가 100에 가까울 수록 쿼드가 원래


크기로 회복된다. gl_InstanceID와 함께 인스턴스 배열을 사용하는 것은 완벽하게 합법적이다:









An asteroid field


 우리가 큰 소행성 고리의 중심에 있는 하나의 큰 행성을 가지고 있는 장면을 상상해보아라. 그러한 소행성 고리는 수천 또는 수만 개의 암석을


포함 할 수 있으며, 오래된 그래픽 카드에서는 신속하게 렌더링되지 않게 된다. 이 시나리오는 모든 소행성이 단일 모델을 사용해 표현 될 수


있기 때문에 인스턴스 렌더링에 특히 유용하다. 각각의 단일 소행성은 각 소행성에 고유한 변형 행렬을 사용해 사소한 변형을 포함한다.



 인스턴스 렌더링의 영향을 보여주기 위해 먼저 인스턴스 렌더링없이 행성 주위를 비행하는 소행성의 장면을 렌더링한다.


이 장면은 여기에서 다운로드 할 수 있는 거대한 행성 모델과 우리가 행성 주위에 적절하게 배치한 많은 소행성 암석을 포함한다.


소행성 암석 모델은 여기에서 다운로드 할 수 있다.



 코드 샘플에서 이전에 모델로드 자습서에서 정의한 모델 로더를 사용해 모델을 로드한다.



 우리가 찾고 있는 효과를 얻기 위해 우리는 모델 매트릭스로 사용할 각 소행성에 대한 변형 행렬을 생성 할 것이다. 변환 행렬은 소행성 고리의


어딘가에서 처음으로 바위를 translate함으로써 만들어진다. 그런 다음 회전 벡터 주위에 임의의 스케일과 임의의 회전을 적용한다.


그 결과 행성 주변의 각 소행성을 다른 소행성과 비교해 더욱 자연스럽고 독창적인 모양으로 변형시키는 변형 행렬이 생성된다.


결과는 각 소행성이 다른 것과 다르게 보이는 소행성으로 가득 찬 반지이다.

unsigned int amount = 1000;
glm::mat4 *modelMatrices;
modelMatrices = new glm::mat4[amount];
srand(glfwGetTime()); // initialize random seed	
float radius = 50.0;
float offset = 2.5f;
for(unsigned int i = 0; i < amount; i++)
{
    glm::mat4 model;
    // 1. translation: displace along circle with 'radius' in range [-offset, offset]
    float angle = (float)i / (float)amount * 360.0f;
    float displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float x = sin(angle) * radius + displacement;
    displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float y = displacement * 0.4f; // keep height of field smaller compared to width of x and z
    displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float z = cos(angle) * radius + displacement;
    model = glm::translate(model, glm::vec3(x, y, z));

    // 2. scale: Scale between 0.05 and 0.25f
    float scale = (rand() % 20) / 100.0f + 0.05;
    model = glm::scale(model, glm::vec3(scale));

    // 3. rotation: add random rotation around a (semi)randomly picked rotation axis vector
    float rotAngle = (rand() % 360);
    model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f));

    // 4. now add to list of matrices
    modelMatrices[i] = model;
}  

 이 코드 조각은 약간 위협적으로 보일지 모르지만 근본적으로 반경에 의해 정의된 반경을 가진 원을 따라 소행성의 x와 z 위치를 변형시키고


각 소행성을 오프셋과 오프셋에 의해 원 주위로 무작위 대체한다. 보다 평평한 소행성 고리를 만들기 위해 y 변위에 미치는 영향을 줄인다.


그런 다음 크기 및 회전 변환을 적용하고 결과 변환 행렬을 크기 양의 modelMatrices에 저장한다. 여기서 우리는 총 1000개의 모델 행렬,


소행성 당 하나씩을 생성한다.



 행성 모델과 바위 모델을 로드하고 쉐이더 세트를 컴파일 한 후에 렌더링 코드는 다음과 같이 보인다:


Image of asteroid field drawn in OpenGL


 이 장면은 프레임 당 총 1001회의 렌더링 호출을 포함하며 이 중 1000개는 암석 모델이다.



 이 숫자를 늘리자마자 장면이 부드럽게 움직이지 않으며 초당 렌더링 할 수 있는 프레임 수가 급격히 줄어든다.


amount를 2000으로 설정하면 더 느려져서 이동하기가 어렵다.



 이번에는 인스턴스 렌더링을 사용해 이 장면을 렌더링해보자. 우선 정점 쉐이더를 약간 수정해야 한다:

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in mat4 instanceMatrix;

out vec2 TexCoords;

uniform mat4 projection;
uniform mat4 view;

void main()
{
    gl_Position = projection * view * instanceMatrix * vec4(aPos, 1.0); 
    TexCoords = aTexCoords;
}

 더 이상 모델 유니폼 변수를 사용하지 않고 mat4를 정점 속성으로 선언해 인스턴스화 된 변형 행렬을 저장할 수 있다.


그러나 vec4보다 큰 정점 속성으로 데이터 유형을 선언 할 때 상황이 약간 다르게 작동한다. 정점 속성으로 허용되는 최대 데이터 양은


vec4와 같다. mat4는 기본적으로 4개의 vec4이므로, 이 특정 행렬에 대해 4개의 정점 속성을 예약해야 한다. 3의 위치를 지정했기 때문에


행렬의 열은 3, 4, 5, 6의 정점 속성 위치를 갖는다.



 그런 다음 4개의 정점 속성의 각 속성 포인터를 설정하고 인스턴스화 된 배열로 구성해야한다:

// vertex Buffer Object
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, amount * sizeof(glm::mat4), &modelMatrices[0], GL_STATIC_DRAW);
  
for(unsigned int i = 0; i < rock.meshes.size(); i++)
{
    unsigned int VAO = rock.meshes[i].VAO;
    glBindVertexArray(VAO);
    // vertex Attributes
    GLsizei vec4Size = sizeof(glm::vec4);
    glEnableVertexAttribArray(3); 
    glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)0);
    glEnableVertexAttribArray(4); 
    glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(vec4Size));
    glEnableVertexAttribArray(5); 
    glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(2 * vec4Size));
    glEnableVertexAttribArray(6); 
    glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(3 * vec4Size));

    glVertexAttribDivisor(3, 1);
    glVertexAttribDivisor(4, 1);
    glVertexAttribDivisor(5, 1);
    glVertexAttribDivisor(6, 1);

    glBindVertexArray(0);
}  

 Mesh의 VAO 변수를 private 변수 대신에 public 변수로 선언함으로써 우리는 조금 더 속임수를 사용해 Vertex Array 객체에 접근 할 수 있었다.


이것은 가장 깨끗한 해결책은 아니지만 이 튜토리얼에 맞게 간단한 수정만 하면 된다. 작은 해킹을 제외하고 이 코드는 명확해야 한다.


우리는 기본적으로 OpenGL이 각 행렬의 정점 속성에 대해 버퍼를 어떻게 해석해야하는지, 그리고 각 정점 속성이 인스턴스화 된 배열인지를


선언하고 있다.



 다음으로 glDrawElementsInstanced를 사용해 mesh의 VAO를 다시 가져온다:

// draw meteorites
instanceShader.use();
for(unsigned int i = 0; i < rock.meshes.size(); i++)
{
    glBindVertexArray(rock.meshes[i].VAO);
    glDrawElementsInstanced(
        GL_TRIANGLES, rock.meshes[i].indices.size(), GL_UNSIGNED_INT, 0, amount
    );
}  

 인스턴스 렌더링을 하지 않으면 1000~1500 개의 소행성을 부드럽게 렌더링 할 수 있었는데, 인스턴스 렌더링을 사용하면


100000 개의 소행성도 렌더링 할 수 있다. 576개의 정점이 있는 암석 모델은 성능 저하없이 각 프레임에 5700만 개가 그려진 것과 같다!



 보시다시피 적절한 유형의 환경에서는 렌더링이 인스턴스화되어 그래픽 카드의 렌더링 기능과 큰 차이가 발생할 수 있다. 이러한 이유 때문에


인스턴스 렌더링은 잔디, 식물군, 파티클 및 장면에 일반적으로 사용된다. 기본적으로 반복되는 모양이 많은 장면은 인스턴스 렌더링에서 이점을


얻을 수 있다.






** ** ** ** ** ** ** ** ** **


인스턴스를 했을 때와 안 했을 때를 둘 다 코드 돌려보니 차이가 확연했다.


프로젝트에서 마을을 그릴 때 잔디가 필요할 것으로 생각되는데 그 때 다시 사용해야겠다.