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