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