designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포. 스킬 (packages/skill) - SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로 본문은 절차와 인덱스만, 지식은 references/ 로 분리 - 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기 - 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외 - 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙, SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침 설치 CLI (packages/cli, packages/core) - npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md - 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다 - 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성 - AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입 - Windsurf 는 12,000자 상한 초과 시 설치를 차단 배포 (build/ci, .forgejo/workflows) - 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러 → draft 릴리스 → Cloudflare Pages. 재실행 멱등 근거 (research/) - 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다 테스트 22개 통과 (core 16 · cli 6)
This commit is contained in:
commit
8808c672dc
135 changed files with 38838 additions and 0 deletions
397
research/canvas/01-api-spec.md
Normal file
397
research/canvas/01-api-spec.md
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# 01. HTML-in-Canvas API 정확한 스펙
|
||||
|
||||
> 조사 기준일: 2026-08-20
|
||||
> 1차 출처: WICG 공식 explainer(living document), WHATWG HTML PR, Chrome Platform Status, Chrome for Developers 블로그, blink-dev Intent 스레드
|
||||
> **이 문서의 모든 시그니처는 WICG explainer의 IDL 블록 원문에서 그대로 옮긴 것이다. 추측한 부분은 명시적으로 "미확인"으로 표시했다.**
|
||||
|
||||
---
|
||||
|
||||
## 0. 한 줄 요약
|
||||
|
||||
`<canvas layoutsubtree>` 안에 실제 HTML을 넣고, `paint` 이벤트 안에서 `ctx.drawElementImage(el, x, y)`(2D) / `gl.texElementImage2D(...)`(WebGL) / `device.queue.copyElementImageToTexture(...)`(WebGPU)를 호출하면, 그 HTML의 **살아있는 렌더링 결과**가 캔버스 픽셀(또는 GPU 텍스처)로 들어온다. 원본 요소는 DOM에 그대로 남아 클릭·포커스·접근성·find-in-page가 계속 동작한다.
|
||||
|
||||
## 1. 공식 명칭과 저장소
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 기능 이름 | **HTML-in-canvas** (Chrome Platform Status 등록명) |
|
||||
| 인큐베이션 | W3C WICG |
|
||||
| Explainer 저장소 | `https://github.com/WICG/html-in-canvas` |
|
||||
| Explainer 렌더링 | `https://wicg.github.io/html-in-canvas/` (형식 스펙이 아니라 explainer 그 자체) |
|
||||
| 스펙 PR | `https://github.com/whatwg/html/pull/11588` — "Add HTML-in-Canvas APIs", 2025-08-21 개설, **2026-08 현재 open / 미머지** |
|
||||
| chromestatus | `https://chromestatus.com/feature/5172548013916160` |
|
||||
| Chromium 버그 | `https://crbug.com/500967896` (Blink 컴포넌트: `Blink>Canvas`) |
|
||||
| 저자 | Philip Rogers, Stephen Chenney(Igalia), Chris Harrelson, Philip Jägenstedt, Khushal Sagar, Vladimir Levin, Fernando Serboncini |
|
||||
|
||||
### 1.1 이름 변천사 — 영상/구 자료의 이름이 지금과 다른 이유
|
||||
|
||||
노마드코더 영상 및 상당수의 블로그가 쓰는 `canvas place element` / `drawElement` / `setHitTestRegions()`는 **모두 옛 이름**이다. WICG 저장소 커밋 로그로 확인한 실제 변천:
|
||||
|
||||
| 날짜 | 변경 |
|
||||
|---|---|
|
||||
| ~2025-08 이전 | 제안 이름 `canvas place element`, 메서드 `drawElement()`, 히트테스트는 `setHitTestRegions()` |
|
||||
| 2025-08-22 | 메서드 rename (`drawElement` → `drawHTMLElement`) |
|
||||
| 2025-09-05 | `drawHTMLElement` → **`drawHTML`** |
|
||||
| 2025-09-11 | `drawHTML` → **`drawElementImage`** ← **현재 이름** |
|
||||
| 2025-10-08 | explainer에서 "place element" 표현 전부 제거 (저장소도 `WICG/canvas-place-element` → `WICG/html-in-canvas`. 옛 저장소는 현재 404) |
|
||||
| 2025-11-08 | **`setHitTestRegions()` 폐기** → "Switch to CSS transforms for hit testing" (반환된 `DOMMatrix`를 `element.style.transform`에 넣는 방식으로 대체) |
|
||||
| 2026-02-10~25 | `paint` 이벤트 / `onpaint` 설계 확정, 이벤트에서 `time` 인자 제거 |
|
||||
| 2026-03-17~31 | `captureElementImage()` + `ElementImage` (OffscreenCanvas 지원) 추가 |
|
||||
| 2026-03-20 | `drawElementImage()` 소스 사각형(sx/sy/swidth/sheight) 오버로드 추가 |
|
||||
| 2026-04-16 / 06-01 | WebGL/WebGPU IDL 대폭 변경 (**아래 4·5절의 "구 시그니처" 주의사항 참조**) |
|
||||
| 2026-06-16 | "privacy-preserving painting" → **"read-back-allowed rendering"** 으로 개념 이름 변경 |
|
||||
| 2026-07-13/14 | 중첩 canvas 허용, `paint` 이벤트가 **역트리 순서**로 발화함을 명시 |
|
||||
|
||||
> ⚠️ WHATWG 스펙 PR #11588 본문에는 아직 `setHitTestRegions()`와 `drawable` 속성이 남아 있다. **explainer(=구현 기준)와 스펙 PR이 아직 동기화되지 않은 상태**이므로, 구현 기준은 explainer를 따라야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. `layoutsubtree` 속성
|
||||
|
||||
**철자: 전부 소문자 `layoutsubtree`** (HTML 속성). IDL 반사 프로퍼티는 camelCase `layoutSubtree`.
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width:400px; height:200px" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
```
|
||||
|
||||
- `boolean` 타입 → **불리언 속성**. 존재하기만 하면 true다. `layoutsubtree="true"`도 되고(WICG 예제가 이 형태를 쓴다) `layoutsubtree=""`도 된다. `layoutsubtree="false"`라고 써도 **true로 취급**되니 끄려면 속성 자체를 제거해야 한다.
|
||||
- 효과 (explainer 원문 기준):
|
||||
- 캔버스 자손이 **레이아웃에 참여**하고 **히트테스트에 참여**한다.
|
||||
- `<canvas>`의 **직계 자식**은 stacking context를 생성하고, 모든 자손의 containing block이 되며, **paint containment**를 갖는다.
|
||||
- 자식은 "보이는 것처럼" 동작하지만, `drawElementImage()`로 명시적으로 그려지기 전까지 **사용자에게는 보이지 않는다**.
|
||||
- 이 속성이 없으면 캔버스 자식은 종전대로 fallback 콘텐츠일 뿐이고, `drawElementImage()`는 예외를 던진다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 2D 컨텍스트 — `drawElementImage()`
|
||||
|
||||
### 3.1 IDL (explainer 원문 그대로)
|
||||
|
||||
```webidl
|
||||
interface mixin CanvasDrawElementImage {
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
};
|
||||
|
||||
CanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
OffscreenCanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
```
|
||||
|
||||
오버로드는 인자 개수 **3 / 5 / 7 / 9개** 네 가지다 — `(el, dx, dy)`, `(el, dx, dy, dw, dh)`, `(el, sx, sy, sw, sh, dx, dy)`, `(el, sx, sy, sw, sh, dx, dy, dw, dh)`. 기존 `CanvasRenderingContext2D.drawImage()`와 정확히 같은 모양이고, 첫 인자만 이미지 소스 대신 `Element`(또는 `ElementImage`)로 바뀐 것이다.
|
||||
|
||||
### 3.2 반환값
|
||||
|
||||
**`DOMMatrix`** — 이 행렬을 `element.style.transform = returned.toString()` 으로 적용하면, DOM 상의 요소 위치가 캔버스에 그려진 위치와 일치한다. 히트테스트·포커스 링·IntersectionObserver·접근성 좌표가 이 DOM 위치를 쓰기 때문에 **이 동기화를 하지 않으면 클릭이 엉뚱한 곳에 떨어진다.**
|
||||
|
||||
### 3.3 요구사항·제약 (explainer 원문 항목)
|
||||
|
||||
- 최근 렌더링 업데이트 시점에 `<canvas>`에 `layoutsubtree`가 지정되어 있어야 한다.
|
||||
- `element`는 최근 렌더링 업데이트 시점에 `<canvas>`의 **직계 자식(direct child)** 이어야 한다.
|
||||
- `element`가 **박스를 생성**해야 한다 (즉 `display:none` 이면 안 된다).
|
||||
- **변환(Transforms)**: 캔버스의 현재 변환 행렬(CTM)은 그리기에 적용된다. 반면 **소스 `element`에 걸린 CSS transform은 그리기에서 무시된다.** (다만 히트테스트/접근성에는 계속 영향을 준다 — 그래서 3.2의 동기화가 성립한다.)
|
||||
- **클리핑**: 넘치는 콘텐츠(layout overflow, ink overflow 모두)는 요소의 **border box로 클리핑**된다.
|
||||
- **크기**: `width`/`height` 인자는 캔버스 좌표계의 목적지 사각형이다. 생략하면 **캔버스 밖에 있을 때와 같은 화면상 크기·비율**이 되도록 자동 사이징된다.
|
||||
→ 실전 의미: `canvas.width`를 device pixel로 잡으면 x/y 좌표도 device pixel 단위여야 한다 (`x * devicePixelRatio`).
|
||||
|
||||
### 3.4 스냅샷 타이밍 (중요)
|
||||
|
||||
- 캔버스 모든 자식의 렌더링 스냅샷은 **`paint` 이벤트 직전**에 기록된다.
|
||||
- `paint` 이벤트 **안에서** 호출하면 → **현재 프레임**의 모습으로 그려진다.
|
||||
- `paint` 이벤트 **밖에서** 호출하면 → **직전 프레임**의 스냅샷이 쓰인다.
|
||||
- 최초 스냅샷이 기록되기 전에 호출하면 **예외**가 발생한다 (Chromium 구현에서는 `InvalidStateError`).
|
||||
|
||||
---
|
||||
|
||||
## 4. WebGL — `texElementImage2D()`
|
||||
|
||||
### 4.1 현재 IDL (explainer 원문)
|
||||
|
||||
```webidl
|
||||
dictionary WebGLCopyElementImageConfig {
|
||||
GLfloat sx;
|
||||
GLfloat sy;
|
||||
GLfloat swidth;
|
||||
GLfloat sheight;
|
||||
GLsizei width;
|
||||
GLsizei height;
|
||||
};
|
||||
|
||||
partial interface WebGLRenderingContext {
|
||||
void texElementImage2D(GLenum target, GLenum internalformat,
|
||||
(Element or ElementImage) element,
|
||||
optional WebGLCopyElementImageConfig config = {});
|
||||
};
|
||||
```
|
||||
|
||||
호출:
|
||||
```js
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, element);
|
||||
```
|
||||
|
||||
### 4.2 ⚠️ 시그니처가 2026-04~06에 바뀌었다
|
||||
|
||||
- **구 시그니처**: `texElementImage2D(target, level, internalformat, format, type, element)` — `texImage2D`와 똑같은 6인자.
|
||||
- **신 시그니처**: `texElementImage2D(target, internalformat, element, config?)` — `level`, `format`, `type`이 사라졌다.
|
||||
- Chrome for Developers 블로그(2026-05-19 최종 수정)의 코드 예제는 **아직 구 시그니처**(`gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, form_element)`)를 보여준다. 반면 WICG 공식 예제 `Examples/webGL.html`은 try/catch로 신·구 양쪽을 지원한다:
|
||||
|
||||
```js
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, draw_element); // 신
|
||||
} catch (e) {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, // 구
|
||||
gl.UNSIGNED_BYTE, draw_element);
|
||||
}
|
||||
```
|
||||
→ **실전 코드에서는 이 try/catch 패턴을 그대로 쓰는 게 안전하다.**
|
||||
|
||||
### 4.3 알려진 미해결 이슈
|
||||
|
||||
- Jake Archibald가 blink-dev Intent 스레드에서 지적: **WebGL 경로에서 텍스처 크기를 지정할 방법이 없다.** `config`의 `width`/`height`가 추가된 배경이지만 논의는 진행 중.
|
||||
|
||||
---
|
||||
|
||||
## 5. WebGPU — `copyElementImageToTexture()`
|
||||
|
||||
### 5.1 현재 IDL (explainer 원문)
|
||||
|
||||
```webidl
|
||||
dictionary GPUCopyElementImageDestination {
|
||||
required GPUImageCopyTextureTagged destination;
|
||||
GPUIntegerCoordinate width;
|
||||
GPUIntegerCoordinate height;
|
||||
};
|
||||
|
||||
dictionary GPUCopyElementImageSource {
|
||||
required (Element or ElementImage) source;
|
||||
float sx;
|
||||
float sy;
|
||||
float swidth;
|
||||
float sheight;
|
||||
};
|
||||
|
||||
partial interface GPUQueue {
|
||||
void copyElementImageToTexture(GPUCopyElementImageSource source,
|
||||
GPUCopyElementImageDestination destination);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 실제 호출 형태 (WICG 젤리 슬라이더 데모 `Examples/webgpu-jelly-slider/src/index.ts` 원문)
|
||||
|
||||
```js
|
||||
canvas.onpaint = () => {
|
||||
const sourceDict = { source: valueElement };
|
||||
const destDict = {
|
||||
destination: { texture: valueRawTexture },
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
try {
|
||||
device.queue.copyElementImageToTexture(sourceDict, destDict); // 신
|
||||
} catch (e) {
|
||||
device.queue.copyElementImageToTexture(valueElement, width, height, // 구
|
||||
{ texture: valueRawTexture });
|
||||
console.log('Note: using old copyElementImageToTexture API');
|
||||
}
|
||||
// ... transform 동기화
|
||||
};
|
||||
```
|
||||
|
||||
> ⚠️ Chrome 블로그는 `device.queue.copyElementImageToTexture(valueElement, { texture: targetTexture })` 라는 **또 다른(간략화된/구) 형태**를 보여준다. IDL과 WICG 데모 소스가 서로 일치하므로 **위 dictionary 2개 형태가 현재 기준**이다.
|
||||
|
||||
`copyExternalImageToTexture()`의 DOM 요소 버전이라고 생각하면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 6. `paint` 이벤트
|
||||
|
||||
### 6.1 IDL
|
||||
|
||||
```webidl
|
||||
[Exposed=Window]
|
||||
interface PaintEvent : Event {
|
||||
constructor(DOMString type, optional PaintEventInit eventInitDict);
|
||||
readonly attribute FrozenArray<Element> changedElements;
|
||||
};
|
||||
|
||||
dictionary PaintEventInit : EventInit {
|
||||
sequence<Element> changedElements = [];
|
||||
};
|
||||
```
|
||||
|
||||
`canvas.onpaint = fn` 또는 `canvas.addEventListener('paint', fn)` 둘 다 가능.
|
||||
|
||||
### 6.2 동작 규칙 (explainer 원문 기준)
|
||||
|
||||
- 캔버스 자식들의 **렌더링이 변했을 때** 발화한다 (포커스, 호버, 입력, CSS 애니메이션 등).
|
||||
- 발화 시점: [update-the-rendering](https://html.spec.whatwg.org/#update-the-rendering) 중 **IntersectionObserver 단계가 실행된 직후**. 설계 문서상으로는 "Paint 단계 직후, 루프 없이 프레임당 1회"(explainer의 Option C).
|
||||
- 이벤트는 **바뀐 자식들의 목록**(`changedElements`)을 담는다.
|
||||
- 캔버스 자식의 **CSS transform 변경은 렌더링에서 무시**되므로, transform만 바꿔서는 다음 프레임에 `paint`가 발화하지 않는다. (→ 3.2의 동기화 코드가 무한 루프를 만들지 않는 이유)
|
||||
- `paint` 안에서 한 **캔버스 드로잉 명령은 현재 프레임에 반영**되지만, `paint` 안에서 한 **DOM 변경은 다음 프레임부터** 반영된다.
|
||||
- `<canvas>`가 여러 개면 `paint`는 **역트리 순서(reverse tree order)** 로 발화한다 → 자손이 조상보다 먼저 발화한다 (중첩 canvas 지원, 2026-07 추가).
|
||||
|
||||
### 6.3 `requestPaint()`
|
||||
|
||||
```webidl
|
||||
void requestPaint();
|
||||
```
|
||||
|
||||
자식이 하나도 안 바뀌어도 `paint`를 **한 번** 강제로 발화시킨다. explainer 표현으로 "`requestAnimationFrame()`과 유사". 매 프레임 갱신이 필요한 앱은 `onpaint` 핸들러 끝에서 `requestPaint()`를 다시 호출해 루프를 만든다. 또한 **최초 1회 호출해서 렌더 파이프라인을 킥스타트**해야 한다(첫 스냅샷 확보).
|
||||
|
||||
---
|
||||
|
||||
## 7. OffscreenCanvas / Worker — `captureElementImage()` & `ElementImage`
|
||||
|
||||
```webidl
|
||||
partial interface HTMLCanvasElement {
|
||||
[CEReactions, Reflect] attribute boolean layoutSubtree;
|
||||
attribute EventHandler onpaint;
|
||||
void requestPaint();
|
||||
ElementImage captureElementImage(Element element);
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
partial interface OffscreenCanvas {
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
[Exposed=(Window,Worker), Transferable]
|
||||
interface ElementImage {
|
||||
readonly attribute double width;
|
||||
readonly attribute double height;
|
||||
undefined close();
|
||||
};
|
||||
```
|
||||
|
||||
- `canvas.captureElementImage(element)` → 요소 렌더링의 **전송 가능한(Transferable) 스냅샷**.
|
||||
- `postMessage(msg, [elementImage])`로 워커에 넘기고, 워커의 `OffscreenCanvasRenderingContext2D.drawElementImage(elementImage, x, y)`로 그린다.
|
||||
- 워커에서 계산된 transform은 `postMessage`로 메인 스레드에 돌려보내 `element.style.transform`에 적용해야 한다. 위치가 동적이면 메인 스레드에서 미리 계산해 `ElementImage` 전송과 동시에 적용하는 편이 낫다.
|
||||
|
||||
---
|
||||
|
||||
## 8. `getElementTransform()` — 3D 컨텍스트용 동기화 헬퍼
|
||||
|
||||
```webidl
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
```
|
||||
|
||||
- **`HTMLCanvasElement`와 `OffscreenCanvas`에 있다.** (Chrome 블로그 본문 산문이 `element.getElementTransform()`이라고 쓴 곳이 있는데, **블로그 자체의 코드 예제와 IDL 모두 `canvas.getElementTransform(el, matrix)`** 이므로 산문 쪽이 오기다.)
|
||||
- WebGL/WebGPU에서는 요소의 최종 화면 위치를 셰이더가 결정하므로 `drawElementImage()`처럼 자동으로 알 수 없다. 그래서 개발자가 MVP 행렬을 스크린 스페이스 행렬로 변환해 넘겨주면, 이 메서드가 `style.transform`에 넣을 `DOMMatrix`를 돌려준다.
|
||||
|
||||
### 8.1 변환 공식 (explainer 원문)
|
||||
|
||||
```
|
||||
T_origin⁻¹ · S_css→grid⁻¹ · T_draw · S_css→grid · T_origin
|
||||
```
|
||||
- `T_draw`: 캔버스 그리드 좌표계에서 요소를 그린 변환. `drawElementImage`의 경우 `CTM · T_(x,y) · S_(destScale)`.
|
||||
- `T_origin`: 요소의 계산된 `transform-origin` 평행이동 행렬.
|
||||
- `S_css→grid`: CSS 픽셀 → 캔버스 그리드 픽셀 스케일 행렬.
|
||||
|
||||
### 8.2 WebGL에서 screenSpaceTransform 만드는 절차 (Chrome 블로그 원문 코드)
|
||||
|
||||
```js
|
||||
// 1. WebGL MVP → DOMMatrix
|
||||
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
|
||||
|
||||
// 2. HTML 요소 정규화 (px → 1x1 단위 사각형, Y축 뒤집기)
|
||||
const width = targetHTMLElement.offsetWidth;
|
||||
const height = targetHTMLElement.offsetHeight;
|
||||
const cssToUnitSpace = new DOMMatrix()
|
||||
.scale(1 / width, -1 / height, 1)
|
||||
.translate(-width / 2, -height / 2);
|
||||
|
||||
// 3. 클립 공간 → 캔버스 뷰포트
|
||||
const clipToCanvasViewport = new DOMMatrix()
|
||||
.translate(canvas.width / 2, canvas.height / 2)
|
||||
.scale(canvas.width / 2, -canvas.height / 2, 1);
|
||||
|
||||
// 4. 합성: (Clip→Pixels) * MVP * (px→unit)
|
||||
const screenSpaceTransform = clipToCanvasViewport.multiply(mvpDOM).multiply(cssToUnitSpace);
|
||||
|
||||
// 5. 적용
|
||||
const computedTransform = canvas.getElementTransform(targetHTMLElement, screenSpaceTransform);
|
||||
if (computedTransform) targetHTMLElement.style.transform = computedTransform.toString();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 접근성·히트테스트가 유지되는 원리
|
||||
|
||||
핵심은 **"같은 요소가 두 곳에 존재"가 아니라 "요소는 DOM에 한 번만 존재하고, 캔버스에는 그 픽셀 복사본이 있다"** 는 것이다.
|
||||
|
||||
- 히트테스트, 포커스, 키보드 탭 순서, 텍스트 선택, 복사/붙여넣기, 우클릭 컨텍스트 메뉴, find-in-page, 번역, 리더 모드, 확장 프로그램, 브라우저 줌, 자동완성 — **전부 DOM 쪽이 처리한다.** 캔버스는 픽셀만 담당한다.
|
||||
- `layoutsubtree`가 자식들을 **접근성 트리에 노출**시킨다. 기존 canvas fallback 콘텐츠와 달리, 그려진 내용과 접근성 트리가 **구조적으로 일치함이 보장**된다 (explainer가 명시한 주요 동기 중 하나).
|
||||
- 단 **DOM 위치와 그려진 위치가 어긋나면 전부 어긋난다.** → `drawElementImage()`의 반환 `DOMMatrix`(또는 `getElementTransform()`)를 매 프레임 `style.transform`에 반영하는 것이 이 API의 필수 계약이다.
|
||||
- DevTools에서 캔버스 안 HTML을 그대로 인스펙트/스타일 수정할 수 있고, 수정 즉시 텍스처에 반영된다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안·프라이버시: "Read-back-allowed rendering"
|
||||
|
||||
(구 명칭 "privacy-preserving painting", 2026-06-16 개칭)
|
||||
|
||||
캔버스 픽셀은 `getImageData()`로 읽을 수 있고, WebGL/WebGPU에서는 항상 읽을 수 있다. 따라서 **저자 코드가 원래 볼 수 없던 정보는 애초에 그려지지 않는다.** 페인팅(픽셀 읽기·타이밍 공격)과 무효화(`onpaint` 발화 여부) **양쪽 모두**에서 민감 정보를 배제한다.
|
||||
|
||||
### 그려지지 않는(=민감) 정보
|
||||
|
||||
- **cross-origin 데이터**: `<iframe>`·`<img>` 등 embedded content의 교차 출처 콘텐츠, `url()` 참조(`background-image`, `clip-path`), 교차 출처로 오염(tainted)된 `<canvas>`, SVG의 `<use>`/`<pattern>`/`<feImage>`.
|
||||
→ **same-origin iframe은 그려진다.** 그 안의 cross-origin 콘텐츠만 안 그려진다.
|
||||
- 시스템 색상 / 테마 / 사용자 환경설정
|
||||
- 맞춤법·문법 검사 밑줄 마커
|
||||
- **방문한 링크 정보(`:visited`)** — 히스토리 스니핑 방지
|
||||
- JS로 접근 불가한 대기 중 폼 자동완성 정보
|
||||
- 서브픽셀 텍스트 안티에일리어싱
|
||||
- 캡션/자막 선택 및 외형에 대한 사용자 설정
|
||||
- IME 팝업 및 IME 고유 텍스트 서식
|
||||
|
||||
### 민감하지 않다고 판정된(=그려지는) 새 정보
|
||||
|
||||
- find-in-page 검색어 하이라이트, text-fragment(URL 프래그먼트) 마커
|
||||
- 스크롤바 및 폼 컨트롤 외형 (Blink/WebKit에서 이미 `foreignObject`로 탐지 가능)
|
||||
- 캐럿 깜빡임 속도
|
||||
- `forced-colors` (이미 미디어 쿼리 + 시스템 색상으로 JS에서 알 수 있음)
|
||||
|
||||
> blink-dev Intent에 명시된 잔여 리스크: "이 API는 그라디언트 픽셀, 폼 컨트롤 렌더링 등 **소량의 새 정보를 노출**하며 이는 상호운용성 리스크를 만든다." Mozilla의 우려도 주로 이 핑거프린팅 지점이다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 기타 확인된 제약
|
||||
|
||||
| 제약 | 내용 | 출처 |
|
||||
|---|---|---|
|
||||
| cross-origin iframe | 지원 안 함 (위 10절) | Chrome 블로그 "Limitations" |
|
||||
| 메인 스레드 스크롤 | 캔버스 내부 콘텐츠는 JS로 그려지므로 **스크롤·애니메이션이 JS와 독립적으로 갱신될 수 없다.** 컴포지터 스레드 스크롤의 이점을 잃는다. 캔버스 안에 스크롤 콘텐츠를 넣을지, 캔버스 전체를 스크롤시킬지 신중히 판단하라. | Chrome 블로그 "Limitations" |
|
||||
| 중첩 canvas | explainer는 자손을 조상 캔버스에 그리는 것을 허용하지만, **Chromium Canary 구현은 가장 가까운 canvas 조상으로 제한**한다 (스펙 PR #11588에서 미해결 논의 중) | whatwg/html#11588 |
|
||||
| ElementImage 교차 캔버스 | `ElementImage`를 만든 캔버스 외의 캔버스에 그리는 것을 제한할지 논의 중 (접근성·래스터화 복잡도 vs 오래된 스냅샷 문제) | whatwg/html#11588 |
|
||||
| GC 압박 | dictionary 기반 API 설계 때문에 애니메이션 중 잦은 GC가 발생한다는 리뷰 지적 | whatwg/html#11588 |
|
||||
| 크기/리사이즈 | 커뮤니티 공통 지적: "이 API에서 크기와 리사이즈가 유일하게 덜 익은 부분". `<canvas>`는 div처럼 `width:100%`가 기본이 아니고 콘텐츠에 따라 높이가 자라지도 않는다. | Frontend Masters (Amit Sheen, 2026-04-21) |
|
||||
| 첫 스냅샷 전 호출 | `InvalidStateError` throw. 반드시 `onpaint` 안에서 그리고, `requestPaint()`로 킥스타트 | explainer + Matt Rothenberg |
|
||||
| 캔버스 자식 크기 | 캔버스 자식의 크기가 캔버스 CSS 크기와 불일치하면 텍스처가 늘어나고 좌표가 페이지 아래로 갈수록 누적 오차 | Matt Rothenberg 실전 노트 |
|
||||
| 반투명 배경 | 반투명 input 배경은 셰이더 효과가 비쳐 나오므로 불투명 색을 쓸 것 | Matt Rothenberg 실전 노트 |
|
||||
| WebGL Y축 | 텍스처는 top-down, WebGL은 bottom-up → UV에서 Y 뒤집기 필요 | Matt Rothenberg 실전 노트 |
|
||||
| 전체 화면 후처리 | 페이지 전체에 후처리를 걸면 오히려 접근성 이점을 훼손할 수 있다 | Codrops |
|
||||
|
||||
---
|
||||
|
||||
## 12. 향후 방향 (explainer "Future considerations")
|
||||
|
||||
**Auto-updating canvas 모드**: `drawElementImage`가 "최신 렌더링을 가리키는 플레이스홀더"를 기록하고, 캔버스가 커맨드 버퍼를 보관해 스크롤/애니메이션 갱신마다 자동 재생하는 모드. 스크립트를 블로킹하지 않고 컴포지터 스레드 스크롤·애니메이션과 완벽히 동기화되는 효과를 가능하게 한다. 2D 컨텍스트에는 실현 가능, WebGPU도 소폭 API 추가로 가능할 것으로 보고 있다. **WebGL은 `getError()` 등 플러시가 필요한 API 때문에 이 모델이 근본적으로 불가**하다고 explainer가 명시.
|
||||
144
research/canvas/02-availability.md
Normal file
144
research/canvas/02-availability.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# 02. 가용성 — 버전 / 플래그 / Origin Trial / 타 브라우저 입장
|
||||
|
||||
> 조사 기준일: **2026-08-20**
|
||||
> 이 시점의 Chrome Stable은 **151** (152는 2026-08-25 릴리스 예정). 출처: chromiumdash 마일스톤 스케줄 API
|
||||
|
||||
---
|
||||
|
||||
## 1. 결론 먼저
|
||||
|
||||
### ❌ 지금 프로덕션에 쓸 수 없다 — 단, "점진적 향상"으로는 오늘 넣을 수 있다
|
||||
|
||||
| 질문 | 답 |
|
||||
|---|---|
|
||||
| Stable Chrome에서 기본 켜져 있나? | **아니다.** chromestatus 상태는 여전히 `In development`이고, desktop/android/webview 출시 마일스톤이 전부 `null`이다. |
|
||||
| 일반 사용자가 볼 수 있나? | **Origin Trial 토큰을 등록한 오리진에 한해** Chrome 148~154 사용자에게 보인다. 그 외에는 `chrome://flags` 수동 활성화 필요. |
|
||||
| Chromium 계열 외 브라우저는? | **전혀 지원 없음.** Firefox·Safari 모두 "No signal"(입장 미표명), 구현 계획 없음. |
|
||||
| 스펙은 확정됐나? | **아니다.** WHATWG PR #11588은 2025-08-21 개설 후 **여전히 open/미머지**. 2026년 상반기에만 메서드 시그니처(WebGL/WebGPU)가 두 번 바뀌었다. |
|
||||
| 안정화 예상 | **공식 예상 없음.** Chrome 팀이 OT를 M150 → M154로 연장하며 밝힌 사유가 "상당한 피드백을 받았고 (WebGL/WebGPU API, 프라이버시에) 중대한 변경을 했다"이므로, 최소 M155(2026-10-06) 이후에나 Intent to Ship이 가능하다. Firefox/Safari 신호가 없는 한 진짜 Baseline까지는 수년 단위. |
|
||||
|
||||
### 실무 권고
|
||||
|
||||
1. **핵심 UX를 이 API에 의존시키지 마라.** 기능 감지 후 미지원이면 평범한 HTML로 폴백되는 구조여야 한다 (CanvasUI가 채택한 모델: "런타임에 지원을 감지하고 우아하게 degrade — API가 없으면 콘텐츠는 그냥 일반 HTML로 렌더되고, 여전히 돌 수 있는 효과 부분은 계속 돈다").
|
||||
2. **API 이름을 코드 전반에 흩뿌리지 마라.** 2025-08 이후 메서드명이 3번(`drawElement`→`drawHTMLElement`→`drawHTML`→`drawElementImage`), 3D 시그니처가 2번 바뀌었다. 얇은 어댑터 레이어 하나로 감싸라 (`03-code-examples.md` §7 참조).
|
||||
3. **데모·포트폴리오·실험·사내 도구에는 지금 써도 된다.** 특히 Chrome 사용자가 절대다수인 크리에이티브 포트폴리오라면 OT 토큰 + 폴백 조합으로 실전 투입 가능하다.
|
||||
4. **폴백이 필요하면 `three-html-render` 폴리필**(`foreignObject` 기반)이 같은 API 표면을 제공한다 → `04-fallbacks.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Chrome 타임라인
|
||||
|
||||
| 단계 | 마일스톤 | 플랫폼 | 비고 |
|
||||
|---|---|---|---|
|
||||
| **DevTrial (플래그)** | **M138부터** | Desktop / Android / WebView 전부 | `chrome://flags/#canvas-draw-element` |
|
||||
| **Origin Trial (최초)** | **M148 ~ M150** | Desktop / Android / WebView | Intent to Experiment 승인 |
|
||||
| **Origin Trial (연장 1회차)** | **~ M154** | Desktop | Intent to Extend Experiment, Mike Taylor LGTM **2026-06-11** |
|
||||
| Intent to Ship | — | — | **아직 없음** |
|
||||
|
||||
### 마일스톤 → 실제 날짜 (chromiumdash 공식 스케줄)
|
||||
|
||||
| 마일스톤 | Branch | Beta | **Stable** |
|
||||
|---|---|---|---|
|
||||
| M148 | 2026-04-06 | 2026-04-08 | **2026-05-05** |
|
||||
| M150 | 2026-06-01 | 2026-06-03 | **2026-06-30** |
|
||||
| M152 | 2026-07-27 | 2026-07-29 | **2026-08-25** |
|
||||
| M153 | 2026-08-17 | 2026-08-19 | **2026-09-08** |
|
||||
| M154 | 2026-08-31 | 2026-09-02 | **2026-09-22** |
|
||||
| M155 | 2026-09-14 | 2026-09-16 | **2026-10-06** |
|
||||
|
||||
→ **Origin Trial은 2026-08-20 현재 진행 중이며, M154(2026-09-22 Stable)까지 유효하다.** 실무적으로 M155가 Stable에 도달하는 **2026-10-06 무렵 만료**된다고 보면 된다. (추가 연장 가능성 있음 — 1차 연장 전례가 있다.)
|
||||
|
||||
### OT 연장 사유 (Intent to Extend Experiment 원문 요지)
|
||||
|
||||
> "상당한 피드백을 받았고 중대한 변경(WebGL/WebGPU API, 프라이버시)을 했기 때문에, 이 단계에서 개발자 입력을 계속 수집하고자 한다."
|
||||
|
||||
---
|
||||
|
||||
## 3. 지금 당장 켜는 방법
|
||||
|
||||
### 3.1 개발자 본인 브라우저 (플래그)
|
||||
|
||||
```
|
||||
chrome://flags/#canvas-draw-element
|
||||
```
|
||||
→ **Enabled** 로 설정하고 브라우저 재시작.
|
||||
|
||||
- 플래그 이름: **`canvas-draw-element`** (옛 메서드명 `drawElement` 시절에 붙은 이름이라 현재 API명과 다르다. 이름은 바뀌지 않았다.)
|
||||
- Chrome **Canary 149 이상**이 Chrome 공식 권장 (Chrome for Developers 블로그).
|
||||
- 커맨드라인 대안: `--enable-blink-features=CanvasDrawElement` (three.js PR #31233이 안내하는 구버전용 방법)
|
||||
- 서드파티 보고: **Brave Stable(Chromium 147+)** 및 기타 Chromium 계열에서도 같은 플래그로 켜진다 (html-in-canvas.dev). Google 1차 출처는 아님.
|
||||
|
||||
### 3.2 실제 사용자에게 노출 (Origin Trial 토큰)
|
||||
|
||||
등록: `https://developer.chrome.com/origintrials/#/view_trial/3478467762190286849`
|
||||
|
||||
토큰을 받아 다음 중 하나로 주입:
|
||||
|
||||
```html
|
||||
<meta http-equiv="origin-trial" content="TOKEN_HERE">
|
||||
```
|
||||
```
|
||||
Origin-Trial: TOKEN_HERE (HTTP 응답 헤더)
|
||||
```
|
||||
|
||||
- 대상: Desktop / Android / WebView
|
||||
- 3rd-party origin trial 지원 여부는 **미확인** (트라이얼 등록 페이지가 로그인 벽 뒤에 있어 확인 불가). 서드파티 스크립트로 배포할 계획이라면 등록 시 확인 필요.
|
||||
|
||||
### 3.3 기능 감지
|
||||
|
||||
```js
|
||||
const HAS_HIC =
|
||||
typeof HTMLCanvasElement !== 'undefined' &&
|
||||
'requestPaint' in HTMLCanvasElement.prototype &&
|
||||
'drawElementImage' in CanvasRenderingContext2D.prototype;
|
||||
```
|
||||
three.js 공식 예제가 쓰는 판정은 `'requestPaint' in HTMLCanvasElement.prototype` 하나다. 2D만 쓸 거면 `drawElementImage`까지, WebGL이면 `'texElementImage2D' in WebGL2RenderingContext.prototype`, WebGPU면 `'copyElementImageToTexture' in GPUQueue.prototype`를 추가로 본다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 타 브라우저 입장 (standards positions)
|
||||
|
||||
| 엔진 | 입장 | 트래커 | 비고 |
|
||||
|---|---|---|---|
|
||||
| **Gecko / Firefox** | **No signal** (미표명) | [mozilla/standards-positions#1076](https://github.com/mozilla/standards-positions/issues/1076) | 2024-09-25 개설, **여전히 open, 라벨은 "Needs proposed position"**. Mozilla Graphics 팀(nical) 배정. chromestatus의 Chrome 팀 주석: "Mozilla는 spec을 stage 2(대략적 API 모양에 대한 합의)로 진행시키는 데 반대하지 않았고, 우리는 핑거프린팅·호환성에 대한 그들의 우려를 해소하기 위해 적극적으로 작업 중이다." |
|
||||
| **WebKit / Safari** | **No signal** (미표명) | [WebKit/standards-positions#630](https://github.com/WebKit/standards-positions/issues/630) | open. `@annevk`, `@smfr`, `@shallawa`, `@cookiecrook` 태그됨. 이슈 본문에 WebKit 엔지니어 코멘트나 공식 라벨 없음. 배경 메모: 이전 제안(canvas place element #403)에서 **retained-mode 캔버스에 대한 우려** 때문에 **immediate-mode API 설계로 회귀**했다고 기재. |
|
||||
| **웹 개발자** | **Positive** | [whatwg/html#10650 코멘트](https://github.com/whatwg/html/issues/10650#issuecomment-3324124682) | DevTrial 사용자들의 긍정 신호. 커뮤니티 데모가 폭발적으로 나오는 중. |
|
||||
| **W3C TAG** | 리뷰 진행 | [w3ctag/design-reviews#1204](https://github.com/w3ctag/design-reviews/issues/1204) | — |
|
||||
|
||||
> 정리: **Chromium 단독 구현이고, 다른 두 엔진 어느 쪽도 "구현하겠다"고 말한 적이 없다.** Mozilla의 "stage 2 진행에 반대 안 함"은 지지가 아니라 논의 진행 허용에 가깝다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 표준화 상태
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| WHATWG HTML PR | [#11588](https://github.com/whatwg/html/pull/11588) "Add HTML-in-Canvas APIs" — **open, 미머지** (2025-08-21 개설, 저자 foolip) |
|
||||
| 성숙도 (chromestatus) | "Specification currently under development in a Working Group" (Working draft) |
|
||||
| Explainer | living document, 계속 갱신 중 (2026-07-14이 마지막 주요 갱신) |
|
||||
| 미해결 스펙 이슈 | ① dictionary 기반 API의 GC 압박, ② `ElementImage`의 교차 캔버스 사용 허용 여부, ③ **중첩 canvas — explainer는 허용하나 Chromium Canary는 가장 가까운 canvas 조상으로 제한(구현 불일치)**, ④ paint 타이밍과 paint-timing 스펙의 조율 |
|
||||
| **스펙 PR과 explainer 불일치** | PR #11588 본문에는 아직 폐기된 `setHitTestRegions()`와 `drawable` 속성이 남아 있다. **구현 기준은 explainer** |
|
||||
|
||||
---
|
||||
|
||||
## 6. 프레임워크/라이브러리 지원 현황 (2026-08 기준)
|
||||
|
||||
| 라이브러리 | API | 상태 |
|
||||
|---|---|---|
|
||||
| **three.js** | `THREE.HTMLTexture(element)` + `three/addons/interaction/InteractionManager.js` | **r184에 정식 포함** (dev 브랜치 2026-04-10 머지). WebGLRenderer / WebGPURenderer 양쪽 지원 |
|
||||
| **PlayCanvas** | `device.supportsHtmlTextures`, `texture.setSource(el)` | 지원. **WebGL 백엔드만**, WebGPU는 대기 중 |
|
||||
| **PixiJS** | `rendering.HTMLSource` | 지원 (WebGL & WebGPU) |
|
||||
| **Babylon.js** | HTML Texture | 지원 |
|
||||
| **CanvasUI** (canvasui.dev, David Haz) | 40+ 이펙트 컴포넌트, shadcn 레지스트리 방식 | React/Solid/Preact/Vue/Svelte/vanilla TS. **런타임 지원 감지 + graceful degradation 내장** |
|
||||
| **three-html-render** (repalash) | `installHtmlInCanvasPolyfill()` | **폴리필**. 네이티브 있으면 fast path, 없으면 `foreignObject` 래스터화. MIT |
|
||||
| **Remotion** | `custom-html-in-canvas` 트랜지션 프레젠테이션 | 지원 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 영상의 "아직 못 쓴다"는 말과 실제의 차이
|
||||
|
||||
노마드코더 영상은 "Chrome Canary의 플래그 뒤에 있다"고만 말하는데, 정확히는:
|
||||
|
||||
- 영상 시점 기준으로도 **Origin Trial이 이미 열려 있었다** (M148 = 2026-05-05 Stable). 즉 사이트 소유자가 토큰을 등록하면 **일반 Chrome Stable 사용자에게도 동작**시킬 수 있다.
|
||||
- 영상의 "시그니처와 API의 기본 모양이 아직 바뀔 수 있다"는 경고는 **정확하다.** 실제로 2026-04~06에 WebGL/WebGPU 시그니처가 바뀌었고, WICG 공식 데모조차 try/catch로 신·구 양쪽을 지원하고 있다.
|
||||
- 영상의 API 이름(`canvas place element`, `drawElement`)은 **구 명칭**이다. 현재는 `html-in-canvas` / `drawElementImage`.
|
||||
700
research/canvas/03-code-examples.md
Normal file
700
research/canvas/03-code-examples.md
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
# 03. 동작하는 코드 예제
|
||||
|
||||
> 전제: Chrome Canary 149+ 에서 `chrome://flags/#canvas-draw-element` = **Enabled**, 또는 Origin Trial 토큰 주입.
|
||||
> 모든 예제는 `01-api-spec.md`의 IDL 기준(2026-08 explainer)이다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 모든 예제가 지키는 4가지 규칙
|
||||
|
||||
1. **`<canvas>`에 `layoutsubtree`**, 그리고 그리려는 요소는 **직계 자식**.
|
||||
2. **그리기는 `onpaint` 안에서만.** 밖에서 그리면 직전 프레임 스냅샷이 쓰이고, 첫 스냅샷 전이면 `InvalidStateError`.
|
||||
3. **`canvas.requestPaint()`로 킥스타트.** 매 프레임 루프가 필요하면 `onpaint` 끝에서 다시 호출.
|
||||
4. **반환된 `DOMMatrix`를 `element.style.transform`에 반영.** 안 하면 클릭이 엉뚱한 데 떨어진다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 최소 예제 (WICG explainer 원문)
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('canvas').getContext('2d');
|
||||
|
||||
canvas.onpaint = () => {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(form_element, 100, 0);
|
||||
form_element.style.transform = transform.toString();
|
||||
};
|
||||
|
||||
// 흐릿함 방지: 캔버스 그리드를 device scale factor에 맞춘다.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
```
|
||||
|
||||
`devicePixelContentBoxSize`를 못 쓰는 환경까지 챙기는 Chrome 블로그 버전:
|
||||
|
||||
```js
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize
|
||||
: Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize
|
||||
: Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
});
|
||||
const supportsDPCB = typeof ResizeObserverEntry !== 'undefined'
|
||||
&& 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
|
||||
observer.observe(canvas, supportsDPCB ? { box: 'device-pixel-content-box' } : {});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 2D 반사(reflection) 이펙트 — 영상 데모의 정확한 재구성
|
||||
|
||||
버튼은 **진짜 클릭 가능한 HTML 버튼**이고, 아래쪽 뒤집힌 반사는 캔버스가 그린 픽셀이다. 완성 단일 파일.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML-in-Canvas: reflection</title>
|
||||
<style>
|
||||
body { margin: 0; background: #0b0b10; display: grid; place-items: center; height: 100vh; }
|
||||
canvas { width: 480px; height: 300px; }
|
||||
|
||||
/* 캔버스 자식은 평범한 HTML/CSS 그대로 */
|
||||
#btn {
|
||||
font: 600 20px/1 system-ui, sans-serif;
|
||||
padding: 16px 32px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #ff5fa2; /* 반투명 배경은 피할 것 (셰이더/블렌딩이 비쳐 나온다) */
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background .2s, scale .12s;
|
||||
}
|
||||
#btn:hover { background: #a05cff; scale: 1.05; }
|
||||
#btn:active { scale: .95; }
|
||||
</style>
|
||||
|
||||
<canvas id="canvas" layoutsubtree>
|
||||
<button id="btn">Press me</button>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const btn = document.getElementById('btn');
|
||||
|
||||
// 캔버스 좌표(= device pixel) 기준 배치 위치. CSS px 로 정의하고 dpr 로 곱한다.
|
||||
const X_CSS = 100, Y_CSS = 90;
|
||||
|
||||
canvas.onpaint = () => {
|
||||
// 캔버스 그리드 / CSS 크기 비율 = 실효 dpr
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const s = canvas.width / rect.width;
|
||||
|
||||
const x = X_CSS * s;
|
||||
const y = Y_CSS * s;
|
||||
const h = btn.offsetHeight * s; // 버튼 높이(캔버스 좌표)
|
||||
|
||||
ctx.reset();
|
||||
|
||||
// ── 1) 반사본: y = (Y+H) 축을 기준으로 뒤집고 흐리게
|
||||
ctx.save();
|
||||
ctx.translate(0, 2 * (y + h));
|
||||
ctx.scale(1, -1);
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawElementImage(btn, x, y); // 반환값은 버리는 게 맞다 (이건 "복사본")
|
||||
ctx.restore();
|
||||
|
||||
// ── 2) 실제 본체: 이 호출의 반환 transform 만 DOM 에 반영한다
|
||||
const t = ctx.drawElementImage(btn, x, y);
|
||||
btn.style.transform = t.toString();
|
||||
|
||||
// ── 3) 매 프레임 루프 (rAF 대응). 정적이면 이 줄을 빼라.
|
||||
canvas.requestPaint();
|
||||
};
|
||||
|
||||
// 최초 스냅샷 확보 + 파이프라인 킥스타트
|
||||
canvas.requestPaint();
|
||||
|
||||
// 캔버스 그리드를 device pixel 에 맞춤 (흐림 방지)
|
||||
new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
canvas.requestPaint();
|
||||
}).observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### 왜 `save()/restore()`와 두 번의 `drawElementImage`인가
|
||||
|
||||
- `drawElementImage`는 **캔버스의 현재 CTM을 적용**한다. 그래서 flip/alpha를 CTM+`globalAlpha`로 걸고 한 번 그리면 반사본이, 원복 후 한 번 더 그리면 본체가 나온다.
|
||||
- 소스 요소에 걸린 CSS transform은 **그리기에서 무시**되므로, `btn.style.transform`을 매 프레임 덮어써도 캔버스 그림은 영향받지 않는다 → **무한 루프가 생기지 않는다.** (그리고 explainer가 "transform 변경은 `paint`를 발화시키지 않는다"고 명시)
|
||||
- 반사 좌표 검산: 반사 변환은 `y' = 2(y+h) − y`. 요소 상단 `y` → `y+2h`, 하단 `y+h` → `y+h`. 즉 본체 바로 아래에 위아래 뒤집혀 붙는다.
|
||||
|
||||
### 클릭 리플 추가 (영상의 확장분)
|
||||
|
||||
```js
|
||||
let ripples = [];
|
||||
btn.addEventListener('click', (e) => {
|
||||
const r = btn.getBoundingClientRect();
|
||||
ripples.push({ x: e.clientX - r.left, y: e.clientY - r.top, t: performance.now() });
|
||||
});
|
||||
```
|
||||
`onpaint` 안에서 본체를 그린 뒤 `ctx.getImageData()`로 픽셀을 읽어 반경 기반으로 UV를 밀어내면 된다. 이 부분은 이 API와 무관한 **평범한 캔버스 픽셀 수학**이다 (영상의 표현 그대로). 다만 **`getImageData`는 느리므로 실전에서는 §4의 WebGL 경로가 정답**이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. `paint` 이벤트 루프 — 두 가지 패턴
|
||||
|
||||
### 패턴 A: 이벤트 구동 (기본값, 저비용)
|
||||
|
||||
호버·포커스·입력 등 **HTML이 실제로 바뀔 때만** 다시 그린다. 대부분의 UI 이펙트에 이게 맞다.
|
||||
|
||||
```js
|
||||
canvas.onpaint = (event) => {
|
||||
ctx.reset();
|
||||
for (const el of event.changedElements) { // 바뀐 요소만 알려준다
|
||||
const t = ctx.drawElementImage(el, 0, 0);
|
||||
el.style.transform = t.toString();
|
||||
}
|
||||
};
|
||||
canvas.requestPaint(); // 최초 1회
|
||||
```
|
||||
|
||||
`PaintEvent.changedElements`는 `FrozenArray<Element>`다. 캔버스에 자식이 여럿일 때 부분 갱신에 쓴다.
|
||||
|
||||
### 패턴 B: 매 프레임 루프 (애니메이션/셰이더)
|
||||
|
||||
```js
|
||||
canvas.onpaint = () => {
|
||||
const now = performance.now();
|
||||
ctx.reset();
|
||||
drawEverything(now);
|
||||
canvas.requestPaint(); // 다음 프레임 예약 → requestAnimationFrame 과 같은 역할
|
||||
};
|
||||
canvas.requestPaint();
|
||||
```
|
||||
|
||||
> `requestAnimationFrame`으로 루프를 돌리면서 그 안에서 `drawElementImage`를 부르면 **직전 프레임 스냅샷**이 쓰여 1프레임 지연이 생기고, 첫 프레임에 예외가 난다. **루프는 `requestPaint()`로 도는 게 맞다.**
|
||||
|
||||
### 중첩 canvas 주의
|
||||
|
||||
`paint`는 **역트리 순서**로 발화한다 (자손 → 조상). 조상 캔버스가 자손 캔버스의 결과에 의존하는 합성을 짤 때 이 순서를 전제할 수 있다. 단, **Chromium Canary는 아직 "요소는 가장 가까운 canvas 조상에만 그릴 수 있다"로 제한**하고 있으므로 (스펙과 구현 불일치) 중첩 구조는 신중히.
|
||||
|
||||
---
|
||||
|
||||
## 4. WebGL 텍스처화 — 전체 화면 셰이더 왜곡
|
||||
|
||||
라이브 HTML을 텍스처로 올려 프래그먼트 셰이더로 왜곡한다. 완성 단일 파일.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML-in-Canvas: WebGL distortion</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #07070c; }
|
||||
#gl { display: block; width: 100vw; height: 100vh; }
|
||||
#ui {
|
||||
width: 100vw; height: 100vh; box-sizing: border-box;
|
||||
padding: 12vh 10vw;
|
||||
font: 16px/1.6 system-ui, sans-serif; color: #eaeaf2;
|
||||
background: #12121b; /* 불투명하게 */
|
||||
}
|
||||
#ui h1 { font-size: 56px; margin: 0 0 24px; letter-spacing: -.03em; }
|
||||
#ui input, #ui button {
|
||||
font: inherit; padding: 12px 16px; border-radius: 10px; border: 1px solid #3a3a52;
|
||||
background: #1c1c29; color: inherit;
|
||||
}
|
||||
#ui input:focus { outline: 2px solid #7b6cff; }
|
||||
</style>
|
||||
|
||||
<canvas id="gl" layoutsubtree>
|
||||
<div id="ui">
|
||||
<h1>Real HTML, real shader.</h1>
|
||||
<p>이 텍스트는 선택·복사·find-in-page가 되고, 아래 입력창은 진짜 입력됩니다.</p>
|
||||
<p><input id="name" placeholder="이름"> <button>보내기</button></p>
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script type="module">
|
||||
const canvas = document.getElementById('gl');
|
||||
const ui = document.getElementById('ui');
|
||||
const gl = canvas.getContext('webgl2', { antialias: true, premultipliedAlpha: false });
|
||||
|
||||
// ── 셰이더 ────────────────────────────────────────────────
|
||||
const VS = `#version 300 es
|
||||
in vec2 aPos;
|
||||
out vec2 vUv;
|
||||
void main() {
|
||||
// 텍스처는 top-down, WebGL 클립공간은 bottom-up → Y 뒤집기
|
||||
vUv = vec2(aPos.x * 0.5 + 0.5, 0.5 - aPos.y * 0.5);
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vUv;
|
||||
out vec4 outColor;
|
||||
uniform sampler2D uTex;
|
||||
uniform vec2 uMouse; // 0..1
|
||||
uniform float uTime;
|
||||
uniform float uAspect;
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
// 커서 주변을 가우시안 감쇠로 끌어당기는 자기장 왜곡
|
||||
vec2 d = (uv - uMouse) * vec2(uAspect, 1.0);
|
||||
float dist = length(d);
|
||||
float pull = exp(-dist * dist * 90.0) * 0.06;
|
||||
vec2 warped = uv - normalize(d + 1e-6) * pull * vec2(1.0 / uAspect, 1.0);
|
||||
|
||||
// 살짝 흐르는 물결
|
||||
warped.x += sin(uv.y * 30.0 + uTime * 1.6) * 0.0015;
|
||||
|
||||
// 경계에서 색수차
|
||||
float ca = pull * 0.35;
|
||||
vec4 c;
|
||||
c.r = texture(uTex, warped + vec2( ca, 0.0)).r;
|
||||
c.g = texture(uTex, warped).g;
|
||||
c.b = texture(uTex, warped - vec2( ca, 0.0)).b;
|
||||
c.a = texture(uTex, warped).a;
|
||||
|
||||
outColor = c;
|
||||
}`;
|
||||
|
||||
function compile(type, src) {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, src); gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||
return s;
|
||||
}
|
||||
const prog = gl.createProgram();
|
||||
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
||||
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
|
||||
gl.useProgram(prog);
|
||||
|
||||
// ── 풀스크린 삼각형 2개 ────────────────────────────────────
|
||||
const vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
const vbo = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
|
||||
const loc = gl.getAttribLocation(prog, 'aPos');
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// ── 텍스처 ────────────────────────────────────────────────
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
// 텍스트에는 mipmap 보다 LINEAR 가 결과가 낫다 (WICG 예제 주석 그대로)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
/** 신/구 texElementImage2D 시그니처를 모두 지원 (WICG 공식 예제 패턴) */
|
||||
function uploadElement(el) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, el); // 현재 IDL
|
||||
} catch (e) {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, // 구 시그니처
|
||||
gl.UNSIGNED_BYTE, el);
|
||||
}
|
||||
}
|
||||
|
||||
const uTex = gl.getUniformLocation(prog, 'uTex');
|
||||
const uMouse = gl.getUniformLocation(prog, 'uMouse');
|
||||
const uTime = gl.getUniformLocation(prog, 'uTime');
|
||||
const uAspect = gl.getUniformLocation(prog, 'uAspect');
|
||||
|
||||
let mouse = [0.5, 0.5];
|
||||
addEventListener('pointermove', (e) => {
|
||||
mouse = [e.clientX / innerWidth, e.clientY / innerHeight];
|
||||
});
|
||||
|
||||
// ── paint 루프 ────────────────────────────────────────────
|
||||
canvas.onpaint = () => {
|
||||
uploadElement(ui); // ← 반드시 paint 안에서
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.useProgram(prog);
|
||||
gl.bindVertexArray(vao);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.uniform1i(uTex, 0);
|
||||
gl.uniform2f(uMouse, mouse[0], mouse[1]);
|
||||
gl.uniform1f(uTime, performance.now() * 0.001);
|
||||
gl.uniform1f(uAspect, canvas.width / canvas.height);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
|
||||
canvas.requestPaint(); // 매 프레임
|
||||
};
|
||||
canvas.requestPaint();
|
||||
|
||||
new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
canvas.requestPaint();
|
||||
}).observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### 이 예제의 히트테스트
|
||||
|
||||
풀스크린 쿼드가 요소를 1:1로 매핑하므로 **DOM 위치가 그려진 위치와 이미 일치**한다 → `style.transform` 조작이 필요 없다. 다만 셰이더가 픽셀을 왜곡하는 만큼 **클릭 지점과 보이는 지점이 왜곡량만큼 어긋난다.** 왜곡을 작게 유지하거나, 왜곡이 큰 순간(전환 애니메이션)에는 포인터 이벤트를 잠깐 무시하는 식으로 다뤄야 한다. (Codrops가 지적한 트레이드오프)
|
||||
|
||||
### 두 텍스처 블렌딩 — 이 API의 진짜 킬러 패턴
|
||||
|
||||
Matt Rothenberg의 "Burn Transition"(영상의 다크모드 불타는 전환)이 쓰는 구조:
|
||||
|
||||
```html
|
||||
<canvas layoutsubtree>
|
||||
<div id="lightPage">...</div>
|
||||
<div id="darkPage">...</div>
|
||||
</canvas>
|
||||
```
|
||||
```js
|
||||
canvas.onpaint = () => {
|
||||
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texLight);
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, lightPage);
|
||||
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texDark);
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, darkPage);
|
||||
// 프래그먼트 셰이더에서 FBM 노이즈로 burn front 를 만들어 두 텍스처를 픽셀 단위 합성
|
||||
...
|
||||
};
|
||||
```
|
||||
**두 개의 라이브 렌더를 임의의 GLSL 함수로 합성하는 것** — View Transitions는 두 개의 *스냅샷*을 CSS 애니메이션으로 넘기는 게 전부라 이건 CSS에 등가물이 없다.
|
||||
|
||||
---
|
||||
|
||||
## 5. three.js 연동 — `THREE.HTMLTexture` (r184+)
|
||||
|
||||
**가장 실용적인 3D 경로.** three.js가 `layoutsubtree` 설정, 요소 부모 관리, 매 프레임 `matrix3d` 계산, 히트테스트 위임까지 다 해준다. 레이캐스팅이 필요 없다.
|
||||
|
||||
```html
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "https://unpkg.com/three@0.184.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.184.0/examples/jsm/",
|
||||
"three-html-render/polyfill": "https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.mjs"
|
||||
}}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
|
||||
|
||||
// 네이티브 API 없으면 foreignObject 폴리필로 대체 (three.js 공식 예제와 동일한 판정)
|
||||
if (!('requestPaint' in HTMLCanvasElement.prototype)) {
|
||||
const { installHtmlInCanvasPolyfill } = await import('three-html-render/polyfill');
|
||||
installHtmlInCanvasPolyfill();
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(devicePixelRatio);
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
renderer.toneMapping = THREE.NeutralToneMapping;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 1, 2000);
|
||||
camera.position.z = 500;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xaaaaaa);
|
||||
scene.environment = new THREE.PMREMGenerator(renderer)
|
||||
.fromScene(new RoomEnvironment(), 0.02).texture;
|
||||
|
||||
// ── 텍스처가 될 HTML. document 에 붙일 필요 없다 — HTMLTexture 가 캔버스 자식으로 넣어준다.
|
||||
const element = document.createElement('div');
|
||||
element.style.cssText = 'width:600px;padding:30px;background:#aaa;color:#000;'
|
||||
+ 'font:30px/1.5 sans-serif;text-align:center';
|
||||
element.innerHTML = `
|
||||
Hello world! <b>formatted</b> 텍스트, 이모지 😀, RTL <span dir="rtl">من فارسی</span>
|
||||
<br><input type="text" placeholder="입력해 보세요">
|
||||
<button>Click me</button>`;
|
||||
|
||||
const material = new THREE.MeshStandardMaterial({ roughness: 0, metalness: 0.5 });
|
||||
material.map = new THREE.HTMLTexture(element); // ← 핵심 한 줄
|
||||
|
||||
const mesh = new THREE.Mesh(new RoundedBoxGeometry(200, 200, 200, 10, 10), material);
|
||||
scene.add(mesh);
|
||||
|
||||
// ── 네이티브 포인터 상호작용 (raycast 불필요, 브라우저 히트테스트에 위임)
|
||||
const interactions = new InteractionManager();
|
||||
interactions.connect(renderer, camera);
|
||||
interactions.add(mesh);
|
||||
|
||||
element.querySelector('button').addEventListener('click', function () {
|
||||
this.textContent = 'Clicked!'; // 3D 표면 위에서 그대로 동작
|
||||
});
|
||||
|
||||
renderer.setAnimationLoop((t) => {
|
||||
mesh.rotation.x = Math.sin(t * 0.0005) * 0.5;
|
||||
mesh.rotation.y = Math.cos(t * 0.0008) * 0.5;
|
||||
interactions.update(); // 매 프레임 transform 동기화
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### `HTMLTexture`가 내부에서 하는 일 (three.js 소스 원문 요지)
|
||||
|
||||
```js
|
||||
class HTMLTexture extends Texture {
|
||||
constructor(element, ...) {
|
||||
super(element, ...);
|
||||
this.isHTMLTexture = true;
|
||||
this.generateMipmaps = false;
|
||||
this.needsUpdate = true;
|
||||
|
||||
const parent = element ? element.parentNode : null;
|
||||
if (parent !== null && 'requestPaint' in parent) {
|
||||
parent.onpaint = () => { this.needsUpdate = true; }; // paint 마다 갱신 플래그
|
||||
parent.requestPaint(); // 킥스타트
|
||||
}
|
||||
}
|
||||
dispose() { /* onpaint 해제 후 super.dispose() */ }
|
||||
}
|
||||
```
|
||||
→ `Texture`를 거의 그대로 쓰되 **`paint` 이벤트를 구독해 `needsUpdate`를 세우는 것**이 전부다. 업로드 자체는 렌더러가 `texElementImage2D` / `copyElementImageToTexture`로 처리한다. **WebGLRenderer와 WebGPURenderer 양쪽 지원.**
|
||||
|
||||
### React Three Fiber 조합 (Codrops 패턴)
|
||||
|
||||
```jsx
|
||||
const texture = new HTMLTexture(document.getElementById('computer_screen'));
|
||||
material.uniforms.map.value = texture;
|
||||
material.map = texture;
|
||||
|
||||
const interactions = new InteractionManager();
|
||||
interactions.connect(gl, camera); // useThree() 의 gl, camera
|
||||
interactions.add(screenMeshRef.current);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
material.uniforms.uTime.value = clock.elapsedTime;
|
||||
interactions.update();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. WebGPU — `copyElementImageToTexture()`
|
||||
|
||||
WICG 젤리 슬라이더 데모의 실제 구조.
|
||||
|
||||
```js
|
||||
const canvas = document.getElementById('canvas'); // <canvas layoutsubtree>
|
||||
const element = document.getElementById('value'); // 캔버스 직계 자식
|
||||
|
||||
const targetTexture = device.createTexture({
|
||||
size: [width, height, 1],
|
||||
format: 'rgba8unorm',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING
|
||||
| GPUTextureUsage.COPY_DST
|
||||
| GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
|
||||
canvas.onpaint = () => {
|
||||
const source = { source: element }; // GPUCopyElementImageSource
|
||||
const dest = { // GPUCopyElementImageDestination
|
||||
destination: { texture: targetTexture },
|
||||
width, height,
|
||||
};
|
||||
|
||||
try {
|
||||
device.queue.copyElementImageToTexture(source, dest); // 현재 IDL
|
||||
} catch (e) {
|
||||
device.queue.copyElementImageToTexture(element, width, height, // 구 시그니처
|
||||
{ texture: targetTexture });
|
||||
}
|
||||
|
||||
// 히트테스트 동기화 — 3D 배치면 canvas.getElementTransform() 사용
|
||||
element.style.transform = `translate(${x}px, ${y}px)`;
|
||||
};
|
||||
canvas.requestPaint();
|
||||
```
|
||||
|
||||
**소스 사각형 크롭**이 필요하면 `source`에 `sx / sy / swidth / sheight`를 추가한다.
|
||||
|
||||
> ⚠️ WICG 젤리 슬라이더 소스에는 `// TODO(pdr): Calculate this correctly using getElementTransform. For now, the transform is just hard-coded.` 라는 주석이 남아 있다. **공식 WebGPU 데모조차 transform 동기화를 하드코딩 중**이라는 뜻으로, 이 경로가 아직 가장 덜 다듬어진 부분이다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 기능 감지 + 어댑터 레이어 (권장 래퍼)
|
||||
|
||||
시그니처가 계속 바뀌므로 호출부를 한 곳에 격리한다.
|
||||
|
||||
```js
|
||||
// hic.js
|
||||
export const HIC = {
|
||||
get supported2D() {
|
||||
return 'requestPaint' in HTMLCanvasElement.prototype
|
||||
&& 'drawElementImage' in CanvasRenderingContext2D.prototype;
|
||||
},
|
||||
get supportedGL() {
|
||||
return 'requestPaint' in HTMLCanvasElement.prototype
|
||||
&& (('texElementImage2D' in WebGL2RenderingContext.prototype) ||
|
||||
('texElementImage2D' in WebGLRenderingContext.prototype));
|
||||
},
|
||||
get supportedGPU() {
|
||||
return typeof GPUQueue !== 'undefined'
|
||||
&& 'copyElementImageToTexture' in GPUQueue.prototype;
|
||||
},
|
||||
|
||||
/** 2D: 그리고 transform 을 동기화. 반환 DOMMatrix. */
|
||||
draw(ctx, el, x, y, w, h) {
|
||||
const t = (w === undefined)
|
||||
? ctx.drawElementImage(el, x, y)
|
||||
: ctx.drawElementImage(el, x, y, w, h);
|
||||
el.style.transform = t.toString();
|
||||
return t;
|
||||
},
|
||||
|
||||
/** WebGL: 신/구 시그니처 흡수 */
|
||||
uploadGL(gl, el, internalformat = gl.RGBA8) {
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, internalformat, el);
|
||||
} catch (_) {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el);
|
||||
}
|
||||
},
|
||||
|
||||
/** WebGPU: 신/구 시그니처 흡수 */
|
||||
uploadGPU(device, el, texture, width, height) {
|
||||
try {
|
||||
device.queue.copyElementImageToTexture(
|
||||
{ source: el },
|
||||
{ destination: { texture }, width, height });
|
||||
} catch (_) {
|
||||
device.queue.copyElementImageToTexture(el, width, height, { texture });
|
||||
}
|
||||
},
|
||||
|
||||
/** 캔버스 그리드를 device pixel 에 맞추고 리사이즈마다 repaint */
|
||||
observeSize(canvas) {
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const dpc = entry.devicePixelContentBoxSize;
|
||||
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * devicePixelRatio);
|
||||
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * devicePixelRatio);
|
||||
canvas.requestPaint?.();
|
||||
});
|
||||
const supportsDPCB = typeof ResizeObserverEntry !== 'undefined'
|
||||
&& 'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
|
||||
ro.observe(canvas, supportsDPCB ? { box: 'device-pixel-content-box' } : {});
|
||||
return ro;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
사용 시 **미지원이면 캔버스를 아예 만들지 말고 HTML을 그대로 노출**하는 것이 폴백의 기본형이다:
|
||||
|
||||
```js
|
||||
if (HIC.supported2D) {
|
||||
canvas.setAttribute('layoutsubtree', '');
|
||||
mountEffect(canvas);
|
||||
} else {
|
||||
canvas.replaceWith(...canvas.childNodes); // 자식 HTML 을 그대로 문서에 승격
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. OffscreenCanvas + Worker (explainer 원문)
|
||||
|
||||
무거운 2D 합성을 워커로 넘기는 경로.
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
<script>
|
||||
const workerCode = `
|
||||
let ctx;
|
||||
self.onmessage = (e) => {
|
||||
if (e.data.canvas) ctx = e.data.canvas.getContext('2d');
|
||||
if (e.data.width && e.data.height) {
|
||||
ctx.canvas.width = e.data.width;
|
||||
ctx.canvas.height = e.data.height;
|
||||
}
|
||||
if (e.data.elementImage) {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(e.data.elementImage, 100, 0);
|
||||
self.postMessage({transform: transform});
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
worker.postMessage({ canvas: offscreen }, [offscreen]);
|
||||
|
||||
canvas.onpaint = () => {
|
||||
const elementImage = canvas.captureElementImage(form_element); // Transferable
|
||||
worker.postMessage({ elementImage }, [elementImage]);
|
||||
};
|
||||
|
||||
worker.onmessage = ({ data }) => {
|
||||
form_element.style.transform = data.transform.toString(); // 메인에서 동기화
|
||||
};
|
||||
|
||||
new ResizeObserver(([entry]) => {
|
||||
worker.postMessage({
|
||||
width: entry.devicePixelContentBoxSize[0].inlineSize,
|
||||
height: entry.devicePixelContentBoxSize[0].blockSize
|
||||
});
|
||||
canvas.requestPaint();
|
||||
}).observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
- 워커에서도 `drawElementImage(elementImage, ...)`가 `DOMMatrix`를 돌려주고, 그걸 `postMessage`로 메인에 되돌려 적용한다.
|
||||
- 위치가 **동적**이면 왕복 지연 때문에 어긋나므로, **메인 스레드에서 위치를 미리 계산해 `ElementImage` 전송과 동시에 `style.transform`을 적용**하라고 explainer가 권한다.
|
||||
- 다 쓴 `ElementImage`는 `close()`로 해제.
|
||||
|
||||
---
|
||||
|
||||
## 9. 실전 체크리스트 (커뮤니티가 실제로 데인 것들)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| ☐ `requestPaint()` 최초 1회 | 안 하면 아무것도 안 그려지고, 첫 그리기에서 `InvalidStateError` |
|
||||
| ☐ 그리기는 `onpaint` 안에서 | 밖이면 1프레임 지연 |
|
||||
| ☐ 캔버스 그리드 = device pixel | `ResizeObserver` + `device-pixel-content-box`. 안 하면 텍스트가 흐리다 |
|
||||
| ☐ 좌표는 device pixel 단위 | CSS px 값에 `canvas.width / rect.width`를 곱하라. 안 하면 Retina에서 어긋난다 |
|
||||
| ☐ 캔버스 자식 크기 = 캔버스 CSS 크기 | 불일치하면 텍스처가 늘어나고 좌표 오차가 페이지 아래로 갈수록 누적 |
|
||||
| ☐ WebGL UV Y 뒤집기 | `vUv = vec2(x*0.5+0.5, 0.5 - y*0.5)` |
|
||||
| ☐ 배경은 불투명 색 | 반투명 배경은 셰이더 효과가 비쳐 나온다 |
|
||||
| ☐ `style.transform` 동기화 | 안 하면 클릭·포커스·find-in-page 하이라이트 위치가 전부 어긋난다 |
|
||||
| ☐ 텍스트에는 `LINEAR` 필터 | mipmap보다 결과가 낫다 (WICG 예제 주석) |
|
||||
| ☐ `<canvas>`는 div가 아니다 | `width:100%` 기본값도 없고 콘텐츠 높이로 자라지도 않는다. 크기를 명시하라 |
|
||||
| ☐ 히트테스트가 필요 없으면 `inert` | WICG WebGL 예제가 `<div id="draw_element" inert>`로 히트테스트를 끈다 |
|
||||
| ☐ 왜곡이 크면 클릭이 어긋남을 인지 | 큰 전환 중에는 `pointer-events: none` 등으로 처리 |
|
||||
| ☐ 스크롤 콘텐츠는 신중히 | 캔버스 안에서는 컴포지터 스레드 스크롤이 불가. 캔버스 전체를 스크롤시키는 편이 낫다 |
|
||||
165
research/canvas/04-fallbacks.md
Normal file
165
research/canvas/04-fallbacks.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# 04. 폴백 전략 비교
|
||||
|
||||
> HTML-in-Canvas를 못 쓰는 브라우저(Firefox, Safari, 그리고 플래그/OT 없는 Chrome Stable 전부)에서 **비슷한 인상**을 내는 방법들.
|
||||
> 핵심 질문 3개로 갈린다: **① 상호작용을 유지하는가 ② 매 프레임 갱신되는가 ③ 렌더된 픽셀을 셰이더가 읽을 수 있는가**
|
||||
|
||||
---
|
||||
|
||||
## 1. 한눈에 보는 비교표
|
||||
|
||||
| 방식 | 원리 | 상호작용<br>유지 | 매 프레임<br>실시간 | 픽셀을<br>셰이더로 | CSS 충실도 | 접근성 | 브라우저 | 비용 |
|
||||
|---|---|:---:|:---:|:---:|---|---|---|---|
|
||||
| **A. 네이티브 HTML-in-Canvas** | 브라우저가 요소 렌더링을 캔버스/텍스처로 직접 복사 | ✅ 완전 | ✅ | ✅ | 100% (브라우저 본체) | ✅ 자동 (DOM 그대로) | Chrome 148+ OT / 플래그만 | 낮음 (GPU 경로) |
|
||||
| **B. `three-html-render` 폴리필** | 네이티브 있으면 fast path, 없으면 `foreignObject` 래스터화 + DOM 오버레이 | ✅ (오버레이가 이벤트 수신) | △ 무효화마다 재래스터 | ✅ | 높음 (브라우저 렌더러 재사용) | ✅ (실 DOM 유지) | 전 브라우저 | 중 (직렬화·이미지 디코드) |
|
||||
| **C. SnapDOM** | DOM → 인라인 SVG `foreignObject` → 래스터화 | ❌ 정지 이미지 | ❌ | ✅ | **매우 높음** (그라디언트·필터·블렌드·transform 포함) | ❌ 별도 대체 필요 | 전 모던 브라우저 | 낮음 (html2canvas 대비 2~16배 빠름) |
|
||||
| **D. `foreignObject` 직접 구현** | 직접 SVG 직렬화 → `<img>` → `drawImage` | ❌ | ❌ | ✅ | 높음 (단 폰트·이미지 인라인 직접 처리) | ❌ | 전 모던 브라우저<br>(Safari 제약 있음) | 낮음, 단 구현 부담 |
|
||||
| **E. html2canvas** | 브라우저 렌더러를 **JS로 재구현**해 DOM을 다시 그림 | ❌ | ❌ | ✅ | **낮음** — 미지원 CSS 목록이 길다 | ❌ | 전 브라우저 | 높음 (느림) |
|
||||
| **F. CSS3DRenderer (three.js)** | 실제 DOM 요소를 `matrix3d`로 3D 배치 | ✅ 완전 | ✅ | **❌ 불가** | 100% (진짜 DOM) | ✅ | 전 브라우저 | 낮음 |
|
||||
| **G. `backdrop-filter` + SVG `feDisplacementMap`** | 배경 레이어를 변위 맵으로 굴절 | ✅ (위 콘텐츠 그대로) | ✅ (CSS 합성) | ❌ (고정 필터 셋) | — | ✅ | **Chromium만.** Safari·Firefox는 `backdrop-filter: url()` 미지원 → 블러로 degrade | 낮음~중 (GPU) |
|
||||
| **H. View Transitions API** | 전/후 **스냅샷 2장**을 CSS로 크로스페이드/클립 | 전환 중 ❌ | 전환 중 ❌ | ❌ | 100% | ✅ | Chrome/Edge/Safari 18+ (Firefox 뒤처짐) | 낮음 |
|
||||
| **I. 배경 캔버스 오버레이** | HTML 뒤/앞에 별도 `<canvas>`를 깔고 이펙트만 그림 | ✅ | ✅ | **❌ HTML을 못 읽음** | — | ✅ | 전 브라우저 | 낮음 |
|
||||
| **J. Satori / 서버 렌더** | 서버에서 HTML/CSS → SVG/PNG | ❌ | ❌ | ✅ | 중 (지원 CSS 부분집합) | ❌ | 무관 | 서버 비용 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 각 방식의 실무 판단
|
||||
|
||||
### A. 네이티브 (기준선)
|
||||
당연히 최선이지만 **오늘 프로덕션 불가**. 나머지 전부는 "A의 어떤 성질을 포기할 것인가"의 문제다.
|
||||
|
||||
### B. `three-html-render` 폴리필 — **가장 현실적인 "같은 코드, 전 브라우저" 해법**
|
||||
|
||||
`https://github.com/repalash/three-html-render` (MIT). three.js 공식 예제 `webgl_materials_texture_html.html`이 실제로 이걸 쓴다.
|
||||
|
||||
```js
|
||||
if (!('requestPaint' in HTMLCanvasElement.prototype)) {
|
||||
const { installHtmlInCanvasPolyfill } = await import('three-html-render/polyfill');
|
||||
installHtmlInCanvasPolyfill();
|
||||
}
|
||||
```
|
||||
|
||||
- **API 표면을 그대로 제공**: `requestPaint()`, `captureElementImage()`, `drawElementImage()`, `texElementImage2D()`, `copyElementImageToTexture()`.
|
||||
- 내부 동작: 캔버스 자식을 오프스크린 host div로 옮기고 → SVG `foreignObject`로 변환 → `<img>`로 2D 캔버스에 렌더 → 텍스처 업로드. DOM 오버레이를 CSS `matrix3d`로 3D 지오메트리에 정렬해 **포인터 이벤트는 진짜 DOM이 받는다.**
|
||||
- Chrome Canary에서는 네이티브 `texElementImage2D` fast path를 자동으로 탄다.
|
||||
- three.js 0.150.0+ 및 standalone WebGL/WebGPU 지원.
|
||||
|
||||
**알려진 한계 (README 명시):**
|
||||
- `textarea` 내부 스크롤이 텍스처에 반영되지 않음
|
||||
- `contenteditable`의 캐럿/선택 영역이 렌더되지 않음
|
||||
- 동적 스타일시트는 수동 무효화 필요
|
||||
- **`:visited`는 폴리필 불가** (브라우저 보안)
|
||||
- 일부 CSS가 `foreignObject` 컨텍스트에서 다르게 렌더됨
|
||||
|
||||
**판정**: three.js 기반 3D UI라면 **이걸 쓰고, 네이티브가 켜지면 자동으로 빨라지는 구조**가 정답.
|
||||
|
||||
### C. SnapDOM — **정지 스냅샷이 필요할 때 최선**
|
||||
|
||||
`https://snapdom.dev` — DOM을 인라인 SVG `foreignObject`로 직렬화한 뒤 브라우저 네이티브 파이프라인으로 래스터화.
|
||||
- "브라우저가 화면에 그릴 수 있는 것이면 대체로 캡처된다" — 그라디언트, 필터, 블렌드 모드, transform, 최신 CSS 포함.
|
||||
- html2canvas 대비 **2~16배 빠름** (복잡한 요소 기준, 단순 요소는 1ms 미만).
|
||||
- **한계**: 정지 이미지다. 매 프레임 재캡처하면 GC와 이미지 디코딩 비용이 프레임을 잡아먹는다.
|
||||
|
||||
**판정**: "클릭 순간에 카드가 산산조각 나며 사라진다" 같은 **일회성 트랜지션**에는 충분하다. 스냅샷을 한 번 뜬 뒤 원본 DOM을 숨기고, 스냅샷 텍스처만 셰이더로 애니메이션하면 A와 시각적으로 구분이 잘 안 된다. 지속적 실시간 왜곡에는 부적합.
|
||||
|
||||
### D. `foreignObject` 직접 구현
|
||||
SnapDOM/html-to-image가 하는 일을 손으로 하는 것. 직접 만들 이유는 거의 없다. 다만 **왜 라이브러리들이 폰트·이미지를 base64로 인라인하는지**는 알아두는 게 좋다 — SVG `<img>`는 외부 리소스를 가져오지 못하고, 외부 리소스가 있으면 **canvas가 taint되어 `getImageData`/WebGL 업로드가 막힌다.**
|
||||
|
||||
주요 함정:
|
||||
- 외부 이미지 → CORS 필요, 아니면 캔버스 taint
|
||||
- 웹폰트 → `@font-face`의 `src`를 data URI로 인라인해야 함
|
||||
- `<canvas>` 자식이 taint 상태면 전체 실패
|
||||
- Safari는 `foreignObject` 안의 일부 CSS 처리가 Chromium과 다르다
|
||||
|
||||
### E. html2canvas — **더 이상 기본 선택지가 아니다**
|
||||
브라우저 렌더링 엔진을 JS로 재구현하는 방식이라, 모든 CSS 속성·레이아웃 예외·텍스트 렌더링 엣지케이스를 손으로 재현해야 한다. 그래서 **"지원하지 않는 CSS 속성" 목록이 길다.** 외부 도메인 이미지는 CORS 헤더 없이는 못 읽는다. 느리다.
|
||||
**판정**: 레거시 유지보수가 아니면 SnapDOM 또는 html-to-image로 갈아타라.
|
||||
|
||||
### F. `CSS3DRenderer` — **"3D 배치"만 필요하면 이게 정답**
|
||||
three.js 애드온. 실제 DOM 요소에 `matrix3d`를 걸어 3D 공간에 배치한다.
|
||||
- **장점**: 100% 진짜 DOM. 상호작용·접근성·폰트 렌더링 완벽. 전 브라우저. 가볍다.
|
||||
- **결정적 한계**: 요소는 **DOM 합성 레이어**로 남으므로 **WebGL 씬과 진짜로 섞이지 않는다.** 깊이 테스트, 오클루전, 셰이더 왜곡, 조명, 그림자, 후처리 어느 것도 적용할 수 없다. 항상 WebGL 캔버스 위/아래 별도 레이어다.
|
||||
- 즉 **평면을 3D로 눕히는 것까지는 되지만, 천이 구겨지거나 유리에 굴절되게는 못 한다.**
|
||||
|
||||
**판정**: "카드를 3D로 기울인다" → CSS3DRenderer. "카드를 물결처럼 왜곡한다" → 불가.
|
||||
|
||||
### G. `backdrop-filter` + SVG `feDisplacementMap` — **Liquid Glass 폴백의 정석**
|
||||
`filter` 프로퍼티로 SVG 필터를 참조해 배경 레이어를 굴절시킨다. `feDisplacementMap`이 두 번째 이미지의 R/G 채널로 첫 이미지를 공간 변위시키므로, 높이장/법선/스넬 법칙 기반 변위 맵을 만들면 진짜 굴절처럼 보인다.
|
||||
|
||||
**브라우저 현실 (2026 기준):**
|
||||
- **Chromium(Chrome/Edge/Brave/Arc)**: `backdrop-filter: url(#filter)` 동작 → 진짜 굴절
|
||||
- **Safari / Firefox**: `backdrop-filter`에 SVG 필터 URL을 **지원하지 않는다.** GPU 가속 안정성 때문에 내장 CSS 필터 함수로 제한. → **자동으로 블러 글래스모피즘으로 degrade** (별도 코드 불필요)
|
||||
- 상호운용 표준화 논의 진행 중: [w3c/svgwg#1142](https://github.com/w3c/svgwg/issues/1142) "define interoperable backdrop displacement/refraction for 'liquid glass' UI"
|
||||
|
||||
**판정**: 영상의 "Apple Liquid Glass" 인상만 필요하다면 **HTML-in-Canvas 없이 CSS+SVG로 상당 부분 재현 가능**하고, 브라우저 지원 범위도 오히려 넓다. 다만 "유리 아래 콘텐츠가 마우스를 따라 실시간으로 출렁이며 색수차까지" 수준은 셰이더가 필요하다.
|
||||
|
||||
### H. View Transitions API — **"상태 A → 상태 B" 전환 한정**
|
||||
- `document.startViewTransition()` 이 전/후 스냅샷을 만들고 `::view-transition-*` 의사 요소를 CSS로 애니메이션한다.
|
||||
- **Matt Rothenberg의 정확한 대비**: "View Transitions는 **두 개의 스냅샷**을 clip-path와 opacity로 애니메이션한다. (HTML-in-Canvas는) **두 개의 라이브 렌더**와 셰이더를 준다."
|
||||
- 즉 불타는 다크모드 전환의 **타이밍과 구조**는 View Transitions로 잡되, **불꽃의 픽셀 단위 시뮬레이션**은 포기하고 CSS `mask-image`(노이즈 PNG/SVG) 애니메이션으로 근사하는 게 현실적인 폴백이다. `mask-image` + `mask-position` 애니메이션으로 "타들어가는 마스크"는 꽤 그럴듯하게 나온다.
|
||||
|
||||
### I. 배경 캔버스 오버레이 — **가장 흔한 오해**
|
||||
"어차피 HTML 뒤에 canvas 깔면 되는 거 아냐?"에 대한 Matt Rothenberg의 답:
|
||||
|
||||
> **"배경 캔버스는 그릴 수는 있어도 읽을 수는 없다(A background canvas can draw. It can't read.)"**
|
||||
|
||||
이 방식으로 **불가능한 것 3가지:**
|
||||
1. **픽셀 단위 왜곡** — CSS `transform`은 요소 박스 전체에 걸린다. div를 회전/스케일/스큐할 수는 있어도, 그 안에 렌더된 **텍스트를 배럴 왜곡**하거나 **input의 아래쪽 절반만 압축**할 수는 없다.
|
||||
2. **두 HTML 상태의 커스텀 블렌딩** — 라이트/다크 테마를 동시에 텍스처로 올려 노이즈·불·스캔라인으로 픽셀 단위 합성.
|
||||
3. **렌더된 콘텐츠에 반응** — 셰이더가 픽셀 휘도를 읽어 어두운 픽셀과 밝은 픽셀을 다르게 처리하거나, 렌더된 HTML의 엣지를 검출해 **바운딩 박스가 아니라 콘텐츠의 실제 모양을 따라가는** 효과.
|
||||
|
||||
**판정**: 글로우, 파티클, 커서 트레일, 배경 그라디언트 같은 **"HTML을 읽을 필요 없는" 효과라면 이 방식으로 충분하고 훨씬 싸다.** 실제로 많은 "화려한" 사이트가 이 정도로 만족한다.
|
||||
|
||||
### J. Satori 등 서버 렌더
|
||||
OG 이미지 생성처럼 **결과가 이미지여도 되는** 경우. 지원 CSS 부분집합이 제한적이지만 서버에서 안정적으로 돈다. 브라우저 인터랙션 효과의 폴백으로는 부적합.
|
||||
|
||||
---
|
||||
|
||||
## 3. 목표별 권장 조합
|
||||
|
||||
| 만들고 싶은 것 | 네이티브 있을 때 | 폴백 |
|
||||
|---|---|---|
|
||||
| **3D 씬 안의 상호작용 UI** (영상의 천 위 포트폴리오, 3D 책) | `THREE.HTMLTexture` + `InteractionManager` | **`three-html-render` 폴리필** (동일 코드, 자동 전환). 3D 배치만 필요하면 `CSS3DRenderer` |
|
||||
| **Liquid Glass / 굴절 오버레이** | WebGL 굴절 셰이더 + `drawElementImage` | **`backdrop-filter` + SVG `feDisplacementMap`** (Chromium 굴절, Safari/FF 블러 degrade) |
|
||||
| **다크모드 불타는 전환** | 두 텍스처 + FBM 셰이더 | **View Transitions + CSS `mask-image` 노이즈 애니메이션** |
|
||||
| **폼 포커스 글로우 / 배경 반응** | 셰이더 한 패스에서 글로우+콘텐츠 합성 | **배경 캔버스 오버레이** (I). 글로우가 폼 *뒤*, 도트 *앞*에 오는 레이어링만 포기 |
|
||||
| **버튼 리플 / 클릭 시 산산조각** | `drawElementImage` + 픽셀 조작 | **SnapDOM으로 1회 스냅샷** → 요소 숨김 → 파티클 애니메이션 |
|
||||
| **HTML → 이미지/영상 내보내기** | `drawElementImage` + `canvas.captureStream()` | **SnapDOM** (클라이언트) 또는 **Satori/Puppeteer** (서버) |
|
||||
| **캔버스 앱의 리치 텍스트 UI** (Figma/Docs류) | `drawElementImage` | 현행 유지 (DOM 오버레이 또는 자체 텍스트 레이아웃) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 실전 폴백 패턴 — CanvasUI 모델
|
||||
|
||||
`canvasui.dev`(David Haz)가 채택한 원칙이 가장 건전하다:
|
||||
|
||||
> "컴포넌트가 **런타임에 지원 여부를 감지하고 우아하게 degrade한다**. API가 없으면 콘텐츠는 그냥 일반 HTML로 렌더되고, 그래도 돌 수 있는 이펙트 부분은 계속 돈다."
|
||||
|
||||
구현 형태:
|
||||
|
||||
```js
|
||||
const HAS_HIC = 'requestPaint' in HTMLCanvasElement.prototype;
|
||||
|
||||
if (HAS_HIC) {
|
||||
canvas.setAttribute('layoutsubtree', '');
|
||||
mountShaderEffect(canvas); // 셰이더 왜곡 전체 경로
|
||||
} else {
|
||||
canvas.replaceWith(...canvas.childNodes); // HTML 을 문서로 승격
|
||||
mountCssOnlyEffect(container); // backdrop-filter / transition / mask 로 근사
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 설계 규칙 3가지:**
|
||||
1. **HTML을 먼저 쓰고 캔버스를 나중에 씌운다.** 캔버스가 없어도 페이지가 완성되어 있어야 한다.
|
||||
2. **캔버스는 장식이지 구조가 아니다.** 레이아웃·포커스 순서·읽기 순서는 전부 HTML이 결정한다.
|
||||
3. **효과의 "의미"와 "구현"을 분리한다.** "제출 시 폼이 사라진다"는 의미는 셰이더 왜곡으로도, CSS `scale`+`opacity`로도 표현된다. 폴백은 열화판이지 부재가 아니어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 비용·성능 메모
|
||||
|
||||
- **A(네이티브)**: 스냅샷은 브라우저 내부 디스플레이 리스트에서 나오므로 직렬화·디코딩이 없다. 다만 explainer가 경고하듯 **캔버스 안 콘텐츠는 컴포지터 스레드 스크롤/애니메이션 혜택을 잃고 JS에 묶인다.** 캔버스 안에 스크롤 영역을 넣기보다 캔버스 전체를 스크롤시켜라.
|
||||
- **B/C/D(foreignObject)**: 매 캡처마다 DOM 직렬화 → SVG 파싱 → 이미지 디코드. 60fps 지속 갱신에는 부적합. **무효화 시점에만 재캡처**하도록 설계할 것.
|
||||
- **E(html2canvas)**: 가장 느리다. 신규 채택 근거 없음.
|
||||
- **F(CSS3DRenderer)**: DOM 합성 레이어라 저렴하지만, 요소가 많으면 레이어 폭발.
|
||||
- **G(backdrop-filter)**: GPU 가속이지만 큰 영역에 걸면 비싸다. Safari/FF가 SVG 필터를 backdrop에 안 붙이는 이유가 정확히 이것(GPU 사용량·불안정성).
|
||||
- **I(배경 캔버스)**: 가장 싸다. HTML을 읽을 필요가 없다면 이걸 먼저 검토하라.
|
||||
107
research/canvas/05-sources.md
Normal file
107
research/canvas/05-sources.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# 05. 출처 목록
|
||||
|
||||
> 조사일 2026-08-20. ★ = 1차 출처(사양·구현·공식 발표). 나머지는 2차/커뮤니티.
|
||||
> 총 60개.
|
||||
|
||||
---
|
||||
|
||||
## A. 사양 · 제안 (1차)
|
||||
|
||||
1. ★ https://github.com/WICG/html-in-canvas — WICG 공식 저장소. 이 문서 전체의 기준이 되는 living explainer(README.md)가 여기 있다.
|
||||
2. ★ https://github.com/WICG/html-in-canvas/blob/main/README.md — explainer 원문. `layoutsubtree`, `drawElementImage`, `paint` 이벤트, `captureElementImage`, 전체 IDL, read-back-allowed rendering 목록, 설계 대안 논의.
|
||||
3. ★ https://wicg.github.io/html-in-canvas/ — explainer의 GitHub Pages 렌더링본. 형식 스펙이 아니라 explainer 그 자체(정규 알고리즘 없음).
|
||||
4. ★ https://github.com/WICG/html-in-canvas/blob/main/security-privacy-questionnaire.md — W3C 보안·프라이버시 자기점검 답변 19문항. 무엇이 그려지지 않는지의 근거.
|
||||
5. ★ https://github.com/whatwg/html/pull/11588 — WHATWG HTML 스펙 PR "Add HTML-in-Canvas APIs" (2025-08-21 개설, **미머지**). GC 압박, 교차 캔버스 `ElementImage`, 중첩 canvas 구현 불일치 등 미해결 논의.
|
||||
6. ★ https://github.com/whatwg/html/issues/10650 — 원 이슈 스레드. 웹 개발자 긍정 신호의 출처.
|
||||
7. https://github.com/WICG/html-in-canvas/commits/main/README.md — 커밋 로그. `drawElement`→`drawHTMLElement`→`drawHTML`→`drawElementImage` 개명 이력과 `setHitTestRegions` 폐기 시점의 근거.
|
||||
8. https://github.com/w3ctag/design-reviews/issues/1204 — W3C TAG 디자인 리뷰.
|
||||
|
||||
## B. Chromium 출시 프로세스 (1차)
|
||||
|
||||
9. ★ https://chromestatus.com/feature/5172548013916160 — Chrome Platform Status "HTML-in-canvas". 상태 `In development`, 표준 성숙도, Gecko/WebKit/개발자 신호, 소유자, 동기.
|
||||
10. ★ https://chromestatus.com/api/v0/features/5172548013916160 — 위 항목의 원시 JSON. desktop/android/webview 출시 마일스톤이 모두 `null`임을 확인.
|
||||
11. ★ https://developer.chrome.com/blog/html-in-canvas-origin-trial — Chrome for Developers 공식 블로그 (Thomas Nattestad, Natalia Markoborodova, 최종수정 2026-05-19). OT 안내, 3단계 사용법, WebGL/WebGPU 코드, `getElementTransform` 행렬 유도, 한계.
|
||||
12. ★ https://developer.chrome.com/release-notes/148 — Chrome 148 릴리스 노트. Stable **2026-05-05**, HTML-in-canvas가 Origin Trials 섹션에 등재.
|
||||
13. ★ https://developer.chrome.com/origintrials/#/view_trial/3478467762190286849 — Origin Trial 등록 페이지 (열람에 로그인 필요).
|
||||
14. ★ https://groups.google.com/a/chromium.org/g/blink-dev/c/t_nGEmJ_v4s — **Intent to Experiment: HTML-in-canvas**. OT M148–M151, chromestatus/스펙 링크, Gecko·WebKit 입장, Jake Archibald의 "WebGL에서 텍스처 크기 지정 불가" 지적.
|
||||
15. ★ http://www.mail-archive.com/blink-dev@chromium.org/msg16735.html — **Intent to Extend Experiment**. DevTrial M138 시작, OT 데스크톱 M148–150 → **M154 연장**, 연장 사유("상당한 피드백 + WebGL/WebGPU·프라이버시 중대 변경").
|
||||
16. ★ http://www.mail-archive.com/blink-dev@chromium.org/msg16743.html — 위 연장 요청에 대한 **Mike Taylor LGTM (2026-06-11)**.
|
||||
17. ★ https://groups.google.com/a/chromium.org/g/blink-dev/c/LYJyOdLbOfY — "Ready for Developer Testing: HTML in Canvas: drawElement". 구 메서드명 시절의 DevTrial 공지.
|
||||
18. ★ https://crbug.com/500967896 — Chromium 추적 버그 (`Blink>Canvas`).
|
||||
19. ★ https://chromiumdash.appspot.com/schedule — Chrome 마일스톤 공식 스케줄. M148=2026-05-05, M150=2026-06-30, M152=2026-08-25, M154=**2026-09-22**, M155=2026-10-06.
|
||||
20. https://groups.google.com/g/html-in-canvas-developer-newsletter — Chrome 팀 운영 개발자 뉴스레터(변경 공지 채널).
|
||||
|
||||
## C. 타 브라우저 입장 (1차)
|
||||
|
||||
21. ★ https://github.com/mozilla/standards-positions/issues/1076 — Mozilla 입장. **open, "Needs proposed position"**. 2024-09-25 개설, Graphics 팀 배정. 공식 입장 미표명 = "No signal".
|
||||
22. ★ https://github.com/WebKit/standards-positions/issues/630 — WebKit 입장. **open, 라벨 없음 = "No signal"**. 이전 제안(canvas place element #403)의 retained-mode 우려로 immediate-mode 설계 회귀했다는 배경 기재.
|
||||
|
||||
## D. 공식 예제 · 데모 (1차)
|
||||
|
||||
23. ★ https://wicg.github.io/html-in-canvas/Examples/complex-text.html — 회전된 복합 텍스트(RTL·세로쓰기·이모지·인라인 이미지·SVG) 2D 데모. `ctx.rotate` + `drawElementImage` + `requestPaint()` 킥스타트 패턴.
|
||||
24. ★ https://wicg.github.io/html-in-canvas/Examples/text-input.html — 캔버스 안 완전 동작 폼(체크박스·라디오·range·submit). "Spaceship Control Panel".
|
||||
25. ★ https://wicg.github.io/html-in-canvas/Examples/pie-chart.html — 멀티라인 라벨 파이 차트. 접근성 개선 유스케이스의 대표 예.
|
||||
26. ★ https://wicg.github.io/html-in-canvas/Examples/webGL.html — `texElementImage2D`로 3D 큐브에 HTML. **신/구 시그니처 try/catch 패턴의 원본**.
|
||||
27. ★ https://wicg.github.io/html-in-canvas/Examples/webgpu-jelly-slider/ — **영상의 젤리 슬라이더 원본**. `copyElementImageToTexture`의 현재 dictionary 시그니처 실사용 예 (소스: `Examples/webgpu-jelly-slider/src/index.ts`).
|
||||
28. ★ https://chrome.dev/html-in-canvas/ — Chrome 팀 공식 데모 갤러리 (3D 빌보드, Tokyo 3D 라벨, 3D 책, Fluid Prism, D3 시각화, OffscreenCanvas, iframe 등).
|
||||
29. ★ https://github.com/GoogleChromeLabs/css-web-ui-demos/blob/main/html-in-canvas/awesome-html-in-canvas.md — **"Awesome HTML-in-Canvas"** 커뮤니티 데모·프레임워크 큐레이션 목록.
|
||||
|
||||
## E. 영상에 나온 데모의 원본
|
||||
|
||||
30. https://arrival.space/htmlcanvas — **영상의 "천 위 포트폴리오"**. 게임 안에 걸린 천에 폼이 그려지고 캐릭터가 부딪히는 데모. 작성자 **Thomas Richter-Trummer (@fimbox)**. 소스: https://github.com/fimbox/html-in-canvas/blob/main/plugins/html-cloth.mjs
|
||||
31. https://mattrothenberg.com/notes/html-in-canvas/ — **Matt Rothenberg (2026-04-04)**. 영상의 **"불타는 다크모드 전환"(Demo 3: The Burn Transition)** 과 **"폼 포커스 글로우 + 제출 시 왜곡"(Demo 1: The Focus Ring)** 의 원본. "배경 캔버스는 그릴 수는 있어도 읽을 수는 없다" 논증, 5구역 화염 셰이더 구조, 실전 함정 6가지.
|
||||
32. https://html-in-canvas.dev/demos/liquid-glass/ — **Liquid Glass Distortion** (En Dash Consulting, 2026-04-10 작성 / 2026-08-19 갱신). 굴절·색수차·프레넬·코스틱 셰이더 + 라이브 DOM.
|
||||
33. https://github.com/jeantimex/liquid-glass-html-in-canvas — 또 다른 Liquid Glass 구현 (WebGL).
|
||||
34. https://github.com/jeantimex/glass-effect-webgpu — WebGPU 기반 실시간 리퀴드 글래스 렌더러.
|
||||
35. https://compiz-web.vercel.app/ — Max Leiter. 셰이더 기반 페이지 전환(Compiz 오마주). 소스: https://github.com/MaxLeiter/compiz-web
|
||||
36. https://x.com/wesbos/status/2041594973674483851 — Wes Bos "Duck Hunt TODO" (폼이자 슈팅 게임). 소스: https://github.com/wesbos/hot-tips/blob/main/html-in-canvas/demos/wicg/website-shatter-shooter.html
|
||||
37. https://x.com/wesbos/status/2041974552478052507 — Wes Bos "Wobble Buttons" (리플 버튼).
|
||||
38. https://html-in-canvas.vittoretrivi.dev/examples/vanish-input — 영상의 "제출하면 입력이 사라지는" 효과 원본. 같은 저자의 login / page-curl / basic-ui 예제도 참조. 소스: https://github.com/motiontx/html-in-canvas
|
||||
|
||||
## F. 데모 · 컴포넌트 모음
|
||||
|
||||
39. https://html-in-canvas.dev/ + https://html-in-canvas.dev/demos/ — 비공식이지만 가장 잘 정리된 레퍼런스 사이트. 17개 데모(2D/WebGL/WebGPU), Hello World부터 OffscreenCanvas 워커까지. 작성: En Dash.
|
||||
40. https://hicshowroom.com/ — HiC Showroom. 각 이펙트가 독립 웹 컴포넌트로 되어 있어 한 줄 import로 삽입 가능.
|
||||
41. https://canvasui.dev/ + https://canvasui.dev/docs — **CanvasUI (David Haz)**. 40+ 이펙트 컴포넌트, React/Solid/Preact/Vue/Svelte/vanilla TS, shadcn 레지스트리 방식. **런타임 지원 감지 + graceful degradation 모델**의 참고 사례.
|
||||
42. https://pixijs-html-in-canvas.vercel.app — Zyie. PixiJS 기반 "HTML Laser" 랜딩 페이지(부서졌다 복원).
|
||||
43. https://vav-labs.com/case-studies/quest-signal/ — Vav Labs. Godot 씬에 접근성 있는 DOM 패널을 월드스페이스 WebGL 텍스처로.
|
||||
|
||||
## G. 프레임워크 통합 (1차)
|
||||
|
||||
44. ★ https://threejs.org/docs/#api/en/textures/HTMLTexture — three.js `HTMLTexture` 공식 문서.
|
||||
45. ★ https://threejs.org/examples/webgl_materials_texture_html.html — three.js 공식 예제. `HTMLTexture` + `InteractionManager` + 폴리필 자동 전환의 완성 코드.
|
||||
46. ★ https://github.com/mrdoob/three.js/pull/31233 — HTMLTexture 도입 PR. **dev 브랜치 2026-04-10 머지, r184 포함.** WebGLRenderer/WebGPURenderer 양쪽 지원, `matrix3d` 자동 계산, 레이캐스팅 불필요.
|
||||
47. ★ https://raw.githubusercontent.com/mrdoob/three.js/dev/src/textures/HTMLTexture.js — `HTMLTexture` 구현 원문(50줄). `parent.onpaint`로 `needsUpdate` 세우고 `parent.requestPaint()` 킥스타트.
|
||||
48. ★ https://developer.playcanvas.com/user-manual/graphics/advanced-rendering/html-in-canvas/ — PlayCanvas 공식 문서. `device.supportsHtmlTextures`, `texture.setSource(el)`. **WebGL 백엔드만, WebGPU 대기 중.**
|
||||
49. https://playcanvas.vercel.app/#/misc/html-texture — PlayCanvas 데모.
|
||||
50. https://pixijs.download/release/docs/rendering.HTMLSource.html — PixiJS `HTMLSource` API 문서.
|
||||
51. https://doc.babylonjs.com/features/featuresDeepDive/materials/using/htmlTexture/ — Babylon.js HTML Texture 문서. 플레이그라운드: https://playground.babylonjs.com/#8RDVXG#1
|
||||
52. https://www.remotion.dev/docs/transitions/presentations/custom-html-in-canvas — Remotion의 HTML-in-Canvas 트랜지션 프레젠테이션.
|
||||
|
||||
## H. 폴백 · 폴리필
|
||||
|
||||
53. ★ https://github.com/repalash/three-html-render — **HTML-in-Canvas 폴리필**. `installHtmlInCanvasPolyfill()`이 `requestPaint`/`captureElementImage`/`drawElementImage`/`texElementImage2D`/`copyElementImageToTexture`를 전부 제공. 내부는 `foreignObject` 래스터화 + `matrix3d` DOM 오버레이. 네이티브 있으면 fast path. MIT. 한계 목록(textarea 스크롤, contenteditable 캐럿, `:visited` 불가) 포함.
|
||||
54. https://snapdom.dev/docs/ — SnapDOM. `foreignObject` 직렬화 + 네이티브 래스터화. html2canvas 대비 2~16배 빠르고 CSS 충실도가 훨씬 높다.
|
||||
55. https://dev.to/tinchox5/why-snapdom-beats-html2canvas-for-dom-to-image-capture-14ch — SnapDOM vs html2canvas 원리 비교. html2canvas가 "브라우저 렌더러를 JS로 재구현"하는 방식이라 미지원 CSS 목록이 긴 이유.
|
||||
56. https://npm-compare.com/dom-to-image,html-to-image,html2canvas — dom-to-image / html-to-image / html2canvas 비교.
|
||||
57. https://kube.io/blog/liquid-glass-css-svg/ — CSS + SVG만으로 굴절(refraction) 구현. `feDisplacementMap` 변위 맵 생성 방법.
|
||||
58. https://blog.logrocket.com/how-create-liquid-glass-effects-css-and-svg/ — `backdrop-filter` + SVG 필터 조합과 **Safari/Firefox의 `backdrop-filter: url()` 미지원**, 자동 블러 degrade.
|
||||
59. https://github.com/w3c/svgwg/issues/1142 — "Filter Effects: define interoperable backdrop displacement/refraction for 'liquid glass' UI". 이 폴백을 표준화하려는 진행 중 논의.
|
||||
60. https://threejs.org/docs/#examples/en/renderers/CSS3DRenderer — three.js `CSS3DRenderer`. 진짜 DOM을 3D 배치하지만 WebGL 씬과 깊이·셰이더로 섞이지 않는다.
|
||||
|
||||
## I. 해설 기사 (2차, 실전 노트로 유용)
|
||||
|
||||
61. https://tympanus.net/codrops/2026/05/13/exploring-the-html-in-canvas-proposal/ — Codrops, Vittorio Retrivi (2026-05-13). React Three Fiber + `HTMLTexture` + CRT 셰이더 완성 코드, 전체 화면 후처리가 접근성을 훼손할 수 있다는 지적.
|
||||
62. https://frontendmasters.com/blog/the-web-is-fun-again-first-experiments-with-html-in-canvas/ — Frontend Masters, Amit Sheen (2026-04-21). 픽셀 조작 데모 10종, "크기/리사이즈가 유일하게 덜 익은 부분", `requestPaint()` 킥스타트 필수, `<canvas>`가 div처럼 동작하지 않는 문제.
|
||||
63. https://biggo.com/news/202508030712_Chrome_HTML-in-Canvas_Security_Concerns — 개발자 커뮤니티의 보안·핑거프린팅 우려 정리. **구 API명(`drawElement`, `texElement2D`, `setHitTestRegions`)이 쓰인 시점의 기사**라 명칭 대조용으로 유용.
|
||||
64. https://www.equero.dev/posts/html-in-canvas-wicg-proposal-drawelementimage/ — Enrique Quero. `drawElementImage`가 `foreignObject` 해킹과 html2canvas를 사실상 대체할 것이라는 관점.
|
||||
65. https://imiel.dev/blog/html-in-canvas-wicg-drawelementimage-guide — Imiel Visser. "OffscreenCanvas 이후 최대의 Canvas API 추가"라는 관점의 가이드.
|
||||
66. https://maximov.by/html-in-canvas-guide.html — 실용 가이드. 플래그 활성화, 성능 주의사항.
|
||||
67. https://azukiazusa.dev/en/blog/html-in-canvas-api/ — 일본어권 해설(영문판). API 개요 정리.
|
||||
68. https://flaviocopes.com/canvas-ui/ — Flavio Copes. CanvasUI 소개.
|
||||
69. https://dev.to/manikant92/google-io-2026-quietly-ended-a-20-year-old-web-problem-meet-the-html-in-canvas-api-4h9d — Google I/O 2026 맥락에서의 소개.
|
||||
70. https://developer.chrome.com/docs/modern-web-guidance — Chrome의 AI 코딩 도구용 최신 웹 가이던스(HTML-in-Canvas 항목 포함). 저장소: https://github.com/GoogleChrome/guidance
|
||||
|
||||
## J. 참고 (영상 원본)
|
||||
|
||||
71. `D:\workspace\designpaca\research\youtube\nomad-html.en.srt` / `nomad-html.ko.srt` — 노마드코더 "이게 진짜 HTML이라고?" 자막 원문. 챕터: 0:00 인트로 / 2:30 왜 불가능했나 / 2:51 작동 원리 / 3:52 직접 만들기(반사 버튼) / 6:03 아직 쓸 수 없다.
|
||||
36
research/canvas/_raw/awesome.md
Normal file
36
research/canvas/_raw/awesome.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Awesome HTML-in-Canvas
|
||||
|
||||
This is a collection of resources to help developers build with HTML-in-Canvas.
|
||||
|
||||
Check out the HTML-in-canvas deployed at [chrome.dev](https://chrome.dev/html-in-canvas/) or view the source code [here](https://github.com/GoogleChromeLabs/css-web-ui-demos/tree/main/html-in-canvas).
|
||||
|
||||
## HTML-in-Canvas demos by the ecosystem
|
||||
This is a curated list of links to awesome HTML-in-canvas demos created by the ecosystem. Note that the demos featured here are contributed by third-party developers and are not created or maintained by Google. Read the [contribution guidelines](https://github.com/GoogleChromeLabs/css-web-ui-demos/blob/main/CONTRIBUTING.md#add-a-demo-to-the-awesome-html-in-canvas-list) to suggest another demo.
|
||||
|
||||
| Demo | Description | Author | Source code |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| [Duck Hunt TODO](https://x.com/wesbos/status/2041594973674483851) | A form that's also a shooting game | [Wes Bos](https://github.com/wesbos) | [Source](https://github.com/wesbos/hot-tips/blob/main/html-in-canvas/demos/wicg/website-shatter-shooter.html) |
|
||||
| [Wobble Buttons](https://x.com/wesbos/status/2041974552478052507) | Interactive ripple-effect buttons | [Wes Bos](https://github.com/wesbos) | [Source](https://github.com/wesbos/hot-tips/blob/main/html-in-canvas/demos/wicg/ripple-buttons.html) |
|
||||
| [Compiz Web](https://compiz-web.vercel.app/) | Shader-driven web page transitions demo | [Max Leiter](https://github.com/MaxLeiter) | [Source](https://github.com/MaxLeiter/compiz-web) |
|
||||
| [HTML cloth](https://arrival.space/htmlcanvas) | Customize a form on a hanging cloth inside a game | [Thomas Richter-Trummer](https://github.com/fimbox) | [Source](https://github.com/fimbox/html-in-canvas/blob/main/plugins/html-cloth.mjs) |
|
||||
| [PixiJS HTML Laser](https://pixijs-html-in-canvas.vercel.app) | Interactive landing page that shatters and heals over time | [Zyie](https://github.com/Zyie) | [Source](https://github.com/Zyie/pixijs-html-in-canvas) |
|
||||
| [Quest Signal](https://vav-labs.com/case-studies/quest-signal/) | A playable Godot scene with accessible, interactive DOM panels rendered as world-space WebGL textures | [Vav Labs](https://vav-labs.com/) | [Source](https://github.com/Vav-Labs/quest-signal) |
|
||||
| More | demos | coming | soon... |
|
||||
|
||||
## Framework Support
|
||||
This is a list of frameworks that have added support for HTML-in-Canvas along with the documentation
|
||||
| Framework | Description | Documentation | Sample Code |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| [Three.js](https://threejs.org/) | JavaScript library used to create and display animated 3D computer graphics with WebGL & WebGPU | [HTMLTexture](https://goo.gle/HIC-threejs) | [Sample](https://goo.gle/HIC-threejs-example) |
|
||||
| [PlayCanvas](https://playcanvas.com/) | Open source engine and tools for building amazing 3D experiences | [html-texture](https://goo.gle/HIC-playcanvas) | [Sample](https://goo.gle/HIC-playcanvas-example) |
|
||||
| [PixiJS](https://pixijs.com/) | Fast, lightweight 2D rendering library for WebGL & WebGPU | [HTMLSource](https://pixijs.download/release/docs/rendering.HTMLSource.html) | [Sample](https://pixijs-html-in-canvas.vercel.app/) |
|
||||
| [Babylon.js](https://babylonjs.com/) | Babylon.js: Powerful, Beautiful, Simple, Open 3D engine for the web | [HTML Texture](https://doc.babylonjs.com/features/featuresDeepDive/materials/using/htmlTexture/) | [Sample](https://playground.babylonjs.com/#8RDVXG#1) |
|
||||
| [CanvasUI](https://canvasui.dev/) | An open source library of tasteful html-in-canvas & WebGL components. | [Introduction](https://canvasui.dev/docs) | [Sample](https://canvasui.dev/docs/components/bend) |
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**Important note on external content**: The demos linked in the [HTML-in-Canvas demos by the ecosystem](#html-in-canvas-demos-by-the-ecosystem) section are created by third-party developers and are not created, maintained, or supported by Google. Please be aware of the following:
|
||||
|
||||
* No endorsement: Inclusion of these links does not constitute an endorsement or recommendation by Google.
|
||||
* Subject to change: Content, functionality, and availability are at the sole discretion of the third-party owners and may change or be removed without notice.
|
||||
* No liability: Google assumes no responsibility or liability for the accuracy, legality, or performance of these demos.
|
||||
50
research/canvas/_raw/ex-complex-text.html
Normal file
50
research/canvas/_raw/ex-complex-text.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Demo of complex text in canvas</title>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
border: 1px solid blue;
|
||||
width: 638px;
|
||||
height: 318px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas id="canvas" width="638" height="318" layoutsubtree="true">
|
||||
<div id="draw_element" style="width: 550px;">
|
||||
Hello from <a href="https://github.com/WICG/html-in-canvas">html-in-canvas</a>!
|
||||
<br>I'm multi-line, <b>formatted</b>,
|
||||
rotated text with emoji (😀), RTL text
|
||||
<span dir=rtl>من فارسی صحبت میکنم</span>,
|
||||
vertical text,
|
||||
<p style="writing-mode: vertical-rl;">
|
||||
这是垂直文本
|
||||
</p>
|
||||
an inline image (<img width="150" src="wolf.jpg">), and
|
||||
<svg width="50" height="50">
|
||||
<circle cx="25" cy="25" r="20" fill="green" />
|
||||
<text x="25" y="30" font-size="15" text-anchor="middle" fill="#fff">
|
||||
SVG
|
||||
</text>
|
||||
</svg>!
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.onpaint = (event) => {
|
||||
ctx.reset();
|
||||
ctx.rotate((15 * Math.PI) / 180);
|
||||
ctx.translate(80 * devicePixelRatio, -20 * devicePixelRatio);
|
||||
let transform = ctx.drawElementImage(draw_element, 0, 0);
|
||||
draw_element.style.transform = transform.toString();
|
||||
};
|
||||
canvas.requestPaint(); // Request an initial paint event.
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
87
research/canvas/_raw/ex-pie-chart.html
Normal file
87
research/canvas/_raw/ex-pie-chart.html
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Pie chart</title>
|
||||
|
||||
<style>
|
||||
.pie {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
}
|
||||
.pie .label {
|
||||
text-align: center;
|
||||
max-width: 40%;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
.pie .label .val {
|
||||
display: block;
|
||||
font-size: xx-large;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas layoutsubtree class="pie" role="list" aria-label="Pie Chart">
|
||||
<div class="label" role="listitem" tabindex="0" data-val="0.45" data-color="tomato">
|
||||
<span class="val">45%</span>Apple
|
||||
</div>
|
||||
<div class="label" role="listitem" tabindex="0" data-val="0.35" data-color="cornflowerblue">
|
||||
<span class="val">35%</span>Blackberry / Bramble
|
||||
</div>
|
||||
<div class="label" role="listitem" tabindex="0" data-val="0.20" data-color="gold">
|
||||
<span class="val">20%</span>Durian
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const canvas = document.querySelector('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.onpaint = () => {
|
||||
ctx.reset();
|
||||
|
||||
// 1. Center the coordinate system.
|
||||
const radius = 0.95 * Math.min(canvas.width, canvas.height) / 2;
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
|
||||
let angle = 0;
|
||||
let focusedPath = null;
|
||||
for (const label of canvas.children) {
|
||||
const slice = Number(label.dataset.val) * Math.PI * 2;
|
||||
|
||||
// 2. Draw the wedge.
|
||||
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
|
||||
grad.addColorStop(0, `color-mix(${label.dataset.color}, white 40%)`);
|
||||
grad.addColorStop(1, label.dataset.color);
|
||||
ctx.fillStyle = grad;
|
||||
const path = new Path2D();
|
||||
path.moveTo(0, 0);
|
||||
path.arc(0, 0, radius, angle, angle + slice);
|
||||
path.closePath();
|
||||
ctx.fill(path);
|
||||
if (document.activeElement === label)
|
||||
focusedPath = path;
|
||||
|
||||
// 3. Draw the label element, and update its transform.
|
||||
const mid = angle + slice / 2;
|
||||
const label_width = label.offsetWidth * devicePixelRatio;
|
||||
const label_height = label.offsetHeight * devicePixelRatio;
|
||||
const x = Math.cos(mid) * radius * 0.60 - label_width / 2;
|
||||
const y = Math.sin(mid) * radius * 0.60 - label_height / 2;
|
||||
const transform = ctx.drawElementImage(label, x, y);
|
||||
label.style.transform = transform;
|
||||
|
||||
angle += slice;
|
||||
}
|
||||
|
||||
// 4. Draw the focus ring on top of everything else.
|
||||
if (focusedPath)
|
||||
ctx.drawFocusIfNeeded(focusedPath, document.activeElement);
|
||||
};
|
||||
canvas.requestPaint(); // Request an initial paint event.
|
||||
|
||||
// Setup a resize observer to resize the canvas in response to dpr changes.
|
||||
new ResizeObserver(([entry]) => {
|
||||
const box = entry.devicePixelContentBoxSize[0];
|
||||
canvas.width = box.inlineSize;
|
||||
canvas.height = box.blockSize;
|
||||
}).observe(canvas, {box: ['device-pixel-content-box']});
|
||||
</script>
|
||||
68
research/canvas/_raw/ex-text-input.html
Normal file
68
research/canvas/_raw/ex-text-input.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Demo of interactive content in canvas</title>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
border: 1px solid blue;
|
||||
width: 638px;
|
||||
height: 318px;
|
||||
}
|
||||
form p {
|
||||
margin: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas id="canvas" width="638" height="318" layoutsubtree="true">
|
||||
<div id=draw_element style="width: 578px" >
|
||||
<form id="demo-form" action="#" method="get">
|
||||
<fieldset>
|
||||
<legend>🚀 Spaceship Control Panel</legend>
|
||||
<p>
|
||||
<label for="shipName">Ship Name:</label>
|
||||
<input type="text" id="shipName" value="The 'Canvas' Voyager">
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" id="hyperdrive" checked>
|
||||
<label for="hyperdrive">Engage Hyperdrive</label>
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>Target System</legend>
|
||||
<p>
|
||||
<input type="radio" id="alpha" name="system" value="alpha" checked>
|
||||
<label for="alpha">Alpha Centauri</label>
|
||||
</p>
|
||||
<p>
|
||||
<input type="radio" id="beta" name="system" value="beta">
|
||||
<label for="beta">Betelgeuse</label>
|
||||
</p>
|
||||
</fieldset>
|
||||
<p>
|
||||
<label for="shieldLevel">Shield Strength:</label>
|
||||
<input type="range" id="shieldLevel" min="0" max="100" value="75">
|
||||
</p>
|
||||
<p style="text-align: right; margin: 0;">
|
||||
<button type="submit">Launch!</button>
|
||||
</p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.onpaint = (event) => {
|
||||
ctx.reset();
|
||||
let x = canvas.width / 25;
|
||||
let y = canvas.height / 25;
|
||||
let transform = ctx.drawElementImage(draw_element, x, y);
|
||||
draw_element.style.transform = transform.toString();
|
||||
};
|
||||
canvas.requestPaint(); // Request an initial paint event.
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
193
research/canvas/_raw/ex-webGL.html
Normal file
193
research/canvas/_raw/ex-webGL.html
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>Demo of complex text in WebGL</title>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"
|
||||
integrity="sha512-zhHQR0/H5SEBL3Wn6yYSaTTZej12z0hVZKOv3TwCUXT1z5qeqGcXJLLrbERYRScEDDpYIJhPC1fk31gqR783iQ=="
|
||||
crossorigin="anonymous"
|
||||
defer>
|
||||
</script>
|
||||
<script src="webGLSetup.js"></script>
|
||||
<style>
|
||||
canvas {
|
||||
border: 1px solid blue;
|
||||
width: 638px;
|
||||
height: 318px;
|
||||
}
|
||||
#draw_element {
|
||||
border: 1px solid blue;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<canvas id="gl-canvas" width="638" height="318" layoutsubtree="true">
|
||||
<!-- inert to prevent hit testing in this example. -->
|
||||
<div id="draw_element" inert>
|
||||
Hello world!<br>I'm multi-line, <b>formatted</b>,
|
||||
rotated text with emoji (😀), RTL text
|
||||
<span dir=rtl>من فارسی صحبت میکنم</span>,
|
||||
vertical text,
|
||||
<p style="writing-mode: vertical-rl;">
|
||||
这是垂直文本
|
||||
</p>
|
||||
an inline image (<img width="150" src="wolf.jpg">), and
|
||||
<svg width="50" height="50">
|
||||
<circle cx="25" cy="25" r="20" fill="green" />
|
||||
<text x="25" y="30" font-size="15" text-anchor="middle" fill="#fff">
|
||||
SVG
|
||||
</text>
|
||||
</svg>!
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
let cubeRotation = 0.0;
|
||||
let currentTime = 0;
|
||||
let deltaTime = 0;
|
||||
let render_context = null;
|
||||
|
||||
//
|
||||
// Initialize a texture and load an image.
|
||||
// When the image finished loading copy it into the texture.
|
||||
//
|
||||
function loadTexture(gl) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
const internalFormat = gl.RGBA8;
|
||||
try {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, internalFormat, draw_element);
|
||||
} catch (e) {
|
||||
// The texElementImage2D API was recently changed (see:
|
||||
// https://github.com/WICG/html-in-canvas#idl-changes). This snippet
|
||||
// supports the old syntax temporarily so that the demos do not break.
|
||||
const level = 0;
|
||||
const srcFormat = gl.RGBA;
|
||||
const destType = gl.UNSIGNED_BYTE;
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, level, internalFormat,
|
||||
srcFormat, destType, draw_element);
|
||||
console.log('Note: using old texElementImage2D API');
|
||||
}
|
||||
|
||||
|
||||
// Linear texture filtering produces better results than mipmap with text.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
// Draw the scene repeatedly
|
||||
function render() {
|
||||
let new_time = performance.now() * 0.001; // convert to seconds
|
||||
deltaTime = new_time - currentTime;
|
||||
currentTime = new_time;
|
||||
|
||||
if (render_context === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawScene(render_context.gl,
|
||||
render_context.program,
|
||||
render_context.buffers,
|
||||
render_context.texture,
|
||||
cubeRotation);
|
||||
|
||||
cubeRotation += deltaTime;
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const canvas = document.querySelector('#gl-canvas');
|
||||
// Initialize the GL context
|
||||
const gl = canvas.getContext('webgl2');
|
||||
|
||||
// Only continue if WebGL is available and working
|
||||
if (gl === null) {
|
||||
alert(
|
||||
'Unable to initialize WebGL. Your browser or machine may not support it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertex shader program
|
||||
const vsSource = `
|
||||
attribute vec4 aVertexPosition;
|
||||
attribute vec2 aTextureCoord;
|
||||
|
||||
uniform mat4 uModelViewMatrix;
|
||||
uniform mat4 uProjectionMatrix;
|
||||
|
||||
varying highp vec2 vTextureCoord;
|
||||
|
||||
void main(void) {
|
||||
gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
|
||||
vTextureCoord = aTextureCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
// Fragment shader program
|
||||
const fsSource = `
|
||||
varying highp vec2 vTextureCoord;
|
||||
|
||||
uniform sampler2D uSampler;
|
||||
|
||||
void main(void) {
|
||||
gl_FragColor = texture2D(uSampler, vTextureCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
// Initialize a shader program; this is where all the lighting
|
||||
// for the vertices and so forth is established.
|
||||
const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
|
||||
|
||||
// Collect all the info needed to use the shader program.
|
||||
// Look up which attribute our shader program is using
|
||||
// for aVertexPosition and look up uniform locations.
|
||||
const programInfo = {
|
||||
program: shaderProgram,
|
||||
attribLocations: {
|
||||
vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
|
||||
textureCoord: gl.getAttribLocation(shaderProgram, 'aTextureCoord'),
|
||||
},
|
||||
uniformLocations: {
|
||||
projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix'),
|
||||
modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix'),
|
||||
uSampler: gl.getUniformLocation(shaderProgram, 'uSampler'),
|
||||
},
|
||||
};
|
||||
|
||||
const buffers = initBuffers(gl);
|
||||
|
||||
// Load texture
|
||||
const texture = loadTexture(gl);
|
||||
// Flip image pixels into the bottom-to-top order that WebGL expects.
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
|
||||
render_context = {
|
||||
gl: gl,
|
||||
program: programInfo,
|
||||
buffers: buffers,
|
||||
texture:texture,
|
||||
};
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
onload = () => {
|
||||
const canvas = document.querySelector('#gl-canvas');
|
||||
canvas.onpaint = () => {
|
||||
main();
|
||||
}
|
||||
canvas.requestPaint();
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
}
|
||||
</script>
|
||||
288
research/canvas/_raw/ex-webGLSetup.js
Normal file
288
research/canvas/_raw/ex-webGLSetup.js
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
//
|
||||
// creates a shader of the given type, uploads the source and
|
||||
// compiles it.
|
||||
//
|
||||
function loadShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
|
||||
// Send the source to the shader object
|
||||
gl.shaderSource(shader, source);
|
||||
|
||||
// Compile the shader program
|
||||
gl.compileShader(shader);
|
||||
|
||||
// See if it compiled successfully
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
alert(
|
||||
`An error occurred compiling the shaders: ${gl.getShaderInfoLog(shader)}`,
|
||||
);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
//
|
||||
// Initialize a shader program, so WebGL knows how to draw our data
|
||||
//
|
||||
function initShaderProgram(gl, vsSource, fsSource) {
|
||||
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
|
||||
// Create the shader program
|
||||
const shaderProgram = gl.createProgram();
|
||||
gl.attachShader(shaderProgram, vertexShader);
|
||||
gl.attachShader(shaderProgram, fragmentShader);
|
||||
gl.linkProgram(shaderProgram);
|
||||
|
||||
// If creating the shader program failed, alert
|
||||
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||
alert(
|
||||
`Unable to initialize the shader program: ${gl.getProgramInfoLog(
|
||||
shaderProgram,
|
||||
)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
function initBuffers(gl) {
|
||||
const positionBuffer = initPositionBuffer(gl);
|
||||
const textureCoordBuffer = initTextureBuffer(gl);
|
||||
const indexBuffer = initIndexBuffer(gl);
|
||||
|
||||
return {
|
||||
position: positionBuffer,
|
||||
textureCoord: textureCoordBuffer,
|
||||
indices: indexBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function initPositionBuffer(gl) {
|
||||
// Create a buffer for the square's positions.
|
||||
const positionBuffer = gl.createBuffer();
|
||||
|
||||
// Select the positionBuffer as the one to apply buffer
|
||||
// operations to from here out.
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
|
||||
const positions = [
|
||||
// Front face
|
||||
-1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0,
|
||||
|
||||
// Back face
|
||||
-1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,
|
||||
|
||||
// Top face
|
||||
-1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0,
|
||||
|
||||
// Bottom face
|
||||
-1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,
|
||||
|
||||
// Right face
|
||||
1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0,
|
||||
|
||||
// Left face
|
||||
-1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0,
|
||||
];
|
||||
|
||||
// Now pass the list of positions into WebGL to build the
|
||||
// shape. We do this by creating a Float32Array from the
|
||||
// JavaScript array, then use it to fill the current buffer.
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
|
||||
|
||||
return positionBuffer;
|
||||
}
|
||||
|
||||
function initIndexBuffer(gl) {
|
||||
const indexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
|
||||
// This array defines each face as two triangles, using the
|
||||
// indices into the vertex array to specify each triangle's
|
||||
// position.
|
||||
|
||||
// prettier-ignore
|
||||
const indices = [
|
||||
0, 1, 2, 0, 2, 3, // front
|
||||
4, 5, 6, 4, 6, 7, // back
|
||||
8, 9, 10, 8, 10, 11, // top
|
||||
12, 13, 14, 12, 14, 15, // bottom
|
||||
16, 17, 18, 16, 18, 19, // right
|
||||
20, 21, 22, 20, 22, 23, // left
|
||||
];
|
||||
|
||||
// Now send the element array to GL
|
||||
|
||||
gl.bufferData(
|
||||
gl.ELEMENT_ARRAY_BUFFER,
|
||||
new Uint16Array(indices),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
|
||||
return indexBuffer;
|
||||
}
|
||||
|
||||
function initTextureBuffer(gl) {
|
||||
const textureCoordBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer);
|
||||
|
||||
const textureCoordinates = [
|
||||
// Front
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Back
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Top
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Bottom
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Right
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
// Left
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0,
|
||||
];
|
||||
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array(textureCoordinates),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
|
||||
return textureCoordBuffer;
|
||||
}
|
||||
|
||||
function drawScene(gl, programInfo, buffers, texture, cubeRotation) {
|
||||
gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque
|
||||
gl.clearDepth(1.0); // Clear everything
|
||||
gl.enable(gl.DEPTH_TEST); // Enable depth testing
|
||||
gl.depthFunc(gl.LEQUAL); // Near things obscure far things
|
||||
|
||||
// Clear the canvas before we start drawing on it.
|
||||
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
|
||||
// Create a perspective matrix, a special matrix that is
|
||||
// used to simulate the distortion of perspective in a camera.
|
||||
// Our field of view is 35 degrees, with a width/height
|
||||
// ratio that matches the display size of the canvas
|
||||
// and we only want to see objects between 0.1 units
|
||||
// and 100 units away from the camera.
|
||||
|
||||
const fieldOfView = (35 * Math.PI) / 180; // in radians
|
||||
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
|
||||
const zNear = 0.1;
|
||||
const zFar = 100.0;
|
||||
const projectionMatrix = mat4.create();
|
||||
|
||||
// note: glMatrix always has the first argument
|
||||
// as the destination to receive the result.
|
||||
mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
|
||||
|
||||
// Set the drawing position to the "identity" point, which is
|
||||
// the center of the scene.
|
||||
const modelViewMatrix = mat4.create();
|
||||
|
||||
// Now move the drawing position a bit to where we want to
|
||||
// start drawing the square.
|
||||
mat4.translate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to translate
|
||||
[-0.0, 0.0, -6.0],
|
||||
); // amount to translate
|
||||
mat4.rotate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to rotate
|
||||
cubeRotation, // amount to rotate in radians
|
||||
[0, 0, 1],
|
||||
); // axis to rotate around (Z)
|
||||
mat4.rotate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to rotate
|
||||
cubeRotation * 0.7, // amount to rotate in radians
|
||||
[0, 1, 0],
|
||||
); // axis to rotate around (Y)
|
||||
mat4.rotate(
|
||||
modelViewMatrix, // destination matrix
|
||||
modelViewMatrix, // matrix to rotate
|
||||
cubeRotation * 0.3, // amount to rotate in radians
|
||||
[1, 0, 0],
|
||||
); // axis to rotate around (X)
|
||||
|
||||
setPositionAttribute(gl, buffers, programInfo);
|
||||
setTextureAttribute(gl, buffers, programInfo);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
|
||||
|
||||
// Tell WebGL to use our program when drawing
|
||||
gl.useProgram(programInfo.program);
|
||||
|
||||
// Set the shader uniforms
|
||||
gl.uniformMatrix4fv(
|
||||
programInfo.uniformLocations.projectionMatrix,
|
||||
false,
|
||||
projectionMatrix,
|
||||
);
|
||||
gl.uniformMatrix4fv(
|
||||
programInfo.uniformLocations.modelViewMatrix,
|
||||
false,
|
||||
modelViewMatrix,
|
||||
);
|
||||
|
||||
// Tell WebGL we want to affect texture unit 0
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
|
||||
// Bind the texture to texture unit 0
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
// Tell the shader we bound the texture to texture unit 0
|
||||
gl.uniform1i(programInfo.uniformLocations.uSampler, 0);
|
||||
|
||||
{
|
||||
const vertexCount = 36;
|
||||
const type = gl.UNSIGNED_SHORT;
|
||||
const offset = 0;
|
||||
gl.drawElements(gl.TRIANGLES, vertexCount, type, offset);
|
||||
}
|
||||
}
|
||||
|
||||
// Tell WebGL how to pull out the positions from the position
|
||||
// buffer into the vertexPosition attribute.
|
||||
function setPositionAttribute(gl, buffers, programInfo) {
|
||||
const numComponents = 3; // pull out 2 values per iteration
|
||||
const type = gl.FLOAT; // the data in the buffer is 32bit floats
|
||||
const normalize = false; // don't normalize
|
||||
const stride = 0; // how many bytes to get from one set of values to the next
|
||||
// 0 = use type and numComponents above
|
||||
const offset = 0; // how many bytes inside the buffer to start from
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
|
||||
gl.vertexAttribPointer(
|
||||
programInfo.attribLocations.vertexPosition,
|
||||
numComponents,
|
||||
type,
|
||||
normalize,
|
||||
stride,
|
||||
offset,
|
||||
);
|
||||
gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
|
||||
}
|
||||
|
||||
// tell webgl how to pull out the texture coordinates from buffer
|
||||
function setTextureAttribute(gl, buffers, programInfo) {
|
||||
const num = 2; // every coordinate composed of 2 values
|
||||
const type = gl.FLOAT; // the data in the buffer is 32-bit float
|
||||
const normalize = false; // don't normalize
|
||||
const stride = 0; // how many bytes to get from one set to the next
|
||||
const offset = 0; // how many bytes inside the buffer to start from
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.textureCoord);
|
||||
gl.vertexAttribPointer(
|
||||
programInfo.attribLocations.textureCoord,
|
||||
num,
|
||||
type,
|
||||
normalize,
|
||||
stride,
|
||||
offset,
|
||||
);
|
||||
gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord);
|
||||
}
|
||||
379
research/canvas/_raw/explainer.md
Normal file
379
research/canvas/_raw/explainer.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# HTML-in-Canvas
|
||||
|
||||
This is a proposal for using 2D and 3D `<canvas>` to customize the rendering of HTML content.
|
||||
|
||||
## Status
|
||||
|
||||
This is a living explainer which is continuously updated as we receive feedback.
|
||||
|
||||
The APIs described here are implemented behind a flag in Chromium and can be enabled with `chrome://flags/#canvas-draw-element`.
|
||||
|
||||
## Motivation
|
||||
|
||||
There is no web API to easily render complex layouts of text and other content into a `<canvas>`. As a result, `<canvas>`-based content suffers in accessibility, internationalization, performance, and quality.
|
||||
|
||||
### Use cases
|
||||
|
||||
* **Styled, Laid Out Content in Canvas.** There’s a strong need for better styled text support in Canvas. Examples include chart components (legend, axes, etc.), rich content boxes in creative tools, and in-game menus.
|
||||
* **Accessibility Improvements.** There is currently no guarantee that the canvas fallback content used for `<canvas>` accessibility always matches the rendered content, and such fallback content can be hard to generate. With this API, elements drawn into the canvas will match their corresponding canvas fallback.
|
||||
* **Composing HTML Elements with Effects.** A limited set of CSS effects, such as filters, backdrop-filter, and mix-blend-mode are already available, but there is a desire to use general WebGL shaders with HTML.
|
||||
* **HTML Rendering in a 3D Context.** 3D aspects of sites and games need to render rich 2D content into surfaces within a 3D scene.
|
||||
* **Media Export.** There's a need to export HTML content as images or video.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
The solution introduces three main primitives: an attribute to opt-in canvas elements, methods to draw child elements into the canvas, and an event which fires to handle updates.
|
||||
|
||||
### 1. The `layoutsubtree` attribute
|
||||
The `layoutsubtree` attribute on a `<canvas>` element opts in canvas descendants to layout and participate in hit testing. It causes the direct children of the `<canvas>` to have a stacking context, become a containing block for all descendants, and have paint containment. Canvas element children behave as if they are visible, but their rendering is not visible to the user unless and until they are explicitly drawn into the canvas via a call to `drawElementImage()` (see below).
|
||||
|
||||
### 2. `drawElementImage` (and WebGL/WebGPU equivalents)
|
||||
The `drawElementImage()` method draws a child of the canvas into the canvas, and returns a transform that can be applied to `element.style.transform` to align its DOM location with its drawn location. A snapshot of the rendering of all children of the canvas is recorded just prior to the `paint` event. When called during the `paint` event, `drawElementImage()` will draw the child as it would appear in the current frame. When called outside the `paint` event, the previous frame's snapshot is used. An exception is thrown if `drawElementImage()` is called with a child before an initial snapshot has been recorded.
|
||||
|
||||
**Requirements & Constraints:**
|
||||
* `layoutsubtree` must be specified on the `<canvas>` in the most recent rendering update.
|
||||
* The `element` must be a direct child of the `<canvas>` in the most recent rendering update.
|
||||
* The `element` must have generated boxes (i.e., not `display: none`) in the most recent rendering update.
|
||||
* **Transforms:** The canvas's current transformation matrix is applied when drawing into the canvas. CSS transforms on the source `element` are **ignored** for drawing (but continue to affect hit testing/accessibility, see below).
|
||||
* **Clipping:** Overflowing content (both layout and ink overflow) is clipped to the element's border box.
|
||||
* **Sizing:** The optional `width`/`height` arguments specify a destination rect in canvas coordinates. If omitted, the `width`/`height` arguments default to sizing the element so that it has the same on-screen size and proportion in canvas coordinates as it does outside the canvas.
|
||||
|
||||
**WebGL/WebGPU Support:**
|
||||
Similar methods are added for 3D contexts: `WebGLRenderingContext.texElementImage2D` and `copyElementImageToTexture`.
|
||||
|
||||
### 3. The `paint` event
|
||||
A `paint` event is added to `canvas` elements and fires if the rendering of any canvas children has changed. This event fires just after intersection observer steps have run during [update-the-rendering](https://html.spec.whatwg.org/#update-the-rendering). The event contains a list of the canvas children which have changed. Because CSS transforms on canvas children are ignored for rendering, changing the transform does not cause the `paint` event to fire in the next frame. Canvas drawing commands made in the `paint` event will appear in the current frame, but DOM changes made in the `paint` event will not show up until the subsequent frame. If there are multiple `<canvas>` elements, the `paint` event fires in _reverse_ tree order which ensures that descendants fire `paint` before ancestors.
|
||||
|
||||
To support application patterns which update every frame, a new `requestPaint()` function is added which will cause the `paint` event to fire once, even if no children have changed (analagous to `requestAnimationFrame()`).
|
||||
|
||||
### 4. `captureElementImage`
|
||||
To support `OffscreenCanvas` in workers, a snapshot of an element can be captured as an `ElementImage` snapshot using `canvas.captureElementImage(element)`. These objects can be transferred to a worker and drawn to an `OffscreenCanvas`.
|
||||
|
||||
### Synchronization
|
||||
|
||||
Browser features like hit testing, intersection observer, and accessibility rely on an element's DOM location. To ensure these work, the element's `transform` property should be updated so that the DOM location matches the drawn location.
|
||||
|
||||
<details>
|
||||
<summary>Calculating a CSS transform to match a drawn location</summary>
|
||||
The general formula for the CSS transform is:
|
||||
|
||||
<div align="center">$$T_{\text{origin}}^{-1} \cdot S_{\text{css} \to \text{grid}}^{-1} \cdot T_{\text{draw}} \cdot S_{\text{css} \to \text{grid}} \cdot T_{\text{origin}} $$</div>
|
||||
|
||||
Where:
|
||||
|
||||
* $$T_{\text{draw}}$$: Transform used to draw the element in the canvas grid coordinate system.
|
||||
For `drawElementImage`, this is $$CTM \cdot T_{(\text{x}, \text{y})} \cdot S_{(\text{destScale})}$$, where $$CTM$$ is the Current Transformation Matrix, $$T_{(\text{x}, \text{y})}$$ is a translation from the x and y arguments, and $$S_{(\text{destScale})}$$ is a scale from the width and height arguments.
|
||||
* $$T_{\text{origin}}$$: Translation matrix of the element's computed `transform-origin`.
|
||||
* $$S_{\text{css} \to \text{grid}}$$: Scaling matrix converting CSS pixels to Canvas Grid pixels.
|
||||
</details>
|
||||
|
||||
To assist with synchronization, `drawElementImage()` returns the CSS transform which can be applied to the element to keep its location synchronized. For 3D contexts, the `getElementTransform(element, drawTransform)` helper method is provided which returns the CSS transform, provided a general transformation matrix.
|
||||
|
||||
The transform used to draw the element on the worker thread needs to be synced back to the DOM, and can simply be `postMessage()`'d back to the main thread if the position is static. If the position is dynamic, an alternative is to calculate the position on the main thread and update `element.style.transform` at the same time that the `ElementImage` objects is sent to the worker thread.
|
||||
|
||||
### Basic Example
|
||||
|
||||
<img width="250" height="38" alt="a screenshot showing a form element with a blinking cursor" src="https://github.com/user-attachments/assets/acbdd231-3259-4819-b57e-32e29c460fc9" />
|
||||
|
||||
```html
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('canvas').getContext('2d');
|
||||
|
||||
canvas.onpaint = () => {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(form_element, 100, 0);
|
||||
form_element.style.transform = transform.toString();
|
||||
};
|
||||
|
||||
// Size the canvas grid to match the device scale factor to prevent blurriness.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
|
||||
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
|
||||
});
|
||||
observer.observe(canvas, {box: 'device-pixel-content-box'});
|
||||
</script>
|
||||
```
|
||||
|
||||
### OffscreenCanvas Example
|
||||
|
||||
In this example, `OffscreenCanvas` in a worker is used. The `canvas` child form is captured as an `ElementImage` object in the `paint` event and transferred to the worker for painting.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>
|
||||
<form id="form_element">
|
||||
<label for="name">name:</label>
|
||||
<input id="name">
|
||||
</form>
|
||||
</canvas>
|
||||
<script>
|
||||
const workerCode = `
|
||||
let ctx;
|
||||
self.onmessage = (e) => {
|
||||
if (e.data.canvas) {
|
||||
ctx = e.data.canvas.getContext('2d');
|
||||
}
|
||||
if (e.data.width && e.data.height) {
|
||||
ctx.canvas.width = e.data.width;
|
||||
ctx.canvas.height = e.data.height;
|
||||
}
|
||||
if (e.data.elementImage) {
|
||||
ctx.reset();
|
||||
const transform = ctx.drawElementImage(e.data.elementImage, 100, 0);
|
||||
self.postMessage({transform: transform});
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
|
||||
worker.postMessage({ canvas: offscreen }, [offscreen]);
|
||||
|
||||
canvas.onpaint = (event) => {
|
||||
const elementImage = canvas.captureElementImage(form_element)
|
||||
worker.postMessage({ elementImage: elementImage }, [elementImage]);
|
||||
};
|
||||
|
||||
// Synchronize the element's CSS transform to match its drawn location.
|
||||
worker.onmessage = ({data}) => {
|
||||
form_element.style.transform = data.transform.toString();
|
||||
};
|
||||
|
||||
// Size the canvas grid to match the device scale factor to prevent blurriness.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
worker.postMessage({
|
||||
width: entry.devicePixelContentBoxSize[0].inlineSize,
|
||||
height: entry.devicePixelContentBoxSize[0].blockSize
|
||||
});
|
||||
canvas.requestPaint();
|
||||
});
|
||||
observer.observe(canvas, { box: 'device-pixel-content-box' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### IDL changes
|
||||
|
||||
```idl
|
||||
partial interface HTMLCanvasElement {
|
||||
[CEReactions, Reflect] attribute boolean layoutSubtree;
|
||||
|
||||
attribute EventHandler onpaint;
|
||||
|
||||
void requestPaint();
|
||||
|
||||
ElementImage captureElementImage(Element element);
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
partial interface OffscreenCanvas {
|
||||
DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);
|
||||
};
|
||||
|
||||
interface mixin CanvasDrawElementImage {
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy);
|
||||
|
||||
DOMMatrix drawElementImage((Element or ElementImage) element,
|
||||
unrestricted double sx, unrestricted double sy,
|
||||
unrestricted double swidth, unrestricted double sheight,
|
||||
unrestricted double dx, unrestricted double dy,
|
||||
unrestricted double dwidth, unrestricted double dheight);
|
||||
};
|
||||
|
||||
CanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
OffscreenCanvasRenderingContext2D includes CanvasDrawElementImage;
|
||||
|
||||
dictionary WebGLCopyElementImageConfig {
|
||||
GLfloat sx;
|
||||
GLfloat sy;
|
||||
GLfloat swidth;
|
||||
GLfloat sheight;
|
||||
GLsizei width;
|
||||
GLsizei height;
|
||||
};
|
||||
|
||||
partial interface WebGLRenderingContext {
|
||||
void texElementImage2D(GLenum target, GLenum internalformat,
|
||||
(Element or ElementImage) element,
|
||||
optional WebGLCopyElementImageConfig config = {});
|
||||
};
|
||||
|
||||
dictionary GPUCopyElementImageDestination {
|
||||
required GPUImageCopyTextureTagged destination;
|
||||
GPUIntegerCoordinate width;
|
||||
GPUIntegerCoordinate height;
|
||||
};
|
||||
|
||||
dictionary GPUCopyElementImageSource {
|
||||
required (Element or ElementImage) source;
|
||||
float sx;
|
||||
float sy;
|
||||
float swidth;
|
||||
float sheight;
|
||||
};
|
||||
|
||||
partial interface GPUQueue {
|
||||
void copyElementImageToTexture(GPUCopyElementImageSource source,
|
||||
GPUCopyElementImageDestination destination);
|
||||
}
|
||||
|
||||
[Exposed=Window]
|
||||
interface PaintEvent : Event {
|
||||
constructor(DOMString type, optional PaintEventInit eventInitDict);
|
||||
|
||||
readonly attribute FrozenArray<Element> changedElements;
|
||||
};
|
||||
|
||||
dictionary PaintEventInit : EventInit {
|
||||
sequence<Element> changedElements = [];
|
||||
};
|
||||
|
||||
[Exposed=(Window,Worker), Transferable]
|
||||
interface ElementImage {
|
||||
readonly attribute double width;
|
||||
readonly attribute double height;
|
||||
undefined close();
|
||||
};
|
||||
```
|
||||
|
||||
## Demos
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/complex-text.html) ([source](Examples/complex-text.html)) using the `drawElementImage` API to draw rotated complex text.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing rotated, complex text drawn into canvas" src="https://github.com/user-attachments/assets/3ef73e0f-9119-49de-bf84-dfb3a4f5d77c" />
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/pie-chart.html) ([source](Examples/pie-chart.html)) using the `drawElementImage` API to draw a pie chart with multi-line labels.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing a pie chart" src="https://github.com/user-attachments/assets/887eefa2-ffc0-49d6-914b-987b05ccb45d" />
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/webgpu-jelly-slider/) ([source](Examples/webgpu-jelly-slider)) using the WebGPU `copyElementImageToTexture` API to draw a div under a jelly slider.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing a range slider with a jelly effect" src="https://github.com/user-attachments/assets/86ecb8b8-4d3b-49b0-8aa0-5f2df5674045" />
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/webGL.html) ([source](Examples/webGL.html)) using the WebGL `texElementImage2D` API to draw HTML onto a 3D cube.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing html content on a 3D cube" src="https://github.com/user-attachments/assets/689fefe3-56d9-4ae9-b386-32a01ebb0117" />
|
||||
|
||||
A demo of the same thing using an experimental extension of [three.js](https://threejs.org/) is [here](https://raw.githack.com/mrdoob/three.js/htmltexture/examples/webgl_materials_texture_html.html). Further instructions and context are [here](https://github.com/mrdoob/three.js/pull/31233).
|
||||
|
||||
#### [Live demo](https://wicg.github.io/html-in-canvas/Examples/text-input.html) ([source](Examples/text-input.html)) of interactive content in canvas.
|
||||
|
||||
<img width="640" height="320" alt="screenshot showing a form drawn into canvas" src="https://github.com/user-attachments/assets/be2d098f-17ae-4982-a0f9-a069e3c2d1d5" />
|
||||
|
||||
## Read-back-allowed rendering
|
||||
|
||||
The `drawElementImage()` method and any other methods that draw element image snapshots, as well as the paint event, must not reveal any security- or privacy-sensitive information that isn't otherwise observable to author code. This concept is called read-back-allowed rendering because it makes it possible to allow pixel read-back, which is always possible with WebGL and WebGPU.
|
||||
|
||||
Both painting (via canvas pixel readbacks or timing attacks) and invalidation (via `onpaint`) have the potential to leak sensitive information, and this is prevented by excluding sensitive information when painting and invalidating.
|
||||
|
||||
Sensitive information includes:
|
||||
* Cross-origin data in [embedded content](https://html.spec.whatwg.org/#embedded-content-category) (e.g., `<iframe>`, `<img>`), [`<url>`](https://drafts.csswg.org/css-values-4/#url-value) references (e.g., `background-image`, `clip-path`), `<canvas>` elements tained with cross-origin data, and [SVG](https://svgwg.org/svg2-draft/single-page.html#types-InterfaceSVGURIReference) (e.g., `<use>`, `<pattern>`, `<feImage>`). Note that same-origin iframes would still paint, but cross-origin content in them would not.
|
||||
* System colors, themes, or preferences.
|
||||
* Spelling and grammar markers.
|
||||
* Visited link information.
|
||||
* Pending form autofill information not otherwise available to JavaScript.
|
||||
* Subpixel text anti-aliasing.
|
||||
* User preferences for caption and subtitle selection and appearance.
|
||||
* IME pop-ups and distinctive IME text formatting.
|
||||
|
||||
The following new information is not considered sensitive:
|
||||
* Search text (find-in-page) and text-fragment (fragment url) markers.
|
||||
* Scrollbar and form element appearance (these are already detectable in Blink and WebKit through [foreignObject](https://jsfiddle.net/progers/qhawnyeu)).
|
||||
* Caret blink rate.
|
||||
* forced-colors (this information is already available to javascript using the `forced-colors` media query and system colors).
|
||||
|
||||
## Developer Trial (dev trial) Information
|
||||
The HTML-in-Canvas features may be enabled with `chrome://flags/#canvas-draw-element` in Chrome Canary.
|
||||
|
||||
We are most interested in feedback on the following topics:
|
||||
* What content works, and what fails? Which failure modes are most important to fix?
|
||||
* How does the feature interact with accessibility features? How can accessibility support be improved?
|
||||
|
||||
Please file bugs or design issues [here](https://github.com/WICG/html-in-canvas/issues/new).
|
||||
|
||||
## Alternatives considered: `paint` event timing
|
||||
|
||||
A new `paint` event is needed to give developers an opportunity to update their canvas rendering in response to paint changes. This is integrated into [update the rendering](https://html.spec.whatwg.org/#update-the-rendering) so that canvas updates can occur in sync with the DOM.
|
||||
|
||||
There are several opportunities in the [update the rendering](https://html.spec.whatwg.org/#update-the-rendering) steps where the `paint` event could fire:
|
||||
|
||||
* 14\. Run animation frame callbacks.
|
||||
|
||||
* 16.2.1\. Recalculate styles and update layout.
|
||||
|
||||
* 16.2.6\. Deliver resize observers, looping back to 16.2.1 if needed.
|
||||
|
||||
* _Option A: Fire `paint` at resize observer timing, looping back to 16.2.1 if needed._
|
||||
|
||||
* 19\. Run the update intersection observations steps.
|
||||
|
||||
* Paint, where the painted output of elements is calculated. This is not an explicitly named step in [update the rendering](https://html.spec.whatwg.org/#update-the-rendering).
|
||||
|
||||
* _Option B: Fire `paint` immediately after Paint, looping back to 16.2.1 if needed._
|
||||
|
||||
* _Option C: Fire `paint` immediately after Paint._
|
||||
|
||||
* Commit / thread handoff, where the painted output is sent to another process. This is not an explicitly named step in [update the rendering](https://html.spec.whatwg.org/#update-the-rendering).
|
||||
|
||||
Note that the `paint` event is the new event on canvas introduced in this proposal, and the Paint step is the existing operation that browsers perform to record the painted output of the rendering tree following [paint order](https://drafts.csswg.org/css-position-4/#painting-order).
|
||||
|
||||
#### Option A: Fire `paint` at resize observer timing, looping back to 16.2.1 if needed.
|
||||
|
||||
Similar to resize observer, a looping approach is needed to handle cases where the paint event performs modifications (including of elements outside the canvas). There is no mechanism for preventing arbitrary javascript from modifying the DOM. Looping will be required for more conditions than those required by ResizeObserver, such as background style changes. A downside of looping is that the user's canvas code may need to run multiple times per frame.
|
||||
|
||||
One option is to do a synchronous Paint step to snapshot the painted output of canvas children. A downside of this approach is that the Paint step may be expensive to run, and may need to be run multiple times. This approach has unique implementation challenges in Gecko, and possibly other engines, due to architectural limitations.
|
||||
|
||||
A second option is to not run the Paint step synchronously, but instead record a placeholder representing how an element will appear on the next rendering update (see [design](https://docs.google.com/document/d/1YaHCxYqE4uQc4-UTWo4a5pHt2I2MutlwJtsnj5ljEkM/edit?usp=sharing)). This model can be implemented with 2D canvas by buffering the canvas commands until the next Paint step. When the next Paint step occurs, the placeholders would then be replaced with the actual rendering. Canvas operations such as `getImageData` require synchronous flushing of the canvas command buffer and would need to show blank or stale data for the placeholders. Unfortunately, this approach has a fundamental flaw for WebGL because many APIs require flushing (e.g., `getError()`, see callsites of [WaitForCmd](https://source.chromium.org/chromium/chromium/src/+/main:gpu/command_buffer/client/implementation_base.h;drc=b3eab4fd06ddbeee84b37224f4cc9d78094fc2f7;l=102)), and calling any of these APIs would result in a deadlock or inconsistent rendering. Therefore, we must run the `paint` event at a time where we have the complete painted display list of an element already available.
|
||||
|
||||
#### Option B: Fire `paint` immediately after Paint, looping back to 16.2.1 if needed.
|
||||
|
||||
See above for the reasons and downsides of looping when there are modifications made during the `paint` event.
|
||||
|
||||
The upside of option B as compared with option A is that it does not require partial Paint of canvas children. An additional downside is that even more steps of [update the rendering](https://html.spec.whatwg.org/#update-the-rendering) need to run on each iteration of the loop.
|
||||
|
||||
#### Option C: Fire `paint` immediately after Paint.
|
||||
|
||||
This is the design approach taken for the API.
|
||||
|
||||
This approach only runs `paint` once per frame, similar to the browser's own Paint step. To solve the issue of javascript being able to perform arbitrary modifications, it is important to ensure that before `paint` runs we have locked in the contents of the rendering update, except for one intentional carve-out: the drawn content of the canvas. DOM invalidations that may occur in the `paint` event apply to the subsequent frame, not the current frame.
|
||||
|
||||
## Alternatives considered: Supporting threaded effects with worker threads
|
||||
|
||||
To support threaded effects, we explored a [design](https://docs.google.com/document/d/1TWe6HP7HMn6y-XnNKppIhgf9FtuXJ6LPgenJJxZDjzg/edit?tab=t.0) where canvas children "snapshots" are sent to a worker thread. In response to threaded scrolling and animations, the worker thread could then render the most up-to-date rendering of the snapshots into OffscreenCanvas. This model requires that javascript can be synchronously called on scroll and animation updates, which is difficult for architectures that perform threaded scroll updates in a restricted process.
|
||||
|
||||
## Future considerations: Supporting threaded effects with an auto-updating canvas
|
||||
|
||||
To support threaded effects such as scrolling and animations, we are considering a future "auto-updating canvas" mode.
|
||||
|
||||
In this model, `drawElementImage` records a placeholder representing the latest rendering. Canvas retains a command buffer which can be automatically replayed following every scroll or animation update. This allows the canvas to re-rasterize with updated placeholders that incorporate threaded scrolling and animations, without needing to block on script. This would enable visual effects that stay perfectly in sync with native scrolling or animations within the canvas, independent of the main thread. This design is viable for 2D contexts, and may be viable for WebGPU with some small API additions.
|
||||
|
||||
## Other documents
|
||||
|
||||
* [Security and Privacy Questionnaire](./security-privacy-questionnaire.md)
|
||||
|
||||
## Authors
|
||||
|
||||
* [Philip Rogers](mailto:pdr@chromium.org)
|
||||
* [Stephen Chenney](mailto:schenney@igalia.com)
|
||||
* [Chris Harrelson](mailto:chrishtr@chromium.org)
|
||||
* [Philip Jägenstedt](mailto:foolip@chromium.org)
|
||||
* [Khushal Sagar](mailto:khushalsagar@chromium.org)
|
||||
* [Vladimir Levin](mailto:vmpstr@chromium.org)
|
||||
* [Fernando Serboncini](mailto:fserb@chromium.org)
|
||||
182
research/canvas/_raw/htex-ex.html
Normal file
182
research/canvas/_raw/htex-ex.html
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>three.js webgl - materials - html texture</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||
<meta property="og:title" content="three.js webgl - materials - html texture">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://threejs.org/examples/webgl_materials_texture_html.html">
|
||||
<meta property="og:image" content="https://threejs.org/examples/screenshots/webgl_materials_texture_html.jpg">
|
||||
<link type="text/css" rel="stylesheet" href="main.css">
|
||||
<style>
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
#draw_element {
|
||||
width: 600px;
|
||||
background-color: #aaaaaa;
|
||||
color: #000000;
|
||||
font-family: sans-serif;
|
||||
font-size: 30px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
/* border: 10px solid #cccccc; */
|
||||
}
|
||||
#draw_element img {
|
||||
animation: swing 1s ease-in-out infinite alternate;
|
||||
}
|
||||
#draw_element input[type="text"] {
|
||||
font-size: 24px;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #888;
|
||||
border-radius: 6px;
|
||||
width: 80%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
#draw_element button {
|
||||
font-size: 24px;
|
||||
padding: 8px 20px;
|
||||
margin-top: 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
#draw_element button:hover {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
@keyframes swing {
|
||||
from { transform: rotate(-15deg); }
|
||||
to { transform: rotate(15deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="info">
|
||||
<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - webgl - HTMLTexture
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "../build/three.module.js",
|
||||
"three/addons/": "./jsm/",
|
||||
"three-html-render/polyfill": "https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.mjs"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { installHtmlInCanvasPolyfill } from 'three-html-render/polyfill';
|
||||
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
import { InteractionManager } from 'three/addons/interaction/InteractionManager.js';
|
||||
|
||||
if ( ! ( 'requestPaint' in HTMLCanvasElement.prototype ) ) {
|
||||
|
||||
installHtmlInCanvasPolyfill();
|
||||
info.innerHTML += '<br><a href="https://github.com/WICG/html-in-canvas" target="_blank">HTML-in-Canvas API</a> not available. Using <a href="https://github.com/repalash/three-html-render" target="_blank">polyfill</a>.';
|
||||
|
||||
}
|
||||
|
||||
let camera, scene, renderer, mesh, interactions;
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
|
||||
renderer = new THREE.WebGLRenderer( { antialias: true } );
|
||||
|
||||
renderer.toneMapping = THREE.NeutralToneMapping;
|
||||
renderer.setPixelRatio( window.devicePixelRatio );
|
||||
renderer.setSize( window.innerWidth, window.innerHeight );
|
||||
renderer.setAnimationLoop( animate );
|
||||
document.body.appendChild( renderer.domElement );
|
||||
|
||||
camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 2000 );
|
||||
camera.position.z = 500;
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color( 0xaaaaaa );
|
||||
scene.environment = new THREE.PMREMGenerator( renderer ).fromScene( new RoomEnvironment(), 0.02 ).texture;
|
||||
|
||||
// HTML element
|
||||
|
||||
const element = document.createElement( 'div' );
|
||||
element.id = 'draw_element';
|
||||
element.innerHTML = `
|
||||
Hello world!<br>I'm multi-line, <b>formatted</b>,
|
||||
rotated text with emoji (😀), RTL text
|
||||
<span dir=rtl>من فارسی صحبت میکنم</span>,
|
||||
vertical text,
|
||||
<p style="writing-mode: vertical-rl;">
|
||||
这是垂直文本
|
||||
</p>
|
||||
an inline image (<img width="150" src="textures/758px-Canestra_di_frutta_(Caravaggio).jpg">), and
|
||||
<svg width="50" height="50">
|
||||
<circle cx="25" cy="25" r="20" fill="green" />
|
||||
<text x="25" y="30" font-size="15" text-anchor="middle" fill="#fff">
|
||||
SVG
|
||||
</text>
|
||||
</svg>!
|
||||
<br>
|
||||
<input type="text" placeholder="Type here...">
|
||||
<button>Click me</button>
|
||||
`;
|
||||
|
||||
const geometry = new RoundedBoxGeometry( 200, 200, 200, 10, 10 );
|
||||
|
||||
const material = new THREE.MeshStandardMaterial( { roughness: 0, metalness: 0.5 } );
|
||||
material.map = new THREE.HTMLTexture( element );
|
||||
|
||||
mesh = new THREE.Mesh( geometry, material );
|
||||
scene.add( mesh );
|
||||
|
||||
// Interaction
|
||||
|
||||
interactions = new InteractionManager();
|
||||
interactions.connect( renderer, camera );
|
||||
interactions.add( mesh );
|
||||
|
||||
// Button click handler
|
||||
|
||||
element.querySelector( 'button' ).addEventListener( 'click', function () {
|
||||
|
||||
this.textContent = 'Clicked!';
|
||||
|
||||
} );
|
||||
|
||||
window.addEventListener( 'resize', onWindowResize );
|
||||
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
renderer.setSize( window.innerWidth, window.innerHeight );
|
||||
|
||||
}
|
||||
|
||||
function animate( time ) {
|
||||
|
||||
mesh.rotation.x = Math.sin( time * 0.0005 ) * 0.5;
|
||||
mesh.rotation.y = Math.cos( time * 0.0008 ) * 0.5;
|
||||
|
||||
interactions.update();
|
||||
|
||||
renderer.render( scene, camera );
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
74
research/canvas/_raw/htmltex.js
Normal file
74
research/canvas/_raw/htmltex.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Texture } from './Texture.js';
|
||||
|
||||
/**
|
||||
* Creates a texture from an HTML element.
|
||||
*
|
||||
* This is almost the same as the base texture class, except that it sets {@link Texture#needsUpdate}
|
||||
* to `true` immediately and listens for the parent canvas's paint events to trigger updates.
|
||||
*
|
||||
* @augments Texture
|
||||
*/
|
||||
class HTMLTexture extends Texture {
|
||||
|
||||
/**
|
||||
* Constructs a new texture.
|
||||
*
|
||||
* @param {HTMLElement} [element] - The HTML element.
|
||||
* @param {number} [mapping=Texture.DEFAULT_MAPPING] - The texture mapping.
|
||||
* @param {number} [wrapS=ClampToEdgeWrapping] - The wrapS value.
|
||||
* @param {number} [wrapT=ClampToEdgeWrapping] - The wrapT value.
|
||||
* @param {number} [magFilter=LinearFilter] - The mag filter value.
|
||||
* @param {number} [minFilter=LinearMipmapLinearFilter] - The min filter value.
|
||||
* @param {number} [format=RGBAFormat] - The texture format.
|
||||
* @param {number} [type=UnsignedByteType] - The texture type.
|
||||
* @param {number} [anisotropy=Texture.DEFAULT_ANISOTROPY] - The anisotropy value.
|
||||
*/
|
||||
constructor( element, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ) {
|
||||
|
||||
super( element, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy );
|
||||
|
||||
/**
|
||||
* This flag can be used for type testing.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @readonly
|
||||
* @default true
|
||||
*/
|
||||
this.isHTMLTexture = true;
|
||||
this.generateMipmaps = false;
|
||||
|
||||
this.needsUpdate = true;
|
||||
|
||||
const parent = element ? element.parentNode : null;
|
||||
|
||||
if ( parent !== null && 'requestPaint' in parent ) {
|
||||
|
||||
parent.onpaint = () => {
|
||||
|
||||
this.needsUpdate = true;
|
||||
|
||||
};
|
||||
|
||||
parent.requestPaint();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
const parent = this.image ? this.image.parentNode : null;
|
||||
|
||||
if ( parent !== null && 'onpaint' in parent ) {
|
||||
|
||||
parent.onpaint = null;
|
||||
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { HTMLTexture };
|
||||
938
research/canvas/_raw/jelly.ts
Normal file
938
research/canvas/_raw/jelly.ts
Normal file
|
|
@ -0,0 +1,938 @@
|
|||
import * as sdf from '@typegpu/sdf';
|
||||
import tgpu, { common, d, std } from 'typegpu';
|
||||
|
||||
import { randf } from '@typegpu/noise';
|
||||
import { Slider } from './slider.ts';
|
||||
import { CameraController } from './camera.ts';
|
||||
import {
|
||||
DirectionalLight,
|
||||
HitInfo,
|
||||
LineInfo,
|
||||
ObjectType,
|
||||
Ray,
|
||||
rayMarchLayout,
|
||||
sampleLayout,
|
||||
SdfBbox,
|
||||
} from './dataTypes.ts';
|
||||
import {
|
||||
beerLambert,
|
||||
createBackgroundTexture,
|
||||
createTextures,
|
||||
fresnelSchlick,
|
||||
intersectBox,
|
||||
} from './utils.ts';
|
||||
import { TAAResolver } from './taa.ts';
|
||||
import {
|
||||
AMBIENT_COLOR,
|
||||
AMBIENT_INTENSITY,
|
||||
AO_BIAS,
|
||||
AO_INTENSITY,
|
||||
AO_RADIUS,
|
||||
AO_STEPS,
|
||||
JELLY_IOR,
|
||||
JELLY_SCATTER_STRENGTH,
|
||||
LINE_HALF_THICK,
|
||||
LINE_RADIUS,
|
||||
MAX_DIST,
|
||||
MAX_STEPS,
|
||||
SPECULAR_INTENSITY,
|
||||
SPECULAR_POWER,
|
||||
SURF_DIST,
|
||||
} from './constants.ts';
|
||||
|
||||
const root = await tgpu.init({
|
||||
device: {
|
||||
optionalFeatures: ['timestamp-query'],
|
||||
},
|
||||
});
|
||||
|
||||
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
|
||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
|
||||
|
||||
const NUM_POINTS = 17;
|
||||
|
||||
const slider = new Slider(root, d.vec2f(-1, 0), d.vec2f(0.9, 0), NUM_POINTS, -0.03);
|
||||
const bezierTexture = slider.bezierTexture.createView();
|
||||
const bezierBbox = slider.bbox;
|
||||
|
||||
let qualityScale = 1.0;
|
||||
let [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
|
||||
|
||||
let textures = createTextures(root, width, height);
|
||||
let backgroundTexture = createBackgroundTexture(root, width, height);
|
||||
|
||||
const sliderElement = document.getElementById('slider') as HTMLInputElement;
|
||||
const valueElement = document.getElementById('value') as HTMLDivElement;
|
||||
|
||||
const valueRawTexture = root.device.createTexture({
|
||||
size: [width, height, 1],
|
||||
format: 'rgba8unorm',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
|
||||
});
|
||||
const valueTextureView = valueRawTexture.createView();
|
||||
|
||||
// Return a number from 0...100 as a string Zero percent...One hundred percent.
|
||||
function getPercentString(n: number): string {
|
||||
if (n === 100) return "One-hundred %";
|
||||
|
||||
const ones: string[] = [
|
||||
"Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
|
||||
"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"
|
||||
];
|
||||
|
||||
const tens: string[] = [
|
||||
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"
|
||||
];
|
||||
|
||||
// Handle 0 through 19
|
||||
if (n < 20) {
|
||||
return `${ones[n]} %`;
|
||||
}
|
||||
|
||||
// Handle 20 through 99
|
||||
const tensWord: string = tens[Math.floor(n / 10)];
|
||||
const onesWord: string = n % 10 === 0 ? "" : `-${ones[n % 10].toLowerCase()}`;
|
||||
|
||||
return `${tensWord}${onesWord} %`;
|
||||
}
|
||||
|
||||
let targetMouseX = 0.9;
|
||||
let currentMouseX = 0.9;
|
||||
|
||||
sliderElement.addEventListener('input', () => {
|
||||
const t = Number(sliderElement.value) / 100.0;
|
||||
targetMouseX = t * 1.9 - 1.0;
|
||||
valueElement.textContent = getPercentString(Number(sliderElement.value));
|
||||
(canvas as any).requestPaint();
|
||||
});
|
||||
valueElement.textContent = getPercentString(Number(sliderElement.value));
|
||||
|
||||
const filteringSampler = root['~unstable'].createSampler({
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
});
|
||||
|
||||
const camera = new CameraController(
|
||||
root,
|
||||
d.vec3f(0, 2.7, 1.9),
|
||||
d.vec3f(0, 0, 0),
|
||||
d.vec3f(0, 1, 0),
|
||||
Math.PI / 4,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const cameraUniform = camera.cameraUniform;
|
||||
|
||||
const lightUniform = root.createUniform(DirectionalLight, {
|
||||
direction: std.normalize(d.vec3f(0.19, -0.24, 0.75)),
|
||||
color: d.vec3f(1, 1, 1),
|
||||
});
|
||||
|
||||
const jellyColorUniform = root.createUniform(d.vec4f, d.vec4f(1.0, 0.45, 0.075, 1.0));
|
||||
const jellyScatterUniform = root.createUniform(d.f32, JELLY_SCATTER_STRENGTH);
|
||||
const groundColorUniform = root.createUniform(d.vec3f, d.vec3f(1.0));
|
||||
const groundTextColorUniform = root.createUniform(d.vec3f, d.vec3f(0.5));
|
||||
|
||||
const randomUniform = root.createUniform(d.vec2f);
|
||||
const blurEnabledUniform = root.createUniform(d.u32);
|
||||
|
||||
const getRay = (ndc: d.v2f) => {
|
||||
'use gpu';
|
||||
const clipPos = d.vec4f(ndc.x, ndc.y, -1.0, 1.0);
|
||||
|
||||
const invView = cameraUniform.$.viewInv;
|
||||
const invProj = cameraUniform.$.projInv;
|
||||
|
||||
const viewPos = invProj.mul(clipPos);
|
||||
const viewPosNormalized = d.vec4f(viewPos.xyz.div(viewPos.w), 1.0);
|
||||
|
||||
const worldPos = invView.mul(viewPosNormalized);
|
||||
|
||||
const rayOrigin = invView.columns[3].xyz;
|
||||
const rayDir = std.normalize(worldPos.xyz.sub(rayOrigin));
|
||||
|
||||
return Ray({
|
||||
origin: rayOrigin,
|
||||
direction: rayDir,
|
||||
});
|
||||
};
|
||||
|
||||
const getSliderBbox = () => {
|
||||
'use gpu';
|
||||
return SdfBbox({
|
||||
left: d.f32(bezierBbox[3]),
|
||||
right: d.f32(bezierBbox[1]),
|
||||
bottom: d.f32(bezierBbox[2]),
|
||||
top: d.f32(bezierBbox[0]),
|
||||
});
|
||||
};
|
||||
|
||||
const sdInflatedPolyline2D = (p: d.v2f) => {
|
||||
'use gpu';
|
||||
const bbox = getSliderBbox();
|
||||
|
||||
const uv = d.vec2f(
|
||||
(p.x - bbox.left) / (bbox.right - bbox.left),
|
||||
(bbox.top - p.y) / (bbox.top - bbox.bottom),
|
||||
);
|
||||
const clampedUV = std.saturate(uv);
|
||||
|
||||
const sampledColor = std.textureSampleLevel(bezierTexture.$, filteringSampler.$, clampedUV, 0);
|
||||
const segUnsigned = sampledColor.x;
|
||||
const progress = sampledColor.y;
|
||||
const normal = sampledColor.zw;
|
||||
|
||||
return LineInfo({
|
||||
t: progress,
|
||||
distance: segUnsigned,
|
||||
normal: normal,
|
||||
});
|
||||
};
|
||||
|
||||
const cap3D = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const endCap = slider.endCapUniform.$;
|
||||
const secondLastPoint = d.vec2f(endCap.x, endCap.y);
|
||||
const lastPoint = d.vec2f(endCap.z, endCap.w);
|
||||
|
||||
const angle = std.atan2(lastPoint.y - secondLastPoint.y, lastPoint.x - secondLastPoint.x);
|
||||
const rot = d.mat2x2f(std.cos(angle), -std.sin(angle), std.sin(angle), std.cos(angle));
|
||||
|
||||
let pieP = position.sub(d.vec3f(secondLastPoint, 0));
|
||||
pieP = d.vec3f(rot.mul(pieP.xy), pieP.z);
|
||||
const hmm = sdf.sdPie(pieP.zx, d.vec2f(1, 0), LINE_HALF_THICK);
|
||||
const extrudeEnd = sdf.opExtrudeY(pieP, hmm, 0.001) - LINE_RADIUS;
|
||||
return extrudeEnd;
|
||||
};
|
||||
|
||||
const sliderSdf3D = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const poly2D = sdInflatedPolyline2D(position.xy);
|
||||
|
||||
let finalDist = d.f32(0.0);
|
||||
if (poly2D.t > 0.94) {
|
||||
finalDist = cap3D(position);
|
||||
} else {
|
||||
const body = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - LINE_RADIUS;
|
||||
finalDist = body;
|
||||
}
|
||||
|
||||
return LineInfo({
|
||||
t: poly2D.t,
|
||||
distance: finalDist,
|
||||
normal: poly2D.normal,
|
||||
});
|
||||
};
|
||||
|
||||
const GroundParams = {
|
||||
groundThickness: 0.03,
|
||||
groundRoundness: 0.02,
|
||||
};
|
||||
|
||||
const rectangleCutoutDist = (position: d.v2f) => {
|
||||
'use gpu';
|
||||
const groundRoundness = GroundParams.groundRoundness;
|
||||
|
||||
return sdf.sdRoundedBox2d(
|
||||
position,
|
||||
d.vec2f(1 + groundRoundness, 0.2 + groundRoundness),
|
||||
0.2 + groundRoundness,
|
||||
);
|
||||
};
|
||||
|
||||
const getMainSceneDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const groundThickness = GroundParams.groundThickness;
|
||||
const groundRoundness = GroundParams.groundRoundness;
|
||||
|
||||
return sdf.opUnion(
|
||||
sdf.sdPlane(position, d.vec3f(0, 1, 0), 0.06),
|
||||
sdf.opExtrudeY(position, -rectangleCutoutDist(position.xz), groundThickness - groundRoundness) -
|
||||
groundRoundness,
|
||||
);
|
||||
};
|
||||
|
||||
const sliderApproxDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const bbox = getSliderBbox();
|
||||
|
||||
const p = position.xy;
|
||||
if (p.x < bbox.left || p.x > bbox.right || p.y < bbox.bottom || p.y > bbox.top) {
|
||||
return 1e9;
|
||||
}
|
||||
|
||||
const poly2D = sdInflatedPolyline2D(p);
|
||||
const dist3D = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - LINE_RADIUS;
|
||||
|
||||
return dist3D;
|
||||
};
|
||||
|
||||
const getSceneDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const mainScene = getMainSceneDist(position);
|
||||
const poly3D = sliderSdf3D(position);
|
||||
|
||||
const hitInfo = HitInfo();
|
||||
|
||||
if (poly3D.distance < mainScene) {
|
||||
hitInfo.distance = poly3D.distance;
|
||||
hitInfo.objectType = ObjectType.SLIDER;
|
||||
hitInfo.t = poly3D.t;
|
||||
} else {
|
||||
hitInfo.distance = mainScene;
|
||||
hitInfo.objectType = ObjectType.BACKGROUND;
|
||||
}
|
||||
return hitInfo;
|
||||
};
|
||||
|
||||
const getSceneDistForAO = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const mainScene = getMainSceneDist(position);
|
||||
const sliderApprox = sliderApproxDist(position);
|
||||
return std.min(mainScene, sliderApprox);
|
||||
};
|
||||
|
||||
const sdfSlot = tgpu.slot<(pos: d.v3f) => number>();
|
||||
|
||||
const getNormalFromSdf = tgpu.fn(
|
||||
[d.vec3f, d.f32],
|
||||
d.vec3f,
|
||||
)((position, epsilon) => {
|
||||
'use gpu';
|
||||
const k = d.vec3f(1, -1, 0);
|
||||
|
||||
const offset1 = k.xyy.mul(epsilon);
|
||||
const offset2 = k.yyx.mul(epsilon);
|
||||
const offset3 = k.yxy.mul(epsilon);
|
||||
const offset4 = k.xxx.mul(epsilon);
|
||||
|
||||
const sample1 = offset1.mul(sdfSlot.$(position.add(offset1)));
|
||||
const sample2 = offset2.mul(sdfSlot.$(position.add(offset2)));
|
||||
const sample3 = offset3.mul(sdfSlot.$(position.add(offset3)));
|
||||
const sample4 = offset4.mul(sdfSlot.$(position.add(offset4)));
|
||||
|
||||
const gradient = sample1.add(sample2).add(sample3).add(sample4);
|
||||
|
||||
return std.normalize(gradient);
|
||||
});
|
||||
|
||||
const getNormalCapSdf = getNormalFromSdf.with(sdfSlot, cap3D);
|
||||
const getNormalMainSdf = getNormalFromSdf.with(sdfSlot, getMainSceneDist);
|
||||
|
||||
const getNormalCap = (pos: d.v3f) => {
|
||||
'use gpu';
|
||||
return getNormalCapSdf(pos, 0.01);
|
||||
};
|
||||
|
||||
const getNormalMain = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
if (std.abs(position.z) > 0.22 || std.abs(position.x) > 1.02) {
|
||||
return d.vec3f(0, 1, 0);
|
||||
}
|
||||
return getNormalMainSdf(position, 0.0001);
|
||||
};
|
||||
|
||||
const getSliderNormal = (position: d.v3f, hitInfo: d.Infer<typeof HitInfo>) => {
|
||||
'use gpu';
|
||||
const poly2D = sdInflatedPolyline2D(position.xy);
|
||||
const gradient2D = poly2D.normal;
|
||||
|
||||
const threshold = LINE_HALF_THICK * 0.85;
|
||||
const absZ = std.abs(position.z);
|
||||
const zDistance = std.max(
|
||||
0,
|
||||
((absZ - threshold) * LINE_HALF_THICK) / (LINE_HALF_THICK - threshold),
|
||||
);
|
||||
const edgeDistance = LINE_RADIUS - poly2D.distance;
|
||||
|
||||
const edgeContrib = 0.9;
|
||||
const zContrib = 1.0 - edgeContrib;
|
||||
|
||||
const zDirection = std.sign(position.z);
|
||||
const zAxisVector = d.vec3f(0, 0, zDirection);
|
||||
|
||||
const edgeBlendDistance = edgeContrib * LINE_RADIUS + zContrib * LINE_HALF_THICK;
|
||||
|
||||
const blendFactor = std.smoothstep(
|
||||
edgeBlendDistance,
|
||||
0.0,
|
||||
zDistance * zContrib + edgeDistance * edgeContrib,
|
||||
);
|
||||
|
||||
const normal2D = d.vec3f(gradient2D.xy, 0);
|
||||
const blendedNormal = std.mix(zAxisVector, normal2D, blendFactor * 0.5 + 0.5);
|
||||
|
||||
let normal = std.normalize(blendedNormal);
|
||||
|
||||
if (hitInfo.t > 0.94) {
|
||||
const ratio = (hitInfo.t - 0.94) / 0.02;
|
||||
const fullNormal = getNormalCap(position);
|
||||
normal = std.normalize(std.mix(normal, fullNormal, ratio));
|
||||
}
|
||||
|
||||
return normal;
|
||||
};
|
||||
|
||||
const getNormal = (position: d.v3f, hitInfo: d.Infer<typeof HitInfo>) => {
|
||||
'use gpu';
|
||||
if (hitInfo.objectType === ObjectType.SLIDER && hitInfo.t < 0.96) {
|
||||
return getSliderNormal(position, hitInfo);
|
||||
}
|
||||
|
||||
return std.select(
|
||||
getNormalCap(position),
|
||||
getNormalMain(position),
|
||||
hitInfo.objectType === ObjectType.BACKGROUND,
|
||||
);
|
||||
};
|
||||
|
||||
const sqLength = (a: d.v3f) => {
|
||||
'use gpu';
|
||||
return std.dot(a, a);
|
||||
};
|
||||
|
||||
const getFakeShadow = (position: d.v3f, lightDir: d.v3f): d.v3f => {
|
||||
'use gpu';
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
const endCapX = slider.endCapUniform.$.x;
|
||||
|
||||
if (position.y < -GroundParams.groundThickness) {
|
||||
// Applying darkening under the ground (the shadow cast by the upper ground layer)
|
||||
const fadeSharpness = 30;
|
||||
const inset = 0.02;
|
||||
const cutout = rectangleCutoutDist(position.xz) + inset;
|
||||
const edgeDarkening = std.saturate(1 - cutout * fadeSharpness);
|
||||
|
||||
// Applying a slight gradient based on the light direction
|
||||
const lightGradient = std.saturate(-position.z * 4 * lightDir.z + 1);
|
||||
|
||||
return d
|
||||
.vec3f(1)
|
||||
.mul(edgeDarkening)
|
||||
.mul(lightGradient * 0.5);
|
||||
} else {
|
||||
const finalUV = d.vec2f(
|
||||
(position.x - position.z * lightDir.x * std.sign(lightDir.z)) * 0.5 + 0.5,
|
||||
1 - (-position.z / lightDir.z) * 0.5 - 0.2,
|
||||
);
|
||||
const data = std.textureSampleLevel(bezierTexture.$, filteringSampler.$, finalUV, 0);
|
||||
|
||||
// Normally it would be just data.y, but there transition is too sudden when the jelly is bunched up.
|
||||
// To mitigate this, we transition into a position-based transition.
|
||||
const jellySaturation = std.mix(0, data.y, std.saturate(position.x * 1.5 + 1.1));
|
||||
const shadowColor = std.mix(d.vec3f(0, 0, 0), jellyColor.rgb, jellySaturation);
|
||||
|
||||
const contrast = 20 * std.saturate(finalUV.y) * (0.8 + endCapX * 0.2);
|
||||
const shadowOffset = -0.3;
|
||||
const featherSharpness = 10;
|
||||
const uvEdgeFeather =
|
||||
std.saturate(finalUV.x * featherSharpness) *
|
||||
std.saturate((1 - finalUV.x) * featherSharpness) *
|
||||
std.saturate((1 - finalUV.y) * featherSharpness) *
|
||||
std.saturate(finalUV.y);
|
||||
const influence = std.saturate((1 - lightDir.y) * 2) * uvEdgeFeather;
|
||||
return std.mix(
|
||||
d.vec3f(1),
|
||||
std.mix(shadowColor, d.vec3f(1), std.saturate(data.x * contrast + shadowOffset)),
|
||||
influence,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateAO = (position: d.v3f, normal: d.v3f) => {
|
||||
'use gpu';
|
||||
let totalOcclusion = d.f32(0.0);
|
||||
let sampleWeight = d.f32(1.0);
|
||||
const stepDistance = AO_RADIUS / AO_STEPS;
|
||||
|
||||
for (let i = 1; i <= AO_STEPS; i++) {
|
||||
const sampleHeight = stepDistance * d.f32(i);
|
||||
const samplePosition = position.add(normal.mul(sampleHeight));
|
||||
const distanceToSurface = getSceneDistForAO(samplePosition) - AO_BIAS;
|
||||
const occlusionContribution = std.max(0.0, sampleHeight - distanceToSurface);
|
||||
totalOcclusion += occlusionContribution * sampleWeight;
|
||||
sampleWeight *= 0.5;
|
||||
if (totalOcclusion > AO_RADIUS / AO_INTENSITY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const rawAO = 1.0 - (AO_INTENSITY * totalOcclusion) / AO_RADIUS;
|
||||
return std.saturate(rawAO);
|
||||
};
|
||||
|
||||
const calculateLighting = (hitPosition: d.v3f, normal: d.v3f, rayOrigin: d.v3f) => {
|
||||
'use gpu';
|
||||
const lightDir = std.neg(lightUniform.$.direction);
|
||||
|
||||
const fakeShadow = getFakeShadow(hitPosition, lightDir);
|
||||
const diffuse = std.max(std.dot(normal, lightDir), 0.0);
|
||||
|
||||
const viewDir = std.normalize(rayOrigin.sub(hitPosition));
|
||||
const reflectDir = std.reflect(std.neg(lightDir), normal);
|
||||
const specularFactor = std.max(std.dot(viewDir, reflectDir), 0) ** SPECULAR_POWER;
|
||||
const specular = lightUniform.$.color.mul(specularFactor * SPECULAR_INTENSITY);
|
||||
|
||||
const baseColor = d.vec3f(0.9);
|
||||
|
||||
const directionalLight = baseColor.mul(lightUniform.$.color).mul(diffuse).mul(fakeShadow);
|
||||
const ambientLight = baseColor.mul(AMBIENT_COLOR).mul(AMBIENT_INTENSITY);
|
||||
|
||||
const finalSpecular = specular.mul(fakeShadow);
|
||||
|
||||
return std.saturate(directionalLight.add(ambientLight).add(finalSpecular));
|
||||
};
|
||||
|
||||
const applyAO = (litColor: d.v3f, hitPosition: d.v3f, normal: d.v3f) => {
|
||||
'use gpu';
|
||||
const ao = calculateAO(hitPosition, normal);
|
||||
const finalColor = litColor.mul(ao);
|
||||
return d.vec4f(finalColor, 1.0);
|
||||
};
|
||||
|
||||
const rayMarchNoJelly = (rayOrigin: d.v3f, rayDirection: d.v3f) => {
|
||||
'use gpu';
|
||||
let distanceFromOrigin = d.f32();
|
||||
let hit = d.f32();
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const p = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
hit = getMainSceneDist(p);
|
||||
distanceFromOrigin += hit;
|
||||
if (distanceFromOrigin > MAX_DIST || hit < SURF_DIST * 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (distanceFromOrigin < MAX_DIST) {
|
||||
return renderBackground(
|
||||
rayOrigin,
|
||||
rayDirection,
|
||||
distanceFromOrigin,
|
||||
std.select(d.f32(), 0.87, blurEnabledUniform.$ === 1),
|
||||
).rgb;
|
||||
}
|
||||
return d.vec3f();
|
||||
};
|
||||
|
||||
const renderPercentageOnGround = (hitPosition: d.v3f, center: d.v3f) => {
|
||||
'use gpu';
|
||||
|
||||
const textWidth = 1.9;
|
||||
const textHeight = 0.33;
|
||||
|
||||
if (
|
||||
std.abs(hitPosition.x - center.x) > textWidth * 0.5 ||
|
||||
std.abs(hitPosition.z - center.z) > textHeight * 0.5
|
||||
) {
|
||||
return d.vec4f();
|
||||
}
|
||||
|
||||
const localX = hitPosition.x - center.x;
|
||||
const localZ = hitPosition.z - center.z;
|
||||
|
||||
const uvX = (localX + textWidth * 0.5) / textWidth;
|
||||
const uvZ = (localZ + textHeight * 0.5) / textHeight;
|
||||
|
||||
if (uvX < 0.0 || uvX > 1.0 || uvZ < 0.0 || uvZ > 1.0) {
|
||||
return d.vec4f();
|
||||
}
|
||||
|
||||
return std.textureSampleLevel(
|
||||
rayMarchLayout.$.valueTexture,
|
||||
filteringSampler.$,
|
||||
d.vec2f(uvX, uvZ),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const renderBackground = (
|
||||
rayOrigin: d.v3f,
|
||||
rayDirection: d.v3f,
|
||||
backgroundHitDist: number,
|
||||
offset: number,
|
||||
) => {
|
||||
'use gpu';
|
||||
const hitPosition = rayOrigin.add(rayDirection.mul(backgroundHitDist));
|
||||
|
||||
const percentageSample = renderPercentageOnGround(
|
||||
hitPosition,
|
||||
d.vec3f(0, 0, 0),
|
||||
);
|
||||
|
||||
let highlights = d.f32();
|
||||
|
||||
const highlightWidth = d.f32(1);
|
||||
const highlightHeight = 0.2;
|
||||
let offsetX = d.f32();
|
||||
let offsetZ = d.f32(0.05);
|
||||
|
||||
const lightDir = lightUniform.$.direction;
|
||||
const causticScale = 0.2;
|
||||
offsetX -= lightDir.x * causticScale;
|
||||
offsetZ += lightDir.z * causticScale;
|
||||
|
||||
const endCapX = slider.endCapUniform.$.x;
|
||||
const sliderStretch = (endCapX + 1) * 0.5;
|
||||
|
||||
if (
|
||||
std.abs(hitPosition.x + offsetX) < highlightWidth &&
|
||||
std.abs(hitPosition.z + offsetZ) < highlightHeight
|
||||
) {
|
||||
const uvX_orig = ((hitPosition.x + offsetX + highlightWidth * 2) / highlightWidth) * 0.5;
|
||||
const uvZ_orig = ((hitPosition.z + offsetZ + highlightHeight * 2) / highlightHeight) * 0.5;
|
||||
|
||||
const centeredUV = d.vec2f(uvX_orig - 0.5, uvZ_orig - 0.5);
|
||||
const finalUV = d.vec2f(centeredUV.x, 1 - (std.abs(centeredUV.y - 0.5) * 2) ** 2 * 0.3);
|
||||
|
||||
const density = std.max(
|
||||
0,
|
||||
(std.textureSampleLevel(bezierTexture.$, filteringSampler.$, finalUV, 0).x - 0.25) * 8,
|
||||
);
|
||||
|
||||
const fadeX = std.smoothstep(0, -0.2, hitPosition.x - endCapX);
|
||||
const fadeZ = 1 - (std.abs(centeredUV.y - 0.5) * 2) ** 3;
|
||||
const fadeStretch = std.saturate(1 - sliderStretch);
|
||||
const edgeFade = std.saturate(fadeX) * std.saturate(fadeZ) * fadeStretch;
|
||||
|
||||
highlights = (density ** 3 * edgeFade * 3 * (1 + lightDir.z)) / 1.5;
|
||||
}
|
||||
|
||||
const originYBound = std.saturate(rayOrigin.y + 0.01);
|
||||
const posOffset = hitPosition.add(
|
||||
d.vec3f(0, 1, 0).mul(offset * (originYBound / (1.0 + originYBound)) * (1 + randf.sample() / 2)),
|
||||
);
|
||||
const newNormal = getNormalMain(posOffset);
|
||||
|
||||
// Calculate fake bounce lighting
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
const sqDist = sqLength(hitPosition.sub(d.vec3f(endCapX, 0, 0)));
|
||||
const bounceLight = jellyColor.rgb.mul((1 / (sqDist * 15 + 1)) * 0.4);
|
||||
const sideBounceLight = jellyColor.rgb
|
||||
.mul((1 / (sqDist * 40 + 1)) * 0.3)
|
||||
.mul(std.abs(newNormal.z));
|
||||
|
||||
const litColor = calculateLighting(posOffset, newNormal, rayOrigin);
|
||||
const backgroundColor = applyAO(groundColorUniform.$.mul(litColor), posOffset, newNormal)
|
||||
.add(d.vec4f(bounceLight, 0))
|
||||
.add(d.vec4f(sideBounceLight, 0));
|
||||
|
||||
const textColor = groundTextColorUniform.$;
|
||||
|
||||
return d.vec4f(
|
||||
std.mix(backgroundColor.rgb, textColor, percentageSample.x).mul(1.0 + highlights),
|
||||
1.0,
|
||||
);
|
||||
};
|
||||
|
||||
const rayMarch = (rayOrigin: d.v3f, rayDirection: d.v3f, _uv: d.v2f) => {
|
||||
'use gpu';
|
||||
let totalSteps = d.u32();
|
||||
|
||||
let backgroundDist = d.f32();
|
||||
for (let i = 0; i < MAX_STEPS; i++) {
|
||||
const p = rayOrigin.add(rayDirection.mul(backgroundDist));
|
||||
const hit = getMainSceneDist(p);
|
||||
backgroundDist += hit;
|
||||
if (hit < SURF_DIST) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const background = renderBackground(rayOrigin, rayDirection, backgroundDist, d.f32());
|
||||
|
||||
const bbox = getSliderBbox();
|
||||
const zDepth = d.f32(0.25);
|
||||
|
||||
const sliderMin = d.vec3f(bbox.left, bbox.bottom, -zDepth);
|
||||
const sliderMax = d.vec3f(bbox.right, bbox.top, zDepth);
|
||||
|
||||
const intersection = intersectBox(rayOrigin, rayDirection, sliderMin, sliderMax);
|
||||
|
||||
if (!intersection.hit) {
|
||||
return background;
|
||||
}
|
||||
|
||||
let distanceFromOrigin = std.max(d.f32(0.0), intersection.tMin);
|
||||
|
||||
for (let i = 0; i < MAX_STEPS; i++) {
|
||||
if (totalSteps >= MAX_STEPS) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
|
||||
const hitInfo = getSceneDist(currentPosition);
|
||||
distanceFromOrigin += hitInfo.distance;
|
||||
totalSteps++;
|
||||
|
||||
if (hitInfo.distance < SURF_DIST) {
|
||||
const hitPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
|
||||
if (!(hitInfo.objectType === ObjectType.SLIDER)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const N = getNormal(hitPosition, hitInfo);
|
||||
const I = rayDirection;
|
||||
const cosi = std.min(1.0, std.max(0.0, std.dot(std.neg(I), N)));
|
||||
const F = fresnelSchlick(cosi, d.f32(1.0), d.f32(JELLY_IOR));
|
||||
|
||||
const reflection = std.saturate(d.vec3f(hitPosition.y + 0.2));
|
||||
|
||||
const eta = 1.0 / JELLY_IOR;
|
||||
const k = 1.0 - eta * eta * (1.0 - cosi * cosi);
|
||||
let refractedColor = d.vec3f();
|
||||
if (k > 0.0) {
|
||||
const refrDir = std.normalize(std.add(I.mul(eta), N.mul(eta * cosi - std.sqrt(k))));
|
||||
const p = hitPosition.add(refrDir.mul(SURF_DIST * 2.0));
|
||||
const exitPos = p.add(refrDir.mul(SURF_DIST * 2.0));
|
||||
|
||||
const env = rayMarchNoJelly(exitPos, refrDir);
|
||||
const progress = hitInfo.t;
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
|
||||
const scatterTint = jellyColor.rgb.mul(1.5);
|
||||
const density = d.f32(20.0);
|
||||
const absorb = d.vec3f(1.0).sub(jellyColor.rgb).mul(density);
|
||||
|
||||
const T = beerLambert(absorb.mul(progress ** 2), 0.08);
|
||||
|
||||
const lightDir = std.neg(lightUniform.$.direction);
|
||||
|
||||
const forward = std.max(0.0, std.dot(lightDir, refrDir));
|
||||
const scatter = scatterTint.mul(jellyScatterUniform.$ * forward * progress ** 3);
|
||||
refractedColor = env.mul(T).add(scatter);
|
||||
}
|
||||
|
||||
const jelly = std.add(reflection.mul(F), refractedColor.mul(1 - F));
|
||||
|
||||
const finalJelly = std.mix(background.rgb, jelly, jellyColorUniform.$.w);
|
||||
|
||||
return d.vec4f(finalJelly, 1.0);
|
||||
}
|
||||
|
||||
if (distanceFromOrigin > backgroundDist) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return background;
|
||||
};
|
||||
|
||||
const raymarchFn = tgpu.fragmentFn({
|
||||
in: { uv: d.vec2f },
|
||||
out: d.vec4f,
|
||||
})(({ uv }) => {
|
||||
randf.seed2(randomUniform.$.mul(uv));
|
||||
|
||||
const ndc = d.vec2f(uv.x * 2 - 1, -(uv.y * 2 - 1));
|
||||
const ray = getRay(ndc);
|
||||
|
||||
const color = rayMarch(ray.origin, ray.direction, uv);
|
||||
return d.vec4f(std.tanh(color.rgb.mul(1.3)), 1);
|
||||
});
|
||||
|
||||
const fragmentMain = tgpu.fragmentFn({
|
||||
in: { uv: d.vec2f },
|
||||
out: d.vec4f,
|
||||
})((input) => {
|
||||
return std.textureSample(sampleLayout.$.currentTexture, filteringSampler.$, input.uv);
|
||||
});
|
||||
|
||||
const rayMarchPipeline = root.createRenderPipeline({
|
||||
vertex: common.fullScreenTriangle,
|
||||
fragment: raymarchFn,
|
||||
targets: { format: 'rgba8unorm' },
|
||||
});
|
||||
|
||||
const renderPipeline = root.createRenderPipeline({
|
||||
vertex: common.fullScreenTriangle,
|
||||
fragment: fragmentMain,
|
||||
targets: { format: presentationFormat },
|
||||
});
|
||||
|
||||
let lastTimeStamp = performance.now();
|
||||
let frameCount = 0;
|
||||
const taaResolver = new TAAResolver(root, width, height);
|
||||
|
||||
function createBindGroups() {
|
||||
return {
|
||||
rayMarch: root.createBindGroup(rayMarchLayout, {
|
||||
backgroundTexture: backgroundTexture.sampled,
|
||||
valueTexture: valueTextureView,
|
||||
}),
|
||||
render: [0, 1].map((frame) =>
|
||||
root.createBindGroup(sampleLayout, {
|
||||
currentTexture: taaResolver.getResolvedTexture(frame),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
(canvas as any).onpaint = () => {
|
||||
const sourceDict = { source: valueElement };
|
||||
const destDict = {
|
||||
destination: { texture: valueRawTexture },
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
try {
|
||||
(root.device.queue as any).copyElementImageToTexture(sourceDict, destDict);
|
||||
} catch (e) {
|
||||
// The copyElementImageToTexture API was recently changed to take two maps
|
||||
// (see: https://github.com/WICG/html-in-canvas#idl-changes). This snippet
|
||||
// supports the old syntax temporarily so that the demos do not break.
|
||||
(root.device.queue as any).copyElementImageToTexture(
|
||||
valueElement, width, height, { texture: valueRawTexture });
|
||||
console.log('Note: using old copyElementImageToTexture API');
|
||||
}
|
||||
|
||||
// TODO(pdr): Calculate this correctly using `getElementTransform`. For now,
|
||||
// the transform is just hard-coded.
|
||||
//const view = camera.view;
|
||||
//const proj = camera.proj;
|
||||
//const mvp = m.mat4.mul(proj, view, d.mat4x4f());
|
||||
//const sliderWidth = sliderElement.clientWidth || (canvas.clientWidth * 0.75);
|
||||
const sliderHeight = sliderElement.clientHeight || (canvas.clientHeight * 0.125);
|
||||
let x = (canvas.width / devicePixelRatio) / 8;
|
||||
let y = (canvas.height / devicePixelRatio) / 2 - (sliderHeight / 2);
|
||||
sliderElement.style.transform = `translate(${x}px, ${y}px)`;
|
||||
valueElement.style.transform = `translate(${x}px, ${y}px)`;
|
||||
};
|
||||
(canvas as any).requestPaint();
|
||||
|
||||
let bindGroups = createBindGroups();
|
||||
|
||||
let animationFrameHandle: number;
|
||||
function render(timestamp: number) {
|
||||
frameCount++;
|
||||
camera.jitter();
|
||||
const deltaTime = Math.min((timestamp - lastTimeStamp) * 0.001, 0.1);
|
||||
lastTimeStamp = timestamp;
|
||||
|
||||
randomUniform.write(d.vec2f((Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2));
|
||||
|
||||
const reduce = motionMedia.matches || transparencyMedia.matches;
|
||||
if (reduce) {
|
||||
currentMouseX = targetMouseX;
|
||||
slider.restLen = Math.max(0.001, Math.abs(currentMouseX - slider.anchor[0])) / (slider.n - 1);
|
||||
} else {
|
||||
currentMouseX += (targetMouseX - currentMouseX) * 0.08;
|
||||
slider.restLen = 1.9 / (slider.n - 1);
|
||||
}
|
||||
|
||||
slider.setDragX(currentMouseX);
|
||||
slider.update(deltaTime);
|
||||
|
||||
const currentFrame = frameCount % 2;
|
||||
|
||||
rayMarchPipeline
|
||||
.withColorAttachment({
|
||||
view: textures[currentFrame].sampled,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
})
|
||||
.with(bindGroups.rayMarch)
|
||||
.draw(3);
|
||||
|
||||
taaResolver.resolve(textures[currentFrame].sampled, frameCount, currentFrame);
|
||||
|
||||
renderPipeline
|
||||
.withColorAttachment({ view: context })
|
||||
.with(bindGroups.render[currentFrame])
|
||||
.draw(3);
|
||||
|
||||
animationFrameHandle = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
[width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
|
||||
camera.updateProjection(Math.PI / 4, width, height);
|
||||
textures = createTextures(root, width, height);
|
||||
backgroundTexture = createBackgroundTexture(root, width, height);
|
||||
taaResolver.resize(width, height);
|
||||
frameCount = 0;
|
||||
|
||||
bindGroups = createBindGroups();
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
animationFrameHandle = requestAnimationFrame(render);
|
||||
|
||||
|
||||
const hcMedia = window.matchMedia('(forced-colors: active)');
|
||||
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const contrastMedia = window.matchMedia('(prefers-contrast: more)');
|
||||
|
||||
const motionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const transparencyMedia = window.matchMedia('(prefers-reduced-transparency: reduce)');
|
||||
|
||||
const updateReducedFeatures = () => {
|
||||
const reduce = motionMedia.matches || transparencyMedia.matches;
|
||||
|
||||
if (reduce) {
|
||||
slider.damping = 1.0;
|
||||
slider.archStrength = 0.0;
|
||||
jellyScatterUniform.write(0.0);
|
||||
} else {
|
||||
slider.damping = 0.01;
|
||||
slider.archStrength = 2.0;
|
||||
jellyScatterUniform.write(JELLY_SCATTER_STRENGTH);
|
||||
}
|
||||
};
|
||||
|
||||
motionMedia.addEventListener('change', updateReducedFeatures);
|
||||
transparencyMedia.addEventListener('change', updateReducedFeatures);
|
||||
updateReducedFeatures();
|
||||
|
||||
const parseColor3 = (colorStr: string): d.Infer<typeof d.vec3f> => {
|
||||
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
return d.vec3f(parseInt(match[1]) / 255, parseInt(match[2]) / 255, parseInt(match[3]) / 255);
|
||||
}
|
||||
return d.vec3f(1.0);
|
||||
};
|
||||
|
||||
const parseColor4 = (colorStr: string): d.Infer<typeof d.vec4f> => {
|
||||
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
|
||||
if (match) {
|
||||
const a = match[4] !== undefined ? parseFloat(match[4]) : 1.0;
|
||||
return d.vec4f(parseInt(match[1]) / 255, parseInt(match[2]) / 255, parseInt(match[3]) / 255, a);
|
||||
}
|
||||
return d.vec4f(1.0, 1.0, 1.0, 1.0);
|
||||
};
|
||||
|
||||
const updateColors = () => {
|
||||
const style = getComputedStyle(sliderElement);
|
||||
|
||||
jellyColorUniform.write(parseColor4(style.color));
|
||||
groundColorUniform.write(parseColor3(style.backgroundColor));
|
||||
groundTextColorUniform.write(parseColor3(style.caretColor));
|
||||
(canvas as any).requestPaint?.();
|
||||
};
|
||||
|
||||
sliderElement.addEventListener('focus', updateColors);
|
||||
sliderElement.addEventListener('blur', updateColors);
|
||||
hcMedia.addEventListener('change', updateColors);
|
||||
darkMedia.addEventListener('change', updateColors);
|
||||
contrastMedia.addEventListener('change', updateColors);
|
||||
updateColors();
|
||||
|
||||
|
||||
export function onCleanup() {
|
||||
sliderElement.removeEventListener('focus', updateColors);
|
||||
sliderElement.removeEventListener('blur', updateColors);
|
||||
hcMedia.removeEventListener('change', updateColors);
|
||||
darkMedia.removeEventListener('change', updateColors);
|
||||
contrastMedia.removeEventListener('change', updateColors);
|
||||
motionMedia.removeEventListener('change', updateReducedFeatures);
|
||||
transparencyMedia.removeEventListener('change', updateReducedFeatures);
|
||||
cancelAnimationFrame(animationFrameHandle);
|
||||
resizeObserver.disconnect();
|
||||
root.destroy();
|
||||
}
|
||||
85
research/canvas/_raw/secpriv.md
Normal file
85
research/canvas/_raw/secpriv.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
01. What information might this feature expose to Web sites or other parties,
|
||||
and for what purposes is that exposure necessary?
|
||||
|
||||
A design requirement is to not expose any new security information, and to limit the amount of new privacy information (see: [Privacy-preserving painting](https://github.com/WICG/html-in-canvas?tab=readme-ov-file#privacy-preserving-painting)). For the purpose of enabling interactivity, this API will reveal form control rendering, scrollbar rendering, text selection, find-in-page selection, and the caret blink rate (all without revealing OS theme colors).
|
||||
|
||||
02. Do features in your specification expose the minimum amount of information
|
||||
necessary to enable their intended uses?
|
||||
|
||||
Yes.
|
||||
|
||||
03. How do the features in your specification deal with personal information,
|
||||
personally-identifiable information (PII), or information derived from
|
||||
them?
|
||||
|
||||
Since the feature renders pixels from DOM elements into canvas, those pixels can now be accessed by script, so it is important that no PII is present in those pixels. Cross-origin information, visited link information, spellcheck information, and autofill previews must not be painted. Disabling painting of this information also prevents revealing invalidation information via the `paint` event. See [privacy-preserving-painting](https://github.com/WICG/html-in-canvas/tree/main?tab=readme-ov-file#privacy-preserving-painting) for additional details.
|
||||
|
||||
04. How do the features in your specification deal with sensitive information?
|
||||
|
||||
See answer above, the feature ensures no new security information is revealed, and limits new privacy information.
|
||||
|
||||
05. Do the features in your specification introduce new state for an origin
|
||||
that persists across browsing sessions?
|
||||
|
||||
No.
|
||||
|
||||
06. Do the features in your specification expose information about the
|
||||
underlying platform to origins?
|
||||
|
||||
Similar to #1, the painting of information revealing information about the underlying platform (e.g., form autofill) is disabled, but some new platform information is revealed for interactivity, such as the caret blink rate. See [privacy-preserving-painting](https://github.com/WICG/html-in-canvas/tree/main?tab=readme-ov-file#privacy-preserving-painting) for additional details.
|
||||
|
||||
8. Does this specification allow an origin to send data to the underlying
|
||||
platform?
|
||||
|
||||
No.
|
||||
|
||||
9. Do features in this specification enable access to device sensors?
|
||||
|
||||
No.
|
||||
|
||||
10. Do features in this specification enable new script execution/loading
|
||||
mechanisms?
|
||||
|
||||
No.
|
||||
|
||||
11. Do features in this specification allow an origin to access other devices?
|
||||
|
||||
No.
|
||||
|
||||
12. Do features in this specification allow an origin some measure of control over
|
||||
a user agent's native UI?
|
||||
|
||||
No.
|
||||
|
||||
13. What temporary identifiers do the features in this specification create or
|
||||
expose to the web?
|
||||
|
||||
None.
|
||||
|
||||
14. How does this specification distinguish between behavior in first-party and
|
||||
third-party contexts?
|
||||
|
||||
There is no difference in behaviour.
|
||||
|
||||
15. How do the features in this specification work in the context of a browser’s
|
||||
Private Browsing or Incognito mode?
|
||||
|
||||
There is no difference in behaviour for these modes.
|
||||
|
||||
16. Does this specification have both "Security Considerations" and "Privacy
|
||||
Considerations" sections?
|
||||
|
||||
The specification is still in progress. The privacy issues have been highlighted in the explainer.
|
||||
|
||||
17. Do features in your specification enable origins to downgrade default
|
||||
security protections?
|
||||
|
||||
No.
|
||||
|
||||
18. How does your feature handle non-"fully active" documents?
|
||||
|
||||
It only works in fully active documents.
|
||||
|
||||
19. What should this questionnaire have asked?
|
||||
|
||||
No suggestions.
|
||||
Loading…
Add table
Add a link
Reference in a new issue