From 5509b54446e3962b537a11c6aa46dd98a27b70d3 Mon Sep 17 00:00:00 2001 From: xiaoiver Date: Sun, 28 Jul 2024 21:55:05 +0800 Subject: [PATCH] fix: use lit async tasks --- packages/lesson_004/package.json | 3 +- packages/lesson_005/package.json | 3 +- packages/lesson_006/package.json | 3 +- packages/lesson_007/package.json | 8 +- .../src/components/infinite-canvas.ts | 102 ++++--- packages/lesson_008/package.json | 4 +- .../src/components/infinite-canvas.ts | 102 ++++--- packages/lesson_009/package.json | 4 +- .../src/components/infinite-canvas.ts | 102 ++++--- packages/lesson_010/examples/main.ts | 103 +++++-- packages/lesson_010/package.json | 6 +- packages/lesson_010/src/ImageExporter.ts | 53 +++- .../src/components/image-exporter.ts | 74 +++-- .../src/components/infinite-canvas.ts | 106 ++++--- .../lesson_010/src/drawcalls/BatchManager.ts | 16 +- packages/lesson_010/src/drawcalls/Drawcall.ts | 10 +- packages/lesson_010/src/drawcalls/SDF.ts | 103 +++++-- .../lesson_010/src/drawcalls/ShadowRect.ts | 19 +- packages/lesson_010/src/plugins/Renderer.ts | 9 +- packages/lesson_010/src/shaders/sdf.ts | 49 +++- packages/lesson_010/src/shapes/Circle.ts | 44 ++- packages/lesson_010/src/shapes/Ellipse.ts | 55 ++-- packages/lesson_010/src/shapes/Rect.ts | 50 ++-- packages/lesson_010/src/shapes/Shape.ts | 15 +- .../src/shapes/mixins/Renderable.ts | 48 ++- packages/lesson_010/src/utils/browser.ts | 14 + packages/lesson_010/src/utils/hashmap.ts | 90 ++++++ packages/lesson_010/src/utils/lang.ts | 1 + packages/lesson_010/src/utils/render-cache.ts | 257 ++++++++++++++++ packages/lesson_010/src/utils/serialize.ts | 274 ++++++++++++++++-- packages/site/docs/guide/lesson-007.md | 197 ++++++++----- .../site/docs/public/figma-stroke-align.png | Bin 0 -> 79472 bytes .../site/docs/public/figma-stroke-center.png | Bin 48610 -> 0 bytes packages/site/docs/zh/guide/lesson-007.md | 198 ++++++++----- packages/site/docs/zh/guide/lesson-010.md | 240 ++++++++++++++- packages/site/docs/zh/guide/lesson-011.md | 10 +- pnpm-lock.yaml | 77 ++--- 37 files changed, 1900 insertions(+), 549 deletions(-) create mode 100644 packages/lesson_010/src/utils/hashmap.ts create mode 100644 packages/lesson_010/src/utils/render-cache.ts create mode 100644 packages/site/docs/public/figma-stroke-align.png delete mode 100644 packages/site/docs/public/figma-stroke-center.png diff --git a/packages/lesson_004/package.json b/packages/lesson_004/package.json index a389ad9..c91529b 100644 --- a/packages/lesson_004/package.json +++ b/packages/lesson_004/package.json @@ -42,7 +42,6 @@ "gl-matrix": "^3.4.3" }, "devDependencies": { - "@types/d3-color": "^3.1.0", - "@types/gl-matrix": "^3.2.0" + "@types/d3-color": "^3.1.0" } } diff --git a/packages/lesson_005/package.json b/packages/lesson_005/package.json index 0505958..a32ddb9 100644 --- a/packages/lesson_005/package.json +++ b/packages/lesson_005/package.json @@ -42,7 +42,6 @@ "gl-matrix": "^3.4.3" }, "devDependencies": { - "@types/d3-color": "^3.1.0", - "@types/gl-matrix": "^3.2.0" + "@types/d3-color": "^3.1.0" } } diff --git a/packages/lesson_006/package.json b/packages/lesson_006/package.json index d1dbcd5..3d29dc8 100644 --- a/packages/lesson_006/package.json +++ b/packages/lesson_006/package.json @@ -43,7 +43,6 @@ "gl-matrix": "^3.4.3" }, "devDependencies": { - "@types/d3-color": "^3.1.0", - "@types/gl-matrix": "^3.2.0" + "@types/d3-color": "^3.1.0" } } diff --git a/packages/lesson_007/package.json b/packages/lesson_007/package.json index d844654..6d95414 100644 --- a/packages/lesson_007/package.json +++ b/packages/lesson_007/package.json @@ -37,16 +37,16 @@ }, "dependencies": { "@antv/g-device-api": "^1.6.12", + "@lit/context": "^1.1.2", + "@lit/task": "^1.0.1", "@pixi/math": "^7.4.2", "bezier-easing": "^2.1.0", "d3-color": "^3.1.0", "eventemitter3": "^5.0.1", "gl-matrix": "^3.4.3", - "lit": "^3.1.3", - "@lit/context": "latest" + "lit": "^3.1.3" }, "devDependencies": { - "@types/d3-color": "^3.1.0", - "@types/gl-matrix": "^3.2.0" + "@types/d3-color": "^3.1.0" } } diff --git a/packages/lesson_007/src/components/infinite-canvas.ts b/packages/lesson_007/src/components/infinite-canvas.ts index fc1917a..a645f3f 100644 --- a/packages/lesson_007/src/components/infinite-canvas.ts +++ b/packages/lesson_007/src/components/infinite-canvas.ts @@ -1,9 +1,21 @@ import { html, css, LitElement } from 'lit'; import { ContextProvider } from '@lit/context'; -import { customElement, property, state, query } from 'lit/decorators.js'; +import { Task } from '@lit/task'; +import { customElement, property, state } from 'lit/decorators.js'; import { Canvas } from '../Canvas'; import { canvasContext } from './context'; +async function checkWebGPUSupport() { + if ('gpu' in navigator) { + const gpu = await navigator.gpu.requestAdapter(); + if (!gpu) { + throw new Error('No WebGPU adapter available.'); + } + } else { + throw new Error('WebGPU is not supported by the browser.'); + } +} + @customElement('ic-canvas-lesson7') export class InfiniteCanvas extends LitElement { static styles = css` @@ -24,12 +36,13 @@ export class InfiniteCanvas extends LitElement { @property() renderer = 'webgl'; + @property() + shaderCompilerPath = + 'https://unpkg.com/@antv/g-device-api@1.6.8/dist/pkg/glsl_wgsl_compiler_bg.wasm'; + @state() zoom = 100; - @query('canvas', true) - $canvas: HTMLCanvasElement; - #provider = new ContextProvider(this, { context: canvasContext }); #canvas: Canvas; @@ -50,49 +63,70 @@ export class InfiniteCanvas extends LitElement { super.disconnectedCallback(); } - async firstUpdated() { - this.#canvas = await new Canvas({ - canvas: this.$canvas, - renderer: this.renderer as 'webgl' | 'webgpu', - }).initialized; - - this.#provider.setValue(this.#canvas); - - this.#canvas.camera.onchange = () => { - this.zoom = Math.round(this.#canvas.camera.zoom * 100); - }; - - this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); - - const animate = (time?: DOMHighResTimeStamp) => { - this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); - - this.#canvas.render(); - this.#rafHandle = window.requestAnimationFrame(animate); - }; - animate(); - } - private resize(event: CustomEvent) { const detail = event.detail as { entries: ResizeObserverEntry[] }; const { width, height } = detail.entries[0].contentRect; const dpr = window.devicePixelRatio; if (width && height) { - const $canvas = this.$canvas; + const $canvas = this.#canvas.getDOM(); $canvas.width = width * dpr; $canvas.height = height * dpr; this.#canvas?.resize(width, height); } } + private initCanvas = new Task(this, { + task: async ([renderer, shaderCompilerPath]) => { + if (renderer === 'webgpu') { + await checkWebGPUSupport(); + } + + const canvas = document.createElement('canvas'); + + this.#canvas = await new Canvas({ + canvas, + renderer, + shaderCompilerPath, + }).initialized; + + this.#provider.setValue(this.#canvas); + + this.#canvas.camera.onchange = () => { + this.zoom = Math.round(this.#canvas.camera.zoom * 100); + }; + + this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); + + const animate = (time?: DOMHighResTimeStamp) => { + this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); + + this.#canvas.render(); + this.#rafHandle = window.requestAnimationFrame(animate); + }; + animate(); + + return this.#canvas.getDOM(); + }, + args: () => + [this.renderer as 'webgl' | 'webgpu', this.shaderCompilerPath] as const, + }); + render() { - return html` - - - - - `; + return this.initCanvas.render({ + pending: () => html``, + complete: ($canvas) => html` + + ${$canvas} + + + `, + error: (e: Error) => html` + + Initialize canvas failed
+ ${e.message} +
`, + }); } } diff --git a/packages/lesson_008/package.json b/packages/lesson_008/package.json index 54fb60c..c5101a2 100644 --- a/packages/lesson_008/package.json +++ b/packages/lesson_008/package.json @@ -37,18 +37,18 @@ }, "dependencies": { "@antv/g-device-api": "^1.6.12", + "@lit/context": "^1.1.2", + "@lit/task": "^1.0.1", "@pixi/math": "^7.4.2", "bezier-easing": "^2.1.0", "d3-color": "^3.1.0", "eventemitter3": "^5.0.1", "gl-matrix": "^3.4.3", "lit": "^3.1.3", - "@lit/context": "latest", "rbush": "^3.0.1" }, "devDependencies": { "@types/d3-color": "^3.1.0", - "@types/gl-matrix": "^3.2.0", "@types/rbush": "^3.0.0" } } diff --git a/packages/lesson_008/src/components/infinite-canvas.ts b/packages/lesson_008/src/components/infinite-canvas.ts index 9c3f54d..5e6911c 100644 --- a/packages/lesson_008/src/components/infinite-canvas.ts +++ b/packages/lesson_008/src/components/infinite-canvas.ts @@ -1,9 +1,21 @@ import { html, css, LitElement } from 'lit'; import { ContextProvider } from '@lit/context'; -import { customElement, property, state, query } from 'lit/decorators.js'; +import { Task } from '@lit/task'; +import { customElement, property, state } from 'lit/decorators.js'; import { Canvas } from '../Canvas'; import { canvasContext } from './context'; +async function checkWebGPUSupport() { + if ('gpu' in navigator) { + const gpu = await navigator.gpu.requestAdapter(); + if (!gpu) { + throw new Error('No WebGPU adapter available.'); + } + } else { + throw new Error('WebGPU is not supported by the browser.'); + } +} + @customElement('ic-canvas-lesson8') export class InfiniteCanvas extends LitElement { static styles = css` @@ -24,12 +36,13 @@ export class InfiniteCanvas extends LitElement { @property() renderer = 'webgl'; + @property() + shaderCompilerPath = + 'https://unpkg.com/@antv/g-device-api@1.6.8/dist/pkg/glsl_wgsl_compiler_bg.wasm'; + @state() zoom = 100; - @query('canvas', true) - $canvas: HTMLCanvasElement; - #provider = new ContextProvider(this, { context: canvasContext }); #canvas: Canvas; @@ -50,49 +63,70 @@ export class InfiniteCanvas extends LitElement { super.disconnectedCallback(); } - async firstUpdated() { - this.#canvas = await new Canvas({ - canvas: this.$canvas, - renderer: this.renderer as 'webgl' | 'webgpu', - }).initialized; - - this.#provider.setValue(this.#canvas); - - this.#canvas.pluginContext.hooks.cameraChange.tap(() => { - this.zoom = Math.round(this.#canvas.camera.zoom * 100); - }); - - this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); - - const animate = (time?: DOMHighResTimeStamp) => { - this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); - - this.#canvas.render(); - this.#rafHandle = window.requestAnimationFrame(animate); - }; - animate(); - } - private resize(event: CustomEvent) { const detail = event.detail as { entries: ResizeObserverEntry[] }; const { width, height } = detail.entries[0].contentRect; const dpr = window.devicePixelRatio; if (width && height) { - const $canvas = this.$canvas; + const $canvas = this.#canvas.getDOM(); $canvas.width = width * dpr; $canvas.height = height * dpr; this.#canvas?.resize(width, height); } } + private initCanvas = new Task(this, { + task: async ([renderer, shaderCompilerPath]) => { + if (renderer === 'webgpu') { + await checkWebGPUSupport(); + } + + const canvas = document.createElement('canvas'); + + this.#canvas = await new Canvas({ + canvas, + renderer, + shaderCompilerPath, + }).initialized; + + this.#provider.setValue(this.#canvas); + + this.#canvas.pluginContext.hooks.cameraChange.tap(() => { + this.zoom = Math.round(this.#canvas.camera.zoom * 100); + }); + + this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); + + const animate = (time?: DOMHighResTimeStamp) => { + this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); + + this.#canvas.render(); + this.#rafHandle = window.requestAnimationFrame(animate); + }; + animate(); + + return this.#canvas.getDOM(); + }, + args: () => + [this.renderer as 'webgl' | 'webgpu', this.shaderCompilerPath] as const, + }); + render() { - return html` - - - - - `; + return this.initCanvas.render({ + pending: () => html``, + complete: ($canvas) => html` + + ${$canvas} + + + `, + error: (e: Error) => html` + + Initialize canvas failed
+ ${e.message} +
`, + }); } } diff --git a/packages/lesson_009/package.json b/packages/lesson_009/package.json index a826ec5..091753d 100644 --- a/packages/lesson_009/package.json +++ b/packages/lesson_009/package.json @@ -37,18 +37,18 @@ }, "dependencies": { "@antv/g-device-api": "^1.6.12", + "@lit/context": "^1.1.2", + "@lit/task": "^1.0.1", "@pixi/math": "^7.4.2", "bezier-easing": "^2.1.0", "d3-color": "^3.1.0", "eventemitter3": "^5.0.1", "gl-matrix": "^3.4.3", "lit": "^3.1.3", - "@lit/context": "latest", "rbush": "^3.0.1" }, "devDependencies": { "@types/d3-color": "^3.1.0", - "@types/gl-matrix": "^3.2.0", "@types/rbush": "^3.0.0" } } diff --git a/packages/lesson_009/src/components/infinite-canvas.ts b/packages/lesson_009/src/components/infinite-canvas.ts index 78674e1..5f1345c 100644 --- a/packages/lesson_009/src/components/infinite-canvas.ts +++ b/packages/lesson_009/src/components/infinite-canvas.ts @@ -1,9 +1,21 @@ import { html, css, LitElement } from 'lit'; import { ContextProvider } from '@lit/context'; -import { customElement, property, state, query } from 'lit/decorators.js'; +import { Task } from '@lit/task'; +import { customElement, property, state } from 'lit/decorators.js'; import { Canvas } from '../Canvas'; import { canvasContext } from './context'; +async function checkWebGPUSupport() { + if ('gpu' in navigator) { + const gpu = await navigator.gpu.requestAdapter(); + if (!gpu) { + throw new Error('No WebGPU adapter available.'); + } + } else { + throw new Error('WebGPU is not supported by the browser.'); + } +} + @customElement('ic-canvas-lesson9') export class InfiniteCanvas extends LitElement { static styles = css` @@ -24,12 +36,13 @@ export class InfiniteCanvas extends LitElement { @property() renderer = 'webgl'; + @property() + shaderCompilerPath = + 'https://unpkg.com/@antv/g-device-api@1.6.8/dist/pkg/glsl_wgsl_compiler_bg.wasm'; + @state() zoom = 100; - @query('canvas', true) - $canvas: HTMLCanvasElement; - #provider = new ContextProvider(this, { context: canvasContext }); #canvas: Canvas; @@ -50,49 +63,70 @@ export class InfiniteCanvas extends LitElement { super.disconnectedCallback(); } - async firstUpdated() { - this.#canvas = await new Canvas({ - canvas: this.$canvas, - renderer: this.renderer as 'webgl' | 'webgpu', - }).initialized; - - this.#provider.setValue(this.#canvas); - - this.#canvas.pluginContext.hooks.cameraChange.tap(() => { - this.zoom = Math.round(this.#canvas.camera.zoom * 100); - }); - - this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); - - const animate = (time?: DOMHighResTimeStamp) => { - this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); - - this.#canvas.render(); - this.#rafHandle = window.requestAnimationFrame(animate); - }; - animate(); - } - private resize(event: CustomEvent) { const detail = event.detail as { entries: ResizeObserverEntry[] }; const { width, height } = detail.entries[0].contentRect; const dpr = window.devicePixelRatio; if (width && height) { - const $canvas = this.$canvas; + const $canvas = this.#canvas.getDOM(); $canvas.width = width * dpr; $canvas.height = height * dpr; this.#canvas?.resize(width, height); } } + private initCanvas = new Task(this, { + task: async ([renderer, shaderCompilerPath]) => { + if (renderer === 'webgpu') { + await checkWebGPUSupport(); + } + + const canvas = document.createElement('canvas'); + + this.#canvas = await new Canvas({ + canvas, + renderer, + shaderCompilerPath, + }).initialized; + + this.#provider.setValue(this.#canvas); + + this.#canvas.pluginContext.hooks.cameraChange.tap(() => { + this.zoom = Math.round(this.#canvas.camera.zoom * 100); + }); + + this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); + + const animate = (time?: DOMHighResTimeStamp) => { + this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); + + this.#canvas.render(); + this.#rafHandle = window.requestAnimationFrame(animate); + }; + animate(); + + return this.#canvas.getDOM(); + }, + args: () => + [this.renderer as 'webgl' | 'webgpu', this.shaderCompilerPath] as const, + }); + render() { - return html` - - - - - `; + return this.initCanvas.render({ + pending: () => html``, + complete: ($canvas) => html` + + ${$canvas} + + + `, + error: (e: Error) => html` + + Initialize canvas failed
+ ${e.message} +
`, + }); } } diff --git a/packages/lesson_010/examples/main.ts b/packages/lesson_010/examples/main.ts index 0c82327..fa37de6 100644 --- a/packages/lesson_010/examples/main.ts +++ b/packages/lesson_010/examples/main.ts @@ -1,4 +1,6 @@ import { Canvas, Circle } from '../src'; +import { ImageLoader } from '@loaders.gl/images'; +import { load } from '@loaders.gl/core'; const $canvas = document.getElementById('canvas') as HTMLCanvasElement; const resize = (width: number, height: number) => { @@ -14,37 +16,80 @@ resize(window.innerWidth, window.innerHeight); const canvas = await new Canvas({ canvas: $canvas, - renderer: 'webgpu', - shaderCompilerPath: - 'https://unpkg.com/@antv/g-device-api@1.6.8/dist/pkg/glsl_wgsl_compiler_bg.wasm', + // renderer: 'webgpu', + // shaderCompilerPath: + // 'https://unpkg.com/@antv/g-device-api@1.6.8/dist/pkg/glsl_wgsl_compiler_bg.wasm', }).initialized; -for (let i = 0; i < 1; i++) { - const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor( - Math.random() * 255, - )},${Math.floor(Math.random() * 255)})`; - const circle = new Circle({ - // cx: Math.random() * 1000, - // cy: Math.random() * 1000, - // r: Math.random() * 20, - cx: 300, - cy: 300, - r: 50, - fill: 'red', - // stroke: 'black', - // strokeWidth: 20, - opacity: 0.5, - // strokeOpacity: 0.5, - }); - canvas.appendChild(circle); - - // circle.addEventListener('pointerenter', () => { - // circle.fill = 'red'; - // }); - // circle.addEventListener('pointerleave', () => { - // circle.fill = fill; - // }); -} +const image = (await load( + 'https://infinitecanvas.cc/canvas.png', + // 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC', + ImageLoader, +)) as ImageBitmap; +const circle4 = new Circle({ + cx: 100, + cy: 100, + r: 50, + fill: image, + stroke: 'black', + strokeWidth: 20, + strokeOpacity: 0.5, +}); +canvas.appendChild(circle4); + +const circle = new Circle({ + cx: 300, + cy: 300, + r: 50, + fill: '#F67676', + stroke: 'black', + strokeWidth: 20, + strokeOpacity: 0.5, +}); +canvas.appendChild(circle); +circle.addEventListener('pointerenter', () => { + circle.fill = 'green'; +}); +circle.addEventListener('pointerleave', () => { + circle.fill = '#F67676'; +}); + +const circle2 = new Circle({ + cx: 200, + cy: 300, + r: 50, + fill: '#F67676', + stroke: 'black', + strokeWidth: 20, + strokeOpacity: 0.5, + strokeAlignment: 'inner', +}); +canvas.appendChild(circle2); +circle2.addEventListener('pointerenter', () => { + circle2.fill = 'green'; +}); +circle2.addEventListener('pointerleave', () => { + circle2.fill = '#F67676'; +}); + +const circle3 = new Circle({ + cx: 100, + cy: 300, + r: 50, + fill: '#F67676', + stroke: 'black', + strokeWidth: 20, + strokeOpacity: 0.5, + strokeAlignment: 'outer', + pointerEvents: 'stroke', +}); +canvas.appendChild(circle3); +circle3.addEventListener('pointerenter', () => { + circle3.stroke = 'green'; +}); +circle3.addEventListener('pointerleave', () => { + circle3.stroke = 'black'; +}); const animate = () => { canvas.render(); diff --git a/packages/lesson_010/package.json b/packages/lesson_010/package.json index 3377ebb..066e829 100644 --- a/packages/lesson_010/package.json +++ b/packages/lesson_010/package.json @@ -38,8 +38,7 @@ "dependencies": { "@antv/g-device-api": "^1.6.12", "@lit/context": "^1.1.2", - "@loaders.gl/core": "^4.2.2", - "@loaders.gl/images": "^4.2.2", + "@lit/task": "^1.0.1", "@pixi/math": "^7.4.2", "bezier-easing": "^2.1.0", "d3-color": "^3.1.0", @@ -49,8 +48,9 @@ "rbush": "^3.0.1" }, "devDependencies": { + "@loaders.gl/core": "^4.2.2", + "@loaders.gl/images": "^4.2.2", "@types/d3-color": "^3.1.0", - "@types/gl-matrix": "^3.2.0", "@types/rbush": "^3.0.0" } } diff --git a/packages/lesson_010/src/ImageExporter.ts b/packages/lesson_010/src/ImageExporter.ts index 36ae0e0..4f9672e 100644 --- a/packages/lesson_010/src/ImageExporter.ts +++ b/packages/lesson_010/src/ImageExporter.ts @@ -21,6 +21,7 @@ export interface DataURLOptions { * The image quality between 0 and 1 for image/jpeg and image/webp. */ encoderOptions: number; + grids: boolean; } export interface DownloadImageOptions { @@ -29,11 +30,16 @@ export interface DownloadImageOptions { } export interface CanvasOptions { + grids: boolean; clippingRegion: Rectangle; beforeDrawImage: (context: CanvasRenderingContext2D) => void; afterDrawImage: (context: CanvasRenderingContext2D) => void; } +export interface SVGOptions { + grids: boolean; +} + export interface ImageExporterOptions { canvas: Canvas; defaultFilename?: string; @@ -96,13 +102,58 @@ export class ImageExporter { return canvas; } - toSVGDataURL() { + toSVGDataURL(options: Partial = {}) { + const { grids } = options; const { canvas } = this.options; const { width, height } = canvas.getDOM(); const $namespace = createSVGElement('svg'); $namespace.setAttribute('width', `${width}`); $namespace.setAttribute('height', `${height}`); + + if (grids) { + const $defs = createSVGElement('defs'); + const $pattern = createSVGElement('pattern'); + $pattern.setAttribute('id', 'smallGrid'); + $pattern.setAttribute('width', '10'); + $pattern.setAttribute('height', '10'); + $pattern.setAttribute('patternUnits', 'userSpaceOnUse'); + const $path = createSVGElement('path'); + $path.setAttribute('d', 'M 10 0 L 0 0 0 10'); + $path.setAttribute('fill', 'none'); + $path.setAttribute('stroke', 'rgba(221,221,221,1)'); + $path.setAttribute('stroke-width', '0.5'); + $pattern.appendChild($path); + + const $pattern2 = createSVGElement('pattern'); + $pattern2.setAttribute('id', 'grid'); + $pattern2.setAttribute('width', '100'); + $pattern2.setAttribute('height', '100'); + $pattern2.setAttribute('patternUnits', 'userSpaceOnUse'); + const $rect = createSVGElement('rect'); + $rect.setAttribute('width', '100'); + $rect.setAttribute('height', '100'); + $rect.setAttribute('fill', 'url(#smallGrid)'); + $pattern2.appendChild($rect); + + const $path2 = createSVGElement('path'); + $path2.setAttribute('d', 'M 100 0 L 0 0 0 100'); + $path2.setAttribute('fill', 'none'); + $path2.setAttribute('stroke', 'rgba(221,221,221,1)'); + $path2.setAttribute('stroke-width', '1'); + $pattern2.appendChild($path2); + + $defs.appendChild($pattern); + $defs.appendChild($pattern2); + $namespace.appendChild($defs); + + const $rect2 = createSVGElement('rect'); + $rect2.setAttribute('width', '100%'); + $rect2.setAttribute('height', '100%'); + $rect2.setAttribute('fill', 'url(#grid)'); + $namespace.appendChild($rect2); + } + $namespace.appendChild(toSVGElement(serializeNode(canvas.root))); const svgDocType = document.implementation.createDocumentType( diff --git a/packages/lesson_010/src/components/image-exporter.ts b/packages/lesson_010/src/components/image-exporter.ts index 22c969d..5213e25 100644 --- a/packages/lesson_010/src/components/image-exporter.ts +++ b/packages/lesson_010/src/components/image-exporter.ts @@ -1,5 +1,5 @@ import { html, css, LitElement } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement, state } from 'lit/decorators.js'; import { consume } from '@lit/context'; import { canvasContext } from './context'; import type { Canvas } from '../Canvas'; @@ -15,41 +15,57 @@ export class Exporter extends LitElement { box-shadow: var(--sl-shadow-medium); background: white; } + + sl-switch { + margin-left: 24px; + } + + sl-menu-item::part(base) { + font-size: 14px; + } `; @consume({ context: canvasContext, subscribe: true }) canvas: Canvas; + @state() + grids = false; + private exporter: ImageExporter; - render() { - if (this.canvas) { - this.exporter = new ImageExporter({ canvas: this.canvas }); + private handleInputChange(event: any) { + this.grids = !this.grids; + } - this.addEventListener('sl-select', async (event: MouseEvent) => { - const selectedItem = (event.detail as any).item; - let dataURL: string; - if ( - selectedItem.value === 'download-image-png' || - selectedItem.value === 'download-image-jpeg' - ) { - const canvas = await this.exporter.toCanvas(); - dataURL = canvas.toDataURL( - `image/${selectedItem.value.split('-').reverse()[0]}`, - ); // png / jpeg - } else if (selectedItem.value === 'download-image-svg') { - dataURL = this.exporter.toSVGDataURL(); - } + connectedCallback() { + super.connectedCallback(); + this.exporter = new ImageExporter({ canvas: this.canvas }); - if (dataURL) { - this.exporter.downloadImage({ - dataURL, - name: 'infinite-canvas-screenshot', - }); - } - }); - } + this.addEventListener('sl-select', async (event: MouseEvent) => { + const selectedItem = (event.detail as any).item; + let dataURL: string; + if ( + selectedItem.value === 'download-image-png' || + selectedItem.value === 'download-image-jpeg' + ) { + const canvas = await this.exporter.toCanvas({ grids: this.grids }); + dataURL = canvas.toDataURL( + `image/${selectedItem.value.split('-').reverse()[0]}`, + ); // png / jpeg + } else if (selectedItem.value === 'download-image-svg') { + dataURL = this.exporter.toSVGDataURL({ grids: this.grids }); + } + + if (dataURL) { + this.exporter.downloadImage({ + dataURL, + name: 'infinite-canvas-screenshot', + }); + } + }); + } + render() { return html` + Grids included Download PNG image diff --git a/packages/lesson_010/src/components/infinite-canvas.ts b/packages/lesson_010/src/components/infinite-canvas.ts index 90a8143..fb8ed1a 100644 --- a/packages/lesson_010/src/components/infinite-canvas.ts +++ b/packages/lesson_010/src/components/infinite-canvas.ts @@ -1,9 +1,21 @@ import { html, css, LitElement } from 'lit'; import { ContextProvider } from '@lit/context'; -import { customElement, property, state, query } from 'lit/decorators.js'; +import { Task } from '@lit/task'; +import { customElement, property, state } from 'lit/decorators.js'; import { Canvas } from '../Canvas'; import { canvasContext } from './context'; +async function checkWebGPUSupport() { + if ('gpu' in navigator) { + const gpu = await navigator.gpu.requestAdapter(); + if (!gpu) { + throw new Error('No WebGPU adapter available.'); + } + } else { + throw new Error('WebGPU is not supported by the browser.'); + } +} + @customElement('ic-canvas-lesson10') export class InfiniteCanvas extends LitElement { static styles = css` @@ -24,12 +36,13 @@ export class InfiniteCanvas extends LitElement { @property() renderer = 'webgl'; + @property() + shaderCompilerPath = + 'https://unpkg.com/@antv/g-device-api@1.6.8/dist/pkg/glsl_wgsl_compiler_bg.wasm'; + @state() zoom = 100; - @query('canvas', true) - $canvas: HTMLCanvasElement; - #provider = new ContextProvider(this, { context: canvasContext }); #canvas: Canvas; @@ -50,50 +63,73 @@ export class InfiniteCanvas extends LitElement { super.disconnectedCallback(); } - async firstUpdated() { - this.#canvas = await new Canvas({ - canvas: this.$canvas, - renderer: this.renderer as 'webgl' | 'webgpu', - }).initialized; - - this.#provider.setValue(this.#canvas); - - this.#canvas.pluginContext.hooks.cameraChange.tap(() => { - this.zoom = Math.round(this.#canvas.camera.zoom * 100); - }); - - this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); - - const animate = (time?: DOMHighResTimeStamp) => { - this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); - - this.#canvas.render(); - this.#rafHandle = window.requestAnimationFrame(animate); - }; - animate(); - } - private resize(event: CustomEvent) { const detail = event.detail as { entries: ResizeObserverEntry[] }; const { width, height } = detail.entries[0].contentRect; const dpr = window.devicePixelRatio; if (width && height) { - const $canvas = this.$canvas; + const $canvas = this.#canvas.getDOM(); $canvas.width = width * dpr; $canvas.height = height * dpr; this.#canvas?.resize(width, height); } } + private initCanvas = new Task(this, { + task: async ([renderer, shaderCompilerPath]) => { + if (renderer === 'webgpu') { + await checkWebGPUSupport(); + } + + const canvas = document.createElement('canvas'); + + this.#canvas = await new Canvas({ + canvas, + renderer, + shaderCompilerPath, + }).initialized; + + this.#provider.setValue(this.#canvas); + + this.#canvas.pluginContext.hooks.cameraChange.tap(() => { + this.zoom = Math.round(this.#canvas.camera.zoom * 100); + }); + + this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); + + const animate = (time?: DOMHighResTimeStamp) => { + this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); + + this.#canvas.render(); + this.#rafHandle = window.requestAnimationFrame(animate); + }; + animate(); + + return this.#canvas.getDOM(); + }, + args: () => + [this.renderer as 'webgl' | 'webgpu', this.shaderCompilerPath] as const, + }); + render() { - return html` - - - - - - `; + return this.initCanvas.render({ + pending: () => html``, + complete: ($canvas) => html` + + ${$canvas} + + + + `, + error: (e: Error) => html` + + Initialize canvas failed
+ ${e.message} +
`, + }); } } diff --git a/packages/lesson_010/src/drawcalls/BatchManager.ts b/packages/lesson_010/src/drawcalls/BatchManager.ts index 8af6cf3..c5cba1c 100644 --- a/packages/lesson_010/src/drawcalls/BatchManager.ts +++ b/packages/lesson_010/src/drawcalls/BatchManager.ts @@ -1,6 +1,7 @@ import { Buffer, Device, RenderPass } from '@antv/g-device-api'; import { Drawcall, SDF, ShadowRect } from '.'; import { Circle, Ellipse, Rect, type Shape } from '../shapes'; +import { RenderCache } from '../utils/render-cache'; /** * Since a shape may have multiple drawcalls, we need to cache them and maintain an 1-to-many relationship. @@ -31,13 +32,21 @@ export class BatchManager { #instancesCache = new WeakMap(); - constructor(private device: Device) {} + #renderCache: RenderCache; + + constructor(private device: Device) { + this.#renderCache = new RenderCache(device); + } private createDrawcalls(shape: Shape, instanced = false) { return SHAPE_DRAWCALL_CTORS.get(shape.constructor as typeof Shape)?.map( (DrawcallCtor) => { // @ts-ignore - const drawcall = new DrawcallCtor(this.device, instanced); + const drawcall = new DrawcallCtor( + this.device, + this.#renderCache, + instanced, + ); drawcall.add(shape); return drawcall; }, @@ -65,7 +74,7 @@ export class BatchManager { } existed = instancedDrawcalls.find((drawcalls) => - drawcalls.every((drawcall) => drawcall.validate()), + drawcalls.every((drawcall) => drawcall.validate(shape)), ); if (!existed) { @@ -128,6 +137,7 @@ export class BatchManager { drawcall.destroy(); }); } + this.#renderCache.destroy(); } clear() { diff --git a/packages/lesson_010/src/drawcalls/Drawcall.ts b/packages/lesson_010/src/drawcalls/Drawcall.ts index ab3260a..b543788 100644 --- a/packages/lesson_010/src/drawcalls/Drawcall.ts +++ b/packages/lesson_010/src/drawcalls/Drawcall.ts @@ -1,6 +1,8 @@ import { Buffer, Device, RenderPass } from '@antv/g-device-api'; import { Shape } from '../shapes'; +import { RenderCache } from '../utils/render-cache'; +// TODO: Use a more efficient way to manage Z index. export const ZINDEX_FACTOR = 100000; export abstract class Drawcall { @@ -14,14 +16,18 @@ export abstract class Drawcall { protected geometryDirty = true; protected materialDirty = true; - constructor(protected device: Device, protected instanced: boolean) {} + constructor( + protected device: Device, + protected renderCache: RenderCache, + protected instanced: boolean, + ) {} abstract createGeometry(): void; abstract createMaterial(uniformBuffer: Buffer): void; abstract render(renderPass: RenderPass): void; abstract destroy(): void; - validate() { + validate(_: Shape) { return this.count() <= this.maxInstances - 1; } diff --git a/packages/lesson_010/src/drawcalls/SDF.ts b/packages/lesson_010/src/drawcalls/SDF.ts index a2f1261..6d2714e 100644 --- a/packages/lesson_010/src/drawcalls/SDF.ts +++ b/packages/lesson_010/src/drawcalls/SDF.ts @@ -13,15 +13,25 @@ import { VertexStepMode, Program, CompareFunction, + TextureUsage, + BindingsDescriptor, + AddressMode, + FilterMode, + MipmapFilterMode, + TransparentBlack, } from '@antv/g-device-api'; import { Circle, Ellipse, Rect, Shape } from '../shapes'; import { Drawcall, ZINDEX_FACTOR } from './Drawcall'; import { vert, frag } from '../shaders/sdf'; -import { paddingMat3 } from '../utils'; +import { isImageBitmapOrCanvases, isString, paddingMat3 } from '../utils'; -export class SDF extends Drawcall { - // protected maxInstances = 5000; +const strokeAlignmentMap = { + center: 0, + inner: 1, + outer: 2, +} as const; +export class SDF extends Drawcall { #program: Program; #fragUnitBuffer: Buffer; #instancedBuffer: Buffer; @@ -32,6 +42,10 @@ export class SDF extends Drawcall { #inputLayout: InputLayout; #bindings: Bindings; + validate(shape: Shape) { + return super.validate(shape); + } + createGeometry(): void { if (!this.#fragUnitBuffer) { this.#fragUnitBuffer = this.device.createBuffer({ @@ -85,7 +99,7 @@ export class SDF extends Drawcall { ? 'diagnostic(off,derivative_uniformity);' : ''; - this.#program = this.device.createProgram({ + this.#program = this.renderCache.createProgram({ vertex: { glsl: defines + vert, }, @@ -169,13 +183,13 @@ export class SDF extends Drawcall { ], }, ); - this.#inputLayout = this.device.createInputLayout({ + this.#inputLayout = this.renderCache.createInputLayout({ vertexBufferDescriptors, indexBufferFormat: Format.U32_R, program: this.#program, }); } else { - this.#inputLayout = this.device.createInputLayout({ + this.#inputLayout = this.renderCache.createInputLayout({ vertexBufferDescriptors, indexBufferFormat: Format.U32_R, program: this.#program, @@ -190,7 +204,7 @@ export class SDF extends Drawcall { } } - this.#pipeline = this.device.createRenderPipeline({ + this.#pipeline = this.renderCache.createRenderPipeline({ inputLayout: this.#inputLayout, program: this.#program, colorAttachmentFormats: [Format.U8_RGBA_RT], @@ -211,33 +225,61 @@ export class SDF extends Drawcall { }, }, ], + blendConstant: TransparentBlack, depthWrite: true, depthCompare: CompareFunction.GREATER, + stencilWrite: false, + stencilFront: { + compare: CompareFunction.ALWAYS, + }, + stencilBack: { + compare: CompareFunction.ALWAYS, + }, }, }); - if (this.instanced) { - this.#bindings = this.device.createBindings({ - pipeline: this.#pipeline, - uniformBufferBindings: [ - { - buffer: uniformBuffer, - }, - ], + const bindings: BindingsDescriptor = { + pipeline: this.#pipeline, + uniformBufferBindings: [ + { + buffer: uniformBuffer, + }, + ], + }; + if (!this.instanced) { + bindings.uniformBufferBindings.push({ + buffer: this.#uniformBuffer, }); - } else { - this.#bindings = this.device.createBindings({ - pipeline: this.#pipeline, - uniformBufferBindings: [ - { - buffer: uniformBuffer, - }, - { - buffer: this.#uniformBuffer, - }, - ], + } + + const { fill } = this.shapes[0]; + // TODO: Canvas Gradient + if (!isString(fill) && isImageBitmapOrCanvases(fill)) { + const texture = this.device.createTexture({ + format: Format.U8_RGBA_NORM, + width: fill.width, + height: fill.height, + usage: TextureUsage.SAMPLED, + }); + texture.setImageData([fill]); + const sampler = this.renderCache.createSampler({ + addressModeU: AddressMode.CLAMP_TO_EDGE, + addressModeV: AddressMode.CLAMP_TO_EDGE, + minFilter: FilterMode.POINT, + magFilter: FilterMode.BILINEAR, + mipmapFilter: MipmapFilterMode.LINEAR, + lodMinClamp: 0, + lodMaxClamp: 0, }); + bindings.samplerBindings = [ + { + texture, + sampler, + }, + ]; } + + this.#bindings = this.renderCache.createBindings(bindings); } render(renderPass: RenderPass) { @@ -324,9 +366,10 @@ export class SDF extends Drawcall { private generateBuffer(shape: Shape) { const { - fillRGB: { r: fr, g: fg, b: fb, opacity: fo }, + fillRGB, strokeRGB: { r: sr, g: sg, b: sb, opacity: so }, strokeWidth, + strokeAlignment, opacity, fillOpacity, strokeOpacity, @@ -354,6 +397,8 @@ export class SDF extends Drawcall { cornerRadius = r; } + const { r: fr, g: fg, b: fb, opacity: fo } = fillRGB || {}; + return [ ...size, fr / 255, @@ -367,7 +412,7 @@ export class SDF extends Drawcall { shape.globalRenderOrder / ZINDEX_FACTOR, strokeWidth, cornerRadius, - 0, + strokeAlignmentMap[strokeAlignment], opacity, fillOpacity, strokeOpacity, @@ -379,7 +424,7 @@ export class SDF extends Drawcall { innerShadowOffsetX, innerShadowOffsetY, innerShadowBlurRadius, - 0, + fillRGB ? 0 : 1, ]; } } diff --git a/packages/lesson_010/src/drawcalls/ShadowRect.ts b/packages/lesson_010/src/drawcalls/ShadowRect.ts index 70695df..fc264e3 100644 --- a/packages/lesson_010/src/drawcalls/ShadowRect.ts +++ b/packages/lesson_010/src/drawcalls/ShadowRect.ts @@ -13,6 +13,7 @@ import { VertexStepMode, Program, CompareFunction, + TransparentBlack, } from '@antv/g-device-api'; import { Rect } from '../shapes'; import { Drawcall, ZINDEX_FACTOR } from './Drawcall'; @@ -77,7 +78,7 @@ export class ShadowRect extends Drawcall { this.#inputLayout.destroy(); this.#pipeline.destroy(); } - this.#program = this.device.createProgram({ + this.#program = this.renderCache.createProgram({ vertex: { glsl: defines + vert, }, @@ -145,13 +146,13 @@ export class ShadowRect extends Drawcall { ], }, ); - this.#inputLayout = this.device.createInputLayout({ + this.#inputLayout = this.renderCache.createInputLayout({ vertexBufferDescriptors, indexBufferFormat: Format.U32_R, program: this.#program, }); } else { - this.#inputLayout = this.device.createInputLayout({ + this.#inputLayout = this.renderCache.createInputLayout({ vertexBufferDescriptors, indexBufferFormat: Format.U32_R, program: this.#program, @@ -165,7 +166,7 @@ export class ShadowRect extends Drawcall { } } - this.#pipeline = this.device.createRenderPipeline({ + this.#pipeline = this.renderCache.createRenderPipeline({ inputLayout: this.#inputLayout, program: this.#program, colorAttachmentFormats: [Format.U8_RGBA_RT], @@ -186,13 +187,21 @@ export class ShadowRect extends Drawcall { }, }, ], + blendConstant: TransparentBlack, depthWrite: false, depthCompare: CompareFunction.GREATER, + stencilWrite: false, + stencilFront: { + compare: CompareFunction.ALWAYS, + }, + stencilBack: { + compare: CompareFunction.ALWAYS, + }, }, }); if (this.instanced) { - this.#bindings = this.device.createBindings({ + this.#bindings = this.renderCache.createBindings({ pipeline: this.#pipeline, uniformBufferBindings: [ { diff --git a/packages/lesson_010/src/plugins/Renderer.ts b/packages/lesson_010/src/plugins/Renderer.ts index 71dd890..f9d1442 100644 --- a/packages/lesson_010/src/plugins/Renderer.ts +++ b/packages/lesson_010/src/plugins/Renderer.ts @@ -185,7 +185,14 @@ export class Renderer implements Plugin { }); this.#renderPass.setViewport(0, 0, width, height); - this.#grid.render(this.#device, this.#renderPass, this.#uniformBuffer); + + if ( + !this.#enableCapture || + (this.#enableCapture && this.#captureOptions.grids) + ) { + this.#grid.render(this.#device, this.#renderPass, this.#uniformBuffer); + } + this.#batchManager.clear(); this.#zIndexCounter = 1; }); diff --git a/packages/lesson_010/src/shaders/sdf.ts b/packages/lesson_010/src/shaders/sdf.ts index 963bbf4..f02c7af 100644 --- a/packages/lesson_010/src/shaders/sdf.ts +++ b/packages/lesson_010/src/shaders/sdf.ts @@ -36,11 +36,13 @@ out vec2 v_FragCoord; out float v_StrokeWidth; out vec4 v_Opacity; out float v_CornerRadius; + out float v_StrokeAlignment; out vec4 v_InnerShadowColor; out vec4 v_InnerShadow; #else #endif out vec2 v_Radius; +out vec2 v_Uv; void main() { mat3 model; @@ -50,6 +52,7 @@ void main() { vec4 strokeColor; float zIndex; float strokeWidth; + float strokeAlignment; #ifdef USE_INSTANCES model = mat3(a_Abcd.x, a_Abcd.y, 0, a_Abcd.z, a_Abcd.w, 0, a_Txty.x, a_Txty.y, 1); @@ -59,12 +62,14 @@ void main() { strokeColor = a_StrokeColor; zIndex = a_ZIndexStrokeWidth.x; strokeWidth = a_ZIndexStrokeWidth.y; + strokeAlignment = a_ZIndexStrokeWidth.w; v_FillColor = fillColor; v_StrokeColor = strokeColor; v_StrokeWidth = strokeWidth; v_Opacity = a_Opacity; v_CornerRadius = a_ZIndexStrokeWidth.z; + v_StrokeAlignment = a_ZIndexStrokeWidth.w; v_InnerShadowColor = a_InnerShadowColor; v_InnerShadow = a_InnerShadow; #else @@ -75,12 +80,23 @@ void main() { strokeColor = u_StrokeColor; zIndex = u_ZIndexStrokeWidth.x; strokeWidth = u_ZIndexStrokeWidth.y; + strokeAlignment = u_ZIndexStrokeWidth.w; #endif - vec2 radius = size + vec2(strokeWidth / 2.0); + float strokeOffset; + if (strokeAlignment < 0.5) { + strokeOffset = strokeWidth / 2.0; + } else if (strokeAlignment < 1.5) { + strokeOffset = 0.0; + } else if (strokeAlignment < 2.5) { + strokeOffset = strokeWidth; + } + + vec2 radius = size + vec2(strokeOffset); v_FragCoord = vec2(a_FragCoord * radius); v_Radius = radius; + v_Uv = (a_FragCoord + 1.0) / 2.0; gl_Position = vec4((u_ProjectionMatrix * u_ViewMatrix @@ -113,12 +129,16 @@ in vec2 v_FragCoord; in float v_StrokeWidth; in vec4 v_Opacity; in float v_CornerRadius; + in float v_StrokeAlignment; in vec4 v_InnerShadowColor; in vec4 v_InnerShadow; #else #endif in vec2 v_Radius; +in vec2 v_Uv; +uniform sampler2D u_Texture; + float epsilon = 0.000001; float sdf_circle(vec2 p, float r) { @@ -191,6 +211,7 @@ void main() { float cornerRadius; vec4 innerShadowColor; vec4 innerShadow; + float strokeAlignment; #ifdef USE_INSTANCES fillColor = v_FillColor; @@ -201,6 +222,7 @@ void main() { strokeOpacity = v_Opacity.z; shape = v_Opacity.w; cornerRadius = v_CornerRadius; + strokeAlignment = v_StrokeAlignment; innerShadowColor = v_InnerShadowColor; innerShadow = v_InnerShadow; #else @@ -212,10 +234,16 @@ void main() { strokeOpacity = u_Opacity.z; shape = u_Opacity.w; cornerRadius = u_ZIndexStrokeWidth.z; + strokeAlignment = u_ZIndexStrokeWidth.w; innerShadowColor = u_InnerShadowColor; innerShadow = u_InnerShadow; #endif + bool useFillImage = innerShadow.w > 0.5; + if (useFillImage) { + fillColor = texture(SAMPLER_2D(u_Texture), v_Uv); + } + float distance; // 'circle', 'ellipse', 'rect' if (shape < 0.5) { @@ -233,8 +261,22 @@ void main() { vec4 color = fillColor; if (strokeWidth > 0.0) { - color = mix_border_inside(over(fillColor, strokeColor), fillColor, distance + strokeWidth); - color = mix_border_inside(strokeColor, color, distance + strokeWidth / 2.0); + float d1; + float d2; + if (strokeAlignment < 0.5) { + d1 = distance + strokeWidth; + d2 = distance + strokeWidth / 2.0; + color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1); + color = mix_border_inside(strokeColor, color, d2); + } else if (strokeAlignment < 1.5) { + d1 = distance + strokeWidth; + d2 = distance; + color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1); + color = mix_border_inside(strokeColor, color, d2); + } else if (strokeAlignment < 2.5) { + d2 = distance + strokeWidth; + color = mix_border_inside(strokeColor, color, d2); + } } outputColor = color; @@ -254,7 +296,6 @@ void main() { float opacity_t = clamp(distance / antialiasedBlur, 0.0, 1.0); outputColor.a *= clamp(1.0 - distance, 0.0, 1.0) * opacity * opacity_t; - // TODO: antialiasing if (outputColor.a < epsilon) discard; } diff --git a/packages/lesson_010/src/shapes/Circle.ts b/packages/lesson_010/src/shapes/Circle.ts index e556411..ead6907 100644 --- a/packages/lesson_010/src/shapes/Circle.ts +++ b/packages/lesson_010/src/shapes/Circle.ts @@ -1,4 +1,9 @@ -import { Shape, ShapeAttributes, isFillOrStrokeAffected } from './Shape'; +import { + Shape, + ShapeAttributes, + isFillOrStrokeAffected, + strokeOffset, +} from './Shape'; import { distanceBetweenPoints } from '../utils'; import { AABB } from './AABB'; @@ -75,21 +80,31 @@ export class Circle extends Shape implements CircleAttributes { } containsPoint(x: number, y: number) { - const halfLineWidth = this.strokeWidth / 2; - const absDistance = distanceBetweenPoints(this.#cx, this.#cy, x, y); + const { + strokeWidth, + strokeAlignment, + cx, + cy, + r, + pointerEvents, + fill, + stroke, + } = this; + + const absDistance = distanceBetweenPoints(cx, cy, x, y); const [hasFill, hasStroke] = isFillOrStrokeAffected( - this.pointerEvents, - this.fill, - this.stroke, + pointerEvents, + fill, + stroke, ); if (hasFill) { - return absDistance <= this.#r; + return absDistance <= r; } if (hasStroke) { + const offset = strokeOffset(strokeAlignment, strokeWidth); return ( - absDistance >= this.#r - halfLineWidth && - absDistance <= this.#r + halfLineWidth + absDistance >= r + offset - strokeWidth && absDistance <= r + offset ); } return false; @@ -97,13 +112,14 @@ export class Circle extends Shape implements CircleAttributes { getRenderBounds() { if (this.renderBoundsDirtyFlag) { - const halfLineWidth = this.strokeWidth / 2; + const { strokeWidth, strokeAlignment, cx, cy, r } = this; + const offset = strokeOffset(strokeAlignment, strokeWidth); this.renderBoundsDirtyFlag = false; this.renderBounds = new AABB( - this.#cx - this.#r - halfLineWidth, - this.#cy - this.#r - halfLineWidth, - this.#cx + this.#r + halfLineWidth, - this.#cy + this.#r + halfLineWidth, + cx - r - offset, + cy - r - offset, + cx + r + offset, + cy + r + offset, ); } return this.renderBounds; diff --git a/packages/lesson_010/src/shapes/Ellipse.ts b/packages/lesson_010/src/shapes/Ellipse.ts index 572f406..3c8ceab 100644 --- a/packages/lesson_010/src/shapes/Ellipse.ts +++ b/packages/lesson_010/src/shapes/Ellipse.ts @@ -1,4 +1,9 @@ -import { Shape, ShapeAttributes, isFillOrStrokeAffected } from './Shape'; +import { + Shape, + ShapeAttributes, + isFillOrStrokeAffected, + strokeOffset, +} from './Shape'; import { AABB } from './AABB'; export interface EllipseAttributes extends ShapeAttributes { @@ -95,23 +100,26 @@ export class Ellipse extends Shape implements EllipseAttributes { } containsPoint(x: number, y: number) { - const { cx, cy, rx, ry, strokeWidth } = this; + const { + cx, + cy, + rx, + ry, + strokeWidth, + strokeAlignment, + pointerEvents, + fill, + stroke, + } = this; + const offset = strokeOffset(strokeAlignment, strokeWidth); - const halfLineWidth = strokeWidth / 2; const [hasFill, hasStroke] = isFillOrStrokeAffected( - this.pointerEvents, - this.fill, - this.stroke, + pointerEvents, + fill, + stroke, ); if (hasFill && hasStroke) { - return isPointInEllipse( - x, - y, - cx, - cy, - rx + halfLineWidth, - ry + halfLineWidth, - ); + return isPointInEllipse(x, y, cx, cy, rx + offset, ry + offset); } if (hasFill) { return isPointInEllipse(x, y, cx, cy, rx, ry); @@ -123,10 +131,9 @@ export class Ellipse extends Shape implements EllipseAttributes { y, cx, cy, - rx - halfLineWidth, - ry - halfLineWidth, - ) && - isPointInEllipse(x, y, cx, cy, rx + halfLineWidth, ry + halfLineWidth) + rx + offset - strokeWidth, + ry + offset - strokeWidth, + ) && isPointInEllipse(x, y, cx, cy, rx + offset, ry + offset) ); } return false; @@ -134,14 +141,14 @@ export class Ellipse extends Shape implements EllipseAttributes { getRenderBounds() { if (this.renderBoundsDirtyFlag) { - const { strokeWidth, cx, cy, rx, ry } = this; - const halfLineWidth = strokeWidth / 2; + const { strokeWidth, strokeAlignment, cx, cy, rx, ry } = this; + const offset = strokeOffset(strokeAlignment, strokeWidth); this.renderBoundsDirtyFlag = false; this.renderBounds = new AABB( - cx - rx - halfLineWidth, - cy - ry - halfLineWidth, - cx + rx + halfLineWidth, - cy + ry + halfLineWidth, + cx - rx - offset, + cy - ry - offset, + cx + rx + offset, + cy + ry + offset, ); } return this.renderBounds; diff --git a/packages/lesson_010/src/shapes/Rect.ts b/packages/lesson_010/src/shapes/Rect.ts index 4207750..d4ee144 100644 --- a/packages/lesson_010/src/shapes/Rect.ts +++ b/packages/lesson_010/src/shapes/Rect.ts @@ -1,5 +1,10 @@ import * as d3 from 'd3-color'; -import { Shape, ShapeAttributes, isFillOrStrokeAffected } from './Shape'; +import { + Shape, + ShapeAttributes, + isFillOrStrokeAffected, + strokeOffset, +} from './Shape'; import { AABB } from './AABB'; export interface RectAttributes extends ShapeAttributes { @@ -200,8 +205,9 @@ export class Rect extends Shape implements RectAttributes { } containsPoint(xx: number, yy: number) { - const { x, y, width, height, strokeWidth, cornerRadius } = this; - const halfLineWidth = strokeWidth / 2; + const { x, y, width, height, strokeWidth, strokeAlignment, cornerRadius } = + this; + const offset = strokeOffset(strokeAlignment, strokeWidth); const [hasFill, hasStroke] = isFillOrStrokeAffected( this.pointerEvents, this.dropShadowColor, @@ -212,10 +218,10 @@ export class Rect extends Shape implements RectAttributes { return isPointInRoundedRectangle( xx, yy, - x - halfLineWidth, - y - halfLineWidth, - x + width + halfLineWidth, - y + height + halfLineWidth, + x - offset, + y - offset, + x + width + offset, + y + height + offset, cornerRadius, ); } @@ -231,23 +237,24 @@ export class Rect extends Shape implements RectAttributes { ); } if (hasStroke) { + const inner = offset - strokeWidth; return ( !isPointInRoundedRectangle( xx, yy, - x + halfLineWidth, - y + halfLineWidth, - x + width - halfLineWidth, - y + height - halfLineWidth, + x - inner, + y - inner, + x + width + inner, + y + height + inner, cornerRadius, ) && isPointInRoundedRectangle( xx, yy, - x - halfLineWidth, - y - halfLineWidth, - x + width + halfLineWidth, - y + height + halfLineWidth, + x - offset, + y - offset, + x + width + offset, + y + height + offset, cornerRadius, ) ); @@ -258,22 +265,23 @@ export class Rect extends Shape implements RectAttributes { getRenderBounds() { if (this.renderBoundsDirtyFlag) { const { - strokeWidth, x, y, width, height, + strokeWidth, + strokeAlignment, dropShadowOffsetX, dropShadowOffsetY, dropShadowBlurRadius, } = this; - const halfLineWidth = strokeWidth / 2; + const offset = strokeOffset(strokeAlignment, strokeWidth); this.renderBoundsDirtyFlag = false; this.renderBounds = new AABB( - x - halfLineWidth, - y - halfLineWidth, - x + width + halfLineWidth, - y + height + halfLineWidth, + x - offset, + y - offset, + x + width + offset, + y + height + offset, ); this.renderBounds.addBounds( new AABB( diff --git a/packages/lesson_010/src/shapes/Shape.ts b/packages/lesson_010/src/shapes/Shape.ts index e3a57b9..20a214d 100644 --- a/packages/lesson_010/src/shapes/Shape.ts +++ b/packages/lesson_010/src/shapes/Shape.ts @@ -180,9 +180,22 @@ function updateTransformBackwards(target: Shape, parentTransform: Matrix) { return parentTransform; } +export function strokeOffset( + strokeAlignment: 'center' | 'inner' | 'outer', + strokeWidth: number, +) { + if (strokeAlignment === 'center') { + return strokeWidth / 2; + } else if (strokeAlignment === 'inner') { + return 0; + } else if (strokeAlignment === 'outer') { + return strokeWidth; + } +} + export function isFillOrStrokeAffected( pointerEvents: PointerEvents, - fill: string, + fill: string | TexImageSource, stroke: string, ): [boolean, boolean] { let hasFill = false; diff --git a/packages/lesson_010/src/shapes/mixins/Renderable.ts b/packages/lesson_010/src/shapes/mixins/Renderable.ts index 1410c4e..88dafcc 100644 --- a/packages/lesson_010/src/shapes/mixins/Renderable.ts +++ b/packages/lesson_010/src/shapes/mixins/Renderable.ts @@ -1,6 +1,7 @@ import * as d3 from 'd3-color'; import { AABB } from '../AABB'; import { GConstructor } from '.'; +import { isString } from '../../utils'; export interface IRenderable { /** @@ -45,7 +46,7 @@ export interface IRenderable { * * base64 image is also supported. * * HTMLImageElement is also supported. */ - fill: string | HTMLImageElement; + fill: string | TexImageSource; /** * It is a presentation attribute defining the color used to paint the outline of the shape. @@ -59,6 +60,16 @@ export interface IRenderable { */ strokeWidth: number; + /** + * This property allows to align a stroke along the outline of the current object. + * @see https://www.w3.org/TR/svg-strokes/#SpecifyingStrokeAlignment + * + * * `center`: This value indicates that the stroke for each subpath is positioned along the outline of the current stroke. The extends of the stroke increase to both sides of the outline accordingly dependent on the `stroke-width`. + * * `inner`: This value indicates that the stroke area is defined by the outline of each subpath of the current object and the computed value of the `stroke-width` property as offset orthogonal from the outline into the fill area of each subpath. The `stroke-linejoin` property must be ignored. + * * `outer`: This value indicates that the stroke area is defined by the outline of each subpath of the current object and the computed value of the `stroke-width` property as offset orthogonal from the outline away from the fill area of each subpath. + */ + strokeAlignment: 'center' | 'inner' | 'outer'; + /** * It specifies the transparency of an object or of a group of objects, * that is, the degree to which the background behind the element is overlaid. @@ -145,11 +156,12 @@ export function Renderable(Base: TBase) { boundsDirtyFlag = true; globalRenderOrder: number; - #fill: string | HTMLImageElement; + #fill: string | TexImageSource; #fillRGB: d3.RGBColor; #stroke: string; #strokeRGB: d3.RGBColor; #strokeWidth: number; + #strokeAlignment: 'center' | 'inner' | 'outer'; #opacity: number; #fillOpacity: number; #strokeOpacity: number; @@ -173,6 +185,7 @@ export function Renderable(Base: TBase) { | 'batchable' | 'visible' | 'strokeWidth' + | 'strokeAlignment' | 'innerShadowColor' | 'innerShadowOffsetX' | 'innerShadowOffsetY' @@ -190,6 +203,7 @@ export function Renderable(Base: TBase) { fill, stroke, strokeWidth, + strokeAlignment, opacity, fillOpacity, strokeOpacity, @@ -206,6 +220,7 @@ export function Renderable(Base: TBase) { this.fill = fill ?? 'black'; this.stroke = stroke ?? 'black'; this.strokeWidth = strokeWidth ?? 0; + this.strokeAlignment = strokeAlignment ?? 'center'; this.opacity = opacity ?? 1; this.fillOpacity = fillOpacity ?? 1; this.strokeOpacity = strokeOpacity ?? 1; @@ -218,18 +233,18 @@ export function Renderable(Base: TBase) { get fill() { return this.#fill; } - set fill(fill: string | HTMLImageElement) { + set fill(fill: string | TexImageSource) { if (this.#fill !== fill) { this.#fill = fill; - if (fill instanceof HTMLImageElement) { - if (!fill.complete) { - fill.onload = () => { - this.renderDirtyFlag = true; - }; - } - } else { + if (isString(fill)) { this.#fillRGB = d3.rgb(fill); + } else { + // if (!fill.complete) { + // fill.onload = () => { + // this.renderDirtyFlag = true; + // }; + // } } this.renderDirtyFlag = true; } @@ -265,10 +280,19 @@ export function Renderable(Base: TBase) { } } + get strokeAlignment() { + return this.#strokeAlignment; + } + set strokeAlignment(strokeAlignment: 'center' | 'inner' | 'outer') { + if (this.#strokeAlignment !== strokeAlignment) { + this.#strokeAlignment = strokeAlignment; + this.renderDirtyFlag = true; + } + } + get opacity() { return this.#opacity; } - set opacity(opacity: number) { if (this.#opacity !== opacity) { this.#opacity = opacity; @@ -279,7 +303,6 @@ export function Renderable(Base: TBase) { get fillOpacity() { return this.#fillOpacity; } - set fillOpacity(fillOpacity: number) { if (this.#fillOpacity !== fillOpacity) { this.#fillOpacity = fillOpacity; @@ -290,7 +313,6 @@ export function Renderable(Base: TBase) { get strokeOpacity() { return this.#strokeOpacity; } - set strokeOpacity(strokeOpacity: number) { if (this.#strokeOpacity !== strokeOpacity) { this.#strokeOpacity = strokeOpacity; diff --git a/packages/lesson_010/src/utils/browser.ts b/packages/lesson_010/src/utils/browser.ts index 79c4200..634278b 100644 --- a/packages/lesson_010/src/utils/browser.ts +++ b/packages/lesson_010/src/utils/browser.ts @@ -17,3 +17,17 @@ export const getGlobalThis = (): typeof globalThis => { export function createSVGElement(type: string, doc?: Document): SVGElement { return (doc || document).createElementNS('http://www.w3.org/2000/svg', type); } + +export function isImageBitmapOrCanvases( + data: TexImageSource, +): data is ImageBitmap | HTMLCanvasElement | OffscreenCanvas { + return ( + data instanceof ImageBitmap || + data instanceof HTMLCanvasElement || + data instanceof OffscreenCanvas + ); +} + +export function isVideo(data: TexImageSource): data is HTMLVideoElement { + return data instanceof HTMLVideoElement; +} diff --git a/packages/lesson_010/src/utils/hashmap.ts b/packages/lesson_010/src/utils/hashmap.ts new file mode 100644 index 0000000..ee8a784 --- /dev/null +++ b/packages/lesson_010/src/utils/hashmap.ts @@ -0,0 +1,90 @@ +// Jenkins One-at-a-Time hash from http://www.burtleburtle.net/bob/hash/doobs.html +export function hashCodeNumberUpdate(hash: number, v: number = 0): number { + hash += v; + hash += hash << 10; + hash += hash >>> 6; + return hash >>> 0; +} + +export function hashCodeNumberFinish(hash: number): number { + hash += hash << 3; + hash ^= hash >>> 11; + hash += hash << 15; + return hash >>> 0; +} + +// Pass this as a hash function to use a one-bucket HashMap (equivalent to linear search in an array), +// which can be efficient for small numbers of items. +export function nullHashFunc(): number { + return 0; +} + +export type EqualFunc = (a: K, b: K) => boolean; +export type HashFunc = (a: K) => number; + +class HashBucket { + keys: K[] = []; + values: V[] = []; +} + +export class HashMap { + buckets = new Map>(); + + constructor( + private keyEqualFunc: EqualFunc, + private keyHashFunc: HashFunc, + ) {} + + private findBucketIndex(bucket: HashBucket, k: K): number { + for (let i = 0; i < bucket.keys.length; i++) + if (this.keyEqualFunc(k, bucket.keys[i])) return i; + return -1; + } + + private findBucket(k: K): HashBucket | undefined { + const bw = this.keyHashFunc(k); + return this.buckets.get(bw); + } + + get(k: K): V | null { + const bucket = this.findBucket(k); + if (bucket === undefined) return null; + const bi = this.findBucketIndex(bucket, k); + if (bi < 0) return null; + return bucket.values[bi]; + } + + add(k: K, v: V): void { + const bw = this.keyHashFunc(k); + if (this.buckets.get(bw) === undefined) + this.buckets.set(bw, new HashBucket()); + const bucket = this.buckets.get(bw)!; + bucket.keys.push(k); + bucket.values.push(v); + } + + delete(k: K): void { + const bucket = this.findBucket(k); + if (bucket === undefined) return; + const bi = this.findBucketIndex(bucket, k); + if (bi === -1) return; + bucket.keys.splice(bi, 1); + bucket.values.splice(bi, 1); + } + + clear(): void { + this.buckets.clear(); + } + + size(): number { + let acc = 0; + for (const bucket of this.buckets.values()) acc += bucket.values.length; + return acc; + } + + *values(): IterableIterator { + for (const bucket of this.buckets.values()) + for (let j = bucket.values.length - 1; j >= 0; j--) + yield bucket.values[j]; + } +} diff --git a/packages/lesson_010/src/utils/lang.ts b/packages/lesson_010/src/utils/lang.ts index 5fe15a4..5eeded5 100644 --- a/packages/lesson_010/src/utils/lang.ts +++ b/packages/lesson_010/src/utils/lang.ts @@ -6,6 +6,7 @@ export const isBoolean = (arg): arg is boolean => arg === !!arg; export const isFunction = (val): val is Function => typeof val === 'function'; export const isUndefined = (val): val is undefined => val === undefined; export const isNil = (val): val is null | undefined => val == null; +export const isString = (a): a is string => typeof a === 'string'; export function camelToKebabCase(str: string) { return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); } diff --git a/packages/lesson_010/src/utils/render-cache.ts b/packages/lesson_010/src/utils/render-cache.ts new file mode 100644 index 0000000..9cf6fc2 --- /dev/null +++ b/packages/lesson_010/src/utils/render-cache.ts @@ -0,0 +1,257 @@ +import type { + AttachmentState, + Bindings, + BindingsDescriptor, + ChannelBlendState, + Color, + Device, + InputLayout, + InputLayoutDescriptor, + MegaStateDescriptor, + Program, + ProgramDescriptor, + RenderPipeline, + RenderPipelineDescriptor, + Sampler, + SamplerDescriptor, +} from '@antv/g-device-api'; +import { + TransparentBlack, + bindingsDescriptorCopy, + bindingsDescriptorEquals, + inputLayoutDescriptorCopy, + inputLayoutDescriptorEquals, + renderPipelineDescriptorCopy, + renderPipelineDescriptorEquals, + samplerDescriptorEquals, +} from '@antv/g-device-api'; +import { + HashMap, + hashCodeNumberFinish, + hashCodeNumberUpdate, + nullHashFunc, +} from './hashmap'; + +function blendStateHash(hash: number, a: ChannelBlendState): number { + hash = hashCodeNumberUpdate(hash, a.blendMode); + hash = hashCodeNumberUpdate(hash, a.blendSrcFactor); + hash = hashCodeNumberUpdate(hash, a.blendDstFactor); + return hash; +} + +function attachmentStateHash(hash: number, a: AttachmentState): number { + hash = blendStateHash(hash, a.rgbBlendState); + hash = blendStateHash(hash, a.alphaBlendState); + hash = hashCodeNumberUpdate(hash, a.channelWriteMask); + return hash; +} + +function colorHash(hash: number, a: Color): number { + hash = hashCodeNumberUpdate( + hash, + (a.r << 24) | (a.g << 16) | (a.b << 8) | a.a, + ); + return hash; +} + +function megaStateDescriptorHash(hash: number, a: MegaStateDescriptor): number { + for (let i = 0; i < a.attachmentsState.length; i++) + hash = attachmentStateHash(hash, a.attachmentsState[i]); + hash = colorHash(hash, a.blendConstant || TransparentBlack); + hash = hashCodeNumberUpdate(hash, a.depthCompare); + hash = hashCodeNumberUpdate(hash, a.depthWrite ? 1 : 0); + hash = hashCodeNumberUpdate(hash, a.stencilFront?.compare); + hash = hashCodeNumberUpdate(hash, a.stencilFront?.passOp); + hash = hashCodeNumberUpdate(hash, a.stencilFront?.failOp); + hash = hashCodeNumberUpdate(hash, a.stencilFront?.depthFailOp); + hash = hashCodeNumberUpdate(hash, a.stencilBack?.compare); + hash = hashCodeNumberUpdate(hash, a.stencilBack?.passOp); + hash = hashCodeNumberUpdate(hash, a.stencilBack?.failOp); + hash = hashCodeNumberUpdate(hash, a.stencilBack?.depthFailOp); + hash = hashCodeNumberUpdate(hash, a.stencilWrite ? 1 : 0); + hash = hashCodeNumberUpdate(hash, a.cullMode); + hash = hashCodeNumberUpdate(hash, a.frontFace ? 1 : 0); + hash = hashCodeNumberUpdate(hash, a.polygonOffset ? 1 : 0); + return hash; +} + +function renderPipelineDescriptorHash(a: RenderPipelineDescriptor): number { + let hash = 0; + hash = hashCodeNumberUpdate(hash, a.program.id); + if (a.inputLayout !== null) + hash = hashCodeNumberUpdate(hash, a.inputLayout.id); + hash = megaStateDescriptorHash(hash, a.megaStateDescriptor!); + for (let i = 0; i < a.colorAttachmentFormats.length; i++) + hash = hashCodeNumberUpdate(hash, a.colorAttachmentFormats[i] || 0); + hash = hashCodeNumberUpdate(hash, a.depthStencilAttachmentFormat || 0); + return hashCodeNumberFinish(hash); +} + +function bindingsDescriptorHash(a: BindingsDescriptor): number { + let hash = 0; + if (a.samplerBindings) { + for (let i = 0; i < a.samplerBindings.length; i++) { + const binding = a.samplerBindings[i]; + if (binding !== null && binding.texture !== null) + hash = hashCodeNumberUpdate(hash, binding.texture.id); + } + } + if (a.uniformBufferBindings) { + for (let i = 0; i < a.uniformBufferBindings.length; i++) { + const binding = a.uniformBufferBindings[i]; + if (binding !== null && binding.buffer !== null) { + hash = hashCodeNumberUpdate(hash, binding.buffer.id); + hash = hashCodeNumberUpdate(hash, binding.binding); + hash = hashCodeNumberUpdate(hash, binding.offset); + hash = hashCodeNumberUpdate(hash, binding.size); + } + } + } + if (a.storageBufferBindings) { + for (let i = 0; i < a.storageBufferBindings.length; i++) { + const binding = a.storageBufferBindings[i]; + if (binding !== null && binding.buffer !== null) { + hash = hashCodeNumberUpdate(hash, binding.buffer.id); + hash = hashCodeNumberUpdate(hash, binding.binding); + hash = hashCodeNumberUpdate(hash, binding.offset); + hash = hashCodeNumberUpdate(hash, binding.size); + } + } + } + if (a.storageTextureBindings) { + for (let i = 0; i < a.storageTextureBindings.length; i++) { + const binding = a.storageTextureBindings[i]; + if (binding !== null && binding.texture !== null) { + hash = hashCodeNumberUpdate(hash, binding.texture.id); + hash = hashCodeNumberUpdate(hash, binding.binding); + } + } + } + return hashCodeNumberFinish(hash); +} + +function programDescriptorEquals( + a: ProgramDescriptor, + b: ProgramDescriptor, +): boolean { + return ( + a.vertex?.glsl === b.vertex?.glsl && a.fragment?.glsl === b.fragment?.glsl + ); +} + +function programDescriptorCopy( + a: Readonly, +): ProgramDescriptor { + return { + vertex: { + glsl: a.vertex?.glsl, + }, + fragment: { + glsl: a.fragment?.glsl, + }, + }; +} + +export class RenderCache { + constructor(private device: Device) {} + + private bindingsCache = new HashMap( + bindingsDescriptorEquals, + bindingsDescriptorHash, + ); + + private renderPipelinesCache = new HashMap< + RenderPipelineDescriptor, + RenderPipeline + >(renderPipelineDescriptorEquals, renderPipelineDescriptorHash); + + private inputLayoutsCache = new HashMap( + inputLayoutDescriptorEquals, + nullHashFunc, + ); + + private programCache = new HashMap( + programDescriptorEquals, + nullHashFunc, + ); + + private samplerCache = new HashMap( + samplerDescriptorEquals, + nullHashFunc, + ); + + createBindings(descriptor: BindingsDescriptor): Bindings { + let bindings = this.bindingsCache.get(descriptor); + if (bindings === null) { + const descriptorCopy = bindingsDescriptorCopy(descriptor); + + descriptorCopy.uniformBufferBindings = + descriptorCopy.uniformBufferBindings?.filter( + ({ size }) => size && size > 0, + ); + + bindings = this.device.createBindings(descriptorCopy); + this.bindingsCache.add(descriptorCopy, bindings); + } + return bindings; + } + + createRenderPipeline(descriptor: RenderPipelineDescriptor): RenderPipeline { + let renderPipeline = this.renderPipelinesCache.get(descriptor); + if (renderPipeline === null) { + const descriptorCopy = renderPipelineDescriptorCopy(descriptor); + descriptorCopy.colorAttachmentFormats = + descriptorCopy.colorAttachmentFormats.filter((f) => f); + renderPipeline = this.device.createRenderPipeline(descriptorCopy); + this.renderPipelinesCache.add(descriptorCopy, renderPipeline); + } + return renderPipeline; + } + + createInputLayout(descriptor: InputLayoutDescriptor): InputLayout { + // remove hollows + descriptor.vertexBufferDescriptors = + descriptor.vertexBufferDescriptors.filter((d) => !!d); + let inputLayout = this.inputLayoutsCache.get(descriptor); + if (inputLayout === null) { + const descriptorCopy = inputLayoutDescriptorCopy(descriptor); + inputLayout = this.device.createInputLayout(descriptorCopy); + this.inputLayoutsCache.add(descriptorCopy, inputLayout); + } + return inputLayout; + } + + createProgram(descriptor: ProgramDescriptor): Program { + let program = this.programCache.get(descriptor); + if (program === null) { + const descriptorCopy = programDescriptorCopy(descriptor); + program = this.device.createProgram(descriptor); + this.programCache.add(descriptorCopy, program); + } + return program; + } + + createSampler(descriptor: SamplerDescriptor): Sampler { + let sampler = this.samplerCache.get(descriptor); + if (sampler === null) { + sampler = this.device.createSampler(descriptor); + this.samplerCache.add(descriptor, sampler); + } + return sampler; + } + + destroy(): void { + for (const bindings of this.bindingsCache.values()) bindings.destroy(); + for (const renderPipeline of this.renderPipelinesCache.values()) + renderPipeline.destroy(); + for (const inputLayout of this.inputLayoutsCache.values()) + inputLayout.destroy(); + for (const program of this.programCache.values()) program.destroy(); + for (const sampler of this.samplerCache.values()) sampler.destroy(); + this.bindingsCache.clear(); + this.renderPipelinesCache.clear(); + this.inputLayoutsCache.clear(); + this.programCache.clear(); + this.samplerCache.clear(); + } +} diff --git a/packages/lesson_010/src/utils/serialize.ts b/packages/lesson_010/src/utils/serialize.ts index a849381..91d040b 100644 --- a/packages/lesson_010/src/utils/serialize.ts +++ b/packages/lesson_010/src/utils/serialize.ts @@ -31,6 +31,7 @@ const renderableAttributes = [ 'fill', 'stroke', 'strokeWidth', + 'strokeAlignment', 'opacity', 'fillOpacity', 'strokeOpacity', @@ -63,12 +64,13 @@ type EllipseAttributeName = (typeof ellipseAttributes)[number]; type RectAttributeName = (typeof rectAttributes)[number]; interface SerializedNode { + uid: number; type: 'g' | 'circle' | 'ellipse' | 'rect'; - attributes?: Record & + attributes?: Pick & Record<'transform', SerializedTransform> & - Partial> & - Partial> & - Partial>; + Partial> & + Partial> & + Partial>; children?: SerializedNode[]; } @@ -122,18 +124,19 @@ export function deserializeNode(data: SerializedNode): Shape { export function serializeNode(node: Shape): SerializedNode { const [type, attributes] = typeofShape(node); - const data: SerializedNode = { + const serialized: SerializedNode = { + uid: node.uid, type, attributes: [...commonAttributes, ...attributes].reduce((prev, cur) => { prev[cur] = node[cur]; return prev; - }, {} as Record & Record<'transform', SerializedTransform>), + }, {}), }; - data.attributes.transform = serializeTransform(node.transform); - data.children = node.children.map(serializeNode); + serialized.attributes.transform = serializeTransform(node.transform); + serialized.children = node.children.map(serializeNode); - return data; + return serialized; } export function serializeTransform(transform: Transform): SerializedTransform { @@ -158,6 +161,185 @@ export function serializeTransform(transform: Transform): SerializedTransform { }; } +/** + * @see https://stackoverflow.com/questions/74958705/how-to-simulate-stroke-align-stroke-alignment-in-svg + * @example + * ```html + * + * + * + * + * ``` + */ +function exportInnerOrOuterStrokeAlignment( + node: SerializedNode, + element: SVGElement, + $g: SVGElement, +) { + const { type, attributes } = node; + const $stroke = element.cloneNode() as SVGElement; + element.setAttribute('stroke', 'none'); + $stroke.setAttribute('fill', 'none'); + + const { strokeWidth, strokeAlignment } = attributes; + const innerStrokeAlignment = strokeAlignment === 'inner'; + const halfStrokeWidth = strokeWidth / 2; + + if (type === 'circle') { + const { r } = attributes; + const offset = innerStrokeAlignment ? -halfStrokeWidth : halfStrokeWidth; + $stroke.setAttribute('r', `${r + offset}`); + } else if (type === 'ellipse') { + const { rx, ry } = attributes; + const offset = innerStrokeAlignment ? -halfStrokeWidth : halfStrokeWidth; + $stroke.setAttribute('rx', `${rx + offset}`); + $stroke.setAttribute('ry', `${ry + offset}`); + } else if (type === 'rect') { + const { x, y, width, height, strokeWidth } = attributes; + $stroke.setAttribute( + 'x', + `${x + (innerStrokeAlignment ? halfStrokeWidth : -halfStrokeWidth)}`, + ); + $stroke.setAttribute( + 'y', + `${y + (innerStrokeAlignment ? halfStrokeWidth : -halfStrokeWidth)}`, + ); + $stroke.setAttribute( + 'width', + `${width + (innerStrokeAlignment ? -strokeWidth : strokeWidth)}`, + ); + $stroke.setAttribute( + 'height', + `${height + (innerStrokeAlignment ? -strokeWidth : strokeWidth)}`, + ); + } + + $g.appendChild($stroke); +} + +/** + * Use filter to create inner shadow. + * @see https://stackoverflow.com/questions/69799051/creating-inner-shadow-in-svg + * @example + * ```html + * + * + * + * + * + * + * + * + * ``` + */ +export function exportInnerShadow( + node: SerializedNode, + element: SVGElement, + $g: SVGElement, +) { + const { + uid, + type, + attributes: { + innerShadowOffsetX, + innerShadowOffsetY, + innerShadowBlurRadius, + // innerShadowColor, + r, + rx, + ry, + width, + height, + }, + } = node; + + const $defs = createSVGElement('defs'); + const $filter = createSVGElement('filter'); + $filter.id = `filter_${uid}`; + + let filterW = 0; + let filterH = 0; + if (type === 'circle') { + filterW = r * 2 + innerShadowOffsetX; + filterH = r * 2 + innerShadowOffsetY; + } else if (type === 'ellipse') { + filterW = rx * 2 + innerShadowOffsetX; + filterH = ry * 2 + innerShadowOffsetY; + } else if (type === 'rect') { + filterW = width + innerShadowOffsetX; + filterH = height + innerShadowOffsetY; + } + $filter.setAttribute('x', '0'); + $filter.setAttribute('y', '0'); + $filter.setAttribute('width', `${filterW}`); + $filter.setAttribute('height', `${filterH}`); + $filter.setAttribute('filterUnits', 'userSpaceOnUse'); + $filter.setAttribute('color-interpolation-filters', 'sRGB'); + + const $feFlood = createSVGElement('feFlood'); + $feFlood.setAttribute('flood-opacity', '0'); + $feFlood.setAttribute('result', 'BackgroundImageFix'); + $filter.appendChild($feFlood); + + const $feBlend = createSVGElement('feBlend'); + $feBlend.setAttribute('mode', 'normal'); + $feBlend.setAttribute('in', 'SourceGraphic'); + $feBlend.setAttribute('in2', 'BackgroundImageFix'); + $feBlend.setAttribute('result', 'shape'); + $filter.appendChild($feBlend); + + // + const $feColorMatrix = createSVGElement('feColorMatrix'); + $feColorMatrix.setAttribute('in', 'SourceAlpha'); + $feColorMatrix.setAttribute('type', 'matrix'); + $feColorMatrix.setAttribute('values', ''); + $feColorMatrix.setAttribute('result', 'hardAlpha'); + $filter.appendChild($feColorMatrix); + + // + const $feMorphology = createSVGElement('feMorphology'); + $feMorphology.setAttribute('radius', '8'); + $feMorphology.setAttribute('operator', 'dilate'); + $feMorphology.setAttribute('in', 'SourceAlpha'); + $feMorphology.setAttribute('result', 'effect1_innerShadow_2429_2'); + $filter.appendChild($feMorphology); + + const $feOffset = createSVGElement('feOffset'); + $feOffset.setAttribute('dx', `${innerShadowOffsetX}`); + $feOffset.setAttribute('dy', `${innerShadowOffsetY}`); + $filter.appendChild($feOffset); + + const $feGaussianBlur = createSVGElement('feGaussianBlur'); + $feGaussianBlur.setAttribute('stdDeviation', `${innerShadowBlurRadius / 2}`); + $filter.appendChild($feGaussianBlur); + + // + const $feComposite = createSVGElement('feComposite'); + $feComposite.setAttribute('in2', 'hardAlpha'); + $feComposite.setAttribute('operator', 'arithmetic'); + $feComposite.setAttribute('k2', '-1'); + $feComposite.setAttribute('k3', '1'); + $filter.appendChild($feComposite); + + // + const $feColorMatrix2 = createSVGElement('feColorMatrix'); + $feColorMatrix2.setAttribute('type', 'matrix'); + $feColorMatrix2.setAttribute('values', ''); + $filter.appendChild($feColorMatrix2); + + // + const $feBlend2 = createSVGElement('feBlend'); + $feBlend2.setAttribute('mode', 'normal'); + $feBlend2.setAttribute('in2', 'shape'); + $feBlend2.setAttribute('result', 'effect1_innerShadow_2429_2'); + $filter.appendChild($feBlend2); + + $defs.appendChild($filter); + + $g.appendChild($defs); + $g.setAttribute('filter', `url(#${$filter.id})`); +} + export function toSVGElement(node: SerializedNode) { const { type, attributes, children } = node; const element = createSVGElement(type); @@ -171,35 +353,87 @@ export function toSVGElement(node: SerializedNode) { innerShadowColor, innerShadowOffsetX, innerShadowOffsetY, + strokeAlignment, ...rest } = attributes; Object.entries(rest).forEach(([key, value]) => { element.setAttribute(camelToKebabCase(key), `${value}`); }); - let $parentGroup = element; - if (children && children.length > 0) { - if (type !== 'g') { - $parentGroup = createSVGElement('g'); - $parentGroup.appendChild(element); - } + // TODO: outerShadow in Rect + + const innerStrokeAlignment = strokeAlignment === 'inner'; + const outerStrokeAlignment = strokeAlignment === 'outer'; + const innerOrOuterStrokeAlignment = + innerStrokeAlignment || outerStrokeAlignment; + + /** + * In the vast majority of cases, it is the element itself. + * + * Here's 3 examples where it's not the element itself but a element as its parent. + * @example + * + * When the element has children. + * ```html + * + * + * + * + * + * ``` + * + * When strokeAlignment is 'inner' or 'outer'. + * ```html + * + * + * + * + * ``` + * + * `innerShadow` is implemented as a filter effect. + * ```html + * + * + * + * + * + * + * ``` + */ + let $g: SVGElement; + if ( + (children && children.length > 0 && type !== 'g') || + innerOrOuterStrokeAlignment || + innerShadowBlurRadius > 0 + ) { + $g = createSVGElement('g'); + $g.appendChild(element); } + if (innerOrOuterStrokeAlignment) { + exportInnerOrOuterStrokeAlignment(node, element, $g); + } + if (innerShadowBlurRadius > 0) { + exportInnerShadow(node, element, $g); + } + + $g = $g || element; + // @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/visibility - $parentGroup.setAttribute('visibility', visible ? 'visible' : 'hidden'); + $g.setAttribute('visibility', visible ? 'visible' : 'hidden'); - $parentGroup.setAttribute( + $g.setAttribute( 'transform', `matrix(${transform.scale.x},${transform.skew.x},${transform.skew.y},${transform.scale.y},${transform.position.x},${transform.position.y})`, ); - $parentGroup.setAttribute( + $g.setAttribute( 'transform-origin', `${transform.pivot.x} ${transform.pivot.y}`, ); children.map(toSVGElement).forEach((child) => { - $parentGroup.appendChild(child); + $g.appendChild(child); }); - return element; + return $g; } diff --git a/packages/site/docs/guide/lesson-007.md b/packages/site/docs/guide/lesson-007.md index 1239e0d..8ebfb20 100644 --- a/packages/site/docs/guide/lesson-007.md +++ b/packages/site/docs/guide/lesson-007.md @@ -6,9 +6,9 @@ outline: deep In this lesson, you will learn the following: -- Developing Web UI with Lit and Shoelace -- Implementing a canvas component -- Implementing a zoom toolbar component +- Developing Web UI with Lit and Shoelace +- Implementing a canvas component +- Implementing a zoom toolbar component
@@ -39,8 +39,8 @@ We define `renderer` property with decorator, so that we can use it with such sy import { property } from 'lit/decorators.js'; export class InfiniteCanvas extends LitElement { - @property() - renderer = 'webgl'; + @property() + renderer = 'webgl'; } ``` @@ -48,16 +48,16 @@ We want the canvas to follow the page's width and height, and Shoelace provides ```ts export class InfiniteCanvas extends LitElement { - @query('canvas', true) - $canvas: HTMLCanvasElement; - - render() { - return html` - - - - `; - } + @query('canvas', true) + $canvas: HTMLCanvasElement; + + render() { + return html` + + + + `; + } } ``` @@ -65,48 +65,96 @@ Listens for size changes during the [connectedCallback] lifecycle and unlistsens ```ts export class InfiniteCanvas extends LitElement { - connectedCallback() { - this.addEventListener('sl-resize', this.resize); - } - disconnectedCallback() { - this.removeEventListener('sl-resize', this.resize); - } + connectedCallback() { + this.addEventListener('sl-resize', this.resize); + } + disconnectedCallback() { + this.removeEventListener('sl-resize', this.resize); + } } ``` +### Lifecycle for canvas initialization {#lifecycle-for-canvas-initialization} + The question of when to create the canvas has been bugging me for a while, trying to get `` in the [connectedCallback] lifecycle would return `undefined` since the CustomElement had not been added to the document yet, so naturally I couldn't query it via the DOM API. In the end I found that [firstUpdated] was a good time to trigger the custom event `ic-ready` after creating the canvas and bring the canvas instance in the event object, and to trigger the custom event `ic-frame` on each tick: ```ts export class InfiniteCanvas extends LitElement { - async firstUpdated() { - this.#canvas = await new Canvas({ - canvas: this.$canvas, - renderer: this.renderer as 'webgl' | 'webgpu', - }).initialized; - - this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); - - const animate = (time?: DOMHighResTimeStamp) => { - this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); - this.#canvas.render(); - this.#rafHandle = window.requestAnimationFrame(animate); - }; - animate(); - } + async firstUpdated() { + this.#canvas = await new Canvas({ + canvas: this.$canvas, + renderer: this.renderer as 'webgl' | 'webgpu', + }).initialized; + + this.dispatchEvent( + new CustomEvent('ic-ready', { detail: this.#canvas }), + ); + + const animate = (time?: DOMHighResTimeStamp) => { + this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); + this.#canvas.render(); + this.#rafHandle = window.requestAnimationFrame(animate); + }; + animate(); + } } ``` +Is there a better solution than this hack? + +### Use async task {#async-task} + +For this kind of scenario where an asynchronous task is executed and then rendered, React provides [\]: + +```tsx +}> + + +``` + +Lit also provides similar [Async Tasks] so that we can display the loading and error states before the asynchronous task completes and when it errors out. When creating an asynchronous task with `Task`, you need to specify the parameters and include the created `` as a return value, so that it can be retrieved and rendered in the `complete` hook of the render function. + +```ts +private initCanvas = new Task(this, { + task: async ([renderer]) => { + return canvas.getDOM(); + }, + args: () => [this.renderer as 'webgl' | 'webgpu'] as const, +}); + +render() { + return this.initCanvas.render({ + pending: () => html``, + complete: ($canvas) => html` + + ${$canvas} + + + `, + error: (e) => html`${e}`, + }); +} +``` + +For example, in Safari, which does not support WebGPU, the following error message will be displayed: + + + + Initialize canvas failed
+ WebGPU is not supported by the browser. +
+ So our canvas component is written. the framework-agnostic nature of web components allows us to use them in a consistent way. Take Vue and React for example: ```vue ``` ```tsx
- +
``` @@ -115,9 +163,9 @@ Get the canvas instance by listening to the `ic-ready` custom event when the can ```ts const $canvas = document.querySelector('ic-canvas'); $canvas.addEventListener('ic-ready', (e) => { - const canvas = e.detail; - // 创建场景图 - canvas.appendChild(circle); + const canvas = e.detail; + // 创建场景图 + canvas.appendChild(circle); }); ``` @@ -140,8 +188,8 @@ Add it to canvas component: ```html - - // [!code ++] + + // [!code ++] ``` @@ -149,35 +197,38 @@ The internal structure of this component looks like this, and you can see that L ```html - - - - ${this.zoom}% - - - + + + + ${this.zoom}% + + + ``` In order to pass the canvas instance from the canvas component to its children, we use [Lit Context], which is saved to the context after instantiation: -```ts +```ts{9} const canvasContext = createContext(Symbol('canvas')); export class InfiniteCanvas extends LitElement { - #provider = new ContextProvider(this, { context: canvasContext }); - - async firstUpdated() { - this.#provider.setValue(this.#canvas); - } + #provider = new ContextProvider(this, { context: canvasContext }); + + private initCanvas = new Task(this, { + task: async ([renderer]) => { + // ...省略实例化画布过程 + this.#provider.setValue(this.#canvas); + } + }); } ``` @@ -185,8 +236,8 @@ It can then be consumed in the child component via the context, and since the ca ```ts export class ZoomToolbar extends LitElement { - @consume({ context: canvasContext, subscribe: true }) - canvas: Canvas; + @consume({ context: canvasContext, subscribe: true }) + canvas: Canvas; } ``` @@ -194,12 +245,12 @@ Add a callback function to the camera that is triggered every time the camera or ```ts export class Camera { - onchange: () => void; - private updateViewProjectionMatrix() { - if (this.onchange) { - this.onchange(); + onchange: () => void; + private updateViewProjectionMatrix() { + if (this.onchange) { + this.onchange(); + } } - } } ``` @@ -207,7 +258,7 @@ The responsive variable `zoom` is modified when the callback is triggered. ```ts this.#canvas.camera.onchange = () => { - this.zoom = Math.round(this.#canvas.camera.zoom * 100); + this.zoom = Math.round(this.#canvas.camera.zoom * 100); }; ``` @@ -223,3 +274,5 @@ We won't go into the details of the UI implementation later. [query]: https://lit.dev/docs/api/decorators/#query [firstUpdated]: https://lit.dev/docs/components/lifecycle/#firstupdated [Lit Context]: https://lit.dev/docs/data/context/ +[Async Tasks]: https://lit.dev/docs/data/task/#overview +[\]: https://react.dev/reference/react/Suspense diff --git a/packages/site/docs/public/figma-stroke-align.png b/packages/site/docs/public/figma-stroke-align.png new file mode 100644 index 0000000000000000000000000000000000000000..923ff5f17b463118b972278945a57637c7d3c04a GIT binary patch literal 79472 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

cc#l_flEX-)NbS<>?XdTTg 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⪼-Fat=A5geU*sc5MJ8gGDol6#6 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 F{vYU~Jm>%b literal 0 HcmV?d00001 diff --git a/packages/site/docs/public/figma-stroke-center.png b/packages/site/docs/public/figma-stroke-center.png deleted file mode 100644 index 42033b08801c7f5c8d8a9d213d22acf1426d493b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48610 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 @@ -39,8 +39,8 @@ export class InfiniteCanvas extends LitElement {} import { property } from 'lit/decorators.js'; export class InfiniteCanvas extends LitElement { - @property() - renderer = 'webgl'; + @property() + renderer = 'webgl'; } ``` @@ -48,16 +48,16 @@ export class InfiniteCanvas extends LitElement { ```ts export class InfiniteCanvas extends LitElement { - @query('canvas', true) - $canvas: HTMLCanvasElement; - - render() { - return html` - - - - `; - } + @query('canvas', true) + $canvas: HTMLCanvasElement; + + render() { + return html` + + + + `; + } } ``` @@ -65,48 +65,97 @@ export class InfiniteCanvas extends LitElement { ```ts export class InfiniteCanvas extends LitElement { - connectedCallback() { - this.addEventListener('sl-resize', this.resize); - } - disconnectedCallback() { - this.removeEventListener('sl-resize', this.resize); - } + connectedCallback() { + this.addEventListener('sl-resize', this.resize); + } + disconnectedCallback() { + this.removeEventListener('sl-resize', this.resize); + } } ``` +### 何时创建画布?{#lifecycle-to-init-canvas} + 何时创建画布这个问题困扰了我一阵,在 [connectedCallback] 生命周期中尝试获取 `` 将返回 `undefined`,因为此时 CustomElement 还没有加入到文档中,自然也没法通过 DOM API 查询到。最后我发现 [firstUpdated] 是个不错的时机,创建画布后触发自定义事件 `ic-ready` 并在事件对象中带上画布实例,同时在每个 tick 中触发自定义事件 `ic-frame`: ```ts export class InfiniteCanvas extends LitElement { - async firstUpdated() { - this.#canvas = await new Canvas({ - canvas: this.$canvas, - renderer: this.renderer as 'webgl' | 'webgpu', - }).initialized; - - this.dispatchEvent(new CustomEvent('ic-ready', { detail: this.#canvas })); - - const animate = (time?: DOMHighResTimeStamp) => { - this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); - this.#canvas.render(); - this.#rafHandle = window.requestAnimationFrame(animate); - }; - animate(); - } + async firstUpdated() { + this.#canvas = await new Canvas({ + canvas: this.$canvas, + renderer: this.renderer as 'webgl' | 'webgpu', + }).initialized; + + this.dispatchEvent( + new CustomEvent('ic-ready', { detail: this.#canvas }), + ); + + const animate = (time?: DOMHighResTimeStamp) => { + this.dispatchEvent(new CustomEvent('ic-frame', { detail: time })); + this.#canvas.render(); + this.#rafHandle = window.requestAnimationFrame(animate); + }; + animate(); + } } ``` +有没有比这种 hack 方式更好的解决办法呢? + +### 使用异步任务 {#async-task} + +对于这种异步任务执行后再渲染的场景,React 提供了 [\]: + +```tsx +}> + + +``` + +Lit 也提供了类似的 [Async Tasks],这样我们就可以在异步任务完成前和出错时分别展示加载中与出错状态。在使用 `Task` 创建异步任务时需要指定参数,同时将创建的 `` 作为返回值,这样在渲染函数 `complete` 钩子中就能拿到它并渲染了。 + +```ts +// 初始化画布逻辑同上 +private initCanvas = new Task(this, { + task: async ([renderer]) => { + return canvas.getDOM(); + }, + args: () => [this.renderer as 'webgl' | 'webgpu'] as const, +}); + +render() { + return this.initCanvas.render({ + pending: () => html``, + complete: ($canvas) => html` + + ${$canvas} + + + `, + error: (e) => html`${e}`, + }); +} +``` + +例如在不支持 WebGPU 的 Safari 浏览器下,会展示如下出错提示: + + + + Initialize canvas failed
+ WebGPU is not supported by the browser. +
+ 这样我们的画布组件就编写完成了。Web components 的框架无关性让我们可以以一致的方式使用。以 Vue 和 React 为例: ```vue ``` ```tsx

``` @@ -115,9 +164,9 @@ export class InfiniteCanvas extends LitElement { ```ts const $canvas = document.querySelector('ic-canvas'); $canvas.addEventListener('ic-ready', (e) => { - const canvas = e.detail; - // 创建场景图 - canvas.appendChild(circle); + const canvas = e.detail; + // 创建场景图 + canvas.appendChild(circle); }); ``` @@ -140,8 +189,8 @@ export class ZoomToolbar extends LitElement {} ```html - - // [!code ++] + + // [!code ++] ``` @@ -149,35 +198,38 @@ export class ZoomToolbar extends LitElement {} ```html - - - - ${this.zoom}% - - - + + + + ${this.zoom}% + + + ``` 为了将画布组件中的画布实例传递到组件中,我们使用了 [Lit Context],在实例化后保存到上下文中: -```ts +```ts{9} const canvasContext = createContext(Symbol('canvas')); export class InfiniteCanvas extends LitElement { - #provider = new ContextProvider(this, { context: canvasContext }); - - async firstUpdated() { - this.#provider.setValue(this.#canvas); - } + #provider = new ContextProvider(this, { context: canvasContext }); + + private initCanvas = new Task(this, { + task: async ([renderer]) => { + // ...省略实例化画布过程 + this.#provider.setValue(this.#canvas); + } + }); } ``` @@ -185,8 +237,8 @@ export class InfiniteCanvas extends LitElement { ```ts export class ZoomToolbar extends LitElement { - @consume({ context: canvasContext, subscribe: true }) - canvas: Canvas; + @consume({ context: canvasContext, subscribe: true }) + canvas: Canvas; } ``` @@ -194,12 +246,12 @@ export class ZoomToolbar extends LitElement { ```ts export class Camera { - onchange: () => void; - private updateViewProjectionMatrix() { - if (this.onchange) { - this.onchange(); + onchange: () => void; + private updateViewProjectionMatrix() { + if (this.onchange) { + this.onchange(); + } } - } } ``` @@ -207,7 +259,7 @@ export class Camera { ```ts this.#canvas.camera.onchange = () => { - this.zoom = Math.round(this.#canvas.camera.zoom * 100); + this.zoom = Math.round(this.#canvas.camera.zoom * 100); }; ``` @@ -223,3 +275,5 @@ this.#canvas.camera.onchange = () => { [query]: https://lit.dev/docs/api/decorators/#query [firstUpdated]: https://lit.dev/docs/components/lifecycle/#firstupdated [Lit Context]: https://lit.dev/docs/data/context/ +[Async Tasks]: https://lit.dev/docs/data/task/#overview +[\]: https://react.dev/reference/react/Suspense diff --git a/packages/site/docs/zh/guide/lesson-010.md b/packages/site/docs/zh/guide/lesson-010.md index f808763..1d6ae33 100644 --- a/packages/site/docs/zh/guide/lesson-010.md +++ b/packages/site/docs/zh/guide/lesson-010.md @@ -6,9 +6,9 @@ outline: deep 图片导入导出在无限画布中是一个非常重要的功能,通过图片产物可以和其他工具打通。因此虽然目前我们的画布绘制能力还很有限,但不妨提前考虑和图片相关的问题。在这节课中你将学习到以下内容: -- 将画布内容导出成 PNG,JPEG 和 SVG 格式的图片,并支持 PDF +- 将画布内容导出成 PNG,JPEG 和 SVG 格式的图片 - 在画布中渲染图片 -- 拓展 SVG 的能力,以 stroke 为例 +- 拓展 SVG 的能力,以 `stroke-aligment` 为例 ## 将画布内容导出成图片 {#export-image} @@ -64,6 +64,7 @@ console.log(dataURL); toCanvas(options: Partial = {}): Promise; interface CanvasOptions { + grid: boolean; clippingRegion: Rectangle; beforeDrawImage: (context: CanvasRenderingContext2D) => void; afterDrawImage: (context: CanvasRenderingContext2D) => void; @@ -72,6 +73,7 @@ interface CanvasOptions { 各配置项含义如下: +- `grid` 是否包含网格 - `clippingRegion` 画布裁剪区域,用矩形表示 - `beforeDrawImage` 在绘制画布内容前调用,适合绘制背景颜色 - `afterDrawImage` 在绘制画布内容后调用,适合绘制水印 @@ -263,6 +265,7 @@ canvas.root = deserializeNode(JSON.parse(json)) as Group; - `transform` 需要将对象中的 `position / rotation / scale` 转换成 `matrix()` - `transform-origin` 对应 `transform` 中的 `pivot` 属性 - `innerShadow` 并不存在 SVG 同名属性,需要使用 filter 实现。可参考 [Creating inner shadow in svg] +- `outerShadow` 同上 下面的例子展示了一个序列化后的圆转换成 `` 的效果,为了能在 HTML 页面中展示需要嵌入 [\] 中,它的尺寸和画布保持一致: @@ -280,7 +283,7 @@ call(() => { }); ``` -最后还有一点需要注意,在我们的场景图中任意图形都可以添加子节点,但 SVG 中只有 `` 才可以添加子元素,`` 是无法拥有子元素的。解决办法也很简单,对于拥有子节点的非 Group 元素,生成 SVG 时在外面套一个 ``,将原本应用在本身的 `transform` 应用在它上面。假设后续我们支持了渲染文本,一个拥有文本子节点的 Circle 对应的 SVG 如下: +还有一点需要注意,在我们的场景图中任意图形都可以添加子节点,但 SVG 中只有 `` 才可以添加子元素,除此之外例如 `` 是无法拥有子元素的。解决办法也很简单,对于拥有子节点的非 Group 元素,生成 SVG 时在外面套一个 ``,将原本应用在本身的 `transform` 应用在它上面。假设后续我们支持了渲染文本,一个拥有文本子节点的 Circle 对应的 SVG 如下: ```html @@ -289,6 +292,8 @@ call(() => { ``` +最后来看如何实现网格的导出,参考 [How to draw grid using HTML5 and canvas or SVG] + ### 导出 PDF {#to-pdf} 现在像素和矢量图都有了,如果还想导出成 PDF 可以使用 [jsPDF],它提供了添加图片的 API,限于篇幅这里就不介绍了。 @@ -371,12 +376,73 @@ circle.stroke = 'black'; ### 实现 {#implementation} -因此第一步 +因此第一步我们将 `fill` 支持的类型从颜色字符串扩展到更多纹理来源: ```ts export interface IRenderable { fill: string; // [!code --] - fill: string | HTMLImageElement; // [!code ++] + fill: string | TexImageSource; // [!code ++] +} + +type TexImageSource = + | ImageBitmap + | ImageData + | HTMLImageElement + | HTMLCanvasElement + | HTMLVideoElement + | OffscreenCanvas + | VideoFrame; +``` + +在顶点数据中需要一个字段表示是否使用了纹理,如果使用就对纹理进行采样,这里的 `SAMPLER_2D()` 并非标准 GLSL 语法,而是我们自定义的标记,用于在 Shader 编译阶段替换成 GLSL100 / GLSL300 / WGSL 的采样语法。另外,目前纹理是上传的图片,后续还可以支持使用 Canvas2D API 创建的渐变例如 [createLinearGradient]: + +```glsl +in vec2 v_Uv; +uniform sampler2D u_Texture; + +if (useFillImage) { + fillColor = texture(SAMPLER_2D(u_Texture), v_Uv); +} +``` + +使用了 `uniform` 会打破我们之前的合批处理逻辑。[Inside PixiJS: Batch Rendering System] 一文介绍了 Pixi.js 的 BatchRenderer 实现逻辑,从下面运行时编译的 Shader 模版可以看出,最大可以同时支持一组 `%count%` 个采样器,每个实例通过顶点数据 `aTextureId` 在采样器组中进行选择。 + +```glsl +// Shader template in Pixi.js BatchRenderer +// vert +attribute int aTextureId; +varying int vTextureId; +vTextureId = aTextureId; + +// frag +uniform sampler2D uSamplers[%count%]; +varying int vTextureId; +``` + +### 增加缓存 {#render-cache} + +之前我们一直没有考虑类似 Program、Bindings、Sampler 等这些 GPU 对象的缓存问题。为此我们增加一个资源缓存管理器以实现复用,依据资源类型分别实现命中逻辑。以 Sampler 为例,当 `SamplerDescriptor` 中的属性完全一致时就会命中缓存,比较逻辑在 `samplerDescriptorEquals` 中。 + +```ts +import { samplerDescriptorEquals } from '@antv/g-device-api'; + +export class RenderCache { + device: Device; + private samplerCache = new HashMap( + samplerDescriptorEquals, + nullHashFunc, + ); + + createSampler(descriptor: SamplerDescriptor): Sampler { + // 优先从缓存中取 + let sampler = this.samplerCache.get(descriptor); + if (sampler === null) { + // 未命中,创建并添加缓存 + sampler = this.device.createSampler(descriptor); + this.samplerCache.add(descriptor, sampler); + } + return sampler; + } } ``` @@ -384,18 +450,169 @@ export interface IRenderable { 最后我们来引入一个有趣的话题。我们可以实现目前 SVG 规范还不支持的特性。 -`opacity` `stroke-opacity` 和 `fill-opacity` 的区别: +先来感受下 SVG 中 `opacity` `stroke-opacity` 和 `fill-opacity` 的区别。下图左边的圆应用了 `opacity="0.5"`,右边应用了 ` fill-opacity="0.5" stroke-opacity="0.5"`。可以看出 stroke 描边有一半在圆内,一半在圆外: -[How to simulate stroke-align (stroke-alignment) in SVG] +在 Figma 中对应的 Stroke 位置的取值为 `Center`,其他可选值包括 `Inside` 和 `Outside`,下图分别展示了这三种取值的效果。在 SVG 中名为 `stroke-alignment`,但目前停留在草案阶段,详见 [Specifying stroke alignment]。 + +![Stroke align in Figma](/figma-stroke-align.png) + +我们为所有图形增加 `strokeAlignment` 属性: + +```ts +export interface IRenderable { + strokeAlignment: 'center' | 'inner' | 'outer'; // [!code ++] +} +``` + +### 渲染部分实现 {#stroke-alignment-rendering} + +在 Shader 部分的实现只需要区分这三种取值,按不同方式混合填充和描边色: + +```glsl +if (strokeAlignment < 0.5) { // center + d1 = distance + strokeWidth; + d2 = distance + strokeWidth / 2.0; + color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1); + color = mix_border_inside(strokeColor, color, d2); +} else if (strokeAlignment < 1.5) { // inner + d1 = distance + strokeWidth; + d2 = distance; + color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1); + color = mix_border_inside(strokeColor, color, d2); +} else if (strokeAlignment < 2.5) { // outer + d2 = distance + strokeWidth; + color = mix_border_inside(strokeColor, color, d2); // No need to use fillColor at all +} +``` + +下面是我们的实现效果,可以看出渲染效果和 Figma 一致: + +```js eval code=false +$icCanvas2 = call(() => { + return document.createElement('ic-canvas-lesson10'); +}); +``` + +```js eval code=false inspector=false +call(() => { + const { Canvas, Circle } = Lesson10; + + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'absolute'; + $stats.style.left = '0px'; + $stats.style.top = '0px'; + + $icCanvas2.parentElement.style.position = 'relative'; + $icCanvas2.parentElement.appendChild($stats); + + $icCanvas2.addEventListener('ic-ready', (e) => { + const canvas = e.detail; + + const circle1 = new Circle({ + cx: 200, + cy: 200, + r: 50, + fill: '#F67676', + stroke: 'black', + strokeWidth: 20, + strokeOpacity: 0.5, + strokeAlignment: 'inner', + }); + canvas.appendChild(circle1); + + const circle2 = new Circle({ + cx: 320, + cy: 200, + r: 50, + fill: '#F67676', + stroke: 'black', + strokeWidth: 20, + strokeOpacity: 0.5, + }); + canvas.appendChild(circle2); + + const circle3 = new Circle({ + cx: 460, + cy: 200, + r: 50, + fill: '#F67676', + stroke: 'black', + strokeWidth: 20, + strokeOpacity: 0.5, + strokeAlignment: 'outer', + }); + canvas.appendChild(circle3); + }); + + $icCanvas2.addEventListener('ic-frame', (e) => { + stats.update(); + }); +}); +``` + +在计算渲染包围盒和拾取判定时,也需要考虑该属性。下面的函数反映了不同取值下,描边应该从图形本身向外延伸多少距离。 + +```ts +function strokeOffset( + strokeAlignment: 'center' | 'inner' | 'outer', + strokeWidth: number, +) { + if (strokeAlignment === 'center') { + return strokeWidth / 2; + } else if (strokeAlignment === 'inner') { + return 0; + } else if (strokeAlignment === 'outer') { + return strokeWidth; + } +} +``` + +### 导出 SVG 部分实现 {#stroke-alignment-export-svg} + +正如前文提到的那样,SVG 目前还不支持 `stroke-alignment`,因此目前只能通过 hack 手段模拟。如果图形比较简单,完全可以分两次绘制填充和描边。下面为 Figma 对 `stroke-alignment: 'inner'` 的导出结果,也采用了这种方式: + +```html + + +``` + +
+ + + + + + + + +
+ +除此之外,Figma 官网文档在 [StrokeAlign in Figma widget] 一文中还给出了另一种思路,不需要创建两个同类元素。[How to simulate stroke-align (stroke-alignment) in SVG] 尝试了这一思路,放大描边宽度至原来的两倍,配合 clipPath 和 mask 将多余部分剔除掉: + +> Inside and outside stroke are actually implemented by doubling the stroke weight and masking the stroke by the fill. This means inside-aligned stroke will never draw strokes outside the fill and outside-aligned stroke will never draw strokes inside the fill. + +出于实现简单考虑,我们选择第一种方式,创建两个同类元素分别绘制填充和描边,可以在上面的示例中尝试导出 SVG。 -Figma 中的 Stroke 取值包括 `Center / Inside / Outside` +## 扩展阅读 {#extended-reading} -![Stroke center in Figma](/figma-stroke-center.png) +- [Export from Figma] +- [Specifying stroke alignment] +- [How to simulate stroke-align (stroke-alignment) in SVG] +- [Creating inner shadow in svg] [Export from Figma]: https://help.figma.com/hc/en-us/articles/360040028114-Export-from-Figma#h_01GWB002EPWMFSXKAEC62GS605 [How to simulate stroke-align (stroke-alignment) in SVG]: https://stackoverflow.com/questions/74958705/how-to-simulate-stroke-align-stroke-alignment-in-svg @@ -421,3 +638,8 @@ Figma 中的 Stroke 取值包括 `Center / Inside / Outside` [ImageLoader]: https://loaders.gl/docs/modules/images/api-reference/image-loader [CompressedTextureLoader]: https://loaders.gl/docs/modules/textures/api-reference/compressed-texture-loader [PIXI Assets]: https://pixijs.download/release/docs/assets.html +[Specifying stroke alignment]: https://www.w3.org/TR/svg-strokes/#SpecifyingStrokeAlignment +[StrokeAlign in Figma widget]: https://www.figma.com/widget-docs/api/component-SVG/#strokealign +[createLinearGradient]: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createLinearGradient +[Inside PixiJS: Batch Rendering System]: https://medium.com/swlh/inside-pixijs-batch-rendering-system-fad1b466c420 +[How to draw grid using HTML5 and canvas or SVG]: https://stackoverflow.com/questions/14208673/how-to-draw-grid-using-html5-and-canvas-or-svg diff --git a/packages/site/docs/zh/guide/lesson-011.md b/packages/site/docs/zh/guide/lesson-011.md index 4a8dcc0..425c52d 100644 --- a/packages/site/docs/zh/guide/lesson-011.md +++ b/packages/site/docs/zh/guide/lesson-011.md @@ -6,8 +6,14 @@ outline: deep 在这节课中你将学习到以下内容: -[use.gpu glyph] -[Easy Scalable Text Rendering on the GPU] +## 扩展阅读 {#extended-reading} + +- [State of Text Rendering 2024] +- [use.gpu glyph] +- [Easy Scalable Text Rendering on the GPU] +- [Text Visualization Browser] [Easy Scalable Text Rendering on the GPU]: https://medium.com/@evanwallace/easy-scalable-text-rendering-on-the-gpu-c3f4d782c5ac [use.gpu glyph]: https://gitlab.com/unconed/use.gpu/-/tree/master/packages/glyph +[Text Visualization Browser]: https://textvis.lnu.se +[State of Text Rendering 2024]: https://behdad.org/text2024/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97962d3..e1e1dc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,9 +113,6 @@ importers: '@types/d3-color': specifier: ^3.1.0 version: 3.1.0 - '@types/gl-matrix': - specifier: ^3.2.0 - version: 3.2.0 packages/lesson_005: dependencies: @@ -138,9 +135,6 @@ importers: '@types/d3-color': specifier: ^3.1.0 version: 3.1.0 - '@types/gl-matrix': - specifier: ^3.2.0 - version: 3.2.0 packages/lesson_006: dependencies: @@ -166,9 +160,6 @@ importers: '@types/d3-color': specifier: ^3.1.0 version: 3.1.0 - '@types/gl-matrix': - specifier: ^3.2.0 - version: 3.2.0 packages/lesson_007: dependencies: @@ -176,8 +167,11 @@ importers: specifier: ^1.6.12 version: 1.6.12 '@lit/context': - specifier: latest - version: 1.1.1 + specifier: ^1.1.2 + version: 1.1.2 + '@lit/task': + specifier: ^1.0.1 + version: 1.0.1 '@pixi/math': specifier: ^7.4.2 version: 7.4.2 @@ -200,9 +194,6 @@ importers: '@types/d3-color': specifier: ^3.1.0 version: 3.1.0 - '@types/gl-matrix': - specifier: ^3.2.0 - version: 3.2.0 packages/lesson_008: dependencies: @@ -210,8 +201,11 @@ importers: specifier: ^1.6.12 version: 1.6.12 '@lit/context': - specifier: latest - version: 1.1.1 + specifier: ^1.1.2 + version: 1.1.2 + '@lit/task': + specifier: ^1.0.1 + version: 1.0.1 '@pixi/math': specifier: ^7.4.2 version: 7.4.2 @@ -237,9 +231,6 @@ importers: '@types/d3-color': specifier: ^3.1.0 version: 3.1.0 - '@types/gl-matrix': - specifier: ^3.2.0 - version: 3.2.0 '@types/rbush': specifier: ^3.0.0 version: 3.0.0 @@ -250,8 +241,11 @@ importers: specifier: ^1.6.12 version: 1.6.12 '@lit/context': - specifier: latest + specifier: ^1.1.2 version: 1.1.2 + '@lit/task': + specifier: ^1.0.1 + version: 1.0.1 '@pixi/math': specifier: ^7.4.2 version: 7.4.2 @@ -277,9 +271,6 @@ importers: '@types/d3-color': specifier: ^3.1.0 version: 3.1.0 - '@types/gl-matrix': - specifier: ^3.2.0 - version: 3.2.0 '@types/rbush': specifier: ^3.0.0 version: 3.0.0 @@ -292,12 +283,9 @@ importers: '@lit/context': specifier: ^1.1.2 version: 1.1.2 - '@loaders.gl/core': - specifier: ^4.2.2 - version: 4.2.2 - '@loaders.gl/images': - specifier: ^4.2.2 - version: 4.2.2(@loaders.gl/core@4.2.2) + '@lit/task': + specifier: ^1.0.1 + version: 1.0.1 '@pixi/math': specifier: ^7.4.2 version: 7.4.2 @@ -320,12 +308,15 @@ importers: specifier: ^3.0.1 version: 3.0.1 devDependencies: + '@loaders.gl/core': + specifier: ^4.2.2 + version: 4.2.2 + '@loaders.gl/images': + specifier: ^4.2.2 + version: 4.2.2(@loaders.gl/core@4.2.2) '@types/d3-color': specifier: ^3.1.0 version: 3.1.0 - '@types/gl-matrix': - specifier: ^3.2.0 - version: 3.2.0 '@types/rbush': specifier: ^3.0.0 version: 3.0.0 @@ -778,9 +769,6 @@ packages: '@lit-labs/ssr-dom-shim@1.2.0': resolution: {integrity: sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==} - '@lit/context@1.1.1': - resolution: {integrity: sha512-q/Rw7oWSJidUP43f/RUPwqZ6f5VlY8HzinTWxL/gW1Hvm2S5q2hZvV+qM8WFcC+oLNNknc3JKsd5TwxLk1hbdg==} - '@lit/context@1.1.2': resolution: {integrity: sha512-S0nw2C6Tkm7fVX5TGYqeROGD+Z9Coa2iFpW+ysYBDH3YvCqOY3wVQvSgwbaliLJkjTnSEYCBe9qFqKV8WUFpVw==} @@ -790,6 +778,9 @@ packages: '@lit/reactive-element@2.0.4': resolution: {integrity: sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==} + '@lit/task@1.0.1': + resolution: {integrity: sha512-fVLDtmwCau8NywnFIXaJxsCZjzaIxnVq+cFRKYC1Y4tA4/0rMTvF6DLZZ2JE51BwzOluaKtgJX8x1QDsQtAaIw==} + '@loaders.gl/core@4.2.2': resolution: {integrity: sha512-d3YElSsqL29MaiOwzGB97v994SPotbTvJnooCqoQsYGoYYrECdIetv1/zlfDsh5UB2Wl/NaUMJrzyOqlLmDz5Q==} @@ -952,10 +943,6 @@ packages: '@types/geojson@7946.0.14': resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} - '@types/gl-matrix@3.2.0': - resolution: {integrity: sha512-CY4JAtSOGQX7rVgqVuOk7ZfaLv8VeadDMPj3smMOy8Hp/YiHONa3Mr0mEUgbo0eEwV7+Owpf6BwspcA7hv4NXg==} - deprecated: This is a stub types definition. gl-matrix provides its own type definitions, so you do not need this installed. - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3362,10 +3349,6 @@ snapshots: '@lit-labs/ssr-dom-shim@1.2.0': {} - '@lit/context@1.1.1': - dependencies: - '@lit/reactive-element': 2.0.4 - '@lit/context@1.1.2': dependencies: '@lit/reactive-element': 2.0.4 @@ -3378,6 +3361,10 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.2.0 + '@lit/task@1.0.1': + dependencies: + '@lit/reactive-element': 2.0.4 + '@loaders.gl/core@4.2.2': dependencies: '@loaders.gl/loader-utils': 4.2.2(@loaders.gl/core@4.2.2) @@ -3515,10 +3502,6 @@ snapshots: '@types/geojson@7946.0.14': {} - '@types/gl-matrix@3.2.0': - dependencies: - gl-matrix: 3.4.3 - '@types/json-schema@7.0.15': {} '@types/linkify-it@3.0.5': {}