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¶
Effect definitions are created using the Effect constructor with 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",
type: "render",
program: "downsample",
inputs: {
scene: "inputTex"
},
outputs: {
color: "downsampled"
}
},
{
name: "composite",
type: "render",
program: "composite",
inputs: {
scene: "inputTex",
bloom: "downsampled"
},
outputs: {
color: "outputColor"
}
}
]
});
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:type:render(fragment shader) orcompute(compute shader).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.iterations: Number of times to run this pass.pingpong: Array of two texture names to swap input/output roles during iterations.
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, injecting the backend-specific directory (glsl/ or wgsl/) and appending the appropriate 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:
Example DSL snippets are auto-generated by the demo UI based on the effect type (starter, filter, or mixer). There is no need to maintain example.dsl files manually.
4. Global Enums¶
To promote consistency and reduce duplication, common enumerations are defined in a global registry. 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.
4b. UI Categories¶
Uniform controls can be visually grouped in the demo UI using the ui.category property. Categories allow complex effects with many parameters to organize their controls into logical sections.
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 controls when a parameter’s UI control is enabled or disabled based on the value of other parameters. This supports both simple truthy checks and complex conditional expressions.
Simple String Format (Legacy):
The simplest form takes a parameter name as a string. The control is enabled when the referenced parameter is “truthy” (non-zero for numbers, true for booleans, non-empty for strings).
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 are AND’d together:
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
5. Lifecycle Methods (Class-Based Effects)¶
Most effects are purely declarative and use the new Effect({...}) pattern shown above. However, for effects requiring CPU-side state management (e.g., simulation steps, complex time-keeping, or audio analysis), you can either:
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(): Called once when the effect is loaded. Initialize state here.onUpdate({ time, delta, uniforms }): Called every frame before rendering. Return an object of computed uniforms.onDestroy(): Called when the effect is removed. Clean up resources here.
// 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 with the following properties:
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 (defaults to lowercasename)tags(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: "input", height: "input", format: "rgba8unorm" }
},
passes: [
{
name: "blurH",
program: "blurH",
inputs: { inputTex: "inputTex" },
outputs: { fragColor: "_blurTemp" }
},
{
name: "blurV",
program: "blurV",
inputs: { inputTex: "_blurTemp" },
outputs: { fragColor: "outputTex" }
}
]
});
7. Formal JSON Schema (Informative)¶
The following normative shape defines the Effect configuration object. Validation MUST apply before graph compilation. Regular expressions shown in /.../ form.
// Pseudocode JSON Schema (non exhaustive formatting for brevity)
{
"$id": "noisemaker.shader-effect.v1",
"type": "object",
"required": ["name", "passes"],
"properties": {
"name": { "type": "string", "pattern": "^[A-Za-z0-9_\-]{1,64}$" },
"namespace": { "type": "string", "pattern": "^[a-zA-Z0-9]+$", "default": "synth" },
"func": { "type": "string", "description": "DSL function name for this effect" },
"tags": {
"type": "array",
"items": { "type": "string", "enum": ["color", "distort", "geometric", "math", "noise", "transform", "util"] },
"description": "Curated tags for effect categorization"
},
"version": { "type": "string", "pattern": "^\d+\.\d+\.\d+$", "default": "1.0.0" },
"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" },
"meta": { "type": "object" }
},
"definitions": {
"uniformSpec": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "type": "string", "enum": ["float","int","uint","bool","vec2","vec3","vec4","mat3","mat4"] },
"default": { "description": "Optional. Fallback: 0, false, or identity matrix." },
"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", "enum": ["slider", "dropdown", "color", "checkbox"] },
"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", "minimum": 1},
{"type": "string", "enum": ["screen","auto","input"]},
{"type": "string", "pattern": "^(?:100|[1-9]?[0-9])%$"},
{"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"}, "default": {"type":"number"}, "multiply": {"type":"number"}, "power": {"type":"number"}, "inputOverride": {"type":"string"}}}
]
},
"textureSpec": {
"type": "object",
"properties": {
"width": { "$ref": "#/definitions/dimensionSpec" },
"height": { "$ref": "#/definitions/dimensionSpec" },
"format": { "type": "string" },
"usage": { "type": "array", "items": {"type":"string", "enum":["sample","storage","render","copySrc","copyDst"]} },
"clear": { "type": "array", "minItems": 4, "maxItems": 4 },
"persistent": { "type": "boolean", "default": false }
},
"required": ["format"],
"additionalProperties": false,
"description": "User-defined textures. Reserved names (inputTex, outputTex, inputTex3d, inputGeo) are synthesized by the runtime."
},
"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": ["name","program"],
"properties": {
"name": { "type": "string", "pattern": "^[A-Za-z0-9_\-]{1,64}$" },
"type": { "type": "string", "enum": ["render","compute","transfer"], "default": "render" },
"program": { "type": "string" },
"inputs": { "type": "object", "additionalProperties": {"type":"string"} },
"outputs": { "type": "object", "additionalProperties": {"type":"string"} },
"iterations": { "type": "integer", "minimum": 1, "default": 1 },
"pingpong": { "type": "array", "items": {"type":"string"}, "minItems": 2, "maxItems": 2 },
"defines": { "type": "object", "additionalProperties": {"type":["string","number","boolean"]} },
"uniforms": {
"type": "object",
"additionalProperties": { "$ref": "#/definitions/uniformSpec" },
"description": "Pass-specific uniforms. Merged with globals; pass-specific values take precedence."
},
"workgroups": { "type": "array", "items": {"type":"integer","minimum":1}, "minItems":1, "maxItems":3 },
"viewport": { "type": "object", "properties": {"x":{"type":"integer"},"y":{"type":"integer"},"w":{"type":"integer"},"h":{"type":"integer"}} },
"conditions": {
"type": "object",
"properties": {
"skipIf": {
"type": "array",
"items": {
"type": "object",
"required": ["uniform", "equals"],
"properties": { "uniform": {"type":"string"}, "equals": {} }
}
},
"runIf": {
"type": "array",
"items": {
"type": "object",
"required": ["uniform", "equals"],
"properties": { "uniform": {"type":"string"}, "equals": {} }
}
}
}
},
"barriers": {
"type": "array",
"items": { "type": "string", "pattern": "^texture:[a-zA-Z0-9_]+:(fragment|compute)->(fragment|compute)$" },
"description": "Explicit memory barriers. Format: 'texture:<name>:<stage>-><stage>'"
},
"readAfterWriteHazards": {
"type": "string",
"enum": ["allow","forbid"],
"default": "forbid",
"description": "If 'allow', the runtime inserts a barrier between write and read within the same pass (if supported) or developer guarantees safety."
}
}
}
}
}
Formats MUST map to backend-supported subsets:
WebGL required:
rgba8,rgba16f,rgba32f (if EXT_color_buffer_float),r8.WebGPU required subset:
rgba8unorm,rgba16float,rgba32float,bgra8unorm, depth formats as available.
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))
if (spec === 'screen' || spec === 'auto') return screenSize
if (spec === 'input') return screenSize // Match input texture dimensions
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: { param: 'volumeSize', default: 64, multiply: 2, power: 2 }
if (spec.param !== undefined) {
let value = uniforms[spec.param] ?? spec.default ?? 64
if (spec.multiply !== undefined) value *= spec.multiply
if (spec.power !== undefined) value = Math.pow(value, spec.power)
return Math.max(1, Math.floor(value))
}
// 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. Fractional results round down; minimum 1px enforced.
7.3 Format Negotiation¶
When an effect requests a format unsupported by the active backend:
Exact Match: Use if available.
Fallback Table: Apply backend-specific mapping:
const webglFallbacks = { 'rgba16float': 'rgba16f', 'rgba32float': hasExtension('EXT_color_buffer_float') ? 'rgba32f' : 'rgba16f', 'rgba8unorm': 'rgba8' }
Precision Downgrade: If no mapping exists, select highest precision supported format with same channel count.
Fail: If no compatible format, emit
ERR_FORMAT_UNSUPPORTED.
Format selection MUST be deterministic and cached per backend context.