CSS 개발자를 위한 GPU 2D 에디터 만들기
Guide
CSS Matrix
Guide
CSS Matrix
  • Part 0. CSS에서 GPU로 사고 전환

    • CSS 박스가 사라지면 무엇이 남는가
    • Canvas는 DOM이 아니라 픽셀 버퍼다
    • CSS pixel, device pixel, backing store
    • scene model과 renderer 분리하기
    • retained mode vs immediate mode
    • DOM event target 없이 hit testing 하기
  • Part 1. Canvas editor의 기본 뼈대

    • render loop와 frame lifecycle
    • viewport와 camera
    • screen/world/local 좌표계 복습
    • cursor anchored zoom
    • grid와 ruler를 canvas에 그리기
    • overlay layer와 control layer 분리
  • Part 2. WebGL 2D renderer

    • WebGL context와 첫 triangle
    • clip space와 화면 좌표
    • shader를 CSS transform 관점으로 읽기
    • rectangle을 두 triangle로 그리기
    • matrix uniform으로 camera 적용하기
    • 색상, alpha, blending
    • 여러 rectangle을 buffer에 담기
    • batching과 draw call 줄이기
    • instanced rectangle renderer
    • line/outline renderer
  • Part 3. Editor tool math

    • pointer 좌표를 world 좌표로 바꾸기
    • CPU hit testing
    • selection bounds
    • hover outline
    • move tool
    • resize handles
    • rotation handle
    • marquee selection
    • snapping과 smart guides
    • group transform
  • Part 4. Figma-like scene graph

    • node tree 설계
    • transform inheritance
    • layer order와 z sorting
    • frame과 clipping
    • fill, stroke, effect 모델
    • command model과 undo/redo
    • JSON export/import
    • renderer-independent editor core
  • Part 5. Image, text, vector

    • texture로 image node 그리기
    • texture atlas 기본
    • text rendering 전략
    • SVG/text를 GPU editor에서 다루는 경계
    • vector path는 어디까지 직접 구현할 것인가
  • Part 6. WebGPU로 옮기기

    • WebGPU adapter/device/context
    • WGSL과 render pipeline
    • WebGL renderer를 WebGPU renderer로 바꾸기
    • uniform buffer와 bind group
    • WebGPU instancing
    • WebGL/WebGPU fallback 전략
  • Part 7. Capstone

    • editor shell 만들기
    • toolbar / layer panel / inspector 연결
    • mini Figma-like editor 완성
    • 성능 점검과 디버깅
    • 배포와 브라우저 호환성 체크
  • Part 8. Three.js로 WebGL 개발하기

    • Three.js를 WebGL renderer로 쓰는 기준
    • Scene, Camera, Renderer, render loop
    • OrthographicCamera로 2D editor 좌표계 만들기
    • BufferGeometry, Material, ShaderMaterial
    • Raycaster와 editor picking
    • Three.js renderer를 editor core 뒤에 붙이기
    • Three.js 프로젝트 세팅과 renderer lifecycle
    • Object3D transform과 editor scene graph 매핑
    • InstancedMesh로 많은 rectangle 그리기
    • Texture, CanvasTexture, Sprite로 이미지/텍스트 다루기
    • Three.js에서 outline, selection, overlay 만들기
    • dispose, cache, renderer.info로 성능 관리하기
    • WebGPURenderer와 TSL로 넘어가는 길
    • RenderTarget을 이용한 picking buffer
    • Three.js를 쓰면 안 좋은 경우
  • Appendix A. GPU editor debugging

    • WebGL/WebGPU 디버그 overlay 만들기
    • 좌표계, matrix, bounds readout 설계
    • frame time, draw call, buffer upload 측정하기
    • Spector.js / Chrome DevTools로 WebGL 프레임 보기
  • Appendix B. Browser and GPU compatibility

    • WebGL/WebGPU feature detection 체크리스트
    • DPR, resize, context lost 처리
    • Safari/Chrome/Firefox 차이와 fallback 정책
    • GPU memory와 texture size 제한
  • Appendix C. Asset pipeline

    • 이미지 로딩, ImageBitmap, texture upload
    • SVG를 texture로 쓸지 vector로 유지할지
    • 폰트 로딩과 text metrics
    • export용 PNG/SVG/JSON 생성 전략
  • Appendix D. Interaction polish and motion

    • inertial pan과 smooth zoom
    • snapping feedback animation
    • selection/hover transition
    • timeline 없이 필요한 최소 모션 수학
  • Appendix E. Production architecture

    • renderer worker / OffscreenCanvas를 고려하는 기준
    • document model versioning과 migration
    • plugin architecture와 command API
    • test 가능한 renderer abstraction 만들기
  • Appendix F. 2D renderer engine patterns

    • renderable type 선택: shape, sprite, mesh
    • static subtree를 texture cache로 굽기
    • render layer와 render group 설계
    • viewport culling과 spatial index
    • clipping 구현: scissor, stencil, mask texture
    • filters와 blend modes가 batch를 깨는 이유
    • interactivity budget: pickable, hitArea, skip children
    • texture GC와 idle resource eviction
    • dynamic text update 비용과 bitmap/glyph 전략
    • Canvas/WebGL editor의 accessibility layer
  • Appendix G. Rendering editor production gaps

    • render invalidation과 dirty flag
    • color space, premultiplied alpha, export 색상
    • stroke join/cap/dash/fill rule
    • editable text: DOM overlay, IME, caret, metrics
    • tool state machine과 pointer capture
    • pixel test와 renderer regression test

cursor anchored zoom

무한 캔버스에서 zoom은 단순히 zoom *= 1.1로 끝나지 않습니다. 사용자는 마우스 커서 아래의 지점이 그대로 남아 있기를 기대합니다.

줌 전에도 커서 아래에 있던 world point
줌 후에도 같은 screen point 아래에 있어야 한다

이것을 cursor anchored zoom이라고 부르겠습니다.

나쁜 zoom은 화면을 미끄러뜨린다

camera 위치를 그대로 두고 zoom만 바꾸면 화면 왼쪽 위를 기준으로 확대됩니다.

camera.zoom *= 1.1;

이러면 커서 아래의 도형이 커서에서 밀려납니다. 사용자는 자신이 보고 있던 지점을 잃어버립니다.

고정할 world point를 먼저 구한다

먼저 zoom 전 커서 아래의 world 좌표를 구합니다.

const before = screenToWorld(cursor, camera);

그다음 zoom을 바꾸고, 같은 cursor screen 좌표가 다시 before를 가리키도록 camera 위치를 계산합니다.

const newZoom = camera.zoom * zoomFactor;

camera.x = before.x - cursor.x / newZoom;
camera.y = before.y - cursor.y / newZoom;
camera.zoom = newZoom;

공식은 짧지만 의미는 분명합니다.

world = screen / zoom + camera
camera = world - screen / zoom

실제 함수는 camera를 직접 mutate하지 않고 새 camera를 반환하는 편이 undo/debug에 좋습니다.

function clamp(value, min, max) {
  return Math.min(max, Math.max(min, value));
}

function zoomAt(camera, screenPoint, zoomFactor) {
  const before = screenToWorld(screenPoint, camera);
  const nextZoom = clamp(
    camera.zoom * zoomFactor,
    camera.minZoom ?? 0.05,
    camera.maxZoom ?? 32
  );

  return {
    ...camera,
    x: before.x - screenPoint.x / nextZoom,
    y: before.y - screenPoint.y / nextZoom,
    zoom: nextZoom
  };
}

검증도 코드로 할 수 있습니다. zoom 후에도 같은 world point가 같은 screen point로 돌아와야 합니다.

const nextCamera = zoomAt(camera, cursor, 1.2);
const afterScreen = worldToScreen(before, nextCamera);

console.assert(Math.abs(afterScreen.x - cursor.x) < 0.001);
console.assert(Math.abs(afterScreen.y - cursor.y) < 0.001);

matrix로 보면 anchor 보정이다

cursor anchored zoom은 matrix 관점에서 보면 “screen anchor 주변으로 camera inverse를 다시 맞추는 일”입니다.

screen = cameraToScreen * world
world = screenToWorld * screen

줌 전후에 아래 식이 유지되어야 합니다.

screenToWorld(nextCamera) * cursorScreen
  ===
screenToWorld(oldCamera) * cursorScreen

그래서 새 camera 위치를 anchorWorld - cursorScreen / nextZoom으로 다시 계산합니다. 이 설명이 잡히면 WebGL에서 projection/camera matrix를 uniform으로 보낼 때도 같은 사고를 유지할 수 있습니다.

wheel delta는 정규화가 필요하다

브라우저와 장치마다 wheel delta는 다르게 들어올 수 있습니다. 트랙패드와 마우스 휠도 느낌이 다릅니다.

처음에는 간단히 시작합니다.

const zoomFactor = Math.exp(-event.deltaY * 0.001);

브라우저 event까지 묶으면 이런 형태가 됩니다.

canvas.addEventListener("wheel", (event) => {
  event.preventDefault();
  const cursor = clientToCanvas(event, canvas);
  const zoomFactor = Math.exp(-event.deltaY * 0.001);
  editor.dispatch({
    type: "setCamera",
    camera: zoomAt(editor.state.camera, cursor, zoomFactor)
  });
}, { passive: false });

나중에는 min/max zoom, smooth zoom, pinch gesture를 추가할 수 있습니다. 하지만 핵심은 항상 같습니다. 커서 아래 world point를 고정합니다.

오늘의 핵심

zoom은 scale 값 하나가 아니라 camera를 다시 계산하는 일입니다.

1. cursor screen point를 world로 변환한다.
2. zoom을 바꾼다.
3. 같은 world point가 같은 screen point에 오도록 camera를 조정한다.

이 감각이 있어야 Figma처럼 자연스러운 캔버스 조작을 만들 수 있습니다.

최근 수정: 26. 5. 16. PM 12:53
Contributors: jinho.park.s3
Prev
screen/world/local 좌표계 복습
Next
grid와 ruler를 canvas에 그리기