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