About vvvv-custom-nodes
vvvv-custom-nodes is a skill guide for implementing custom nodes in vvvv gamma with C#. It explains node importing, lifecycle, pin design, and runtime behavior for developers building node libraries.
Key features
- 01Explain ProcessNode lifecycle and behavior
- 02Configure assembly imports for node visibility
- 03Design Update signatures and pins
- 04Handle live reload and resource cleanup
- 05Set pin visibility and default values
Use cases
- 01Implement stateful nodes with per-frame updates
- 02Expose C# libraries in the vvvv node browser
- 03Avoid allocations in frequently updated nodes
- 04Diagnose pin defaults and live-reload behavior
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ tebjan/ vvvv-skills --skill vvvv-custom-nodesAfter installing, type/vvvv-custom-nodesin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
advanced.mddocument-persistence.mdexamples.mdregions.mdservices.mdtemplates/stateful-node.csstateless-node.csDetails
From the project READMEWriting Custom Nodes for vvvv gamma
What [ProcessNode] actually does (and what it does NOT do)
Important reality check. vvvv imports every public class, struct, enum, method, and property from the assembly's configured namespaces — [ProcessNode] does NOT control whether a class becomes a node. A plain public class Foo { public int Bar(int x) => x; } will appear in the node browser exactly the same way as one decorated with [ProcessNode]. The attribute affects how the runtime treats the class, not whether it gets imported.
What [ProcessNode] DOES:
- Tells vvvv "this is a stateful node — keep ONE instance alive per node in the patch and call its
Update()method each frame". - Lets you set
Name = "..."(rename for the node browser),Category = "..."(override the assembly's category), andHasStateOutput = true(expose the instance as an output pin). - Engages live reload,
IDisposablecleanup, and theNodeContextconstructor injection.
What [ProcessNode] does NOT do:
- It does NOT make a class visible to vvvv. Visibility comes from the C#
publicaccess modifier plus an[assembly: ImportAsIs/ImportNamespace/ImportType]attribute. See vvvv-node-libraries for the import rules. - It does NOT hide internal helpers. If a helper is
publicand its namespace is imported, it's a node. To hide one: make itinternal(removes it from VL entirely), or keep it public and tag it[Smell(SymbolSmell.Internal)]/[Smell(SymbolSmell.Hidden)](stays usable as a pin type, just isn't browsable). See advanced.md → Smell Attribute. - It does NOT change pin generation. Pin generation is driven by the
Update()method signature for[ProcessNode]classes, and by public method/property signatures for non-[ProcessNode]classes.
When to use [ProcessNode]: the class needs frame-by-frame updates, persistent state between frames, or IDisposable cleanup. When to skip [ProcessNode]: stateless utility classes, value types, static helper methods — leaving the attribute off avoids the per-frame Update() ceremony, and the public methods become operation nodes automatically.
ProcessNode Pattern — The Core Pattern
Before implementing a node, read a working sibling and reuse its public lifecycle and pin pattern. A normal stateful node should expose a compact Update interface; keep engine properties and setters in its implementation. Check the actual imported process and fragment list, not only the C# signature. A fragmented node factory deliberately exposes separate input setters/output getters; use it only when those independent operations are needed and accepted. Replacing it with one Update also changes when outputs are evaluated, so review scheduling side effects.
The canonical stateful C# node in vvvv gamma:
[ProcessNode]
public class MyTransform : IDisposable
{
private float _lastInput;
private float _cachedResult;
/// <summary>
/// Transforms input values with caching.
/// </summary>
public void Update(
out float output, // 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;
}
output = _cachedResult; // ALWAYS output cached data
}
public void Dispose() { /* cleanup */ }
}
Prerequisite: vvvv only sees a [ProcessNode] class (or any other public class) if its namespace is covered by an assembly-level import attribute — [assembly: ImportAsIs], [assembly: ImportNamespace], or [assembly: ImportType] for that specific class. Without one of those, the assembly's public API is invisible to the node browser entirely. Projects scaffolded by vvvv include [assembly: ImportAsIs] automatically. For multi-namespace libraries or hand-picked node lists, see vvvv-node-libraries — ImportAsIs is AllowMultiple = false, so you'll reach for ImportNamespace (multi-use) or ImportType (per-type) when one root attribute isn't enough.
Non-Negotiable Rules
[ProcessNode]metadata on every stateful node class; use a supportedProcessNodeFactorywhen compatibility requires aliases- 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.
State that belongs to the document
A Create pin is the standard boundary for persistent initial state. Restore its value in the constructor; when relevant runtime state changes, apply the change live and write the updated value back to that pin through the document model. This covers window bounds, interaction results, calibration and editable models, regardless of which subsystem caused the change. Keep supported VL value types; serialization to a String is optional. See document-persistence.md for ownership, typed writeback, update modes and Save/reopen behavior.
Class Naming vs Node Name
The rule is: users must never see "Node" in vvvv's node browser. How you achieve this:
Custom registration names use PascalCase without spaces, not just a renamed instance label. Preserve stock/imported names and readable pin labels. For public renames, preserve compatibility through aliases unless the owner explicitly requests a breaking migration without legacy code. In that case migrate owned selectors and authoring catalogs/tests before removing aliases; document that external saved patches must migrate too.
// 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.
⚠️ HasStateOutput also un-hides the class and all its public members as individual nodes.
ImportedSymbol.GetSmell() adds SymbolSmell.Hidden to the type whenever a
ProcessNodeAttribute is present and HasStateOutput == false — which is why a normal
[ProcessNode] class shows up as exactly one node and its Update/Dispose/properties do
not clutter the browser. Flipping HasStateOutput = true removes that automatic Hidden,
so the type entry plus every public member reappears as browsable nodes.
If you need the state output but not the member spam, either add
[Smell(SymbolSmell.Advanced)] to the noisy members, or avoid HasStateOutput entirely by
giving Update() an explicit out parameter for the thing downstream nodes need. Note that
switching an existing node away from HasStateOutput is a breaking change — saved patches
reference the pin as Kind="StateOutputPin" and will red-node.
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)
}
Collection Inputs
A collection input is IReadOnlyList<T> when the node needs the count or index access, otherwise
IEnumerable<T>. Never Spread<T>: a spread still connects to both, and so does every other
collection. Outputs stay Spread<T>.
Pin Groups (Collection Inputs)
For a variable number of inputs with add/remove buttons. VL.StandardLibs declares collection pin
groups as Spread<T>; this is the only place a Spread<T> parameter appears:
public void Update(
out float result,
[Pin(Name = "Input", PinGroupKind = PinGroupKind.Collection, PinGroupDefaultCount = 2)]
Spread<IRenderer?> input)
{ }
DefaultValue for Complex Types
Check the resolved pin defaults, not just declarations: nonliteral attributes must convert to the intended typed value, and authored factories must give matching values to both pin descriptions and runtime input storage. Shared pins on sibling nodes should share defaults from one definition. Treat zero/empty colors, vectors and matrices as intentional only when their meaning is defined; preserve deliberate sentinel values and explicit patch overrides.
For defaults that cannot be C# literal expressions:
public void Update(
[DefaultValue(typeof(Color4), "R:0.1 G:0.1 B:0.15 A:1")] Color4 clearColor,
[DefaultValue(typeof(Vector3), "X:0 Y:3 Z:0")] Vector3 position,
[DefaultValue(typeof(Int2), "1920, 1080")] Int2 size,
bool clear = true)
{ }
In Stride 4.2.1, Color4Converter and Vector3Converter parse named floating
components: R:... G:... B:... A:... and X:... Y:... Z:..., respectively.
Vector field names are case-sensitive. Comma lists are invalid for these types;
DefaultValueAttribute can swallow conversion errors and leave Value null,
silently importing black/zero defaults. Verify the actual loaded attribute's
Value against the intended struct, not just its source string. Other types use
their own registered converter; do not generalize this grammar to every struct.
Attributes — when you actually need them
Verified against VL.StandardLibs/VL.Core/src/Import/ source. Most attribute properties have sensible defaults — set them only when you want to override the default. Setting an attribute property to its default value is just visual noise.
[ProcessNode] — class-level (AllowMultiple = false)
| Property | Default | Set it when… |
|---|---|---|
Name | C# class name | …the node's user-facing name should differ from the class name. If Name would equal the class name, omit it. |
Category | from assembly-level import attribute | …you want this specific class to land in a different category than the rest of the assembly. |
HasStateOutput | false | …downstream nodes need access to the node instance itself (e.g. to call methods on it). |
FragmentSelection | Implicit (all public members become fragments) | …you want only members marked [Fragment] to be exposed. |
StateOutputNotVisibleByDefault | false | …you have a state output but want it hidden until the user enables it. |
Summary, Remarks, Tags | null | These are alternatives to XML doc comments; prefer the comments unless you need programmatic access. |
So [ProcessNode] (bare) is the right default for the common case where the class name = node name and category falls out of the assembly attribute. Adding [ProcessNode(Name = "Foo", Category = "Bar")] is only useful when those values diverge from what the defaults would produce.
[Pin] — parameter / property / return-value (AllowMultiple = false)
| Property | Default | Set it when… |
|---|---|---|
Name | the parameter name (vvvv applies title-casing automatically) | …you need a name vvvv can't derive — e.g. spaces, punctuation, or a name unrelated to the parameter. [Pin(Name = "input")] on a parameter input is redundant — vvvv already shows it as Input. |
Visibility | PinVisibility.Visible | …you want the pin Optional (collapsible) or Hidden (inspector-only). |
Exposition | PinExpositionMode.Local | …you want the pin auto-exposed on parent patches (InfectPatch / Expose). |
PinGroupKind | PinGroupKind.None | …the parameter is a Spread<T> that should appear as an add/remove pin group. |
PinGroupDefaultCount | 0 | …a pin group should start with N entries. |
PinGroupEditMode | None | …you want to limit add/remove to one direction only. |
Output names: instance state versus method result
[ProcessNode(HasStateOutput = true)]exposes the class instance as Output automatically. Do not add a return attribute to name that state pin. If a fragment already defines a non-state Output, the instance pin becomes State Output.HasStateOutputdefaults to false. An ordinary unannotated mutable-classUpdate()return is a separate Result, even if its implementation returnsthis.- Static operation returns usually become Result. Without an explicit return pin attribute, the importer can derive Output when the first input type matches the return type. Inspect the generated surface before overriding it.
outparameters use their parameter names:out T outputbecomes Output. For an authored node's main method result, use[return: Pin(Name = "Output")]only when the generated name would otherwise differ. Secondary outputs keep meaningful names such as Texture and Error. Preserve imported/stock names.
These are importer rules (ProcessUtils.GetOutputs, ImportedSymbol.CreateOutputs),
not an instruction to decorate every instance method. For an instance-state output,
prefer HasStateOutput and a void Update over returning this just to expose it.
[DefaultValue] — parameter (from System.ComponentModel)
NOT a vvvv attribute. Set it only for value types where the literal isn't expressible as a C# default — Color4, Vector3, Int2, etc. For int, float, bool, string, prefer the C# parameter default (int x = 5).
⚠️ Never combine [DefaultValue(...)] with = default on the same parameter — the C# default wins and your typed default becomes black/zero/null. See the dedicated section above.
[Fragment] — member-level (AllowMultiple = false)
Only relevant when [ProcessNode(FragmentSelection = FragmentSelection.Explicit)] is set on the class. With the default Implicit selection, [Fragment] is unnecessary noise.
| Property | Default | Set it when… |
|---|---|---|
Order | 0 (declaration order) | …you need a specific fragment order independent of source layout. |
IsHidden | false | …a member should NOT become a fragment despite being public. |
IsDefault | false | …this fragment should run at the default moment when nothing else is wired. |
Rule of thumb
- If the attribute has only default values, don't write the attribute.
- If you only need ONE non-default property, write only that one (e.g.
[Pin(Visibility = PinVisibility.Optional)]). - The presence of an attribute should always communicate "I'm overriding a default", never "I'm restating the default".
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;
[return: Pin(Name = "Output")]
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:
[return: Pin(Name = "Output")]
public ReadOnlySpan<ParticleState> Update(
SimulationConfig config,
float deltaTime,
out TimingStats stats) // Secondary output via out
{
// Returns primary output, secondary via out
}
Bang Detection
Use vvvv's term bang in pin help text and node documentation. A bang is a
one-frame true pulse. For a trigger that may stay true, produce a bang once
when it changes from false to true:
private bool _lastTrigger;
public void Update(out bool triggered, bool trigger = false)
{
triggered = trigger && !_lastTrigger; // bang: false -> true, this frame 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 runtime state persisted through typed Create-pin values in the owning document, see document-persistence.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/.
Keep this knowledge growing
Leave the next patcher one fewer mystery. On first use of any skill in this collection with no successful check in seven days, compare the source with tebjan/vvvv-skills. If no record exists, check once. Share one local check timestamp/revision across these skills; do not poll per invocation. Preserve local extensions and pinned project versions; checking is not permission to install updates. If offline, record the deferred check and continue the task.
When actual work exposes a gap, capture the tested vvvv/package versions, a minimal reproduction or authoritative source, and the observed result. Treat other users' reports as leads, not facts until verified. Improve the smallest relevant instruction; do not turn one example into a universal rule. Redact private data and obtain publishing authorization before submitting upstream. See the contribution policy. Merged improvements reach users through their installer/update workflow, not through a local edit or an automatic overwrite of everyone's skills.