Effect Definition Spec¶
An “Effect” is a self-contained unit that transforms inputs to outputs using one or more rendering or compute passes.
1. Schema¶
Create effect definitions with the Effect constructor and a configuration object. This is the primary and recommended approach.
import { Effect } from '../../../src/runtime/effect.js';
export default new Effect({
name: "SimpleBloom",
namespace: "examples",
func: "bloom",
globals: {
intensity: {
type: "float",
default: 0.5,
min: 0,
max: 1,
ui: { label: "Intensity", control: "slider" }
},
threshold: {
type: "float",
default: 0.5,
min: 0,
max: 1,
ui: { label: "Threshold", control: "slider" }
}
},
textures: {
downsampled: { width: "25%", height: "25%", format: "rgba16f" }
},
passes: [
{
name: "downsample",
program: "downsample",
inputs: {
scene: "inputTex"
},
outputs: {
color: "downsampled"
}
},
{
name: "composite",
program: "composite",
inputs: {
scene: "inputTex",
bloom: "downsampled"
},
outputs: {
color: "outputTex"
}
}
]
});
2. Key Concepts¶
namespace: Logical grouping for the effect (e.g.,"synth","filter"). Combined withname, it forms the unique identity.textures: Defines the internal render targets. Dimensions can be absolute, relative to screen ("screen","50%"), or fixed.passes:program: Key to look up the shader code (GLSL/WGSL).inputs: Maps shader uniform samplers to texture names.outputs: Maps shader output locations (or write buffers) to texture names.uniforms: Maps shader uniform names to effect-global parameter names.repeat: A fixed repeat count or the name of a uniform that supplies it.Backend fields include
entryPoint,drawMode,drawBuffers,count,countUniform,blend,workgroups,storageBuffers, andstorageTextures.
3. On-Disk Layout¶
Effects are typically authored as a directory containing a definition file, shader sources, and documentation.
my-effect/
├── definition.js # Exports new Effect({...}) or Effect subclass
├── glsl/
│ └── my-shader.glsl # WebGL implementation
├── wgsl/
│ └── my-shader.wgsl # WebGPU implementation
└── help.md # User documentation (markdown)
Shader References:
The program field in a pass can specify a relative path (e.g., "./my-shader"). The runtime resolves this path relative to the definition file. It inserts the backend directory (glsl/ or wgsl/) and appends the corresponding extension (.glsl or .wgsl).
Documentation:
The help.md file is optional but recommended for library effects. It provides context for the editor UI.
Example DSL:
The demo UI generates example DSL snippets from the effect type (starter, filter, or mixer). You do not need to maintain example.dsl files manually.
4. Global Enums¶
A global registry defines common enumerations for consistency and reduced duplication. Effects reference these enums by name instead of redefining the choices.
Global Registry Example:
const globalEnums = {
"interpolation": {
"nearest": 0,
"linear": 1,
"hermite": 2,
"cubic": 3
},
"wrapMode": {
"clamp": 0,
"repeat": 1,
"mirror": 2
}
};
Effect Usage:
globals: {
interp: {
type: "int",
enum: "interpolation", // References global key
default: "linear" // Uses string key
}
}
The runtime resolves the string value (e.g., "linear") to its integer counterpart (1) before binding to the shader.
Exact vector formatting:
For vec4 parameters that store coordinates, set ui.format: 'vector'
to preserve their numeric values when generating DSL:
vertices: {
type: 'vec4',
default: [0, 0, 1, 1],
ui: { format: 'vector' }
}
When the unparser receives this parameter definition and an array or typed
array of four finite numbers, it emits an array literal without rounding,
color clamping, or exponent notation. Remap uses this for its packed vertex
pairs. Hosts that call unparse() must supply effect definitions through
getEffectDef so the formatter can read the metadata. This property does
not change the parameter’s shader type or reject legacy hexadecimal input.
4b. UI Categories¶
Use ui.category to group uniform controls visually in the demo UI. Categories organize controls into sections for complex effects with many parameters.
Category Requirements:
Category names MUST be camelCase (start with lowercase letter, no spaces/underscores/hyphens)
Categories appear in order of first occurrence in the globals object
Controls without a category default to
"general"(displayed last)The UI shows category labels on hover and renders separators between groups
Example:
globals: {
temperature: {
type: "float",
default: 0,
uniform: "gradeTemperature",
ui: {
label: "Temperature",
control: "slider",
category: "primary" // camelCase required
}
},
hslHueCenter: {
type: "float",
default: 0,
uniform: "gradeHslHueCenter",
ui: {
label: "Hue Center",
control: "slider",
category: "hslSecondary", // camelCase, no spaces
enabledBy: "hslEnable" // Only enabled when hslEnable is truthy
}
}
}
4c. Conditional Control Visibility (enabledBy)¶
The enabledBy property enables or disables a parameter’s UI control according to other parameter values. It supports simple truthy checks and complex conditional expressions.
Simple String Format (Legacy):
The simplest form takes a parameter name as a string. A “truthy” value enables the control:
A non-zero number
A true boolean
A non-empty string
enabledBy: "hslEnable" // enabled when hslEnable is truthy
Comparison Operators:
For more precise control, use an object with param and one or more comparison operators:
enabledBy: { param: "intensity", gt: 0.5 } // enabled when intensity > 0.5
enabledBy: { param: "intensity", gte: 0.5 } // enabled when intensity >= 0.5
enabledBy: { param: "intensity", lt: 0.5 } // enabled when intensity < 0.5
enabledBy: { param: "intensity", lte: 0.5 } // enabled when intensity <= 0.5
enabledBy: { param: "mode", eq: 1 } // enabled when mode === 1
enabledBy: { param: "mode", neq: 0 } // enabled when mode !== 0
Set Membership:
Check if a value is a member of (or excluded from) a set of values:
enabledBy: { param: "mode", in: [1, 2, 3] } // enabled when mode is 1, 2, or 3
enabledBy: { param: "mode", notIn: [0, 4] } // enabled when mode is NOT 0 or 4
enabledBy: { param: "preset", in: ["a", "b"] } // works with strings too
Multiple Conditions (AND):
Multiple operators in a single object use AND logic:
enabledBy: { param: "intensity", gt: 0, lt: 1 } // enabled when 0 < intensity < 1
Logical Operators:
For complex conditions, use or, and, and not:
// OR: enabled when EITHER condition is true
enabledBy: {
or: [
{ param: "mode", eq: 1 },
{ param: "enabled", eq: true }
]
}
// AND (explicit): enabled when ALL conditions are true
enabledBy: {
and: [
{ param: "mode", gt: 0 },
{ param: "intensity", gte: 0.5 }
]
}
// NOT: invert a condition
enabledBy: { not: { param: "disabled", eq: true } }
// Complex nested conditions
enabledBy: {
or: [
{ param: "mode", eq: 2 },
{ and: [
{ param: "mode", eq: 1 },
{ param: "advanced", eq: true }
]}
]
}
Operator Reference:
Operator |
Description |
|---|---|
|
Equal to value |
|
Not equal to value |
|
Greater than value (numbers only) |
|
Greater than or equal to value (numbers only) |
|
Less than value (numbers only) |
|
Less than or equal to value (numbers only) |
|
Value is member of array |
|
Value is not member of array |
|
Array of conditions, any must be true |
|
Array of conditions, all must be true |
|
Invert the nested condition |
BANNED:
category: "Primary"— PascalCase forbiddencategory: "HSL Secondary"— spaces forbiddencategory: "hsl_secondary"— underscores forbidden
4d. Reseed-Gated Parameters (resetOnChange)¶
Some parameters are only read when an effect reseeds its state: a spawn
layout or seed read when an agent respawns, a density read while a volume
is seeded. Changing such a parameter live has no visible effect until the
effect’s resetState fires. Mark
those parameters with ui.resetOnChange: true so hosts can distinguish
them and pulse the effect’s resetState when one changes, instead of
restarting the effect on every choice.
layout: {
type: 'int',
default: 0,
uniform: 'layout',
ui: {
label: 'layout',
control: 'dropdown',
resetOnChange: true // read only when an agent respawns
}
}
The value must be a boolean; the definition validator diagnoses any other type. The flag is a UI hint only: it does not change the parameter’s shader type or its uniform name.
5. Lifecycle Methods (Class-Based Effects)¶
Most effects are purely declarative and use the new Effect({...}) pattern above. Some effects require CPU-side state, such as simulation steps, complex time-keeping, or audio analysis. For these effects, choose one of these approaches:
Pass lifecycle functions in the config (simpler):
import { Effect } from '../../../src/runtime/effect.js';
export default new Effect({
name: "PulseEffect",
namespace: "examples",
func: "pulse",
globals: {
speed: { type: "float", default: 1.0 },
intensity: { type: "float", default: 0.5 }
},
passes: [
{ name: "main", program: "pulse", outputs: { color: "outputTex" } }
],
// Lifecycle hooks as config properties
onInit() {
this.state.phase = 0;
},
onUpdate({ time, delta, uniforms }) {
this.state.phase += delta * uniforms.speed;
return {
u_pulse: Math.sin(this.state.phase) * uniforms.intensity
};
}
});
Subclass Effect (for complex cases with additional methods):
import { Effect } from '../../../src/runtime/effect.js';
export default class MediaEffect extends Effect {
name = "Media";
namespace = "synth";
func = "media";
globals = { /* ... */ };
passes = [ /* ... */ ];
onInit() {
this.state.imageWidth = 1;
this.state.imageHeight = 1;
}
onUpdate(_context) {
return {
imageSize: [this.state.imageWidth || 1, this.state.imageHeight || 1]
};
}
// Additional custom methods
setMediaDimensions(width, height) {
this.state.imageWidth = width;
this.state.imageHeight = height;
}
}
When to use class-based effects:
You need custom methods beyond lifecycle hooks
You have complex module-level setup (e.g., building enum choices from imports)
The effect requires external resource management
Lifecycle Method Contract:
The runtime invokes these methods at specific stages:
onInit(): The runtime calls this once when it loads the effect. Initialize state here.onUpdate({ time, delta, uniforms }): The runtime calls this every frame before rendering. Return an object of computed uniforms.onDestroy(): The runtime calls this when it removes the effect. Release resources here.
Uniforms returned from onUpdate bind under fallback semantics: a returned
key binds only when the pass does not already resolve that uniform, so
DSL-provided, step-provided, and authored values keep priority. The hooks run
in the production Pipeline (not only the test harness): onInit() once
per effect instance per pipeline lifetime (idempotent across resize and hot
recompile), onUpdate() once per frame before pass execution, and
onDestroy() at dispose with hook errors joining the existing dispose
error path.
// Lifecycle methods can be defined in config or as class methods
onInit() {
this.state.generation = 0;
this.state.lastUpdate = 0;
}
onUpdate({ time, delta, uniforms }) {
// Update state periodically
if (time - this.state.lastUpdate > 0.1) {
this.state.generation++;
this.state.lastUpdate = time;
}
// Return computed uniforms for this frame
return {
u_generation: this.state.generation,
u_computed_value: Math.sin(time) * uniforms.intensity
};
}
onDestroy() {
// Cleanup resources (e.g., event listeners, audio contexts)
}
6. Effect Constructor Reference¶
The Effect constructor accepts a configuration object. The list below describes common properties. Section 7 summarizes the remaining
fields that the current constructor copies.
Required:
name(string): Display name for the effectpasses(array): One or more render/compute passes
Optional:
namespace(string): Logical grouping (e.g.,"filter","synth","mixer")func(string): DSL function name used by runtime registrationtags(array): Curated tags for categorization (see section 2b)globals(object): Uniform parameters exposed to shaders and UItextures(object): Internal render targetsonInit(function): Lifecycle hook called once on loadonUpdate(function): Lifecycle hook called every frameonDestroy(function): Lifecycle hook called on cleanup
Example - Minimal Effect:
import { Effect } from '../../../src/runtime/effect.js';
export default new Effect({
name: "Invert",
namespace: "filter",
func: "inv",
passes: [
{
name: "main",
program: "invert",
inputs: { inputTex: "inputTex" },
outputs: { fragColor: "outputTex" }
}
]
});
Example - Effect with Globals and Textures:
import { Effect } from '../../../src/runtime/effect.js';
export default new Effect({
name: "Blur",
namespace: "filter",
func: "blur",
globals: {
radiusX: { type: "float", default: 5.0, min: 0, max: 50, uniform: "radiusX" },
radiusY: { type: "float", default: 5.0, min: 0, max: 50, uniform: "radiusY" }
},
textures: {
_blurTemp: { width: "screen", height: "screen", format: "rgba8unorm" }
},
passes: [
{
name: "blurH",
program: "blurH",
inputs: { inputTex: "inputTex" },
outputs: { fragColor: "_blurTemp" }
},
{
name: "blurV",
program: "blurV",
inputs: { inputTex: "_blurTemp" },
outputs: { fragColor: "outputTex" }
}
]
});
7. Configuration Shape and Validation¶
Effect definitions are validated at development and load time by
validateEffectDefinition() in shaders/src/runtime/effect-validator.js.
The validator is a deterministic, side-effect-free function that returns an array of
descriptive error strings (empty if valid) without throwing or mutating definitions.
It validates the full definition grammar consumed across effect.js, expander.js,
compiler.js, and pipeline.js.
The following schema summarizes the consumed authoring shape. Regular expressions use
/.../ form.
// Deliberately abridged shape; validation is described above.
{
"type": "object",
"required": ["name", "passes"],
"properties": {
"name": { "type": "string", "description": "Non-empty display name; spaces are allowed" },
"namespace": { "type": "string", "description": "Logical namespace" },
"func": { "type": "string", "description": "DSL function name for this effect" },
"description": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"globals": { "type": "object", "additionalProperties": { "$ref": "#/definitions/uniformSpec" } },
"textures": { "type": "object", "additionalProperties": { "$ref": "#/definitions/textureSpec" } },
"passes": { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/passSpec" } },
"outputTex3d": { "type": "string", "description": "Internal texture name to expose as 3D volume output" },
"outputGeo": { "type": "string", "description": "Internal texture name to expose as geometry buffer output" },
"uniformLayout": { "type": "object" },
"uniformLayouts": { "type": "object" },
"paramAliases": { "type": "object" },
"openCategories": { "type": "array", "items": { "type": "string" } },
"defaultProgram": { "type": "string" },
"hidden": { "type": "boolean" },
"deprecatedBy": { "type": "string" },
"onInit": { "type": "function" },
"onUpdate": { "type": "function" },
"onDestroy": { "type": "function" },
"asyncInit": { "type": "function" }
},
"definitions": {
"uniformSpec": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "type": "string", "enum": ["boolean","color","float","geometry","int","mat3","member","palette","string","surface","vec2","vec3","vec4","volume"] },
"default": { "description": "Optional effect-defined default." },
"min": { "type": "number" },
"max": { "type": "number" },
"step": { "type": "number" },
"choices": {
"type": "object",
"additionalProperties": { "type": "integer" },
"description": "Map of label strings to integer values for dropdowns"
},
"enum": { "type": "string", "description": "Reference to a global enum key" },
"ui": {
"type": "object",
"properties": {
"label": { "type": "string" },
"control": { "type": ["string", "boolean"] },
"category": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9]*$", "description": "UI grouping category (MUST be camelCase)" },
"hint": { "type": "string", "description": "Tooltip text for the control" },
"enabledBy": {
"oneOf": [
{ "type": "string", "description": "Parameter name for truthy check" },
{ "$ref": "#/definitions/enableCondition" }
],
"description": "Condition that must be satisfied for this control to be enabled"
}
}
},
"requires": {
"type": "object",
"description": "Conditional visibility logic (e.g. show this uniform only if another uniform has a specific value)"
}
}
},
"dimensionSpec": {
"oneOf": [
{"type": "number"},
{"type": "string", "description": "screen, auto, input, resolution, or a percentage parsed with parseFloat (the string keywords all resolve to screen size)"},
{"type": "object", "required": ["scale"], "properties": {"scale": {"type":"number"}, "clamp": {"type":"object", "properties": {"min": {"type":"number"}, "max": {"type":"number"}}}}},
{"type": "object", "required": ["param"], "properties": {"param": {"type":"string"}, "paramDefault": {"type":"number"}, "default": {"type":"number"}, "multiply": {"type":"number"}, "power": {"type":"number"}}},
{"type": "object", "required": ["screenDivide"], "properties": {"screenDivide": {"type":"string"}, "default": {"type":"number"}}}
]
},
"textureSpec": {
"type": "object",
"properties": {
"width": { "$ref": "#/definitions/dimensionSpec" },
"height": { "$ref": "#/definitions/dimensionSpec" },
"depth": { "$ref": "#/definitions/dimensionSpec" },
"format": { "type": "string" },
"mipmaps": { "type": "boolean", "description": "2D only: allocate a full mip chain and regenerate it from level-0 writes each frame" },
"persistent": { "type": "boolean", "description": "2D only: resample contents into the new texture when allocation size changes instead of starting empty" },
"filter": { "type": "string", "enum": ["nearest", "linear"], "description": "3D (\"textures3d\") only: sampler filtering; unauthored 3D defaults are linear on WebGL2 and nearest on WebGPU" }
},
"description": "User-defined textures. Width and height default to screen; format defaults to rgba16f. Unknown or misplaced policy fields are rejected with per-field diagnostics."
},
"enableCondition": {
"type": "object",
"description": "Conditional expression for enabledBy",
"properties": {
"param": { "type": "string", "description": "Parameter name to check" },
"eq": { "description": "Equal to value" },
"neq": { "description": "Not equal to value" },
"gt": { "type": "number", "description": "Greater than" },
"gte": { "type": "number", "description": "Greater than or equal" },
"lt": { "type": "number", "description": "Less than" },
"lte": { "type": "number", "description": "Less than or equal" },
"in": { "type": "array", "description": "Value is member of array" },
"notIn": { "type": "array", "description": "Value is not member of array" },
"or": { "type": "array", "items": { "$ref": "#/definitions/enableCondition" }, "description": "Any condition must be true" },
"and": { "type": "array", "items": { "$ref": "#/definitions/enableCondition" }, "description": "All conditions must be true" },
"not": { "$ref": "#/definitions/enableCondition", "description": "Invert condition" }
}
},
"passSpec": {
"type": "object",
"required": ["program"],
"properties": {
"name": { "type": "string", "description": "Pass label; preserved verbatim on expanded passes" },
"program": { "type": "string" },
"type": { "type": "string", "description": "Pass type label (e.g. render, compute). Backend shader-kind dispatch remains source-derived." },
"inputs": { "type": "object", "additionalProperties": {"type":"string"} },
"outputs": { "type": "object", "additionalProperties": {"type":"string"} },
"entryPoint": { "type": "string" },
"drawMode": { "type": "string" },
"drawBuffers": { "type": "integer", "minimum": 1 },
"count": { "type": ["integer", "string"] },
"countUniform": { "type": "string" },
"repeat": { "type": ["integer", "string"] },
"blend": {},
"clear": { "type": "boolean", "description": "Whether to clear output attachments before pass execution (WebGPU render-pass loadOp)" },
"samplerTypes": { "type": "object", "additionalProperties": { "type": "string", "enum": ["nearest", "linear"] }, "description": "Per-binding sampler filtering selection (WebGPU)" },
"viewport": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"w": { "type": "number" },
"h": { "type": "number" },
"width": { "$ref": "#/definitions/dimensionSpec" },
"height": { "$ref": "#/definitions/dimensionSpec" }
},
"description": "Per-pass viewport box. Dimension specifications resolve against current uniforms and screen dimensions per frame."
},
"conditions": {
"type": "object",
"properties": {
"runIf": { "type": "array" },
"skipIf": { "type": "array" }
},
"description": "Conditional execution predicates evaluated per frame before dispatch"
},
"uniforms": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Shader uniform name to effect-global parameter name."
},
"workgroups": { "type": "array", "items": {"type":"integer","minimum":1}, "minItems":1, "maxItems":3 },
"storageBuffers": { "type": "object" },
"storageTextures": { "type": "object" }
}
}
}
}
Section 7.3 describes how backends handle format names.
7.1 Reserved Texture Names¶
The runtime synthesizes these textures automatically. Do not define them in textures.
2D Pipeline (standard):
inputTex— 2D input from the previous effect in the chainoutputTex— 2D output to the next effect in the chain
3D Pipeline (volumetric):
inputTex3d— 3D volume input from the previous effectoutputTex3d— Effect-level property pointing to an internal texture to expose as 3D output
Geometry Pipeline:
inputGeo— Geometry buffer (normals + depth) from upstream raymarched effectoutputGeo— Effect-level property pointing to an internal texture to expose as geometry output
Effects that produce 3D volumes or geometry buffers declare the output at effect level:
export default new Effect({
name: "VolumeGenerator",
namespace: "synth3d",
textures: {
volumeCache: { width: 64, height: 4096, format: "rgba16float" },
geoBuffer: { width: "screen", height: "screen", format: "rgba16float" }
},
passes: [ /* ... */ ],
outputTex3d: "volumeCache", // Expose volumeCache as 3D output
outputGeo: "geoBuffer" // Expose geoBuffer as geometry output
});
7.2 Dimension Resolution Algorithm¶
For each texture dimension (width or height), resolve to integer pixels:
function resolveDimension(spec, screenSize, uniforms = {}) {
if (typeof spec === 'number') return Math.max(1, Math.floor(spec))
// 'screen', 'auto', 'input', and 'resolution' are all validator-accepted
// keywords whose resolution is the screen dimension; no fallback diagnostic.
if (['screen', 'auto', 'input', 'resolution'].includes(spec)) return screenSize
if (typeof spec === 'string' && spec.endsWith('%')) {
const percent = parseFloat(spec)
return Math.max(1, Math.floor(screenSize * percent / 100))
}
if (typeof spec === 'object') {
// Param-based; `default` is the final fallback after transforms.
if (spec.param !== undefined) {
const hasTransform = spec.power !== undefined || spec.multiply !== undefined
const paramDefault = spec.paramDefault ?? 64
let value = uniforms[spec.param] ?? paramDefault
if (spec.multiply !== undefined) value *= spec.multiply
if (spec.power !== undefined) value = Math.pow(value, spec.power)
if (hasTransform && uniforms[spec.param] === undefined && spec.default !== undefined) {
value = spec.default
}
return Math.max(1, Math.floor(value))
}
// Screen-divide: { screenDivide: 'zoom', default: 1 }
if (spec.screenDivide !== undefined) {
const divisor = uniforms[spec.screenDivide] ?? spec.default ?? 1
return Math.max(1, Math.round(screenSize / divisor))
}
// Scale-based: { scale: 0.5, clamp: { min: 64, max: 512 } }
if (spec.scale !== undefined) {
let computed = Math.floor(screenSize * spec.scale)
if (spec.clamp) {
if (spec.clamp.min !== undefined) computed = Math.max(spec.clamp.min, computed)
if (spec.clamp.max !== undefined) computed = Math.min(spec.clamp.max, computed)
}
return Math.max(1, computed)
}
}
return screenSize // Fallback
}
All dimensions MUST be positive integers. Numeric, percentage, parameter, and
scale results round down. The screenDivide form rounds to the nearest
integer. Dimension resolution enforces a minimum of 1px.
7.3 Backend Format Resolution¶
Each backend resolves format names independently. There is no capability negotiation or precision-downgrade policy.
WebGL recognizes
rgba8,rgba16f,rgba32f,r8,r16f, andr32f. Any other name falls back to thergba8descriptor.WebGPU maps those compact names to their WebGPU equivalents. It recognizes the corresponding WebGPU-format spellings plus
bgra8unorm. It passes any other non-empty format string through unchanged. An omitted format resolves torgba8unorm.
Unsupported resolved formats or usages fail when the backend creates or uses the texture. The resolver does not check device capabilities in advance.