| /** |
| * @module particles-sk |
| * @description <h2><code>particles-player-sk</code></h2> |
| * |
| * <p> |
| * Handles the bulk of the work displaying Particles. |
| * </p> |
| * |
| */ |
| import { $$ } from 'common-sk/modules/dom'; |
| import { define } from 'elements-sk/define'; |
| import { errorMessage } from 'elements-sk/errorMessage'; |
| import { html, TemplateResult } from 'lit-html'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import type { |
| Particles, CanvasKit, Surface, Canvas, |
| } from '../../build/canvaskit/canvaskit.js'; |
| |
| // eslint-disable-next-line @typescript-eslint/no-var-requires |
| const CanvasKitInit = require('../../build/canvaskit/canvaskit.js'); |
| |
| const DEFAULT_SIZE = 256; |
| const ZOOM_IN_FACTOR = 1.1; // 10% |
| const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; |
| |
| export interface PlayerConfig { |
| body: any; |
| width: number; |
| height: number; |
| } |
| |
| // This element might be loaded from a different site, and that means we need |
| // to be careful about how we construct the URL back to the canvas.wasm file. |
| // Start by recording the script origin. |
| const scriptOrigin = new URL((document!.currentScript as HTMLScriptElement).src).origin; |
| const kitReady = CanvasKitInit({ |
| locateFile: (file: any) => `${scriptOrigin}/dist/${file}`, |
| }); |
| |
| /** |
| * Information needed to construct a single HTML control for a uniform. Note |
| * that some uniforms actually represent more than one control, such as a |
| * 'float3', in which case code will need to create three instances of |
| * UniformControl. |
| */ |
| interface UniformControl { |
| id: string; |
| uniformSlot: number; |
| } |
| |
| interface Point { |
| x: number; |
| y: number; |
| } |
| |
| export function floatSlider(uniform: UniformControl | null): TemplateResult { |
| if (!uniform) { |
| return html``; |
| } |
| return html` <div class="widget"> |
| <input |
| name=${uniform.id} |
| id=${uniform.id} |
| min="0" |
| max="1" |
| step="0.00001" |
| type="range" |
| /> |
| <label for=${uniform.id}>${uniform.id}</label> |
| </div>`; |
| } |
| |
| export class ParticlesPlayerSk extends ElementSk { |
| private sliders: UniformControl[] = []; |
| |
| private zoomLevel: number = 1.0; |
| |
| private kit: CanvasKit | null = null; // CanvasKit instance |
| |
| private context: number = -1; // CanvasKit context. |
| |
| private animation: Particles | null = null; // Particles instance |
| |
| private surface: Surface | null = null; // Surface |
| |
| private canvas: Canvas | null = null; // Cached Canvas (surface.getCanvas()). |
| |
| private time: number = 0; |
| |
| private lastTime: number = 0; |
| |
| private lastDrag: Point | null = null; |
| |
| constructor() { |
| super(ParticlesPlayerSk.template); |
| } |
| |
| private static template = (ele: ParticlesPlayerSk) => html` |
| <div class="container"> |
| ${ele.sliders.map(floatSlider)} |
| <canvas |
| id="player" |
| @wheel=${ele.wheelHandler} |
| @mousemove=${ele.dragHandler} |
| width=${ele.width * window.devicePixelRatio} |
| height=${ele.height * window.devicePixelRatio} |
| style="width: ${ele.width}px; height: ${ele.height}px;" |
| > |
| Your browser does not support the canvas tag. |
| </canvas> |
| </div>`; |
| |
| |
| connectedCallback(): void { |
| super.connectedCallback(); |
| this._render(); |
| } |
| |
| attributeChangedCallback(): void { |
| this._render(); |
| } |
| |
| initialize(config: PlayerConfig): Promise<void> { |
| this.width = config.width; |
| this.height = config.height; |
| this._render(); |
| |
| return kitReady.then((ck: CanvasKit) => { |
| this.kit = ck; |
| try { |
| this._initializeParticles(config.body); |
| } catch (error) { |
| errorMessage(error); |
| } |
| this._render(); |
| }); |
| } |
| |
| isPlaying(): boolean { |
| return !this.paused; |
| } |
| |
| play(): void { |
| if (!this.isPlaying()) { |
| this.paused = false; |
| } |
| this._render(); |
| } |
| |
| pause(): void { |
| if (this.isPlaying()) { |
| this.paused = true; |
| } |
| } |
| |
| resetView(): void { |
| const ck = this.kit; |
| const canvas = this.canvas; |
| // Reset to identity |
| const tt = canvas!.getTotalMatrix(); |
| const itt = ck!.Matrix.invert(tt)!; |
| canvas!.concat(itt); |
| // Zoom to the middle of the animation |
| canvas!.translate(this.width / 2, this.height / 2); |
| this.zoomLevel = 1.0; |
| } |
| |
| restartAnimation(): void { |
| this.time = 0; |
| this.lastTime = 0; |
| } |
| |
| private dragHandler(e: MouseEvent) { |
| if (!e.buttons) { |
| this.lastDrag = null; |
| return; |
| } |
| if (this.lastDrag) { |
| const dx = e.clientX - this.lastDrag.x; |
| const dy = e.clientY - this.lastDrag.y; |
| |
| this.canvas!.translate(dx / this.zoomLevel, dy / this.zoomLevel); |
| } |
| this.lastDrag = { |
| x: e.clientX, |
| y: e.clientY, |
| }; |
| } |
| |
| private drawFrame() { |
| if (!this.animation || !this.canvas) { |
| return; |
| } |
| |
| // Go through all the sliders on the page that we created and poll those inputs for their |
| // value. Plug those values (range [0.0, 1.0]) into the uniforms. |
| const uniforms = this.animation.uniforms(); |
| this.sliders.forEach((slider) => { |
| const s = $$<HTMLInputElement>(`input#${slider.id}`, this); |
| if (!s) { |
| return; |
| } |
| uniforms[slider.uniformSlot] = s.valueAsNumber; |
| }); |
| window.requestAnimationFrame(() => this.drawFrame()); |
| if (!this.lastTime) { |
| this.animation.start(0, true); |
| this.lastTime = Date.now(); |
| } |
| |
| if (this.isPlaying()) { |
| this.time += Date.now() - this.lastTime; |
| } |
| this.lastTime = Date.now(); |
| |
| this.kit!.setCurrentContext(this.context); |
| this.canvas.clear(this.kit!.BLACK); |
| this.animation.update(this.time / 1000.0); |
| this.animation.draw(this.canvas); |
| this.surface!.flush(); |
| } |
| |
| private _initializeParticles(particlesJSON: any) { |
| // Rebuild the surface only if needed. |
| if ( |
| !this.surface |
| || (this.surface!.width() !== this.width) |
| || (this.surface!.height() !== this.height) |
| ) { |
| this._render(); |
| |
| // eslint-disable-next-line no-unused-expressions |
| this.surface?.delete(); |
| const canvasEle = $$<HTMLCanvasElement>('#player', this)!; |
| this.surface = this.kit!.MakeCanvasSurface(canvasEle); |
| if (!this.surface) { |
| throw new Error('Could not make SkSurface.'); |
| } |
| // We don't need to call .delete() on the canvas because |
| // the parent surface will do that for us. |
| this.canvas = this.surface.getCanvas(); |
| this.context = this.kit!.currentContext(); |
| } |
| |
| // eslint-disable-next-line no-unused-expressions |
| this.animation?.delete(); |
| |
| this.animation = this.kit!.MakeParticles( |
| JSON.stringify(particlesJSON), |
| ); |
| if (!this.animation) { |
| throw new Error('Could not parse Particles JSON.'); |
| } |
| |
| // Go through all uniforms this animation has and look for those with the |
| // prefix 'slider_' For those uniforms, we will make a slider on the UI and |
| // then every frame, we will poll those inputs for their value and plug the |
| // values into the uniforms. The sliders will be in range [0.0, 1.0]. Note |
| // that the matrices are column major. |
| |
| // TODO(jcgregorio) Group rows together on the display so matrices look like |
| // matrices. |
| |
| // TODO(jgrergorio) If the name contains "color" then either display a color |
| // picker or at the very least change the postfixes to _r, _g_, and _b. |
| |
| // TODO(jcgregorio) Break out the uniforms handling into its own element |
| // to be re-used on shaders.skia.org. |
| this.sliders = []; |
| const an = this.animation; |
| for (let i = 0; i < an.getUniformCount(); i++) { |
| const name = an.getUniformName(i); |
| if (name.startsWith('slider_')) { |
| const uniform = an.getUniform(i); |
| for (let row = 0; row < uniform.rows; row++) { |
| for (let col = 0; col < uniform.columns; col++) { |
| let id = `${name.substring('slider_'.length)}`; |
| if (uniform.rows > 1) { |
| id += `_${row}`; |
| } |
| if (uniform.columns > 1) { |
| id += `_${col}`; |
| } |
| this.sliders.push({ |
| id: id, |
| uniformSlot: uniform.slot + row + col * uniform.rows, |
| }); |
| } |
| } |
| } |
| } |
| |
| this._render(); |
| this.canvas!.clear(this.kit!.BLACK); |
| this.resetView(); |
| this.restartAnimation(); |
| this.drawFrame(); |
| } |
| |
| private wheelHandler(e: WheelEvent) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| |
| let zoom = 0; |
| if (e.deltaY < 0) { |
| zoom = ZOOM_IN_FACTOR; |
| } else { |
| zoom = ZOOM_OUT_FACTOR; |
| } |
| this.zoomLevel *= zoom; |
| const ck = this.kit; |
| const canvas = this.canvas; |
| |
| const tt = canvas!.getTotalMatrix(); |
| const itt = ck!.Matrix.invert(tt)!; |
| const pts = [e.clientX, e.clientY]; |
| ck!.Matrix.mapPoints(itt, pts); // Transform DOM pts into canvas space |
| |
| const matr = ck!.Matrix.scaled(zoom, zoom, pts[0], pts[1]); |
| canvas!.concat(matr); |
| } |
| |
| static get observedAttributes(): string[] { |
| return ['width', 'height', 'paused']; |
| } |
| |
| get width(): number { return +(this.getAttribute('width') || DEFAULT_SIZE); } |
| |
| set width(val: number) { this.setAttribute('width', val.toFixed(0)); } |
| |
| get height(): number { return +(this.getAttribute('height') || DEFAULT_SIZE); } |
| |
| set height(val: number) { this.setAttribute('height', val.toFixed(0)); } |
| |
| get paused(): boolean { return this.hasAttribute('paused'); } |
| |
| set paused(val: boolean) { |
| if (val) { |
| this.setAttribute('paused', ''); |
| } else { |
| this.removeAttribute('paused'); |
| } |
| } |
| } |
| |
| define('particles-player-sk', ParticlesPlayerSk); |