Skip to content

GS Painting Tool

WebGPU · In-World Art Tool

GS
Painting

브러쉬 스트로크로 가우시안 스플랫 필드를 직접 생성·편집하는 웹 기반 아트 툴.

Section 00

Getting
Started

씬 오픈부터 Unity 내보내기까지
기본 워크플로우

스크롤로 계속 ↓

01

페인팅

브러쉬 종류를 선택하고 파라미터를 설정한 뒤
WebGPU 뷰포트의 3D 표면에 직접 스플랫을 스폰합니다.

→ §04 브러쉬 시스템

02

레이어 시스템

편집 내용을 레이어로 구성하고
가시성 토글·병합으로 변경 사항을 굽습니다.

→ §09 레이어 시스템

03

저장

.gsp 프로젝트로 저장하거나 .ply로 내보내
Unity GaussianSplat 모듈에서 재임포트합니다.

→ §09 파일 I/O

04

프로파일러

렌더링 패스별 GPU 타이밍·드로우콜·스플랫 카운트를
실시간으로 확인합니다.

Section 01

전체 아키텍처

툴은 Main ThreadGS Worker Thread 두 개의 실행 컨텍스트로 분리된다. Main thread는 UI·입력·렌더링을 담당하고, Worker는 GS 스폰·병합·삭제·KNN 연산 전체를 처리한다.

Main Thread
Input Layer
Pointer Events API · 압력·펜·터치
↓ stroke segments
Brush State
타입·크기·색상·모드 · 스트로크 폴리라인 축적
↓ postMessage / SharedArrayBuffer
WebGPU Renderer
Tile Cluster + Beer-Lambert OIT · 60fps
Worker Bridge
→ stroke data ← GS buffer delta
GS Worker Thread
Spawn Pass
SDF Voronoi → Poisson disk · GS 위치·방향 초기화
Merge / Delete Pass
Octree radius query · 거리·회전·스케일 기준 병합
Octree 갱신
NativeChunkedVolumeOctree (WASM 포팅 예정)
Electron 이식 경로

툴은 순수 브라우저 기준으로 설계하되, 추후 Electron 래핑 시 Node.js API(File System, IPC)를 브라우저 폴백(File System Access API, postMessage)으로 교체하는 방식으로 이식한다. WebGPU 렌더링과 Worker 구조는 변경 없이 유지된다.

Section 02

UI 레이아웃

전체 레이아웃은 CSS Grid 기반 5-zone 구조. 뷰포트가 가장 많은 공간을 차지하며, 좌우 패널은 접기 가능하다.

Top Toolbar파일·편집·뷰·브러쉬 메뉴 / Undo·Redo / 저장
Left Panel브러쉬 타입 선택
브러쉬 파라미터
색상 피커 · 팔레트
ViewportWebGPU <canvas>
GS 실시간 렌더링
입력 오버레이 · Gizmo
XYZ
Right Panel레이어 목록
GS 통계
선택 GS 속성 · 물리
Status BarGS 카운트 / 마우스 좌표 / 브러쉬 정보 / fps
영역주요 컴포넌트비고
Top ToolbarFile / Edit / View 메뉴, Undo/Redo 버튼, 저장·불러오기, 브러쉬 단축키 표시height: 40px 고정
Left Panel브러쉬 타입 아이콘 그리드, 크기·밀도·경도 슬라이더, HSV 색상 피커, 스왓치 팔레트width: 220px, 접기 가능
ViewportWebGPU <canvas> (GS 렌더), 투명 오버레이 <canvas> (입력·커서), 카메라 Gizmo, 그리드flex: 1
Right Panel레이어 목록(순서 변경, visibility, lock), 선택 GS 속성 편집, 물리 시뮬레이션 토글, GS 통계width: 240px, 접기 가능
Status Bar좌측: GS 수 / 레이어명. 우측: 뷰포트 커서 3D 좌표, 현재 브러쉬 크기, fpsheight: 24px 고정

Section 03

브라우저 입력 처리

모든 포인터 입력은 Pointer Events API로 단일화하여 처리한다. 마우스·터치·펜 태블릿이 동일한 이벤트 흐름을 따른다.

3.1 이벤트 등록 & 캡처

입력 오버레이 canvas — 이벤트 등록
overlayCanvas.addEventListener('pointerdown',  onPointerDown);
overlayCanvas.addEventListener('pointermove',  onPointerMove);
overlayCanvas.addEventListener('pointerup',    onPointerUp);
overlayCanvas.addEventListener('pointercancel',onPointerCancel);
overlayCanvas.addEventListener('wheel',        onWheel, { passive: false });

function onPointerDown(e) {
  overlayCanvas.setPointerCapture(e.pointerId);
  beginStroke(e);
}
function onPointerMove(e) {
  const events = e.getCoalescedEvents?.() ?? [e];
  for (const ev of events) appendStrokePoint(ev);
}

3.2 포인터 정보 추출

e.pointerId
멀티터치 / 멀티펜 구분 키. setPointerCapture 인자.
e.pointerType
"mouse" / "pen" / "touch". 타입별 행동 분기.
e.pressure
0.0~1.0 필압. 펜 태블릿은 아날로그, 마우스는 0 or 0.5.
e.tiltX / tiltY
펜 기울기 각도 (°). 브러쉬 방향·스케일 연산에 활용 가능.
e.offsetX / offsetY
canvas 기준 픽셀 좌표. DPR 보정 후 NDC로 변환.
e.buttons
비트마스크: 1=좌클릭, 2=우클릭, 4=중간. 오른쪽 드래그=카메라.

3.3 압력 → 브러쉬 파라미터 매핑

pressure (0~1)brush_radius = lerp(minRadius, maxRadius, pressure)
pressure (0~1)spawn_density = lerp(minDensity, maxDensity, pressure²)
tiltX, tiltYGS 주축 오프셋 (펜 기울기 방향으로 스플랫 기울임)

3.4 rAF 기반 스트로크 처리

rAF 루프 — 입력 일괄 처리
let pendingPoints = [];

function appendStrokePoint(e) {
  const dpr = window.devicePixelRatio;
  pendingPoints.push({ x: e.offsetX * dpr, y: e.offsetY * dpr,
    pressure: e.pressure, tiltX: e.tiltX, tiltY: e.tiltY, t: performance.now() });
}

function rafLoop(timestamp) {
  if (pendingPoints.length > 0) {
    gsWorker.postMessage({ type: 'stroke', points: pendingPoints });
    pendingPoints = [];
  }
  renderer.render();
  requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);

3.5 뷰포트 조작 입력 분기

입력 조합동작
Left drag (기본)브러쉬 스트로크 (현재 브러쉬 타입)
Right drag / Alt + Left drag카메라 Orbit
Middle drag / Shift + Left drag카메라 Pan
Wheel카메라 Zoom (dolly)
Ctrl + Z / Ctrl + YUndo / Redo
[ / ]브러쉬 반경 −/+
Space + drag캔버스 임시 Pan (브러쉬 모드 유지)

Section 04

브러쉬 시스템

4.1 브러쉬 타입

Paint
GS 스폰 + 기존 GS 병합. 설정된 색상·반경·밀도로 새 스플랫을 씬에 추가.
B
Erase
반경 내 GS 삭제. density 감쇠 또는 즉시 삭제 선택.
E
Smooth
반경 내 GS의 extents·quaternion을 이웃 평균으로 수렴.
S
Select
GS 범위 선택. 선택 후 이동·색상·삭제 등 일괄 편집.
M
Push / Pull
GS를 브러쉬 법선 방향으로 밀거나 당김. 표면 조각 느낌.
G
Repaint
반경 내 GS의 color만 교체. 위치 유지, 스폰·병합 없음.
C

4.2 브러쉬 파라미터

파라미터범위압력 반응설명
Radius0.01 ~ 10.0 m✓ size월드 공간 기준 브러쉬 풋프린트 반경. [ / ] 키로 조절.
Density0.0 ~ 1.0✓ density²스트로크 스텝당 채워지는 새 스플랫 비율.
Size (extents)0.001 ~ 2.0신규 GS의 extents 균등 스케일. 스트로크 방향으로 elongate.
Hardness0.0 ~ 1.0가우시안 falloff 형태. 0=부드러운 가장자리, 1=날카로운 경계.
Spacing1 ~ 200 %스트로크 선분당 최소 간격 (radius 기준 %).
Noise0.0 ~ 1.0스폰 위치에 추가하는 랜덤 오프셋 강도.
Color (OKLCh)L · C · H지각적으로 균일한 OKLCh 색상 모델. 선형 RGB로 변환 후 GS에 저장.

4.3 스트로크 상태머신

IDLE
커서 위치 계산 · 브러쉬 원 오버레이
pointerdown
STROKE_START
첫 포인트 기록 · Undo 스냅샷 생성
pointermove
STROKING
선분 축적 · Worker 전송 · GS 스폰/병합
pointerup / cancel
STROKE_END
스트로크 완료 · Undo 스택 push · Octree 갱신
완료
IDLE

Section 05

뷰포트 & 카메라

5.1 카메라 모델

Orbit 카메라. target point를 중심으로 구면 좌표계로 회전한다.

targetvec3 — 궤도 중심점
radiusfloat — 중심까지 거리
azimuthfloat — 수평 각도 (rad)
elevationfloat — 수직 각도 (rad, ±π/2)
fovfloat — 수직 시야각 (rad)
near / farfloat — 클립 평면

5.2 스크린 → 월드 좌표 변환

NDC → World Ray
const ndc = vec2(
  (px / canvas.width)  * 2 - 1,
  1 - (py / canvas.height) * 2
);
const clipPos  = vec4(ndc, -1, 1);
const viewPos  = invProj * clipPos;
const worldDir = normalize((invView * vec4(viewPos.xy, -1, 0)).xyz);
const rayOrigin = cameraPosition;

5.3 카메라 조작 구현

Orbit
azimuth += dx * sensitivity
elevation = clamp(elev − dy * sensitivity, −π/2+ε, π/2−ε)
Pan
target += cameraRight * (−dx * panScale)
target += cameraUp * (dy * panScale)
Zoom (Dolly)
radius = clamp(radius * (1 − wheel * 0.001), minR, maxR)
Focus (F)
target = 선택 GS 위치 평균
radius = max(선택 GS AABB 크기 * 2, 0.5)

Section 06

WebGPU 렌더링 파이프라인

Tile Cluster + Beer-Lambert OIT 렌더링을 브라우저 WebGPU로 구현한다. WebGPU 미지원 환경에서는 WebGL 2 기반 폴백을 제공한다.

GPUDevice 초기화
navigator.gpu.requestAdapter()adapter.requestDevice(). 미지원 시 WebGL 2 폴백. GS GPU 버퍼(GPUBuffer, STORAGE | COPY_DST), 타일 버퍼, 유니폼 버퍼 할당.
Projection Pass (Compute)
GS를 스크린스페이스로 투영. 각 GS의 2D AABB를 계산하여 16×16 타일 버킷에 등록. 타일당 최대 GS 개수 제한, 초과 시 depth 기준 상위 N개만 유지.
OIT Accumulation Pass (Compute / Fragment)
타일별로 포함된 GS를 순회하며 Beer-Lambert 누적. 픽셀당: T *= exp(−σ), C += T_in · color · (1 − exp(−σ)). 순서 무관 누적이므로 sorting 불필요.
Composite Pass (Fragment)
누적된 색상·투과율을 배경 위에 합성. 체커보드 배경(투명 확인용) 또는 단색 배경. 오버레이 canvas (cursor, selection rect 등)를 drawImage로 합성.
CPU → GPU GS 버퍼 업로드

Worker에서 GS가 변경될 때마다 변경된 GS 인덱스 범위를 delta로 Main thread에 전달. Main thread는 해당 범위만 device.queue.writeBuffer()로 부분 업로드. CPU 레이아웃(56B)을 GPU 레이아웃(64B)으로 변환하는 별도 변환 버퍼 필요 (pos 이후 4B 패딩 삽입).

Section 07

GS Worker 스레드

GS 연산(스폰·병합·삭제·KNN)은 별도 Worker에서 실행하여 Main thread 블로킹을 방지한다.

Main → Worker
메시지 type페이로드설명
init{ gsBuffer: SharedArrayBuffer, capacity }SharedArrayBuffer 공유 초기화
stroke{ points[], brushParams }스트로크 포인트 배열 전달 → 스폰/병합 실행
erase{ center, radius }반경 내 GS 삭제
smooth{ center, radius, strength }반경 내 GS 스무딩 패스
undo_restore{ snapshot: ArrayBuffer }Undo 스냅샷 복원
Worker → Main
메시지 type페이로드설명
gs_delta{ dirtyRange: [start, end], gsCount }변경된 GS 인덱스 범위 → GPU 버퍼 부분 업로드
stats{ gsCount, mergeCount, deleteCount }Status Bar 통계 업데이트
📦
SharedArrayBuffer vs. postMessage transfer

GS 배열이 크면 매 프레임 postMessage 복사가 부담된다. SharedArrayBuffer를 사용하면 Worker와 Main thread가 동일 메모리를 참조. 단, Atomics로 동시 접근 동기화 필요 (COOP/COEP 헤더 요구). SharedArrayBuffer 미사용 환경에서는 ArrayBuffer transfer로 폴백.

Section 08

Undo / Redo

Command 패턴. 각 스트로크 완료 시점에 영향받은 GS 인덱스 범위의 스냅샷만 저장한다. 전체 GS 필드를 매번 복사하지 않는다.

Undo Stack
Stroke Adelta snapshot
Stroke Bdelta snapshot
Stroke C ← cursordelta snapshot
Redo Stack
Stroke Dpopped on undo
스냅샷 단위 — 스트로크 시작 시 영향받을 GS 인덱스 범위를 예측(브러쉬 AABB 반경 쿼리)하여 해당 범위의 GS 데이터를 ArrayBuffer로 복사 보관.
Undo 실행 — 스택에서 최신 커맨드 pop → 스냅샷 ArrayBuffer를 Worker로 전달(undo_restore) → Worker가 해당 인덱스 범위를 스냅샷으로 덮어씀 → Redo 스택에 push.
스택 크기 제한 — 최대 50 스텝. 초과 시 가장 오래된 커맨드 제거. 스냅샷 총 메모리 상한 설정 가능(기본 512MB).
새 스트로크 시 Redo 스택 클리어 — Undo 후 새 스트로크를 그리면 Redo 스택을 비운다 (표준 편집기 동작).

Section 09

파일 I/O & 레이어 시스템

9.1 파일 포맷

.gsp
Native GS Project
바이너리 헤더 + GS 배열(CPU 레이아웃 56B×N) + 레이어 메타 JSON. 전체 편집 상태 보존. Undo 스냅샷은 포함하지 않음.
저장불러오기
.ply
PLY Export
표준 Gaussian Splatting PLY 포맷. position, quaternion(rot_*), scale(scale_*), opacity, color 필드 매핑. 타 GS 뷰어(Luma, SuperSplat 등)와 호환.
내보내기 전용
.json
Layer Metadata
레이어 이름·순서·visibility·softbody 설정 등 메타데이터. .gsp 파일 내 임베드되거나 독립 파일로 공유 가능.
메타

9.2 .gsp 바이너리 구조

Header 32 B
magic u32version u32gridCount u32layerCount u32directoryOffset u32metaOffset u32metaLength u32reserved u32
Grid Directory Entry 24 B × N
gx, gy, gz i32×3gsCount u32dataOffset u32dataLength u32
Grid Float Data
Per-splat floats, stride GS_CPU_STRIDE_FLOATS (56 B)
Layer Metadata JSON
Layer names, visibility, blend modes

9.3 브라우저 File I/O

File System Access API — 저장 / 불러오기
async function saveProject() {
  const handle = await window.showSaveFilePicker({
    suggestedName: 'scene.gsp',
    types: [{ description: 'GS Project', accept: { 'application/octet-stream': ['.gsp'] } }]
  });
  const writable = await handle.createWritable();
  await writable.write(buildGspBuffer(gsField, layers));
  await writable.close();
}

async function loadProject() {
  const [handle] = await window.showOpenFilePicker({
    types: [{ accept: { 'application/octet-stream': ['.gsp', '.ply'] } }]
  });
  const file = await handle.getFile();
  const buffer = await file.arrayBuffer();
  parseGspOrPly(buffer);
}

9.4 레이어 시스템

속성타입설명
namestring레이어 표시명
visiblebool숨김/표시. 렌더링에서 제외 (GS 삭제 아님)
lockedbool잠금. 브러쉬가 이 레이어의 GS를 수정 불가
opacityfloat 0~1레이어 전체 투명도 스케일 (density에 곱함)
softbodyboolSpring-Mass 물리 활성화 여부
gsBufferFloat32Array이 레이어의 GS 데이터 배열 (CPU 레이아웃)
WebGPU 지원 브라우저 필요 — Chrome 113+ / Edge 113+. navigator.gpu를 사용할 수 없는 환경에서는 실행 시 경고가 표시되고 WebGL 2 폴백으로 전환됩니다.