Mirror: A frag-canvas custom element to apply Shadertoy fragment shaders to a canvas or image/video element

feat: Pause rendering when element isn't visible (#5)

* Add observers and track visibility; pause when not visible

* Add changeset

Changed files
+117 -37
.changeset
src
+5
.changeset/nice-streets-taste.md
···
···
+
---
+
'frag-canvas': patch
+
---
+
+
Pause rendering when element isn't visible
+50 -37
src/frag-canvas-element.ts
···
const VERSION_300 = '#version 300 es';
const VS_SOURCE_100 =
···
}
class FragCanvas extends HTMLElement implements HTMLCanvasElement {
-
static observedAttributes = [];
private state: ReturnType<typeof createState> | null = null;
private input: HTMLCanvasElement | HTMLImageElement | HTMLVideoElement;
private output: HTMLCanvasElement;
-
-
#mutationObserver = new MutationObserver(() => {
-
if (this.state) {
-
this.state.updateFragShader(this.source);
-
}
-
});
-
-
#resizeObserver = new ResizeObserver(entries => {
-
const entry = entries[0];
-
if (this.state && entry) {
-
const width = entry.devicePixelContentBoxSize[0].inlineSize;
-
const height = entry.devicePixelContentBoxSize[0].blockSize;
-
if (this.autoresize) {
-
this.input.width = width;
-
this.input.height = height;
-
}
-
this.state.updateViewport(width, height);
-
this.state.drawImmediate();
-
this.#rescheduleDraw();
-
}
-
});
constructor() {
super();
···
cancelAnimationFrame(this.#frameID);
this.#frameID = undefined;
}
-
this.#frameID = requestAnimationFrame(function draw(
-
timestamp: DOMHighResTimeStamp
-
) {
-
if (self.state) {
-
self.state.draw(self.input, timestamp);
-
self.#frameID = requestAnimationFrame(draw);
-
}
-
});
}
connectedCallback() {
const gl = this.output.getContext('webgl2', {
alpha: true,
desynchronized: true,
···
const state = (this.state = gl && createState(gl, init));
if (state) {
-
this.#mutationObserver.observe(this, {
-
subtree: true,
-
characterData: true,
-
});
-
this.#resizeObserver.observe(this, { box: 'device-pixel-content-box' });
this.#rescheduleDraw();
}
}
disconnectedCallback() {
-
this.#mutationObserver.disconnect();
-
this.#resizeObserver.disconnect();
if (this.#frameID !== undefined) {
cancelAnimationFrame(this.#frameID);
this.#frameID = undefined;
···
+
import { trackVisibility, trackResizes, trackTextUpdates } from './observers';
+
const VERSION_300 = '#version 300 es';
const VS_SOURCE_100 =
···
}
class FragCanvas extends HTMLElement implements HTMLCanvasElement {
+
static observedAttributes = ['pause'];
+
private subscriptions: (() => void)[] = [];
private state: ReturnType<typeof createState> | null = null;
private input: HTMLCanvasElement | HTMLImageElement | HTMLVideoElement;
private output: HTMLCanvasElement;
+
public pause: boolean = false;
constructor() {
super();
···
cancelAnimationFrame(this.#frameID);
this.#frameID = undefined;
}
+
if (!this.pause) {
+
this.#frameID = requestAnimationFrame(function draw(
+
timestamp: DOMHighResTimeStamp
+
) {
+
if (self.state && !self.pause) {
+
self.state.draw(self.input, timestamp);
+
self.#frameID = requestAnimationFrame(draw);
+
}
+
});
+
}
}
connectedCallback() {
+
this.pause = !!this.getAttribute('pause');
+
const gl = this.output.getContext('webgl2', {
alpha: true,
desynchronized: true,
···
const state = (this.state = gl && createState(gl, init));
if (state) {
+
this.subscriptions.push(
+
trackResizes(this, entry => {
+
const { inlineSize: width, blockSize: height } = entry;
+
if (this.autoresize) {
+
this.input.width = width;
+
this.input.height = height;
+
}
+
state.updateViewport(width, height);
+
state.drawImmediate();
+
this.#rescheduleDraw();
+
}),
+
trackTextUpdates(this, () => {
+
state.updateFragShader(this.source);
+
}),
+
trackVisibility(this, isVisible => {
+
this.pause = !isVisible;
+
this.#rescheduleDraw();
+
})
+
);
+
this.#rescheduleDraw();
+
}
+
}
+
+
attributeChangedCallback(
+
name: string,
+
_oldValue: unknown,
+
newValue: unknown
+
) {
+
if (name === 'pause') {
+
this.pause = !!newValue;
this.#rescheduleDraw();
}
}
disconnectedCallback() {
+
this.pause = true;
+
this.subscriptions.forEach(unsubscribe => unsubscribe());
+
this.subscriptions.length = 0;
if (this.#frameID !== undefined) {
cancelAnimationFrame(this.#frameID);
this.#frameID = undefined;
+62
src/observers.ts
···
···
+
let intersectionObserver: IntersectionObserver | undefined;
+
const intersectionListeners = new Map<Element, (isVisible: boolean) => void>();
+
const getIntersectionObserver = () =>
+
intersectionObserver ||
+
(intersectionObserver = new IntersectionObserver(entries => {
+
for (const entry of entries) {
+
const listener = intersectionListeners.get(entry.target);
+
if (listener) {
+
listener(entry.isIntersecting);
+
}
+
}
+
}));
+
+
export function trackVisibility(
+
element: Element,
+
onChange: (isVisible: boolean) => void
+
): () => void {
+
const observer = getIntersectionObserver();
+
intersectionListeners.set(element, onChange);
+
observer.observe(element);
+
return () => {
+
observer.unobserve(element);
+
intersectionListeners.delete(element);
+
};
+
}
+
+
let resizeObserver: ResizeObserver | undefined;
+
const resizeListeners = new Map<
+
Element,
+
(box: { inlineSize: number; blockSize: number }) => void
+
>();
+
const getResizeObserver = () =>
+
resizeObserver ||
+
(resizeObserver = new ResizeObserver(entries => {
+
for (const entry of entries) {
+
const listener = resizeListeners.get(entry.target);
+
if (listener) listener(entry.devicePixelContentBoxSize[0]);
+
}
+
}));
+
+
export function trackResizes(
+
element: Element,
+
onChange: (box: { inlineSize: number; blockSize: number }) => void
+
): () => void {
+
const observer = getResizeObserver();
+
resizeListeners.set(element, onChange);
+
observer.observe(element, { box: 'device-pixel-content-box' });
+
return () => {
+
resizeListeners.delete(element);
+
};
+
}
+
+
export function trackTextUpdates(
+
element: Element,
+
onChange: () => void
+
): () => void {
+
const observer = new MutationObserver(onChange);
+
observer.observe(element, { subtree: true, characterData: true });
+
return () => {
+
observer.disconnect();
+
};
+
}