From bf4b57ddf0efe48bdbe0d478a7074f53423daec1 Mon Sep 17 00:00:00 2001 From: Mark Silverwood <3482679+SlicedSilver@users.noreply.github.com> Date: Thu, 2 May 2024 18:58:20 +0100 Subject: [PATCH] Add offscreen canvas demo --- examples/offscreen/.npmrc | 1 + examples/offscreen/bouncing-ball-model.ts | 93 ++++++++++ examples/offscreen/bouncing-ball-renderer.ts | 45 +++++ examples/offscreen/index.css | 40 +++++ examples/offscreen/index.html | 41 +++++ examples/offscreen/index.ts | 180 +++++++++++++++++++ examples/offscreen/package.json | 10 ++ examples/offscreen/rollup-worker.config.mjs | 14 ++ examples/offscreen/rollup.config.mjs | 14 ++ examples/offscreen/tsconfig.json | 9 + examples/offscreen/worker-thread.ts | 68 +++++++ package.json | 8 +- src/canvas-element-bitmap-size.ts | 1 + 13 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 examples/offscreen/.npmrc create mode 100644 examples/offscreen/bouncing-ball-model.ts create mode 100644 examples/offscreen/bouncing-ball-renderer.ts create mode 100644 examples/offscreen/index.css create mode 100644 examples/offscreen/index.html create mode 100644 examples/offscreen/index.ts create mode 100644 examples/offscreen/package.json create mode 100644 examples/offscreen/rollup-worker.config.mjs create mode 100644 examples/offscreen/rollup.config.mjs create mode 100644 examples/offscreen/tsconfig.json create mode 100644 examples/offscreen/worker-thread.ts diff --git a/examples/offscreen/.npmrc b/examples/offscreen/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/examples/offscreen/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/examples/offscreen/bouncing-ball-model.ts b/examples/offscreen/bouncing-ball-model.ts new file mode 100644 index 0000000..2bb9ec5 --- /dev/null +++ b/examples/offscreen/bouncing-ball-model.ts @@ -0,0 +1,93 @@ +import { + CanvasElementBitmapSizeBinding, + createCanvasRenderingTarget2D, + Size, +} from 'fancy-canvas'; +import { BouncingBallRenderer, Position } from './bouncing-ball-renderer.js'; + +type Canvas = CanvasElementBitmapSizeBinding['canvas']; + +export class BouncingBallModel { + private readonly _canvas: Canvas; + private readonly _color: string; + private _running: boolean = false; + private _size: Size; + private _mediaSize: Size; + private _renderer: BouncingBallRenderer; + private _position: Position = { + x: 10, + y: 10, + }; + private _velocity: Position = { + x: 10, + y: 5, + }; + + public constructor( + canvas: Canvas, + color: string, + size: Size, + mediaSize: Size + ) { + this._canvas = canvas; + this._color = color; + this._size = size; + this._mediaSize = mediaSize; + this._renderer = new BouncingBallRenderer(this._color); + this.start(); + } + + public updateSize( + size: Size, + mediaSize: Size, + isOffscreen?: boolean + ): void { + this._size = size; + this._mediaSize = mediaSize; + if (isOffscreen) { + (this._canvas as OffscreenCanvas).height = size.height; + (this._canvas as OffscreenCanvas).width = size.width; + } + } + + public start(): void { + this._running = true; + this._animate(); + } + + public stop(): void { + this._running = false; + } + + private _animate(): void { + const newPos: Position = { + x: this._position.x + this._velocity.x, + y: this._position.y + this._velocity.y, + }; + if (newPos.x <= 10 || newPos.x >= this._mediaSize.width - 10) { + this._velocity.x *= -1; + newPos.x = Math.min( + Math.max(10, newPos.x), + this._mediaSize.width - 10 + ); + } + if (newPos.y <= 10 || newPos.y >= this._mediaSize.height - 10) { + this._velocity.y *= -1; + newPos.y = Math.min( + Math.max(10, newPos.y), + this._mediaSize.height - 10 + ); + } + const target = createCanvasRenderingTarget2D({ + get2DContext: options => this._canvas.getContext('2d', options), + bitmapSize: this._size, + canvasElementClientSize: this._mediaSize, + }); + this._renderer.updatePos(newPos); + this._renderer.draw(target); + this._position = newPos; + if (this._running) { + requestAnimationFrame(this._animate.bind(this)); + } + } +} diff --git a/examples/offscreen/bouncing-ball-renderer.ts b/examples/offscreen/bouncing-ball-renderer.ts new file mode 100644 index 0000000..18fbe0a --- /dev/null +++ b/examples/offscreen/bouncing-ball-renderer.ts @@ -0,0 +1,45 @@ +import { + CanvasRenderingTarget2D, + MediaCoordinatesRenderingScope, +} from 'fancy-canvas'; + +export interface Position { + x: number; + y: number; +} + +export class BouncingBallRenderer { + private readonly _color: string; + private _position: Position | undefined; + + public constructor(color: string) { + this._color = color; + } + + public updatePos(position: Position) { + this._position = position; + } + + public draw(target: CanvasRenderingTarget2D): void { + target.useMediaCoordinateSpace( + (scope: MediaCoordinatesRenderingScope) => { + scope.context.clearRect( + 0, + 0, + scope.mediaSize.width, + scope.mediaSize.height + ); + scope.context.beginPath(); + scope.context.arc( + this._position?.x ?? 0, + this._position?.y ?? 0, + 10, + 0, + 2 * Math.PI + ); + scope.context.fillStyle = this._color; + scope.context.fill(); + } + ); + } +} diff --git a/examples/offscreen/index.css b/examples/offscreen/index.css new file mode 100644 index 0000000..4869429 --- /dev/null +++ b/examples/offscreen/index.css @@ -0,0 +1,40 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, + Ubuntu, sans-serif; +} + +#header, +#content, +.actions { + max-width: 680px; + margin: 0 auto; +} + +.actions { + display: flex; + flex-direction: row; + gap: 12px; + margin-block: 12px; +} + +#header { + font-size: 36px; + width: 100%; +} + +#content { + display: flex; + flex-direction: column; + gap: 24px; +} + +.example h3 { + color: var(--example-color); + margin: 12px 0; +} + +.example-canvas { + border: 2px solid var(--example-color); + height: 150px; + width: 100%; +} diff --git a/examples/offscreen/index.html b/examples/offscreen/index.html new file mode 100644 index 0000000..cf12413 --- /dev/null +++ b/examples/offscreen/index.html @@ -0,0 +1,41 @@ + + + + Fancy Canvas - Offscreen Demo + + + +
+ +
+
+ + + +
+
+
+

Offscreen Disabled

+ +
+
+

Offscreen Main Thread

+ +
+
+

Offscreen Worker Thread

+ +
+
+ + + diff --git a/examples/offscreen/index.ts b/examples/offscreen/index.ts new file mode 100644 index 0000000..0534ded --- /dev/null +++ b/examples/offscreen/index.ts @@ -0,0 +1,180 @@ +import { + bindCanvasElementBitmapSizeTo, + CanvasElementBitmapSizeBinding, + Size, +} from 'fancy-canvas'; +import { BouncingBallModel } from './bouncing-ball-model.js'; +import { + AdjustCanvasSizeMessage, + CreateWorkerMessageData, +} from './worker-thread.js'; + +class BouncingBall { + private readonly _canvasHTMLElement: HTMLCanvasElement; + private readonly _color: string; + private readonly _offscreen: T; + private readonly _worker: boolean; + private readonly _binding: CanvasElementBitmapSizeBinding; + private _model: BouncingBallModel | undefined = undefined; + private _workerThread: Worker | undefined; + + public constructor( + container: HTMLCanvasElement, + color: string, + offscreen: T, + worker: boolean + ) { + this._canvasHTMLElement = container; + this._color = color; + this._offscreen = offscreen; + this._worker = worker; + this._binding = bindCanvasElementBitmapSizeTo(this._canvasHTMLElement, { + type: 'device-pixel-content-box', + options: { + allowResizeObserver: true, + allowOffscreenCanvas: this._offscreen, + }, + setOffscreenCanvasSize: this._updateOffscreenCanvasSize.bind(this), + }) as CanvasElementBitmapSizeBinding; + this._binding.subscribeSuggestedBitmapSizeChanged(() => { + this._binding.applySuggestedBitmapSize(); + }); + if (this._worker) { + this._createModelWorkerThread(); + } else { + this._createModelMainThread(); + } + const resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const { width } = entry.contentRect; + this._binding.resizeCanvasElement({ + height: 150, // fixed height + width, + }); + } + }); + const parent = this._canvasHTMLElement.parentElement; + if (parent) { + resizeObserver.observe(parent); + } + } + + public start(): void { + if (this._workerThread) { + this._workerThread.postMessage({ + type: 'start', + }); + return; + } + this._model?.start(); + } + + public pause(): void { + if (this._workerThread) { + this._workerThread.postMessage({ + type: 'pause', + }); + return; + } + this._model?.stop(); + } + + private _createModelMainThread(): void { + this._model = new BouncingBallModel( + this._binding.canvas, + this._color, + this._binding.bitmapSize, + this._binding.canvasElementClientSize + ); + this._binding.subscribeBitmapSizeChanged( + (_oldSize: Size, newSize: Size) => { + this._model?.updateSize( + newSize, + this._binding.canvasElementClientSize + ); + } + ); + } + + private _createModelWorkerThread(): void { + this._workerThread = new Worker('./worker-thread.js'); + const offscreenCanvas = this._binding.requestOffscreenCanvas(); + if (offscreenCanvas) { + this._workerThread.postMessage( + { + type: 'create-canvas', + data: { + canvas: offscreenCanvas, + color: this._color, + bitmapSize: this._binding.bitmapSize, + canvasElementClientSize: + this._binding.canvasElementClientSize, + } satisfies CreateWorkerMessageData, + }, + [offscreenCanvas] + ); + } + } + + private _updateOffscreenCanvasSize(bitmapSize: Size): void { + if (!this._workerThread) { + return; + } + this._workerThread.postMessage({ + type: 'adjust-canvas-size', + data: { + bitmapSize, + canvasElementClientSize: this._binding.canvasElementClientSize, + } satisfies AdjustCanvasSizeMessage, + }); + } +} + +const disabledCanvas = document.querySelector( + '#offscreen-canvas-disabled' +); +const mainCanvas = document.querySelector( + '#offscreen-canvas-main' +); +const workerCanvas = document.querySelector( + '#offscreen-canvas-worker' +); +const startButton = document.querySelector('#start-button'); +const pauseButton = document.querySelector('#pause-button'); +const taskButton = document.querySelector('#task-button'); + +if ( + disabledCanvas && + mainCanvas && + workerCanvas && + startButton && + pauseButton && + taskButton +) { + const disabledBall = new BouncingBall( + disabledCanvas, + '#F23645', + false, + false + ); + const mainBall = new BouncingBall(mainCanvas, '#2962ff', true, false); + const workerBall = new BouncingBall(workerCanvas, '#089981', true, true); + startButton.addEventListener('click', () => { + disabledBall.start(); + mainBall.start(); + workerBall.start(); + }); + pauseButton.addEventListener('click', () => { + disabledBall.pause(); + mainBall.pause(); + workerBall.pause(); + }); + taskButton.addEventListener('click', () => { + const start = Date.now(); + while (Date.now() - start < 1000) { + for (let i = 0; i < 100000; i++) { + (window as unknown as any).num = Math.random() * Math.random(); + } + } + }); +} diff --git a/examples/offscreen/package.json b/examples/offscreen/package.json new file mode 100644 index 0000000..6df36ef --- /dev/null +++ b/examples/offscreen/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "scripts": { + "build": "rollup -c && rollup -c rollup-worker.config.mjs && copyfiles *.html *.css ../../bin/offscreen" + }, + "dependencies": { + "fancy-canvas": "file:../../dist/fancy-canvas" + }, + "type": "module" +} diff --git a/examples/offscreen/rollup-worker.config.mjs b/examples/offscreen/rollup-worker.config.mjs new file mode 100644 index 0000000..d1e52e8 --- /dev/null +++ b/examples/offscreen/rollup-worker.config.mjs @@ -0,0 +1,14 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; + +export default { + input: 'worker-thread.ts', + output: [ + { + file: '../../bin/offscreen/worker-thread.js', + format: 'iife', + }, + ], + + plugins: [nodeResolve(), typescript()], +}; diff --git a/examples/offscreen/rollup.config.mjs b/examples/offscreen/rollup.config.mjs new file mode 100644 index 0000000..51f12e3 --- /dev/null +++ b/examples/offscreen/rollup.config.mjs @@ -0,0 +1,14 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; + +export default { + input: 'index.ts', + output: [ + { + file: '../../bin/offscreen/index.js', + format: 'iife', + }, + ], + + plugins: [nodeResolve(), typescript()], +}; diff --git a/examples/offscreen/tsconfig.json b/examples/offscreen/tsconfig.json new file mode 100644 index 0000000..42498e3 --- /dev/null +++ b/examples/offscreen/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "moduleResolution": "NodeNext", + "outDir": "../../bin/offscreen", + "lib": ["dom", "es2020"], + "module": "NodeNext" + } +} diff --git a/examples/offscreen/worker-thread.ts b/examples/offscreen/worker-thread.ts new file mode 100644 index 0000000..0bac4bc --- /dev/null +++ b/examples/offscreen/worker-thread.ts @@ -0,0 +1,68 @@ +import { Size } from 'fancy-canvas'; +import { BouncingBallModel } from './bouncing-ball-model.js'; + +export class WorkerHandler { + private _model: BouncingBallModel | undefined; + + constructor() { + self.addEventListener('message', this.onMessage.bind(this)); + } + + private onMessage(e: MessageEvent): void { + const { type, data } = e.data; + + switch (type) { + case 'create-canvas': + this._create(data); + break; + case 'adjust-canvas-size': + this._adjustSize(data); + break; + case 'start': + this._start(); + break; + case 'pause': + this._pause(); + break; + default: + console.warn(`Unknown message type: ${type}`); + } + } + + private _create(data: CreateWorkerMessageData): void { + this._model = new BouncingBallModel( + data.canvas, + data.color, + data.bitmapSize, + data.canvasElementClientSize + ); + self.postMessage('created'); + } + + private _adjustSize(data: AdjustCanvasSizeMessage): void { + this._model?.updateSize(data.bitmapSize, data.canvasElementClientSize, true); + } + + private _start(): void { + this._model?.start(); + } + + private _pause(): void { + this._model?.stop(); + } +} + +export interface CreateWorkerMessageData { + canvas: OffscreenCanvas; + color: string; + bitmapSize: Size; + canvasElementClientSize: Size; +} + +export interface AdjustCanvasSizeMessage { + bitmapSize: Size; + canvasElementClientSize: Size; +} + +// self-start +new WorkerHandler(); diff --git a/package.json b/package.json index 37c9867..d2bf63e 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,12 @@ "scripts": { "build": "npm run src:build && npm run examples:init && npm run examples:build", "src:build": "npm run build --prefix src", - "examples:init": "cd examples && cd checkers && npm install", - "examples:build": "npm run build --prefix examples/checkers", + "examples:init": "npm run examples:init:checkers && npm run examples:init:offscreen", + "examples:init:checkers": "cd examples/checkers && npm install", + "examples:init:offscreen": "cd examples/offscreen && npm install", + "examples:build": "npm run examples:build:checkers && npm run examples:build:offscreen", + "examples:build:checkers": "npm run build --prefix examples/checkers", + "examples:build:offscreen": "npm run build --prefix examples/offscreen", "tests:init": "cd tests && npm install", "test": "npm run tests:init && npm start --prefix tests", "lint": "npm run lint:ec && npm run lint:eslint", diff --git a/src/canvas-element-bitmap-size.ts b/src/canvas-element-bitmap-size.ts index 49d2189..7799f06 100644 --- a/src/canvas-element-bitmap-size.ts +++ b/src/canvas-element-bitmap-size.ts @@ -349,6 +349,7 @@ export function bindTo( canvasElement, target.transform, target.options, + target.setOffscreenCanvasSize, ); }