Demo UI¶
The Noisemaker Shader Demo lets you explore GPU shader effects in the browser. It renders in real time with live parameter controls and a DSL code editor. It supports both WebGL 2 and WebGPU backends.
View the hosted demo at https://noisemaker.app/demo/shaders/
What the Demo Does¶
The shader demo provides:
Effect browser with categorized presets (synth, filter, nm, etc.)
Live parameter controls generated automatically from effect definitions
DSL code editor for composing effect chains programmatically
Backend switching between GLSL (WebGL 2) and WGSL (WebGPU)
Bidirectional sync between controls and DSL text
Quick Start¶
Running locally:
# Start a local server
cd /path/to/noisemaker
npx http-server -p 8000
# Open in browser
open http://localhost:8000/demo/shaders/
Embedding in your project:
<!DOCTYPE html>
<html>
<head>
<script type="module">
import { CanvasRenderer, UIController } from './lib/demo-ui.js';
const canvas = document.getElementById('canvas');
const renderer = new CanvasRenderer({
canvas,
width: 512,
height: 512,
basePath: '../../shaders'
});
await renderer.loadManifest();
await renderer.loadEffect('synth/noise');
await renderer.compile('search synth\nnoise().write(o0)\nrender(o0)');
renderer.start();
</script>
</head>
<body>
<canvas id="canvas" width="512" height="512"></canvas>
</body>
</html>
Components¶
CanvasRenderer¶
The core rendering engine that manages the GPU pipeline:
import { CanvasRenderer } from './shaders/src/renderer/canvas.js';
const renderer = new CanvasRenderer({
canvas: HTMLCanvasElement, // Target canvas
width: 1024, // Render resolution
height: 1024,
basePath: '../../shaders', // Path to shader assets
preferWebGPU: false, // Use WebGPU if available
useBundles: false, // Use pre-built effect bundles
bundlePath: '../../dist/effects',
onFPS: (fps) => { }, // FPS callback
onError: (err) => { } // Error callback
});
// Load effect manifest, fetch the bundles you'll use, then compile
await renderer.loadManifest();
await renderer.loadEffect('synth/noise');
await renderer.compile('search synth\nnoise().write(o0)\nrender(o0)');
renderer.start();
// Control playback
renderer.pause();
renderer.resume();
renderer.stop();
UIController¶
Manages the demo UI — effect selection, controls, DSL editing:
import { UIController } from './lib/demo-ui.js';
const ui = new UIController(renderer, {
effectSelect: document.getElementById('effect-select'),
dslEditor: document.getElementById('dsl-editor'),
controlsContainer: document.getElementById('controls'),
statusEl: document.getElementById('status'),
fpsCounterEl: document.getElementById('fps'),
onControlChange: () => { /* handle control changes */ },
onRequestRecompile: () => { /* handle recompile requests */ }
});
// Load an effect
await ui.loadEffect('synth/noise');
// Get current DSL
const dsl = ui.getDsl();
DSL Language¶
Compose effects using a chainable DSL:
// Basic noise
search synth
noise().write(o0)
render(o0)
// Chained effects
search synth, filter
noise(octaves: 4, scale: 2.0)
.posterize(levels: 8)
.bloom(radius: 0.5)
.write(o0)
render(o0)
// Multiple surfaces
search synth, mixer
noise().write(o0)
noise(seed: 42).write(o1)
blend(tex: read(o1), amount: 0.5).write(o0)
render(o0)
See Polymorphic DSL for full DSL specification.
Bundling for Distribution¶
You can bundle shader effects into standalone JavaScript modules for production deployments.
Building Bundles¶
npm run bundle:shaders
This produces:
dist/shaders/noisemaker-shaders-core.esm.js— Core runtime + UI (ESM)dist/shaders/noisemaker-shaders-core.min.js— Minified IIFE variantdist/effects/{namespace}/{effect}.js— Per-effect mini-bundles
Using Bundles¶
import { CanvasRenderer, UIController } from './noisemaker-shaders-core.esm.js';
const renderer = new CanvasRenderer({
canvas,
width: 512,
height: 512,
useBundles: true,
bundlePath: './effects'
});
await renderer.loadManifest();
await renderer.loadEffect('synth/noise');
await renderer.compile('search synth\nnoise().write(o0)\nrender(o0)');
renderer.start();
URL Parameters¶
The demo supports URL parameters for deep linking:
?effect=synth/noise— Load specific effect?backend=webgpu— Select rendering backend?bundles=1— Use pre-built effect bundles
Pluggable Controls¶
Downstream projects can substitute custom web components for the default HTML elements.
Overview¶
The UIController class manages all UI interactions for the shader demo:
Effect selection and loading
DSL editing and parsing
Dynamic control generation from effect parameters
Bidirectional sync between controls and DSL text
The control system allows downstream projects to substitute custom web components (like <my-custom-dropdown>) for the default HTML elements (<select>, <input type="range">, etc.).
Architecture¶
Control Handle Interface¶
A ControlHandle object represents each control:
{
element: HTMLElement, // DOM element to append
getValue: () => any, // Get current value
setValue: (value) => void // Set display value
}
The UIController stores these handles on control group elements (controlGroup._controlHandle). The checkStructureAndApplyState() method uses them to update controls without knowing their implementation details.
Control Factory¶
The ControlFactory class provides factory methods for creating controls:
import { ControlFactory } from './lib/control-factory.js'
const factory = new ControlFactory()
// Create a dropdown
const selectHandle = factory.createSelect({
choices: [
{ value: 0, label: 'Option A' },
{ value: 1, label: 'Option B' }
],
value: 0,
className: 'control-select'
})
// Create a slider
const sliderHandle = factory.createSlider({
value: 0.5,
min: 0,
max: 1,
step: 0.01,
className: 'control-slider'
})
Available factory methods:
createSelect(options)— Dropdown/select controlscreateSlider(options)— Range slider controlscreateToggle(options)— Boolean toggle switchescreateColorPicker(options)— Color picker inputscreateButton(options)— Momentary action buttonscreateTextDisplay(options)— Read-only text labelscreateValueDisplay(options)— Value display spans
Customizing Controls¶
Downstream projects can provide custom control implementations by extending ControlFactory:
import { ControlFactory, UIController } from './lib/demo-ui.js'
class CustomControlFactory extends ControlFactory {
createSelect(options) {
// Use a custom web component instead of <select>
const el = document.createElement('my-custom-dropdown')
el.items = options.choices.map(c => ({
value: c.value,
label: c.label
}))
el.value = options.value
return {
element: el,
getValue: () => el.value,
setValue: (v) => { el.value = v }
}
}
createSlider(options) {
const el = document.createElement('my-custom-slider')
el.min = options.min
el.max = options.max
el.step = options.step
el.value = options.value
return {
element: el,
getValue: () => el.value,
setValue: (v) => { el.value = v }
}
}
}
// Pass the custom factory to UIController
const ui = new UIController(renderer, {
controlFactory: new CustomControlFactory(),
effectSelect: document.getElementById('effect-select'),
dslEditor: document.getElementById('dsl-editor'),
controlsContainer: document.getElementById('controls'),
statusEl: document.getElementById('status')
})
ProgramState¶
ProgramState manages state independently between the UI and the renderer. It provides:
Centralized state access via
getValue()/setValue()Event-driven updates - emits
change,structurechange,reseteventsBatching - combines multiple changes into a single event
Serialization -
serialize()/deserialize()for undo/redo and persistenceMedia metadata - stores metadata about media and text inputs
Basic Usage¶
// Access via UIController
const state = ui.programState
// Get/set parameter values
const value = state.getValue('step_0', 'scale')
state.setValue('step_0', 'scale', 2.0)
// Batch multiple changes (single event)
state.batch(() => {
state.setValue('step_0', 'scale', 2.0)
state.setValue('step_0', 'octaves', 4)
})
// Subscribe to changes
state.on('change', ({ stepKey, paramName, value }) => {
console.log(`${stepKey}.${paramName} = ${value}`)
})
// Reset a step to defaults
state.resetStep('step_0', effectDef)
// Serialize for undo/redo
const snapshot = state.serialize()
state.deserialize(snapshot)
// Get all step values (replaces _effectParameterValues)
const allValues = state.getAllStepValues()
DSL Synchronization¶
The control system maintains bidirectional sync between controls and DSL text:
Controls → DSL¶
When a control value changes:
The control’s
changeevent firesprogramState.setValue()updates the state_updateDslFromEffectParams()regenerates the DSL textThe UI updates the DSL editor
DSL → Controls¶
When DSL text changes (e.g., user edits the text):
The UI calls
checkStructureAndApplyState(dsl)For each parameter, the method finds the control group
If
controlGroup._controlHandle.setValueexists, the method calls itOtherwise, the method queries native elements for backward compatibility
This design updates custom web components when DSL text changes. Custom dropdowns therefore stay synchronized with DSL edits.
Module Controls Reset Hook¶
When you click a module’s “reset” button, the UIController rebuilds that module’s controls. Downstream projects need to repeat any custom UI transformations after the rebuild. These transformations can include a custom layout for mixer A/B sliders.
The onModuleControlsReset callback fires after the UIController rebuilds a module’s controls:
const ui = new UIController(renderer, {
// ... other options ...
onModuleControlsReset: (stepIndex, moduleElement, effectDef) => {
// Re-apply custom UI transformations
if (effectDef.category === 'mixer') {
this._applyMixerLayout(moduleElement, effectDef)
}
}
})
Callback parameters:
stepIndex— The step index of the affected module in the pipelinemoduleElement— The DOM element (<div class="shader-module">) whose controls were rebuilteffectDef— The effect definition object, useful for checking effect type or accessing globals
Integration Points¶
UIController Options¶
new UIController(renderer, {
// Required
effectSelect: HTMLSelectElement, // Effect selector dropdown
dslEditor: HTMLTextAreaElement, // DSL text editor
controlsContainer: HTMLElement, // Container for effect controls
statusEl: HTMLElement, // Status message display
// Optional
fpsCounterEl: HTMLElement, // FPS counter display
loadingDialog: HTMLDialogElement, // Loading dialog
loadingDialogTitle: HTMLElement, // Loading dialog title
loadingDialogStatus: HTMLElement, // Loading dialog status text
loadingDialogProgress: HTMLElement, // Loading dialog progress bar
// Callbacks
onControlChange: Function, // Called when any control changes
onRequestRecompile: Function, // Called when recompile is needed
onModuleControlsReset: Function, // Called after module reset button rebuilds controls
// Pluggable controls
controlFactory: ControlFactory // Custom control factory
})
Exports¶
The demo-ui.js module exports:
import {
// Main class
UIController,
// Control factory
ControlFactory,
defaultControlFactory,
// Utilities
camelToSpaceCase,
formatEnumName,
formatValue,
extractEffectsFromDsl,
// Re-exported from canvas.js
cloneParamValue,
isStarterEffect,
hasTexSurfaceParam,
hasExplicitTexParam,
getVolGeoParams,
is3dGenerator,
is3dProcessor,
getEffect
} from './lib/demo-ui.js'
Example: Custom Dropdown Component¶
Here’s a complete example of integrating a custom <select-dropdown> web component:
class SelectDropdown extends HTMLElement {
static get observedAttributes() { return ['value'] }
constructor() {
super()
this._items = []
this._value = null
}
set items(arr) {
this._items = arr
this._render()
}
get value() { return this._value }
set value(v) {
this._value = v
this._updateDisplay()
}
// ... implementation details ...
}
customElements.define('select-dropdown', SelectDropdown)
// Factory that uses it
class AppControlFactory extends ControlFactory {
createSelect(options) {
const el = document.createElement('select-dropdown')
el.items = options.choices
el.value = options.value
return {
element: el,
getValue: () => el.value,
setValue: (v) => { el.value = v }
}
}
}
With this setup:
Controls render using
<select-dropdown>instead of<select>User interactions update the DSL text correctly
DSL text edits update the dropdown via
setValue()You do not need to override
checkStructureAndApplyState()or other internal methods