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 };