아키텍처
패키지 의존 관계
mirage-engine (파사드)
│
┌────────────┼────────────┐
▼ ▼ ▼
core sandwich (타입)
│ │
┌────┼────┐ │
▼ ▼ ▼ ▼
painter │ wasm- dom-tracker
└── computedom-tracker는 의존성이 없습니다. painter는 three에만 의존합니다. 모든
화살표가 아래를 향하며, 순환이 없습니다.
프레임 파이프라인
Tracker가 뭔가 바뀌었다를 판단
packages/dom-tracker/src/Tracker.ts
MutationObserver가 childList, attributes, characterData, subtree를
감시합니다. 각 레코드가 pendingMask에 비트를 더합니다.
| 변경 | 설정되는 비트 |
|---|---|
childList | DIRTY_STRUCTURE |
style 속성 | DIRTY_RECT | DIRTY_STYLE + 파싱된 StyleData |
class 속성 | DIRTY_RECT | DIRTY_STYLE |
data-* 속성 | DIRTY_RECT | DIRTY_STYLE (data-mirage*면 DIRTY_STRUCTURE 추가) |
characterData | DIRTY_CONTENT | DIRTY_RECT |
구조 변경은 즉시 플러시되고, 나머지는 200ms 디바운스를 기다립니다. 연속된 편집을 한 번의 추출로 합치기 위해서입니다.
이후 renderLoop이 다섯 개의 훅을 순서대로 발화합니다. onBeforeRender,
onLayoutChange(더티일 때만), onScrollChange, onStyleChange, onRender.
Syncer가 훅과 작업을 연결
packages/core/src/core/Syncer.ts
Syncer는 자체 루프가 없습니다. 트래커 훅과 렌더러 호출 사이의 어댑터입니다.
tracker.onLayoutChange.add((mask, deletions) => {
renderer.updateScroll();
const graph = extractSceneGraph(target, mask, USER_LAYER, /* … */ ctx);
renderer.syncScene(graph, deletions);
renderer.saveInitialLocals(sharedArray);
});
tracker.onStyleChange.add((styles) => animateMeshByData(registry, styles, sharedArray));
tracker.onRender.add(() => {
renderer.updateScroll();
wasmSync.updatePhysics(lastNodeCount);
renderer.syncMeshesByWasm(sharedArray);
renderer.render();
});Extractor가 DOM을 평탄화
packages/core/src/dom/Extractor.ts
재귀 함수 extractSceneGraph 하나가 SceneNode 트리를 만듭니다. 노드마다:
getBoundingClientRect()와getComputedStyle()을 읽고data-mirage-filter/-select를 가시성 비트로 파싱하고data-mirage-travel을isTraveler,captureLayer,nativeLayer, 네이티브 스타일 오버라이드로 파싱하고data-mirage-shader를ShaderHooks로 파싱하고<img>, 인라인 직렬화된<svg>,background-image에서imageSrc를 결정하고- 텍스트 노드를
Range.getClientRects()로 줄 단위 박스로 쪼개고 wasmIndex를 부여하고 공유 메모리에[parent, localX, localY]를 씁니다
부모를 자식보다 먼저 방문하므로 공유 배열이 위상 정렬 순서로 나옵니다. WASM 패스가 한 번의 순회로 해결되는 이유입니다.
Renderer가 메시를 조정
packages/core/src/renderer/Renderer.ts
syncScene이 리사이즈/이동을 감지한 뒤 reconcileNode를 재귀 호출합니다.
- 요소를 키로
MeshRegistry에서 메시를 조회 - 셰이더 해시가 바뀌었으면 해제 후 재생성
- 아니면 스케일, 위치, 레이어, 머티리얼 유니폼을 갱신
TEXT노드는reconcileTextChild를 거치며, 내용이나 스타일이 바뀌면 줄마다 자식 메시를 다시 만듭니다- 이미지 텍스처를
TextureLifecycleManager에 등록/해제
삭제된 요소는 pendingDeletions로 들어와 메시·지오메트리·머티리얼·텍스처가
정리됩니다.
WASM이 위치를 확정
packages/wasm-compute/src/lib.rs
for i in 0..node_count {
let offset = i * 5;
let parent = buffer[offset];
if parent >= 0.0 {
let p = parent as usize * 5;
buffer[offset + 3] = buffer[p + 3] + buffer[offset + 1];
buffer[offset + 4] = buffer[p + 4] + buffer[offset + 2];
} else {
buffer[offset + 3] = buffer[offset + 1];
buffer[offset + 4] = buffer[offset + 2];
}
}이후 syncMeshesByWasm이 월드 좌표를 읽어 WebGL 공간으로 변환하고 스크롤
오프셋을 적용합니다(position: fixed 메시는 건너뜁니다).
렌더
traveler가 먼저입니다. 각 레이어에 대해 카메라를 캡처 채널 31 - n으로
전환하고, traveler마다 시저 박스를 설정한 뒤 해당 레이어의
WebGLRenderTarget에 씬을 렌더링합니다. 그다음 카메라를 기본 채널로 되돌려
실제 프레임을 그리며, overflow: hidden 클리핑을 위해 메시별 시저 훅을
적용합니다.
핵심 자료구조
MeshRegistry
WeakMap<HTMLElement, THREE.Mesh>입니다. 약한 키 덕분에 제거된 요소의 메시가
별도 관리 없이 수거 대상이 됩니다. 텍스트 노드는 Text 노드 자체가 유효한
WeakMap 키가 아니므로 부모 요소 + 인덱스로 키를 만듭니다.
TextureLifecycleManager
WeakMap 세 개(텍스처, 로드 상태, url)와 루트 마진 300px의
IntersectionObserver로 구성됩니다. 화면 밖 텍스처는 해제되고 돌아오면 다시
로드됩니다. 컬링은 overlay + viewport 모드에서만 활성화됩니다.
공유 메모리
노드당 WASM_STRIDE = 5 float이며, Syncer.start()에서 10,000 노드 분량을
선할당합니다. JS 뷰는 new Float32Array(wasm.memory.buffer, ptr, len) —
같은 바이트를 복사 없이 봅니다.
미러링 노드가 10,000개를 넘으면 할당 범위를 넘어 씁니다. 현재 확장 경로가 없으며, 알려진 한계입니다.
레이어 할당
채널은 충돌을 피하려고 양 끝에서부터 할당합니다.
0 BASE ← 일반 가시 씬
1 SELECTED ← data-mirage-select 패스
…
21 getCaptureLayer(10) ← traveler 레이어 10
…
30 getCaptureLayer(1) ← traveler 레이어 1
31 HIDDEN ← 추출되지만 절대 그리지 않음왜 이런 설계인가
추출은 게이팅하고, 렌더링은 하지 않는다. 매 프레임 렌더링은 저렴하고 예측 가능합니다. 추출은 그렇지 않습니다 — 동기 레이아웃을 강제하니까요. 그래서 Mirage는 무조건 렌더링하고, 추출만 더티 플래그 뒤에 둡니다.
텍스트는 줄 단위로 쪼갠다. 텍스트 노드당 쿼드 하나면 줄바꿈된 텍스트가 늘어납니다. 줄마다 측정해 각자 메시를 주면 메시 수는 늘지만 타이포그래피가 정확해집니다.
위치는 JS가 아니라 WASM에 산다. 평탄한 타입 배열에 대한 부모→자식 누적은 캐시 친화적이고, JS에서 노드마다 객체를 순회하는 비용을 없앱니다.
요소 캐시는 WeakMap. 프레임워크 리렌더로 요소가 계속 생겼다 사라집니다.
약한 키를 쓰면 누수도, 수동 무효화도 없습니다.
과거 설계 기록
저장소 루트에 동기화나 native 경로를 손대기 전에 읽어 볼 만한 엔지니어링 로그 두 개가 있습니다.
cbrs_troubleshooting.md— 카메라 기반 스크롤을 매 프레임 rect 동기화로 교체한 이유(Lenis와 가상 스크롤에서 발생한 "200ms 텔레포트" 버그). WASM 패스 이전에 작성되어 함수 이름은 현재와 다릅니다.native_troubleshooting.md—data-mirage-travel="native"를 만들며 발견한 스타일 오염, 레이어 초기화, SVG 오버라이드 버그.