UNPKG

@spearwolf/twopoint5d

Version:

Create 2.5D realtime graphics and pixelart with WebGL and three.js

359 lines 14.6 kB
import { emit, eventize, off, on, once, onceAsync, retain, retainClear } from '@spearwolf/eventize'; import { WebGPURenderer } from 'three/webgpu'; import { OnDisplayDispose, OnDisplayInit, OnDisplayPause, OnDisplayRenderFrame, OnDisplayResize, OnDisplayRestart, OnDisplayStart, } from '../events.js'; import { Chronometer } from './Chronometer.js'; import { DisplayStateMachine } from './DisplayStateMachine.js'; import { FrameLoop } from './FrameLoop.js'; import { isWebGLRenderer } from './isWebGLRenderer.js'; import { isWebGPURenderer } from './isWebGPURenderer.js'; import { Stylesheets } from './Stylesheets.js'; import { getContentAreaSize, getHorizontalInnerMargin, getIsContentBox, getVerticalInnerMargin } from './styleUtils.js'; let canvasMaxResolutionWarningWasShown = false; function showCanvasMaxResolutionWarning(w, h) { if (!canvasMaxResolutionWarningWasShown) { console.warn(`Oops, the canvas width or height should not bigger than ${Display.MaxResolution} pixels (${w}x${h} was requested).`, 'If you need more, please set Display.MaxResolution before you create a Display!'); canvasMaxResolutionWarningWasShown = true; } } export class Display { static { this.MaxResolution = 8192; } static { this.CssRulesPrefixContainer = 'twopoint5d-container'; } static { this.CssRulesPrefixDisplay = 'twopoint5d-canvas'; } static { this.CssRulesPrefixFullscreen = 'twopoint5d-canvas--fullscreen'; } #chronometer; #stateMachine; #lastResizeHash; #lastResizePollMs; #didEmitResize; #fullscreenCssRules; #fullscreenCssRulesMustBeRemoved; #width; #height; get width() { return this.#width; } get height() { return this.#height; } #isFirstFrame; get isFirstFrame() { return this.#isFirstFrame; } get canvas() { return this.renderer.domElement; } get isWebGPUBackend() { return this.renderer?.backend?.['isWebGPUBackend'] ?? false; } get isWebGLBackend() { return this.renderer?.backend?.['isWebGLBackend'] ?? false; } #waitForRenderer; constructor(domElementOrRenderer, options) { this.#chronometer = new Chronometer(undefined, 1 / 30); this.#stateMachine = new DisplayStateMachine(); this.#lastResizeHash = ''; this.resizePollIntervalMs = 0; this.#lastResizePollMs = Number.NEGATIVE_INFINITY; this.#didEmitResize = false; this.#fullscreenCssRulesMustBeRemoved = false; this.pixelZoom = 0; this.styleImageRendering = undefined; this.#width = 0; this.#height = 0; this.frameNo = 0; this.#isFirstFrame = true; this.#emit = (eventName) => { if (this.renderer != null) { emit(this, eventName, this.getEventProps()); } }; this.onResize = (listener) => on(this, OnDisplayResize, listener); this.onRenderFrame = (listener) => on(this, OnDisplayRenderFrame, listener); this.onNextFrame = (listener) => once(this, OnDisplayRenderFrame, listener); this.nextFrame = () => onceAsync(this, OnDisplayRenderFrame); this.onInit = (listener) => on(this, OnDisplayInit, listener); this.onStart = (listener) => on(this, OnDisplayStart, listener); this.onRestart = (listener) => on(this, OnDisplayRestart, listener); this.onPause = (listener) => on(this, OnDisplayPause, listener); this.onDispose = (listener) => once(this, OnDisplayDispose, listener); eventize(this); retain(this, [OnDisplayInit, OnDisplayStart, OnDisplayResize]); this.#chronometer.stop(); this.resizeToCallback = options?.resizeTo; this.styleSheetRoot = options?.styleSheetRoot ?? document.head; if (isWebGLRenderer(domElementOrRenderer)) { console.warn('The Display constructor expects a WebGPURenderer or an HTML element as the first argument.', 'Since twopoint5d@0.13 a WebGLRenderer is not supported anymore.'); throw new TypeError('The Display constructor expects a WebGPURenderer or an HTML element as the first argument!'); } if (isWebGPURenderer(domElementOrRenderer)) { this.renderer = domElementOrRenderer; this.resizeToElement = this.renderer.domElement; } else if (domElementOrRenderer instanceof HTMLElement) { let canvas; if (domElementOrRenderer.tagName === 'CANVAS') { canvas = domElementOrRenderer; } else { const container = document.createElement('div'); Stylesheets.addRule(container, Display.CssRulesPrefixContainer, 'display:block;width:100%;height:100%;margin:0;padding:0;border:0;line-height:0;font-size:0;', this.styleSheetRoot); domElementOrRenderer.appendChild(container); canvas = document.createElement('canvas'); container.appendChild(canvas); } this.resizeToElement = domElementOrRenderer; const createRenderer = options?.createRenderer ?? ((params) => { return new WebGPURenderer({ ...params, }); }); this.renderer = createRenderer({ canvas, stencil: false, alpha: true, antialias: true, powerPreference: 'high-performance', ...options, }); this.#waitForRenderer = this.renderer.init(); } this.frameLoop = new FrameLoop(options?.maxFps ?? 0, this.renderer); const { domElement: canvas } = this.renderer; Stylesheets.addRule(canvas, Display.CssRulesPrefixDisplay, 'touch-action: none;', this.styleSheetRoot); canvas.setAttribute('touch-action', 'none'); this.resizeToElement = options?.resizeToElement ?? this.resizeToElement; this.resizeToAttributeEl = options?.resizeToAttributeEl ?? canvas; this.resize(); on(this.#stateMachine, { [DisplayStateMachine.Init]: async () => { await this.#waitForRenderer; this.#emit(OnDisplayInit); }, [DisplayStateMachine.Restart]: () => this.#emit(OnDisplayRestart), [DisplayStateMachine.Start]: () => { const t = performance.now() / 1000; this.#chronometer.start(t); this.#chronometer.update(t); this.#emit(OnDisplayStart); }, [DisplayStateMachine.Pause]: () => { this.#chronometer.stop(performance.now() / 1000); retainClear(this, OnDisplayStart); this.#emit(OnDisplayPause); }, }); if (typeof document !== 'undefined') { const onDocVisibilityChange = () => { this.#stateMachine.documentIsVisible = !document.hidden; }; document.addEventListener('visibilitychange', onDocVisibilityChange, false); once(this, OnDisplayDispose, () => { document.removeEventListener('visibilitychange', onDocVisibilityChange, false); }); onDocVisibilityChange(); } this.#waitForRenderer.then(() => { this.frameLoop.start(this); }); } get now() { return this.#chronometer.time; } get deltaTime() { return this.#chronometer.deltaTime; } get maxDeltaTime() { return this.#chronometer.maxDeltaTime; } set maxDeltaTime(value) { this.#chronometer.maxDeltaTime = value; } get pause() { return this.#stateMachine.state === DisplayStateMachine.PAUSED; } set pause(pause) { this.#stateMachine.pausedByUser = pause; } get isRunning() { return this.#stateMachine.isRunning; } get pixelRatio() { if (this.pixelZoom > 0) { return 1.0; } return this.devicePixelRatio; } get devicePixelRatio() { return window.devicePixelRatio ?? 1; } resize() { this.#didEmitResize = false; if (this.resizePollIntervalMs > 0) { const nowMs = performance.now(); if (nowMs - this.#lastResizePollMs < this.resizePollIntervalMs) { return; } this.#lastResizePollMs = nowMs; } let wPx = 300; let hPx = 150; const canvasElement = this.canvas; let sizeRefElement = this.resizeToElement; let fullscreenCssRulesMustBeRemoved = this.#fullscreenCssRulesMustBeRemoved; if (this.resizeToAttributeEl.hasAttribute('resize-to')) { const resizeTo = this.resizeToAttributeEl.getAttribute('resize-to').trim(); if (resizeTo.match(/^:?(fullscreen|window)$/)) { wPx = window.innerWidth; hPx = window.innerHeight; sizeRefElement = undefined; let fullscreenCssRules = this.#fullscreenCssRules; if (!fullscreenCssRules) { fullscreenCssRules = Stylesheets.installRule(Display.CssRulesPrefixFullscreen, `position:fixed;top:0;left:0;`, this.styleSheetRoot); this.#fullscreenCssRules = fullscreenCssRules; } if (fullscreenCssRulesMustBeRemoved) { fullscreenCssRulesMustBeRemoved = false; } else { canvasElement.classList.add(fullscreenCssRules); this.#fullscreenCssRulesMustBeRemoved = true; } } else if (resizeTo === 'self') { sizeRefElement = this.resizeToElement ?? canvasElement; } else if (resizeTo) { sizeRefElement = document.querySelector(resizeTo) ?? this.resizeToElement ?? canvasElement; } } if (fullscreenCssRulesMustBeRemoved) { if (this.#fullscreenCssRules) { canvasElement.classList.remove(this.#fullscreenCssRules); } this.#fullscreenCssRulesMustBeRemoved = false; } if (this.resizeToCallback) { const size = this.resizeToCallback(this); if (size) { wPx = size[0]; hPx = size[1]; } } else if (sizeRefElement) { const area = getContentAreaSize(sizeRefElement); wPx = area.width; hPx = area.height; } let cssWidth = wPx; let cssHeight = hPx; const canvasStyle = getComputedStyle(canvasElement, null); const canvasIsContentBox = getIsContentBox(canvasStyle); const canvasHorizontalInnerMargin = getHorizontalInnerMargin(canvasStyle); const canvasVerticalInnerMargin = getVerticalInnerMargin(canvasStyle); if (canvasIsContentBox && canvasElement !== sizeRefElement) { wPx -= canvasHorizontalInnerMargin; hPx -= canvasVerticalInnerMargin; cssWidth -= canvasHorizontalInnerMargin; cssHeight -= canvasVerticalInnerMargin; } else if (!canvasIsContentBox && canvasElement === sizeRefElement) { cssWidth += canvasHorizontalInnerMargin; cssHeight += canvasVerticalInnerMargin; } if (wPx < 0) { wPx = 0; } if (hPx < 0) { hPx = 0; } if (cssWidth < 0) { cssWidth = 0; } if (cssHeight < 0) { cssHeight = 0; } if (wPx > Display.MaxResolution) { wPx = Display.MaxResolution; showCanvasMaxResolutionWarning(wPx, hPx); } if (hPx > Display.MaxResolution) { hPx = Display.MaxResolution; showCanvasMaxResolutionWarning(wPx, hPx); } const { pixelRatio, pixelZoom } = this; const resizeHash = `${wPx}|${cssWidth}x${hPx}|${cssHeight}x${pixelRatio},${pixelZoom}`; if (resizeHash !== this.#lastResizeHash) { this.#lastResizeHash = resizeHash; if (pixelZoom > 0) { this.#width = wPx / pixelZoom; this.#height = hPx / pixelZoom; } else { this.#width = wPx; this.#height = hPx; } this.#width = Math.floor(this.#width); this.#height = Math.floor(this.#height); this.renderer.setPixelRatio(pixelRatio); this.renderer.setSize(this.width, this.height, false); canvasElement.style.width = `${cssWidth}px`; canvasElement.style.height = `${cssHeight}px`; canvasElement.style.imageRendering = this.styleImageRendering ?? (pixelZoom > 0 ? 'pixelated' : 'auto'); const isConstructing = this.frameNo === 0; if (!isConstructing) { this.#emit(OnDisplayResize); this.#didEmitResize = true; } } } [FrameLoop.OnFrame](props) { if (this.isRunning) { this.renderFrame(props.now * 1000); } } renderFrame(now = window.performance.now()) { this.#isFirstFrame = this.frameNo === 0; this.frameNo += 1; this.#chronometer.update(now / 1000); this.resize(); if (this.isFirstFrame && !this.#didEmitResize) this.#emit(OnDisplayResize); this.#emit(OnDisplayRenderFrame); } async start(beforeStartCallback) { await this.#waitForRenderer; if (typeof beforeStartCallback === 'function') { await beforeStartCallback(this.getEventProps()); } this.#stateMachine.pausedByUser = false; this.#stateMachine.start(); return this; } stop() { this.#stateMachine.pausedByUser = true; } dispose() { this.stop(); this.frameLoop.stop(this); emit(this, OnDisplayDispose, this); off(this); this.renderer?.dispose(); delete this.renderer; } getEventProps() { return { display: this, renderer: this.renderer, width: this.width, height: this.height, pixelRatio: this.pixelRatio, now: this.now, deltaTime: this.deltaTime, frameNo: this.frameNo, }; } #emit; } //# sourceMappingURL=Display.js.map