typegpu-shader-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +33 -0
- package/src/fragment-shader.ts +39 -0
- package/src/index.ts +1 -0
- package/src/mouse.ts +45 -0
- package/src/provided-uniforms.ts +31 -0
- package/src/typegpu-shader-canvas.ts +115 -0
- package/src/vertex-shader.ts +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Wayne
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# typegpu-shader-canvas
|
|
2
|
+
|
|
3
|
+
This library makes it easy to render TypeScript based fragment shaders directly to a web based canvas element. This is made possible by the amazing [TypeGPU](https://typegpu.com) library.
|
|
4
|
+
|
|
5
|
+
- 🚫 No setting up a WebGPU device or pipeline
|
|
6
|
+
- 🚫 No need to render triangle geometry
|
|
7
|
+
- 🚫 No need to write vertex shaders
|
|
8
|
+
- ✨ Just provide a canvas, and your shader code, and look at the pretty pixels.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- 🖱️ Mouse event handling for both position and clicked buttons, including off canvas tracking.
|
|
14
|
+
- 📐 Use `uv` (clip space) or `xy` (pixels) based coordinates.
|
|
15
|
+
- 🔄 Automatically start animating in a render loop.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install typegpu-shader-canvas
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### WebGPU Types
|
|
25
|
+
|
|
26
|
+
Your `tsconfig.json` **must** include [`@webgpu/types`](https://docs.swmansion.com/TypeGPU/getting-started/) so TypeScript recognizes WebGPU globals:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"compilerOptions": {
|
|
31
|
+
"types": ["@webgpu/types"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Build Plugin
|
|
37
|
+
|
|
38
|
+
Your build pipeline **must** include [`unplugin-typegpu`](https://docs.swmansion.com/TypeGPU/tooling/unplugin-typegpu/) — This is what allows your shader to be compiled to WebGPU's WGSL.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#### Vite example
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// vite.config.ts
|
|
45
|
+
import typegpuPlugin from 'unplugin-typegpu/vite'
|
|
46
|
+
import { defineConfig } from 'vite'
|
|
47
|
+
|
|
48
|
+
export default defineConfig({
|
|
49
|
+
plugins: [typegpuPlugin({})],
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { vec3f, vec4f } from 'typegpu/data'
|
|
57
|
+
import { mix, sin } from 'typegpu/std'
|
|
58
|
+
import { createShaderCanvas } from 'typegpu-shader-canvas'
|
|
59
|
+
|
|
60
|
+
createShaderCanvas(
|
|
61
|
+
document.getElementById('canvas'),
|
|
62
|
+
({ uv, time }) => {
|
|
63
|
+
'use gpu'
|
|
64
|
+
|
|
65
|
+
const color = mix(
|
|
66
|
+
vec3f(1, 0, 0),
|
|
67
|
+
vec3f(0, 0, 1),
|
|
68
|
+
sin(time + uv.x) * 0.5 + 0.5
|
|
69
|
+
)
|
|
70
|
+
return vec4f(color, 1)
|
|
71
|
+
},
|
|
72
|
+
).startRendering()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## API
|
|
76
|
+
|
|
77
|
+
### `createShaderCanvas(canvas, fragmentShader)`
|
|
78
|
+
|
|
79
|
+
Creates a WebGPU shader canvas that renders a fragment shader to the given `<canvas>` element.
|
|
80
|
+
|
|
81
|
+
**Parameters:**
|
|
82
|
+
|
|
83
|
+
- `canvas` — an `HTMLCanvasElement` (e.g. from `document.getElementById`)
|
|
84
|
+
- `fragmentShader` — a function that receives `FragmentParameters` and returns a `vec4f` color. Must contain a `'use gpu'` directive.
|
|
85
|
+
|
|
86
|
+
**Returns** an object with:
|
|
87
|
+
|
|
88
|
+
- `startRendering()` — start a rendering loop with `requestAnimationFrame`.
|
|
89
|
+
- `render()` — renders a single frame. Use this if you want implement you own rendering trigger.
|
|
90
|
+
|
|
91
|
+
### `FragmentParameters`
|
|
92
|
+
|
|
93
|
+
The struct passed to your fragment shader function, with these fields:
|
|
94
|
+
|
|
95
|
+
| Field | Type | Description |
|
|
96
|
+
| ------------- | ------- | -------------------------------------------- |
|
|
97
|
+
| `uv` | `vec2f` | Clip-space coordinates (-1 to 1) |
|
|
98
|
+
| `xy` | `vec2f` | Pixel coordinates |
|
|
99
|
+
| `time` | `f32` | Elapsed time in seconds since page load |
|
|
100
|
+
| `mouse` | `Mouse` | Mouse state (see below) |
|
|
101
|
+
| `resolution` | `vec2f` | Canvas resolution in pixels |
|
|
102
|
+
| `aspectRatio` | `f32` | Canvas aspect ratio (width / height) |
|
|
103
|
+
|
|
104
|
+
#### `Mouse`
|
|
105
|
+
|
|
106
|
+
| Field | Type | Description |
|
|
107
|
+
| -------- | ------- | ------------------------------------------ |
|
|
108
|
+
| `xy` | `vec2f` | Position in pixels on the canvas |
|
|
109
|
+
| `uv` | `vec2f` | Position in clip-space (-1 to 1) |
|
|
110
|
+
| `isOver` | `i32` | 1 if the mouse is over the canvas, else 0 |
|
|
111
|
+
| `down` | `i32` | 1 if the left mouse button is down, else 0 |
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "typegpu-shader-canvas",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A simple WebGPU shader canvas powered by TypeGPU",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Alex Wayne",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "vite",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@webgpu/types": "^0.1.69",
|
|
21
|
+
"typegpu": "^0.9.0",
|
|
22
|
+
"unplugin-typegpu": "^0.9.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
|
26
|
+
"@webgpu/types": "^0.1.69",
|
|
27
|
+
"prettier": "^3.8.1",
|
|
28
|
+
"typegpu": "^0.9.0",
|
|
29
|
+
"typescript": "^5.9.3",
|
|
30
|
+
"unplugin-typegpu": "^0.9.0",
|
|
31
|
+
"vite": "^7.3.1"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import tgpu, { type TgpuBufferReadonly } from 'typegpu'
|
|
2
|
+
import { type Infer, struct, type v4f, vec2f, vec4f } from 'typegpu/data'
|
|
3
|
+
|
|
4
|
+
import { ProvidedUniforms } from './provided-uniforms'
|
|
5
|
+
|
|
6
|
+
export const FragmentParameters = struct({
|
|
7
|
+
uv: vec2f,
|
|
8
|
+
xy: vec2f,
|
|
9
|
+
...ProvidedUniforms.propTypes,
|
|
10
|
+
})
|
|
11
|
+
export type FragmentParameters = Infer<typeof FragmentParameters>
|
|
12
|
+
|
|
13
|
+
export function createFragmentShader(
|
|
14
|
+
fragmentShaderImplementation: (
|
|
15
|
+
fragmentParameters: Infer<typeof FragmentParameters>,
|
|
16
|
+
) => v4f,
|
|
17
|
+
providedUniforms: TgpuBufferReadonly<typeof ProvidedUniforms>,
|
|
18
|
+
) {
|
|
19
|
+
return tgpu['~unstable'].fragmentFn({
|
|
20
|
+
in: { uv: vec2f },
|
|
21
|
+
out: { color: vec4f },
|
|
22
|
+
})(({ uv }) => {
|
|
23
|
+
// Calculate pixel coordinates from clip space coordinates
|
|
24
|
+
const xy = uv.add(vec2f(1, 1)).mul(providedUniforms.$.resolution).mul(0.5)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
color: fragmentShaderImplementation(
|
|
28
|
+
FragmentParameters({
|
|
29
|
+
uv,
|
|
30
|
+
xy,
|
|
31
|
+
time: providedUniforms.$.time,
|
|
32
|
+
mouse: providedUniforms.$.mouse,
|
|
33
|
+
resolution: providedUniforms.$.resolution,
|
|
34
|
+
aspectRatio: providedUniforms.$.aspectRatio,
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createShaderCanvas } from './typegpu-shader-canvas'
|
package/src/mouse.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { TgpuBuffer } from 'typegpu'
|
|
2
|
+
import { type v2f, vec2f } from 'typegpu/data'
|
|
3
|
+
|
|
4
|
+
import { Mouse, ProvidedUniforms } from './provided-uniforms'
|
|
5
|
+
|
|
6
|
+
function getXY(event: MouseEvent, rect: DOMRect): v2f {
|
|
7
|
+
return vec2f(event.clientX - rect.left, -(event.clientY - rect.top))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getUV(rect: DOMRect, xy: v2f): v2f {
|
|
11
|
+
return xy.div(vec2f(rect.width, rect.height)).mul(2).sub(vec2f(1, -1))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getIsOver(rect: DOMRect, xy: v2f): 0 | 1 {
|
|
15
|
+
const inX = xy.x >= 0 && xy.x <= rect.width
|
|
16
|
+
const inY = xy.y <= 0 && xy.y >= -rect.height
|
|
17
|
+
return inX && inY ? 1 : 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getMouseDown(event: MouseEvent): 0 | 1 {
|
|
21
|
+
return (event.buttons & 1) !== 0 ? 1 : 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function trackMouse(
|
|
25
|
+
canvas: HTMLElement,
|
|
26
|
+
providedUniforms: TgpuBuffer<typeof ProvidedUniforms>,
|
|
27
|
+
): void {
|
|
28
|
+
function handleMouseEvent(event: MouseEvent) {
|
|
29
|
+
const rect = canvas.getBoundingClientRect()
|
|
30
|
+
const xy = getXY(event, rect)
|
|
31
|
+
|
|
32
|
+
providedUniforms.writePartial({
|
|
33
|
+
mouse: Mouse({
|
|
34
|
+
xy,
|
|
35
|
+
uv: getUV(rect, xy),
|
|
36
|
+
isOver: getIsOver(rect, xy),
|
|
37
|
+
down: getMouseDown(event),
|
|
38
|
+
}),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
document.addEventListener('mousemove', handleMouseEvent)
|
|
43
|
+
canvas.addEventListener('mousedown', handleMouseEvent)
|
|
44
|
+
canvas.addEventListener('mouseup', handleMouseEvent)
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Infer, f32, i32, struct, vec2f } from 'typegpu/data'
|
|
2
|
+
|
|
3
|
+
export const Mouse = struct({
|
|
4
|
+
/** The XY position in pixels on the canvas */
|
|
5
|
+
xy: vec2f,
|
|
6
|
+
|
|
7
|
+
/** The UV position on the canvas */
|
|
8
|
+
uv: vec2f,
|
|
9
|
+
|
|
10
|
+
/** 1 if the mouse is over the canvas, 0 otherwise */
|
|
11
|
+
isOver: i32,
|
|
12
|
+
|
|
13
|
+
/** 1 if the left mouse button is down, 0 otherwise */
|
|
14
|
+
down: i32,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export const ProvidedUniforms = struct({
|
|
18
|
+
/** Elapsed time since page load, in seconds */
|
|
19
|
+
time: f32,
|
|
20
|
+
|
|
21
|
+
/** The mouse state */
|
|
22
|
+
mouse: Mouse,
|
|
23
|
+
|
|
24
|
+
/** The resolution of the canvas, in pixels */
|
|
25
|
+
resolution: vec2f,
|
|
26
|
+
|
|
27
|
+
/** The aspect ratio of the canvas */
|
|
28
|
+
aspectRatio: f32,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export type ProvidedUniforms = Infer<typeof ProvidedUniforms>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import tgpu, { type TgpuBufferReadonly } from 'typegpu'
|
|
2
|
+
import { type Infer, type v4f, vec2f } from 'typegpu/data'
|
|
3
|
+
|
|
4
|
+
import { FragmentParameters, createFragmentShader } from './fragment-shader'
|
|
5
|
+
import { trackMouse } from './mouse'
|
|
6
|
+
import { ProvidedUniforms } from './provided-uniforms'
|
|
7
|
+
import { createVertexShader, quadVertices } from './vertex-shader'
|
|
8
|
+
|
|
9
|
+
const root = await tgpu.init()
|
|
10
|
+
const presentationFormat = navigator.gpu.getPreferredCanvasFormat()
|
|
11
|
+
|
|
12
|
+
export function createShaderCanvas(
|
|
13
|
+
canvas: HTMLElement | undefined | null,
|
|
14
|
+
fragmentShaderImplementation: (
|
|
15
|
+
fragmentParameters: Infer<typeof FragmentParameters>,
|
|
16
|
+
) => v4f,
|
|
17
|
+
) {
|
|
18
|
+
validateCanvas(canvas)
|
|
19
|
+
const ctx = getCtx(canvas)
|
|
20
|
+
const providedUniformsBuffer = createProvidedUniformsBuffer(canvas)
|
|
21
|
+
trackMouse(canvas, providedUniformsBuffer)
|
|
22
|
+
|
|
23
|
+
const drawPipeline = createPipeline(
|
|
24
|
+
fragmentShaderImplementation,
|
|
25
|
+
providedUniformsBuffer.as('readonly'),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
function render() {
|
|
29
|
+
providedUniformsBuffer.writePartial({
|
|
30
|
+
time: performance.now() / 1000,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
drawPipeline(ctx)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderWithRequestAnimationFrame() {
|
|
37
|
+
render()
|
|
38
|
+
requestAnimationFrame(renderWithRequestAnimationFrame)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
render,
|
|
43
|
+
startRendering: renderWithRequestAnimationFrame,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createPipeline(
|
|
48
|
+
fragmentShaderImplementation: (
|
|
49
|
+
fragmentParameters: Infer<typeof FragmentParameters>,
|
|
50
|
+
) => v4f,
|
|
51
|
+
providedUniformsBuffer: TgpuBufferReadonly<typeof ProvidedUniforms>,
|
|
52
|
+
) {
|
|
53
|
+
const pipeline = root['~unstable']
|
|
54
|
+
.withVertex(createVertexShader())
|
|
55
|
+
.withFragment(
|
|
56
|
+
createFragmentShader(
|
|
57
|
+
fragmentShaderImplementation,
|
|
58
|
+
providedUniformsBuffer,
|
|
59
|
+
),
|
|
60
|
+
{ color: { format: presentationFormat } },
|
|
61
|
+
)
|
|
62
|
+
.createPipeline()
|
|
63
|
+
|
|
64
|
+
return function drawPipeline(ctx: GPUCanvasContext) {
|
|
65
|
+
pipeline
|
|
66
|
+
.withColorAttachment({
|
|
67
|
+
color: {
|
|
68
|
+
view: ctx.getCurrentTexture().createView(),
|
|
69
|
+
loadOp: 'clear',
|
|
70
|
+
storeOp: 'store',
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
.draw(quadVertices.$.length, 1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateCanvas(
|
|
78
|
+
canvas: HTMLElement | null | undefined,
|
|
79
|
+
): asserts canvas is HTMLCanvasElement {
|
|
80
|
+
if (!canvas)
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Canvas element is ${canvas === null ? 'null' : 'undefined'}`,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if (!(canvas instanceof HTMLCanvasElement))
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Canvas must be an HTMLCanvasElement. got ${canvas === null ? 'null' : 'undefined'}`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getCtx(canvas: HTMLCanvasElement): GPUCanvasContext {
|
|
92
|
+
const ctx = canvas.getContext('webgpu')
|
|
93
|
+
if (!ctx) throw new Error('Failed to get webgpu context')
|
|
94
|
+
|
|
95
|
+
ctx.configure({
|
|
96
|
+
device: root.device,
|
|
97
|
+
format: presentationFormat,
|
|
98
|
+
alphaMode: 'premultiplied',
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return ctx
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createProvidedUniformsBuffer(canvas: HTMLCanvasElement) {
|
|
105
|
+
const providedUniformsBuffer = root
|
|
106
|
+
.createBuffer(ProvidedUniforms)
|
|
107
|
+
.$usage('storage')
|
|
108
|
+
|
|
109
|
+
providedUniformsBuffer.writePartial({
|
|
110
|
+
resolution: vec2f(canvas.clientWidth, canvas.clientHeight),
|
|
111
|
+
aspectRatio: canvas.clientWidth / canvas.clientHeight,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return providedUniformsBuffer
|
|
115
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import tgpu from 'typegpu'
|
|
2
|
+
import { arrayOf, builtin, vec2f, vec4f } from 'typegpu/data'
|
|
3
|
+
|
|
4
|
+
export const quadVertices = tgpu.const(arrayOf(vec2f, 6), [
|
|
5
|
+
vec2f(-1, -1),
|
|
6
|
+
vec2f(-1, 1),
|
|
7
|
+
vec2f(1, 1),
|
|
8
|
+
vec2f(1, 1),
|
|
9
|
+
vec2f(1, -1),
|
|
10
|
+
vec2f(-1, -1),
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
export function createVertexShader() {
|
|
14
|
+
return tgpu['~unstable'].vertexFn({
|
|
15
|
+
in: { idx: builtin.vertexIndex },
|
|
16
|
+
out: {
|
|
17
|
+
clipPos: builtin.position,
|
|
18
|
+
uv: vec2f,
|
|
19
|
+
},
|
|
20
|
+
})(({ idx }) => {
|
|
21
|
+
const vertex = quadVertices.$[idx]!
|
|
22
|
+
return {
|
|
23
|
+
clipPos: vec4f(vertex, 0, 1),
|
|
24
|
+
uv: vertex,
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
}
|