Skip to content

Commit 6ae05fb

Browse files
committed
✨ post on shader web component
1 parent 960c60c commit 6ae05fb

File tree

2 files changed

+259
-1
lines changed

2 files changed

+259
-1
lines changed

src/content/blog-md/2025/09-11/shader-web-component.mdx

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,258 @@
22
title: Shader Web Component
33
subtitle: 09 November 2025
44
description: Example of a web component for rendering Web GPU Shaders
5-
published: false
65
---
76

87
import Example from './example.astro'
98

9+
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:
10+
1011
<Example />
12+
13+
Using the component looks like so:
14+
15+
```html title="page.html"
16+
<script type="module" src="/web-components/shader-canvas.js"></script>
17+
18+
<site-shader-canvas>
19+
<canvas />
20+
<script type="text/wgsl">
21+
struct VertexOutput {
22+
@builtin(position) position: vec4f,
23+
@location(0) texcoord: vec2f,
24+
};
25+
26+
@vertex fn vs(
27+
@builtin(vertex_index) vertexIndex : u32
28+
) -> VertexOutput {
29+
const pos = array(
30+
vec2( 1.0, 1.0),
31+
vec2( 1.0, -1.0),
32+
vec2(-1.0, -1.0),
33+
vec2( 1.0, 1.0),
34+
vec2(-1.0, -1.0),
35+
vec2(-1.0, 1.0),
36+
);
37+
38+
var vsOutput: VertexOutput;
39+
40+
let xy = pos[vertexIndex];
41+
vsOutput.texcoord = pos[vertexIndex] * vec2f(0.5, 0.5) + vec2f(0.5);
42+
vsOutput.position = vec4f(pos[vertexIndex], 0, 1);
43+
44+
return vsOutput;
45+
}
46+
47+
@group(0) @binding(0) var<uniform> uTime: f32;
48+
49+
@fragment fn fs(fsInput: VertexOutput) -> @location(0) vec4f {
50+
var red = abs(sin(uTime/10.0)) * fsInput.texcoord.x;
51+
var blue = abs(cos(uTime/5.0)) * fsInput.texcoord.y;
52+
return vec4f(red, 0.0, blue, 1.0);
53+
}
54+
</script>
55+
</site-shader-canvas>
56+
```
57+
58+
The component code is:
59+
60+
```js title="/shader-canvas.js"
61+
// @ts-check
62+
import { setupCanvas } from './shader.js'
63+
64+
class ShaderCanvas extends HTMLElement {
65+
static observedAttributes = ['centered', 'highlight', 'large']
66+
67+
/** type {MutationObserver} */
68+
#observer
69+
70+
/** @type {HTMLCanvasElement} */
71+
#canvas
72+
73+
/** @type {HTMLScriptElement} */
74+
#script
75+
76+
constructor() {
77+
super()
78+
this.#observer = new MutationObserver(() => this.#initialize())
79+
this.#observer.observe(this, { childList: true })
80+
}
81+
82+
disconnectedCallback() {
83+
this.#observer.disconnect()
84+
}
85+
86+
connectedCallback() {
87+
this.#initialize()
88+
}
89+
90+
async #initialize() {
91+
console.log('here')
92+
const initialized = this.#canvas && this.#script
93+
if (initialized) {
94+
return
95+
}
96+
97+
const canvas = this.querySelector('canvas')
98+
99+
/** @type {HTMLScriptElement} */
100+
const script = this.querySelector('script[type="text/wgsl"]')
101+
102+
if (!(script && canvas)) {
103+
return
104+
}
105+
106+
this.#observer.disconnect()
107+
108+
this.#canvas = canvas
109+
this.#script = script
110+
111+
console.log(canvas, script)
112+
113+
const render = await setupCanvas(this.#canvas, this.#script.innerText)
114+
115+
function renderLoop() {
116+
requestAnimationFrame(() => {
117+
render?.()
118+
renderLoop()
119+
})
120+
}
121+
122+
renderLoop()
123+
}
124+
}
125+
126+
customElements.define('site-shader-canvas', ShaderCanvas)
127+
```
128+
129+
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](/blog/2024/24-08/unintentionally-made-a-programming-language)
130+
131+
```js title="shader.js"
132+
// @ts-check
133+
134+
/**
135+
* @param {HTMLCanvasElement} canvas
136+
* @param {string} shader - WebGPU Shader
137+
* @returns {Promise<((saveTo?: string) => void) | undefined>} renderer function. Will be `undefined` if there is an instantiation error
138+
*/
139+
export async function setupCanvas(
140+
canvas,
141+
shader,
142+
) {
143+
// @ts-ignore
144+
const adapter = await navigator.gpu?.requestAdapter()
145+
const device = await adapter?.requestDevice()
146+
if (!device) {
147+
return
148+
}
149+
150+
151+
/**
152+
* @type {any}
153+
*/
154+
const ctx = canvas?.getContext('webgpu')
155+
if (!ctx) {
156+
return
157+
}
158+
159+
// @ts-ignore
160+
const format = navigator.gpu.getPreferredCanvasFormat()
161+
ctx.configure({
162+
device,
163+
format,
164+
})
165+
166+
const module = device.createShaderModule({
167+
label: 'base shader',
168+
code: shader,
169+
})
170+
171+
const pipeline = device.createRenderPipeline({
172+
label: 'render pipeline',
173+
layout: 'auto',
174+
vertex: {
175+
module,
176+
},
177+
fragment: {
178+
module,
179+
targets: [
180+
{
181+
format,
182+
},
183+
],
184+
},
185+
})
186+
187+
const uTime = device.createBuffer({
188+
size: [4],
189+
// @ts-ignore
190+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
191+
})
192+
193+
let curr = 1
194+
195+
/**
196+
* @param {string} [saveTo]
197+
*/
198+
function render(saveTo) {
199+
curr += 0.1
200+
201+
202+
// https://stackoverflow.com/questions/70284258/destroyed-texture-texture-used-in-a-submit-when-using-a-video-texture-in-ch
203+
// render pass descriptor needs to be recreated since this doesn't live very long on the GPU
204+
const renderPassDescriptor = {
205+
label: 'render pass descriptor',
206+
colorAttachments: [
207+
{
208+
loadOp: 'clear',
209+
storeOp: 'store',
210+
clearValue: [0, 0, 0, 0],
211+
view: ctx.getCurrentTexture().createView(),
212+
},
213+
],
214+
}
215+
216+
const bindGroup = device.createBindGroup({
217+
layout: pipeline.getBindGroupLayout(0),
218+
entries: [{
219+
binding: 0,
220+
resource: { buffer: uTime }
221+
}],
222+
})
223+
224+
const encoder = device.createCommandEncoder({ label: 'command encoder' })
225+
const pass = encoder.beginRenderPass(renderPassDescriptor)
226+
227+
pass.setPipeline(pipeline)
228+
pass.setBindGroup(0, bindGroup)
229+
230+
device.queue.writeBuffer(uTime, 0, new Float32Array([curr]));
231+
232+
pass.draw(6) // call our vertex shader 6 times
233+
pass.end()
234+
235+
const commandBuffer = encoder.finish()
236+
device.queue.submit([commandBuffer])
237+
if (saveTo) {
238+
// saving must be done during the render
239+
downloadCanvas(canvas, saveTo)
240+
}
241+
}
242+
243+
return render
244+
}
245+
246+
/**
247+
* @param {HTMLCanvasElement} canvas
248+
* @param {string} name
249+
*/
250+
function downloadCanvas(canvas, name) {
251+
const data = canvas.toDataURL('image/png')
252+
const link = document.createElement('a')
253+
254+
link.download = name.split('.').slice(0, -1).join('.') + '.png'
255+
link.href = data
256+
link.click()
257+
link.parentNode?.removeChild(link)
258+
}
259+
```

src/data/projects.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88
],
99
"link": "/blog/2025/09-11/coat-rack"
1010
},
11+
{
12+
"title": "Unnamed WebGPU Shader Based Image Editor",
13+
"description": "A very complicated way to edit your images",
14+
"tags": [
15+
"shaders",
16+
"photography"
17+
],
18+
"link": "/blog/2024/24-08/unintentionally-made-a-programming-language"
19+
},
1120
{
1221
"title": "Déjà vu",
1322
"description": "Developers don't read documentation. Just bring it to them instead.",

0 commit comments

Comments
 (0)