SDK Diagrams Home SDK Reference
SDK Diagrams

Technical Architecture

Implementation-level diagrams for every layer of the scheng SDK — from graph compilation through GPU execution, shader binding, control routing, texture formats, and distribution packaging.

// Diagram 01

Full System Architecture

Every component in a running scheng instrument. Inputs arrive on dedicated threads, control routes through ParamStore, the GPU render graph executes deterministically each frame, and output sinks are called after queue.submit().

INPUTS Syphon In NDI Receive Webcam Video File RTMP / RTSP CONTROL MIDI CC OSC ParamStore targets → values step_frame() smooth=0.05 each tick GPU CORE — WgpuRuntime Hot-Reload AssetWatcher naga GLSL → SPIR-V pipeline cache Shader Graph NodeConfig / frame uniforms HashMap input_textures[4] iChannel0–3 Render Pipeline Rgba16Float targets MSAA 1× / 4× / 8× FrameBlock UBO CustomBlock UBO bind groups / pass topoological order CommandEncoder queue.submit() one batch / frame Metal / DX12 / Vulkan OutputSinks PreviewSink SyphonSink NdiSink FfmpegSink SpoutSink (Win) present() called after submit() non-blocking drop-safe channels trait OutputSink about_to_wait() → tick() → execute_frame() → queue.submit() → present() OUTPUTS Syphon Out NDI Out RTMP Stream RTSP Stream Record MP4 Preview Window Texture inputs (dedicated threads) Control → ParamStore GPU execution (render thread) Output sinks (post-submit)

Non-blocking I/O

All input sources (webcam, video, NDI, Syphon, RTMP) run on dedicated threads and communicate via bounded channels. The GPU render thread never blocks waiting for input — when a source falls behind, frames drop gracefully using try_send semantics.

Single CommandEncoder per frame

All node render passes are encoded into one CommandEncoder and submitted as a single batch via queue.submit(). Output sinks are only called after submission — never before. This is the most common source of all-black output bugs.

ParamStore is threadsafe

Wrapped in Arc<Mutex<ParamStore>>. MIDI callbacks and OSC threads write targets from their threads. The render thread calls step_frame() and reads smoothed values. The mutex is held briefly — never across a frame boundary.

Engine vs. instrument boundary

Everything inside the GPU Core box is the engine. Everything outside — graph construction, time, MIDI, device management — belongs to the instrument. The engine does not own time, parse protocols, or manage hardware.


// Diagram 02

Layer Model & Crate Boundaries

scheng is organized into five isolated layers. No layer reaches backward across a boundary. This isolation is the primary guarantee of deterministic execution and long-term SDK stability.

LAYER 01 Instrument Owns: graph construction · time · MIDI/OSC parsing · device management · output routing your application — wraps the engine, defines the instrument LAYER 02 scheng-graph Owns: node topology · port definitions · connection validation · compile() → ExecutionPlan No GPU. No behavior. Topology only. Static after compile(). LAYER 03 scheng-runtime-wgpu Owns: execute_frame() · render targets · pipeline cache · compat layer · CustomBlock Deterministic executor. Does not own time, topology, or control protocols. LAYER 04 scheng-param-store Owns: ParamStore · ParamSchema · smoothing · MIDI CC index · OSC address index Control bridge. Maps external signals to NodeConfig.uniforms. No protocol parsing. LAYER 05 I/O Crates (edge) Owns: device polling · texture upload · OutputSink impl · protocol-specific code scheng-input-* · scheng-output-* — evolve independently without breaking core guarantees.
ARCHITECTURAL RULE No layer reaches backward. Topology (scheng-graph) never calls the runtime. The runtime never parses MIDI. I/O crates never mutate graph topology. All interaction is explicit and flows downward through the layer stack.

// Diagram 03

Per-Frame Execution Lifecycle

Every frame executes in exactly this sequence. The hot path avoids allocation, shader recompilation, and blocking I/O. GPU commands are batched into a single CommandEncoder and submitted once.

01 Poll Inputs webcam.poll() vid.upload_frame() syphon.poll() → Arc<Texture> 02 Step Params store.step_frame() targets → values smooth each param u_tbar, u_mix … 03 Build Configs NodeConfig / node frag_shader src uniforms HashMap input_textures[4] 04 GPU Render topo sort order RenderPass/node Rgba16Float target MSAA if active 05 Submit queue.submit() one batch GPU executes async CPU continues 06 Present Sinks sink.present() preview blit · Syphon NDI push · ffmpeg pipe all after submit() ✓ hot path — no allocation · no blocking I/O · no shader recompilation
CRITICAL ORDER — submit before present queue.submit() must be called before any sink's present(). Calling present() before submission reads from a render target that hasn't been written yet — all pixels will be black or from the previous frame. execute_frame() enforces this order automatically.

// Diagram 04

Shader Compat Layer

The compat layer processes your raw GLSL 330 source before GPU compilation. It strips standard uniform declarations (injecting them via UBO instead), rewrites iChannel sampler references, and collects custom u_* uniforms for the CustomBlock.

User GLSL Source #version 330 core uniform float uTime; ← strip uniform vec2 uResolution; ← strip uniform sampler2D iChannel0; ← strip uniform float u_tbar; ← collect uniform float u_mix; ← collect void main() { vec4 a = texture(iChannel0, v_uv); ← rewrite fragColor = mix(a, b, u_tbar); } process() Compat Operations 1. Strip #version 2. Strip std uniforms uTime / uResolution uFrame / iChannelN 3. Collect u_* names → custom_uniform_names 4. Strip u_* declarations 5. Rewrite iChannelN → sampler2D(tex, samp) 6. Inject COMPAT_HEADER FrameBlock + CustomBlock 7. Prepend + user body Processed GLSL #version 450 core layout(binding=5) uniform FrameBlock { vec2 uResolution; float uTime; uint uFrame; }; layout(binding=6) uniform CustomBlock { float u_tbar; float u_mix; }; // iChannel0–3 samplers injected void main() { vec4 a = texture( sampler2D(iChannel0_tex, iSampler), v_uv); fragColor = mix(a,b,u_tbar); } naga SPIR-V / MSL compiled shader module cached by hash lazy — first use only recompile on src change Metal / DX12 / Vulkan

Binding layout

Binding 0–3: iChannel0–3 texture views. Binding 4: iSampler. Binding 5: FrameBlock UBO (uResolution, uTime, uFrame). Binding 6: CustomBlock UBO (all u_* uniforms in declaration order).

Pipeline cache key

Pipelines are cached by (shader_source_hash, sample_count). Changing NodeConfig.frag_shader invalidates the cache for that node. MSAA level changes also recompile. All other NodeConfig changes are free — no GPU work.


// Diagram 05

MIDI → ParamStore → Shader

The complete data path from a physical MIDI controller to a GPU uniform value. Three separate threads, two handoffs, one smoother.

MIDI THREAD RENDER THREAD — every tick MIDI Controller CC1 = 64 any hardware midir callback msg[0]=0xB0 cc=1 val=64 lock() ParamStore set_by_midi_cc(1, 64) targets["u_tbar"]=0.5 step_frame() / tick val += (target-val) * (1 - smooth) NodeConfig uniforms["u_tbar"] = store.get("u_tbar") CustomBlock GPU binding 6 UBO float u_tbar = 0.502 GLSL Shader uniform float u_tbar; void main() { mix(a, b, u_tbar) } per-pixel GPU smooth=0.05 → reaches target in ~20 frames at 30fps — silky fader response, no stepping artifacts
STEP_FRAME() IS MANDATORY set_by_midi_cc() writes to targets. get() reads from values. They are different HashMaps. The smoother only advances values toward targets when step_frame() is called. Without it, MIDI input appears to have no effect.

// Diagram 06

Texture Formats & Precision

Input textures arrive as 8-bit. Internal render targets use 16-bit half-float. FFmpeg output converts back to 8-bit YUV with bt.709 color tagging. Each stage has a specific format for a specific reason.

STAGE FORMAT PRECISION WHY Webcam / Video / NDI Rgba8Unorm 256 values/channel 8-bit per channel Hardware delivers 8-bit. No benefit to 16-bit here. GPU Render Targets Rgba16Float 65,536 values/channel 16-bit half-float Eliminates quantization banding across passes. Syphon Input (macOS) Rgba8Unorm 8-bit after BGRA→RGBA swap in Metal bridge getBytes copies from Metal. Channel swap is CPU-side. FFmpeg Output YUV420P bt.709 f16→u8 in readback 8-bit YUV broadcast std color_range tv H.264 requires YUV420P. bt.709 prevents SD shift. Preview Window sRGB surface (auto) display native wgpu selects for display hardware.
// From executor.rs — bt.709 FFmpeg flags applied automatically
"-colorspace", "bt709",
"-color_primaries", "bt709",
"-color_trc", "bt709",
"-color_range", "tv"

// Diagram 07

Distribution Bundle Structure

A scheng instrument compiles to a native binary. Each platform requires a different packaging format. The binary, framework/DLL dependencies, and shader assets must all be co-located so the instrument finds them at runtime without environment variables.

cargo build --release target/release/my-instrument[.exe] macOS Windows Linux MyInstrument.app/Contents/ MacOS/my-instrument ← binary Frameworks/Syphon.framework Frameworks/libndi.dylib if NDI Resources/assets/shaders/ install_name_tool fix rpaths codesign --deep "Developer ID..." xcrun notarytool submit --wait xcrun stapler staple → MyInstrument.dmg MyInstrument/ (folder) my-instrument.exe ← binary Processing.NDI.Lib.x64.dll NDI Spout.dll if Spout (coming soon) assets/shaders/ ffmpeg must be in PATH on target → MyInstrument-win.zip MyInstrument.AppDir/ usr/bin/my-instrument ← binary usr/bin/assets/shaders/ AppRun ← entry point script requires Vulkan driver appimagetool bundles → single exe → my-instrument.AppImage All platforms: asset_dir() resolves shaders relative to binary macOS: checks Contents/Resources/ first · others: uses binary directory · include_str!() fallback baked into binary
// Hybrid asset resolution — works in dev AND distributed bundle
fn asset_dir() -> std::path::PathBuf {
    let exe = std::env::current_exe().unwrap();
    let dir = exe.parent().unwrap();
    // macOS .app: binary is in Contents/MacOS/, assets in Contents/Resources/
    let resources = dir.parent().unwrap().join("Resources");
    if resources.exists() { resources } else { dir.to_path_buf() }
}

// Embedded fallback so the binary always works, even without shader files
const MAIN_FRAG: &str = include_str!("../assets/shaders/main.frag");

cfg.frag_shader = std::fs::read_to_string(asset_dir().join("assets/shaders/main.frag"))
    .ok()
    .or_else(|| Some(MAIN_FRAG.to_string()));
← Back to SDK Reference