[자율주행] WebGL2로 뷰어를 직접 짓기 — three.js를 언제 벗어나는가
자율주행/시각화
· 2026-09-05
본문의 점선 밑줄 친 전문용어를 누르면 설명이 열립니다.
자율주행 시각화에서 "브라우저 로 옮겨가는 중"이라고 한 줄 적고 넘어갔다. 그 한 줄 안에 실제로는 꽤 큰 선택이 들어 있다. 기성 엔진() 위에 올릴 것인가, GPU API를 직접 잡을 것인가.
결론부터 말하면 "가 three.js보다 낫다"는 명제는 그대로는 참이 아니다. 둘은 같은 층이 아니다. 하지만 자율주행 뷰어라는 특정한 부하에서는 three.js를 쓸수록 three.js의 이점이 사라지는 구간이 분명히 있고, 그 지점이 어디인지는 말할 수 있다.
층위부터 정리
애플리케이션 (뷰어)
────────────────────────
three.js 씬 그래프 · 재질 · 로더 · 카메라 컨트롤
────────────────────────
WebGL2 버퍼 · 셰이더 · 드로우콜 (OpenGL ES 3.0)
────────────────────────
드라이버 / GPU
은 API고 는 그 위의 프레임워크다. three.js도 내부적으로는 를 호출한다(최근 three 는 WebGL1 지원을 아예 뺐다 — 요즘 three 는 사실상 WebGL2 전용이다). 그러니 "three.js로는 못 하는데 WebGL2로는 되는 것"은 거의 없다.
진짜 질문은 다른 것이다. 그 추상화가 내 부하에서 값을 하는가, 비용만 남는가.
WebGL1 → WebGL2, 자율주행에서 실제로 갈리는 것
는 OpenGL ES 3.0에 대응한다. 스펙 차이는 길지만, 과 격자를 다루는 입장에서 실제로 체감되는 건 여덟 가지쯤이다.
| 기능 | 왜 AV 데이터에서 중요한가 |
|---|---|
| 정수 attribute | intensity·ring·라벨 id를 float로 억지로 밀어넣지 않아도 된다. u8·u16 그대로 올려 대역폭과 메모리를 줄인다 |
gl_VertexID | attribute 없이 정점 인덱스를 안다. 격자·인스턴스를 버퍼 없이 절차적으로 생성 |
| (표준) | 3D 박스 200개를 1번에. WebGL1에서는 확장에 의존했다 |
| (표준) | 정점 상태 묶음을 한 번에 바인딩. 프레임마다 attribute 세팅을 반복하지 않는다 |
| UBO | 카메라·조명 같은 공용 유니폼을 한 버퍼로 공유 |
| 3D 텍스처 / 텍스처 배열 | 점유 격자를 그대로 올린다. 200×200×16 을 3D 텍스처 한 장으로 |
| 정점 셰이더 출력을 버퍼로 되받는다. CPU를 거치지 않는 상태 갱신 | |
| MRT · 정수 렌더타깃 | 화면과 별개로 객체 id 버퍼를 같이 그려 피킹에 쓴다 |
여기에 texelFetch(보간 없는 정확한 텍셀 조회), depth texture, 멀티샘플 렌더버퍼, sRGB, NPOT 텍스처 완전 지원이 붙는다.
세 번째 줄과 여섯 번째 줄이 특히 크다. 박스와 화살표는 인스턴싱으로, 점유 격자는 3D 텍스처로 — 이 둘이 WebGL1 시절 자율주행 뷰어가 힘들었던 이유의 절반이다.
three.js가 값을 하는 구간
먼저 공정하게. 가 확실히 이기는 곳이 있다.
- 씬 그래프와 — 부모-자식 트랜스폼, 쿼터니언, 카메라. tf 트리를 화면에 옮길 때 이걸 직접 짜면 며칠이 간다.
- 카메라 컨트롤 — OrbitControls의 damping·제약·터치 처리는 직접 만들면 은근히 길다.
- 로더 — 차량 모델(GLTF), 텍스처, 환경맵.
- 재질과 조명 — PBR을 직접 구현할 이유는 없다.
- 생태계 — 후처리, 헬퍼, 예제.
즉 "3D 장면"을 만드는 일에는 three.js가 압도적으로 낫다. 자차 모델을 띄우고, 조명을 넣고, 마우스로 돌리는 부분은 전부 여기 해당한다.
three.js가 걸림돌이 되는 구간
문제는 자율주행 뷰어의 본체가 "3D 장면"이 아니라 대용량 데이터 스트림의 시각화라는 데 있다.
하나. 오브젝트당 오버헤드. three.js는 렌더 루프마다 각 Object3D의 행렬을 갱신하고 절두체 컬링을 수행한다. 오브젝트가 수백 개면 아무것도 아니지만, 박스 하나를 Mesh 하나로 만들면 프레임마다 JS에서 그만큼의 일이 반복된다. 그래서 결국 직접 병합하거나 한다. 그 순간 씬 그래프의 이점은 사라진다.
둘. . 3D 박스 200개를 LineSegments 200개로 그리면 드로우콜 200번이다. 실무 답은 하나다 — 전부 하나의 지오메트리로 합쳐 드로우콜 1번으로. 이건 three.js의 기능이 아니라 three.js를 우회하는 설계다.
셋. 버퍼 레이아웃 제어. 스트리밍은 "매 프레임 전부 새로 올리기"가 아니라 링 버퍼에 부분 갱신(bufferSubData) 이 정답이다. three.js에도 updateRange·InterleavedBuffer가 있지만, 인터리브드 레이아웃과 부분 갱신과 더블 버퍼링을 정밀하게 조율하려 할수록 프레임워크와 씨름하게 된다.
넷. 메모리. BufferAttribute는 CPU 쪽 타입 배열을 계속 들고 있다. 3천만 점이면 위치만 360MB가 브라우저 힙에 남는다. 직접 짜면 GPU에 올린 뒤 CPU 사본을 놓아버릴 수 있다.
다섯. 번들. 뷰어가 페이지 안에 들어가는 제품이라면 무시하기 어렵다.
정리하면 이렇다. three.js로 못 하는 게 아니라, 필요한 최적화를 하나씩 적용할수록 three.js를 안 쓰는 코드에 가까워진다.
이 사이트의 라이다 씬이 정확히 그 상태다
Autonomy 뷰의 은 three.js로 그린다. 그런데 코드를 열어 보면 three.js답게 쓰지 않는다.
- 36만 점이
Points하나 + 커스텀ShaderMaterial하나다. 점마다 오브젝트를 만들 리 없다. - 회전 스캔 효과는 CPU가 아니라 정점 셰이더에서 만든다. 점 위치는 정적이고, 매 프레임 바뀌는 건 유니폼
uSweep하나뿐이다.
1float ang = atan(position.x, position.z); 2float behind = mod(uSweep - ang, 6.2831853) / 6.2831853; 3float beam = smoothstep(0.45, 0.0, min(behind, 1.0 - behind) * 6.2831853); 4vColor = aColor * (0.6 + 0.55 * beam + 0.24 * pow(1.0 - behind, 2.5));
- 3D 박스 19개는 전부 하나의
LineSegments로 병합해 1번. - 큰 점군의 컬링 계산을 피하려고
frustumCulled = false로 끄고 바운딩 스피어를 수동으로 넣는다. - 객체 라벨은
CSS2DRenderer대신Vector3.project(camera)로 직접 투영해 DOM에 얹는다.
three.js에서 실제로 쓰는 건 컨텍스트 관리·카메라·OrbitControls·자차 메시뿐이고, 데이터 경로는 사실상 손으로 짠 이다. 데모 규모에서는 이 절충이 합리적이다. 하지만 점 수가 한 자릿수 더 늘고 스트리밍이 붙는 순간, 남아 있는 three.js 부분이 이득보다 제약이 된다.
그래서 자체 구축한다면 — WebGL2 설계 지침
여기서부터가 실제 설계다.
버퍼 레이아웃
점마다 필드가 여럿이다. 배열을 따로 두지 말고 인터리브드 한 덩어리로 만든다. GPU가 한 정점의 모든 속성을 인접 메모리에서 읽으므로 캐시 효율이 좋다.
stride 20 bytes
0 x f32 ┐
4 y f32 ├ 위치
8 z f32 ┘
12 intensity u8 ← 정수 그대로 (WebGL2)
13 ring u8
14 flags u8
15 (pad) u8
16 t_offset f32 ← 스윕 안에서의 상대 시각
intensity를 float으로 올리면 점당 4바이트가 더 붙는다. 3천만 점이면 120MB 차이다. 정수 attribute가 그냥 스펙 항목이 아닌 이유가 이것이다.
부분 갱신과 링 버퍼
스트리밍 로그는 프레임마다 전체를 다시 올리면 안 된다. 큰 버퍼를 미리 할당해 두고 새 만 덮어쓴다.
1gl.bindBuffer(gl.ARRAY_BUFFER, vbo); 2gl.bufferSubData(gl.ARRAY_BUFFER, writeOffset, sweepBytes); // 새 스윕만 3// 드로우는 유효 구간만 4gl.drawArrays(gl.POINTS, firstPoint, pointCount);
여기에 프레임 인덱스를 오프셋 범위로 매핑해 두면 타임라인 스크럽이 버퍼 오프셋 계산이 된다. 이게 리플레이 뷰어의 핵심 자료구조다.
인스턴싱
박스·화살표· 마커처럼 같은 형상이 반복되는 것은 전부 이다. 큐브 엣지 지오메트리 하나를 만들고, 인스턴스별 속성(중심·크기·yaw·색)을 divisor 1로 붙인다.
1gl.vertexAttribDivisor(locCenter, 1); // 인스턴스마다 하나씩 2gl.drawArraysInstanced(gl.LINES, 0, 24, boxCount); // 드로우콜 1번
점유 격자는 3D 텍스처로
격자를 인스턴스 큐브 수십만 개로 그리는 건 낭비다. R8UI 3D 텍스처 한 장에 올리고, 프래그먼트 셰이더에서 레이마칭하거나 슬라이스를 그린다. WebGL1로는 애초에 불가능했던 접근이다.
색상은 LUT 텍스처로
거리·높이 램프를 셰이더에 하드코딩하지 말고 1D(또는 Nx1) 텍스처로 올린다. 램프를 바꾸는 게 텍스처 교체 한 번이 되고, 라벨 클래스별 색도 같은 방식으로 처리한다.
피킹은 GPU에서
"이 점이 어느 객체인가"를 CPU에서 레이캐스팅하지 않는다. MRT로 객체 id를 정수 렌더타깃에 같이 그리고, 클릭 지점만 읽는다.
주의: readPixels는 GPU 파이프라인을 멈춘다. WebGL2에서는 픽셀 버퍼 오브젝트로 받아 fenceSync 로 완료를 기다렸다가 getBufferSubData 하면 비동기가 된다. 60fps를 지키려면 이 차이가 크다.
좌표 정밀도 — 조용히 무너지는 곳
이건 반드시 짚어야 한다. GPU의 float은 32비트고 가수부가 24비트다. 월드 좌표를 UTM 같은 절대 좌표(수십만 m)로 그대로 올리면 표현 가능한 최소 간격이 수 cm로 벌어진다. 카메라를 당기면 점들이 격자에 붙어 떠는 게 보인다.
해법은 3D 기하에서 하던 것과 같다. 원점을 옮긴다. 에고 위치(또는 타일 원점)를 기준으로 상대 좌표를 만들어 올리고, 큰 값은 CPU의 float64에만 둔다. 자율주행 뷰어에서 이건 선택이 아니라 필수다.
그 밖의 함정
- 컨텍스트 손실 — 탭을 오래 두거나 GPU가 리셋되면 컨텍스트가 날아간다.
webglcontextlost/restored를 반드시 처리한다. 안 하면 "돌아왔더니 까만 화면"이 된다. - DPR —
devicePixelRatio를 그대로 쓰면 레티나에서 픽셀이 4배다. 2로 클램프한다. - 화면 밖에서는 멈춘다 —
IntersectionObserver와visibilitychange로 rAF를 끈다. 배터리와 팬 소리가 달라진다. - 정리 — 지오메트리·텍스처·프로그램을 명시적으로 해제한다. SPA에서 라우트를 오가면 GPU 메모리가 샌다.
EXT_color_buffer_float— float 렌더타깃은 WebGL2에서도 확장이다. 없을 때의 경로를 준비한다.
그러면 무엇을 고르나
| 상황 | 선택 |
|---|---|
| 자차 모델·조명·간단한 오버레이가 있는 3D 장면 | three.js |
| 데모·프로토타입, 팀에 그래픽스 인력이 없음 | three.js |
| 점 수백만 + 실시간 스트리밍 + 타임라인 스크럽 | WebGL2 직접 |
| 점유 격자·볼륨 렌더링 | WebGL2 직접 (3D 텍스처) |
| 뷰어가 제품의 핵심이고 몇 년을 유지할 것 | WebGL2 직접 |
| 위 둘이 섞임 | 얇은 자체 레이어 — 카메라·수학은 three.js에서 빌리고 데이터 경로만 직접 |
마지막 줄이 현실적으로 가장 흔한 답이다. 그리고 그게 지금 이 사이트가 있는 자리이기도 하다.
WebGPU는?
컴퓨트 셰이더가 있다는 게 결정적이다. 점군 다운샘플링·정렬·컬링을 GPU에서 끝낼 수 있고, 파이프라인 상태 객체 덕에 드로우콜 오버헤드도 낮다. 자율주행 뷰어에 딱 맞는 물건이다.
다만 지금 시점에서 뷰어를 통째로 에만 얹는 건 이르다. 브라우저·드라이버 편차가 남아 있고, 사내 도구라면 몰라도 외부에 여는 뷰어라면 폴백이 필요하다. 현실적인 태도는 이것이다 — 렌더러 인터페이스를 한 겹 두고 백엔드를 갈아끼울 수 있게 짜 둔다. WebGL2로 시작하되, 데이터 경로를 API에 직접 묶지 않는다.
정리
- WebGL과 three.js는 경쟁 관계가 아니라 층이다. three.js도 WebGL2 위에서 돈다.
- WebGL2가 AV 데이터에서 실제로 바꾸는 것: 정수 attribute, 표준 인스턴싱, 3D 텍스처, , MRT 피킹, .
- three.js는 장면에 강하고, 자율주행 뷰어의 본체인 데이터 스트림에서는 최적화를 더할수록 이점이 사라진다.
- 자체 구축의 핵심은 화려한 셰이더가 아니라 버퍼 레이아웃·부분 갱신·드로우콜 최소화·다.
- 절대 좌표를 그대로 GPU에 올리지 마라. 원점을 에고로 옮긴다.
- 는 방향은 맞지만, 지금은 백엔드를 갈아끼울 수 있게 짜 두는 것이 답이다.
이어서 볼 글: 시각화의 역할과 도구 생태계는 자율주행 시각화, 그 데이터가 어떤 포맷으로 들어오는지는 MCAP 완전 정복, 좌표와 변환의 근간은 3D 기하 기초에서 다룬다.