Mirror: A frag-canvas custom element to apply Shadertoy fragment shaders to a canvas or image/video element
at main 13 kB view raw
1import { trackVisibility, trackResizes, trackTextUpdates } from './observers'; 2 3const VERSION_300 = '#version 300 es'; 4 5const VS_SOURCE_100 = 6 'attribute vec2 vPos;\n' + 7 'void main() {\n' + 8 ' gl_Position = vec4(vPos, 0.0, 1.0);\n' + 9 '}'; 10const VS_SOURCE_300 = 11 `${VERSION_300}\n` + 12 'in vec4 vPos;\n' + 13 'void main() {\n' + 14 ' gl_Position = vPos;\n' + 15 '}'; 16 17const makeDateVector = () => { 18 const DATE = new Date(); 19 const year = DATE.getFullYear(); 20 const month = DATE.getMonth() + 1; 21 const day = DATE.getDate(); 22 const time = 23 DATE.getHours() * 60 * 60 + 24 DATE.getMinutes() * 60 + 25 DATE.getSeconds() + 26 DATE.getMilliseconds() * 0.001; 27 return [year, month, day, time] as const; 28}; 29 30const isImageElement = (tex: TexImageSource): tex is HTMLImageElement => 31 (tex as Element).tagName === 'IMG'; 32 33const preprocessShader = (source: string) => { 34 let header = ''; 35 let output = source.trim(); 36 let isES300 = false; 37 if (output.startsWith(VERSION_300)) { 38 isES300 = true; 39 output = output.slice(VERSION_300.length + 1); 40 header += `${VERSION_300}\n`; 41 } 42 43 if (!/^\s*precision /.test(output)) header += 'precision highp float;\n'; 44 45 if (!/main\s*\(/.test(output)) { 46 const ioRe = /\(\s*out\s+vec4\s+(\S+)\s*,\s*in\s+vec2\s+(\S+)\s*\)/g; 47 const io = ioRe.exec(source); 48 output = output.replace(/mainImage\s*\(/, 'main(').replace(ioRe, '()'); 49 if (isES300 && io) { 50 header += `out vec4 ${io[1]};\n`; 51 if (io[2] !== 'gl_FragCoord') 52 header += `#define ${io[2]} gl_FragCoord.xy\n`; 53 } else if (io) { 54 if (io[1] !== 'gl_FragColor') header += `#define ${io[1]} gl_FragColor\n`; 55 if (io[2] !== 'gl_FragCoord') 56 header += `#define ${io[2]} gl_FragCoord.xy\n`; 57 } 58 } 59 60 if (isES300 && output.includes('gl_FragColor')) { 61 header += 'out vec4 aFragColor;\n'; 62 header += '#define gl_FragColor aFragColor.xy\n'; 63 } 64 65 if (output.includes('iChannel0')) header += 'uniform sampler2D iChannel0;\n'; 66 if (output.includes('iResolution')) header += 'uniform vec2 iResolution;\n'; 67 if (output.includes('iChannelResolution')) 68 header += 'uniform vec3 iChannelResolution[1];\n'; 69 if (output.includes('iTime')) header += 'uniform float iTime;\n'; 70 if (output.includes('iTimeDelta')) header += 'uniform float iTimeDelta;\n'; 71 if (output.includes('iFrame')) header += 'uniform float iFrame;\n'; 72 if (output.includes('iChannel')) header += 'uniform float iChannel;\n'; 73 if (output.includes('iDate')) header += 'uniform vec4 iDate;\n'; 74 75 if (isES300) output = output.replace(/texture2D\s*\(/g, 'texture('); 76 77 return { 78 source: `${header}\n${output}`, 79 isES300, 80 }; 81}; 82 83interface InitState { 84 width: number; 85 height: number; 86 fragSource: string; 87} 88 89function createState(gl: WebGL2RenderingContext, init: InitState) { 90 const program = gl.createProgram(); 91 92 const vertShader300 = gl.createShader(gl.VERTEX_SHADER); 93 const vertShader100 = gl.createShader(gl.VERTEX_SHADER); 94 95 const fragShader = gl.createShader(gl.FRAGMENT_SHADER); 96 if (!vertShader100 || !vertShader300 || !fragShader) { 97 return null; 98 } 99 100 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); 101 102 gl.shaderSource(vertShader100, VS_SOURCE_100); 103 gl.compileShader(vertShader100); 104 gl.shaderSource(vertShader300, VS_SOURCE_300); 105 gl.compileShader(vertShader300); 106 107 const screenVertex = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); 108 const vertexBuffer = gl.createBuffer(); 109 gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); 110 gl.bufferData(gl.ARRAY_BUFFER, screenVertex, gl.STATIC_DRAW); 111 112 const texture = gl.createTexture(); 113 gl.activeTexture(gl['TEXTURE0']); 114 gl.bindTexture(gl.TEXTURE_2D, texture); 115 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 116 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 117 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 118 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 119 120 let width = init.width; 121 let height = init.height; 122 123 let vertexPos: GLint = 0; 124 let iResolution: WebGLUniformLocation | null = null; 125 let iChannelResolution: WebGLUniformLocation | null = null; 126 let iTime: WebGLUniformLocation | null = null; 127 let iTimeDelta: WebGLUniformLocation | null = null; 128 let iFrame: WebGLUniformLocation | null = null; 129 let iChannel: WebGLUniformLocation | null = null; 130 let iDate: WebGLUniformLocation | null = null; 131 132 let frameCount = 0; 133 let prevTimestamp: DOMHighResTimeStamp; 134 let prevSource: string | null; 135 136 const state = { 137 draw(source: TexImageSource, timestamp: DOMHighResTimeStamp) { 138 prevTimestamp = timestamp; 139 140 gl.useProgram(program); 141 142 if (isImageElement(source)) { 143 if (source.complete) { 144 const { currentSrc } = source; 145 if (prevSource !== currentSrc) { 146 prevSource = currentSrc; 147 gl.texImage2D( 148 gl.TEXTURE_2D, 149 0, 150 gl.RGBA, 151 gl.RGBA, 152 gl.UNSIGNED_BYTE, 153 source 154 ); 155 } 156 } 157 } else if (source) { 158 prevSource = null; 159 gl.texImage2D( 160 gl.TEXTURE_2D, 161 0, 162 gl.RGBA, 163 gl.RGBA, 164 gl.UNSIGNED_BYTE, 165 source 166 ); 167 if (iChannelResolution) 168 gl.uniform3fv(iChannelResolution, [width, height, 0]); 169 } else { 170 prevSource = null; 171 if (iChannelResolution) gl.uniform3fv(iChannelResolution, [0, 0, 0]); 172 } 173 174 if (iResolution) gl.uniform2f(iResolution, width, height); 175 if (iTime) gl.uniform1f(iTime, timestamp / 1000); 176 if (iTimeDelta) gl.uniform1f(iTime, (timestamp - prevTimestamp) / 1000); 177 if (iFrame) gl.uniform1f(iFrame, frameCount++); 178 if (iChannel) gl.uniform1i(iChannel, 0); 179 if (iDate) gl.uniform4f(iDate, ...makeDateVector()); 180 181 gl.enableVertexAttribArray(vertexPos); 182 gl.vertexAttribPointer(vertexPos, 2, gl.FLOAT, false, 0, 0); 183 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); 184 }, 185 186 updateViewport(newWidth: number, newHeight: number) { 187 gl.canvas.width = width = newWidth; 188 gl.canvas.height = height = newHeight; 189 gl.viewport(0, 0, width, height); 190 }, 191 192 updateFragShader(fragSource: string) { 193 const preprocessed = preprocessShader(fragSource); 194 gl.shaderSource(fragShader, preprocessed.source); 195 gl.compileShader(fragShader); 196 const vertShader = preprocessed.isES300 ? vertShader300 : vertShader100; 197 gl.attachShader(program, vertShader); 198 gl.attachShader(program, fragShader); 199 200 gl.linkProgram(program); 201 202 vertexPos = gl.getAttribLocation(program, 'vPos'); 203 iResolution = gl.getUniformLocation(program, 'iResolution'); 204 iChannelResolution = gl.getUniformLocation(program, 'iChannelResolution'); 205 iTime = gl.getUniformLocation(program, 'iTime'); 206 iTimeDelta = gl.getUniformLocation(program, 'iTimeDelta'); 207 iFrame = gl.getUniformLocation(program, 'iFrame'); 208 iChannel = gl.getUniformLocation(program, 'iChannel'); 209 iDate = gl.getUniformLocation(program, 'iDate'); 210 }, 211 212 drawImmediate() { 213 gl.useProgram(program); 214 gl.enableVertexAttribArray(vertexPos); 215 gl.vertexAttribPointer(vertexPos, 2, gl.FLOAT, false, 0, 0); 216 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); 217 }, 218 }; 219 220 state.updateViewport(width, height); 221 state.updateFragShader(init.fragSource); 222 return state; 223} 224 225class FragCanvas extends HTMLElement implements HTMLCanvasElement { 226 static observedAttributes = ['pause']; 227 228 private subscriptions: (() => void)[] = []; 229 private state: ReturnType<typeof createState> | null = null; 230 private input: HTMLCanvasElement | HTMLImageElement | HTMLVideoElement; 231 private output: HTMLCanvasElement; 232 public pause: boolean = false; 233 234 constructor() { 235 super(); 236 237 const sheet = new CSSStyleSheet(); 238 sheet.insertRule(':host([hidden]) { display: none; }'); 239 sheet.insertRule(':host { display: block; position: relative; }'); 240 sheet.insertRule( 241 ':host * { position: absolute; width: 100%; height: 100%; }' 242 ); 243 sheet.insertRule(':host *:not(:last-child) { visibility: hidden; }'); 244 245 const shadow = this.attachShadow({ mode: 'closed' }); 246 const output = (this.output = document.createElement('canvas')); 247 const input = (this.input = 248 this.querySelector(':not(canvas, script)') || 249 document.createElement('canvas')); 250 251 shadow.adoptedStyleSheets = [sheet]; 252 shadow.appendChild(input); 253 shadow.appendChild(output); 254 } 255 256 getContext( 257 contextId: '2d', 258 options?: CanvasRenderingContext2DSettings 259 ): CanvasRenderingContext2D | null; 260 getContext( 261 contextId: 'bitmaprenderer', 262 options?: ImageBitmapRenderingContextSettings 263 ): ImageBitmapRenderingContext | null; 264 getContext( 265 contextId: 'webgl', 266 options?: WebGLContextAttributes 267 ): WebGLRenderingContext | null; 268 getContext( 269 contextId: 'webgl2', 270 options?: WebGLContextAttributes 271 ): WebGL2RenderingContext | null; 272 273 getContext(contextId: string, options?: any) { 274 if (!(this.input instanceof HTMLCanvasElement)) { 275 return null; 276 } 277 this.input.width = this.width; 278 this.input.height = this.height; 279 return this.input.getContext(contextId, { 280 alpha: true, 281 desynchronized: true, 282 preserveDrawingBuffer: true, 283 ...options, 284 }); 285 } 286 287 toBlob(callback: BlobCallback, type?: string, quality?: any): void { 288 return this.output.toBlob(callback, type, quality); 289 } 290 291 toDataURL(type?: string, quality?: any): string { 292 return this.output.toDataURL(type, quality); 293 } 294 295 captureStream(frameRequestRate?: number): MediaStream { 296 return this.output.captureStream(frameRequestRate); 297 } 298 299 transferControlToOffscreen(): OffscreenCanvas { 300 return ( 301 this.input instanceof HTMLCanvasElement ? this.input : this.output 302 ).transferControlToOffscreen(); 303 } 304 305 get autoresize() { 306 return this.hasAttribute('autoresize'); 307 } 308 309 set autoresize(autoresize: boolean) { 310 if (autoresize) { 311 this.setAttribute('autoresize', ''); 312 } else { 313 this.removeAttribute('autoresize'); 314 } 315 } 316 317 get source() { 318 let text = ''; 319 for (const child of this.childNodes) { 320 if (child.nodeType === Node.TEXT_NODE) { 321 text += child.textContent || ''; 322 } else if (child instanceof HTMLScriptElement) { 323 text = child.textContent || ''; 324 break; 325 } 326 } 327 return text.trim(); 328 } 329 330 get width() { 331 if (this.state) { 332 return this.output.width; 333 } else { 334 return this.clientWidth * devicePixelRatio; 335 } 336 } 337 338 set width(width) { 339 this.input.width = width; 340 } 341 342 get height() { 343 if (this.state) { 344 return this.output.height; 345 } else { 346 return this.clientHeight * devicePixelRatio; 347 } 348 } 349 350 set height(height) { 351 this.input.height = height; 352 } 353 354 #frameID: number | undefined; 355 #rescheduleDraw() { 356 const self = this; 357 if (this.#frameID !== undefined) { 358 cancelAnimationFrame(this.#frameID); 359 this.#frameID = undefined; 360 } 361 if (!this.pause) { 362 this.#frameID = requestAnimationFrame(function draw( 363 timestamp: DOMHighResTimeStamp 364 ) { 365 if (self.state && !self.pause) { 366 self.state.draw(self.input, timestamp); 367 self.#frameID = requestAnimationFrame(draw); 368 } 369 }); 370 } 371 } 372 373 connectedCallback() { 374 this.pause = !!this.getAttribute('pause'); 375 376 const gl = this.output.getContext('webgl2', { 377 alpha: true, 378 desynchronized: true, 379 preserveDrawingBuffer: true, 380 }); 381 382 const init = { 383 fragSource: this.source, 384 width: this.clientWidth * devicePixelRatio, 385 height: this.clientHeight * devicePixelRatio, 386 }; 387 388 const state = (this.state = gl && createState(gl, init)); 389 if (state) { 390 this.subscriptions.push( 391 trackResizes(this, entry => { 392 const { inlineSize: width, blockSize: height } = entry; 393 if (this.autoresize) { 394 this.input.width = width; 395 this.input.height = height; 396 } 397 state.updateViewport(width, height); 398 state.drawImmediate(); 399 this.#rescheduleDraw(); 400 }), 401 trackTextUpdates(this, () => { 402 state.updateFragShader(this.source); 403 }), 404 trackVisibility(this, isVisible => { 405 this.pause = !isVisible; 406 this.#rescheduleDraw(); 407 }) 408 ); 409 this.#rescheduleDraw(); 410 } 411 } 412 413 attributeChangedCallback( 414 name: string, 415 _oldValue: unknown, 416 newValue: unknown 417 ) { 418 if (name === 'pause') { 419 this.pause = !!newValue; 420 this.#rescheduleDraw(); 421 } 422 } 423 424 disconnectedCallback() { 425 this.pause = true; 426 this.subscriptions.forEach(unsubscribe => unsubscribe()); 427 this.subscriptions.length = 0; 428 if (this.#frameID !== undefined) { 429 cancelAnimationFrame(this.#frameID); 430 this.#frameID = undefined; 431 } 432 } 433} 434 435customElements.define('frag-canvas', FragCanvas); 436export { FragCanvas };