From 92d3ac897e4e97ea56658468e9b59188c9ba23ff Mon Sep 17 00:00:00 2001 From: AMAGI / Jun Yuri Date: Wed, 12 Jun 2024 11:11:16 -0700 Subject: [PATCH] feat: support nested elements and inputs (#55) * feat: support nested elements in dom-to-canvas * feat: add rerender hook * fix: remove SVG wrapper to fix position bug * docs: revise log shader * Revert "docs: revise log shader" This reverts commit cae9dbce844051d4ee0bc44ed64dd55537d6b23d. * refactor: tidy InputSection * feat: save original opacity of the element * feat: avoid flashing the original element in text rerender * docs: improve text input example * docs: update div examples * perf: reduce DOM traversal * fix: SVG getting overdrawn and scaled incorrectly * perf: avoid rendering the same element concurrrently * feat: use OffscreenCanvas for better quality and performance * chore: remove unused css * refactor: revise hooks types * feat: use MutationObserver to watch element updates * refactor: move property dec * refactor: prefer # over private * chore: remove unused * docs: update Div section desc --- packages/docs/src/dom/DivSection.css | 36 +++++ packages/docs/src/dom/DivSection.tsx | 131 ++++++++++++++++ packages/docs/src/dom/InputSection.css | 6 +- packages/docs/src/dom/InputSection.tsx | 55 +++++-- packages/docs/src/dom/UsageSection.tsx | 25 +--- packages/react-vfx/src/dom-to-canvas.ts | 77 ++++++---- packages/react-vfx/src/element.tsx | 55 ++++--- packages/react-vfx/src/hooks.ts | 22 +++ packages/react-vfx/src/react-vfx.ts | 2 + packages/react-vfx/src/types.ts | 1 + packages/react-vfx/src/vfx-player.ts | 189 ++++++++++++------------ 11 files changed, 414 insertions(+), 185 deletions(-) create mode 100644 packages/docs/src/dom/DivSection.css create mode 100644 packages/docs/src/dom/DivSection.tsx create mode 100644 packages/react-vfx/src/hooks.ts diff --git a/packages/docs/src/dom/DivSection.css b/packages/docs/src/dom/DivSection.css new file mode 100644 index 0000000..2c72f13 --- /dev/null +++ b/packages/docs/src/dom/DivSection.css @@ -0,0 +1,36 @@ +/* .DivSection { + text-align: left; +} */ + +.DivSection h2 { + font-style: italic; + overflow: visible; + text-align: center; +} + +.DivSections { + width: 720px; + max-width: 90%; + margin: 0 auto; +} +.DivSectionField { + text-align: left; + display: flex; + flex-direction: column; + margin-bottom: 1em; +} + +.DivSection label { + font-weight: bold; +} + +.DivSection input, +.DivSection textarea { + font-size: 1em; + border-radius: 3px; + padding: 2px 10px; + width: 100%; +} +.DivSection textarea { + height: 5em; +} diff --git a/packages/docs/src/dom/DivSection.tsx b/packages/docs/src/dom/DivSection.tsx new file mode 100644 index 0000000..be42fc8 --- /dev/null +++ b/packages/docs/src/dom/DivSection.tsx @@ -0,0 +1,131 @@ +import React, { useState, useCallback, useRef, useEffect } from "react"; +import * as VFX from "react-vfx"; +import "./DivSection.css"; +import { InlineCode } from "./Code"; + +const shader = ` +precision mediump float; +uniform vec2 resolution; +uniform vec2 offset; +uniform float time; +uniform sampler2D src; +uniform float dist; + +float noise(float y, float t) { + float n = ( + sin(y * .07 + t * 8. + sin(y * .5 + t * 10.)) + + sin(y * .7 + t * 2. + sin(y * .3 + t * 8.)) * .7 + + sin(y * 1.1 + t * 2.8) * .4 + ); + n += sin(y * 124. + t * 100.7) * sin(y * 877. - t * 38.8) * .3; + + return n; +} + +void main (void) { + vec2 uv = (gl_FragCoord.xy - offset) / resolution; + float t = mod(time, 30.); + float amp = (3. + dist * 30.) / resolution.x; + + vec2 uvr = uv, uvg = uv, uvb = uv; + if (abs(noise(uv.y, t)) > 1. || dist > 0.03) { + uvr.x += noise(uv.y, t) * amp; + uvg.x += noise(uv.y, t + 10.) * amp; + uvb.x += noise(uv.y, t + 20.) * amp; + } + + vec4 cr = texture2D(src, uvr); + vec4 cg = texture2D(src, uvg); + vec4 cb = texture2D(src, uvb); + + gl_FragColor = vec4( + cr.r, + cg.g, + cb.b, + step(.1, cr.a + cg.a + cb.a) + ); +} +`; + +const DivSection: React.FC = () => { + const divRef = useRef(null); + const { rerenderElement } = VFX.useVFX(); + + const distRef = useRef(0); + + useEffect(() => { + let isMounted = true; + const decay = () => { + distRef.current *= 0.8; + if (isMounted) { + requestAnimationFrame(decay); + } + }; + decay(); + + return () => { + isMounted = false; + }; + }); + + const onChange = () => { + rerenderElement(divRef.current); + distRef.current = 1; + }; + + return ( +
+

Div (experimental)

+

+ REACT-VFX also has VFXDiv, which allow + us to wrap any elements... +
+ so you can make an interactive form with WebGL effects!! +

+ distRef.current, + }} + > +
+
+ + +
+ +
+ + +
+ +
+ + - + +
+

+ + {text === "" ? "Input something..." : text} + +

+ setText(e.target.value)} + /> +
); }; diff --git a/packages/docs/src/dom/UsageSection.tsx b/packages/docs/src/dom/UsageSection.tsx index efbf95d..af9c58e 100644 --- a/packages/docs/src/dom/UsageSection.tsx +++ b/packages/docs/src/dom/UsageSection.tsx @@ -1,6 +1,7 @@ import React from "react"; import * as VFX from "react-vfx"; import InputSection from "./InputSection"; +import DivSection from "./DivSection"; import dedent from "dedent"; import { Code, InlineCode } from "./Code"; @@ -169,28 +170,8 @@ const UsageSection: React.VFC = () => ( `} -
-

Text

-

- Use {""} instead of{" "} - {""}.
-

- - {dedent` - import { VFXSpan } from 'react-vfx'; - - Hello world! - `} - -

- {""} automatically - re-renders when its content is updated. -

- -

- NOTE: VFXSpan doesn't work with nested elements. -

-
+ +

Custom Shaders

diff --git a/packages/react-vfx/src/dom-to-canvas.ts b/packages/react-vfx/src/dom-to-canvas.ts index fa3ff6e..a7d14e1 100644 --- a/packages/react-vfx/src/dom-to-canvas.ts +++ b/packages/react-vfx/src/dom-to-canvas.ts @@ -11,7 +11,7 @@ const convertHtmlToXml = (html: string): string => { doc.documentElement.appendChild(range.createContextualFragment(html)); doc.documentElement.setAttribute( "xmlns", - doc.documentElement.namespaceURI! + doc.documentElement.namespaceURI!, ); // Get well-formed markup @@ -21,59 +21,82 @@ const convertHtmlToXml = (html: string): string => { // Clone DOM node. function cloneNode(node: T): T { - return node.cloneNode() as T; + return node.cloneNode(true) as T; } // Render element content to canvas and return it. -// ref. export default function getCanvasFromElement( element: HTMLElement, - oldCanvas?: HTMLCanvasElement -): Promise { + originalOpacity: number, + oldCanvas?: OffscreenCanvas, +): Promise { const rect = element.getBoundingClientRect(); - const canvas = oldCanvas ?? document.createElement("canvas"); - canvas.width = Math.max(rect.width * 1.01, rect.width + 1); // XXX - canvas.height = Math.max(rect.height * 1.01, rect.height + 1); + const ratio = window.devicePixelRatio; + const width = rect.width * ratio; + const height = rect.height * ratio; + + const canvas = + oldCanvas && oldCanvas.width === width && oldCanvas.height === height + ? oldCanvas + : new OffscreenCanvas(width, height); // Clone element with styles in text attribute // to apply styles in SVG const newElement = cloneNode(element); - const styles = window.getComputedStyle(element); - Array.from(styles).forEach((key) => { - newElement.style.setProperty( - key, - styles.getPropertyValue(key), - styles.getPropertyPriority(key) - ); - }); - newElement.innerHTML = element.innerHTML; - - // Wrap the element for text styling - const wrapper = document.createElement("div"); - wrapper.style.setProperty("text-align", styles.textAlign); - wrapper.style.setProperty("vertical-align", styles.verticalAlign); - wrapper.appendChild(newElement); + syncStylesOfTree(element, newElement); + newElement.style.setProperty("opacity", originalOpacity.toString()); // Create SVG string - const html = wrapper.outerHTML; + const html = newElement.outerHTML; const xml = convertHtmlToXml(html); const svg = - `` + + `` + `${xml}`; return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { - const ctx = canvas.getContext("2d"); + const ctx = canvas.getContext( + "2d", + ) as OffscreenCanvasRenderingContext2D | null; if (ctx === null) { return reject(); } - ctx.drawImage(img, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.scale(ratio, ratio); + ctx.drawImage(img, 0, 0, width, height); + ctx.setTransform(1, 0, 0, 1, 0, 0); + resolve(canvas); }; img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }); } + +function syncStylesOfTree(el1: HTMLElement, el2: HTMLElement): void { + // Sync CSS styles + const styles = window.getComputedStyle(el1); + Array.from(styles).forEach((key) => { + el2.style.setProperty( + key, + styles.getPropertyValue(key), + styles.getPropertyPriority(key), + ); + }); + + // Reflect input value to HTML attributes + if (el2.tagName === "INPUT") { + el2.setAttribute("value", (el2 as HTMLInputElement).value); + } else if (el2.tagName === "TEXTAREA") { + el2.innerHTML = (el2 as HTMLTextAreaElement).value; + } + + for (let i = 0; i < el1.children.length; i++) { + const c1 = el1.children[i] as HTMLElement; + const c2 = el2.children[i] as HTMLElement; + syncStylesOfTree(c1, c2); + } +} diff --git a/packages/react-vfx/src/element.tsx b/packages/react-vfx/src/element.tsx index 2470476..25ee98c 100644 --- a/packages/react-vfx/src/element.tsx +++ b/packages/react-vfx/src/element.tsx @@ -7,20 +7,32 @@ type VFXElementProps = JSX.IntrinsicElements[T] & VFXProps; function VFXElementFactory( - type: T -): React.FC> { - const VFXElement: React.FC> = ( - props: VFXElementProps - ) => { + type: T, +): React.ForwardRefExoticComponent< + React.PropsWithoutRef> & React.RefAttributes +> { + return React.forwardRef(function VFXElement( + props: VFXElementProps, + parentRef: React.ForwardedRef, + ) { const player = useContext(VFXContext); - const ref = useRef(null); + + const elementRef = useRef(); + const ref = (e: HTMLElement): void => { + elementRef.current = e; + if (parentRef instanceof Function) { + parentRef(e); + } else if (parentRef) { + parentRef.current = e; + } + }; const { shader, release, uniforms, overflow, ...rawProps } = props; // Create scene useEffect(() => { - const element = ref.current; - if (element === null) { + const element = elementRef.current; + if (element === undefined) { return; } @@ -31,24 +43,25 @@ function VFXElementFactory( overflow, }); + const mo = new MutationObserver(() => { + if (elementRef.current) { + player?.updateTextElement(elementRef.current); + } + }); + mo.observe(element, { + characterData: true, + attributes: true, + subtree: true, + }); + return () => { player?.removeElement(element); + mo.disconnect(); }; - }, [ref, player, shader, release, uniforms, overflow]); - - // Rerender if the content is updated - useEffect(() => { - if (ref.current === null) { - return; - } - - player?.updateTextElement(ref.current); - }, [player, props.children]); + }, [elementRef, player, shader, release, uniforms, overflow]); return React.createElement(type, { ...rawProps, ref }); - }; - - return VFXElement; + }); } export default VFXElementFactory; diff --git a/packages/react-vfx/src/hooks.ts b/packages/react-vfx/src/hooks.ts new file mode 100644 index 0000000..b8b97c2 --- /dev/null +++ b/packages/react-vfx/src/hooks.ts @@ -0,0 +1,22 @@ +import { useContext } from "react"; +import { VFXContext } from "./context"; + +export type UseVFX = { + /** + * Rerender the element texture used in the shader. + * VFX elements update the texture automatically when the contents are updated, + * however in some cases VFX can't detect changes (e.g. input elements update). + * In such scenarios, you can manually trigger texture update by calling this function. + */ + rerenderElement: (element: HTMLElement | null) => void; +}; + +export function useVFX(): UseVFX { + const player = useContext(VFXContext); + + return { + rerenderElement: (e: HTMLElement | null) => { + e && player?.updateTextElement(e); + }, + }; +} diff --git a/packages/react-vfx/src/react-vfx.ts b/packages/react-vfx/src/react-vfx.ts index 81bf9f9..1e69aee 100644 --- a/packages/react-vfx/src/react-vfx.ts +++ b/packages/react-vfx/src/react-vfx.ts @@ -6,3 +6,5 @@ import vElementFactory from "./element"; export const VFXSpan = vElementFactory<"span">("span"); export const VFXDiv = vElementFactory<"div">("div"); export const VFXP = vElementFactory<"p">("p"); + +export * from "./hooks"; diff --git a/packages/react-vfx/src/types.ts b/packages/react-vfx/src/types.ts index dbdeed9..66d6da8 100644 --- a/packages/react-vfx/src/types.ts +++ b/packages/react-vfx/src/types.ts @@ -57,6 +57,7 @@ export interface VFXElement { release: number; isGif: boolean; overflow: VFXElementOverflow; + originalOpacity: number; } export type VFXElementOverflow = diff --git a/packages/react-vfx/src/vfx-player.ts b/packages/react-vfx/src/vfx-player.ts index 82a012a..ca314e7 100644 --- a/packages/react-vfx/src/vfx-player.ts +++ b/packages/react-vfx/src/vfx-player.ts @@ -24,73 +24,66 @@ type Rect = { const gifFor = new Map(); export default class VFXPlayer { - renderer: THREE.WebGLRenderer; - camera: THREE.Camera; - isPlaying = false; - pixelRatio = 2; - elements: VFXElement[] = []; + #canvas: HTMLCanvasElement; + #renderer: THREE.WebGLRenderer; + #camera: THREE.Camera; + #isPlaying = false; + #pixelRatio = 2; + #elements: VFXElement[] = []; - textureLoader = new THREE.TextureLoader(); + #textureLoader = new THREE.TextureLoader(); - viewport: Rect = { + #viewport: Rect = { left: 0, right: 0, top: 0, bottom: 0, }; - scrollX = 0; - scrollY = 0; + #mouseX = 0; + #mouseY = 0; - mouseX = 0; - mouseY = 0; + #isRenderingToCanvas = new WeakMap(); - constructor( - private canvas: HTMLCanvasElement, - pixelRatio?: number, - ) { - this.renderer = new THREE.WebGLRenderer({ + constructor(canvas: HTMLCanvasElement, pixelRatio?: number) { + this.#canvas = canvas; + this.#renderer = new THREE.WebGLRenderer({ canvas, alpha: true, }); - this.renderer.autoClear = false; - this.renderer.setClearAlpha(0); + this.#renderer.autoClear = false; + this.#renderer.setClearAlpha(0); if (typeof window !== "undefined") { - this.pixelRatio = pixelRatio || window.devicePixelRatio; + this.#pixelRatio = pixelRatio || window.devicePixelRatio; - window.addEventListener("resize", this.resize); - window.addEventListener("scroll", this.scroll, { - passive: true, - }); - window.addEventListener("mousemove", this.mousemove); + window.addEventListener("resize", this.#resize); + window.addEventListener("mousemove", this.#mousemove); } - this.resize(); - this.scroll(); + this.#resize(); - this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); - this.camera.position.set(0, 0, 1); + this.#camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); + this.#camera.position.set(0, 0, 1); } destroy(): void { if (typeof window !== "undefined") { - window.removeEventListener("resize", this.resize); - window.removeEventListener("scroll", this.scroll); - window.removeEventListener("mousemove", this.mousemove); + window.removeEventListener("resize", this.#resize); + window.removeEventListener("mousemove", this.#mousemove); } } - private updateCanvasSize(): void { + #updateCanvasSize(): void { if (typeof window !== "undefined") { const w = window.innerWidth; const h = window.innerHeight; - if (w !== this.width() || h !== this.height()) { - this.canvas.width = w; - this.canvas.height = h; - this.renderer.setSize(w, h); - this.renderer.setPixelRatio(this.pixelRatio); - this.viewport = { + if (w !== this.#width() || h !== this.#height()) { + this.#canvas.width = w; + this.#canvas.height = h; + this.#renderer.setSize(w, h); + this.#renderer.setPixelRatio(this.#pixelRatio); + this.#viewport = { top: 0, left: 0, right: w, @@ -100,33 +93,33 @@ export default class VFXPlayer { } } - private width(): number { - return this.viewport.right - this.viewport.left; + #width(): number { + return this.#viewport.right - this.#viewport.left; } - private height(): number { - return this.viewport.bottom - this.viewport.top; + #height(): number { + return this.#viewport.bottom - this.#viewport.top; } - private resize = async (): Promise => { + #resize = async (): Promise => { if (typeof window !== "undefined") { // Update dom2canvas result. // Render elements in viewport first, then render elements outside of the viewport. - for (const e of this.elements) { + for (const e of this.#elements) { if (e.type === "text" && e.isInViewport) { const rect = e.element.getBoundingClientRect(); if (rect.width !== e.width || rect.height !== e.height) { - await this.rerenderTextElement(e); + await this.#rerenderTextElement(e); e.width = rect.width; e.height = rect.height; } } } - for (const e of this.elements) { + for (const e of this.#elements) { if (e.type === "text" && !e.isInViewport) { const rect = e.element.getBoundingClientRect(); if (rect.width !== e.width || rect.height !== e.height) { - await this.rerenderTextElement(e); + await this.#rerenderTextElement(e); e.width = rect.width; e.height = rect.height; } @@ -135,49 +128,53 @@ export default class VFXPlayer { } }; - private scroll = (): void => { + #mousemove = (e: MouseEvent): void => { if (typeof window !== "undefined") { - this.scrollX = window.scrollX; - this.scrollY = window.scrollY; + this.#mouseX = e.clientX; + this.#mouseY = window.innerHeight - e.clientY; } }; - private mousemove = (e: MouseEvent): void => { - if (typeof window !== "undefined") { - this.mouseX = e.clientX; - this.mouseY = window.innerHeight - e.clientY; + async #rerenderTextElement(e: VFXElement): Promise { + if (this.#isRenderingToCanvas.get(e.element)) { + return; } - }; + this.#isRenderingToCanvas.set(e.element, true); - private async rerenderTextElement(e: VFXElement): Promise { try { - e.element.style.setProperty("opacity", "1"); // TODO: Restore original opacity - const oldTexture: THREE.CanvasTexture = e.uniforms["src"].value; - const canvas = oldTexture.image; - - const texture = new THREE.CanvasTexture(canvas); + const oldCanvas = oldTexture.image; - await dom2canvas(e.element, canvas); + const canvas = await dom2canvas( + e.element, + e.originalOpacity, + oldCanvas, + ); if (canvas.width === 0 || canvas.width === 0) { throw "omg"; } + const texture = new THREE.CanvasTexture(canvas); e.uniforms["src"].value = texture; oldTexture.dispose(); - - e.element.style.setProperty("opacity", "0"); } catch (e) { console.error(e); } + + this.#isRenderingToCanvas.set(e.element, false); } async addElement(element: HTMLElement, opts: VFXProps = {}): Promise { - const shader = this.getShader(opts.shader || "uvGradient"); + const shader = this.#getShader(opts.shader || "uvGradient"); const rect = element.getBoundingClientRect(); const overflow = sanitizeOverflow(opts.overflow); - const isInViewport = isRectInViewport(this.viewport, rect, overflow); + const isInViewport = isRectInViewport(this.#viewport, rect, overflow); + + const originalOpacity = + element.style.opacity === "" + ? 1 + : parseFloat(element.style.opacity); // Create values for element types let texture: THREE.Texture; @@ -188,17 +185,17 @@ export default class VFXPlayer { isGif = !!element.src.match(/\.gif/i); if (isGif) { - const gif = await GIFData.create(element.src, this.pixelRatio); + const gif = await GIFData.create(element.src, this.#pixelRatio); gifFor.set(element, gif); texture = new THREE.Texture(gif.getCanvas()); } else { - texture = this.textureLoader.load(element.src); + texture = this.#textureLoader.load(element.src); } } else if (element instanceof HTMLVideoElement) { texture = new THREE.VideoTexture(element); type = "video" as VFXElementType; } else { - const canvas = await dom2canvas(element); + const canvas = await dom2canvas(element, originalOpacity); texture = new THREE.CanvasTexture(canvas); type = "text" as VFXElementType; } @@ -269,22 +266,23 @@ export default class VFXPlayer { release: opts.release ?? 0, isGif, overflow, + originalOpacity, }; - this.elements.push(elem); + this.#elements.push(elem); } removeElement(element: HTMLElement): void { - const i = this.elements.findIndex((e) => e.element === element); + const i = this.#elements.findIndex((e) => e.element === element); if (i !== -1) { - this.elements.splice(i, 1); + this.#elements.splice(i, 1); } } updateTextElement(element: HTMLElement): Promise { - const i = this.elements.findIndex((e) => e.element === element); + const i = this.#elements.findIndex((e) => e.element === element); if (i !== -1) { - return this.rerenderTextElement(this.elements[i]); + return this.#rerenderTextElement(this.#elements[i]); } // Do nothing if the element is not found @@ -293,29 +291,29 @@ export default class VFXPlayer { } play(): void { - this.isPlaying = true; - this.playLoop(); + this.#isPlaying = true; + this.#playLoop(); } stop(): void { - this.isPlaying = false; + this.#isPlaying = false; } - private playLoop = (): void => { + #playLoop = (): void => { const now = Date.now() / 1000; - this.renderer.clear(); + this.#renderer.clear(); // This must done every frame because iOS Safari doesn't fire // window resize event while the address bar is transforming. - this.updateCanvasSize(); + this.#updateCanvasSize(); - for (const e of this.elements) { + for (const e of this.#elements) { const rect = e.element.getBoundingClientRect(); // Check intersection const isInViewport = isRectInViewport( - this.viewport, + this.#viewport, rect, e.overflow, ); @@ -343,13 +341,14 @@ export default class VFXPlayer { e.enterTime === -1 ? 0 : now - e.enterTime; e.uniforms["leaveTime"].value = e.leaveTime === -1 ? 0 : now - e.leaveTime; - e.uniforms["resolution"].value.x = rect.width * this.pixelRatio; // TODO: use correct width, height - e.uniforms["resolution"].value.y = rect.height * this.pixelRatio; - e.uniforms["offset"].value.x = rect.left * this.pixelRatio; + e.uniforms["resolution"].value.x = rect.width * this.#pixelRatio; // TODO: use correct width, height + e.uniforms["resolution"].value.y = rect.height * this.#pixelRatio; + e.uniforms["offset"].value.x = rect.left * this.#pixelRatio; e.uniforms["offset"].value.y = - (window.innerHeight - rect.top - rect.height) * this.pixelRatio; - e.uniforms["mouse"].value.x = this.mouseX * this.pixelRatio; - e.uniforms["mouse"].value.y = this.mouseY * this.pixelRatio; + (window.innerHeight - rect.top - rect.height) * + this.#pixelRatio; + e.uniforms["mouse"].value.x = this.#mouseX * this.#pixelRatio; + e.uniforms["mouse"].value.y = this.#mouseY * this.#pixelRatio; for (const [key, gen] of Object.entries(e.uniformGenerators)) { e.uniforms[key].value = gen(); @@ -363,14 +362,14 @@ export default class VFXPlayer { // Set viewport if (e.overflow === "fullscreen") { - this.renderer.setViewport( + this.#renderer.setViewport( 0, 0, window.innerWidth, window.innerHeight, ); } else { - this.renderer.setViewport( + this.#renderer.setViewport( rect.left - e.overflow.left, window.innerHeight - (rect.top + rect.height) - @@ -381,20 +380,20 @@ export default class VFXPlayer { } // Render to viewport - this.camera.lookAt(e.scene.position); + this.#camera.lookAt(e.scene.position); try { - this.renderer.render(e.scene, this.camera); + this.#renderer.render(e.scene, this.#camera); } catch (e) { console.error(e); } } - if (this.isPlaying) { - requestAnimationFrame(this.playLoop); + if (this.#isPlaying) { + requestAnimationFrame(this.#playLoop); } }; - private getShader(shaderNameOrCode: string): string { + #getShader(shaderNameOrCode: string): string { if (shaderNameOrCode in shaders) { return shaders[shaderNameOrCode as keyof typeof shaders]; } else {