GS Painting Tool
Section 01
전체 아키텍처
툴은 Main Thread와 GS Worker Thread 두 개의 실행 컨텍스트로 분리된다. Main thread는 UI·입력·렌더링을 담당하고, Worker는 GS 스폰·병합·삭제·KNN 연산 전체를 처리한다.
툴은 순수 브라우저 기준으로 설계하되, 추후 Electron 래핑 시 Node.js API(File System, IPC)를 브라우저 폴백(File System Access API, postMessage)으로 교체하는 방식으로 이식한다. WebGPU 렌더링과 Worker 구조는 변경 없이 유지된다.
Section 02
UI 레이아웃
전체 레이아웃은 CSS Grid 기반 5-zone 구조. 뷰포트가 가장 많은 공간을 차지하며, 좌우 패널은 접기 가능하다.
브러쉬 파라미터
색상 피커 · 팔레트
GS 통계
선택 GS 속성 · 물리
<canvas> (GS 렌더), 투명 오버레이 <canvas> (입력·커서), 카메라 Gizmo, 그리드flex: 1Section 03
브라우저 입력 처리
모든 포인터 입력은 Pointer Events API로 단일화하여 처리한다. 마우스·터치·펜 태블릿이 동일한 이벤트 흐름을 따른다.
3.1 이벤트 등록 & 캡처
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.pointerIde.pointerType"mouse" / "pen" / "touch". 타입별 행동 분기.e.pressuree.tiltX / tiltYe.offsetX / offsetYe.buttons3.3 압력 → 브러쉬 파라미터 매핑
3.4 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 뷰포트 조작 입력 분기
Section 04
브러쉬 시스템
4.1 브러쉬 타입
4.2 브러쉬 파라미터
4.3 스트로크 상태머신
Section 05
뷰포트 & 카메라
5.1 카메라 모델
Orbit 카메라. target point를 중심으로 구면 좌표계로 회전한다.
targetvec3 — 궤도 중심점radiusfloat — 중심까지 거리azimuthfloat — 수평 각도 (rad)elevationfloat — 수직 각도 (rad, ±π/2)fovfloat — 수직 시야각 (rad)near / farfloat — 클립 평면5.2 스크린 → 월드 좌표 변환
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 카메라 조작 구현
elevation = clamp(elev − dy * sensitivity, −π/2+ε, π/2−ε)
target += cameraUp * (dy * panScale)
radius = max(선택 GS AABB 크기 * 2, 0.5)
Section 06
WebGPU 렌더링 파이프라인
Tile Cluster + Beer-Lambert OIT 렌더링을 브라우저 WebGPU로 구현한다. WebGPU 미지원 환경에서는 WebGL 2 기반 폴백을 제공한다.
navigator.gpu.requestAdapter() → adapter.requestDevice(). 미지원 시 WebGL 2 폴백. GS GPU 버퍼(GPUBuffer, STORAGE | COPY_DST), 타일 버퍼, 유니폼 버퍼 할당.T *= exp(−σ), C += T_in · color · (1 − exp(−σ)). 순서 무관 누적이므로 sorting 불필요.drawImage로 합성.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 블로킹을 방지한다.
init{ gsBuffer: SharedArrayBuffer, capacity }SharedArrayBuffer 공유 초기화stroke{ points[], brushParams }스트로크 포인트 배열 전달 → 스폰/병합 실행erase{ center, radius }반경 내 GS 삭제smooth{ center, radius, strength }반경 내 GS 스무딩 패스undo_restore{ snapshot: ArrayBuffer }Undo 스냅샷 복원gs_delta{ dirtyRange: [start, end], gsCount }변경된 GS 인덱스 범위 → GPU 버퍼 부분 업로드stats{ gsCount, mergeCount, deleteCount }Status Bar 통계 업데이트GS 배열이 크면 매 프레임 postMessage 복사가 부담된다. SharedArrayBuffer를 사용하면 Worker와 Main thread가 동일 메모리를 참조. 단, Atomics로 동시 접근 동기화 필요 (COOP/COEP 헤더 요구). SharedArrayBuffer 미사용 환경에서는 ArrayBuffer transfer로 폴백.
Section 08
Undo / Redo
Command 패턴. 각 스트로크 완료 시점에 영향받은 GS 인덱스 범위의 스냅샷만 저장한다. 전체 GS 필드를 매번 복사하지 않는다.
ArrayBuffer로 복사 보관.ArrayBuffer를 Worker로 전달(undo_restore) → Worker가 해당 인덱스 범위를 스냅샷으로 덮어씀 → Redo 스택에 push.Section 09
파일 I/O & 레이어 시스템
9.1 파일 포맷
9.2 .gsp 바이너리 구조
u32version u32gridCount u32layerCount u32directoryOffset u32metaOffset u32metaLength u32reserved u32i32×3gsCount u32dataOffset u32dataLength u32GS_CPU_STRIDE_FLOATS (56 B)9.3 브라우저 File I/O
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 레이어 시스템
navigator.gpu를 사용할 수 없는 환경에서는 실행 시 경고가 표시되고 WebGL 2 폴백으로 전환됩니다.