Cesium ↔ Three.js 가우시안 스플랫 연계 구조
Cesium 3D 지도 위에 @mkkellogg/gaussian-splats-3d 가우시안 스플랫을 겹쳐 보여주는 co-viewer 구현 문서.
핵심 파일: [CesiumSplatOverlay.ts](./CesiumSplatOverlay.ts)
1. 전체 컨셉 (Overlay 방식)
tebben/cesium-gaussian-splatting 방식 채택. 하나의 WebGL 컨텍스트를 공유하지 않고, Cesium 캔버스 위에 별도의 Three.js 캔버스를 겹쳐 놓고 매 프레임 카메라만 동기화한다.
┌───────────────────────────────────────────┐
│ cesiumWidget.container (position:relative) │
│ ┌─────────────────────────────────────┐ │
│ │ Cesium canvas (지형/건물/지도) │ │ z-index: 0
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ overlayCanvas (Three.js 스플랫) │ │ z-index: 1
│ │ position:absolute, pointer-events:none│ │ (클릭은 Cesium으로 통과)
│ └─────────────────────────────────────┘ │
└───────────────────────────────────────────┘
항목내용
| 장점 | 가시화가 안정적, 구현 단순, Cesium 렌더링에 간섭 없음 |
| 단점 | 지형/건물과의 오클루전(가림)이 부정확 (깊이 버퍼 비공유) |
2. 연계를 가능하게 한 결정적 로직 4가지
(A) 오버레이 캔버스 + 독립 Three 렌더러 — ensureInitialized()
Cesium 컨테이너에 투명 캔버스를 붙이고, 그 위에 Three.js WebGLRenderer와 GaussianSplats3D.Viewer를 selfDrivenMode: false (자체 RAF 루프 끔)로 생성한다. 렌더 구동은 전적으로 Cesium 프레임에 종속시킨다.
const overlay = document.createElement('canvas');
overlay.style.cssText =
'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:1;';
this.container.appendChild(overlay);
const splatViewer = new GaussianSplats3D.Viewer({
threeScene,
renderer,
camera: threeCamera,
rootElement: this.container,
selfDrivenMode: false, // ← Cesium 프레임이 렌더를 구동
useBuiltInControls: false, // ← 카메라 조작은 Cesium이 담당
gpuAcceleratedSort: false,
// ...
});
(B) ENU 부동 원점(Floating Origin) — setEnuOrigin() / toEnuLocal()
지구 중심(ECEF) 좌표는 값이 수백만 m라 float32 정밀도가 깨진다. 그래서 스플랫 앵커 지점(lon/lat/height)을 원점으로 하는 ENU(East-North-Up) 로컬 좌표계를 만들고, 모든 Three 좌표를 이 작은 로컬 공간에서 다룬다.
- eastNorthUpToFixedFrame(원점) → ENU→ECEF 변환행렬
- 그 역행렬 fixedToEnu를 저장해 두고, 카메라/스플랫 위치를 전부 ENU로 투영
private setEnuOrigin(lon: number, lat: number, height: number): void {
const originEcef = Cartesian3.fromDegrees(lon, lat, height);
const enuToFixed = Transforms.eastNorthUpToFixedFrame(originEcef);
this.fixedToEnu = Matrix4.inverse(enuToFixed, new Matrix4());
}
(C) 매 프레임 카메라 동기화 — postRender → syncCamera()
연계의 심장부. Cesium의 scene.postRender 이벤트에 핸들러를 걸어, Cesium이 한 프레임을 그릴 때마다 Cesium 카메라의 위치/방향/up/FOV를 ENU로 변환해 Three 카메라에 그대로 복사한다. 결과적으로 두 캔버스의 시점이 픽셀 단위로 일치한다.
const posEnu = Matrix4.multiplyByPoint(fixedToEnu, cesiumCamera.positionWC, /* ... */);
const dirEnu = Matrix4.multiplyByPointAsVector(fixedToEnu, cesiumCamera.directionWC, /* ... */);
const upEnu = Matrix4.multiplyByPointAsVector(fixedToEnu, cesiumCamera.upWC, /* ... */);
camera.position.set(posEnu.x, posEnu.y, posEnu.z);
camera.up.set(upEnu.x, upEnu.y, upEnu.z);
camera.lookAt(posEnu.x + dirEnu.x, posEnu.y + dirEnu.y, posEnu.z + dirEnu.z);
camera.updateMatrixWorld(true); // ← sort용 quaternion 갱신
camera.fov = CesiumMath.toDegrees(fovy); // Cesium FOV → Three FOV
camera.updateProjectionMatrix();
추가로, Cesium이 정지 시 렌더를 멈추지 않도록 requestRenderMode=false, shouldAnimate=true로 강제하여 postRender가 계속 돌게 한다 (applySceneFlags()).
(D) 렌더 준비 가드 — isSplatGeometryReady() / ensureFirstSort()
스플랫은 로드 직후 첫 정렬(sort)이 끝나야 geometry의 instanceCount가 채워진다. 그 전에 render()를 호출하면 빈 dummyGeometry를 그리려다 Cannot read properties of undefined (reading 'count')가 난다. 그래서 준비 상태를 확인하고, 첫 sort 완료를 기다리는 가드를 둔다.
private isSplatGeometryReady(): boolean {
const mesh = this.splatViewer?.splatMesh;
if (!mesh?.visible) return false;
const splatCount = mesh.getSplatCount?.() ?? 0;
if (splatCount <= 0) return false;
const geom = mesh.geometry as THREE.InstancedBufferGeometry | null;
if (!geom?.attributes?.position) return false;
const instanceCount = geom.instanceCount;
return typeof instanceCount === 'number' && instanceCount > 0;
}
renderFrame()은 라이브러리의 self-driven 루프와 동일하게 update() → shouldRender() → render() 순서로 안전하게 호출한다.
3. 한 프레임의 데이터 흐름
Cesium 카메라 이동
│
▼
scene.postRender 이벤트 발생 ──(매 프레임)
│
▼
renderFrame()
├─ syncOverlayCanvasSize() : 캔버스 크기를 Cesium 캔버스에 맞춤
├─ syncCamera() : Cesium 카메라 → ENU → Three 카메라 복사
├─ canRunSplatUpdate() : initialized && splatRenderReady 확인
├─ splatViewer.update() : 스플랫 정렬/유니폼 갱신
├─ isSplatGeometryReady() : instanceCount>0 가드
└─ shouldRender() → render(): 오버레이 캔버스에 스플랫 그리기
4. 좌표/자세 배치 로직 (스플랫을 지도에 꽂기)
addSplat()에서:
- splatUrlBypass304() — ?cb=타임스탬프를 붙여 304 응답 회피
- (mkkellogg는 response.ok만 성공으로 보기 때문)
- toEnuLocal() — 스플랫 위치(lon/lat/height)를 ENU 로컬 좌표로 변환
- buildRotationQuaternion() — alignYUpToZUp(스플랫 Y-up → 지도 Z-up 보정) +
- heading/pitch/roll 적용
- addSplatScene(url, { position, rotation, scale, format })로 로드
- ensureFirstSort()로 첫 렌더 준비 대기
5. three.js 단일화 (연계의 전제 조건)
이 모든 게 동작하려면 앱과 스플랫 라이브러리가 같은 three 인스턴스를 써야 한다. 서로 다른 three 사본을 쓰면 한 버전의 SplatMesh geometry를 다른 버전의 WebGLRenderer로 그리며 Cannot read properties of undefined (reading 'count') 오류가 난다.
- 루트 node_modules/three → 0.184.0 (gaussian-splats-3d가 사용)
- (과거) projects/master/node_modules/three → 0.137.5 (앱 코드가 사용) ← 충돌 원인
해결: package.json의 three를 ^0.184.0으로 올리고 yarn install로 통일. 추가로 umirc.shared.ts에서 webpack alias로 루트 단일 사본을 강제 (MFSU 포함 일괄 적용).
// projects/master/umirc.shared.ts
config.resolve.alias.set(
'three',
path.resolve(__dirname, '../../node_modules/three'),
);
alias 변경 후에는 dev 서버 재시작 + MFSU 캐시(node_modules/.cache) 삭제가 필요하다.
6. 관련 파일
파일역할
| CesiumSplatOverlay.ts | 오버레이 엔진 (캔버스/카메라 동기화/로드/렌더/dispose) |
| splatPresets.ts | 스플랫 목록 + lon/lat/height 배치 프리셋 |
| useCesiumSplatOverlay.ts | React 훅: 토글 로드/dispose, flyTo |
| SplatViewerPanel.tsx | UI 패널 (메뉴 ID 143) |
요약: ① 투명 오버레이 캔버스 + 독립 Three 렌더러를 띄우고, ② ENU 부동 원점으로 좌표 정밀도를 확보한 뒤, ③ scene.postRender마다 Cesium 카메라를 Three 카메라에 복사(syncCamera)하고, ④ 스플랫 준비 가드를 거쳐 오버레이에 렌더 — 이 네 가지가 Cesium 뷰와 three.js를 묶은 결정적 로직.
'3D' 카테고리의 다른 글
| [3D] Cesium Terrain Server 학습 (0) | 2026.07.04 |
|---|---|
| [3D] Cesium Terrain Builder (0) | 2026.07.04 |
| [3D] Gaussian Splatting 기술 학습 (0) | 2026.07.03 |
| [3D] ml-sharp 기술 학습 (0) | 2026.07.03 |
| [3D] Gaussian Splatting Rendering(Cesium + Three.js) (0) | 2026.07.02 |