paiams

Go Back

Gaussian Splatting point-light shadow


CrossGaussian을 만들면서 3D Gaussian Splatting 장면과 일반 Unity 오브젝트를 한 공간에 배치했다. 하지만 기존 Gaussian Splatting 렌더러는 포인트 라이트가 만드는 동적 그림자를 지원하지 않았다. 주변 오브젝트는 빛에 반응하는데 splat 장면만 조명과 분리돼 보여 두 장면을 자연스럽게 섞기 어려웠다.

이 프로젝트에서는 Aras Pranckevičius의 UnityGaussianSplatting을 바탕으로 URP용 포인트 라이트 그림자 생성과 적용 과정을 추가했다. 3DGS 렌더러 전체를 새로 만든 작업은 아니다. asset import와 Gaussian data representation, sorting, 기본 splat rendering은 업스트림 구현을 사용했다.

Gaussian splat을 빛의 시점에서 그리기

일반적인 mesh shadow caster는 빛의 시점에서 triangle을 다시 그려 depth map을 만든다. Gaussian splat은 화면에 투영한 ellipse를 조합해 장면을 표현하므로 같은 shadow caster를 그대로 쓸 수 없었다. 빛의 위치에서도 각 splat의 중심과 covariance를 투영해 ellipse를 다시 만들고 Gaussian의 형태가 남는 픽셀만 depth에 기록해야 했다.

포인트 라이트는 모든 방향으로 빛을 내기 때문에 한 장의 2D depth texture로는 주변 전체를 담을 수 없다. 라이트를 중심으로 여섯 방향을 렌더링해 하나의 shadow cubemap에 저장했다.

flowchart TD
  shared["CSCalcSharedLightData<br/>공통 계산 · 1회<br/>world position · 3D covariance · opacity"]

  subgraph faces["Cubemap face 0 … 5 · 여섯 면 반복"]
    direction TB
    view["CSCalcLightViewData<br/>clip-space center · ellipse axes"]
    raster["ShadowCasterSplat.shader<br/>DrawProcedural · depth 기록"]
    view --> raster
  end

  shared --> view
  raster --> cubemap[("Shadow cubemap")]
  cubemap --> main["RenderGaussianSplats.shader<br/>depth 비교 → 그림자 적용"]

초기 구현에서는 여섯 장의 2D render texture를 따로 관리했다. 이후 하나의 cube RenderTexture에 직접 기록하도록 바꿔 texture binding과 면별 자원 관리를 줄였다. 각 면을 렌더링할 때는 CommandBuffer로 대상 cubemap face를 지정하고 ShadowCasterSplat.shader를 사용해 색상 없이 depth만 썼다.

공통 계산과 면별 계산 나누기

각 splat의 world position과 3D covariance는 cubemap의 어느 면을 그리든 같다. 처음에는 이 값까지 여섯 번 반복해서 계산할 수 있는 구조였지만 공통 작업과 라이트 시점별 작업을 두 Compute Shader kernel로 분리했다.

CSCalcSharedLightData는 모든 면에서 재사용할 position, covariance, opacity를 한 번 계산한다. CSCalcLightViewData는 이 결과를 받아 현재 cubemap face의 view·projection matrix로 clip-space 중심과 화면상의 ellipse 축을 구한다. 같은 데이터를 여섯 번 다시 만드는 대신 달라지는 시점 계산만 면마다 실행하는 구조다.

ShadowCasterSplat.shader는 계산된 중심과 두 ellipse 축으로 quad를 만든다. fragment shader에서는 Gaussian falloff와 opacity를 곱한 뒤 alpha cutoff보다 작은 픽셀을 버린다. ZWrite On, ColorMask 0으로 설정해 남은 Gaussian 형태의 depth만 cubemap에 기록했다.

좌표계가 맞지 않았던 문제

가장 오래 걸린 문제는 행렬이었다. C#에서 만든 view matrix와 projection matrix를 Compute Shader에 그대로 넘기자 cubemap의 일부 면에서 그림자 방향이 뒤집히거나 엉뚱한 곳에 나타났다. CPU에서 준비한 행렬과 GPU가 렌더링에 사용하는 좌표계·projection 규칙이 맞지 않았기 때문이다.

각 cubemap face의 view matrix를 GPU의 UNITY_MATRIX_V 구조에 맞춰 직접 구성하고 projection matrix에는 GL.GetGPUProjectionMatrix()를 적용했다. depth를 읽을 때도 저장된 값을 그대로 비교하지 않고 _LightZBufferParamsLinearEyeDepth를 사용해 선형화했다. 이 방식으로 reversed Z와 standard Z에서 같은 비교 흐름을 사용할 수 있게 했다.

메인 렌더링에 그림자 적용하기

완성된 depth cubemap은 전역 texture인 _ShadowCubemap으로 메인 splat shader에 전달했다. fragment의 world position에서 라이트까지의 벡터를 구하고 hardware cubemap sampler로 해당 방향의 depth를 읽는다. 현재 fragment의 거리와 저장된 depth에 bias를 더한 값을 비교해 밝은 영역과 그림자 영역을 나눴다.

이전처럼 여섯 장의 texture와 face별 matrix를 fragment 단계에서 직접 고를 필요가 없어졌다. 그림자 여부는 0과 1 사이의 visibility 값으로 만들고 _LightBrightness_ShadowBrightness 사이를 보간해 최종 splat color에 반영했다.

CrossGaussian에서 확인한 동작

CrossGaussian에서는 이 shadow casting을 조명 표현뿐 아니라 360° 카메라에서 가려진 영역을 살펴보는 데 활용했다. 아래 영상은 Unity에서 point light를 움직일 때 3DGS 장면의 결과가 달라지는 모습이다.

Unity에서 포인트 라이트를 움직이며 Gaussian Splatting 장면의 shadow casting 결과를 확인하는 모습

CrossGaussian의 occlusion-aware exploration에 사용한 shadow casting 데모.

현재 한계

현재 공개 구현은 기본적인 hard shadow까지만 지원한다. PCF나 PCSS를 이용한 soft shadow, cubemap face별 culling과 LOD, shadow resolution 자동 조절은 아직 구현하지 않았다.

Profiler marker는 코드에 추가했지만 장면별 splat 수, cubemap 해상도, GPU별 실행 시간을 같은 조건에서 반복 측정한 결과는 없다. 성능을 최적화했다고 단정하지 않고 공통 계산 분리와 cubemap 통합으로 중복 작업과 자원 관리를 줄였다고만 설명한다. Unity와 URP의 여러 버전, 다양한 GPU에서 동작하는지도 별도 검증이 필요하다.

링크