html-in-canvas로 영화 프로젝트 헤일메리 명장면 구현하기

하늘·2026년 4월 12일
post-thumbnail

최근에 영화 프로젝트 헤일메리를 봤습니다. 평소에 SF물을 좋아해서 그런지 원작 책을 읽지 않았는데도 너무너무.. 너무너무 재미있었습니다. (현재는 책도 읽는 중)

그래서 영화 속 특정 장면을 canvas를 이용해서 인터렉션 웹으로 만들고자 하는 아이디어가 번뜩 떠올랐는데요, 최근 X(구 트위터)에서 핫한 html-in-canvas와 같이 구현하면 재미있을 것 같았습니다.

html-in-canvas란?

canvas의 한계점

canvas에 클릭, 마우스 호버 같은 이벤트 핸들러를 구현하거나 텍스트, 이미지를 넣으려면 어떻게 해야 했을까요? canvas에는 DOM 요소를 자식 요소로 넣을 수 없어서 이벤트를 직접 canvas에 달고 좌표 계산으로 처리해야만 했습니다. 이미지는 new Image로 받은 다음 이미지를 그릴 위치와 크기 계산을 해야 했지요.

  • 이벤트 핸들러를 조작해야 하는 경우
canvas.addEventListener("click", (e) => {
	const rect = canvas.getBoundingClientRect();
  	const x = e.clientX - rect.left; 
  	const y = e.clientY - rect.top; 
  
  	// 이 x, y로 다른 이벤트 처리를 할 수 있습니다
})

getBoundingClientRect()canvas의 뷰포트 위치를 계산해야 내부 좌표를 얻을 수 있었습니다.

  • 이미지를 그리는 경우
const img = new Image();
img.src = "./my-image.jpg";

img.onload = () => {
  ctx.drawImage(img, x, y); // 원본 사이즈
  ctx.drawImage(img, x, y, width, height);  // 리사이즈
};

상당히 번거롭습니다. 이벤트 핸들러가 달린 많은 버튼과 텍스트, 이미지를 렌더링하려면 좌표 계산과 크기를 일일이 지정해 주었어야 했어요.

html-in-canvas의 등장

다시 한 번 더 canvas의 고질적인 문제를 보자면,

  • 텍스트 렌더링 품질 → canvasfillText()는 브라우저 네이티브 텍스트 렌더링보다 품질이 낮습니다. 서브픽셀 안티앨리어싱, 커닝, ligature 처리에서 차이가 납니다.
  • 레이아웃을 직접 계산해야 함 → 줄바꿈, 말줄임, 다단 레이아웃 같은 걸 전부 Javascript에서 구현해야 합니다.
  • 접근성 보장이 어려움 → canvas스크린 리더기가 읽지 못합니다.
  • 인터렉션 구현 비용 → 버튼 click, hover, focus 같은 걸 좌표 기반으로 직접 구현해야 합니다.

위 문제를 근본적으로 해결하기 위해 HTML-in-Canvas가 등장했습니다. Canvas 안에 HTML 요소를 직접 자식으로 넣을 수 있고, 그걸 그대로 Canvas 위에 그릴 수 있게 해 줍니다. 하지만 아직 모든 브라우저에서 지원하는 것이 아닌 제안 단계이므로 아래와 같은 문제가 있습니다.

  • W3C 정식 표준화 전 인큐베이터 단계입니다. 모든 브라우저에서 정식적으로 채택된 단계가 아닙니다.
  • Chrome canary에서만 동작합니다. chrome://flags/#canvas-draw-element 에서 플래그를 켜야 합니다.
  • Firefox, Safari 미지원
  • 제안, 논의 단계라서 API 변경 가능성이 있습니다.

그렇지만 Canvas를 사용하는 프론트엔드에게는 엄청난 희소식이 아닐 수가 없는데요, 특히 차트에서 큰 도움이 될 것 같습니다!

어떻게 사용해요?

1. layoutsubtree attribute

<canvas id="canvas" layoutsubtree>
  <div class="container">
    <button class="btn">클릭</button>
  </div>
</canvas>

Canvaslayoutsubtree 속성을 붙이면, 자식 HTML 요소들이 레이아웃에 참여합니다. 브라우저가 자식 요소의 크기, 위치를 계산하고 hit testing(클릭 감지)과 접근성 트리에도 포함시켜요. 즉 Canvas 안에 HTML을 넣으면 브라우저가 진짜 HTML로 취급하는 것입니다.

하지만 이 시점에는 아직 화면에는 보이지 않아요. 레이아웃도 잡혀 있고, 클릭도 되는데 CSS visiblity: hidden처럼 화면에만 보이지 않는 상태인 겁니다. 다음 단계인 drawElementImage()로 그려 줘야 비로소 Canvas 위에 나타납니다.

2. drawElementImage()

Canvas 위에 Canvas 자식 요소(HTML)를 그리는 메서드입니다. CSS 스타일이 그대로 반영돼서, border-radius, color, box-shadow 같은 것들이 Canvas 위에서도 동작합니다.

const container = document.querySelector(".container");
const ctx = canvas.getContext("2d");

canvas.onpaint = () => {
  ctx.reset();
  
  const transform = ctx.drawElementImage(container, 270, 270);
  
  container.style.transform = transform;
}
  1. 브라우저가 container DOM을 렌더링합니다.
    layoutsubtree 덕분에 브라우저는 이미 container가 canvas 안에 있고, 어떻게 생겼는지 알고 있습니다. CSS 스타일이 다 적용된 상태로요!

  2. drawElementImage(container, 270, 270)
    그 렌더링 결과(container 렌더링 결과)를 스냅샷 찍듯이 Canvas의 x로 270, y로 270 위치에 그려요.

  3. transform 반환
    "나 270, 270에 그렸어" 라는 container의 위치 정보를 transform으로 돌려 줍니다.

  4. container.style.transform = transform
    DOM 위치를 실제 그려진 위치에 맞춰요. 이 단계를 거쳐야 클릭 같은 이벤트 핸들러가 정확하게 동작합니다.

🤔 굳이 transform을 써야 하나요? absolute로는 안 되나요?

대답은, 네. DOM 요소에 CSS position: absolute를 주면, 위치 렌더링은 비슷하게 보일지라도 캔버스가 변형되었을 때 DOM 요소는 가만히 있어요.

// canvas 전체를 회전시키면
ctx.rotate(50deg);
ctx.drawImage(...);
              
// 버튼은 그래도 absolute, top, left에 고정
// canvas 콘텐츠 위치랑 다르게 놀아요

캔버스가 변형될 때마다 위치를 재계산해 줘야 하고, WebGL 텍스처나 3D scene에 붙이기 불가능합니다. 하지만 html-in-canvas 방식은 캔버스가 변형될 때마다 자동으로 재계산을 하고, 동기화를 해 주고, WebGL 텍스처, 3D scene에 붙이는 것도 가능합니다.

position: absolute는 2D 평면에서 canvas 위에 UI를 얹는 것이고, html-in-canvas는 canvas의 렌더링 파이프라인 안에 HTML이 들어갑니다.

실제로 사용해 봤어요

html-in-canvas를 이용해서 영화 <프로젝트 헤일메리>의 장면을 재현해 봤습니다.

🚨 주의! 스포일러가 있습니다!

디자인부터 난관이다

일단 저는 미적 감각이 없습니다. 나름 C4D로 모델링 하는 법을 배운 사람이지만 그림도 못 그리고, 모델링도 못하고, 아무튼.. 머릿속에는 이것저것 만들어 보고 싶은 생각이 많았지만 그걸 디자인으로 만드는 게 어려웠습니다. 처음에는 Claude에게 프롬프트 작성을 시키고, 이미지는 Gemini에게 시켰습니다.

그리고 처음에는 html-in-canvas로 만들 생각도 없었습니다. 그냥 프로젝트 헤일메리 장면을 웹 게임으로 만들고 싶었어요.

로키가 헤일메리호를 향해 Blip-B를 던질 때, 사용자의 키보드 이벤트로 그레이스를 조종해서 Blip-B를 받는 게임(?)을 생각하고, 아래와 같은 이미지를 생성했습니다.

오.. 괜찮은데?

그렇지만 문제는 로키였습니다. 아무리 레퍼런스를 주고, 프롬프트를 고치고, 혼내도 봤는데 이게 최선이었어요.

내가 원한 건 이런 그림이 아니었는데.. 그러던 중 X에서 SpriteCook라는 도구를 발견했습니다.

(출처 링크: https://x.com/yasinaktimur/status/2042333277814509652?s=20)

SpriteCook의 MCP를 Claud Code와 연결해서, 프롬프트 입력만으로 도트 이미지를 쉽게 만들 수 있었습니다. 테크놀로지아~ 이거다. 바로 실행에 옮겼습니다.

퀄리티가 꽤 괜찮았습니다(!). 이제 보니 이것도 나노바나나로 만드는 거였더라고요. 그리고 무료 토큰을 주긴 하지만 토큰을 엄청 많이 먹어서, 1만 원 주고 토큰도 구매했습니다.

이제 로키를 만들 차례입니다.

ㅋㅋ

로키는 다리가 다섯 개고, 거미 모양이며, 다리는 자유롭게 쓸 수 있고, 색깔은 이거고... 여러 차례 혼내 보기도 하고 달래기도 했지만 갈수록 이상해지는 로키 이미지에 그냥 로키는 포기하기로 했습니다.

Blip-B를 던지는 로키, 그걸 받는 그레이스라는 아이디어도 그냥 삭제했습니다. 머릿속에서요.

그러다가 문득 그러면 그레이스가 타우세티 행성 가까이에서 아스트로파지를 보는 장면은 어떨까? 생각했습니다.

  • 우주복을 입은 그레이스의 뒷모습과 헤일메리호, 그리고 타우세티는 SpriteCook을 이용해서 만들면 됨
  • 그리고 아스트로파지는 canvas로 만들면 될 것 같음

당장 진행시켜.

html-in-canvas로 만들기 🛠️

canvas 드로잉은 그리는 순서가 곧 z-index입니다.

1. 배경 이미지 3장 (크로스페이드 루프)
2. 딤 오버레이 (반투명 검정)
3. blob 파티클
4. 우주선 / 캐릭터
5. HTML UI (버튼, 토스트) ← html-in-canvas

배경 cross-fade는 phase 상태로 관리했고, 캐릭터 유영은 Math.sin()으로 y축으로 두둥실 떠나니게 했습니다.

// background (타우세티) cross-fade
ctx.globalAlpha = opacity * alpha; // 현재 이미지 -> fade out 되는 중..
ctx.globalAlpha = (1 - opacity) * alpha; // 다음 이미지 -> fade in 되는 중..

// 유영하는 그레이스
const floatY = Math.sin(time * 0.007) * 8;
ctx.drawImage(imgs.grace, x, y + floatY, width, height);

사실 이게 중요한 게 아니라서 간략하게 정리했습니다.

왜 canvas 밖에 두지 않았는가?

<canvas layoutsubtree>
  <button id="btn">Start IR Scan</button>
  <div class="toast" id="toast-1">
    <img src="./assets/rocky.jpg" alt="Rocky" />
    <p>Grace, you see Astrophage, question?</p>
    <button>Yes</button>
  </div>
  <div class="toast" id="toast-2">
    <img src="./assets/rocky.jpg" alt="Rocky" />
    <p>Amaze! Amaze! Amaze! \🪨/</p>
  </div>
</canvas>

<button id="btn">Start IR Scan</button> 버튼을 누르면 아스트로파지가 보이는 애니메이션, <button>Yes</button> 버튼을 누르면 id="toast-2" 토스트가 보이는 애니메이션을 만들고 싶었습니다.

앞서 설명에서도 언급한 것처럼, position: absolute 방식은 canvas 드로잉과 HTML UI가 서로 다른 렌더링 레이어에 존재해서, canvas가 변형되면 같이 변형되어야 할 UI들이 변형되지 않습니다.

이번 프로젝트에서 결정적이었던 건 fade 타이밍이었는데, toast UI가 아스트로파지(blob) 위에서 fade 애니메이션이 실행되어야 했습니다. canvas 드로잉 순서 안에서 globalAlpha로 통합 제어 하지 않으면 CSS 타이밍이랑 canvas 타이밍이 엇갈립니다.

즉, 같은 requestAnimationFrame 루프 안에서 canvas 드로잉과 HTML 요소의 opacity가 함께 제어되어야 했어요.

canvas.onpaint = () => {
  ctx.clearRect(0, 0, W, H); // 초기화

  // 1. canvas 드로잉 (배경, blob, 캐릭터)
  drawBackground();
  drawBlobs();
  drawCharacter();

  // 2. HTML 요소를 canvas 위에 합성
  const t = ctx.drawElementImage(btn, x, y);
  btn.style.transform = t; // DOM 위치 동기화 → 클릭 이벤트가 정확하게 동작
};

function loop() {
  time++; // 그레이스가 유영하기 위해 (Math.sin()) time 조절
  canvas.requestPaint(); // onpaint 트리거
  requestAnimationFrame(loop);
}

requestPaint()onpaint를 트리거하고, onpaint 안에서 canvas 드로잉과 HTML 합성을 한 번에 처리하는 게 핵심입니다.

style.opacity가 안 먹혀요

Toast UI의 fade를 처음에 style.opacity로 하면 더 간단하지 않을까? 싶어서 fade in / fade out 애니메이션은 CSS opacity로 제어하고 싶었습니다. 그런데 동작하지 않았습니다.

toast.style.opacity = 0.5;
ctx.drawElementImage(toast, x, y);

drawElementImage는 호출되는 순간 요소의 스냅샷을 찍습니다. 그런데 style.opacity는 브라우저의 스타일 계산 사이클에서 반영되는데, 이 타이밍이 drawElementImage가 스냅샷을 찍는 타이밍과 다릅니다. 즉, style.opacity = 0.5를 바로 위에서 썼어도, 스냅샷에는 아직 반영이 되지 않은 상태인 것입니다.

// globalAlpha로 canvas 드로잉과 같은 방식으로 처리
ctx.save();
ctx.globalAlpha = 0.5;
ctx.drawElementImage(toast, x, y);
ctx.restore();

반면 globalAlpha는 캔버스 드로잉 컨텍스트에 직접 작용합니다. drawElementImage도 결국 캔버스에 픽셀을 그리는 행위라서, 스냅샷을 캔버스 위에 올릴 때 globalAlpha가 그대로 적용됩니다. 스타일 계산 사이클을 거치지 않고 캔버스 렌더링 타이밍 안에서 바로 처리되는 것입니다.

즉, style.opacity는 DOM 렌더링 타이밍(JavaScript → Style → Layout → Paint → Composite), globalAlpha는 캔버스 렌더링 타이밍이라서, opacity를 조절할 때, drawElementImage와 함께 쓰려면 같은 타이밍인 globalAlpha를 사용해야 합니다.

CSS transition도 안 먹혀요

위와 같은 이유입니다. CSS transition의 중간 프레임이 스냅샷에 반영되지 않아서 fade, 슬라이드 같은 애니메이션은 전부 Javascript에서 매 프레임 직접 계산해야 합니다.

// CSS transition 안 먹힘
toast.style.transition = "opacity 0.3s";
toast.style.opacity = 0;

// 매 프레임 globalAlpha 직접 조정
let toastOpacity = 1;

canvas.onpaint = () => {
  toastOpacity -= 0.05;
  ctx.save();
  ctx.globalAlpha = Math.max(0, toastOpacity);
  ctx.drawElementImage(toast, x, y);
  ctx.restore();
};

ctx.reset() 쓰면 배경이 날아가요

공식 예제에서는 onpaint 안에서 ctx.reset()을 쓰는데, canvas 드로잉이랑 같이 쓰면 배경이 초기화돼서 clearRect로 픽셀만 지우는 방식으로 전환했습니다.

// canvas 드로잉과 같이 쓰면 안 됨
canvas.onpaint = () => {
  ctx.reset(); // transform, globalAlpha 등 canvas 상태 전체 초기화

// 픽셀만 지우기
canvas.onpaint = () => {
  ctx.clearRect(0, 0, W, H);

그래서 결과는

완성했습니다.

좋음! 좋음! 좋음! \🪨/

Start IR Scan 버튼을 누르면, 적외선을 쏘는 것처럼 핑크색의 유영하는 아스트로파지가 보입니다. 또, 헤일메리호에 있는 로키가 그레이스에게 아스트로파지가 잘 보이냐는 메시지도 남깁니다. Yes 버튼을 누르면 로키가 기뻐하고요.

개인이 크롬 설정에서 html-in-canvas 기능을 활성화해야 오류 없이 잘 보이므로 배포 링크 대신 움짤로 대체했습니다. 용량 때문에 화질구지로 올림 ㅠㅠ

마무리

html-in-canvas는 아직 Chrome Canary 전용 실험적 API입니다.

그렇지만 canvas 작업에서 항상 불편했던 이벤트 처리(클릭 좌표 계산), 텍스트 렌더링, 접근성 문제를 근본적으로 해결해 줄 가능성이 있습니다. 특히 차트 라이브러리, 인터렉티브 canvas 기반 에디터처럼 UI가 캔버스와 강하게 결합되는 경우에 엄청난 도움이 될 것 같아요. 차트로 고생했던 전 회사 직원이 생각나는 만큼 얼른 표준이 되었으면 좋겠습니다. 🥹

참고문서

https://github.com/WICG/html-in-canvas

profile
아무튼 어찌저찌 하고 있습니다.... 🫠

1개의 댓글

comment-user-thumbnail
2026년 4월 13일

너무재밌겠따~~ 저도 캔버스 하고싶어요

답글 달기