wgsl-play
v0.1.0
Published
Web component for rendering WESL/WGSL fragment shaders.
Readme
wgsl-play
Web component for rendering WESL/WGSL fragment shaders.
Usage
<script type="module">import "wgsl-play";</script>
<wgsl-play src="./shader.wesl"></wgsl-play>That's it. The component auto-fetches dependencies and starts animating.
Shader API
wgsl-play renders a fullscreen triangle using a built-in vertex shader and only
accepts fragment shaders. Write a single @fragment function.
WESL extensions are supported (imports, conditional compilation).
Standard uniforms are available via env::u:
import env::u;
@fragment fn main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = pos.xy / u.resolution;
return vec4f(uv, sin(u.time) * 0.5 + 0.5, 1.0);
}When no @uniforms struct is declared, a default is provided with resolution and time.
Custom Uniforms
Declare a struct with @uniforms to add your own fields with UI controls:
import env::u;
@uniforms struct Params {
@auto resolution: vec2f,
@auto time: f32,
@range(1.0, 20.0, 5.0, 6.0) frequency: f32,
@color(0.2, 0.5, 1.0) tint: vec3f,
@toggle(0) invert: u32,
}
@fragment fn main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let wave = sin(pos.x * u.frequency + u.time);
var color = wave * u.tint;
if u.invert == 1u { color = 1.0 - color; }
return vec4f(color, 1.0);
}@auto -- Runtime Fields
The player fills these automatically each frame. The field name determines
which value is bound (or use @auto(name) when the field name differs):
| Name | Type | Description |
|------|------|-------------|
| resolution | vec2f | Canvas size in pixels |
| time | f32 | Elapsed time in seconds |
| delta_time | f32 | Delta time since last frame |
| frame | u32 | Frame count |
| mouse_pos | vec2f | Pointer position in pixels |
| mouse_delta | vec2f | Pointer movement since last frame |
| mouse_button | i32 | Active button: 0=none, 1=left, 2=middle, 3=right |
UI Annotations
These generate interactive controls in the player.
@range(min, max [, step [, initial]])
Slider for f32 or i32. Step defaults to 0.01 for f32, 1 for i32.
Initial defaults to min.
@range(1.0, 20.0) frequency: f32,
@range(1.0, 20.0, 5.0) frequency: f32, // step=5
@range(1.0, 20.0, 0.5, 5.0) frequency: f32, // step=0.5, initial=5@color(r, g, b)
Color picker for vec3f:
@color(0.2, 0.5, 1.0) tint: vec3f,@toggle([initial])
Boolean toggle for u32 (0 or 1). WGSL forbids bool in uniform buffers.
@toggle invert: u32, // default=0
@toggle(1) invert: u32, // default=1Plain Fields
Fields without annotations are zero-initialized and settable from JavaScript
via setUniform(). This works before or after compilation.
@uniforms struct Params {
@auto resolution: vec2f,
brightness: f32, // no annotation — set from JS
}const player = document.querySelector("wgsl-play");
player.setUniform("brightness", 0.8);Resource Annotations
Bind GPU resources by annotating shader globals. The component owns
@group(0) — @binding(0) is the uniform buffer, and annotated resources
take @binding(1) and onward in declaration order.
@texture(name) — host-provided image
Resolves to a child <img> or <canvas> of the <wgsl-play> element.
Lookup matches [data-texture="name"] first, then falls back to #name.
The image is decoded and uploaded as rgba8unorm.
<wgsl-play>
<script type="text/wesl">
import env::u;
@texture(nebula) var photo: texture_2d<f32>;
@sampler(linear) var samp: sampler;
@fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
return textureSample(photo, samp, pos.xy / u.resolution);
}
</script>
<img data-texture="nebula" src="/images/nebula.jpg" hidden>
</wgsl-play>Only texture_2d<f32> is supported. texture_cube, texture_2d_array,
and storage textures are rejected with a clear error. <img> decoding
uses imageOrientation: "from-image", premultiplyAlpha: "none", and
colorSpaceConversion: "none" for deterministic uploads.
Changing an <img> src, swapping data-texture, or adding/removing
texture children rebuilds the pipeline automatically.
@sampler(filter)
Creates a sampler with clamp-to-edge addressing. Filter is linear or
nearest.
@sampler(nearest) var samp: sampler;@buffer
Zero-initialized storage buffer; size inferred from the WGSL type.
read and read_write are both allowed.
@buffer var<storage, read> palette: array<vec4f, 8>;@test_texture (the wgsl-test fixture annotation) is rejected at
runtime — use @texture(name) with a host element instead.
Inline source
You can include shader code inline if you'd prefer. Use a <script type="text/wgsl"> (or <script type="text/wesl">) tag.
<wgsl-play>
<script type="text/wesl">
import env::u;
@fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = pos.xy / u.resolution;
return vec4f(uv, sin(u.time) * 0.5 + 0.5, 1.0);
}
</script>
</wgsl-play>Programmatic control
const player = document.querySelector("wgsl-play");
player.shader = shaderCode;
player.pause();
player.rewind();
player.play();Importing shaders (Vite)
import shader from './examples/noise.wesl?raw';
const player = document.querySelector("wgsl-play");
player.shader = shader;The ?raw suffix imports the file as a string. This keeps shaders alongside your source files with HMR support.
API
Attributes
src- URL to .wesl/.wgsl fileshader-root- Root path for internal imports (default:/shaders)autoplay- Start animating on load (default:true). Setautoplay="false"to start pausedtransparent- Use premultiplied alpha for transparent backgrounds (default: opaque)from- Element ID of a source provider (e.g., wgsl-edit) to connect tono-controls- Hide playback controls (play/pause, rewind, fullscreen)no-settings- Hide the uniform controls panelwidth/height- Fixed canvas resolution in pixels, independent of display size. When set, the canvas is not resized by the CSS layoutpixel-ratio- Scale factor from CSS pixels to canvas pixels (default:devicePixelRatio). Setpixel-ratio="1"for 1:1 CSS pixels (no HiDPI scaling)resizable- Show a drag handle to let users resize the element interactivelyfetch-libs- Auto-fetch missing libraries from npm (default:true). Setfetch-libs="false"to disablefetch-sources- Auto-fetch local .wesl source files via HTTP (default:true). Setfetch-sources="false"to disable
Properties
shader: string- Get/set shader source (single-file convenience)conditions: Record<string, boolean>- Get/set conditions for conditional compilation (@if/@elif/@else)project: WeslProject- Get/set full project config (weslSrc, libs, conditions, constants)pixelRatio: number- Get/set canvas-to-CSS pixel ratio (default:devicePixelRatio)isPlaying: boolean- Playback state (readonly)time: number- Animation time in seconds (readonly)hasError: boolean- Compilation error state (readonly)errorMessage: string | null- Error message (readonly)
Methods
play()- Start/resume animationpause()- Pause animationrewind()- Reset to t=0renderFrame()- Wait for any in-flight build, render one frame, and resolve once it has been presented (for snapshotting the canvas)setUniform(name, value)- Set a uniform value programmaticallyshowError(message)- Display error (empty string clears)
Events
compile-error-{ message, source: "wesl"|"webgpu", kind: "shader"|"resource", resourceSource?, locations }.kind: "resource"covers missing@texturehost elements, unsupported texture dims, and@test_texturerejection;resourceSourcenames the offending var/sourceinit-error-{ message: string }(WebGPU init failed)playback-change-{ isPlaying: boolean }uniforms-layout-{ detail: AnnotatedLayout }(fired after each compile)
Canvas Sizing
By default the canvas resolution tracks CSS size at devicePixelRatio.
Use pixel-ratio or width/height to decouple:
<!-- 1:1 CSS pixels (blocky on HiDPI, great for pixel art) -->
<wgsl-play pixel-ratio="1" style="width:512px; height:512px"></wgsl-play>
<!-- Fixed 64x64 canvas, stretched to whatever CSS size -->
<wgsl-play width="64" height="64" style="width:512px; height:512px"></wgsl-play>For crisp upscaling of low-res canvases, add image-rendering: pixelated:
Styling
wgsl-play {
width: 512px;
height: 512px;
}
wgsl-play::part(canvas) {
image-rendering: pixelated;
}Multi-file Shaders
For apps with multiple shader files, use shader-root.
public/
shaders/
utils.wesl # import package::utils
effects/
main.wesl # import super::common
common.wesl<wgsl-play src="/shaders/effects/main.wesl" shader-root="/shaders"></wgsl-play>Local shader modules referenced via package:: or super::
will be fetched from the web server.
// effects/main.wesl
import package::utils::noise;
import super::common::tint;
@fragment fn main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
return tint(noise(pos.xy));
}Using with wesl-plugin
For more control, use the wesl-plugin to assemble shaders and libraries at build time and provide them wgsl-play in JavaScript or TypeScript.
- provides full support for Hot Module Reloading during development
- allows specifying fixed library dependency versions
Runtime linking (?link)
With Runtime linking, WGSL is constructed from WGSL/WESL at runtime. Use runtime linking to enable virtual modules, conditional transpilation, and injecting shader constants from JavaScript.
// vite.config.ts
import { linkBuildExtension } from "wesl-plugin";
import viteWesl from "wesl-plugin/vite";
export default {
plugins: [viteWesl({ extensions: [linkBuildExtension] })]
};
// app.ts
import shaderConfig from "./shader.wesl?link";
// wgsl-play links internally, allowing runtime conditions/constants
player.project = {
...shaderConfig,
conditions: { MOBILE: isMobileGPU },
constants: { num_lights: 4 }
};Exports
// Default - auto-registers element
import "wgsl-play";
// Element class only (manual registration)
import { WgslPlay } from "wgsl-play/element";
// Configuration
import { defaults } from "wgsl-play";
defaults({ shaderRoot: "/custom/shaders" });
// Pre-bundled, all deps included
import "wgsl-play/bundle";