홈페이지의 토성 옆에 있는 글자 카드(생각 조각, 노트 같은 것들)는 지금까지 3D 화면 위에 떠 있는 DOM이었습니다. 이번 주 Chrome 157에서 HTML-in-Canvas가 정식 출시되었습니다. 지금은 Chrome 157 이상에서만 동작하지만 어차피 언젠가는 다들 지원할 테니 fallback만 잘 만들어 두면 되고, 그래서 저는 처음부터 이 카드들을 토성이 그려지는 canvas 안으로 옮기기로 마음먹었습니다. 글자와 위성을 같은 worker가 같은 프레임에 그리고, 홈페이지가 유휴 상태일 때 메인 스레드 작업이 70% 가까이 줄었습니다.

145ms/s
메인 스레드 총 작업
기존 460, −69%
24ms/s
스타일 재계산
기존 310, −92%
157+
지원 브라우저
Chrome/Edge, 나머지는 DOM

위 숫자는 Chrome 158에서 CPU를 4배 느리게(느린 휴대폰 시뮬레이션) 하고 홈페이지를 10초 동안 유휴 상태로 둔 채 측정한 것이고, dev 모드 기준입니다. 프로덕션 빌드에서는 절대 수치가 다르겠지만 비율은 비슷할 겁니다.

HTML-in-Canvas란 무엇인가#

저는 이것이 최근 몇 년 사이 웹 개발 방식을 실제로 바꿀 몇 안 되는 플랫폼 업데이트라고 생각합니다. 마지막으로 이런 느낌을 받은 건 WebGPU(Chrome 113, 2023년)였습니다. WebGPU는 브라우저가 현대 GPU를 직접 쓸 수 있게 해 주었고, HTML-in-Canvas는 나머지 절반을 채워 GPU가 "진짜 HTML"을 그릴 수 있게 해 줍니다. WebMCP도 최근 큰 뉴스였지만, 그건 AI 에이전트가 웹사이트를 조작하게 하는 것이라 화면을 어떻게 그리느냐와는 별개의 이야기입니다. 아무튼 이런 기능이 나온 게 정말 반갑습니다. 십수 년 동안 존재해 온 오래된 문제를 풀어 주니까요.

예전에는 두 가지 길뿐이었다#

이전까지 canvas와 HTML은 서로 다른 세계였습니다. canvas(2D, WebGL, WebGPU) 안에 레이아웃이 잡힌 텍스트나 버튼을 넣으려면 두 가지 방법뿐이었고, 둘 다 대가가 있었습니다.

방법설명대가
글자를 픽셀로 그리기fillText(), 또는 글자를 텍스처나 SDF 폰트로 만들기스크린 리더가 읽지 못해 보이지 않는 DOM을 따로 유지해야 함. Ctrl+F 검색, 선택과 복사, 우클릭 메뉴가 안 되고 브라우저 번역과 맞춤법 검사도 동작하지 않음. 다국어 줄바꿈, 하이픈 처리, 세로쓰기를 하려면 레이아웃 엔진을 직접 가져와야 함
DOM을 canvas 위에 겹치기매 프레임 JavaScript로 3D 좌표를 계산해 HTML 요소를 이동canvas와 같은 프레임이 아니라서 카메라가 움직이면 떠다니고 떨림. HTML이 항상 canvas 전체 위에 덮여 3D 물체에 가려질 수 없고 shader도 적용 불가. 매 프레임 스타일 변경이 메인 스레드를 잡아먹음

제 홈페이지는 두 번째 방법을 쓰고 있었고, 이 글 뒤에서 측정한 문제는 전부 여기서 비롯됩니다.

이제 세 번째 길이 생겼다#

HTML-in-Canvas는 브라우저가 일반 DOM 요소를 한 장의 이미지로 "찍어서" canvas에 넘겨 그리게 합니다. 핵심은 요소 자체는 여전히 DOM에 남아 있다는 점입니다. 그래서 다음이 가능합니다.

  • 브라우저가 원래 가진 기능이 모두 유지됩니다: 클릭, Tab 키 포커스, 스크린 리더, 텍스트 선택, Ctrl+F, 번역, 폼 자동 완성이 모두 그대로 동작합니다.
  • 레이아웃은 브라우저에 맡깁니다: CSS, 폰트, 줄바꿈, 다국어, :hover 같은 스타일 변화까지 브라우저가 배치한 결과가 그대로 그려지니, 레이아웃을 직접 구현할 필요가 없습니다.
  • 그릴 때는 GPU 텍스처입니다: 3D 장면에 넣고, 물체에 가려지고, 원근에 따라 변형되고, shader로 왜곡이나 광채를 줄 수 있습니다. DOM 오버레이로는 할 수 없는 것들입니다.
  • worker에 넘길 수 있습니다: 메인 스레드는 스냅샷만 찍고, 그리는 작업은 worker의 OffscreenCanvas에서 할 수 있습니다. 다른 3D 콘텐츠와 같은 프레임에 나오니 더 이상 떠다니지 않습니다.
  • 클릭 위치도 따라갑니다: 어디에 그렸는지 브라우저에 알려 주면 클릭과 접근성 위치가 그려진 곳과 맞춰집니다.

제안서에 나온 용도로는 차트의 범례와 축, 창작 도구의 텍스트 상자, 게임 메뉴, 3D 장면 속 스크린이나 간판, shader로 HTML 처리하기, HTML을 이미지나 영상으로 내보내기 등이 있습니다. Babylon.js, PlayCanvas, luma.gl에는 이미 대응하는 사용법이 있습니다.

제약도 있습니다. 개인정보 보호를 위해 다른 출처의 이미지나 iframe, 시스템 색상, 맞춤법 검사의 빨간 밑줄, 방문한 링크의 색상은 그려지지 않습니다. 텍스트에 이 없어서 원래 DOM보다 살짝 옅어 보입니다.

먼저 브라우저와 API 확인하기#

정식 버전에서 이름이 바뀌었다#

이 API는 을 거친 뒤 이름이 바뀌었습니다. 제가 자료를 찾으며 본 튜토리얼, 글, 심지어 프레임워크 구현까지 상당수가 아직 옛 방식이었습니다.

용도정식 버전옛 방식
활성화<canvas content="drawable">, 자식 요소에 drawablelayoutsubtree
2Dctx.drawElementImage(el, x, y)동일
WebGLgl.texElementSubImage2D(...)texElementImage2D
WebGPUdevice.queue.drawElementImageToTexture(...)copyElementImageToTexture
다시 그리는 시점paint 이벤트, 다시 찍으려면 canvas.requestPaint() 호출
3D 안에서의 클릭canvas.updateElementGeometry(el, { canvasTransform })
worker에서 사용메인 스레드에서 captureElementImage(el)로 스냅샷을 찍어 transfer

옛 이름은 Chrome 158에서 이미 undefined라서, 옛 튜토리얼대로 쓰면 아무 반응도 없습니다. 감지하는 방법은 속성을 한 번 설정해 보고 그게 반영되어 돌아오는지 보는 것입니다.

ts
function supportsHtmlInCanvas(): boolean {
  const canvas = document.createElement('canvas');
  canvas.setAttribute('content', 'drawable');
  return canvas.content === 'drawable' && typeof canvas.captureElementImage === 'function';
}

TypeScript의 lib.dom에는 아직 이 API들이 없어서 타입을 직접 선언해야 하고, 지원하지 않는 브라우저에서는 존재하지 않으니 전부 선택적(optional)으로 표시해야 합니다.

가장 간단한 사용법#

먼저 2D 버전을 보면 API 전체의 뼈대가 여기 다 있습니다. canvas에 content="drawable"을, 그릴 자식 요소에 drawable을 붙입니다.

html
<canvas id="c" width="600" height="300" content="drawable">
  <div id="card" drawable>
    <h2>노트 NOTES</h2>
    <a href="/notes">최신 글</a>
  </div>
</canvas>
ts
const canvas = document.querySelector<HTMLCanvasElement>('#c')!;
const ctx = canvas.getContext('2d')!;
const card = document.querySelector<HTMLElement>('#card')!;

// 요소의 내용이나 스타일이 바뀌면(:hover 포함) 브라우저가 paint를 보내니 여기서 다시 그린다
canvas.addEventListener('paint', () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawElementImage(card, 40, 60);
});
canvas.requestPaint(); // 매 프레임 움직이려면 paint 안에서 한 번 더 호출

몇 가지 규칙이 있습니다.

  • drawable은 하나의 독립된 그리기 단위입니다. 그 아래 자손은 함께 그려지지만, 자손 중에 또 drawable이 있으면 그건 제외되어 따로 그려야 합니다.
  • 2D는 클릭 위치를 자동으로 맞춰 줍니다. drawElementImage의 인자와 canvas의 현재 변환을 보고 브라우저가 요소가 어디에 그려졌는지 알기 때문에, 클릭, getBoundingClientRect(), 돋보기까지 모두 맞춰집니다.
  • WebGL/WebGPU는 직접 알려 줘야 합니다. 텍스처가 shader에 의해 어디에 붙었는지 브라우저는 모르니 행렬을 updateElementGeometry()에 넘겨야 합니다. 업데이트하지 않으면 물체가 돌아가도 클릭은 원래 자리에 떨어집니다. clearElementGeometry()는 클릭할 수 없게 만들고 접근성 트리에서 화면 밖으로 표시합니다.

로컬에 테스트할 브라우저가 없었다#

제 서버의 Edge는 148이고 apt의 안정 버전도 156이라 둘 다 지원하지 않았습니다. Edge 157은 beta뿐이고 sudo로 설치해야 해서, 결국 npx @puppeteer/browsers install chrome@beta로 Chrome for Testing 158을 홈 디렉터리에 sudo 없이 설치했습니다. 서버에는 모니터가 없으니 xvfb로 창 모드를 띄우고 Vulkan 옵션을 붙여야 진짜 WebGPU를 쓸 수 있었습니다.

three.js의 HTMLTexture는 쓸 수 없다#

three.js에는 r184부터 HTMLTexture가 있으니, 원래대로라면 그냥 가져다 쓰면 됩니다. 프로젝트에 있는 r186 소스 코드를 읽어 보니 문제가 두 가지 있었습니다.

옛 이름을 쓰고 있다

layoutsubtree를 설정하고 texElementImage2D와 copyElementImageToTexture를 호출하는데, 이것들은 157 이후로 모두 존재하지 않습니다. 이름 변경 수정은 three.js PR #34732에 있고, 작성자는 바로 Chrome 157 발표 글을 쓴 Chrome DevRel인데, 제가 작업할 때까지 아무도 review하지 않은 상태였습니다. 내친김에 막 나온 r187도 받아서 봤는데 역시 옛 이름이었습니다.

canvas가 메인 스레드에 있다고 가정한다

요소를 renderer.domElement에 appendChild한 다음 paint 이벤트를 겁니다. 그런데 제 홈페이지의 별하늘과 토성은 와 위에서 돌아가고, worker는 DOM을 가질 수 없습니다. 그 PR이 병합되더라도 여전히 domElement에서 스냅샷을 찍기 때문에, 제 구조에서는 그 경로가 아예 실행되지 않습니다.

그래서 업로드 흐름은 직접 작성해야 했습니다.

paint 이벤트:captureElementImage(글자 카드)postMessage(스냅샷, transfer)drawElementImageToTexture → 텍스처위성과 같은 프레임에 그림글자 카드의 화면상 위치updateElementGeometry(클릭 위치)메인 스레드worker(WebGPU)메인 스레드worker(WebGPU)

대상으로는 three의 render target 텍스처를 썼습니다. 마침 r187에 renderer.initRenderTarget()이 새로 추가되어, GPU 텍스처를 미리 할당해 두고 거기에 쓸 수 있었습니다. 대략 40줄로 HTMLTexture를 대체했고, 핵심은 이 부분입니다.

ts
const rt = new THREE.RenderTarget(texW, texH, { depthBuffer: false });
renderer.initRenderTarget(rt);
const gpuTexture = renderer.backend.get(rt.texture).texture;

device.queue.drawElementImageToTexture(
  { source: image },                       // 메인 스레드에서 넘어온 ElementImage
  { texture: gpuTexture, size: { width: texW, height: texH } },
);
image.close();                             // 다 쓰면 해제해야 함

image.close()는 그 PR에서 배운 것입니다. 처음 프로토타입에서는 빠뜨려서, paint가 일어날 때마다 해제되지 않은 스냅샷이 하나씩 남았습니다.

프로토타입: 토성 주위를 도는 글자 카드#

사이트 코드를 건드리기 전에, 먼저 임시 공간에 독립 프로토타입을 만들어 홈페이지 구조를 그대로 재현했습니다. worker 안의 three.js WebGPU가 토성을 그리고, DOM 글자 카드 몇 장이 그 주위를 돕니다.

오른쪽 카드는 카메라에 가까워서 원근이 적용되어 더 크고, 마우스를 올려 두어서 테두리가 호박색으로 바뀌었습니다. CSS의 :hover가 텍스처에 즉시 반영된다는 뜻입니다. 뒤쪽 카드는 토성에 가려졌는데, 깊이 테스트가 자연스럽게 해 준 것입니다. 클릭도 정확합니다. 투영된 위치를 클릭하면 elementFromPoint가 올바른 카드를 돌려주고, 가려진 카드는 지오메트리를 지운 뒤 같은 위치를 클릭하면 canvas만 잡힙니다.

프로토타입에서도 몇 가지 함정을 밟았습니다.

  • 텍스처의 색 공간과 UV. 처음에는 바탕색이 밝고 푸르게 뜨고 글자가 위아래로 뒤집혔습니다. 색 공간을 처리하고, 평면의 도 위아래로 뒤집어야 했습니다.
  • 간헐적인 오류. drawElementImageToTexture가 대략 9번에 1번꼴로 "No context found for ElementImage"를 던졌습니다. 요소를 막 넣고 아직 한 번도 그려지지 않은 순간에는 찍히지 않는 것이라, catch한 뒤 requestPaint()를 호출해 다시 찍으면 됩니다.
  • Tab 포커스가 토성 뒤로 간다. 키보드 포커스가 가려진 카드에 떨어지는데, 이건 따로 동작을 정해야 합니다.
  • 멀리 있는 글자가 흐릿하다. 카드가 카메라에서 멀면 텍스처가 축소되니 2배 해상도로 찍어야 합니다.

먼저 작은 라벨만 옮겼더니: 장점은 하나뿐#

프로토타입이 가능하다는 걸 확인하고 새 worktree를 열었습니다. 첫 단계로 홈페이지에 항상 떠 있는 작은 라벨 다섯 개(WORK, NOTES 같은 것들)로 간을 보면서, 실제 사이트에 붙였을 때 정말 어떤 이점이 있는지 확인했습니다. 마우스를 올려야 나타나는 글자 카드는 일단 DOM으로 두었습니다. ?hic=0(원래 DOM 버전으로 강제)과 비교해 보니 결과가 조금 의외였습니다.

  • 메인 스레드가 멈춰도 라벨이 위성에 붙어 있다. 이게 유일하게 뚜렷한 장점이었습니다. 일부러 메인 스레드를 3.5초 멈추게 했더니, 위성은 worker 안에서 평소대로 돌고 DOM 라벨은 제자리에 얼어붙어 점과 글자가 따로 놀았습니다. canvas에 그린 라벨은 내내 위성을 따라갔습니다.
  • 메인 스레드는 13%만 줄었고, 스타일 재계산은 20% 줄었습니다.
  • "행성에 실제로 가려지는" 상황은 홈페이지에서 거의 일어나지 않는다. 가장 안쪽 궤도의 먼 쪽은 행성 위쪽 가장자리를 스치기만 하고, 라벨은 항상 토성에서 멀어지는 방향으로 뻗기 때문에, 2분(거의 두 바퀴)을 기다려도 한 번도 마주치지 못했습니다.
0:00 / 0:00
일부러 메인 스레드를 3.5초 멈추게 함: 왼쪽 DOM 라벨은 제자리에 얼어붙고, 오른쪽 canvas에 그린 라벨은 위성을 따라감

대가도 있었습니다. 그려진 라벨을 클릭할 수 있게 하려면 canvas 위의 세 층(.horbit, <main>, z-index 10인 콘텐츠 컨테이너)이 모두 마우스를 통과시켜야 해서, 꽤 깊은 곳까지 손대야 했습니다.

메인 스레드는 실제로 어디서 막히는가#

13%는 너무 적어서, 나머지 시간이 어디에 쓰이는지 알고 싶었습니다. 로 성능 추적을 녹화하면서 스타일 무효화 추적을 켜고 CPU를 4배 느리게 한 다음, 각 을 그것을 일으킨 요소에 귀속시키고, 하나씩 빼 가며 측정했습니다.

홈페이지 유휴, CPU 4배 감속(초당 ms)메인 스레드스타일 재계산
기존(DOM 라벨)580–660420–494
라벨을 canvas로 이동560–570360–400
글자 카드까지 제거약 175약 50

큰 부분은 라벨이 아니라 글자 카드였습니다. 글자 카드가 스타일 재계산의 거의 90%를 차지했고, 그중 약 60%는 아주 어이없는 이유에서 나왔습니다. 유휴 상태에서 돌아가며 힌트를 보여 주느라 글자 카드의 opacity가 매 프레임 바뀌는데, CSS에 transition: opacity 0.3s가 걸려 있어서 값을 쓸 때마다 이전 트랜지션을 취소하고 새 트랜지션을 만들고 있었습니다.

이 부분은 HTML-in-Canvas 없이도 고칠 수 있어서, 먼저 페이드 인/아웃을 JavaScript에서 계산하도록 바꿨습니다. 모든 브라우저가 혜택을 봅니다. 남은 절반은 매 프레임 십여 개 요소에 inline style을 쓰는 것 자체라서, 글자 카드도 canvas에 그리게 해야만 없앨 수 있습니다. 이걸로 작은 라벨만 옮겼을 때 절약이 적었던 이유도 설명됩니다. 매 프레임 스타일이 바뀌는 진짜 큰손은 글자 카드였던 겁니다.

글자 카드도 옮기기#

트랜지션을 고친 다음, 이어서 글자 카드도 옮겼습니다. 당장 혜택을 보는 건 Chrome 157 이상뿐이지만 지원은 늘어날 일만 남았습니다. fallback을 잘 만들어 두면 오늘은 새 Chrome 사용자가 먼저 이점을 얻고, 나중에는 모든 사람이 얻게 됩니다.

원칙은 "화면이 지금과 완전히 똑같을 것"이었기 때문에, 정식 버전은 프로토타입과 결정적인 차이가 하나 있습니다. 글자는 3D 장면에 들어가지 않습니다. 전체 이미지의 최상위 레이어에 화면 좌표로 그리고 장치 픽셀에 정렬해서, 스냅샷을 픽셀 대 픽셀로 붙입니다. 글자가 다시 샘플링되어 뭉개지지 않습니다. 어차피 홈페이지에서는 실제 가림이 거의 없으니, 그 대가로 DOM 버전과 구별할 수 없는 글자를 얻은 셈입니다.

위치, 페이드 인/아웃, 언제 자리를 비켜 줄지는 순수 함수 모듈(lib/orbitLabels)로 뽑아내서 DOM 버전과 worker가 같은 로직을 공유합니다. 양쪽에서 계산한 결과가 반드시 같습니다.

줄마다 따로 정렬해야 한다#

글자 카드의 각 줄은 위성 위치에 따라 정렬이 바뀝니다. 위성이 오른쪽에 있으면 왼쪽 정렬, 왼쪽에 있으면 오른쪽 정렬, 아래에 있으면 가운데 정렬이고, 그것도 연속적으로 변합니다. 예전처럼 매 프레임 각 줄의 translate를 바꾸면 또다시 스타일 재계산과 스냅샷 재촬영이 일어나서 헛수고가 됩니다.

제 방법은 글자 카드 전체를 한 번만 찍고, 각 줄의 위쪽 경계와 너비를 함께 worker에 보내는 것입니다. worker는 그릴 때 스냅샷을 가로 띠로 잘라서, 각 줄을 같은 텍스처 안의 하위 영역으로 삼아 각자 좌우로 옮깁니다. 바깥쪽 테두리(키보드 포커스 외곽선이 그려지는 곳)는 옮기지 않습니다. 이렇게 하면 카드 내용이 바뀌지 않는 한 다시 찍을 필요가 없고, 매 프레임 정점만 움직이면 됩니다.

언제 다시 찍을지는 paint 이벤트가 정합니다. 이번에 바뀐 요소(changedElements)를 함께 알려 주기 때문에, 마우스를 올리거나 테마를 바꾸거나 카드 내용이 갱신될 때 바뀐 카드만 다시 찍습니다.

ts
canvas.addEventListener('paint', (e) => {
  const changed = e.changedElements;
  const out = [];
  let missed = false;
  for (const el of labels) {
    if (changed?.length && !changed.includes(el)) continue;
    try {
      out.push({ image: canvas.captureElementImage(el), w: el.offsetWidth, h: el.offsetHeight });
    } catch {
      missed = true; // 막 넣어서 아직 그려진 적이 없음, 다음 프레임에 다시 찍기
    }
  }
  if (out.length) worker.postMessage({ type: 'labels', labels: out }, out.map((l) => l.image));
  if (missed) canvas.requestPaint();
});

ElementImage는 transfer가 가능해서 worker에 넘길 때 복사할 필요가 없고, worker 쪽에서는 업로드가 끝나면 close()합니다.

색이 30% 차이 났다#

첫 버전을 그려 보니 작은 글자가 DOM 버전보다 30% 정도 어두웠습니다. 파고들어 보니 두 가지 문제가 있었습니다.

  • 스냅샷은 인데, 저는 사전 곱셈된 것으로 취급해서 투명도를 두 번 곱하고 있었습니다.
  • three는 화면을 canvas로 출력할 때 먼저 투명도를 나누고, sRGB로 변환한 다음, 다시 곱합니다. DOM 글자가 canvas 위에 겹칠 때는 sRGB 값 위에서 섞이는데, 선형 공간에서 섞으면 밝은 글자의 안티앨리어싱 가장자리가 가늘어집니다.

그래서 글자를 겹치는 단계는 three의 출력 규칙을 거꾸로 따라가야 합니다. 먼저 canvas의 원래 sRGB 값을 계산하고, 그 값 위에서 글자와 섞은 뒤, 출력에 필요한 선형 값으로 되돌립니다. 검증은 색을 알고 있는 반투명 바탕으로 했습니다. 흰색 50%를 겹쳤을 때 예상값 134.5, 실제 132였습니다.

위아래 두 장 중 하나는 DOM 버전, 하나는 canvas에 그린 버전인데, 확대해도 거의 구별이 안 됩니다. 유일한 차이는 DOM 글자는 소수점 픽셀에 놓이고 canvas 버전은 정수 픽셀에 정렬되어 가장자리가 조금 더 선명하다는 점입니다.

클릭과 키보드#

worker는 매 프레임 글자 카드의 화면상 박스를 계산해 메인 스레드로 돌려보내고, 이것을 updateElementGeometry에 넘겨 클릭이 그려진 위치에 떨어지게 합니다. 박스가 이전 프레임과 같으면 보내지 않습니다.

키보드와 스크린 리더는 페이지에 있는 보이지 않는 링크 사본을 쓰게 바꿨고, Tab 순서는 예전과 같습니다. canvas에 그린 글자 카드는 aria-hidden으로 표시해 두 번 읽히지 않게 했습니다. 포커스 외곽선은 canvas 안의 글자 카드 위에 그립니다.

하마터면 놓칠 뻔한 부분이 하나 있었습니다. 포커스 외곽선을 그리려고 스냅샷 바깥에 여백을 한 겹 두었더니, 마우스 클릭 범위도 7px 넓어졌습니다. 그래서 키보드 포커스일 때만 바깥 여백을 포함하도록 바꿨고, 마우스 클릭 범위는 DOM 버전과 완전히 같아졌습니다.

결과#

초당 메인 스레드(Chrome 158, CPU 4배 감속, 홈페이지 유휴)DOM 버전(?hic=0)canvas에 그림
총 작업460 ms145 ms(−69%)
스타일 재계산310 ms24 ms(−92%)
스크립트60 ms35 ms

메인 스레드가 매 프레임 하는 일은 "어느 위성이 켜져 있는가"를 계산하는 것뿐이고, 값이 바뀌지 않으면 보내지도 않습니다. 배포 후 실제 사이트에서 확인했습니다. Chrome 158은 HTML-in-Canvas로, Edge 148은 기존대로 DOM으로 동작했고 양쪽 모두 오류가 없었습니다. Firefox, Safari, WebGL2만 있는 기기, 3D가 실행되지 않은 경우도 모두 DOM 버전입니다.

글자가 텍스처가 된 뒤#

성능 작업을 마치고 나니 사실 더 궁금했던 건 다른 쪽이었습니다. 글자가 이제 GPU 위의 텍스처 한 장이니, DOM 오버레이로는 불가능한 효과를 만들어 이 우주에 진짜로 녹아들게 할 수 있지 않을까? 아이디어를 다섯 개 적고 각각 시험판을 만들어 봤습니다. 이 부분은 아직 실험 브랜치에 있고, 배포되지 않았습니다.

페이지 전환 시 글자가 위성으로 빨려 들어감

원래는 글자 카드를 클릭하면 글자가 먼저 사라지고, 그다음 토성이 그 위성으로 접혀 들어갔습니다. 이제는 글자도 함께 빨려 들어가면서 점점 가늘고 길어지고, 마지막 짧은 구간에서만 사라집니다. 반대로 뒤로 가기로 홈페이지에 돌아오면 각 라벨이 자기 위성에서 "뱉어져" 나와 처음엔 가늘고 길다가 원래 모양으로 펼쳐지는데, 위성이 튀어나오는 순서를 따라 하나씩 나옵니다.

0:00 / 0:00
글자 카드를 클릭하면 글자가 토성과 함께 위성으로 빨려 들어가고, 뒤로 가기로 돌아오면 라벨이 각자의 위성에서 다시 뱉어져 나옴(실험 중)
신호 디코딩처럼 나타나기

글자 카드가 나타날 때 무선 신호가 수신되듯 위성 지점에서 바깥으로 퍼져 나가고, 퍼지는 가장자리에 빨강·파랑 색 어긋남과 주사선이 생기며 약 0.5초 만에 디코딩이 끝납니다. 실제로는 아주 빨라서, 아래는 2배 확대하고 5배 느리게 한 버전입니다.

0:00 / 0:00
신호 디코딩처럼 나타나기, 2배 확대, 5배 슬로(실험 중)
발광

영문 라벨 주위에 부드러운 금빛이 한 겹 감도는데, 위성의 광채와 같은 질감입니다. 처음에는 별하늘의 에 바로 연결하려 했지만 전혀 효과가 없었습니다. 광채가 캔버스의 투명한 곳에 떨어지는데 거기는 투명도가 0이라 출력 단계에서 통째로 먹혀 버렸습니다. 결국 글자 레이어에서 직접 가우시안 블러를 하는 방식으로 바꿨습니다.

조명

글자 카드의 광원 쪽은 살짝 밝고 반대쪽은 살짝 어두워서, 토성이 빛을 받는 방향과 일치합니다. 효과가 아주 옅어서 확대해 비교해야 보이지만, 더 사실적으로 보입니다.

사실 맨 처음에는 "블랙홀 중력 렌즈"도 있었습니다. 블랙홀 테마에서 글자가 블랙홀 근처를 지나갈 때 뒤의 별들과 함께 휘어지는 효과입니다. 렌즈 계산은 원래 있었기 때문에 글자를 굴절된 위치에서 샘플링하도록 바꾸기만 하면 되어서, 기술적으로는 가장 재미있었습니다.

하지만 실제로 보니 너무 휘어서 다소 추상적이었고, 글자가 자기 위성에서 꽤 멀어지는 데다 클릭 범위는 휘기 전 위치에 머물렀습니다. 결국 이건 빼고 "뱉어 내는" 버전으로 바꿨습니다. 빨려 들어가는 게 중력 때문이라면, 돌아올 때 거꾸로 뱉어 내는 게 자연스럽게 이어집니다. 홈페이지로 돌아와 토성이 자라날 때, 각 라벨은 자기 위성이 튀어나오는 순간 그 지점에서 처음엔 가늘고 길다가 원래 모양으로 펼쳐지며, 약 0.7초 걸립니다.

첫 버전은 위성이 튀어나오는 진행도를 그대로 썼는데, 그건 0.25초밖에 안 되고 라벨도 투명한 상태에서 페이드 인되어서, 아무것도 안 한 것처럼 보였습니다. 그래서 라벨이 자기 애니메이션을 따로 돌리게 바꿨고, 나올 때부터 불투명한 상태로 모양만 한 점에서 펼쳐지게 했습니다. 돌아올 때는 보통 10px짜리 작은 라벨뿐이라 뱉어 내는 쪽이 빨려 들어가는 쪽보다 절제되어 있고, 확대하고 느리게 해야 잘 보입니다.

0:00 / 0:00
라벨이 위성에서 뱉어져 나옴, 2배 확대, 4배 슬로(실험 중)

이 효과들은 Chrome 157 이상에서만 보이니 어디까지나 덤일 뿐, 기능이 여기에 의존해서는 안 됩니다. 나중에 정식 버전으로 정리할 때 빨려 들어가기, 뱉어 내기, 디코딩은 prefers-reduced-motion일 때 꺼지게 할 예정입니다.

주의할 점#

  • 덤으로만 쓰기. 현재는 Chrome/Edge 157 이상만 지원하니 반드시 DOM 버전으로 돌아가는 경로가 있어야 합니다. 제 경우 감지 실패, WebGL2, 3D 미실행, 또는 URL에 ?hic=0이 붙어 있으면 모두 DOM으로 갑니다.
  • 옛 튜토리얼대로 쓰지 않기. 먼저 정식 버전 이름인지 확인하세요. layoutsubtree, texElementImage2D 계열은 158에서 이미 존재하지 않습니다.
  • three.js의 HTMLTexture는 아직 쓰지 않기. r187 기준으로도 여전히 옛 이름이고, canvas가 worker에 있다면 수정된 뒤에도 쓸 수 없습니다.
  • 스냅샷은 close하기. ElementImage를 다 쓰고 해제하지 않으면 계속 쌓입니다.
  • 못 찍으면 다시 찍기. 간헐적인 "No context found for ElementImage"는 catch하고 requestPaint()로 다음 프레임에 다시 찍으세요.
  • 옮기기 전에 먼저 측정하기. canvas로 옮기기 전에 메인 스레드 시간이 정말 그 요소들에 쓰이고 있는지 확인하세요. 제가 첫 단계에서 작은 라벨만 옮겼을 때 절약한 건 13%뿐이었습니다.

이번에 가장 크게 와닿은 건 사실 메인 스레드를 멈춘 비교 영상이었습니다. 글자를 worker에 맡겨 그리기만 하면 메인 스레드가 아무리 바빠도 끌려가지 않고, "위성으로 빨려 들어가고 뱉어지는" 효과도 worker에서 돌아가니 전혀 영향을 받지 않습니다. 페이지 전환이 끊기는 원인도 똑같습니다. 새 페이지가 마운트될 때 메인 스레드를 점유하는데, 전환 효과의 점들도 메인 스레드에서 그려지기 때문입니다. 그래서 다음으로는 같은 방식으로 전환 효과의 점과 혜성 꼬리도 worker로 옮기려고 하고, 이미 브랜치에서 작업을 시작했습니다. 끝나면 따로 글을 쓰겠습니다.