프로젝트, 연구/3DGS 구현

[3DGS 구현] 7. Renderer 만들기

CSE 2026. 3. 27. 10:19

 

 

3DGS논문을 읽었다면 타일을 사용하는 rasterization에 대해 기억날 것이다.

리마인드를 위해 3DGS 논문 리뷰 글에서 사진을 하나 가져왔다.

이 때 전체적인 계산의 흐름은 다음과 같았다.

 

1. 각 타일의 범위에 포함되는 가우시안을 찾기

2. 가우시안을 앞에서부터 순서대로 정렬하기

3. 픽셀별로 알파블렌딩을 통해 최종 픽셀 값 구하기

 

이를 해주는 것이 rasterizer인데, 이는 torch로 구현을 할 수 없다.

왜냐하면 (1600,1200) 크기의 이미지를 고려했을 때 7500개의 타일, 192만개의 픽셀이 나오게 된다.

이 각 픽셀마다 알파블렌딩을 통해 픽셀의 값을 구해야하는데 픽셀 독립적인 계산이므로 단순히 torch로 구현이 안된다.

이는 계산을 CUDA커널에 직접 커스텀해 코딩을 해야한다.

논문의 저자들도 C++을 사용해서 코딩을 하였는데 너무 복잡하기 때문에 github의 코드를 사용하자.

 

아마 3DGS 학습시켜보기 이 글을 따라했다면, 또는 직접 github의 원본 코드를 돌려봤다면, 

gaussian-splatting/submodules/diff-gaussian-rasterization이라는 경로가 기억날 것이다.

이 경로로 이동해 아래의 명령어를 실행한다.

pip install -e . --no-build-isolation

 

잠시 기다리면 성공적으로 설치가 될 것이다.

만약 문제가 발생하는 겅우는 CUDA버전을 다시 살펴보거나, 위의 링크를 참고하여 재설치를 해보는 것을 추천한다.

이를 통해 제공되는 rasterizer를 사용할 수 있게 되었다.

 

 

이제 renderer.py파일을 만들자.

여기는 render함수 하나를 정의할 것이다.

이 함수는 특정 카메라 위치에서본 가우시안들의 카메라 평면에 대한 projection(2D모습)을 이미지로 반환한다.

이 때 몇가지 변수도 추가로 내뱉는데 이들은 학습에 필요한 값들이다.

 

 

GaussianModel클래스 내부에 아래와 같은 코드를 추가해주자.

변수를 속성에 맞게끔 변형해서 내뱉는 함수이다.

예를 들어 scale같은 경우 log를 씌워서 저장했었으므로 exp를 씌워 반환한다.

    @property
    def get_xyz(self):
        return self._xyz

    @property
    def get_scaling(self):
        return torch.exp(self._scaling) 

    @property
    def get_rotation(self):
        return torch.nn.functional.normalize(self._rotation) 

    @property
    def get_opacity(self):
        return torch.sigmoid(self._opacity)

 

아래는 renderer.py파일의 render함수이다.

먼저 인자를 보자.

viewpoint_camera는 이전 글에서 만들었던 camera클래스를 의미한다.

pc는 이전 글에서 만들었던 GaussianModel 클래스의 인스턴스이다.

pipe는 추가적인 변수리스트를 의미하는데, 후반에 조금 더 자세히 설명한다.

 

screenspace_points는 get_xyz의 모양인 (N,3)대로 공간을 잡고 다 0으로 채워놓는 것이다.

0이 의미있는 것이 아니고 그냥 공간을 미리 잡아놓기 위함이다.

 

나중에 렌더 엔진에 들어갔다 나오면 2차원에 투영된 가우시안의 중심 좌표와 depth를 합쳐 (N,3)의 데이터가 저장된다.

 

또 torch는 역전파시 중간 미분값을 계산하고 사용이되면 바로 없애는데, 위 좌표들의 변화량(미분)을 계산하고 나오는 gradient를  없애지 말라고 미리 retain_grad()를 통해 설정한다.

후에 densification과정에서 이 미분값을 기준으로 가우시안을 쪼개거나 복제하기 때문이다.

import torch
import math
from diff_gaussian_rasterization import GaussianRasterizer, GaussianRasterizationSettings

def render(viewpoint_camera, pc, pipe, bg_color: torch.Tensor, scaling_modifier=1.0, override_color=None):

    
    screenspace_points = torch.zeros_like(pc.get_xyz, dtype=pc.get_xyz.dtype, requires_grad=True, device="cuda") + 0 #메모리 미리 확보, 일단 0으로 채워 (N,3)만큼
    try:
        screenspace_points.retain_grad() #값 버리지 않는 이유: 나중에 다시 보고 불안정하면 쪼개야하니까
    except:
        pass

 

위에서 설치했었던 엔진에 넣을 값을 설정해야한다.

먼저 엔진은 foV_x, foV_y 각도의 절반의 tan값을 요구하므로 이에 맞춰 변형해준다.

그 외 각종 행렬 및 속성들을 모두 전달한다.

    #래스터라이저 엔진이 요구하는 값들 채우기
    tan_fovx = math.tan(viewpoint_camera.foV_x * 0.5)
    tan_fovy = math.tan(viewpoint_camera.foV_y * 0.5)

    raster_settings = GaussianRasterizationSettings(
        image_height=int(viewpoint_camera.image_height),
        image_width=int(viewpoint_camera.image_width),
        tanfovx=tan_fovx,
        tanfovy=tan_fovy,
        bg=bg_color, #배경색
        scale_modifier=scaling_modifier, #scale조정용
        viewmatrix=viewpoint_camera.world_view_transform,      # 카메라 클래스
        projmatrix=viewpoint_camera.full_proj_transform,      # 카메라 클래스
        sh_degree=pipe.sh_degree,
        campos=viewpoint_camera.camera_center,                # 카메라 클래스
        prefiltered=False,
        debug=pipe.debug,
        antialiasing=False
    )

    rasterizer = GaussianRasterizer(raster_settings=raster_settings)

 

means3D에는 3차원 가우시안들의 중심점 좌표를 저장한다.

means2D에는 위에서 계속 얘기했던 screenspace_points를 그대로 저장한다.

scale, rotation, opacity등 나머지 속성들도 가져온다.

GaussianModel 클래스에서 sh함수의 계수를 차수에 따라 2개로 나눠 저장했었는데 이를 연결해준다.

 

rasterizer()를 호출해 렌더링을 진행한다.

그 결과로는 2D이미지와 radii라는 값이 나온다.

radii는 N차원의 텐서로 2차원에 투영된 타원(가우시안)을 감싸는 최소의 원의 반지름을 저장한다.

이 값이 어디 필요하냐고 할 수 있지만 계산 효율을 위해 필요하다.

먼저 이 값이 0이라면 이 가우시안은 해당 2D화면에서 보이지 않는 것이므로 학습에서 제외시킬 수 있다.

또 이 값이 너무 크면 가우시안이 너무 크다는 소리이므로 쪼개는 대상으로 삼을 수도 있다.

    means3D = pc.get_xyz #가우시안 중심점
    means2D = screenspace_points #2차원 투영된 좌표 + depth  = (N,3)
    opacity = pc.get_opacity


    scales = pc.get_scaling
    rotations = pc.get_rotation



    shs = torch.cat([pc._features_dc, pc._features_rest], dim=1) # 두 개 이어붙임

    #렌더링
    rendered_image, radii,_ = rasterizer(
        means3D=means3D,
        means2D=means2D,
        shs=shs,
        colors_precomp=override_color,
        opacities=opacity,
        scales=scales,
        rotations=rotations,
        cov3D_precomp=None #공분산은 그냥 엔진보고 계산하라 함
    )
    #radii는 N차원 텐서임 : 2차원에 투영된 타원을 감싸는 원의 반지름 저장

    
    return {
        "render": rendered_image,
        "viewspace_points": screenspace_points,
        "visibility_filter": radii > 0,
        "radii": radii
    }

 

 


테스트

여태까지 짰던 모든 코드들이 잘 통합되어 작동하는지 확인하는 코드이다.

 

간략히 설명하면, 

1. 먼저 이전에 만들어놨던 camera class의 인스턴스들의 리스트를 로딩한다.

2. 이들 중 하나의 정보를 골라 해당 카메라각도에서 바라본 현재 3DGS의 렌더링 모습을 이미지로 저장한다.

 

사실 2를 위해서 1이 필요하지는 않다.

그냥 아무 카메라 정보를 하나만 가져오면 되는데, 모든 이미지가 GPU로 올라갈 수 있는지 확인하기 위해 1번을 넣었다.

현재 사용하는 bonsai데이터 셋은 원본 사이즈로 모두 로드하면 현재 내 GPU의 11GB에서는 OOM가 발생한다.

따라서 resolution_scale=2.0을 통해 이미지를 2배 줄였다.

import torch
import torchvision
from renderer import render
from gaussianModel import GaussianModel
import config
from camera_loader import load_cameras
import os


class Pipe:
    def __init__(self):
        self.sh_degree = 2
        self.debug = False

def test():
    # 모델 준비 및 초기화
    from points3d_bin_parser import Point3DLoader
    import knn
    
    loader = Point3DLoader(config.POINTS3D_BIN_PATH)
    points = loader.load()
    initial_scales = knn.compute_initial_scaling(points)

    model = GaussianModel().cuda()
    model.create_from_pcd(points, initial_scales)
    
    # 진짜 카메라들 로드 
    print("camera class 리스트 로딩")
    cameras = load_cameras(
        resolution_scale=2.0
    )
    
    # 첫 번째 카메라 선택
    viewpoint_cam = cameras[0]
    print(f"선택된 카메라: {viewpoint_cam.image_name}")

    pipe = Pipe()
    bg_color = torch.tensor([0, 0, 0], dtype=torch.float32, device="cuda")

    # 렌더링
    print("렌더링 시작")
    render_pkg = render(viewpoint_cam, model, pipe, bg_color)
    image = render_pkg["render"] # (3, H, W) 텐서

    # 이미지 저장
    torchvision.utils.save_image(image, "test_output.png")
    print(" test_output.png 생성.")

    # Radii와 Gradient 확인
    print(f"화면에 보이는 가우시안 개수: {render_pkg['radii'].gt(0).sum().item()}")
    print(f"Screenspace points shape: {render_pkg['viewspace_points'].shape}")

if __name__ == "__main__":
    test()

 

이 코드를 실행시키면 이 test.py와 같은 위치에 test_output.png파일이 생성된다.

test_output.png