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