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