关于 vvvv-custom-nodes
vvvv-custom-nodes 是一份使用 C# 为 vvvv gamma 实现自定义节点的技能指南,说明节点导入、生命周期、引脚设计和运行时行为,面向构建节点库的开发者。
主要功能
- 01说明 ProcessNode 生命周期与行为
- 02配置程序集导入以控制节点可见性
- 03设计 Update 签名和引脚
- 04处理实时重载与资源释放
- 05设置引脚可见性和默认值
适用场景
- 01实现逐帧更新的有状态节点
- 02让 C# 库显示在 vvvv 节点浏览器中
- 03避免频繁更新节点产生内存分配
- 04排查引脚默认值和实时重载行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tebjan/ vvvv-skills --skill vvvv-custom-nodes安装后在 Claude Code 中输入/vvvv-custom-nodes确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
advanced.mddocument-persistence.mdexamples.mdregions.mdservices.mdtemplates/stateful-node.csstateless-node.cs详细介绍
为 vvvv gamma 编写自定义节点
[ProcessNode] 实际上做什么(以及它不会做什么)
重要的现实提醒。 vvvv 会从程序集配置的命名空间中导入每个 public 类、结构体、枚举、方法和属性——[ProcessNode] 不会控制类是否会成为节点。普通的 public class Foo { public int Bar(int x) => x; } 与添加了 [ProcessNode] 的类一样,都会出现在节点浏览器中。此特性影响的是运行时如何处理该类,而不是该类是否会被导入。
[ProcessNode] 会做什么:
- 告诉 vvvv:“这是一个有状态的节点——在补丁中为每个节点保留一个实例,并在每一帧调用它的
Update()方法。” - 允许你设置
Name = "..."(重命名节点浏览器中的名称)、Category = "..."(覆盖程序集的类别)以及HasStateOutput = true(将实例公开为输出引脚)。 - 启用热重载、
IDisposable清理和NodeContext构造函数注入。
[ProcessNode] 不会做什么:
- 它不会让类对 vvvv 可见。可见性由 C#
public访问修饰符以及[assembly: ImportAsIs/ImportNamespace/ImportType]特性决定。导入规则请参阅 vvvv-node-libraries。 - 它不会隐藏内部辅助项。如果辅助项是
public,且其命名空间已导入,它就是一个节点。若要隐藏它:将其设为internal(使其完全不出现在 VL 中),或者保留为public并为其添加[Smell(SymbolSmell.Internal)]/[Smell(SymbolSmell.Hidden)](仍可用作引脚类型,只是不在浏览器中显示)。请参阅 advanced.md → Smell Attribute。 - 它不会改变引脚生成方式。对于
[ProcessNode]类,引脚由Update()方法签名生成;对于非[ProcessNode]类,则由公共方法和属性签名生成。
何时使用 [ProcessNode]: 类需要逐帧更新、在帧之间保留状态,或执行 IDisposable 清理。何时跳过 [ProcessNode]: 无状态实用类、值类型、静态辅助方法——省略此特性可免去逐帧调用 Update() 的流程,公共方法也会自动成为操作节点。
ProcessNode 模式——核心模式
实现节点之前,先阅读一个可正常工作的同类节点,并复用其公共生命周期 和引脚模式。常见的有状态节点应提供精简的 Update 接口; 将引擎属性和 setter 放在其实现中。检查实际导入的 process 和 fragment 列表, 不要只看 C# 签名。fragmented node 工厂会有意公开独立的输入 setter 和输出 getter; 只有在需要且接受这些独立操作时才使用它。将其替换为一个 Update 也会改变输出的求值时机, 因此请检查调度方面的副作用。
vvvv gamma 中规范的有状态 C# 节点:
[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 */ }
}
前提条件:只有当程序集级导入特性覆盖了 [ProcessNode] 类(或任何其他公共类)所在的命名空间时,vvvv 才能看到它——可以是 [assembly: ImportAsIs]、[assembly: ImportNamespace],或针对特定类的 [assembly: ImportType]。如果没有这些特性之一,节点浏览器将完全看不到程序集的公共 API。vvvv 生成的项目模板会自动包含 [assembly: ImportAsIs]。对于多命名空间库或手动挑选的节点列表,请参阅 vvvv-node-libraries——ImportAsIs 的 AllowMultiple = false,因此当单个根特性不够用时,你需要使用可多次应用的 ImportNamespace,或按类型指定的 ImportType。
不可妥协的规则
- 每个有状态节点类都必须使用
[ProcessNode]元数据;需要别名以确保兼容性时,使用受支持的ProcessNodeFactory - vvvv 中可见的名称不能包含「Node」——vvvv 中的所有内容本来就是节点,因此「Node」后缀是多余的
out参数必须放在最前面,带默认值的值输入放在后面- 在类和 Update 方法上添加 XML 注释(在 vvvv 中显示为工具提示)
- Update 中绝不分配内存——不使用
new或 LINQ,所有内容都要缓存 - 检测变化——仅当输入实际发生变化时才重新计算
- 始终输出最新数据——即使没有执行任何操作,也要输出缓存的结果
- 避免不必要的公共成员——数据流仅通过 Update 的输入/输出参数传递
- 任何持有原生/非托管资源的节点都必须实现
IDisposable
实时重新加载行为
当 .vl 文档引用 .csproj 源项目时,vvvv 会在运行时通过 Roslyn 编译 C#。保存 .cs 文件时,vvvv 会重新编译并重启受影响的节点:
- 对当前实例调用
Dispose()(如果实现了IDisposable) - 使用全新的
NodeContext运行新构造函数 - 在下一帧恢复调用
Update()
对节点作者的影响:
- 实例字段会重置——运行期间累积的任何状态(缓存、计数器、连接)都会在代码更改时丢失。这是预期行为。
- 静态字段也会重置——整个内存中的程序集都会被替换。不要依赖静态状态在编辑后保留。
- Dispose 必须彻底释放资源——必须释放原生句柄、网络连接和 GPU 资源。开发期间反复重新加载会导致泄漏累积。
- 构造函数必须快速执行——每次保存都会运行构造函数。应使用
_needsInit标志,将繁重的初始化延后到首次调用Update()时执行。 - 上述缓存和变化检测规则的部分原因也在于此:你的代码运行在一个永不停止的程序中。在可能连续运行数小时的 60 FPS 循环中,
Update()中的内存分配会增加 GC 压力。
属于文档的状态
Create 引脚是持久化初始状态的标准边界。在构造函数中恢复其值;相关运行时状态发生变化时,实时应用更改,并通过文档模型将更新后的值写回该引脚。这适用于窗口边界、交互结果、校准和可编辑模型,无论变化由哪个子系统引起。请使用受支持的 VL 值类型;序列化为 String 是可选的。有关所有权、类型化写回、更新模式以及保存/重新打开行为,请参阅 document-persistence.md。
类名与节点名称
规则是:用户绝不能在 vvvv 的节点浏览器中看到「Node」。实现方式如下:
自定义注册名称应使用无空格的 PascalCase,而不是仅重命名实例标签。保留原版/导入的名称和易读的引脚标签。对于公开重命名,除非所有者明确要求进行不带旧代码的破坏性迁移,否则应通过 别名 保持兼容性。在这种情况下,应先迁移自有选择器和创作目录/测试,再移除别名;并说明外部已保存的补丁也必须迁移。
// 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 — 暴露节点实例
[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, ...) { ... }
}
另一种做法:从方法返回 this,以暴露实例。
⚠️ HasStateOutput 还会取消隐藏类及其所有公共成员,并将它们分别显示为节点。
只要存在 ProcessNodeAttribute,ImportedSymbol.GetSmell() 就会在 类型 上添加 SymbolSmell.Hidden,前提是 HasStateOutput == false。因此,普通的 [ProcessNode] 类只会显示为一个节点,其 Update/Dispose/属性不会挤满浏览器。将 HasStateOutput = true 后,会移除自动添加的 Hidden,于是类型条目及其所有公共成员都会重新显示为可浏览节点。
如果需要状态输出,但不想让成员过多,可以给那些杂乱的成员添加 [Smell(SymbolSmell.Advanced)],或者完全不使用 HasStateOutput,而是给 Update() 添加一个显式的 out 参数,用于传递下游节点所需的内容。请注意,将现有节点从 HasStateOutput 切换为其他方式属于破坏性变更——已保存的补丁会引用 Kind="StateOutputPin" 形式的引脚,并显示为红色节点。
引脚可见性
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)
}
集合输入
如果节点需要获取数量或按索引访问集合,则使用 IReadOnlyList<T>;否则使用 IEnumerable<T>。不要使用 Spread<T>:Spread<T> 仍可连接到这两种类型,其他所有集合类型也一样。输出仍使用 Spread<T>。
引脚组(集合输入)
用于数量可变的输入,并提供添加/移除按钮。VL.StandardLibs 将集合引脚组声明为 Spread<T>;这是唯一使用 Spread<T> 参数的地方:
public void Update(
out float result,
[Pin(Name = "Input", PinGroupKind = PinGroupKind.Collection, PinGroupDefaultCount = 2)]
Spread<IRenderer?> input)
{ }
复杂类型的 DefaultValue
检查解析后的引脚默认值,而不只是声明:非字面量特性必须转换为预期的类型化值,而手动编写的工厂必须为引脚描述和运行时输入存储提供匹配的值。同级节点上的共享引脚应从同一个定义获取默认值。只有在含义明确时,才将零值/空颜色、向量和矩阵视为有意设置;保留刻意设置的哨兵值和显式补丁覆盖值。
对于无法用 C# 字面量表达式表示的默认值:
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)
{ }
在 Stride 4.2.1 中,Color4Converter 和 Vector3Converter 会解析带名称的浮点分量:R:... G:... B:... A:... 和 X:... Y:... Z:...。向量字段名称区分大小写。这些类型不接受逗号分隔的列表;DefaultValueAttribute 可能会吞掉转换错误,并使 Value 保持为 null,从而在静默导入时产生黑色/零值默认值。请将实际加载的特性中的 Value 与预期结构体进行比对,而不只是检查其源字符串。其他类型使用各自注册的转换器;不要将这种语法规则推广到所有结构体。
特性——仅在确实需要时使用
已根据 VL.StandardLibs/VL.Core/src/Import/ 源码验证。大多数特性属性都有合理的默认值——只有需要覆盖默认值时才设置它们。将特性属性设为其默认值只会造成视觉干扰。
[ProcessNode] — 类级别(AllowMultiple = false)
| 属性 | 默认值 | 在以下情况下设置…… |
|---|---|---|
Name | C# 类名 | …节点面向用户显示的名称应与类名不同。如果 Name 与类名相同,请省略。 |
Category | 来自程序集级导入特性 | …你希望此特定类归入与程序集其余部分不同的类别。 |
HasStateOutput | false | …下游节点需要访问节点实例本身(例如调用其方法)。 |
FragmentSelection | Implicit(所有公共成员都会成为片段) | …你只想公开标记了 [Fragment] 的成员。 |
StateOutputNotVisibleByDefault | false | …你有状态输出,但希望在用户启用之前将其隐藏。 |
Summary、Remarks、Tags | null | 这些可替代 XML 文档注释;除非需要通过程序访问,否则优先使用注释。 |
因此,对于类名 = 节点名且类别由程序集特性确定这一常见情况,直接使用 [ProcessNode](不带参数)即可。只有当这些值与默认生成的值不同时,添加 [ProcessNode(Name = "Foo", Category = "Bar")] 才有用。
[Pin] — 参数 / 属性 / 返回值(AllowMultiple = false)
| 属性 | 默认值 | 在以下情况下设置…… |
|---|---|---|
Name | 参数名(vvvv 会自动将其转换为标题式大小写) | …你需要一个 vvvv 无法推导出的名称,例如包含空格、标点符号,或与参数无关的名称。对名为 input 的参数使用 [Pin(Name = "input")] 是多余的——vvvv 已会将其显示为 Input。 |
Visibility | PinVisibility.Visible | …你希望引脚为 Optional(可折叠)或 Hidden(仅在检查器中显示)。 |
Exposition | PinExpositionMode.Local | …你希望引脚自动暴露到父级补丁上(InfectPatch / Expose)。 |
PinGroupKind | PinGroupKind.None | …该参数是应显示为可添加 / 删除引脚组的 Spread<T>。 |
PinGroupDefaultCount | 0 | …引脚组应以 N 个条目开始。 |
PinGroupEditMode | None | …你希望将添加 / 删除操作限制为单一方向。 |
输出名称:实例状态与方法结果
[ProcessNode(HasStateOutput = true)]会自动将类实例作为 Output 暴露。不要添加返回值特性来命名该状态引脚。如果某个片段已定义非状态 Output,实例引脚将成为 State Output。HasStateOutput默认值为 false。未标注的可变类Update()返回值是独立的 Result,即使其实现返回this也是如此。- 静态操作的返回值通常会成为 Result。如果没有显式的返回值引脚特性,当第一个输入类型与返回类型匹配时,导入器可能会推导出 Output。覆盖之前,请检查生成的界面。
out参数使用其参数名:out T output会成为 Output。对于自定义节点的主方法结果,仅当生成的名称不同才使用[return: Pin(Name = "Output")]。次要输出保留有意义的名称,例如 Texture 和 Error。保留导入项 / 内置项的名称。
这些是导入器规则(ProcessUtils.GetOutputs、ImportedSymbol.CreateOutputs),并不意味着要为每个实例方法都添加特性。对于实例状态输出,优先使用 HasStateOutput 和 void Update,而不是仅为暴露实例而返回 this。
[DefaultValue] — 参数(来自 System.ComponentModel)
这不是 vvvv 特性。仅当值类型的字面量无法用 C# 默认值表达时才设置,例如 Color4、Vector3、Int2 等。对于 int、float、bool、string,优先使用 C# 参数默认值(int x = 5)。
⚠️ 切勿在同一参数上同时使用 [DefaultValue(...)] 和 = default——C# 的 default 会优先生效,使你的类型化默认值变成黑色 / 零 / null。请参阅上方专门章节。
[Fragment] — 成员级(AllowMultiple = false)
仅当类上设置了 [ProcessNode(FragmentSelection = FragmentSelection.Explicit)] 时才相关。使用默认的 Implicit 选择时,[Fragment] 是不必要的噪声。
| 属性 | 默认值 | 在以下情况下设置…… |
|---|---|---|
Order | 0(声明顺序) | ……你需要独立于源代码布局的特定片段顺序。 |
IsHidden | false | ……某个成员不应成为片段,即使它是公共成员也是如此。 |
IsDefault | false | ……当没有其他内容连接时,你希望此片段在默认时机运行。 |
经验法则
- 如果特性只有默认值,就不要写这个特性。
- 如果你只需要一个非默认属性,只写那一个(例如
[Pin(Visibility = PinVisibility.Optional)])。 - 特性的存在应始终表示“我正在覆盖默认值”,而不是“我正在重述默认值”。
构造函数模式
简单节点(无需特殊上下文):
public MyNode() { }
需要 NodeContext 的节点(用于 AppHost、服务、Fuse 着色器图):
public MyNode(NodeContext nodeContext)
{
_nodeContext = nodeContext;
// Access: nodeContext.AppHost.IsExported, nodeContext.AppHost.Services, etc.
}
有关 IFrameClock 注入、Stride 游戏访问、日志记录和服务使用模式,请参阅 services.md。
变更检测模式
简单模式 — 直接比较字段
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;
}
多输入 — 哈希检查
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;
}
引用类型 — 标识检查
if (!ReferenceEquals(newBuffer, _lastBuffer))
{
ProcessBuffer(newBuffer);
_lastBuffer = newBuffer;
}
重建标志 — 用于管线/效果状态
当多个 setter 都可能使状态失效时:
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;
}
快速参考
| 输入类型 | 比较方式 | 备注 |
|---|---|---|
| 值类型(int、float、bool) | != | 直接比较 |
| 引用类型(对象) | ReferenceEquals() | 比较标识,而非相等性 |
| 多个输入 | HashCode.Combine() | 使用单个哈希值进行脏检查 |
| 集合 | 长度 + 抽样元素 | 完整比较的开销太大 |
| 多个 setter | _needsRebuild 标志 | 在 setter 中设置标志,在 Update 中检查 |
基于返回值的输出(单个输出)
当节点有一个主要输出时,可以直接返回它,而不是使用 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;
}
}
当你有一个主要输出和其他输出时,可将返回值与 out 混用:
[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 检测
在 pin 帮助文本和节点文档中使用 vvvv 的术语 bang。bang 是一个持续一帧的 true 脉冲。对于可能持续为 true 的触发器,应在其从 false 变为 true 时生成一次 bang:
private bool _lastTrigger;
public void Update(out bool triggered, bool trigger = false)
{
triggered = trigger && !_lastTrigger; // bang: false -> true, this frame only
_lastTrigger = trigger;
}
操作节点
vvvv 会从所有公共 C# 方法自动生成节点,无需特性。不要为仅转发调用的简单方法创建 [ProcessNode] 包装器。结构体的 Split() 方法也会自动成为节点。
静态方法会自动生成节点,无需 [ProcessNode]:
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);
}
}
如何选择
| 模式 | 使用场景 |
|---|---|
[ProcessNode] 类 | 管理帧间状态、缓存和脏检查 |
| 静态方法 | 纯函数、无状态,将输入转换为输出 |
结构体 + Split() | 解包为独立 pin 的数据容器 |
更新循环中的内存与性能
- 热路径中不要使用
new关键字 - 不要使用 LINQ(
.Where、.Select、.ToList)——枚举器会导致隐式分配 - 缓存集合——预先分配并重复使用数组/列表
- 不要拼接字符串——如有需要,使用
StringBuilder,但要避免在热路径中使用 - 向量类型:内部使用
System.Numerics进行 SIMD 运算,在 API 边界使用Stride.Core.Mathematics - 零开销转换:
Unsafe.As<StrideVector3, NumericsVector3>(ref val)
关于自定义区域(基于委托的、ICustomRegion、IRegion<TInlay>),请参阅 regions.md。 关于高级模式(FragmentSelection、Smell、Dynamic Enums、Settings/Split、Pin Name Derivation),请参阅 advanced.md。 关于通过所属文档中的强类型 Create 引脚值持久化运行时状态,请参阅 document-persistence.md。 关于服务的使用(IFrameClock、Game、Logging),请参阅 services.md。 关于从 C# 节点使用公共通道,请参阅 vvvv-channels。 代码示例请参阅 examples.md。 起始模板请参阅 templates/。
让知识持续增长
让下一个 patcher 少一个疑问。在首次使用本集合中的任意技能时,如果过去七天内没有成功检查记录,请将源内容与 tebjan/vvvv-skills 进行比较。如果没有记录,只检查一次。 在这些技能之间共享一个本地检查时间戳/修订版本;不要在每次调用时轮询。 保留本地扩展和固定的项目版本;检查并不代表获准安装更新。如果离线,请记录延期检查并继续任务。
实际工作发现知识缺口时,记录经过测试的 vvvv/软件包版本、最简复现步骤或权威来源,以及观察到的结果。将其他用户的报告视为线索,在验证之前不要当作事实。改进最相关的最小范围指引;不要将一个示例推广为通用规则。提交上游之前,请删除私人数据并取得发布授权。请参阅贡献政策。 合并后的改进通过其安装程序/更新工作流触达用户,而不是通过本地编辑或自动覆盖所有人的技能。