눈팅하는 게임개발자 블로그
OpenGL Advanced 5-10 Instancing 본문
원문 사이트
learnopengl.com/Advanced-OpenGL/Instancing
번역 사이트
gyutts.tistory.com/158?category=755809
Instancing
대부분의 모델에 같은 포맷의 데이터 세팅이 포함되어 있지만
다른 world transformation을 가지는 많은 모델을 그리는 장면이 있다고 가정해보자.
화면이 잔디 잎으로 가득 찬 장면을 상상해보자. 각 잔디 잎 자체는 소수의 삼각형으로 구성된 작은 모형이다.
그 삼각형들 중 몇개를 그려야 할 것이고, 수천 또는 수만 줄의 잔디 잎으로 scene이 채워질 것이며.
각 프레임을 렌더링해야 한다.
각 잎은 단지 몇 개의 삼각형으로 구성되어 있기 때문에 각 잎을 렌더링하는 것은 큰 문제가 없지만.
수천 개의 렌더링 호출을 수행하면 성능이 크게 저하된다.
실제로 많은 양의 객체를 렌더링한다면 코드는 다음과 같을 것이다.
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에게 이 데이터를 사용해 단일 도면 호출로 여러 객체를 그리는 것이
훨씬 더 편할 것이다. Instancing을 입력하는 것이다.
인스턴싱은 객체를 렌더링해야 할 때마다 모든 CPU로부터 GPU로의 통신에 드는 비용을 절약하면서
단일 렌더링 호출로 많은 객체를 한 번에 그려주는 기술이다.
이는 한 번만 이루어져야 한다. 인스턴스화를 사용해 렌더링하려면 렌더 호출 glDrawArrays 및 glDrawElements를
각 glDrawArrayInstanced 및 glDrawElementsInstanced로 변경해야 한다.
이러한 인스턴스 렌더링된 버전의 클래식 렌더링 함수는 렌더링 할 인스턴스 수를 설정하는 인스턴스 카운트라는
추가 매개 변수를 사용한다.
필요한 모든 데이터를 GPU에 한번만 전송한 다음 GPU에 단일 호출로 이 모든 인스턴스를 그려야 하는 방법을 알려준다.
그 다음 GPU는 CPU와 이 이상 데이터를 주고받지 않고 이러한 모든 인스턴스를 렌더링한다.
이 기능 자체만으로는 크게 의미가 없다. 동일한 객체를 수천 번 렌더링 하는 것은 렌더링 된 객체가
모두 동일하게 렌더링되고 동일한 위치에 렌더링 되기 때문에 아무런 소용이 없다.
수천 개의 객체를 렌더링 한다 하더라도 단지 하나의 대상만이 보일 뿐이다.
이러낳 이유로 GLSL은 gl_InstanceID라 불리는 또 다른 built-in 변수를 vertex 쉐이더에 추가하였다.
인스턴스 드로잉에 대한 느낌을 얻기 위해 하나의 렌더 호출로 정규화된 장치 좌표에 100개의 2D 쿼드를
렌더링하는 간단한 예제를 실행해보자.
100개의 오프셋 벡터의 일정한 배열을 인덱싱하여 인스턴스화된 각 쿼드에 작은 오프셋을 추가하여 이 작업을
수행한다.
결과는 다음과 같다.
각 쿼드들은 2개의 삼각형, 총 6개의 정점으로 구성된다.
각 정점에는 2D NDC 위치 벡터와 컬러 벡터가 포함된다.
다음은 이 예제에서 사용된 정점 데이터이다.
float quadVertices[] = {
-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;
}
}
여기서 10 x 10 격자의 모든 위치에 대한 translation 벡터가 포함된 100개의 translation 벡터 집합을 만든다.
translation 배열을 생성하는 것 외에도 데이터를 정점 쉐이더의 유니폼 배열로 전송해야 한다.
shader.use();
for (size_t 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와 완전히 같다.
10 x 10 격자에 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로 설정하면 두 개의 인스턴스마다 내용이 업데이트된다. 속성 divisor을 1로 설정함으로써 속성 위치 2의 정점
속성이 인스턴스화된 배열임을 OpenGL에게 효과적으로 알린다.
glDrawArrayInstance를 사용해 쿼드를 다시 렌더링하면 결과는 다음과 같다.
이는 이전 예제와 완전히 동일하지만 이번에는 인스턴스화 된 배열을 사용해 완성했다.
인스턴스화 된 드로잉을 위해 정점 쉐이더에 훨씬 많은 데이터를 전달할 수 있다.
추가적으로 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에 가까울수록
쿼드가 원래 크기로 회복된다.
An asteroid field
큰 소행성 고리의 중심에 있는 하나의 큰 행성을 포함하는 씬을 만든다고 한다면,
소행성 고리는 수천 또는 수만 개의 암석을 포함할 수 있으며
오래된 그래픽카드에서는 이를 신속하게 렌더링할 수 없다.
이 시나리오는 모든 소행성이 단일 모델을 사용해 표현될 수 있기 때문에 인스턴스 렌더링에 특히 유용하다.
각각의 단일 소행성은 각 소행성에 고유한 변형 행렬을 사용해 사소한 변형을 포함한다.
인스턴스 렌더링의 영향을 보여주기 위해 먼저 인스턴스 렌더링없이 행성 주위를 비행하는 소행성의 장면을 렌더링한다.
코드 샘플에서 이전에 모델 로드 강좌에서 정의한 모델 로더를 사용해 모델을 로드한다.
해당 효과를 얻기 위해 모델 매트릭스로 사용할 각 소행성에 대한 변환 행렬을 생성할 것이다.
변환 행렬은 소행성 고리의 어딘가에서 처음으로 바위를 translate 함으로써 만들어진다.
그 다음 회전 벡터 주위에 임의의 스케일과 임의의 회전을 적용한다.
그 결과 행성 주변의 각 소행성을 다른 소행성과 비교해 더욱 자연스럽고 독창적인 모양으로 변형시키는 변형 행렬이
생성된다. 결과는 각 소행성이 다른 것과 다르게 보이는 소행성으로 가득 찬 반지이다.
unsigned int amount = 1000;
glm::mat4* modelMatrices;
modelMatrices = new glm::mat4[amount];
srand(glfwGetTime());
float radius = 50.0;
float offset = 2.5f;
for (size_t i = 0; i < amount; i++)
{
glm::mat4 model;
// 1. translation
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;
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
float scale = (rand() % 20) / 100.0f + 0.05f;
model = glm::scale(model, glm::vec3(scale));
float rotAngle = (rand() % 360);
model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f));
modelMatrices[i] = model;
}
이 코드는 다소 복잡해보일 수 있지만 근본적으로 반경에 의해 정의된 반경을 가진 원을 따라
소행성의 x와 z 위치를 변형시키고 각 소행성을 오프셋과 오프셋에 의해 원 주위로 무작위로 배치한다.
보다 평평한 소행성 고리를 만들기 위해 y변위에 미치는 영향은 최소화한다.
그 다음 크기 및 회전 변환을 적용하고 결과 변환 행렬을 크기 양의 modelMatrices에 저장한다.
여기서 총 1000개의 모델 행렬, 소행성 당 하나씩을 생성한다.
행성 모델과 바위 모델을 로드하고 쉐이더 세트를 컴파일한 후 결과는 다음과 같다.
이 장면은 프레임 당 총 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()
{
TexCoords = aTexCoords;
gl_Position = projection * view * instanceMatrix * vec4(aPos, 1.0f);
}
더 이상 모델 유니폼 변수를 사용하지 않고 mat4를 정점 속성으로 선언해 인스턴스화된 변형 행렬을 저장할 수 있다.
그러나 vec4보다 큰 정점 속성으로 데이터 유형을 선언할 때 이는 약간 다르게 동작하는데,
정점 속성으로 허용되는 최대 데이터 양은 vec4와 같다.
mat4는 기본적으로 4개의 vec4이므로, 이 특정 행렬에 대해 4개의 정점 속성을 예약해야 한다.
3의 위치를 지정했기 때문에 행렬의 열은 3, 4, 5, 6의 정점 속성 위치를 갖는다.
그 다음 4개의 정점 속성의 각 속성 포인터를 설정하고 인스턴스화된 배열로 구성해야 한다.
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);
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*)(vec4Size * 2));
glEnableVertexAttribArray(6);
glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(vec4Size * 3));
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만개가 그러진 것과 같다.
이와 같이 적절한 유형의 환경에서는 렌더링이 인스턴스화 되어 그래픽 카드의 렌더링 기능과 큰 차이가 발생할 수 있다.
이러한 이유 때문에 인스턴스 렌더링은 잔디, 식물군, 파티클 및 장면에 일반적으로 사용된다.
기본적으로 반복되는 모양이 많은 장면은 인스턴스 렌더링에서의 이점을 얻을 수 있다.
/////////////////////
인스턴스 렌더링이 기본적으로 빠른 이유는
CPU에서 GPU로 데이터를 전송하는 만큼의 비용을 아껴서
그만큼 많은 객체를 렌더링하는 데에 드는 비용을 줄인다는 것.
'공부한거 > OpenGL' 카테고리의 다른 글
OpenGL Advanced Lighting 6-1 Advanced Lighting (0) | 2020.12.12 |
---|---|
OpenGL Advanced 5-11 Anti aliasing (0) | 2020.12.12 |
OpenGL Advanced 5-9 Geometry Shader (0) | 2020.12.11 |
OpenGL Advanced 5-8 Advanced GLSL (0) | 2020.12.11 |
OpenGL Advanced 5-7 Advanced Data (0) | 2020.12.10 |