C++와 OpenGL로 단순화된 3D Gaussian Splatting 렌더러를 처음부터 구현하며, 구면 조화 함수 색상, 공분산 투영, 타원 스플랫 렌더링, 정렬까지 핵심 수학과 파이프라인을 설명합니다.
3D Gaussian splatting은 다음 질문에 답하는 기법입니다. 장면의 사진 데이터셋이 주어졌을 때, 이를 어떻게 3D로 재구성할 수 있을까요? 이는 머신러닝 알고리즘을 사용해 다양한 카메라 각도에 대해 다음 과정을 수행함으로써 달성됩니다. 장면을 렌더링하고, 같은 카메라 각도에서 촬영된 사진과 비교한 뒤, 렌더링된 이미지와 정답 이미지 사이의 차이를 줄이도록 장면을 업데이트합니다. 하지만 전통적인 3D 렌더러와는 달리, 3DGS는 삼각형을 기본 프리미티브로 사용하지 않고 Gaussian splat 이라는 객체를 사용하므로, 렌더링 알고리즘도 3DGS에 고유한 방식이 됩니다.
이 글에서는 약 1000줄 정도의 코드로 단순화된 렌더러를 처음부터 만들어 보면서 3D Gaussian splatting이 어떻게 동작하는지 보여주는 것을 목표로 합니다. 주된 목적은 3DGS 수학에 대한 직관을 쌓는 것입니다. 선형대수, 확률론, 컴퓨터 그래픽스의 기초를 이해하고 있으면 좋습니다.
렌더러는 C++와 OpenGL로 작성되었습니다. 모든 코드는 GitHub에 공개되어 있지만, 이 튜토리얼은 어떤 그래픽스 엔진(WebGPU, Metal, DirectX…)으로도 재현할 수 있을 만큼 일반적으로 설명하려고 했습니다. 튜토리얼이 끝나면 다음과 같은 Gaussian 장면을 실시간으로 렌더링할 수 있게 됩니다.
또한 WASD와 마우스로 탐색할 수 있는 인터랙티브 WebGPU 시각화도 포함했습니다.
이 글은 학습이 아니라 렌더링만 다룹니다. 하지만 3DGS 렌더러 Kerbl et al. 2023 에는 학습 파이프라인과 강하게 결합된 기술적 결정들(미분 가능성, 양의 준정부호 공분산 보존)이 있으며, 이 글에서 그것들도 짚어 보겠습니다.
먼저 렌더링할 장면이 필요합니다. 이 글을 쓰는 시점에는 좋은 3DGS 장면을 여전히 찾기 어렵지만, 다행히 Supersplat이 최근 자사 웹사이트에서 splat을 다운로드할 수 있게 지원했으므로, 여기서는 그 방법을 사용하겠습니다. 비교적 가벼운 장면(약 20만 개 splat)인 이 토마토 접시 장면을 사용할 것이지만, 원한다면 다른 장면을 사용해도 됩니다. 다만 우리가 만들 렌더러는 큰 장면에 최적화되어 있지는 않다는 점은 유의하세요.
그다음 Supersplat에서 다운로드한 Gaussian splat 장면을 불러오겠습니다. 이렇게 하면 장면의 방향이 올바른지 확인할 수 있고, 더 중요한 점으로 splat이 실제로 무엇인지 처음으로 살펴볼 수 있습니다. 일반적인 포맷은 .ply이며, 이를 위해 ply_loader.h에 커스텀 로더를 포함해 두었습니다. 이 로더는 .ply 파일을 Scene으로 감싼 GaussianSplat 객체 배열로 파싱합니다.
constexpr int SH_COUNT = 16;
constexpr int SH_CHANNEL_COUNT = 3;
constexpr int SH_FLOAT_COUNT = SH_COUNT * SH_CHANNEL_COUNT;
struct GaussianSplat {
glm::vec3 centroid = glm::vec3(0.0f);
float opacity = 0.0f;
std::array<float, SH_FLOAT_COUNT> sphericalHarmonics = {};
std::array<float, 3> scale = {0.0f, 0.0f, 0.0f};
std::array<float, 4> rotation = {1.0f, 0.0f, 0.0f, 0.0f};
};
struct Scene {
std::vector<GaussianSplat> splats;
};
이를 하나씩 살펴보겠습니다.
centroid는 월드 공간 좌표계에서의 splat 위치입니다. 이는 모델 데이터가 모델 공간에 표현되는 일반적인 그래픽스의 Model-View-Projection 파이프라인과 다릅니다. 따라서 3DGS 파이프라인에는 Model 행렬이 없고, View 행렬(3D 월드 공간 -> 3D 카메라 공간)과 projection 행렬(3D 카메라 공간 -> 2D 화면 공간)만 존재합니다.scale과 rotation은 splat의 기하를 설명하지만, 이는 뒤에서 다시 다루겠습니다.opacity와 sphericalHarmonics는 splat의 가시성과 색을 설명하지만, 이것도 뒤에서 다시 다루겠습니다.학습된 3DGS 장면에서는, 알려진 카메라 시점에서 splat들을 렌더링하고 그 결과를 학습용 사진과 비교한 뒤 이미지 오차를 각 splat의 centroid, scale, rotation, opacity, 색 계수로 역전파함으로써 이러한 값들이 학습 중 최적화됩니다.
첫 번째 간단한 확인 단계로, 3DGS 장면을 불러오고 각 splat의 centroid를 GL_POINTS로 그려 봅시다(예를 들어 포인트 클라우드처럼).
Scene scene = loadPly("scene.ply");
std::vector<glm::vec3> centroids;
centroids.reserve(scene.splats.size());
for (const GaussianSplat& splat : scene.splats) {
centroids.push_back(splat.centroid);
}
GLuint vao = 0;
GLuint vbo = 0;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER,
centroids.size() * sizeof(glm::vec3),
centroids.data(),
GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), nullptr);
glDrawArrays(GL_POINTS, 0, static_cast<GLsizei>(centroids.size()));
이제 포인트 클라우드가 생겼으니 색을 추가해 봅시다! 3DGS가 매우 좋아 보이는 이유 중 하나는 시점 의존적인 색을 포착한다는 점입니다. 반짝이는 물체, 하이라이트, 작은 반사는 카메라가 움직일 때 함께 변합니다. 이는 색을 단일 RGB 값이 아니라 구면 조화 함수(SH, spherical harmonics)로 저장함으로써 달성됩니다. 이렇게 하면 각 splat이 시선 방향에 따라 달라지는 색을 가질 수 있어, 광택이 있는 재질을 표현하는 데 유용합니다.
여기서는 SH를 깊게 다루지는 않겠지만, 직관은 꽤 단순합니다. 이는 푸리에 급수와 비슷하게, 구 위의 임의의 함수를 기저 함수들로 표현하는 방법입니다. 이를 통해 각 splat에 대해 방향 에서의 RGB 색을 출력하는 함수를 가질 수 있습니다.
정의는 다음과 같습니다.
where are the SH basis functions and are the RGB coefficients stored in the splat.
SH에 대한 더 시각적인 설명이 필요하다면 Visual Notes on Spherical Harmonics를 추천합니다.
주어진 카메라 시점에 대한 RGB를 복원하려면, 카메라에서 splat centroid로 향하는 방향을 정규화하고, 그 방향에서 SH 기저를 평가한 다음, 각 기저값에 저장된 RGB 계수를 곱해 모두 더합니다. 그 후 +0.5 바이어스를 적용하고(SH 출력이 0 중심일 때 검정이 아니라 중간 회색 근처가 되도록), [0, 1]로 클램프합니다.
셰이더 관점에서 보면, 이 연산은 대략 다음과 같습니다.
vec3 rgb = vec3(0.5);
for (int i = 0; i < 16; ++i) {
rgb += shCoefficient[i] * shBasis(i, direction);
}
rgb = clamp(rgb, 0.0, 1.0);
실제 셰이더에서는 SH 기저 함수를 루프 대신 명시적으로 전개해서 작성하지만, 여기에는 너무 길어서 싣지 않겠습니다.
이제 색이 있는 포인트 클라우드가 생겼습니다. 시선 방향에 따라 색이 변하는 모습이 벌써 보일 것입니다!
이제 포인트 클라우드를 떠나 실제 splat으로 들어가 봅시다! 하지만 그러려면 약간의 수학이 필요합니다. 3D Gaussian splatting의 핵심 수학 개념은 바로 Gaussian입니다. 이 글을 확률 수업으로 만들 생각은 없지만, Gaussian 분포에 대해 몇 가지 요소는 염두에 두는 것이 중요하다고 생각합니다.
아마 평균 과 표준편차 로 매개변수화되는 1차원 Gaussian을 본 적이 있을 것입니다.
A 1D Gaussian with mean and variance
이 분포는 2차원이나 3차원으로도 확장할 수 있습니다. 차원 Gaussian의 확률밀도함수는 다음과 같습니다.
In 3D, is now a 3D vector, called the centroid. This is our GaussianSplat.centroid that we loaded earlier. The geometric interpretation of is simply the splat’s position in world space. Our standard deviation becomes a positive semi-definite matrix , called the covariance matrix.
일반적으로 평균 이고 공분산이 인 Gaussian을 말할 때는 로 표기합니다. 여기서 는 정규분포를 뜻하며, Gaussian 분포의 다른 이름입니다.
그리고 2D와 3D에서의 모습은 다음과 같습니다.
The iso-density contours of a 2D Gaussian are concentric ellipses that are implied from the eigenvectors and eigenvalues of
A 3D Gaussian is similar to what we have in the 2D case: iso-density surfaces are ellipsoids
이제 3D Gaussian splatting이 어떻게 동작하는지 대략 이해할 수 있습니다.
The splat lives as a probability distribution in 3D, gets projected to a 2D distribution in screen space, and only at the very end becomes pixels
장면의 모든 splat에 대해 이 과정을 수행하면, 그것이 바로 3DGS 장면을 렌더링하는 방식입니다! 다만 여기에는 몇 가지 까다로운 부분이 있습니다.
이 단계에서 이해해야 할 핵심 아이디어는, 1단계와 2단계에서는 래스터화에서 삼각형을 다루듯이 구체적인 기하 객체를 다루는 것이 아니라는 점입니다. 대신 우리는 확률분포를 다루고 있으며, 이것이 마지막 단계에서야 비로소 그릴 수 있는 기하로 바뀝니다. 지금은 조금 추상적으로 느껴질 수 있지만, 곧 더 구체적으로 보게 될 것입니다!
공분산 행렬은 대칭이며 양의 준정부호(PSD)여야 합니다. 직관적으로 말하면, 이는 어떤 축 방향으로 공간을 늘일 수는 있지만 음의 분산을 만들 수는 없다는 뜻입니다. 이는 공분산이 타원체의 크기와 방향을 제어하기 때문에 중요합니다.
3D Gaussian 분포는 와 로 매개변수화되며, 는 대칭 양의 준정부호 행렬들의 집합입니다.
의 중요한 성질 중 하나는, 어떤 회전 행렬 과 스케일링 행렬 가 존재하여 다음을 만족한다는 점입니다.
See the appendix for why this is the case.
이 결과를 반대로 볼 수도 있습니다. 임의의 회전 행렬 과 스케일링 행렬 에 대해, 는 반드시 공분산 행렬이 됩니다.
이 압축된 프리미티브는 3DGS가 실용적인 이유 중 하나입니다. 각 splat의 기하는 centroid 하나, 세 개의 scale, 회전 하나로 기술되므로, 학습 코드가 각 splat마다 최적화해야 할 기하 파라미터 수가 적습니다.
앞에서 본 GaussianSplat 구조체를 기억하시나요? 이 구조체는 전체 공분산 행렬이 아니라, 정확히 이 표현인 scale과 rotation 멤버를 사용합니다!
scale은 로그 공간의 세 개 float입니다. exp(scale)를 적용하면, 그 값들이 스케일링 행렬 의 대각 원소가 됩니다.
rotation은 회전 행렬 을 저장하는 압축된 방법인 quaternion을 인코딩하는 네 개의 float입니다. 여기서는 quaternion을 자세히 다루지 않겠지만, 온라인에 좋은 자료가 많습니다.
왜 이것이 중요할까요? 3DGS 장면은 학습 루프를 통해 생성되며, 그 과정에서 각 Gaussian의 파라미터는 특정 카메라 각도에서 렌더링된 이미지와 정답 이미지 사이의 오차를 역전파하여 업데이트됩니다. 만약 한 번의 업데이트에서 의 원소를 직접 바꾼다면, 업데이트된 행렬이 여전히 PSD라는 보장이 없으므로 더 이상 공분산 행렬이 아니게 됩니다. 하지만 scale과 rotation 파라미터를 업데이트하면, 재구성된 는 반드시 공분산 행렬이 된다는 것을 알 수 있습니다.
방금 보았듯이, 우리의 그래픽스 프리미티브는 확률분포입니다. 점이나 삼각형과는 달리, 이런 프리미티브는 표준 래스터 파이프라인으로 직접 그릴 수 없습니다.
splat을 화면에 올리는 순진한 방법은 분포에서 점들을 샘플링해서 그 점들을 그리는 것입니다. 이렇게 하면 centroid 근처는 빽빽하고 가장자리로 갈수록 성긴 무작위 점 구름처럼 보일 것입니다.
이 접근의 문제는 비효율적이고 노이즈가 많다는 점입니다. 각 splat마다 타원이 부드럽게 보일 만큼 충분한 난수 샘플을 생성해야 하고, 샘플 수가 많지 않으면 결과가 계속 깜빡일 것입니다.
우리는 이미 3DGS 렌더링의 핵심 아이디어를 말했습니다. 이 3D 확률분포를 2D 화면 공간으로 투영하고, 그다음에야 비로소 분포를 근사하는 타원으로 그린다는 것입니다. 이것이 3DGS의 아름다운 아이디어입니다. 파이프라인의 끝까지 우리는 오직 확률분포만 다루며, 2D에 도달한 마지막 단계에서야 이 분포를 실제로 렌더링 가능한 것으로 구체화합니다.
원래의 3DGS 렌더러는 학습 중에 사용되므로 미분 가능하도록 설계되었습니다. 이 튜토리얼 렌더러는 표시 전용이지만, 우리가 이제 사용할 수학은 이러한 제약에서 나왔습니다. splat을 매끄럽게 유지하고, 분포로서 투영하며, 맨 마지막에만 픽셀로 바꾸는 것입니다.
Gaussian의 centroid를 월드 공간에서 화면 공간으로 투영하는 것은, 그것이 3D 공간의 단일 점일 뿐이므로 매우 간단합니다.
우리는 Model-View-Projection 변환을 적용하고자 합니다. 앞서 말했듯이, 3DGS 장면은 이미 월드 공간에 있으므로 Model 행렬은 항등행렬입니다(즉, Model 변환은 생략할 수 있습니다).
그다음 View 행렬을 적용하여 카메라 공간으로 옮기고, Projection 행렬과 perspective divide를 적용해 화면 위로 보냅니다. 그 결과, 화면 공간의 2D 벡터인 를 얻게 됩니다.
이 부분의 목표는 3D 월드 공간에 있는 공분산 행렬을 화면 공간에 있는 2D 공분산 행렬로 바꾸는 것입니다. 이것이 3DGS 렌더링의 핵심입니다!
첫 단계는 quaternion과 스케일링 행렬로부터 3D 공분산 행렬을 재구성하는 것입니다. 앞서 본 것처럼, 우리는 를 가집니다.
glm::mat3 buildCovariance(const GaussianSplat& splat) {
glm::quat q(splat.rotation[0], splat.rotation[1],
splat.rotation[2], splat.rotation[3]);
glm::mat3 R = glm::mat3_cast(glm::normalize(q)); // quaternion -> 3x3 rotation
glm::vec3 sigma(std::exp(splat.scale[0]),
std::exp(splat.scale[1]),
std::exp(splat.scale[2]));
glm::mat3 S = glm::mat3(sigma.x, 0.0f, 0.0f, // diagonal scale matrix
0.0f, sigma.y, 0.0f,
0.0f, 0.0f, sigma.z);
glm::mat3 RS = R * S;
return RS * glm::transpose(RS); // Sigma = (RS)(RS)^T
}
이제 재미있는 부분입니다. 이것을 어떻게 화면 공간으로 투영할까요? 다시 말해, 표준 MVP 파이프라인을 여기에 적용하고 싶습니다.
앞서 언급했듯이 Gaussian splat은 이미 월드 공간에 정의되어 있으므로, Model 행렬은 항등행렬입니다.
MVP 파이프라인의 다음 단계는 View 행렬입니다. 표준 view 행렬은 glm::lookAt()에서 얻는 회전과 평행이동을 결합합니다.
where , , and are the camera’s right, up, and forward axes, and is the camera position.
평행이동은 공분산에 영향을 주지 않습니다. 공분산은 절대 위치가 아니라 centroid 주변의 퍼짐을 기술하기 때문입니다. 이런 이유로 우리는 view 행렬의 회전 성분만 남겨 공분산 행렬에 적용합니다. 수학적으로는 view 행렬의 왼쪽 위 블록만 유지하는 것입니다.
glm::mat4 viewMatrix =
glm::lookAt(cameraPosition, cameraPosition + cameraForward, cameraUp);
// Extract the 3x3 rotation block of the view matrix (drop the translation column).
glm::mat3 M = glm::mat3(viewMatrix);
// Transform covariance to eye space: Sigma' = M Sigma M^T
glm::mat3 Sigma_eye = M * Sigma * glm::transpose(M);
이 시점에서 Gaussian 분포는 월드 공간에서 눈 공간, 즉 카메라 앞쪽 공간으로 변환되었습니다. 즉, 를 얻었습니다.
잠깐, 왜 를 하는 걸까요? 그래픽스 파이프라인에서 보통 하듯이 단순히 가 아닌 이유는 무엇일까요? 이유는 우리가 구체적인 3D 객체가 아니라 확률분포 를 다루고 있기 때문입니다.
실제로 Gaussian 분포에 를 적용한다고 말할 때, 우리가 의미하는 것은 이 분포를 따르는 모든 확률 벡터에 선형변환을 적용한다는 것입니다.
가 Gaussian 분포를 따르는 확률 벡터라면, 임의의 행렬 에 대해 다음이 성립합니다.
The proof is in the appendix.
따라서 변환된 벡터의 공분산은 가 되며, 이것이 눈 공간에서 splat의 공분산이 인 이유입니다.
그래픽스 파이프라인의 다음 단계는 원근 투영입니다. 목표는 카메라 공간의 3D Gaussian을 화면 공간의 2D Gaussian으로 투영하는 함수 를 찾는 것입니다. 원근 투영을 수행하는 것 외에도, 는 다음 조건을 만족하려면 선형이어야 합니다.
i.e. the projected distribution is still a 2D Gaussian. 선형성은 핵심입니다. 이것이 투영 후에도 평균과 공분산만으로 계속 표현할 수 있게 해 주기 때문입니다. 만약 투영이 완전히 비선형이라면, 투영된 형태는 더 이상 정확한 Gaussian이 아니게 되고, 단일 2D 공분산 행렬만으로는 화면 위의 splat을 설명할 수 없게 됩니다.
문제는 원근 투영이 선형이 아니라는 것 입니다. 카메라 공간의 점 을 near plane 위의 로 투영할 때, 계산식은 다음과 같습니다.
(see songho.ca for details)
여기서 로 나누는 것이 비선형성을 만들어 내므로, 일반적으로 투영된 분포는 Gaussian이 아닙니다.
아이디어는, 원래 2001년 EWA Volume Splatting에서 나온 것으로, 카메라 공간의 centroid 인 주변에서 1차 Taylor 전개를 사용해 투영을 국소적으로 선형화하는 것입니다.
where is the Jacobian of the perspective projection evaluated at . 이것은 centroid 주변에서 유효한 좋은 국소 선형 근사를 제공하며, 바로 그 주변에 Gaussian 질량의 대부분이 존재합니다.
에서 평가한 Jacobian은 다음과 같습니다.
where and are the focal lengths in pixels, derived from the OpenGL projection matrix entries and :
with and (again, see songho.ca for the full derivation).
마지막 행이 0인 것은 우연이 아닙니다. 이는 2D로 투영할 때 성분을 버린다는 사실을 반영하며, 정확히 이 때문에 나중에 왼쪽 위 블록을 추출해 를 얻을 수 있습니다.
기억하세요. 우리는 여전히 분포 수준에서 작업하고 있습니다. 행렬 자체를 직접 투영하는 것이 아니라, 이 분포를 따르는 확률 벡터들을 투영하고 있습니다. 가 의 선형 근사이고, 임의의 행렬 에 대해 다음이 성립하므로
and the constant offset in the Taylor approximation changes only the mean, not the covariance, we have:
따라서 이전 단계의 view 변환과 결합하면, 전체 투영 공분산은 다음과 같습니다.
Since the last row of is zero (we’re dropping the component), we extract the top-left block to get the final 2D covariance matrix.
이제 우리는 2D 공간에서 완전히 투영된 분포 를 갖게 되며, 여기서:
where is the projection matrix, is the view matrix, is the homogeneous coordinate before the perspective divide, and is evaluated at the centroid in camera space.
이제 가 생겼으니, 이것을 화면에 어떻게 그릴까요?
순진한 접근은 모든 픽셀에서 확률밀도함수를 평가하는 것입니다. 이런 방식은 미분 가능한 point splatting 기법 Yifan et al. 2019 에서 사용되기도 하지만, 매우 느립니다. 대부분의 픽셀은 주어진 splat으로부터 무시할 만한 기여만 받는데도, 그와 상관없이 평가 비용을 지불해야 하기 때문입니다.
원래 3DGS 논문에서의 해결책은 splat 주변에 직사각형(일반적으로 quad 라고 부름)을 만들고, 그 내부의 프래그먼트만 셰이딩하면서, 각 프래그먼트의 색은 앞서 했던 것처럼 구면 조화 함수로 계산하는 것입니다.
우리는 Gaussian 분포가 취하는 값의 “대부분”을 담는 quad를 만들고 싶습니다. 이를 위해 다시 공분산 행렬의 성질을 사용합니다. 가 대칭 양의 준정부호이므로, 닫힌형식의 고유값을 가집니다.
and unit eigenvectors where , and by orthogonality. 이들은 Gaussian 타원의 주축을 정의하며, 고유값은 각 축을 따라 퍼짐의 제곱을 줍니다.
Eigen-decomposition of the covariance gives the ellipse's principal axes (eigenvectors ) and squared spreads (eigenvalues )
축 방향으로, 거리 에서의 Gaussian 값은 입니다. 우리는 반지름 을 사용해 splat을 그릴 것입니다. 1차원에서는 Gaussian 질량의 약 99.7%가 평균에서 세 표준편차 이내에 있습니다. 여기서는 그 아이디어를 투영된 타원의 두 주축에 각각 독립적으로 적용합니다. 이는 정확한 2D 확률 진술은 아니지만, Gaussian 기여가 이미 매우 작아지는 실용적인 절단 기준을 제공합니다.
따라서 경계 quad의 두 반축을 다음과 같이 정의합니다.
정확한 절단값은 구현 선택 사항이며, 여기서는 원래 3DGS 논문에 맞추기 위해 를 사용합니다.
quad는 와 가 생성하는 평행사변형이며, 두 개의 삼각형으로 구성됩니다. 네 개의 꼭짓점은 템플릿 좌표 로 매개변수화됩니다.
The shared template quad is stretched and rotated by the pixel-space half-axes and
흥미로운 구현 세부사항 하나는, 투영된 centroid를 단순히 gl_Position에 쓰고 끝내지 않는다는 점입니다. 먼저 그 정규화 장치 좌표(NDC)를 계산하고, 그 주변에 quad의 네 꼭짓점을 오프셋합니다.
We compute this manually rather than relying on gl_Position directly, because we need the NDC coordinates to position the quad. 각 꼭짓점은 centroid로부터 자신의 코너 변위만큼 오프셋되며(여기서 ). 가 픽셀 단위이므로, 이를 다음과 같이 NDC로 변환합니다.
따라서 최종 꼭짓점 위치는 다음과 같습니다.
The projected centroid becomes ; each of the four quad vertices is then offset from it by a corner-specific combination of and , converted from pixels to NDC
quad는 평평하므로, 는 네 꼭짓점 모두에서 동일합니다. 우리는 원근 나눗셈을 이미 수동으로 수행했기 때문에 로 설정합니다. vertex shader는 또한 를 varying으로 전달하여, 각 프래그먼트가 quad 내부에서의 보간된 위치를 받도록 합니다.
// Per-vertex (shared unit quad)
layout(location = 0) in vec2 quadCorner; // (-1,-1), (1,-1), (-1,1), (1,1)
// Per-splat instance attributes
layout(location = 1) in vec3 splatCentroid; // `centroid` is a GLSL keyword
layout(location = 2) in float opacity;
layout(location = 3) in vec3 scale;
layout(location = 4) in vec4 rotation;
out vec2 splatCoord;
out vec3 splatColor;
out float splatOpacity;
uniform mat4 view;
uniform mat4 projection;
uniform vec2 viewportSize; // (W, H) in pixels
void main() {
vec4 clip = projection * view * vec4(splatCentroid, 1.0);
vec3 centerCamera = vec3(view * vec4(splatCentroid, 1.0));
vec3 ndc = clip.xyz / clip.w;
// Build Sigma = RS(RS)^T from exp(scale) and rotation,
// then project it with Sigma_2D = J M Sigma (JM)^T.
mat3 covariance2D = projectCovarianceToScreen(centerCamera, buildCovariance3D());
float a = covariance2D[0][0] + 0.3;
float b = covariance2D[1][0];
float d = covariance2D[1][1] + 0.3;
// Closed-form eigen-decomposition of the 2x2 covariance.
float determinant = a * d - b * b;
float mid = 0.5 * (a + d);
float radius = sqrt(max(mid * mid - determinant, 0.0));
float lam1 = max(mid + radius, 0.01);
float lam2 = max(mid - radius, 0.01);
vec2 e1 = abs(b) > 0.00001 ? normalize(vec2(b, lam1 - a))
: (a >= d ? vec2(1.0, 0.0) : vec2(0.0, 1.0));
vec2 e2 = vec2(-e1.y, e1.x);
// 3-sigma principal half-axes in pixels.
vec2 b1 = 3.0 * sqrt(lam1) * e1;
vec2 b2 = 3.0 * sqrt(lam2) * e2;
// Vertex offset converted from pixels to NDC.
vec2 deltaPixel = quadCorner.x * b1 + quadCorner.y * b2;
vec2 deltaNdc = deltaPixel / (viewportSize * 0.5);
gl_Position = vec4(ndc.xy + deltaNdc, ndc.z, 1.0);
splatCoord = quadCorner;
splatColor = evaluateSphericalHarmonics();
splatOpacity = 1.0 / (1.0 + exp(-opacity));
}
템플릿 위치 에 있는 프래그먼트는 화면 위치 에 놓입니다. 이 프래그먼트가 두 주축 방향으로 centroid에서 떨어진 거리는 다음과 같습니다.
Letting and , the Gaussian value at this fragment is:
우리는 Gaussian 밀도에서 정규화 항은 버립니다. 절대 확률값이 아니라 0에서 1로 감소하는 정도만 관심 있기 때문입니다. 를 대입하면:
빠른 sanity check를 해 보면, 는 중심에서 완전히 불투명하고, 는 코너에서 거의 투명합니다. 최종 프래그먼트 출력은 다음과 같습니다.
where comes from the SH color reconstruction and is sigmoid(opacity).
in vec2 splatCoord;
in vec3 splatColor;
in float splatOpacity;
out vec4 FragColor;
void main() {
// G(px, py) = exp(-4.5(px^2 + py^2)).
// Derived by substituting d_i = 3*sqrt(lambda_i)*p_i
// into exp(-d_i^2 / (2*lambda_i)).
float power = -4.5 * dot(splatCoord, splatCoord);
if (power < -9.0)
discard; // exp(-9) is nearly transparent
float alpha = min(0.99, splatOpacity * exp(power));
if (alpha < 0.001)
discard;
FragColor = vec4(splatColor, alpha);
}
struct GpuGaussian {
glm::vec3 centroid;
float opacity;
std::array<float, 3> scale;
std::array<float, 4> rotation;
};
// Upload unit quad geometry (shared across all splats).
static const glm::vec2 quadCorners[] = {
{-1.0f, -1.0f},
{ 1.0f, -1.0f},
{-1.0f, 1.0f},
{ 1.0f, 1.0f},
};
GLuint vao, quadVBO, splatVBO;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glGenBuffers(1, &quadVBO);
glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(quadCorners), quadCorners, GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
glEnableVertexAttribArray(0);
// Upload per-splat instance data (locations 1..4 bound with divisor = 1).
glGenBuffers(1, &splatVBO);
glBindBuffer(GL_ARRAY_BUFFER, splatVBO);
glBufferData(GL_ARRAY_BUFFER, gpuGaussians.size() * sizeof(GpuGaussian),
gpuGaussians.data(), GL_DYNAMIC_DRAW);
// ... glVertexAttribPointer + glVertexAttribDivisor(loc, 1) for each attribute ...
// Draw all splats in a single call.
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, (GLsizei)gpuGaussians.size());
이 시점이 되면 장면이 꽤 좋아 보이기 시작하지만, 아직 한 가지 문제가 있습니다. 일부 splat이 잘못된 순서로 블렌딩됩니다. 모든 splat이 반투명하기 때문에, 프래그먼트를 어떤 순서로 블렌딩하느냐에 따라 최종 색이 달라집니다. 따라서 어떤 형태로든 정렬이 필요합니다.
우리 3DGS 렌더러의 마지막 단계는 splat 정렬입니다. 일반적인 래스터라이저에서는 가시성 처리를 depth testing으로 하는 경우가 많습니다. 하지만 3DGS에서는 이것만으로 충분하지 않습니다. splat은 반투명이기 때문에, 하나의 픽셀이 같은 광선을 따라 여러 splat으로부터 색을 받을 수 있고, 가장 가까운 프래그먼트 뒤의 모든 것을 버리면 잘못된 결과가 나오기 때문입니다.
공식 3DGS 렌더러는 GPU 정렬과 front-to-back 블렌딩을 사용하는 타일 기반 CUDA 래스터라이저를 사용합니다. 이는 이 튜토리얼의 범위를 벗어납니다.
대신 여기서는 매 프레임 CPU에서 카메라 공간 깊이 기준으로 splat을 정렬하고, 그 정렬된 순서를 GPU에 업로드한 뒤, 표준 alpha blending으로 뒤에서 앞으로 그립니다. 당연히 공식 방법보다 비효율적이지만, 너무 크지 않은 장면에서는 잘 동작합니다.
OpenGL 카메라 공간에서 카메라는 음의 축 방향을 바라보므로, 더 먼 splat일수록 값이 더 작고 음수입니다. 카메라 공간 z를 증가하는 순서로 정렬하면 먼 것에서 가까운 것 순서가 됩니다.
struct SplatSortEntry {
float cameraZ = 0.0f;
std::size_t splatIndex = 0;
};
void sortSplatsBackToFront(const Scene& scene,
const glm::mat4& view,
std::vector<SplatSortEntry>& sortEntries) {
sortEntries.resize(scene.splats.size());
for (std::size_t i = 0; i < scene.splats.size(); ++i) {
glm::vec4 cameraCentroid = view * glm::vec4(scene.splats[i].centroid, 1.0f);
sortEntries[i] = {cameraCentroid.z, i};
}
std::sort(sortEntries.begin(), sortEntries.end(),
[](const SplatSortEntry& a, const SplatSortEntry& b) {
return a.cameraZ < b.cameraZ; // far first
});
}
그런 다음 매 프레임 그 순서대로 업로드 배열을 다시 만듭니다.
glm::mat4 view = camera.getViewMatrix();
sortSplatsBackToFront(scene, view, sortEntries);
sortedGaussians.resize(scene.splats.size());
sortedSphericalHarmonics.resize(scene.splats.size() * SH_FLOAT_COUNT);
for (std::size_t outputIndex = 0; outputIndex < sortEntries.size(); ++outputIndex) {
const GaussianSplat& splat = scene.splats[sortEntries[outputIndex].splatIndex];
sortedGaussians[outputIndex] = makeGpuGaussian(splat);
std::copy(splat.sphericalHarmonics.begin(), splat.sphericalHarmonics.end(),
sortedSphericalHarmonics.begin() + outputIndex * SH_FLOAT_COUNT);
}
uploadSortedSplats(buffers, sortedGaussians, sortedSphericalHarmonics);
glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4,
static_cast<GLsizei>(scene.splats.size()));
이 정도면 이 튜토리얼 장면에는 충분하지만, 큰 장면에서는 꽤 느릴 것입니다.
최종 결과는 다음과 같습니다.
우리는 학습된 3DGS 장면을 위한 완전한 표시 전용 렌더러를 만들었습니다. PLY를 불러오고, centroid를 그리며, 시점 의존적인 SH 색을 복원하고, 3D 공분산을 재구성하고, 이를 2D 공분산으로 투영한 뒤, 각 splat을 화면 공간 quad로 그리고, 마지막으로 반투명 splat을 정렬해 블렌딩이 올바르게 동작하도록 만들었습니다.
물론 이것은 의도적으로 단순한 렌더러입니다. 원 논문의 CUDA 래스터라이저는 특히 타일링, 정렬, 합성 측면에서 훨씬 더 정교합니다. 하지만 핵심 아이디어는 이미 여기 있습니다. 3DGS 장면은 매끄러운 3D 분포를 2D로 투영함으로써 렌더링됩니다.
재미있게 읽으셨길 바랍니다! 코드는 GitHub에 있습니다.
또한 X에서 피드백을 남겨 주셔도 좋습니다: @b__feldman
정의상 공분산 행렬은 대칭 양의 준정부호(PSD)입니다. 대칭이라는 것은 이고, PSD라는 것은 임을 뜻합니다.
스펙트럴 정리에 따르면, 이런 행렬은 정규직교 기저에서 대각화 가능하며, 따라서 다음과 같이 쓸 수 있습니다.
where is an orthogonal matrix and is a diagonal matrix with non-negative values (i.e. a scaling matrix), .
If we define where
we can rewrite
where is orthogonal and is a scaling matrix. 를 회전 행렬로 선택할 수도 있습니다. 만약 이라면, 의 고유벡터 열 하나의 부호를 뒤집으면 됩니다. 는 변하지 않으므로, 이 분해가 나타내는 공분산은 그대로지만 행렬식의 부호는 바뀝니다.
를 Gaussian 확률 벡터라 하고, 를 선형변환이라고 합시다. 기댓값의 선형성에 의해 다음이 성립합니다.
공분산에 대해서는:
따라서:
다음과 같이 인용할 수 있습니다.
Feldman, Benjamin. (May 2026). “3D Gaussian Splatting in a Weekend”. bfeldman.me. https://bfeldman.me/3dgs-weekend/.
또는:
@article{feldman2026gs,
title = "3D Gaussian Splatting in a Weekend",
author = "Feldman, Benjamin",
journal = "bfeldman.me",
year = "2026",
month = "May",
url = "https://bfeldman.me/3dgs-weekend/"
}