mcpField
V
vvvv-custom-nodes

vvvv-custom-nodes

github.com/tebjan开发3488 次安装2 天前更新
代码仓库

关于 vvvv-custom-nodes

vvvv-custom-nodes 是一份使用 C# 为 vvvv gamma 实现自定义节点的技能指南,说明节点导入、生命周期、引脚设计和运行时行为,面向构建节点库的开发者。

主要功能

  1. 01说明 ProcessNode 生命周期与行为
  2. 02配置程序集导入以控制节点可见性
  3. 03设计 Update 签名和引脚
  4. 04处理实时重载与资源释放
  5. 05设置引脚可见性和默认值

适用场景

  1. 01实现逐帧更新的有状态节点
  2. 02让 C# 库显示在 vvvv 节点浏览器中
  3. 03避免频繁更新节点产生内存分配
  4. 04排查引脚默认值和实时重载行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tebjan/vvvv-skills --skill vvvv-custom-nodes

安装后在 Claude Code 中输入/vvvv-custom-nodes确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

advanced.md
document-persistence.md
examples.md
regions.md
services.md
templates/
stateful-node.cs
stateless-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。

不可妥协的规则
  1. 每个有状态节点类都必须使用 [ProcessNode] 元数据;需要别名以确保兼容性时,使用受支持的 ProcessNodeFactory
  2. vvvv 中可见的名称不能包含「Node」——vvvv 中的所有内容本来就是节点,因此「Node」后缀是多余的
  3. out 参数必须放在最前面,带默认值的值输入放在后面
  4. 在类和 Update 方法上添加 XML 注释(在 vvvv 中显示为工具提示)
  5. Update 中绝不分配内存——不使用 new 或 LINQ,所有内容都要缓存
  6. 检测变化——仅当输入实际发生变化时才重新计算
  7. 始终输出最新数据——即使没有执行任何操作,也要输出缓存的结果
  8. 避免不必要的公共成员——数据流仅通过 Update 的输入/输出参数传递
  9. 任何持有原生/非托管资源的节点都必须实现 IDisposable
实时重新加载行为

当 .vl 文档引用 .csproj 源项目时,vvvv 会在运行时通过 Roslyn 编译 C#。保存 .cs 文件时,vvvv 会重新编译并重启受影响的节点:

  1. 对当前实例调用 Dispose()(如果实现了 IDisposable)
  2. 使用全新的 NodeContext 运行新构造函数
  3. 在下一帧恢复调用 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)
属性默认值在以下情况下设置……
NameC# 类名…节点面向用户显示的名称应与类名不同。如果 Name 与类名相同,请省略。
Category来自程序集级导入特性…你希望此特定类归入与程序集其余部分不同的类别。
HasStateOutputfalse…下游节点需要访问节点实例本身(例如调用其方法)。
FragmentSelectionImplicit(所有公共成员都会成为片段)…你只想公开标记了 [Fragment] 的成员。
StateOutputNotVisibleByDefaultfalse…你有状态输出,但希望在用户启用之前将其隐藏。
Summary、Remarks、Tagsnull这些可替代 XML 文档注释;除非需要通过程序访问,否则优先使用注释。

因此,对于类名 = 节点名且类别由程序集特性确定这一常见情况,直接使用 [ProcessNode](不带参数)即可。只有当这些值与默认生成的值不同时,添加 [ProcessNode(Name = "Foo", Category = "Bar")] 才有用。

[Pin] — 参数 / 属性 / 返回值(AllowMultiple = false)
属性默认值在以下情况下设置……
Name参数名(vvvv 会自动将其转换为标题式大小写)…你需要一个 vvvv 无法推导出的名称,例如包含空格、标点符号,或与参数无关的名称。对名为 input 的参数使用 [Pin(Name = "input")] 是多余的——vvvv 已会将其显示为 Input。
VisibilityPinVisibility.Visible…你希望引脚为 Optional(可折叠)或 Hidden(仅在检查器中显示)。
ExpositionPinExpositionMode.Local…你希望引脚自动暴露到父级补丁上(InfectPatch / Expose)。
PinGroupKindPinGroupKind.None…该参数是应显示为可添加 / 删除引脚组的 Spread<T>。
PinGroupDefaultCount0…引脚组应以 N 个条目开始。
PinGroupEditModeNone…你希望将添加 / 删除操作限制为单一方向。
输出名称:实例状态与方法结果
  • [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] 是不必要的噪声。

属性默认值在以下情况下设置……
Order0(声明顺序)……你需要独立于源代码布局的特定片段顺序。
IsHiddenfalse……某个成员不应成为片段,即使它是公共成员也是如此。
IsDefaultfalse……当没有其他内容连接时,你希望此片段在默认时机运行。
经验法则
  1. 如果特性只有默认值,就不要写这个特性。
  2. 如果你只需要一个非默认属性,只写那一个(例如 [Pin(Visibility = PinVisibility.Optional)])。
  3. 特性的存在应始终表示“我正在覆盖默认值”,而不是“我正在重述默认值”。

构造函数模式

简单节点(无需特殊上下文):

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/软件包版本、最简复现步骤或权威来源,以及观察到的结果。将其他用户的报告视为线索,在验证之前不要当作事实。改进最相关的最小范围指引;不要将一个示例推广为通用规则。提交上游之前,请删除私人数据并取得发布授权。请参阅贡献政策。 合并后的改进通过其安装程序/更新工作流触达用户,而不是通过本地编辑或自动覆盖所有人的技能。

评论

支持 Markdown