Mirror: A frag-canvas custom element to apply Shadertoy fragment shaders to a canvas or image/video element
1import { trackVisibility, trackResizes, trackTextUpdates } from './observers';
2
3const VERSION_300 = '#version 300 es';
4
5const VS_SOURCE_100 =
6 'attribute vec2 vPos;\n' +
7 'void main() {\n' +
8 ' gl_Position = vec4(vPos, 0.0, 1.0);\n' +
9 '}';
10const VS_SOURCE_300 =
11 `${VERSION_300}\n` +
12 'in vec4 vPos;\n' +
13 'void main() {\n' +
14 ' gl_Position = vPos;\n' +
15 '}';
16
17const makeDateVector = () => {
18 const DATE = new Date();
19 const year = DATE.getFullYear();
20 const month = DATE.getMonth() + 1;
21 const day = DATE.getDate();
22 const time =
23 DATE.getHours() * 60 * 60 +
24 DATE.getMinutes() * 60 +
25 DATE.getSeconds() +
26 DATE.getMilliseconds() * 0.001;
27 return [year, month, day, time] as const;
28};
29
30const isImageElement = (tex: TexImageSource): tex is HTMLImageElement =>
31 (tex as Element).tagName === 'IMG';
32
33const preprocessShader = (source: string) => {
34 let header = '';
35 let output = source.trim();
36 let isES300 = false;
37 if (output.startsWith(VERSION_300)) {
38 isES300 = true;
39 output = output.slice(VERSION_300.length + 1);
40 header += `${VERSION_300}\n`;
41 }
42
43 if (!/^\s*precision /.test(output)) header += 'precision highp float;\n';
44
45 if (!/main\s*\(/.test(output)) {
46 const ioRe = /\(\s*out\s+vec4\s+(\S+)\s*,\s*in\s+vec2\s+(\S+)\s*\)/g;
47 const io = ioRe.exec(source);
48 output = output.replace(/mainImage\s*\(/, 'main(').replace(ioRe, '()');
49 if (isES300 && io) {
50 header += `out vec4 ${io[1]};\n`;
51 if (io[2] !== 'gl_FragCoord')
52 header += `#define ${io[2]} gl_FragCoord.xy\n`;
53 } else if (io) {
54 if (io[1] !== 'gl_FragColor') header += `#define ${io[1]} gl_FragColor\n`;
55 if (io[2] !== 'gl_FragCoord')
56 header += `#define ${io[2]} gl_FragCoord.xy\n`;
57 }
58 }
59
60 if (isES300 && output.includes('gl_FragColor')) {
61 header += 'out vec4 aFragColor;\n';
62 header += '#define gl_FragColor aFragColor.xy\n';
63 }
64
65 if (output.includes('iChannel0')) header += 'uniform sampler2D iChannel0;\n';
66 if (output.includes('iResolution')) header += 'uniform vec2 iResolution;\n';
67 if (output.includes('iChannelResolution'))
68 header += 'uniform vec3 iChannelResolution[1];\n';
69 if (output.includes('iTime')) header += 'uniform float iTime;\n';
70 if (output.includes('iTimeDelta')) header += 'uniform float iTimeDelta;\n';
71 if (output.includes('iFrame')) header += 'uniform float iFrame;\n';
72 if (output.includes('iChannel')) header += 'uniform float iChannel;\n';
73 if (output.includes('iDate')) header += 'uniform vec4 iDate;\n';
74
75 if (isES300) output = output.replace(/texture2D\s*\(/g, 'texture(');
76
77 return {
78 source: `${header}\n${output}`,
79 isES300,
80 };
81};
82
83interface InitState {
84 width: number;
85 height: number;
86 fragSource: string;
87}
88
89function createState(gl: WebGL2RenderingContext, init: InitState) {
90 const program = gl.createProgram();
91
92 const vertShader300 = gl.createShader(gl.VERTEX_SHADER);
93 const vertShader100 = gl.createShader(gl.VERTEX_SHADER);
94
95 const fragShader = gl.createShader(gl.FRAGMENT_SHADER);
96 if (!vertShader100 || !vertShader300 || !fragShader) {
97 return null;
98 }
99
100 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
101
102 gl.shaderSource(vertShader100, VS_SOURCE_100);
103 gl.compileShader(vertShader100);
104 gl.shaderSource(vertShader300, VS_SOURCE_300);
105 gl.compileShader(vertShader300);
106
107 const screenVertex = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
108 const vertexBuffer = gl.createBuffer();
109 gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
110 gl.bufferData(gl.ARRAY_BUFFER, screenVertex, gl.STATIC_DRAW);
111
112 const texture = gl.createTexture();
113 gl.activeTexture(gl['TEXTURE0']);
114 gl.bindTexture(gl.TEXTURE_2D, texture);
115 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
116 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
117 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
118 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
119
120 let width = init.width;
121 let height = init.height;
122
123 let vertexPos: GLint = 0;
124 let iResolution: WebGLUniformLocation | null = null;
125 let iChannelResolution: WebGLUniformLocation | null = null;
126 let iTime: WebGLUniformLocation | null = null;
127 let iTimeDelta: WebGLUniformLocation | null = null;
128 let iFrame: WebGLUniformLocation | null = null;
129 let iChannel: WebGLUniformLocation | null = null;
130 let iDate: WebGLUniformLocation | null = null;
131
132 let frameCount = 0;
133 let prevTimestamp: DOMHighResTimeStamp;
134 let prevSource: string | null;
135
136 const state = {
137 draw(source: TexImageSource, timestamp: DOMHighResTimeStamp) {
138 prevTimestamp = timestamp;
139
140 gl.useProgram(program);
141
142 if (isImageElement(source)) {
143 if (source.complete) {
144 const { currentSrc } = source;
145 if (prevSource !== currentSrc) {
146 prevSource = currentSrc;
147 gl.texImage2D(
148 gl.TEXTURE_2D,
149 0,
150 gl.RGBA,
151 gl.RGBA,
152 gl.UNSIGNED_BYTE,
153 source
154 );
155 }
156 }
157 } else if (source) {
158 prevSource = null;
159 gl.texImage2D(
160 gl.TEXTURE_2D,
161 0,
162 gl.RGBA,
163 gl.RGBA,
164 gl.UNSIGNED_BYTE,
165 source
166 );
167 if (iChannelResolution)
168 gl.uniform3fv(iChannelResolution, [width, height, 0]);
169 } else {
170 prevSource = null;
171 if (iChannelResolution) gl.uniform3fv(iChannelResolution, [0, 0, 0]);
172 }
173
174 if (iResolution) gl.uniform2f(iResolution, width, height);
175 if (iTime) gl.uniform1f(iTime, timestamp / 1000);
176 if (iTimeDelta) gl.uniform1f(iTime, (timestamp - prevTimestamp) / 1000);
177 if (iFrame) gl.uniform1f(iFrame, frameCount++);
178 if (iChannel) gl.uniform1i(iChannel, 0);
179 if (iDate) gl.uniform4f(iDate, ...makeDateVector());
180
181 gl.enableVertexAttribArray(vertexPos);
182 gl.vertexAttribPointer(vertexPos, 2, gl.FLOAT, false, 0, 0);
183 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
184 },
185
186 updateViewport(newWidth: number, newHeight: number) {
187 gl.canvas.width = width = newWidth;
188 gl.canvas.height = height = newHeight;
189 gl.viewport(0, 0, width, height);
190 },
191
192 updateFragShader(fragSource: string) {
193 const preprocessed = preprocessShader(fragSource);
194 gl.shaderSource(fragShader, preprocessed.source);
195 gl.compileShader(fragShader);
196 const vertShader = preprocessed.isES300 ? vertShader300 : vertShader100;
197 gl.attachShader(program, vertShader);
198 gl.attachShader(program, fragShader);
199
200 gl.linkProgram(program);
201
202 vertexPos = gl.getAttribLocation(program, 'vPos');
203 iResolution = gl.getUniformLocation(program, 'iResolution');
204 iChannelResolution = gl.getUniformLocation(program, 'iChannelResolution');
205 iTime = gl.getUniformLocation(program, 'iTime');
206 iTimeDelta = gl.getUniformLocation(program, 'iTimeDelta');
207 iFrame = gl.getUniformLocation(program, 'iFrame');
208 iChannel = gl.getUniformLocation(program, 'iChannel');
209 iDate = gl.getUniformLocation(program, 'iDate');
210 },
211
212 drawImmediate() {
213 gl.useProgram(program);
214 gl.enableVertexAttribArray(vertexPos);
215 gl.vertexAttribPointer(vertexPos, 2, gl.FLOAT, false, 0, 0);
216 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
217 },
218 };
219
220 state.updateViewport(width, height);
221 state.updateFragShader(init.fragSource);
222 return state;
223}
224
225class FragCanvas extends HTMLElement implements HTMLCanvasElement {
226 static observedAttributes = ['pause'];
227
228 private subscriptions: (() => void)[] = [];
229 private state: ReturnType<typeof createState> | null = null;
230 private input: HTMLCanvasElement | HTMLImageElement | HTMLVideoElement;
231 private output: HTMLCanvasElement;
232 public pause: boolean = false;
233
234 constructor() {
235 super();
236
237 const sheet = new CSSStyleSheet();
238 sheet.insertRule(':host([hidden]) { display: none; }');
239 sheet.insertRule(':host { display: block; position: relative; }');
240 sheet.insertRule(
241 ':host * { position: absolute; width: 100%; height: 100%; }'
242 );
243 sheet.insertRule(':host *:not(:last-child) { visibility: hidden; }');
244
245 const shadow = this.attachShadow({ mode: 'closed' });
246 const output = (this.output = document.createElement('canvas'));
247 const input = (this.input =
248 this.querySelector(':not(canvas, script)') ||
249 document.createElement('canvas'));
250
251 shadow.adoptedStyleSheets = [sheet];
252 shadow.appendChild(input);
253 shadow.appendChild(output);
254 }
255
256 getContext(
257 contextId: '2d',
258 options?: CanvasRenderingContext2DSettings
259 ): CanvasRenderingContext2D | null;
260 getContext(
261 contextId: 'bitmaprenderer',
262 options?: ImageBitmapRenderingContextSettings
263 ): ImageBitmapRenderingContext | null;
264 getContext(
265 contextId: 'webgl',
266 options?: WebGLContextAttributes
267 ): WebGLRenderingContext | null;
268 getContext(
269 contextId: 'webgl2',
270 options?: WebGLContextAttributes
271 ): WebGL2RenderingContext | null;
272
273 getContext(contextId: string, options?: any) {
274 if (!(this.input instanceof HTMLCanvasElement)) {
275 return null;
276 }
277 this.input.width = this.width;
278 this.input.height = this.height;
279 return this.input.getContext(contextId, {
280 alpha: true,
281 desynchronized: true,
282 preserveDrawingBuffer: true,
283 ...options,
284 });
285 }
286
287 toBlob(callback: BlobCallback, type?: string, quality?: any): void {
288 return this.output.toBlob(callback, type, quality);
289 }
290
291 toDataURL(type?: string, quality?: any): string {
292 return this.output.toDataURL(type, quality);
293 }
294
295 captureStream(frameRequestRate?: number): MediaStream {
296 return this.output.captureStream(frameRequestRate);
297 }
298
299 transferControlToOffscreen(): OffscreenCanvas {
300 return (
301 this.input instanceof HTMLCanvasElement ? this.input : this.output
302 ).transferControlToOffscreen();
303 }
304
305 get autoresize() {
306 return this.hasAttribute('autoresize');
307 }
308
309 set autoresize(autoresize: boolean) {
310 if (autoresize) {
311 this.setAttribute('autoresize', '');
312 } else {
313 this.removeAttribute('autoresize');
314 }
315 }
316
317 get source() {
318 let text = '';
319 for (const child of this.childNodes) {
320 if (child.nodeType === Node.TEXT_NODE) {
321 text += child.textContent || '';
322 } else if (child instanceof HTMLScriptElement) {
323 text = child.textContent || '';
324 break;
325 }
326 }
327 return text.trim();
328 }
329
330 get width() {
331 if (this.state) {
332 return this.output.width;
333 } else {
334 return this.clientWidth * devicePixelRatio;
335 }
336 }
337
338 set width(width) {
339 this.input.width = width;
340 }
341
342 get height() {
343 if (this.state) {
344 return this.output.height;
345 } else {
346 return this.clientHeight * devicePixelRatio;
347 }
348 }
349
350 set height(height) {
351 this.input.height = height;
352 }
353
354 #frameID: number | undefined;
355 #rescheduleDraw() {
356 const self = this;
357 if (this.#frameID !== undefined) {
358 cancelAnimationFrame(this.#frameID);
359 this.#frameID = undefined;
360 }
361 if (!this.pause) {
362 this.#frameID = requestAnimationFrame(function draw(
363 timestamp: DOMHighResTimeStamp
364 ) {
365 if (self.state && !self.pause) {
366 self.state.draw(self.input, timestamp);
367 self.#frameID = requestAnimationFrame(draw);
368 }
369 });
370 }
371 }
372
373 connectedCallback() {
374 this.pause = !!this.getAttribute('pause');
375
376 const gl = this.output.getContext('webgl2', {
377 alpha: true,
378 desynchronized: true,
379 preserveDrawingBuffer: true,
380 });
381
382 const init = {
383 fragSource: this.source,
384 width: this.clientWidth * devicePixelRatio,
385 height: this.clientHeight * devicePixelRatio,
386 };
387
388 const state = (this.state = gl && createState(gl, init));
389 if (state) {
390 this.subscriptions.push(
391 trackResizes(this, entry => {
392 const { inlineSize: width, blockSize: height } = entry;
393 if (this.autoresize) {
394 this.input.width = width;
395 this.input.height = height;
396 }
397 state.updateViewport(width, height);
398 state.drawImmediate();
399 this.#rescheduleDraw();
400 }),
401 trackTextUpdates(this, () => {
402 state.updateFragShader(this.source);
403 }),
404 trackVisibility(this, isVisible => {
405 this.pause = !isVisible;
406 this.#rescheduleDraw();
407 })
408 );
409 this.#rescheduleDraw();
410 }
411 }
412
413 attributeChangedCallback(
414 name: string,
415 _oldValue: unknown,
416 newValue: unknown
417 ) {
418 if (name === 'pause') {
419 this.pause = !!newValue;
420 this.#rescheduleDraw();
421 }
422 }
423
424 disconnectedCallback() {
425 this.pause = true;
426 this.subscriptions.forEach(unsubscribe => unsubscribe());
427 this.subscriptions.length = 0;
428 if (this.#frameID !== undefined) {
429 cancelAnimationFrame(this.#frameID);
430 this.#frameID = undefined;
431 }
432 }
433}
434
435customElements.define('frag-canvas', FragCanvas);
436export { FragCanvas };