在 Graphite 中创建节点:从文档图到 Graphene 原型节点执行器的完整指南
【免费下载链接】GraphiteCommunity-built comprehensive 2D content creation appplication for graphic design, digital art, and interactive real-time motion graphics powered by a node-based procedural graphics engine项目地址: https://gitcode.com/GitHub_Trending/gr/Graphite
Graphite 是一款以节点化编辑为核心工作流的 2D 内容创作应用,所有图层操作都可以在节点图中以可视化的方式连接、修改与回放。本文以官方节点开发指南(node-graph/README.md)为主线,结合仓库源码,系统讲解从定义DocumentNode、编写属性面板控件,到实现 Graphene 原型节点、注册节点构造函数、最终执行整张文档图的完整链路,帮助你掌握为 Graphite 添加自定义节点的全部技术要素。
节点的用途(Purpose of Nodes)
Graphite 是一个以节点化编辑工作流为核心的图像编辑器:所有操作在图中以可视化方式相互连接。这种设计非常灵活,因为它允许在任意时刻查看或修改所有操作,而不会丢失原始数据——例如,对图像施加的滤镜、混合、变换都以节点与连线的方式保留在文档中,随时可以回到任意一步调整参数。
节点系统在设计上追求尽可能通用:所有数据类型都可表示,并且为各种使用场景规划了广泛的内置节点集合。节点不只是"滤镜",它既是文档编辑的基础单元,也是 Graphite 底层 Graphene 程序化渲染引擎(node-based procedural graphics engine)的计算原语。
文档图(The Document Graph)
编辑器呈现给用户的图称为文档图(document graph),它在NodeNetwork结构体中定义。每一个被放入图中的节点(DocumentNode)具有以下属性(该结构体的真实定义位于 node-graph/graph-craft/src/document.rs 的DocumentNode定义中):
pub struct DocumentNode { pub inputs: Vec<NodeInput>, pub call_argument: Type, pub implementation: DocumentNodeImplementation, pub skip_deduplication: bool, pub visible: bool, pub original_location: OriginalLocation, }对照源码(node-graph/graph-craft/src/document.rs 第 36-66 行),实际结构还额外包含一个context_features: ContextDependencies字段,用于记录节点的 Context 抽取/注入注解。各字段的语义如下:
inputs:节点的输入列表。每个输入要么是图中其他节点的输出(NodeInput::Node,保存node_id与output_index),要么是常量值(NodeInput::Value,由TaggedValue承载),要么是NodeInput::Import——表示该输入来自图外部,在嵌套网络的 flatten(扁平化)阶段解析;此外还有Scope、Reflection、Inline(内联 Rust 源码,用于 GPU 编译)等变体。call_argument:该节点可被求值的参数类型。implementation:节点实现,可以是嵌套的文档网络(DocumentNodeImplementation::Network)或一个原型节点标识符(DocumentNodeImplementation::ProtoNode),也可以是Extract(用于元编程/GPU 源码提取)。visible:对应图中节点的"眼睛"图标。隐藏的节点在 flatten 阶段会被替换为一个直通(passthrough)节点。skip_deduplication:当两个不同原型节点哈希到相同值时(例如两个内容相同的值节点),编译期默认会去重;但某些节点(如需要在图外访问的MonitorNode)不希望被去重,可置为true。original_location:节点在文档网络中的路径,用于推导类型与错误信息。
定义一种新的文档节点类型
每个DocumentNode都有特定类型,例如 "Opacity"(不透明度)节点。你可以在编辑器的节点图消息处理器中定义自己的文档节点类型。原文档指向的document_node_types.rs在当前仓库中已演化为 editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs(并配套宏生成器 document_node_derive.rs)。一个不透明度节点的类型定义示例如下:
DocumentNodeDefinition { name: "Opacity", category: "Image Adjustments", implementation: DocumentNodeImplementation::proto("graphene_core::raster::OpacityNode"), inputs: vec![ DocumentInputType::value("Image", TaggedValue::ImageFrame(ImageFrame::empty()), true), DocumentInputType::value("Factor", TaggedValue::F32(100.), false), ], outputs: vec![DocumentOutputType::new("Image", FrontendGraphDataType::Raster)], properties: node_properties::multiply_opacity, ..Default::default() },这里的标识符必须与即将讨论的原型节点(proto-node)的标识符保持一致(通常是节点实现的路径)。
[!NOTE] 定义在
graphene_core中的节点由graphene_std重新导出。但如果类型名的字符串与实现不完全匹配,你将会遇到错误。
属性面板(Properties panel)
节点的输入名称会在输入被**暴露(expose)**时显示在图中(在属性面板中以一个圆点呈现)。默认输入值在节点首次创建或连线断开时被使用。
一个输入由TaggedValue(允许通过 serde 序列化动态类型)外加一个exposed布尔值构成,exposed决定该输入是否默认在节点图 UI 中显示为一个圆点。在 Opacity 节点中,"Image" 输入默认显示,而 "Factor" 输入默认隐藏,从而使图面更清爽。需要指出的是,NodeInput枚举(node-graph/graph-craft/src/document.rs 第 203-228 行)还提供了NodeInput::value(tagged_value, exposed)等构造函数,且is_exposed()方法(第 285-294 行)表明:节点连线输入永远视为暴露,Value输入取决于exposed标记,Scope/Inline/Reflection输入则不暴露。
properties字段是一个函数,用于定义数字输入控件——在图中选中 Opacity 节点即可看到。其代码如下:
pub fn multiply_opacity(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> { let factor = number_widget(document_node, node_id, 1, "Factor", NumberInput::default().min(0.).max(100.).unit("%"), true); vec![LayoutGroup::Row { widgets: factor }] }这里number_widget的NumberInput通过min(0.).max(100.).unit("%")配置了取值范围 0–100 与百分比单位,true表示该控件默认暴露。
Graphene(原型节点执行器)
Graphene crate(位于 node-graph/nodes/gcore)与 Graphene 标准库(位于 node-graph/nodes/gstd)是节点实际实现代码所在的位置。
实现一个节点,就是定义一个实现了Nodetrait 的struct。Nodetrait 位于 node-graph/libraries/core-types/src/lib.rs 第 49-64 行,其核心是一个接收一个泛型输入的eval函数:
/// The node trait allows for defining any node. Nodes can only take one call argument input, however they can store references to other nodes inside the struct. /// See `node-graph/README.md` for information on how to define a new node. pub trait Node<'i, Input> { type Output: 'i; /// Evaluates the node with the single specified input. fn eval(&'i self, input: Input) -> Self::Output; /// Resets the node, e.g. the LetNode's cache is set to None. fn reset(&self) {} /// Returns the name of the node for diagnostic purposes. fn node_name(&self) -> &'static str { std::any::type_name::<Self>() } /// Serialize the node which is used for the `introspect` function which can retrieve values from monitor nodes. fn serialize(&self) -> Option<std::sync::Arc<dyn std::any::Any + Send + Sync>> { log::warn!("Node::serialize not implemented for {}", std::any::type_name::<Self>()); None } }一个作用于颜色的不透明度节点实现示例:
use crate::{Color, Node}; #[derive(Debug, Clone, Copy)] pub struct OpacityNode<OpacityMultiplierInput> { opacity_multiplier: OpacityMultiplierInput, } impl<'i, OpacityMultiplierInput: Node<'i, (), Output = f64> + 'i> Node<'i, Color> for OpacityNode<OpacityMultiplierInput> { type Output = Color; fn eval(&'i self, color: Color) -> Color { let opacity_multiplier = self.opacity_multiplier.eval(()) as f32 / 100.; Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), color.a() * opacity_multiplier) } }eval函数只能接收一个输入。为了支持多个输入,节点结构体可以存储对其他节点的引用。这里opacity_multiplier字段是泛型的,被约束为 traitNode<'i, (), Output = f64>——这意味着它是一个"输入为()(计算不透明度无需输入)、输出为f64"的节点。在执行OpacityNode时,需要调用self.opacity_multiplier.eval(())来求值提供opacity_multiplier输入的节点;这发生在每次运行该节点时。
对应的单元测试(求值Color::WHITE后 alpha 通道应变为 0.1):
#[test] fn test_opacity_node() { let opacity_node = OpacityNode { opacity_multiplier: crate::value::CopiedNode(10_f64), // set opacity to 10% }; assert_eq!(opacity_node.eval(Color::WHITE), Color::from_rgbaf32_unchecked(1., 1., 1., 0.1)); }graphene_core::value::CopiedNode是一个"求值时复制10_f64并返回"的节点。其实现位于 node-graph/libraries/core-types/src/value.rs 第 158-170 行,pub struct CopiedNode<T: Copy>(pub T),且为任意输入类型I实现了Node<'i, I>(忽略输入、返回持有的值)——这正是它可被用作()输入节点、输出固定常量的原因。
此外,NodeIOtrait(node-graph/libraries/core-types/src/lib.rs 第 69-104 行)为节点提供了运行时类型信息(input_type/output_type/input_type_name/output_type_name),并可将节点转为NodeIOTypes(调用参数、返回值与输入列表),这是节点注册与类型推断的基础。
使用node宏创建新节点
无需手动用复杂泛型实现Nodetrait,可以使用node宏,将其应用于像opacity这样的函数。该宏会自动生成结构体、trait 实现、节点注册表(node_registry)条目、文档节点定义以及属性面板条目:
#[node_macro::node(category("Raster: Adjustments"))] fn opacity(_input: (), #[default(424242)] color: Color, #[range] #[soft(0..100)] opacity_multiplier: f64) -> Color { let opacity_multiplier = opacity_multiplier as f32 / 100.; Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), color.a() * opacity_multiplier) }从宏的实现源码(node-graph/node-macro/src/codegen.rs)可以确认其生成流程:fn_name/struct_name/mod_name由函数名派生(结构体名会追加Node后缀),category属性是必需的(缺失会在解析期报错);每个普通参数对应生成一个泛型字段与输入(下划线开头的参数会被隐藏,不生成输入),并在eval中逐一求值;#[soft/hard]边界会通过Clampable::clamp_hard_min/clamp_hard_max(node-graph/node-macro/src/codegen.rs 第 306-316 行)在执行前对参数做强制钳制。
宏的附加选项(Additional Macro Options)
宏调用可以通过附加属性进行扩展。当前支持的属性有:name、path、skip_impl、category。使用泛型时,#[implementations()]属性可以自动为你填充节点注册表。此外还可以使用default、expose、soft、hard和range属性来影响属性的生成方式。
各参数详细说明(结合 node-graph/node-macro/src/parsing.rs 第 271-329 行的解析实现):
#[default(value)]:指定参数的默认值(如示例中的#[default(424242)])。注意:节点的调用参数(call argument)不允许设置默认值,解析器会直接报错(parsing.rs 第 649-650 行)。#[expose]:默认暴露该输入(在属性面板/图中显示圆点)。#[range]:将该数字输入渲染为可拖动的滑块,而不是默认的步进输入框(parsing.rs 中number_mode_range: bool字段)。#[soft(a..b)]与#[hard(a..b)]:分别设置滑块的建议范围(suggested extent)与强制钳制范围(enforced clamp)。任一端点都可以省略,例如0..或..100;两个端点都是闭区间(包含边界),因此不存在..=形式。输入框内键入的值可以超出 soft 范围,但会被钳制到 hard 边界内——所以#[soft]只有与#[range]组合使用时才有意义。解析器接受整数或浮点字面量(统一按f64处理,parsing.rs 第 217-235 行),且要求至少指定一个边界(第 310 行)。#[name("...")]:覆盖节点显示名(否则按结构体名转换为 Title Case)。#[path(...)]:显式指定节点的注册路径/标识符。#[skip_impl]:跳过自动生成的register_node实现(用于需要手写注册逻辑的场景;codegen.rs 第 1065 行显示,此时只会生成register_metadata调用)。#[implementations(...)]:列出该泛型参数的多个具体实现类型,自动生成多条注册表行(codegen.rs 中通过implementations字段逐行生成结构化条目)。
执行一个文档NodeNetwork
当文档图被执行时,会发生以下步骤(对照源码可进一步确认各环节的真实实现):
- 扁平化:
NodeNetwork通过NodeNetwork::flatten扁平化。这一步会移除所有DocumentNodeImplementation::Network(它允许嵌套的文档节点网络),把所有内部节点移动到单一节点图中。源码实现在 node-graph/graph-craft/src/document.rs 第 909-1000 行:扁平化时隐藏节点会被替换为直通节点(passthrough),值输入会被替换为独立的值节点,嵌套网络内部节点 ID 通过merge_ids(对父子 ID 哈希得到稳定新 ID)重映射后并入父网络,Import输入则按索引与父节点输入一一对接。 - 转换为原型图(proto-graph):
NodeNetwork被转换为原型图。每个节点的输入以节点 ID 列表的形式存储在ProtoNode的ConstructionArgs结构体中。文档图到原型图的转换由NodeNetwork::into_proto_networks完成(同样位于 node-graph/graph-craft/src/document.rs,并配合 node-graph/graph-craft/src/graphene_compiler.rs 的编译流水线)。 - 解析为构造函数:新创建的
ProtoNode通过 node-graph/interpreted-executor/src/node_registry.rs 中定义的映射转换为对应的构造函数,这一步由BorrowTree::push_node完成。node_registry.rs 第 641 行声明了静态注册表NODE_REGISTRY,其键为ProtoNodeIdentifier,值为HashMap<NodeIOTypes, NodeConstructor>——同一标识符可按不同的输入/输出类型组合(NodeIOTypes)注册多个构造函数重载。 - 执行构造函数:构造函数以
ConstructionArgs枚举运行。构造函数通常会对这些输入进行求值,例如一个Pi节点作为Add节点的第二个输入时,Add节点的构造函数会求值Pi节点——如果你在Pi节点实现里放置一条 log 语句就能观察到这一点。 - 存入借用树(BorrowTree):解析后的函数存放在
BorrowTree中,它允许后续节点引用先前的原型节点作为输入,并确保节点在被其他节点引用期间不会被移除。BorrowTree与DynamicExecutor的实现在 node-graph/interpreted-executor/src/dynamic_executor.rs:DynamicExecutor持有tree: BorrowTree与typing_context: TypingContext,TypingContext::new(&node_registry::NODE_REGISTRY)负责类型推断;update方法在图形变更时增量重建借用树,尽量复用未变化的节点(orphaned_nodes记录跨帧存留的孤立节点以支持 introspection)。
节点构造函数定义
对图像的每个像素应用不透明度变换的节点,其构造函数定义如下:
( // Matches against the string defined in the document node. ProtoNodeIdentifier::new("graphene_core::raster::OpacityNode"), // This function is run when converting the `ProtoNode` struct into the desired struct. |args| { Box::pin(async move { // Creates an instance of the struct that defines the node. let node = construct_node!(args, graphene_core::raster::OpacityNode<_>, [f64]).await; // Create a new map image node, that calls the `node` for each pixel. let map_node = graphene_std::raster::MapImageNode::new(graphene_core::value::ValueNode::new(node)); // Wraps this in a type erased future `Box<Pin<dyn core::future::Future<Output = T> + 'n>>` - this allows it to work with async. let map_node = graphene_std::any::FutureWrapperNode::new(map_node); // The `DynAnyNode` downcasts its input from a `Box<dyn DynAny>` i.e. dynamically typed, to the desired statically typed input value. It then runs the wrapped node and converts the result back into a dynamically typed `Box<dyn DynAny>`. let any: DynAnyNode<Image<Color>, _, _> = graphene_std::any::DynAnyNode::new(graphene_core::value::ValueNode::new(map_node)); // Nodes are stored as type erased, which means they are `Box<dyn NodeIo + Node>`. This allows us to create dynamic graphs, using dynamic dispatch so we do not have to know all node combinations at compile time. any.into_type_erased() }) }, // Defines the call argument, return value, and inputs. NodeIOTypes::new(concrete!(Image<Color>), concrete!(Image<Color>), vec![fn_type!((), f64)]), ),借用栈中的节点以Box<dyn DynAny>作为输入并输出另一个Box<dyn DynAny>,以支持任意类型。要使用具体类型,必须对传入的值进行向下转型(downcast)。由于OpacityNode一次只处理一个像素,我们首先插入一个MapImageNode,对图像中的每个像素调用OpacityNode。最后对结果调用.into_type_erased(),将其插入借用栈。
对照 node-graph/interpreted-executor/src/node_registry.rs 第 643-677 行的async_node!宏可以看到更现代的注册写法:宏为每个fn_params类型依次执行downcast_node、用DynAnyNode包裹、最后Box::new(any) as TypeErasedBox,并同时生成NodeIOTypes(含call_argument、return_value与参数列表)。例如注册表顶部的 Monitor 节点族(第 39-84 行)就为Context => Item<...>/Context => List<...>的每一种类型组合生成了独立注册行。
为了简化光栅节点的注册,还有一个raster_node!宏,它可以把不透明度节点的定义简化为:
raster_node!(graphene_core::raster::OpacityNode<_>, params: [f64]),对于不需要逐像素运行的节点,还有更通用的register_node!:
register_node!(graphene_core::transform_nodes::SetTransformNode<_>, input: Vector, params: [DAffine2]),类型适配与注册表结构
值得补充的是,node_registry 除了业务节点外,还通过一组宏注册了大量类型适配节点(node-graph/interpreted-executor/src/node_registry.rs 第 155-638 行):input_adapter_node!为每个元素类型注册Item<T>/List<T>直通与Into转换;item_to_list_node!/bundle_node!/unbundle_node!处理单例提升与列表打包/解包;convert_adapter_wildcard!注册数值类型之间的强制转换(如f64 => f32/u32/.../DVec2/String);还有ranked_value_types!统一驱动值类型(i32、BlendMode、Stroke、Font等)的秩提升适配与 memoize/monitor/context 缓存链。所有这些适配器与业务节点一起,在node_registry()末尾合并进同一张NODE_REGISTRY哈希表(第 607-637 行),并统一做秩归一化(normalize_rank)与泛型名清理(去掉stringify!产生的换行、剥离<generics>后缀,仅保留适配器节点的元素后缀)。
调试(Debugging)
在节点内部可以使用log::debug!()宏进行调试,例如:
log::debug!("The opacity is {opacity_multiplier}");官方指南同时指出,还需要一个工具来方便地查看图在应用各个步骤时的状态,也需要一种透明的方式看到哪些构造函数正在运行、哪些节点正在被求值、以及它们的执行顺序——这也是 Graphite 后续持续改进的方向。从源码看,Nodetrait 提供的node_name()(返回类型名)与serialize()(配合 Monitor 节点的introspect能力取回运行值,node-graph/libraries/core-types/src/lib.rs 第 56-63 行)正是为这类诊断场景预留的基础设施。
结论
虽然通过宏可以隐藏部分细节来简化节点的编写,但创建节点仍然涉及众多文件和概念:文档图侧的DocumentNode/DocumentNodeDefinition定义与属性面板控件(editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs)、Graphene 侧的Nodetrait 实现(node-graph/libraries/core-types/src/lib.rs)、node/raster_node!/register_node!等宏(node-graph/node-macro/src)、以及执行端的节点注册表与借用树(node-graph/interpreted-executor/src/node_registry.rs、node-graph/interpreted-executor/src/dynamic_executor.rs)。Graphite 团队正在持续让这套系统更易用,社区贡献者如有疑问,可以在 Graphite 的 Discord 中寻求帮助。
【免费下载链接】GraphiteCommunity-built comprehensive 2D content creation appplication for graphic design, digital art, and interactive real-time motion graphics powered by a node-based procedural graphics engine项目地址: https://gitcode.com/GitHub_Trending/gr/Graphite
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考