Pipeline Spec

This document outlines the specification for the Noisemaker Rendering Pipeline and effect definition format. It is designed to support complex, multi-pass effects defined declaratively, executed on a unified GPU pipeline supporting either WebGL 2 or WebGPU backends.

1. Core Philosophy

  1. Declarative Effects: Effects are defined as data (JSON graphs), not imperative code.

  2. Graph-Based Execution: The pipeline treats the entire frame as a Directed Acyclic Graph (DAG) of passes.

  3. Multi-Pass By Design: Effect definitions expand into explicit multi-pass schedules; layering and feedback are first-class.

  4. Backend Agnostic: The definition format is abstract; the runtime handles the specifics of WebGL 2 vs. WebGPU.

  5. GPU-Resident Pipeline: The entire render loop runs on the GPU with zero CPU copies; the CPU only orchestrates dispatch.

  6. Compute First: First-class support for compute shaders (native in WebGPU, emulated via GPGPU in WebGL 2).


2. Pipeline Architecture

The pipeline consists of three main phases:

Phase 1: Graph Compilation (Logical Graph & Render Graph)

Occurs when the DSL code changes. See Compiler Specification for the detailed specification of this phase.

  1. Parse DSL: Generate AST.

  2. Analyze: Generate Logical Graph (Effect Chain).

  3. Expand Effects: Replace high-level nodes with their constituent passes defined in the JSON schema (Render Graph).

  4. Scope State Textures: Effects that maintain simulation state use global_* textures (e.g., global_rd_state, global_ca_state, global_accum). During expansion, these are scoped per-chain (e.g., global_rd_state_chain_0) so that multiple instances of the same stateful effect in separate chains get independent state. Particle textures (global_xyz, global_vel, etc.) are further scoped per-pipeline to the node that creates them. Effects within the same chain share state, which is required for patterns like loopBegin/loopEnd that share global_accum.

  5. Topological Sort: Order the passes based on texture dependencies to ensure inputs are ready before they are read.

  6. Resource Analysis: Determine the lifetime of each intermediate texture to enable memory pooling.

Phase 2: Resource Allocation (Execution Plan Assembly)

Occurs before execution (or when screen size changes).

  1. Texture Pool: A shared pool of textures of various sizes/formats.

  2. Allocation: Assign physical textures from the pool to the logical texture requirements of the graph.

    • Optimization: Reuse textures. If tex_B is only used by Node 3, and Node 3 writes to tex_C, tex_B’s physical texture can be released back to the pool after Node 3 executes (or reused for tex_C if dimensions match and no read/write conflict exists).

Phase 3: Execution (GPU Driver)

Occurs every frame.

  1. Update Globals: Refresh runtime uniforms sourced from the implementation: time (seconds since start), deltaTime (frame-to-frame delta), frame (integer tick), resolution (vec2 pixels), and aspect (width ÷ height).

  2. Iterate Passes: Walk the sorted list of passes in the Execution Plan.

  3. Dispatch:

    • WebGL 2:

      • Activate the compiled WebGLProgram for the pass and resolve the target framebuffer (global surfaces map to the current write buffer).

      • Derive the viewport from the target texture dimensions (or the pass override) and bind it before issuing work.

      • Bind each declared input texture to successive texture units and upload merged uniforms from globalUniforms + pass uniforms via gl.uniform*.

      • Configure blending if the pass requests it, then issue either gl.drawArrays(gl.TRIANGLES, 0, 3) for the default full-screen triangle or gl.drawArrays(gl.POINTS, ...) when drawMode == 'points'.

    • WebGPU:

      • Create a command encoder at frame start, then for each pass resolve the output texture view (respecting double-buffer swaps).

      • Build a bind group that packs sampled textures, the default sampler, and a freshly uploaded uniform buffer containing globalUniforms merged with pass uniforms.

      • Render passes begin a render pass that clears the target, set the pipeline, bind group, and emit passEncoder.draw(3, 1, 0, 0) for the full-screen triangle.

      • Compute passes begin a compute pass, set the compute pipeline/bind group, and dispatch passEncoder.dispatchWorkgroups(...) using explicit workgroups or dimensions derived from the output texture.


3. Backend Specifics

3.1 WebGL 2 Implementation

  • Render Passes: Standard drawArrays into Framebuffer Objects (FBOs).

  • Compute Passes: Emulated via GPGPU.

    • Vertex Shader: Renders a full-screen quad.

    • Fragment Shader: Performs the “compute” logic per pixel.

    • Output: Writes to a texture via FBO.

    • Limitation: No shared memory or arbitrary scatter writes. Compute logic must be mapped to 1:1 pixel outputs where possible.

3.2 WebGPU Implementation

  • Render Passes: Native RenderPipeline.

  • Compute Passes: Native ComputePipeline.

    • Supports storage textures and buffers.

    • Supports arbitrary read/write (scatter/gather).


4. Constraints & Requirements

  1. Vanilla JS: No build steps or transpilers required for the runtime logic.

  2. Context Awareness: The pipeline must detect gl vs gpu context and switch strategies transparently.

  3. Hot Reloading: Changing the DSL or an Effect Definition must instantly rebuild the graph without reloading the page.

  4. Error Handling: Missing textures or cyclic dependencies must be caught during the Graph Compilation phase.

5. Compute Shader Support (Unified Spec)

Compute passes are first-class. The type: "compute" pass specification MUST supply any non‑default dispatch shape. The runtime produces equivalent behavior on WebGL (emulation) and WebGPU (native).

5.1 WebGPU (Native)

  • Each compute pass compiles into a GPUComputePipeline keyed by program + static defines.

  • Dispatch shape derives from workgroups: [x,y,z] (all integers ≥ 1). If omitted: [ceil(width/8), ceil(height/8), 1] for 2D textures.

  • Bindings:

    • Sampled inputs (inputs) become @group(0) sampled textures or storage textures depending on usage declaration.

    • Uniform buffer pack consolidates scalars/vec/mat into a single std140 layout (see Section 10).

    • Storage outputs allowed if declared with texture spec usage containing storage.

5.2 WebGL 2 (Emulated)

  • A full‑screen triangle/quad fragment shader substitutes invocation IDs. Emulation contract:

    • gl_FragCoord.xy maps to GlobalInvocationID.xy.

    • Emulated local size fixed at 1; workgroups only influences virtual coordinate scaling.

    • Formula: GlobalInvocationID.xy = floor(gl_FragCoord.xy)

  • Scatter writes are FORBIDDEN; only 1:1 mapping output textures.

    • Detection: Static analysis of the shader source checks for imageStore or equivalent random-access write operations. If detected, ERR_COMPUTE_UNSUPPORTED_FEATURE is raised.

  • Multiple outputs require MRT; if backend lacks format support, validation fails (ERR_COMPUTE_MRT_UNSUPPORTED).

    • Detection: If outputs has > 1 entry and the backend context does not support MAX_DRAW_BUFFERS >= N, this error is raised during validation.

5.3 Cross‑Backend Restrictions

  • Shared memory, subgroup ops, atomics: disallowed in spec until extended in a future version (effect.version >= 2.x).

  • A compute pass MAY precede or follow render passes; ordering resolved by dependency edges (Section 9).

  • Ping‑pong iteration semantics identical to render passes: after each iteration, textures listed in pingpong swap logical roles.


6. Validation Rules

Validation occurs in deterministic phases; failing any phase aborts compilation with error codes.

  1. Structure: JSON schema compliance (ERR_SCHEMA).

  2. Name Uniqueness: No duplicate passes[].name (ERR_DUP_PASS_NAME).

  3. Texture References: Every inputs / outputs value refers to either a declared texture or a global surface alias (oN) (ERR_BAD_TEX_REF).

  4. Ping-Pong Integrity: pingpong pair MUST both exist in textures (ERR_PINGPONG_UNDECL).

  5. Iterations: If iterations > 1 and no pingpong, pass MUST be purely functional (no reading from its own output) or error (ERR_ITER_NO_PINGPONG).

  6. Dependency Graph: Cycles forbidden unless ALL edges cross a persistent texture flagged feedback (future extension) (ERR_CYCLE).

  7. Compute Limitations (WebGL): Any compute pass requesting storage usage or scatter writes invalid (ERR_COMPUTE_UNSUPPORTED_FEATURE).

  8. Viewport Bounds: Viewport must not exceed target texture size (ERR_VIEWPORT_BOUNDS).

  9. Workgroup Shape (WebGPU): Product must not exceed device limits (ERR_WORKGROUP_LIMIT).

  10. Uniform Type Coercion: Values must coerce without precision loss beyond IEEE 754 single for floats (ERR_UNIFORM_COERCE).

Error objects MUST include: { code, message, pass?: name, texture?: name, detail?: any }.

6.1 Shader Compilation Lifecycle

Compilation Triggers:

  1. First use of a program name.

  2. Change to shader source text (hot reload).

  3. Change to static defines for same program.

Cache Key: hash(programName, backend, sortedDefines, version)

WebGL Pipeline Creation:

function compileWebGLProgram(programName, defines, glslSource) {
  const vertexShader = compileShader(gl.VERTEX_SHADER, FULLSCREEN_QUAD_VERT)
  const fragmentShader = compileShader(gl.FRAGMENT_SHADER, injectDefines(glslSource, defines))
  const program = gl.createProgram()
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    throw { code: 'ERR_SHADER_LINK', detail: gl.getProgramInfoLog(program) }
  }
  return { program, uniforms: extractUniforms(program), samplers: extractSamplers(program) }
}

WebGPU Pipeline Creation:

async function compileWebGPUPipeline(programName, defines, wgslSource, pipelineType) {
  const module = device.createShaderModule({ code: injectDefines(wgslSource, defines) })
  const compilationInfo = await module.getCompilationInfo()
  if (compilationInfo.messages.some(m => m.type === 'error')) {
    throw { code: 'ERR_SHADER_COMPILE', detail: compilationInfo.messages }
  }

  if (pipelineType === 'render') {
    return device.createRenderPipeline({ /* layout derived from pass spec */ })
  } else {
    return device.createComputePipeline({ /* layout derived */ })
  }
}

Invalidation: Changing shader source invalidates cache entry; next frame recompiles. During recompilation, previous version remains active (no visual glitch). Compilation errors block new graph but preserve old.


7. Resource Lifetime & Pooling Algorithm

  1. Scan passes sequentially assigning firstUse / lastUse per texture.

    • firstUse: Index of the first pass that writes to the texture (or reads it, if it’s an input-only texture like a uniform). For feedback loops, synthetic nodes set firstUse = 0.

    • lastUse: Index of the last pass that reads from the texture.

    • Persistent surfaces (oN) have firstUse = 0, lastUse = Infinity.

  2. Group textures by (format,widthPx,heightPx,usageSignature).

    • Fallback Format Algorithm: If a requested format is unsupported, the runtime selects the “highest precision supported with same channel count”.

      • Example candidates for rgba16f: ['rgba16f', 'rgba32f', 'rgba8'].

      • Example candidates for r16f: ['r16f', 'r32f', 'r8'].

  3. Maintain a freelist per group; when allocating, search freelist for a texture whose releaseFrame <= currentFrameCompilationId.

  4. After a pass executes, if texture’s index equals its lastUse and it is not a global surface or persistent, mark it reusable.

  5. Pool compaction runs every N compilations (N=60 default, configurable via config.poolCompactionInterval) to delete unused physical textures. Deterministic allocation ensures identical graphs yield stable resource binding order for reproducibility.

7.1 Binding Slot Assignment

WebGL Texture Units:

  • Slots 0..N assigned sequentially in pass input declaration order.

  • Maximum validated against gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) (minimum 16 guaranteed).

  • Exceed limit triggers ERR_TOO_MANY_TEXTURES.

function bindWebGLTextures(pass, textureMap) {
  let unit = 0
  for (const [samplerName, textureName] of Object.entries(pass.inputs)) {
    if (unit >= maxTextureUnits) throw { code: 'ERR_TOO_MANY_TEXTURES', pass: pass.name }
    const texture = textureMap[textureName]
    gl.activeTexture(gl.TEXTURE0 + unit)
    gl.bindTexture(gl.TEXTURE_2D, texture.handle)
    gl.uniform1i(pass.uniformLocations[samplerName], unit)
    unit++
  }
}

WebGPU Bind Groups:

  • Group 0: Textures (sampled or storage) in declaration order.

  • Group 1: Uniform buffer (single consolidated UBO).

  • Group 2: Reserved for future storage buffers.

function createWebGPUBindGroup(pass, textureMap, uniformBuffer) {
  const entries = []
  let binding = 0

  for (const [samplerName, textureName] of Object.entries(pass.inputs)) {
    const texture = textureMap[textureName]
    entries.push({
      binding: binding++,
      resource: texture.usage.includes('storage')
        ? texture.view // storage texture
        : texture.sampler // sampled texture
    })
  }

  return device.createBindGroup({
    layout: pass.pipeline.getBindGroupLayout(0),
    entries
  })
}

7.2 Uniform Buffer Layout (std140)

All uniforms packed into single buffer per pass. Alignment rules:

  • float, int, uint, bool: 4-byte align, 4-byte stride.

  • vec2: 8-byte align, 8-byte stride.

  • vec3: 16-byte align, 12-byte data (pad to 16).

  • vec4: 16-byte align, 16-byte stride.

  • mat3: Array of 3 vec3 (16-byte aligned each) = 48 bytes.

  • mat4: Array of 4 vec4 = 64 bytes.

Packing Strategy: The buffer is allocated with a fixed initial size (e.g., 256 bytes). If the required size exceeds the capacity, the buffer is reallocated to the next power of two that fits the data (e.g., 512, 1024).

function packUniforms(uniformSpecs, values) {
  // Calculate required size first
  let requiredSize = 0;
  // ... (calculation logic) ...

  // Reallocate if needed (power of two growth)
  if (requiredSize > currentBufferSize) {
      currentBufferSize = Math.pow(2, Math.ceil(Math.log2(requiredSize)));
      buffer = new ArrayBuffer(currentBufferSize);
  }

  const view = new DataView(buffer)
  let offset = 0

  for (const [name, spec] of Object.entries(uniformSpecs)) {
    const value = values[name] ?? spec.default
    switch (spec.type) {
      case 'float': view.setFloat32(offset, value, true); offset += 4; break
      case 'int': view.setInt32(offset, value, true); offset += 4; break
      case 'bool': view.setInt32(offset, value ? 1 : 0, true); offset += 4; break
      case 'vec2':
        offset = alignTo(offset, 8)
        view.setFloat32(offset, value[0], true)
        view.setFloat32(offset + 4, value[1], true)
        offset += 8
        break
      case 'vec3':
        offset = alignTo(offset, 16)
        view.setFloat32(offset, value[0], true)
        view.setFloat32(offset + 4, value[1], true)
        view.setFloat32(offset + 8, value[2], true)
        offset += 16 // Padded
        break
      case 'vec4':
        offset = alignTo(offset, 16)
        for (let i = 0; i < 4; i++) view.setFloat32(offset + i * 4, value[i], true)
        offset += 16
        break
      case 'mat3':
        offset = alignTo(offset, 16)
        for (let col = 0; col < 3; col++) {
          for (let row = 0; row < 3; row++) {
            view.setFloat32(offset + row * 4, value[col * 3 + row], true)
          }
          offset += 16
        }
        break
      case 'mat4':
        offset = alignTo(offset, 16)
        for (let i = 0; i < 16; i++) {
          view.setFloat32(offset + i * 4, value[i], true)
        }
        offset += 64
        break
    }
  }

  return { buffer: buffer.slice(0, offset), size: offset }
}

function alignTo(offset, alignment) {
  return Math.ceil(offset / alignment) * alignment
}

8. Surface Management & Frame Buffering

8.0 Surface Types

The pipeline provides several types of global surfaces:

2D Surfaces (o0..``o7``): Standard double-buffered surfaces where reading within a frame sees any writes made earlier in that same frame.

3D Volume Surfaces (vol0..``vol7``): Persistent 3D texture volumes for volumetric effects. Default size is 64×64×64.

Geometry Buffers (geo0..``geo7``): Screen-sized 2D textures storing precomputed raymarching results (xyz=surface normal, w=depth). These enable downstream post-processing without re-raymarching.

Global 2D surfaces (o0.. o7) defined implicitly:

surfaceTable = {
  o0: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  o1: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  o2: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  o3: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  o4: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  o5: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  o6: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  o7: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true }
}

Global 3D volume surfaces (vol0.. vol7) defined implicitly:

volumeTable = {
  vol0: { format: 'rgba16f', width: 64, height: 64, depth: 64, is3D: true },
  vol1: { format: 'rgba16f', width: 64, height: 64, depth: 64, is3D: true },
  // ... vol2 through vol7
}

Global geometry buffers (geo0.. geo7) defined implicitly:

geoBufferTable = {
  geo0: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  geo1: { format: 'rgba16f', width: 'screen', height: 'screen', doubleBuffered: true },
  // ... geo2 through geo7
}

CRITICAL: User-Only Surfaces

Surfaces o0..``o7``, vol0..``vol7``, and geo0..``geo7`` are reserved exclusively for user composition and MUST NOT be hardwired within effect definitions. Effects requiring internal feedback or temporary storage must allocate their own internal surfaces (e.g., _feedbackBuffer, _temp0) in their textures property. Hardwiring these surfaces within an effect definition will corrupt the user’s composition graph.

Terminology:

  • doubleBuffered: The surface has two physical textures (read/write) swapped every frame. This allows reading the previous frame’s content while writing the current frame.

  • persistent: The surface’s content is preserved across frames (and resizes). All global surfaces are effectively persistent. Internal textures can be marked persistent: true to enable feedback effects.

8.0.1 Global Surface Behavior

Frame index F selects read buffer = (F-1) mod 2, write buffer = F mod 2. A chain writing .write(o0) targets write buffer; chains reading o0 before its write in frame use read buffer. After a write to oN, subsequent reads in the same frame see the freshly written content. Validation forbids multiple writes to same surface in a frame unless explicitly marked compositeAllowed (future extension) (ERR_SURFACE_MULTIWRITE).

8.1 Resize Behavior

When screen dimensions change:

  1. Detection: Compare (currentWidth, currentHeight) to cached (lastWidth, lastHeight) before frame execution.

  2. Invalidation: If changed, mark all dimension='screen' or dimension='%' textures for reallocation.

  3. Preserve Persistent: Global surfaces (oN) preserve content if persistent=true via blit to temporary, resize, blit back.

    • Fallback: If the new format is incompatible with the old format (e.g., channel count change), the blit is skipped and the surface is cleared to transparent black.

  4. Rebuild Pool: Recompute all texture dimensions; existing pool entries with mismatched sizes released.

  5. Recompile: If any viewport or workgroup depends on texture size, recompute those values.

function handleResize(newWidth, newHeight, graph) {
  if (newWidth === graph.lastWidth && newHeight === graph.lastHeight) return

  const resizedTextures = []
  for (const [name, spec] of Object.entries(graph.textures)) {
    const oldW = spec.resolvedWidth
    const oldH = spec.resolvedHeight
    spec.resolvedWidth = resolveDimension(spec.width, 'w', { w: newWidth, h: newHeight })
    spec.resolvedHeight = resolveDimension(spec.height, 'h', { w: newWidth, h: newHeight })

    if (oldW !== spec.resolvedWidth || oldH !== spec.resolvedHeight) {
      resizedTextures.push({ name, spec, oldW, oldH })
    }
  }

  for (const { name, spec, oldW, oldH } of resizedTextures) {
    const oldTexture = graph.textureMap[name]
    if (spec.persistent && oldTexture) {
      const temp = createTexture(oldW, oldH, spec.format)
      blitTexture(oldTexture, temp)
      destroyTexture(oldTexture)
      const newTexture = createTexture(spec.resolvedWidth, spec.resolvedHeight, spec.format)
      blitTexture(temp, newTexture, { preserveAspect: false })
      destroyTexture(temp)
      graph.textureMap[name] = newTexture
    } else {
      if (oldTexture) destroyTexture(oldTexture)
      graph.textureMap[name] = createTexture(spec.resolvedWidth, spec.resolvedHeight, spec.format)
    }
  }

  graph.lastWidth = newWidth
  graph.lastHeight = newHeight
}

9. Execution Order Determination

Algorithm (Kahn):

  1. Build nodes for each pass; edge from A->B if B reads a texture written by A and not yet overwritten.

  2. For surfaces, if pass P reads oX and no pass writes oX earlier in frame, add edge from synthetic node SURFACE_PREV_oX to P.

  3. Initialize queue with zero in-degree nodes; pop, append to execution list; decrement successors; continue.

  4. If nodes remain with in-degree > 0 -> cycle error (ERR_CYCLE).

    • Feedback Loops: Cycles are strictly forbidden within a single frame’s dependency graph. Feedback effects MUST use persistent textures or global surfaces to read data from the previous frame, which does not create a dependency cycle in the current frame.

  5. Expand iteration passes by duplicating node logically N times during schedule emission while preserving resource indices.

Dynamic Pass Skipping: Passes may define conditions (e.g., skipIf). The runtime evaluates these conditions against the current uniform values before dispatching the pass. If the condition is met, the pass is skipped, and its output textures retain their previous content (or are cleared if not persistent). This is a runtime check and does not alter the compiled graph structure.

9.1 Pass Expansion (Iterations & Ping-Pong)

For a pass with iterations > 1 and optional pingpong:

function expandPass(pass, textureMap) {
  const steps = []

  if (pass.iterations === 1) {
    steps.push({
      name: pass.name,
      // ...
    })
    return steps
  }

  // Multi-iteration
  if (!pass.pingpong || pass.pingpong.length !== 2) {
    throw { code: 'ERR_ITER_NO_PINGPONG', pass: pass.name }
  }

  const [texA, texB] = pass.pingpong

  for (let i = 0; i < pass.iterations; i++) {
    const isEven = i % 2 === 0
    const readTex = i === 0 ? pass.inputs : { [Object.keys(pass.inputs)[0]]: isEven ? texA : texB }
    const writeTex = isEven ? texB : texA

    // Deterministic Naming: passName#iteration
    steps.push({
      name: `${pass.name}#${i}`,
      inputs: readTex,
      outputs: { [Object.keys(pass.outputs)[0]]: writeTex },
      uniforms: { ...pass.uniforms, _iteration: i },
      program: pass.program
    })
  }

  return steps
}

Final output of iterated pass is the last-written ping-pong texture. Subsequent passes reading the logical output name receive a remapped reference to the correct buffer.


10. Uniform & Binding Conventions

  • Naming: Shader side may use u_*; adapter strips prefix for effect/global key mapping.

  • Packing: All scalar/vec/mat uniforms grouped in a single buffer per pass; layout: std140 for WebGPU & WebGL aligning to 16‑byte boundaries.

  • Boolean: Represented as int (0/1) in GLSL ES; WGSL uses native bool but numeric mirror provided for deterministic hashing.

  • Matrices: Row-major in effect spec; adapter transposes if backend requires column-major.

  • Enumeration: Always numeric int.

  • Samplers vs Storage: Storage only if texture usage includes storage and backend supports; fallback error otherwise.

10.1 Implicit Texture Creation

inputTex: Canonical reference to the upstream chain output.

  • Created automatically for non-generator effects.

  • Dimensions and format inherited from previous pass output or default to screen-sized rgba16f.

  • If first pass in chain reads inputTex but no upstream exists, error ERR_NO_INPUT.

  • Legacy name inputColor is still recognized for backwards compatibility.

outputColor: Synthetic texture representing the effect’s final output.

  • Created automatically; dimensions match largest output texture or inputTex if no explicit textures.

  • Format matches pass output format declaration or defaults to rgba16f.

  • Becomes the inputTex for the next chained effect.

function createImplicitTextures(effect, upstreamOutput) {
  const textures = { ...effect.textures }

  if (!effect.meta?.generator) {
    if (!upstreamOutput) throw { code: 'ERR_NO_INPUT', effect: effect.name }
    textures.inputTex = {
      handle: upstreamOutput.handle,
      width: upstreamOutput.width,
      height: upstreamOutput.height,
      format: upstreamOutput.format,
      synthetic: true
    }
  }

  // outputColor created after final pass executes; spec determined by output declaration
  const lastPass = effect.passes[effect.passes.length - 1]
  const outputTexName = lastPass.outputs.color || lastPass.outputs[Object.keys(lastPass.outputs)[0]]

  if (outputTexName === 'outputColor') {
    textures.outputColor = {
      width: textures.inputTex?.width || 'screen',
      height: textures.inputTex?.height || 'screen',
      format: 'rgba16f',
      synthetic: true
    }
  }

  return textures
}

11. Error Codes (Summary)

Code

Meaning

ERR_SCHEMA

Schema validation failed

ERR_DUP_PASS_NAME

Duplicate pass name

ERR_BAD_TEX_REF

Input/output references unknown texture/surface

ERR_PINGPONG_UNDECL

Ping-pong texture undeclared

ERR_ITER_NO_PINGPONG

Iterative pass missing pingpong or self-read unsafe

ERR_CYCLE

Cyclic dependency detected

ERR_COMPUTE_UNSUPPORTED_FEATURE

Compute feature not emulatable on WebGL

ERR_VIEWPORT_BOUNDS

Viewport out of target bounds

ERR_WORKGROUP_LIMIT

Workgroup size exceeds device limits

ERR_UNIFORM_COERCE

Uniform value invalid/coercion failed

ERR_SURFACE_MULTIWRITE

Multiple writes to same surface without extension

ERR_COMPUTE_MRT_UNSUPPORTED

Multi-render-target compute emulation unsupported

ERR_READBACK_FORBIDDEN

Attempted GPU-to-CPU readback within frame

ERR_TOO_MANY_TEXTURES

Exceeded maximum texture units for backend

ERR_DIMENSION_INVALID

Texture dimension spec invalid

ERR_FORMAT_UNSUPPORTED

Texture format not supported by backend

ERR_SHADER_COMPILE

Shader compilation failed

ERR_SHADER_LINK

Shader program linking failed

ERR_NO_INPUT

Non-generator effect missing input

ERR_ENUM_INVALID

Unknown enum string provided

ERR_CONDITION_SYNTAX

Invalid pass condition entry

Errors MUST be stable across versions for tooling.


12. Performance Requirements

  • Graph compilation target < 5ms for 200 passes on mid-tier hardware (baseline reference; not enforced at runtime, used for regression).

  • Texture reuse rate >= 70% for identical dimension/format groups over steady frame after warmup.

  • No pass may trigger synchronous GPU readback; validation MUST block code paths attempting gl.readPixels or WebGPU mapAsync on resources derived from effect outputs within frame (ERR_READBACK_FORBIDDEN).

  • Optional metrics emitter: { compileTimeMs, passCount, textureAllocCount, poolHitRate } for diagnostics.


13. Versioning & Extensibility

  • version field governs opt-in features; minor increments add backward-compatible fields, major increments may introduce reserved keywords.

  • Reserved future fields: buffers, feedback, async, subgraphs.

  • Tooling MUST ignore unknown top-level keys starting with _ (private extensions).

  • Versioning strategy: introducing v2 features requires explicit effect.version = "2.0.0".


14. Runtime Data Structures (Normative)

14.1 Compiled Graph

interface CompiledGraph {
  id: string                    // Hash of source effects + configuration
  version: string               // Effect version
  passes: CompiledPass[]        // Topologically sorted execution order
  textures: Map<string, GPUTexture>
  surfaces: Map<string, DoublBufferedSurface>
  uniformBuffers: Map<string, UniformBuffer>
  pipelines: Map<string, Pipeline>
  metrics: GraphMetrics
  lastWidth: number
  lastHeight: number
  compiledAt: number            // Timestamp
}

interface CompiledPass {
  id: string                    // Unique within graph (name + iteration index)
  effectName: string
  program: string
  type: 'render' | 'compute' | 'transfer'
  inputs: Map<string, TextureBinding>
  outputs: Map<string, TextureBinding>
  uniforms: Map<string, UniformValue>
  viewport?: { x: number, y: number, w: number, h: number }
  workgroups?: [number, number, number]
  pipeline: Pipeline            // Cached compiled pipeline
}

interface TextureBinding {
  name: string                  // Logical name
  physical: GPUTexture          // Actual GPU resource
  slot: number                  // Binding slot index
  sampler?: GPUSampler
}

interface DoublBufferedSurface {
  name: string
  buffers: [GPUTexture, GPUTexture]
  currentFrame: number
  format: string
  width: number
  height: number
}

interface GraphMetrics {
  compileTimeMs: number
  passCount: number
  textureAllocCount: number
  poolHitRate: number
  lastFrameTimeMs: number
}

14.2 Effect Lifecycle State Machine

STATES:
  UNLOADED → definition not yet parsed
  VALIDATING → schema validation in progress
  VALIDATED → passed validation, ready for compilation
  COMPILING → shader compilation in progress
  READY → executable, cached
  ERROR → validation or compilation failed
  STALE → source changed, needs recompilation

TRANSITIONS:
  UNLOADED --[load]--> VALIDATING
  VALIDATING --[pass]--> VALIDATED --[compile]--> COMPILING --[success]--> READY
  VALIDATING --[fail]--> ERROR
  COMPILING --[fail]--> ERROR
  READY --[execute]--> READY
  READY --[sourceChange]--> STALE --[recompile]--> COMPILING
  ERROR --[fix]--> UNLOADED

14.3 Frame Execution State

interface FrameState {
  frameIndex: number
  graph: CompiledGraph
  globalUniforms: Map<string, any>  // time, mouse, resolution, etc.
  surfaces: Map<string, DoublBufferedSurface>
  activePass: number                // Current pass index (for debugging)
  errors: FrameError[]
}

interface FrameError {
  code: string
  pass?: string
  timestamp: number
  recovered: boolean                // True if execution continued
}

15. Determinism Guarantees

  • Hash of sorted pass list + resource allocation signature MUST remain stable given identical effect + screen size.

  • Shaders compiled with identical program + defines produce identical pipeline keys.

  • Ping-pong iteration ordering deterministic: iteration index appended to diagnostics path <passName>#<i>.


16. State Transition Specification

16.1 Hot Reload Protocol

When shader source or effect definition changes:

  1. Detect Change: File watch or manual reload triggers.

  2. Parse: Parse new definition; if parse fails, retain old graph and emit diagnostic.

  3. Validate: Run validation (Section 6); if fails, retain old graph.

  4. Compile Shaders: Compile new programs; compilation is async (WebGPU) or sync (WebGL).

  5. Atomic Swap: On next frame boundary, swap graph.current pointer to new compiled graph.

  6. Cleanup: Release old pipelines and unused textures after 2-frame delay (ensure no in-flight commands).

Frame Consistency: A frame MUST execute entirely with one graph version; no mid-frame swaps.

16.2 Error Recovery

On runtime error during execution:

  1. Catch: Wrap each pass dispatch in try-catch (JS) or error callback (GPU).

  2. Log: Record error in FrameState.errors with pass context.

  3. Skip Pass: Mark pass as failed; do not execute dependent passes this frame.

  4. Fallback Texture: Substitute error texture (magenta checkerboard) for failed pass outputs.

  5. Continue: Attempt remaining independent passes.

  6. Diagnostics: Emit structured error event to console/UI with pass name, error code, and shader line if applicable.

Validation errors MUST prevent graph execution entirely. Runtime errors (GPU out of memory, device lost) allow partial frame with degraded output.


17. Glossary

  • AST (Abstract Syntax Tree): The tree representation of the user’s DSL code produced by the Parser.

  • Logical Graph (Effect Chain): A high-level graph where nodes are Effect instances and edges represent data flow between Effects. Produced by the Semantic Analyzer.

  • Render Graph (Passes): A lower-level graph where Effects have been expanded into their constituent Render/Compute Passes. Produced by the Effect Expander.

  • Execution Plan (Linear Pass Schedule): A linear list of passes sorted topologically, with resources allocated and barriers inserted. This is what the runtime executes.

  • Compiled Graph: The final runtime object containing the Execution Plan, allocated resources, and compiled pipelines.