关于 vvvv-node-libraries
为 vvvv gamma 开发者提供节点库创建与发布指南,涵盖目录、命名、分类、服务注册和节点导入规则,并说明如何控制节点浏览器中的可见内容。
主要功能
- 01节点库目录与命名规范
- 02节点导入属性选择
- 03节点分类与可见性控制
- 04程序集初始化与服务注册
适用场景
- 01搭建新的 vvvv gamma 节点库
- 02将节点整理到多个分类
- 03避免辅助类型出现在节点浏览器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tebjan/ vvvv-skills --skill vvvv-node-libraries安装后在 Claude Code 中输入/vvvv-node-libraries确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
contributing.mddesign-guidelines.mdexamples.mdpublishing.md详细介绍
创建 vvvv gamma 节点库
节点库是一种项目,可作为可分发包向 vvvv gamma 提供多个节点。本指南涵盖项目层面的注意事项:目录结构、命名约定、分类组织、服务注册和节点工厂。
有关如何编写单个节点类(ProcessNode、Update、pins、变更检测),请参阅 vvvv-custom-nodes。有关如何在节点构造函数中使用服务(IFrameClock、Game、日志记录),请参阅 vvvv-custom-nodes/services.md。
创建自己的库与向非本人所有的库贡献代码是两项不同的任务。本 SKILL.md 及其设计/发布参考资料介绍的是如何创建和分发包。若要修改或向现有/上游库提交 PR(fork → branch → PR 工作流、可编辑源包、.vl 差异问题),请参阅 contributing.md。
库识别模式
当文件夹名称、.vl 文件和 .nuspec 文件名称相同时,vvvv 会将目录识别为一个库:
VL.MyLibrary/ # Folder name = package name
├── VL.MyLibrary.vl # .vl document — MUST match folder name
├── VL.MyLibrary.nuspec # NuGet spec — MUST match folder name
├── lib/
│ └── net8.0/ # Compiled DLLs go here
│ └── VL.MyLibrary.dll
├── src/
│ ├── Initialization.cs # [assembly:] attributes + AssemblyInitializer
│ ├── Nodes/
│ │ ├── MyProcessNode.cs # [ProcessNode] classes
│ │ └── MyOperations.cs # Static methods (stateless nodes)
│ ├── Services/
│ │ └── MyService.cs # Per-app singletons
│ └── VL.MyLibrary.csproj
├── shaders/ # Optional: SDSL shaders (auto-discovered)
│ └── MyEffect_TextureFX.sdsl
└── help/ # Optional: .vl help patches
└── HowTo Use MyNode.vl
关键约定:
- 文件夹名称、
.vl文件和.nuspec文件必须完全相同(例如,均为VL.MyLibrary) .csproj必须将 DLL 输出到相对于包根目录的lib/net8.0/- 包内的
.vl文件不得引用.csproj— 这会使包进入可编辑模式 - 库目录必须位于已配置的包存储库目录中,vvvv 才能找到它
.csproj 输出路径
.csproj 必须将编译结果输出到库的 lib/net8.0/ 文件夹:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputPath>..\..\lib\net8.0\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
哪些内容会作为节点导入 — 基本规则
当同时满足以下两个条件时,类型会成为 vvvv 节点浏览器中的节点:
- 类型是
public(并且位于已导入的程序集内)。 - 类型的 C# 命名空间由
[assembly: ImportAsIs]/[assembly: ImportNamespace]声明覆盖,或者类型由[assembly: ImportType]声明列出。
如果任一条件不满足,该类型对 vvvv 都不可见。**导入是按命名空间选择加入的,而不只是取决于类型的可访问性。**位于无人导入的命名空间中的 public 类,与 internal 类一样不会显示在节点浏览器中。
导入某个类型后,vvvv 会根据其完整的公共接口生成节点:
- 公共类和结构体 → 构造函数 + 公共方法/属性会成为节点
- 公共静态方法 → 操作节点
- 公共枚举 → 拆分 + 值会成为节点
- 公共记录和接口 → 按类处理
**[ProcessNode] 并不决定节点是否可见。**它纯粹是生命周期语法糖 — 它告诉 vvvv:“这是一个带有 Update() 方法的有状态类,请为每个节点管理一个实例,并在每一帧调用 Update()”。导入命名空间中的普通 public class Foo { public Foo() {} public int Bar(int x) => x; } 与添加了 [ProcessNode] 的类完全相同,都会出现在节点浏览器中。该特性影响节点的调用方式,而不是节点是否显示。
对库设计的启示:决定“节点浏览器中显示什么”的主要手段是导入哪些命名空间,而不是哪些类型是 public。但请注意,导入具有递归性 — 声明一个根命名空间会导入其下的所有嵌套命名空间,因此 .Internal 子命名空间并不是隐藏内容的地方。有关实际可用的四种方法,请参阅将辅助内容排除在节点浏览器之外。
来源:VL.StandardLibs ImportAsIsAttribute,Gray Book — 使用 C# 编写节点。
Initialization.cs — 入口点
每个节点库都需要程序集级特性。将它们放在同一个文件中:
using VL.Core;
using VL.Core.CompilerServices;
using VL.Core.Import;
// Required: tells vvvv to scan this assembly for nodes
[assembly: ImportAsIs(Namespace = "MyCompany.MyLibrary", Category = "MyLibrary")]
// Optional: register services before any node runs
[assembly: AssemblyInitializer(typeof(MyCompany.MyLibrary.Initialization))]
namespace MyCompany.MyLibrary;
public sealed class Initialization : AssemblyInitializer<Initialization>
{
public override void Configure(AppHost appHost)
{
var services = appHost.Services;
// Register per-app singletons (created lazily on first access)
services.RegisterService<MyService>(serviceProvider =>
{
return new MyService(serviceProvider);
});
}
}
选择合适的导入特性
vvvv 提供三种程序集级特性,用于声明哪些内容会成为节点。请根据所需的控制程度进行选择。
[assembly: ImportAsIs] — 单一、以命名空间为根
[assembly: ImportAsIs(Namespace = "VL.MyLib", Category = "MyLib")]
| 属性 | 行为 |
|---|---|
AllowMultiple | false — 每个程序集最多一个 |
| 范围 | Namespace 中的所有公共类型(包括其子命名空间) |
| 类别 | Category 参数是根类别;Namespace 下的子命名空间会扩展此类别 |
两个参数均为可选;四种组合都合法,且各自的作用不同:
[assembly: ImportAsIs] // the scaffolded default:
// nothing stripped, no root →
// VL.MyLib.Particles ⇒ "VL.MyLib.Particles"
[assembly: ImportAsIs(Namespace = "VL.MyLib")] // strip only →
// VL.MyLib.Particles ⇒ "Particles" (top level!)
[assembly: ImportAsIs(Category = "MyLib")] // root only →
// VL.MyLib.Particles ⇒ "MyLib.VL.MyLib.Particles"
[assembly: ImportAsIs(Namespace = "VL.MyLib", Category = "MyLib")] // ✅ strip + root →
// VL.MyLib.Particles ⇒ "MyLib.Particles"
最后一种形式几乎总是你想要的。当整个库都位于一个根命名空间下,并且你想要一个根类别时,请使用 ImportAsIs。不能叠加两个 ImportAsIs 来将子命名空间拆分到不同类别中——它的 AllowMultiple = false。
[assembly: ImportNamespace] — 按命名空间导入,可多次使用
// ⚠️ Order matters — first declaration wins. Specific before general.
[assembly: ImportNamespace("VL.MyLib.Renderers", Category = "MyLib.Rendering")]
[assembly: ImportNamespace("VL.MyLib.Resources", Category = "MyLib.Resources")]
[assembly: ImportNamespace("VL.MyLib.Experimental", Category = "MyLib.Experimental")]
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")] // catch-all, LAST
| 属性 | 行为 |
|---|---|
AllowMultiple | true — 可按需声明多个 |
| 范围 | 该命名空间中的公共类型以及其下所有嵌套命名空间(递归) |
| 解析方式 | 以首个声明为准,而非最长前缀——先声明具体命名空间,再声明一般命名空间 |
当一个库包含多个子命名空间,且你希望它们分别归入不同类别,同时不让 C# 文件夹名称污染浏览器时,请使用此属性。对于多类别库,这是合适的工具。
[assembly: ImportType] — 按类型导入,手动挑选
[assembly: ImportType(typeof(MyRenderer), Category = "MyLib.Rendering")]
[assembly: ImportType(typeof(MyResource), Category = "MyLib.Resources", Name = "Resource")]
| 属性 | 行为 |
|---|---|
AllowMultiple | true — 可按需声明多个 |
| 范围 | 仅限列出的类型——程序集中的其他内容都不会自动导入 |
| 配合使用 | 可单独使用(不搭配 ImportAsIs/ImportNamespace),用于仅包含明确列表的库;也可与命名空间属性搭配,为特定类型覆盖类别或名称 |
用于精细控制——例如,只想公开大型内部代码库中精心挑选的子集,或者想将某个特例放入与同命名空间类型不同的类别。
决策矩阵
| 库的结构 | 推荐的属性 |
|---|---|
| 单一命名空间、单一类别,且所有公共类型都适合公开 | [assembly: ImportAsIs(Namespace, Category)] |
| 一个库,包含多个不同的子类别 | 每个子命名空间使用一个 [assembly: ImportNamespace] |
| 精选的节点集合,且有许多不想公开的公共辅助类型 | 每个节点使用一个 [assembly: ImportType],不使用 ImportAsIs |
| 大多数类型自动导入,少数特例除外 | [assembly: ImportAsIs] + [assembly: ImportType] 覆盖 |
🛑 导入是递归的——嵌套命名空间并不是藏身之处
这是导致库的节点浏览器被污染的最常见原因。这个错误很容易犯,因为从字面上看,这种做法似乎完全正确。
AssemblySymbolSource.cs 中的 ImportFromNamespace 会遍历命名空间树,直到找到完整名称等于所声明命名空间的节点,然后对该节点调用 ImportAll——而 ImportAll 会递归遍历该节点下的每一个嵌套命名空间和嵌套类型。
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
namespace VL.MyLib; // → category "MyLib"
namespace VL.MyLib.Internal; // → category "MyLib.Internal" ← STILL IMPORTED
namespace VL.MyLib.Internal.Gpu; // → category "MyLib.Internal.Gpu" ← STILL IMPORTED
**将辅助类型放在子命名空间中并不能隐藏它们。**这只会将它们移入一个子类别,用户仍然可以在 NodeBrowser 搜索框中输入内容来找到它们——以及这些类型的所有公共成员。
ImportNamespace("VL.MyLib") 确实不会导入同级命名空间(VL.MyLibInternals)……但 IsMatch 使用的是 ns.StartsWith(Namespace),没有边界检查,因此在计算类别时,ImportNamespace("VL.Foo") 仍会错误匹配 VL.FooBar。按同级命名空间划分很脆弱。请改用下面四种真正有效的方法之一。
从节点浏览器中排除辅助类型——四种方法
根据还有谁需要此类型来选择。
1. internal — 类型完全不会进入 VL
ImportAll 会在第一行就退出:
if (s.DeclaredAccessibility != Accessibility.Public)
return;
internal 类型没有节点、类别、成员节点、IOBox 或序列化接口。已经没有任何 vvvv 相关内容需要考虑。这是最有效、成本最低的手段——优先使用它。
如果测试或示例程序集需要这个类型,请将其保留为 internal,并添加
[assembly: InternalsVisibleTo("MyLib.Tests")]。VL 无法看到它;C# 仍然可以访问它。
2. [Smell(...)] — 在 C# 中公开,在浏览器中筛除
VL.Core.Import.SmellAttribute(AttributeTargets.All)就是适用于每种类型/成员的退出机制。类型仍然是 public,也仍会被导入——它仍可作为引脚类型、仍可通过链接传递、仍可跨程序集引用——只是不会显示在 NodeBrowser 中。
using VL.Core; // SymbolSmell
using VL.Core.Import; // SmellAttribute
[ProcessNode]
[Smell(SymbolSmell.Internal)]
public sealed class SkiaRendererNode : IDisposable { /* ... */ }
这是 VL.StandardLibs 中的实际代码(VL.Skia/src/SkiaRendererNode.cs)。完整标志列表请参阅下文来自 C# 的方面。
⚠️ 请从
VL.Core.Import导入SmellAttribute。同名的VL.Core.CompilerServices.SmellAttribute带有[Obsolete(error: true)],仅为二进制兼容性而保留——使用它会导致编译错误。
3. 类别中的方面关键字 — 一行隐藏整个分组
名为 Internal、Hidden、Advanced、Experimental、Obsolete 或
Adaptive 的类别段会将相应方面应用于该类别中的所有内容,然后从解析后的类别名称中移除该段。类别中的每个符号都会继承 Smell
(ImportedSymbol.GetSmell() 从 ParentCategory.Smell 开始),因此一个声明就能覆盖任意数量的类型:
[assembly: ImportNamespace("VL.MyLib.Plumbing", Category = "MyLib.Internal")]
// every public type under VL.MyLib.Plumbing → category "MyLib", smell Internal
由于子命名空间的末尾部分会附加到 Category,然后再解析关键字,因此普通的
C# 命名空间段也同样适用——无需属性:
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
namespace VL.MyLib.Particles; // → "MyLib.Particles", normal
namespace VL.MyLib.Particles.Advanced; // → "MyLib.Particles", Advanced aspect
namespace VL.MyLib.Internal; // → "MyLib", Internal aspect
这样一来,C# 文件夹布局和 vvvv 类别树就是同一个结构,这是大型库最易维护的安排。
4. 仅使用 ImportType — 封闭的允许列表
完全跳过 ImportAsIs/ImportNamespace,逐个列出面向用户的类型。无论命名空间或可访问性如何,未列出的内容都会保持不可见。
[assembly: ImportType(typeof(Renderer), Category = "MyLib")]
[assembly: ImportType(typeof(Settings), Category = "MyLib")]
// Nothing else is imported.
适用于大型内部代码库中精心挑选的少量公开接口。每个节点需要一行代码。
快速决策
| 情况 | 手段 |
|---|---|
| 此程序集之外没有任何内容需要它 | internal(1) |
| 测试/示例需要它,但用户绝不能看到 | internal + [InternalsVisibleTo](1) |
| 它是合法的引脚类型或跨程序集 API,只是不应显示在浏览器中 | [Smell(SymbolSmell.Internal)](2) |
| 整个命名空间都是基础设施代码 | 类别/命名空间中的方面关键字(3) |
| 代码库很大,节点接口很小 | 仅使用 ImportType(4) |
类别解析规则 — 已通过 vvvv 源码验证
优先级顺序(从高到低):
- 类本身上的
[ProcessNode(Category = "X")]/[Category("X")]——优先级最高。 [assembly: ImportType(typeof(T), Category = "X", NamespacePrefixToStrip = "...")]——针对单个类型的覆盖设置。[assembly: ImportNamespace("X.Sub", Category = "Y")]——适用于X.Sub下的类型,前提是导入这些类型的就是此属性(见下文的优先匹配规则)。[assembly: ImportAsIs(Namespace = "X", Category = "Y")]——整个库的唯一默认设置。它会在所有ImportNamespace之前运行,因此会先占用X下的所有内容。- 没有导入属性匹配此类型——原始 C# 命名空间会原样用作类别,并标记为
SymbolSmell.External。此路径不会解析方面关键字。
供参考的两种全库形式:
// Explicit — the recommended default. Strips "VL.MyLib", roots everything under "MyLib".
[assembly: ImportAsIs(Namespace = "VL.MyLib", Category = "MyLib")]
// Bare — what vvvv's own project template scaffolds. Nothing stripped, no category root:
// a class in VL.MyLib.Particles lands in the top-level category "VL.MyLib.Particles".
[assembly: ImportAsIs]
🛑 先匹配优先,而非最长前缀
DirectImportSymbolSource 按固定顺序导入,并将已导入的列表作为排除集传入,因此第一个认领某个类型的属性决定其类别——后续属性会静默跳过该类型:
if (importAsIsAttribute != null)
ImportFromNamespace(builder, …, importAsIsAttribute.Namespace, importAttribute: importAsIsAttribute);
foreach (var a in importNamespaceAttributes) // ← declaration order
ImportFromNamespace(builder, …, a.Namespace, alreadyImported: builder, a);
foreach (var a in importTypeAttributes)
ImportAll(builder, …, alreadyImported: builder, a);
// ImportAll, first two lines:
if (s.DeclaredAccessibility != Accessibility.Public) return;
if (alreadyImported != null && alreadyImported.Any(c => …Equals(c.Symbol, s))) return;
顺序为:ImportAsIs → 按声明顺序排列的 ImportNamespace → ImportType。
因此:应将最具体的 ImportNamespace 声明在最前面。 如果把宽泛的根命名空间声明放在较窄的声明上方,就会吞掉所有内容,并使较窄的行变成无效代码:
// ❌ BROKEN — the root line imports everything recursively; lines 2-3 never fire.
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
[assembly: ImportNamespace("VL.MyLib.Plumbing", Category = "MyLib.Internal")] // dead
[assembly: ImportNamespace("VL.MyLib.Config", Category = "MyLib.Settings")] // dead
// ✅ CORRECT — specific first, catch-all last.
[assembly: ImportNamespace("VL.MyLib.Plumbing", Category = "MyLib.Internal")]
[assembly: ImportNamespace("VL.MyLib.Config", Category = "MyLib.Settings")]
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
此失败不会有任何提示——既没有警告,也没有错误。其表现是子命名空间名称出现在你以为已经重新映射过的节点浏览器文件夹中。若要确认某个类型实际归属的位置,可以将它放入补丁中,保存,然后读取 .vl XML 中的 LastCategoryFullName。
对于第 3 和第 4 层(ImportAsIs / ImportNamespace),最终类别由 VL.Core/src/Import/ImportAsIsAttribute.cs 中的 GetCategory(typeNamespace) 计算得出:
// Pseudocode of the actual VL.Core implementation:
root = Category ?? "";
if (typeNamespace == "") return root;
if (Namespace == "") cat = typeNamespace;
else if (typeNamespace.Length > Namespace.Length)
cat = typeNamespace.Substring(Namespace.Length + 1);
else /* typeNamespace == Namespace */ cat = "";
if (cat == "") return root;
if (root == "") return cat;
return $"{root}.{cat}";
实际使用中,这会带来一个不太显而易见的结果:
[assembly: ImportAsIs(...)] | C# 命名空间 | vvvv 类别 | 是否令人意外? |
|---|---|---|---|
Namespace = "VL.MyLib", Category = "MyLib" | VL.MyLib | MyLib | 否 |
Namespace = "VL.MyLib", Category = "MyLib" | VL.MyLib.Particles | MyLib.Particles | 否 |
Namespace = "VL.MyLib" (无 Category) | VL.MyLib | ""(根目录) | 是——空类别/根目录 |
Namespace = "VL.MyLib" (无 Category) | VL.MyLib.Particles | Particles | 是——顶层 |
Namespace = "VL.MyLib" (无 Category) | VL.MyLib.Internal.Helpers | Internal.Helpers | 是——顶层的 “Internal” 泄漏! |
如果没有 Category=,就只会剥掉前缀——不会回退并加上 Namespace 的最后一段。 因此,[ImportAsIs(Namespace = "VL.MyLib")] 中的类若位于 VL.MyLib.Config,就会被放到顶层 Config,而不是 MyLib.Config。这是最常见的意外情况——除非你确实想让子命名空间污染顶层,否则始终要显式设置 Category。
调试“为什么我的节点跑到了顶层 Helpers,而不是 MyLib.Helpers?”时,请检查:是不是忘记了 ImportAsIs 上的 Category = 参数?
来自 C# 的方面——[Smell] 和类别关键字
方面控制 NodeBrowser 的可见性和节点状态。Gray Book 为已修补的符号记录了这些内容;在 C# 中,有两种方式可以应用相同的设置。
标志
VL.Core.SymbolSmell 是一个 [Flags] 枚举:
| 标志 | 值 | 效果 |
|---|---|---|
Default | 0 | 正常 |
Obsolete | 1 | 已弃用;为保持向后兼容而保留 |
Experimental | 2 | 不稳定/开发中 |
Advanced | 4 | 用户启用 Advanced 筛选器之前,在 NodeBrowser 中隐藏 |
Hidden | 8 | 永不在 NodeBrowser 中提供 |
Internal | 16 | 仅在定义它的文档/库中可用 |
Adaptive | 512 | 将节点纳入自适应类型分派 |
Advanced 是库作者最常用的选项:Gray Book 的理由是,“任何库开发者都会提供一些超酷的节点和类型,供该库 90% 的用户使用”——其他内容则标记为 Advanced,而不是删除。
方式 1——在符号上使用 [Smell(SymbolSmell.X)]
using VL.Core;
using VL.Core.Import;
[Smell(SymbolSmell.Experimental)]
public sealed class FormBoundsNotification { /* ... */ }
适用于类型、方法和属性——AttributeUsage(AttributeTargets.All)。
类型上的 [Smell] 不会级联到其成员;应为每个符号单独设置,或者使用类别(方式 2)覆盖整个分组。
方式 2 — 将关键字用作类别/命名空间片段
Symbols.ToSmell 会识别且仅识别以下区分大小写的片段名称:
| 片段 | 方面 |
|---|---|
Internal | SymbolSmell.Internal |
Advanced | SymbolSmell.Advanced |
Experimental | SymbolSmell.Experimental |
Obsolete | SymbolSmell.Obsolete |
Hidden | SymbolSmell.Hidden |
Adaptive | SymbolSmell.Adaptive |
匹配到的片段会从解析出的类别中移除,因此 MyLib.Particles.Advanced 和
MyLib.Advanced.Particles 都会解析为类别 MyLib.Particles,并带有 Advanced
方面。气味会继承:ImportedSymbol.GetSmell() 从 ParentCategory.Smell 开始,
再将符号自身的 [Smell] 按位或叠加上去,因此一个关键字就能覆盖类别中的所有类型。
⚠️ 关键字解析仅适用于通过属性提供的类别。 在
GetCategoryAndSmellForFullName(name, isAssemblyCategory) 中,关键字循环位于
else 分支中——当 isAssemblyCategory: true(未匹配到任何导入属性时使用的原始命名空间回退路径)时,名称会原样使用,并标记为 SymbolSmell.External。实际效果是:.Advanced 命名空间片段之所以生效,是因为 ImportAsIs/
ImportNamespace 生成了该类别。对于从未导入的类型,它不起作用。
方式 3 — 在节点的版本片段中使用关键字
NameVersionAndSmell.Split 会解析节点名称中的版本部分,因此这种写法同样可行:
[ProcessNode(Name = "GetBytes (Advanced)")]
各方面不会做什么
Advanced/Hidden/Internal 符号仍然完全可用。它仍会被导入,仍有类别,仍可用作引脚类型,现有链接、IOBoxes 和已保存的补丁仍可正常解析。方面只会筛选NodeBrowser 列表。因此,对于用户通过引脚配置、但不应需要在浏览器中查找的枚举和设置类型,Advanced 是合适的工具。
vvvv 会为你应用的两个方面
- 不带
HasStateOutput的[ProcessNode]会隐藏该类型。 如果存在ProcessNodeAttribute且HasStateOutput == false,ImportedSymbol.GetSmell()会为类型添加SymbolSmell.Hidden,因此只会显示处理节点。将HasStateOutput = true会取消隐藏该类型,并将每个公共成员作为独立节点显示——这是一个确实容易被忽略的浏览器杂乱来源。 - 枚举运算符会自动标记为
Advanced。 导入枚举时生成的=、!=、|、&、^操作会自动获得SymbolSmell.Advanced(devvvvs/vvvv#3885),因此你无需自行隐藏它们。
服务注册模式
服务在 Configure(AppHost) 中注册,并由节点通过 NodeContext 使用。本节仅介绍注册方式——关于使用模式,请参阅 vvvv-custom-nodes/services.md。
直接注册为 Singleton(推荐)
services.RegisterService<MyService>(serviceProvider =>
{
// Created lazily on first GetService<MyService>() call
return new MyService(serviceProvider);
});
IResourceProvider 模式(用于托管生命周期)
当服务封装了需要显式释放的资源时:
services.RegisterService<IResourceProvider<MyGPUService>>(serviceProvider =>
{
var gameProvider = serviceProvider.GetService<IResourceProvider<Game>>();
return gameProvider.Bind(game =>
{
var service = MyGPUService.Create(game);
return ResourceProvider.Return(service, disposeAction: s => s?.Dispose());
});
});
动态节点工厂
为动态节点集注册程序化节点生成:
public override void Configure(AppHost appHost)
{
// Dynamic node factory from shader files or other sources
appHost.RegisterNodeFactory("VL.MyLibrary.ShaderNodes",
init: MyShaderNodeFactory.Init);
}
当节点由外部文件(着色器、配置文件)生成,而非编写为 C# 类时,请使用节点工厂。详情请参阅 vvvv Node Factories 文档。
用于服务访问的扩展方法
为你的服务提供强类型访问器:
public static class MyLibraryExtensions
{
public static MyService? GetMyService(this ServiceRegistry services)
=> services.GetService(typeof(MyService)) as MyService;
public static MyService? GetMyService(this IServiceProvider services)
=> services.GetService(typeof(MyService)) as MyService;
}
.csproj 必备内容
将输出到 lib/net8.0/ 的完整库 .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<OutputPath>..\..\lib\net8.0\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="VL.Core" Version="2025.7.*" />
<PackageReference Include="VL.Core.Import" Version="2025.7.*" />
<PackageReference Include="VL.Stride.Runtime" Version="2025.7.*" />
</ItemGroup>
</Project>
VL 包版本应与 vvvv 安装版本匹配。OutputPath 会将编译后的 DLL 放入库的 lib/net8.0/ 文件夹,vvvv 会在此查找它们。
真实示例:自定义渲染库
通过服务注册和节点工厂初始化库:
[assembly: AssemblyInitializer(typeof(Initialization))]
[assembly: ImportAsIs(Namespace = "VL.MyRendering", Category = "MyRendering")]
public sealed class Initialization : AssemblyInitializer<Initialization>
{
public override void Configure(AppHost appHost)
{
appHost.Services.RegisterService<CustomGameSystem>(sp =>
{
var vlGame = sp.GetService<VLGame>();
if (vlGame == null) return null!;
var customGame = CustomGameSystem.Create(vlGame, sp);
vlGame.GameSystems.Add(customGame);
return customGame;
});
// Dynamic node factory from shader files
appHost.RegisterNodeFactory("VL.MyRendering.ShaderNodes",
init: ShaderNodeFactory.Init);
}
}
有关命名约定、引脚规则、切面和标准类型,请参阅 design-guidelines.md。 有关发布 NuGet、帮助补丁和库结构,请参阅 publishing.md。 有关为现有上游库贡献更改/PR,请参阅 contributing.md。 有关完整的真实示例(VL.IO.MQTT、VL.Audio),请参阅 examples.md。
持续完善这些知识
让下一位补丁编写者少一个疑问。首次使用本集合中的任何技能时,如果过去七天内没有成功检查记录,请将源内容与 tebjan/vvvv-skills 进行比较。如果没有记录,请检查一次。这些技能共享一个本地检查时间戳/修订版本;不要在每次调用时都检查。保留本地扩展和固定的项目版本;检查并不意味着可以安装更新。如果处于离线状态,请记录延期检查,并继续任务。
如果实际工作暴露出知识缺口,请记录经过测试的 vvvv/包版本、最小复现或权威来源,以及观察到的结果。将其他用户的报告视为线索,核实后再当作事实。只改进最相关的说明;不要把一个示例变成普遍规则。提交到上游之前,请删除私人数据并获得发布授权。请参阅贡献政策。合并的改进会通过用户的安装程序/更新工作流触达用户,而不是通过本地编辑或自动覆盖每个人的技能。