Writing Custom Nodes for vvvv gamma
ProcessNode Pattern — The Core Pattern
Every stateful C# node in vvvv gamma uses [ProcessNode]:
[ProcessNode]
public class MyTransform : IDisposable
{
private float _lastInput;
private float _cachedResult;
/// <summary>
/// Transforms input values with caching.
/// </summary>
public void Update(
out float result, // OUT parameters FIRST
out string error, // More out params
float input = 0f, // Value inputs with defaults AFTER
bool reset = false)
{
error = null;
if (input != _lastInput || reset)
{
_cachedResult = ExpensiveComputation(input);
_lastInput = input;
}
result = _cachedResult; // ALWAYS output cached data
}
public void Dispose() { /* cleanup */ }
}
Prerequisite: [ProcessNode] only works if [assembly: ImportAsIs] is set on the assembly. Projects created by vvvv include this automatically. For library-level ImportAsIs with Namespace/Category parameters, see vvvv-node-libraries.
Non-Negotiable Rules
[ProcessNode]attribute on every stateful node class- No "Node" in the vvvv-visible name — everything in vvvv is already a node, so "Node" suffix is redundant
outparameters FIRST, value inputs with defaults AFTER- XML comments on class and Update method (shown as tooltip in vvvv)
- ZERO allocations in Update — no
new, no LINQ, cache everything - Change detection — only recompute when inputs actually change
- Always output latest data — even when no work is done, output cached result
- No unnecessary public members — data flows through Update in/out params only
IDisposablefor any node holding native/unmanaged resources
Live Reload Behavior
When the .vl document references a .csproj source project, vvvv compiles C# via Roslyn at runtime. On .cs file save, vvvv recompiles and restarts affected nodes:
Dispose()is called on the current instance (ifIDisposable)- The new constructor runs with a fresh
NodeContext Update()resumes on the next frame
Implications for node authors:
- Instance fields reset — any state accumulated during runtime (caches, counters, connections) is lost on code change. This is expected.
- Static fields also reset — the entire in-memory assembly is replaced. Do not rely on static state to survive edits.
- Dispose must be thorough — native handles, network connections, and GPU resources must be released. Leaks accumulate across reloads during development.
- Constructor must be fast — it runs each time you save. Defer heavy initialization to the first
Update()call using a_needsInitflag. - The rules about caching and change detection above exist partly because of this: your code runs in a program that never stops. Allocations in
Update()cause GC pressure in a 60 FPS loop that may run for hours.
Class Naming vs Node Name
The rule is: users must never see "Node" in vvvv's node browser. How you achieve this:
// Simple: class name IS the node name — no suffix needed
[ProcessNode]
public class Wander { } // vvvv shows: "Wander"
// Class has "Node" suffix + Name property strips it — also fine
[ProcessNode(Name = "Scan")]
public class ScanNode { } // vvvv shows: "Scan"
// Completely different internal name — fine when class manages another type
[ProcessNode(Name = "MeshRenderer", Category = "Stride.Rendering.Custom")]
public class CustomMeshRenderer { } // vvvv shows: "MeshRenderer"
HasStateOutput — Exposing Node Instance
[ProcessNode(HasStateOutput = true)]
public class ParticleSystem
{
// The node itself becomes an output pin,
// allowing downstream nodes to call methods on it
public void Update(out int particleCount, ...) { ... }
}
Alternative: return this from a method to expose the instance.
Pin Visibility
public void Update(
out Spread<float> result,
[Pin(Visibility = PinVisibility.OnlyInspector)] out string error,
float input = 0f,
[Pin(Visibility = PinVisibility.Optional)] bool advanced = false)
{
// PinVisibility values:
// Visible — always shown (default)
// Optional — user can show/hide
// Hidden — not visible, only via inspector
// OnlyInspector — only in inspector panel (use for debug/error outputs)
}
Pin Groups (Collection Inputs)
For Spread inputs with add/remove buttons in vvvv:
public void Update(
out float result,
[Pin(Name = "Input", PinGroupKind = PinGroupKind.Collection, PinGroupDefaultCount = 2)]
Spread<IRenderer?> input)
{ }
DefaultValue for Complex Types
For defaults that cannot be C# literal expressions:
public void Update(
[DefaultValue(typeof(Color4), "0.1, 0.1, 0.15, 1.0")] Color4 clearColor,
[DefaultValue(typeof(Int2), "1920, 1080")] Int2 size,
bool clear = true)
{ }
Constructor Patterns
Simple node (no special context):
public MyNode() { }
Node needing NodeContext (for AppHost, services, Fuse shader graphs):
public MyNode(NodeContext nodeContext)
{
_nodeContext = nodeContext;
// Access: nodeContext.AppHost.IsExported, nodeContext.AppHost.Services, etc.
}
For IFrameClock injection, Stride Game access, logging, and service consumption patterns, see services.md.
Change Detection Patterns
Simple — Direct Field Comparison
private float _lastParam;
private Result _cached;
public void Update(out Result result, float param = 0f)
{
if (param != _lastParam)
{
_cached = Compute(param);
_lastParam = param;
}
result = _cached;
}
Multi-Input — Hash Check
private int _lastHash;
private Config _cached;
public void Update(out Config config, float a = 0f, int b = 0, string c = "")
{
int hash = HashCode.Combine(a, b, c);
if (hash != _lastHash)
{
_cached = new Config(a, b, c);
_lastHash = hash;
}
config = _cached;
}
Reference Types — Identity Check
if (!ReferenceEquals(newBuffer, _lastBuffer))
{
ProcessBuffer(newBuffer);
_lastBuffer = newBuffer;
}
Rebuild Flag — For Pipeline/Effect State
When multiple setters can invalidate state:
private bool _needsRebuild = true;
public void SetShader(ShaderStage vs) { _shader = vs; _needsRebuild = true; }
public void Update(out Effect effect)
{
if (_needsRebuild)
{
RebuildPipeline();
_needsRebuild = false;
}
effect = _cachedEffect;
}
Quick Reference
| Input Type | Comparison | Notes |
|---|---|---|
| Value types (int, float, bool) | != | Direct comparison |
| Reference types (objects) | ReferenceEquals() | Identity, not equality |
| Multiple inputs | HashCode.Combine() | Single hash for dirty check |
| Collections | Length + sample elements | Full comparison too expensive |
| Multiple setters | _needsRebuild flag | Set flag in setters, check in Update |
Return-Based Output (Single Output)
When a node has a single primary output, you can return it directly instead of using out:
[ProcessNode]
public class NoiseSteering
{
private SteeringConfig? _cached;
public ISteering Update(
float strength = 2.0f,
float noiseFrequency = 0.05f,
int priority = 0)
{
if (_cached is null || _cached.Strength != strength ||
_cached.NoiseFrequency != noiseFrequency || _cached.Priority != priority)
{
_cached = new SteeringConfig(strength, noiseFrequency, priority);
}
return _cached;
}
}
Mix return + out when you have one primary output plus additional outputs:
public ReadOnlySpan<ParticleState> Update(
SimulationConfig config,
float deltaTime,
out TimingStats stats) // Secondary output via out
{
// Returns primary output, secondary via out
}
Rising Edge Detection (Bang/Trigger)
For boolean inputs that should trigger once (not every frame they're true):
private bool _lastTrigger;
public void Update(out bool triggered, bool trigger = false)
{
triggered = trigger && !_lastTrigger; // Rising edge only
_lastTrigger = trigger;
}
Operation Nodes
vvvv auto-generates nodes from all public C# methods — no attribute needed. Don't create [ProcessNode] wrappers for simple methods that just forward calls. Struct Split() methods also become nodes automatically.
Static methods auto-generate nodes — no [ProcessNode] needed:
public static class MathOps
{
public static float Remap(float value, float inMin = 0f, float inMax = 1f,
float outMin = 0f, float outMax = 1f)
{
float t = (value - inMin) / (inMax - inMin);
return outMin + t * (outMax - outMin);
}
}
When to Use Which
| Pattern | Use When |
|---|---|
[ProcessNode] class | Manages state between frames, caching, dirty-checking |
| Static method | Pure function, no state, transforms input to output |
Struct + Split() | Data containers that unpack into separate pins |
Memory & Performance in Update Loop
- No
newkeyword in hot paths - No LINQ (
.Where,.Select,.ToList) — hidden allocations via enumerators - Cache collections — pre-allocate, reuse arrays/lists
- No string concatenation — use
StringBuilderif needed, but avoid in hot path - Vector types:
System.Numericsinternally for SIMD,Stride.Core.Mathematicsat API boundaries - Zero-cost conversion:
Unsafe.As<StrideVector3, NumericsVector3>(ref val)
For custom regions (delegate-based, ICustomRegion, IRegion<TInlay>), see regions.md. For advanced patterns (FragmentSelection, Smell, Dynamic Enums, Settings/Split, Pin Name Derivation), see advanced.md. For service consumption (IFrameClock, Game, Logging), see services.md. For working with public channels from C# nodes, see vvvv-channels. For code examples, see examples.md. For starter templates, see templates/.