Shader Web Component
So I want to use some more shaders and I want to also migrate everything over to use wgsl instead of glsl but it's kinda annoying to set them up every time so I made a little web component for using them, this is very much a work-in-progress, but here's it in action:
Using the component looks like so:
<script type="module" src="/web-components/shader-canvas.js"></script>
<site-shader-canvas>
<canvas />
<script type="text/wgsl">
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) texcoord: vec2f,
};
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> VertexOutput {
const pos = array(
vec2( 1.0, 1.0),
vec2( 1.0, -1.0),
vec2(-1.0, -1.0),
vec2( 1.0, 1.0),
vec2(-1.0, -1.0),
vec2(-1.0, 1.0),
);
var vsOutput: VertexOutput;
let xy = pos[vertexIndex];
vsOutput.texcoord = pos[vertexIndex] * vec2f(0.5, 0.5) + vec2f(0.5);
vsOutput.position = vec4f(pos[vertexIndex], 0, 1);
return vsOutput;
}
@group(0) @binding(0) var<uniform> uTime: f32;
@fragment fn fs(fsInput: VertexOutput) -> @location(0) vec4f {
var red = abs(sin(uTime/10.0)) * fsInput.texcoord.x;
var blue = abs(cos(uTime/5.0)) * fsInput.texcoord.y;
return vec4f(red, 0.0, blue, 1.0);
}
</script>
</site-shader-canvas>
The component code is:
// @ts-check
import { setupCanvas } from './shader.js'
class ShaderCanvas extends HTMLElement {
static observedAttributes = ['centered', 'highlight', 'large']
/** @type {MutationObserver} */
#observer
/** @type {HTMLCanvasElement} */
#canvas
/** @type {HTMLScriptElement} */
#script
constructor() {
super()
this.#observer = new MutationObserver(() => this.#initialize())
this.#observer.observe(this, { childList: true })
}
disconnectedCallback() {
this.#observer.disconnect()
}
connectedCallback() {
this.#initialize()
}
async #initialize() {
console.log('here')
const initialized = this.#canvas && this.#script
if (initialized) {
return
}
const canvas = this.querySelector('canvas')
/** @type {HTMLScriptElement} */
const script = this.querySelector('script[type="text/wgsl"]')
if (!(script && canvas)) {
return
}
this.#observer.disconnect()
this.#canvas = canvas
this.#script = script
console.log(canvas, script)
const render = await setupCanvas(this.#canvas, this.#script.innerText)
function renderLoop() {
requestAnimationFrame(() => {
render?.()
renderLoop()
})
}
renderLoop()
}
}
customElements.define('site-shader-canvas', ShaderCanvas)
And the code for actually doing the shader rendering pipeline is and is a heavily simplified version of what I'm currently using for my Image Editor
// @ts-check
/**
* @param {HTMLCanvasElement} canvas
* @param {string} shader - WebGPU Shader
* @returns {Promise<((saveTo?: string) => void) | undefined>} renderer function. Will be `undefined` if there is an instantiation error
*/
export async function setupCanvas(
canvas,
shader,
) {
// @ts-ignore
const adapter = await navigator.gpu?.requestAdapter()
const device = await adapter?.requestDevice()
if (!device) {
return
}
/**
* @type {any}
*/
const ctx = canvas?.getContext('webgpu')
if (!ctx) {
return
}
// @ts-ignore
const format = navigator.gpu.getPreferredCanvasFormat()
ctx.configure({
device,
format,
})
const module = device.createShaderModule({
label: 'base shader',
code: shader,
})
const pipeline = device.createRenderPipeline({
label: 'render pipeline',
layout: 'auto',
vertex: {
module,
},
fragment: {
module,
targets: [
{
format,
},
],
},
})
const uTime = device.createBuffer({
size: [4],
// @ts-ignore
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
let curr = 1
/**
* @param {string} [saveTo]
*/
function render(saveTo) {
curr += 0.1
// https://stackoverflow.com/questions/70284258/destroyed-texture-texture-used-in-a-submit-when-using-a-video-texture-in-ch
// render pass descriptor needs to be recreated since this doesn't live very long on the GPU
const renderPassDescriptor = {
label: 'render pass descriptor',
colorAttachments: [
{
loadOp: 'clear',
storeOp: 'store',
clearValue: [0, 0, 0, 0],
view: ctx.getCurrentTexture().createView(),
},
],
}
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uTime }
}],
})
const encoder = device.createCommandEncoder({ label: 'command encoder' })
const pass = encoder.beginRenderPass(renderPassDescriptor)
pass.setPipeline(pipeline)
pass.setBindGroup(0, bindGroup)
device.queue.writeBuffer(uTime, 0, new Float32Array([curr]));
pass.draw(6) // call our vertex shader 6 times
pass.end()
const commandBuffer = encoder.finish()
device.queue.submit([commandBuffer])
if (saveTo) {
// saving must be done during the render
downloadCanvas(canvas, saveTo)
}
}
return render
}
/**
* @param {HTMLCanvasElement} canvas
* @param {string} name
*/
function downloadCanvas(canvas, name) {
const data = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.download = name.split('.').slice(0, -1).join('.') + '.png'
link.href = data
link.click()
link.parentNode?.removeChild(link)
}