Add more details to Graphene concept documentation (#1437)

* Start improving node system docs

* Add note on debugging

* Explain testing protonodes

* Code review comments

* Review pass

* Further improve explanation of manual_compostion

* Fix explanation of ComposeNode graph rewriting

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube 2023-10-28 03:21:15 +01:00 committed by GitHub
parent bfb6df3b74
commit ceb2f4c13f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 278 additions and 111 deletions

View file

@ -6,23 +6,22 @@ Graphite is an image editor which is centred around a node based editing workflo
## The Document Graph
The graph that is presented to users in the editor is known as the document graph. Each node that has been placed in this graph has the following properties:
The graph that is presented to users in the editor is known as the document graph, and is defined in the `NodeNetwork` struct. Each node that has been placed in this graph has the following properties:
```rs
pub struct DocumentNode {
// An identifier used to display in the editor and to display the appropriate properties.
pub name: String,
// A NodeInput::Node { node_id, output_index } specifies an input from another node.
// A NodeInput::Value { tagged_value, exposed } specifies a constant value. An exposed value is visible as a dot in the node graph UI.
// A NodeInput::Network(Type) specifies a node will get its input from outside the graph, which is resolved later.
pub inputs: Vec<NodeInput>,
// A nested document network or a proto-node identifier
pub manual_composition: Option<Type>,
pub has_primary_output: bool,
pub implementation: DocumentNodeImplementation,
// Contains the position of the node and other future properties
pub metadata: DocumentNodeMetadata,
pub skip_deduplication: bool,
pub hash: u64,
pub path: Option<Vec<NodeId>>,
}
```
(The actual defenition is currently found at `node-graph/graph-craft/src/document.rs:38`)
(Explanatory comments omitted; the actual definition is currently found in [`node-graph/graph-craft/src/document.rs`](https://github.com/GraphiteEditor/Graphite/blob/improve-node-docs/node-graph/graph-craft/src/document.rs))
Each `DocumentNode` is of a particular type, for example the "Opacity" node type. You can define your own type of document node in `editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/document_node_types.rs`. A sample document node type definition for the opacity node is shown:
@ -33,16 +32,23 @@ DocumentNodeType {
identifier: NodeImplementation::proto("graphene_core::raster::OpacityNode<_>"),
inputs: vec![
DocumentInputType::value("Image", TaggedValue::ImageFrame(ImageFrame::empty()), true),
DocumentInputType::value("Factor", TaggedValue::F64(100.), false),
DocumentInputType::value("Factor", TaggedValue::F32(100.), false),
],
outputs: vec![DocumentOutputType::new("Image", FrontendGraphDataType::Raster)],
properties: node_properties::multiply_opacity,
..Default::default()
},
```
The identifier here must be the same as that of the proto-node which will be discussed soon (usually the path to the node implementation).
The input names are shown in the graph when an input is exposed (with a dot in the properties panel). The default input is used when a node is first created or when a link is disconnected. An input is comprised from a `TaggedValue` (allowing serialisation of a dynamic type with serde) in addition to an exposed boolean, which defines if the input is shown as a dot in the node graph UI by default. In the opacity node, the "Color" input is shown but the "Factor" input is hidden from the graph by default, allowing for a less cluttered graph.
> [!NOTE]
> Nodes defined in `graphene_core` are re-exported by `graphene_std`. However if the strings for the type names do not match exactly then you will encounter an error.
## Properties panel
The input names are shown in the graph when an input is exposed (with a dot in the properties panel). The default input is used when a node is first created or when a link is disconnected. An input is comprised from a `TaggedValue` (allowing serialization of a dynamic type with serde) in addition to an exposed boolean, which defines if the input is shown as a dot in the node graph UI by default. In the opacity node, the "Color" input is shown but the "Factor" input is hidden from the graph by default, allowing for a less cluttered graph.
The properties field is a function that defines a number input, which can be seen by selecting the opacity node in the graph. The code for this property is shown below:
@ -54,34 +60,49 @@ pub fn multiply_opacity(document_node: &DocumentNode, node_id: NodeId, _context:
}
```
## Node Implementation
## Graphene (protonode executor)
Defining the actual implementation for a node is done by implementing the `Node` trait. The `Node` trait has a function called `eval` that takes one generic input. A node implementation for the opacity node is seen below:
The graphene crate (found in `gcore/`) and the graphene standard library (found in `gstd/`) is where actual implementation for nodes are located.
Implementing a node is done by defining a `struct` implementing the `Node` trait. The `Node` trait has a required function named `eval` that takes one generic input. A sample implementation for an opacity node acting on a color is seen below:
```rs
use crate::{Color, Node};
#[derive(Debug, Clone, Copy)]
pub struct OpacityNode<O> {
opacity_multiplier: O,
pub struct OpacityNode<OpacityMultiplierInput> {
opacity_multiplier: OpacityMultiplierInput,
}
impl<'i, N: Node<'i, (), Output = f64> + 'i> Node<'i, Color> for OpacityNode<N> {
impl<'i, OpacityMultiplierInput: Node<'i, (), Output = f64> + 'i> Node<'i, Color> for OpacityNode<OpacityMultiplierInput> {
type Output = Color;
fn eval<'s: 'i>(&'s self, color: Color) -> 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)
}
}
```
impl<N> OpacityNode<N> {
pub fn new(node: N) -> Self {
Self { opacity_multiplier: node }
}
The `eval` function can only take one input. To support more than one input, the node struct can store references to other nodes. This can be seen here, as the `opacity_multiplier` field, which is generic and is constrained to the trait `Node<'i, (), Output = f64>`. This means that it is a node with the input of `()` (no input is required to compute the opacity) and an output of an `f64`.
To compute the value when executing the `OpacityNode`, we need to call `self.opacity_multiplier.eval(())`. This evaluates the node that provides the `opacity_multiplier` input, with the input value of `()`— nothing. This occurs each time the opacity node is run.
To test this:
```rs
#[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));
}
```
The `eval` function can only take one input. To support more than one input, the node struct can contain references to other nodes (it is the references that implement the `Node` trait). If the input is a constant, then it will reference a node that simply evaluates to a constant. If the input is a node, then the relevant proto-node will be referenced. To evaluate the opacity multiplier input, you can pass in `()` (because no input is required to calculate the opacity multiplier) which returns an `f64`. This is because of the generics we have applied: `N: Node<'i, (), Output = f64>` A helper function to create a new node struct is also defined here.
The `graphene_core::value::CopiedNode` is a node that, when evaluated, copies `10_f32` and returns it.
This process can be made more concise using the `node_fn` macro, which can be applied to a function like `image_opacity` with an attribute of the name of the node:
## Creating a new protonode
Instead of manually implementing the `Node` trait with complex generics, one can use the `node_fn` macro, which can be applied to a function like `image_opacity` with an attribute of the name of the node:
```rs
#[derive(Debug, Clone, Copy)]
@ -96,32 +117,51 @@ fn image_opacity(color: Color, opacity_multiplier: f64) -> Color {
}
```
## Inserting the Proto-Node
## Alternative macros
When the document graph is executed, it is first converted to a proto-graph, which has all of the nested node graphs flattened as well as separating out the primary input from the secondary inputs. The secondary inputs are stored as a list of node ids in the construction arguments field of the `ProtoNode`. The newly created `ProtoNode`s are then converted into the corresponding dynamic rust functions using the mapping defined in `node-graph/interpreted-executor/src/node_registry.rs`. The resolved functions are then stored in a `BorrowTree`, which allows previous proto-nodes to be referenced as inputs by later nodes. The `BorrowTree` ensures nodes can't be removed while being referenced by other nodes.
`#[node_macro::node_fn(NodeName)]` generates an implementation of the `Node` trait for NodeName with the specific input types, and also generates a `fn new` that can be used to construct the node struct. If multiple implementations for different types are needed, then it is necessary to avoid creating this `new` function twice, so you can use `#[node_macro::node_impl(NodeName)]`.
If you need to manually implement the `Node` trait without using the macro, but wish to have an automatically generated `fn new`, you can use `#[node_macro::node_new(NodeName)]`, which can be applied to a function.
## Executing a document `NodeNetwork`
When the document graph is executed, the following steps occur:
- The `NodeNetwork` is flattened using `NodeNetwork::flatten`. This involves removing any `DocumentNodeImplementation::Network` - which allow for nested document node networks (not currently exposed in the UI). Instead, all of the inner nodes are moved into a single node graph.
- The `NodeNetwork` is converted into a proto-graph, which separates out the primary input from the secondary inputs. The secondary inputs are stored as a list of node ids in the `ConstructionArgs` struct in the `ProtoNode`. Converting a document graph into a proto graph is done with `NodeNetwork::into_proto_networks`.
- The newly created `ProtoNode`s are then converted into the corresponding constructor functions using the mapping defined in `node-graph/interpreted-executor/src/node_registry.rs`. This is done by `BorrowTree::push_node`.
- The constructor functions are run with the `ConstructionArgs` enum. Constructors generally evaluate the result of these secondary inputs e.g. if you have a `Pi` node that is used as the second input to an `Add` node, the `Add` node's constructor will evaluate the `Pi` node. This is visible if you place a log statement in the `Pi` node's implementation.
- The resolved functions are stored in a `BorrowTree`, which allows previous proto-nodes to be referenced as inputs by later nodes. The `BorrowTree` ensures nodes can't be removed while being referenced by other nodes.
The definition for the constructor of a node that applies the opacity transformation to each pixel of an image:
```rs
(
// Matches against the string defined in the document node.
NodeIdentifier::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 calles 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()
})
},
NodeIOTypes::new(concrete!(Image<Color>), concrete!(Image<Color>), vec![fn_type!(f64))]),
// Defines the input, output, and parameters (where each parameter is a function taking in some input and returning another input).
NodeIOTypes::new(concrete!(Image<Color>), concrete!(Image<Color>), vec![fn_type!((), f64))]),
),
raster_node!(graphene_core::raster::OpacityNode<_>, params: [f64]),
```
Nodes in the borrow stack take a `Box<dyn DynAny>` as input and output another `Box<dyn DynAny>`, to allow for any type. To use a specific type, we must downcast the values that have been passed in.
However the `OpacityNode` only works on one pixel at a time, so we first insert a `MapImageNode` to call the `OpacityNode` for every pixel in the image.
Finally we call `.into_type_erased()` on the result and that is inserted into the borrow stack.
However we also need to add an implementation so that the user can change the opacity of just a single color. To simplify this process for raster nodes, a `raster_node!` macro is available which can simplify the defention of the opacity node to:
We also need to add an implementation so that the user can change the opacity of just a single color. To simplify this process for raster nodes, a `raster_node!` macro is available which can simplify the definition of the opacity node to:
```rs
raster_node!(graphene_core::raster::OpacityNode<_>, params: [f64]),
```
@ -133,8 +173,12 @@ register_node!(graphene_core::transform::SetTransformNode<_>, input: VectorData,
## Debugging
Debugging inside your node can be done with the `log` macros, for example `info!("The opacity is {opacity_multiplier}");`
Debugging inside your node can be done with the `log` macros, for example `info!("The opacity is {opacity_multiplier}");`.
We need a utility to easily view a graph as the various steps are applied. We also need a way to transparently see which constructors are being run, which nodes are being evaluated, and in what order.
## Conclusion
Defining some basic nodes to allow for a simple image editing workflow would be invaluable. Currently defining nodes is quite a laborious process however efforts at simplification are being discussed. Any contributions you might have would be greatly appreciated. If any parts of this guide are outdated or difficult to understand, please feel free to ask for help in the Graphite Discord. We are very happy to answer any questions :)
Currently defining nodes is a very laborious and error prone process, spanning many files and concepts. It is necessary to simplify this if we want contributors to be able to write their own nodes.
Any contributions you might have would be greatly appreciated. If any parts of this guide are outdated or difficult to understand, please feel free to ask for help in the Graphite Discord. We are very happy to answer any questions :)

View file

@ -46,10 +46,15 @@ pub use raster::Color;
pub use types::Cow;
// pub trait Node: for<'n> NodeIO<'n> {
/// The node trait allows for defining any node. Nodes can only take one 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: 'i>: 'i {
type Output: 'i;
/// Evalutes 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) {}
/// Serialize the node which is used for the `introspect` function which can retrieve values from monitor nodes.
#[cfg(feature = "std")]
fn serialize(&self) -> Option<std::sync::Arc<dyn core::any::Any>> {
log::warn!("Node::serialize not implemented for {}", core::any::type_name::<Self>());

View file

@ -11,6 +11,8 @@ pub mod value;
pub type NodeId = u64;
/// Hash two IDs together, returning a new ID that is always consistant for two input IDs in a specific order.
/// This is used during [`NodeNetwork::flatten`] in order to ensure consistant yet non-conflicting IDs for inner networks.
fn merge_ids(a: u64, b: u64) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
@ -21,6 +23,7 @@ fn merge_ids(a: u64, b: u64) -> u64 {
#[derive(Clone, Debug, PartialEq, Default, specta::Type, Hash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
/// Metadata about the node including its position in the graph UI
pub struct DocumentNodeMetadata {
pub position: IVec2,
}
@ -40,18 +43,114 @@ fn return_true() -> bool {
#[derive(Clone, Debug, PartialEq, Hash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DocumentNode {
/// An identifier used to display in the UI and to display the appropriate properties.
pub name: String,
/// The inputs to a node, which are either:
/// - From other nodes within this graph [`NodeInput::Node`],
/// - A constant value [`NodeInput::Value`],
/// - A [`NodeInput::Network`] which specifies that this input is from outside the graph, which is resolved in the graph flattening step in the case of nested networks.
/// In the root network, it is resolved when evaluating the borrow tree.
pub inputs: Vec<NodeInput>,
/// Manual composition is a way to override the default composition flow of one node into another.
///
/// Through the usual node composition flow, the upstream node providing the primary input for a node is evaluated before the node itself is run.
/// - Abstract example: upstream node `G` is evaluated and its data feeds into the primary input of downstream node `F`,
/// just like function composition where function `F` is evaluated and its result is fed into function `F`.
/// - Concrete example: a node that takes an image as primary input will get that image data from an upstream node that produces image output data and is evaluated first before being fed downstream.
///
/// This is achieved by automatically inserting `ComposeNode`s, which run the first node with the overall input and then feed the resulting output into the second node.
/// The `ComposeNode` is basically a function composition operator: the parentheses in `F(G(x))` or circle math operator in `(G ∘ F)(x)`.
/// For flexability, instead of being a language construct, Graphene splits out composition itself as its own low-level node so that behavior can be overridden.
/// The `ComposeNode`s are then inserted during the graph rewriting step for nodes that don't opt out with `manual_composition`.
/// Instead of node `G` feeding into node `F` feeding as the result back to the caller,
/// the graph is rewritten so nodes `G` and `F` both feed as lambdas into the parameters of a `ComposeNode` which calls `F(G(input))` and returns the result to the caller.
///
/// A node's manual composition input represents an input that is not resolved through graph rewriting with a `ComposeNode`,
/// and is instead just passed in when evaluating this node within the borrow tree.
/// This is similar to having the first input be a `NodeInput::Network` after the graph flattening.
///
/// ## Example Use Case: CacheNode
///
/// The `CacheNode` is a pass-through node on cache miss, but on cache hit it needs to avoid evaluating the upstream node and instead just return the cached value.
///
/// First, let's consider what that would look like using the default composition flow if the `CacheNode` instead just always acted as a pass-through (akin to a cache that always misses):
///
/// ```text
/// ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
/// │ │◄───┤ │◄───┤ │◄─── EVAL (START)
/// │ G │ │PassThroughNode│ │ F │
/// │ ├───►│ ├───►│ │───► RESULT (END)
/// └───────────────┘ └───────────────┘ └───────────────┘
/// ```
///
/// This acts like the function call `F(PassThroughNode(G(input)))` when evaluating `F` with some `input`: `F.eval(input)`.
/// - The diagram's upper track of arrows represents the flow of building up the call stack:
/// since `F` is the output it is encountered first but deferred to its upstream caller `PassThroughNode` and that is once again deferred to its upstream caller `G`.
/// - The diagram's lower track of arrows represents the flow of evaluating the call stack:
/// `G` is evaluated first, then `PassThroughNode` is evaluated with the result of `G`, and finally `F` is evaluated with the result of `PassThroughNode`.
///
/// With the default composition flow (no manual composition), `ComposeNode`s would be automatically inserted during the graph rewriting step like this:
///
/// ```text
/// ┌───────────────┐ ┌───────────────┐
/// │ │◄───┤ │◄─── EVAL (START)
/// │ ComposeNode │ │ F │
/// ┌───────────────┐ │ ├───►│ │───► RESULT (END)
/// │ │◄─┐ ├───────────────┤ └───────────────┘
/// │ F │ └─┤ │
/// │ ├─┐ │ First │
/// └───────────────┘ └─►│ │
/// ┌───────────────┐ ├───────────────┤
/// │ │◄───┤ │
/// │ ComposeNode │ │ Second │
/// ┌───────────────┐ │ ├───►│ │
/// │ │◄─┐ ├───────────────┤ └───────────────┘
/// │ G │ └─┤ │
/// │ ├─┐ │ First │
/// └───────────────┘ └─►│ │
/// ┌───────────────┐ ├───────────────┤
/// | │◄───┤ │
/// │PassThroughNode│ │ Second │
/// │ ├───►│ │
/// └───────────────┘ └───────────────┘
/// ```
///
/// Now let's swap back from the `PassThroughNode` to the `CacheNode` to make caching actually work.
/// It needs to override the default composition flow so that `G` is not automatically evaluated when the cache is hit.
/// We need to give the `CacheNode` more manual control over the order of execution.
/// So the `CacheNode` opts into manual composition and, instead of deferring to its upstream caller, it consumes the input directly:
///
/// ```text
/// ┌───────────────┐ ┌───────────────┐
/// │ │◄───┤ │◄─── EVAL (START)
/// │ CacheNode │ │ F │
/// │ ├───►│ │───► RESULT (END)
/// ┌───────────────┐ ├───────────────┤ └───────────────┘
/// │ │◄───┤ │
/// │ G │ │ Cached Data │
/// │ ├───►│ │
/// └───────────────┘ └───────────────┘
/// ```
///
/// Now, the call from `F` directly reaches the `CacheNode` and the `CacheNode` can decide whether to call `G.eval(input_from_f)`
/// in the event of a cache miss or just return the cached data in the event of a cache hit.
pub manual_composition: Option<Type>,
#[serde(default = "return_true")]
pub has_primary_output: bool,
// A nested document network or a proto-node identifier.
pub implementation: DocumentNodeImplementation,
/// Metadata about the node including its position in the graph UI.
pub metadata: DocumentNodeMetadata,
/// When two different protonodes hash to the same value (e.g. two value nodes each containing `2_u32` or two multiply nodes that have the same node IDs as input), the duplicates are removed.
/// See [`crate::proto::ProtoNetwork::generate_stable_node_ids`] for details.
/// However sometimes this is not desirable, for example in the case of a [`graphene_core::memo::MonitorNode`] that needs to be accessed outside of the graph.
#[serde(default)]
pub skip_deduplication: bool,
/// Used as a hash of the graph input where applicable. This ensures that protonodes that depend on the graph's input are always regenerated.
#[serde(default)]
pub hash: u64,
/// The path to this node as of when [`NodeNetwork::generate_node_paths`] was called.
/// For example if this node was ID 6 inside a node with ID 4 and with a [`DocumentNodeImplementation::Network`], the path would be [4, 6].
pub path: Option<Vec<NodeId>>,
}
@ -72,6 +171,7 @@ impl Default for DocumentNode {
}
impl DocumentNode {
/// Locate the input that is a [`NodeInput::Network`] at index `offset` and replace it with a [`NodeInput::Node`].
pub fn populate_first_network_input(&mut self, node_id: NodeId, output_index: usize, offset: usize, lambda: bool) {
let (index, _) = self
.inputs
@ -90,7 +190,7 @@ impl DocumentNode {
unreachable!("tried to resolve not flattened node on resolved node {self:?}");
};
let (input, mut args) = if let Some(ty) = self.manual_composition {
(ProtoNodeInput::ShortCircut(ty), ConstructionArgs::Nodes(vec![]))
(ProtoNodeInput::ManualComposition(ty), ConstructionArgs::Nodes(vec![]))
} else {
let first = self.inputs.remove(0);
match first {
@ -102,7 +202,7 @@ impl DocumentNode {
assert_eq!(output_index, 0, "Outputs should be flattened before converting to protonode. {:#?}", self.name);
(ProtoNodeInput::Node(node_id, lambda), ConstructionArgs::Nodes(vec![]))
}
NodeInput::Network(ty) => (ProtoNodeInput::Network(ty), ConstructionArgs::Nodes(vec![])),
NodeInput::Network(ty) => (ProtoNodeInput::ManualComposition(ty), ConstructionArgs::Nodes(vec![])),
NodeInput::Inline(inline) => (ProtoNodeInput::None, ConstructionArgs::Inline(inline)),
}
};
@ -177,41 +277,6 @@ pub enum NodeInput {
/// Input that is provided by the parent network to this document node, instead of from a hardcoded value or another node within the same network.
Network(Type),
/// A short circuting input represents an input that is not resolved through function composition
/// but rather by actually consuming the provided input instead of passing it to its predecessor.
///
/// In Graphite nodes are functions, and by default these are composed into a single function
/// by automatic insertion of inserting Compose nodes.
///
/// ```text
/// ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
/// │ │◄───┤ │◄───┤ │
/// │ A │ │ B │ │ C │
/// │ ├───►│ ├───►│ │
/// └───────────────┘ └───────────────┘ └───────────────┘
/// ```
///
/// This is equivalent to calling c(b(a(input))) when evaluating c with input ( `c.eval(input)`).
/// But sometimes we might want to have a little more control over the order of execution.
/// This is why we allow nodes to opt out of the input forwarding by consuming the input directly.
///
/// ```text
/// ┌───────────────┐ ┌───────────────┐
/// │ │◄───┤ │
/// │ Cache Node │ │ C │
/// │ ├───►│ │
/// ┌───────────────┐ ├───────────────┤ └───────────────┘
/// │ │◄───┤ │
/// │ A │ │ * Cached Node │
/// │ ├───►│ │
/// └───────────────┘ └───────────────┘
/// ```
///
/// In this case the Cache node actually consumes its input and then manually forwards it to its parameter Node.
/// This is necessary because the Cache Node needs to short-circut the actual node evaluation.
// TODO: Update
// ShortCircut(Type),
/// A Rust source code string. Allows us to insert literal Rust code. Only used for GPU compilation.
/// We can use this whenever we spin up Rustc. Sort of like inline assembly, but because our language is Rust, it acts as inline Rust.
Inline(InlineRust),
@ -283,9 +348,14 @@ impl NodeInput {
#[derive(Clone, Debug, PartialEq, Hash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
/// Represents the implementation of a node, which can be a nested [`NodeNetwork`], a proto [`NodeIdentifier`], or extract.
pub enum DocumentNodeImplementation {
/// A nested [`NodeNetwork`] that is flattened by the [`NodeNetwork::flatten`] function.
Network(NodeNetwork),
/// A protonode identifier which can be found in `node_registry.rs`.
Unresolved(NodeIdentifier),
/// `DocumentNode`s with a `DocumentNodeImplementation::Extract` are converted into a `ClonedNode` that returns the `DocumentNode` specified by the single `NodeInput::Node`.
/// The referenced node (specified by the single `NodeInput::Node`) is removed from the network, and any `NodeInput::Node`s used by the referenced node are replaced with a generically typed network input.
Extract,
}
@ -317,6 +387,7 @@ impl DocumentNodeImplementation {
#[derive(Clone, Copy, Debug, Default, PartialEq, DynAny, specta::Type, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
/// Defines a particular output port, specifying the node ID and output index.
pub struct NodeOutput {
pub node_id: NodeId,
pub node_output_index: usize,
@ -329,6 +400,7 @@ impl NodeOutput {
#[derive(Clone, Debug, Default, PartialEq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
/// A network of nodes containing each [`DocumentNode`] and its ID, as well as a list of input nodes and [`NodeOutput`]s
pub struct NodeNetwork {
pub inputs: Vec<NodeId>,
pub outputs: Vec<NodeOutput>,
@ -554,6 +626,7 @@ impl NodeNetwork {
}
}
/// Check there are no cycles in the graph (this should never happen).
pub fn is_acyclic(&self) -> bool {
let mut dependencies: HashMap<u64, Vec<u64>> = HashMap::new();
for (node_id, node) in &self.nodes {
@ -587,6 +660,7 @@ impl NodeNetwork {
}
}
/// Iterate over the primary inputs of nodes, so in the case of `a -> b -> c`, this would yield `c, b, a` if we started from `c`.
struct FlowIter<'a> {
stack: Vec<NodeId>,
network: &'a NodeNetwork,
@ -612,6 +686,7 @@ impl<'a> Iterator for FlowIter<'a> {
/// Functions for compiling the network
impl NodeNetwork {
/// Replace all references in the graph of a node ID with a new node ID defined by the function `f`.
pub fn map_ids(&mut self, f: impl Fn(NodeId) -> NodeId + Copy) {
self.inputs.iter_mut().for_each(|id| *id = f(*id));
self.outputs.iter_mut().for_each(|output| output.node_id = f(output.node_id));
@ -642,6 +717,7 @@ impl NodeNetwork {
outwards_links
}
/// Populate the [`DocumentNode::path`], which stores the location of the document node to allow for matching the resulting protonodes to the document node for the purposes of typing and finding monitor nodes.
pub fn generate_node_paths(&mut self, prefix: &[NodeId]) {
for (node_id, node) in &mut self.nodes {
let mut new_path = prefix.to_vec();
@ -657,6 +733,7 @@ impl NodeNetwork {
}
}
/// Replace all references in any node of `old_input` with `new_input`
fn replace_node_inputs(&mut self, old_input: NodeInput, new_input: NodeInput) {
for node in self.nodes.values_mut() {
node.inputs.iter_mut().for_each(|input| {
@ -667,6 +744,7 @@ impl NodeNetwork {
}
}
/// Replace all references in any node of `old_output` with `new_output`
fn replace_network_outputs(&mut self, old_output: NodeOutput, new_output: NodeOutput) {
for output in self.outputs.iter_mut() {
if *output == old_output {
@ -675,7 +753,7 @@ impl NodeNetwork {
}
}
/// Removes unused nodes from the graph. Returns a list of bools which represent if each of the inputs have been retained
/// Removes unused nodes from the graph. Returns a list of booleans which represent if each of the inputs have been retained.
pub fn remove_dead_nodes(&mut self) -> Vec<bool> {
// Take all the nodes out of the nodes list
let mut old_nodes = std::mem::take(&mut self.nodes);
@ -709,11 +787,12 @@ impl NodeNetwork {
are_inputs_used
}
/// Remove all nodes that contain [`DocumentNodeImplementation::Network`] by moving the nested nodes into the parent network.
pub fn flatten(&mut self, node: NodeId) {
self.flatten_with_fns(node, merge_ids, generate_uuid)
}
/// Recursively dissolve non-primitive document nodes and return a single flattened network of nodes.
/// Remove all nodes that contain [`DocumentNodeImplementation::Network`] by moving the nested nodes into the parent network.
pub fn flatten_with_fns(&mut self, node: NodeId, map_ids: impl Fn(NodeId, NodeId) -> NodeId + Copy, gen_id: impl Fn() -> NodeId + Copy) {
self.resolve_extract_nodes();
let Some((id, mut node)) = self.nodes.remove_entry(&node) else {
@ -870,6 +949,7 @@ impl NodeNetwork {
Ok(())
}
/// Strips out any [`graphene_core::ops::IdNode`]s that are unnecessary.
pub fn remove_redundant_id_nodes(&mut self) {
let id_nodes = self
.nodes
@ -888,6 +968,9 @@ impl NodeNetwork {
}
}
/// Converts the `DocumentNode`s with a `DocumentNodeImplementation::Extract` into a `ClonedNode` that returns
/// the `DocumentNode` specified by the single `NodeInput::Node`.
/// The referenced node is removed from the network, and any `NodeInput::Node`s used by the referenced node are replaced with a generically typed network input.
pub fn resolve_extract_nodes(&mut self) {
let mut extraction_nodes = self
.nodes
@ -898,33 +981,32 @@ impl NodeNetwork {
self.nodes.retain(|_, node| !matches!(node.implementation, DocumentNodeImplementation::Extract));
for (_, node) in &mut extraction_nodes {
if let DocumentNodeImplementation::Extract = node.implementation {
assert_eq!(node.inputs.len(), 1);
let NodeInput::Node { node_id, output_index, .. } = node.inputs.pop().unwrap() else {
panic!("Extract node has no input, inputs: {:?}", node.inputs);
assert_eq!(node.inputs.len(), 1);
let NodeInput::Node { node_id, output_index, .. } = node.inputs.pop().unwrap() else {
panic!("Extract node has no input, inputs: {:?}", node.inputs);
};
assert_eq!(output_index, 0);
// TODO: check if we can read lambda checking?
let mut input_node = self.nodes.remove(&node_id).unwrap();
node.implementation = DocumentNodeImplementation::Unresolved("graphene_core::value::ClonedNode".into());
if let Some(input) = input_node.inputs.get_mut(0) {
*input = match &input {
NodeInput::Node { .. } => NodeInput::Network(generic!(T)),
ni => NodeInput::Network(ni.ty()),
};
assert_eq!(output_index, 0);
// TODO: check if we can readd lambda checking
let mut input_node = self.nodes.remove(&node_id).unwrap();
node.implementation = DocumentNodeImplementation::Unresolved("graphene_core::value::ClonedNode".into());
if let Some(input) = input_node.inputs.get_mut(0) {
*input = match &input {
NodeInput::Node { .. } => NodeInput::Network(generic!(T)),
ni => NodeInput::Network(ni.ty()),
};
}
for input in input_node.inputs.iter_mut() {
if let NodeInput::Node { .. } = input {
*input = NodeInput::Network(generic!(T))
}
}
node.inputs = vec![NodeInput::value(TaggedValue::DocumentNode(input_node), false)];
}
for input in input_node.inputs.iter_mut() {
if let NodeInput::Node { .. } = input {
*input = NodeInput::Network(generic!(T))
}
}
node.inputs = vec![NodeInput::value(TaggedValue::DocumentNode(input_node), false)];
}
self.nodes.extend(extraction_nodes);
}
/// Creates a proto network for evaluating each output of this network.
pub fn into_proto_networks(self) -> impl Iterator<Item = ProtoNetwork> {
let mut nodes: Vec<_> = self.nodes.into_iter().map(|(id, node)| (id, node.resolve_proto_node())).collect();
nodes.sort_unstable_by_key(|(i, _)| *i);
@ -1116,7 +1198,7 @@ mod test {
let proto_node = document_node.resolve_proto_node();
let reference = ProtoNode {
identifier: "graphene_core::structural::ConsNode".into(),
input: ProtoNodeInput::Network(concrete!(u32)),
input: ProtoNodeInput::ManualComposition(concrete!(u32)),
construction_args: ConstructionArgs::Nodes(vec![(0, false)]),
document_node_path: vec![],
skip_deduplication: false,
@ -1134,7 +1216,7 @@ mod test {
10,
ProtoNode {
identifier: "graphene_core::structural::ConsNode".into(),
input: ProtoNodeInput::Network(concrete!(u32)),
input: ProtoNodeInput::ManualComposition(concrete!(u32)),
construction_args: ConstructionArgs::Nodes(vec![(14, false)]),
document_node_path: vec![1, 0],
skip_deduplication: false,

View file

@ -78,10 +78,14 @@ impl NodeContainer {
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Default, PartialEq, Clone, Hash, Eq)]
/// A list of [`ProtoNode`]s, which is an intermediate step between the [`crate::document::NodeNetwork`] and the `BorrowTree` containing a single flattened network.
pub struct ProtoNetwork {
// TODO: remove this since it seems to be unused?
// Should a proto Network even allow inputs? Don't think so
pub inputs: Vec<NodeId>,
/// The node ID that provides the output. This node is then responsible for calling the rest of the graph.
pub output: NodeId,
/// A list of nodes stored in a Vec to allow for sorting.
pub nodes: Vec<(NodeId, ProtoNode)>,
}
@ -104,8 +108,7 @@ impl core::fmt::Display for ProtoNetwork {
f.write_str("Primary input: ")?;
match &node.input {
ProtoNodeInput::None => f.write_str("None")?,
ProtoNodeInput::Network(ty) => f.write_fmt(format_args!("Network (type = {ty:?})"))?,
ProtoNodeInput::ShortCircut(ty) => f.write_fmt(format_args!("Lambda (type = {ty:?})"))?,
ProtoNodeInput::ManualComposition(ty) => f.write_fmt(format_args!("Manual Composition (type = {ty:?})"))?,
ProtoNodeInput::Node(_, _) => f.write_str("Node")?,
}
f.write_str("\n")?;
@ -137,10 +140,15 @@ impl core::fmt::Display for ProtoNetwork {
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
/// Defines the arguments used to construct the boxed node struct. This is used to call the constructor function in the `node_registry.rs` file - which is hidden behind a wall of macros.
pub enum ConstructionArgs {
/// A value of a type that is known, allowing serialization (serde::Deserialize is not object safe)
Value(value::TaggedValue),
// the bool indicates whether to treat the node as lambda node
// TODO: use a struct for clearer naming.
/// A list of nodes used as inputs to the constructor function in `node_registry.rs`.
/// The bool indicates whether to treat the node as lambda node.
Nodes(Vec<(NodeId, bool)>),
// TODO: What?
Inline(InlineRust),
}
@ -166,9 +174,9 @@ impl PartialEq for ConstructionArgs {
impl Hash for ConstructionArgs {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
core::mem::discriminant(self).hash(state);
match self {
Self::Nodes(nodes) => {
"nodes".hash(state);
for node in nodes {
node.hash(state);
}
@ -180,6 +188,7 @@ impl Hash for ConstructionArgs {
}
impl ConstructionArgs {
// TODO: what? Used in the gpu_compiler crate for something.
pub fn new_function_args(&self) -> Vec<String> {
match self {
ConstructionArgs::Nodes(nodes) => nodes.iter().map(|n| format!("n{:0x}", n.0)).collect(),
@ -191,6 +200,7 @@ impl ConstructionArgs {
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, PartialEq, Hash, Eq)]
/// A protonode is an intermediate step between the `DocumentNode` and the boxed struct that actually runs the node (found in the [`BorrowTree`]). It has one primary input and several secondary inputs in [`ConstructionArgs`].
pub struct ProtoNode {
pub construction_args: ConstructionArgs,
pub input: ProtoNodeInput,
@ -200,17 +210,22 @@ pub struct ProtoNode {
pub hash: u64,
}
/// A ProtoNodeInput represents the input of a node in a ProtoNetwork.
/// For documentation on the meaning of the variants, see the documentation of the `NodeInput` enum
/// in the `document` module
/// A ProtoNodeInput represents the primary input of a node in a ProtoNetwork.
/// Similar to [`crate::document::NodeInput`].
#[derive(Debug, PartialEq, Eq, Clone, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ProtoNodeInput {
/// [`ProtoNode`]s do not require any input, e.g. the value node just takes in [`ConstructionArgs`].
None,
Network(Type),
/// A ShortCircut input represents an input that is not resolved through function composition but
/// actually consuming the provided input instead of passing it to its predecessor
ShortCircut(Type),
/// A ManualComposition input represents an input that opts out of being resolved through the default `ComposeNode`, which first runs the previous (upstream) node, then passes that evaluated result to this node
/// Instead, ManualComposition lets this node actually consume the provided input instead of passing it to its predecessor.
///
/// Say we have the network `a -> b -> c` where `c` is the output node and `a` is the input node.
/// We would expect `a` to get input from the network, `b` to get input from `a`, and `c` to get input from `b`.
/// This could be represented as `f(x) = c(b(a(x)))`. `a` is run with input `x` from the network. `b` is run with input from `a`. `c` is run with input from `b`.
///
/// However if `b`'s input is using manual composition, this means it would instead be `f(x) = c(b(x))`. This means that `b` actually gets input from the network, and `a` is not automatically executed as it would be using the default ComposeNode flow.
ManualComposition(Type),
/// the bool indicates whether to treat the node as lambda node.
/// When treating it as a lambda, only the node that is connected itself is fed as input.
/// Otherwise, the the entire network of which the node is the output is fed as input.
@ -227,6 +242,8 @@ impl ProtoNodeInput {
}
impl ProtoNode {
/// A stable node ID is a hash of a node that should stay constant. This is used in order to remove duplicates from the graph.
/// In the case of `skip_deduplication`, the `document_node_path` is also hashed in order to avoid duplicate monitor nodes from being removed (which would make it impossible to load thumbnails).
pub fn stable_node_id(&self) -> Option<NodeId> {
use std::hash::Hasher;
let mut hasher = rustc_hash::FxHasher::default();
@ -240,10 +257,7 @@ impl ProtoNode {
std::mem::discriminant(&self.input).hash(&mut hasher);
match self.input {
ProtoNodeInput::None => (),
ProtoNodeInput::ShortCircut(ref ty) => {
ty.hash(&mut hasher);
}
ProtoNodeInput::Network(ref ty) => {
ProtoNodeInput::ManualComposition(ref ty) => {
ty.hash(&mut hasher);
}
ProtoNodeInput::Node(id, lambda) => (id, lambda).hash(&mut hasher),
@ -251,6 +265,7 @@ impl ProtoNode {
Some(hasher.finish() as NodeId)
}
/// Construct a new [`ProtoNode`] with the specified construction args and a `ClonedNode` implementation.
pub fn value(value: ConstructionArgs, path: Vec<NodeId>) -> Self {
Self {
identifier: NodeIdentifier::new("graphene_core::value::ClonedNode"),
@ -262,6 +277,8 @@ impl ProtoNode {
}
}
/// Converts all references to other node IDs into new IDs by running the specified function on them.
/// This can be used when changing the IDs of the nodes, for example in the case of generating stable IDs.
pub fn map_ids(&mut self, f: impl Fn(NodeId) -> NodeId, skip_lambdas: bool) {
if let ProtoNodeInput::Node(id, lambda) = self.input {
if !(skip_lambdas && lambda) {
@ -289,6 +306,7 @@ impl ProtoNetwork {
);
}
/// Construct a hashmap containing a list of the nodes that depend on this proto network.
pub fn collect_outwards_edges(&self) -> HashMap<NodeId, Vec<NodeId>> {
let mut edges: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
for (id, node) in &self.nodes {
@ -306,6 +324,8 @@ impl ProtoNetwork {
edges
}
/// Convert all node IDs to be stable (based on the hash generated by [`ProtoNode::stable_node_id`]).
/// This function requires that the graph be topologically sorted.
pub fn generate_stable_node_ids(&mut self) {
debug_assert!(self.is_topologically_sorted());
let outwards_edges = self.collect_outwards_edges();
@ -319,6 +339,7 @@ impl ProtoNetwork {
}
}
/// Create a hashmap with the list of nodes this proto network depends on/uses as inputs.
pub fn collect_inwards_edges(&self) -> HashMap<NodeId, Vec<NodeId>> {
let mut edges: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
for (id, node) in &self.nodes {
@ -336,6 +357,7 @@ impl ProtoNetwork {
edges
}
/// Inserts a [`graphene_core::structural::ComposeNode`] for each node that has a [`ProtoNodeInput::Node`]. The compose node evaluates the first node, and then sends the result into the second node.
pub fn resolve_inputs(&mut self) -> Result<(), String> {
// Perform topological sort once
self.reorder_ids()?;
@ -375,6 +397,7 @@ impl ProtoNetwork {
Ok(())
}
/// Update all of the references to a node ID in the graph with a new ID named `compose_node_id`.
fn replace_node_id(&mut self, outwards_edges: &HashMap<u64, Vec<u64>>, node_id: u64, compose_node_id: u64, skip_lambdas: bool) {
// Update references in other nodes to use the new compose node
if let Some(referring_nodes) = outwards_edges.get(&node_id) {
@ -469,6 +492,7 @@ impl ProtoNetwork {
sorted
}*/
/// Sort the nodes vec so it is in a topological order. This ensures that no node takes an input from a node that is found later in the list.
fn reorder_ids(&mut self) -> Result<(), String> {
let order = self.topological_sort()?;
@ -571,8 +595,7 @@ impl TypingContext {
// Get the node input type from the proto node declaration
let input = match node.input {
ProtoNodeInput::None => concrete!(()),
ProtoNodeInput::ShortCircut(ref ty) => ty.clone(),
ProtoNodeInput::Network(ref ty) => ty.clone(),
ProtoNodeInput::ManualComposition(ref ty) => ty.clone(),
ProtoNodeInput::Node(id, _) => {
let input = self
.inferred
@ -794,7 +817,7 @@ mod test {
10,
ProtoNode {
identifier: "cons".into(),
input: ProtoNodeInput::Network(concrete!(u32)),
input: ProtoNodeInput::ManualComposition(concrete!(u32)),
construction_args: ConstructionArgs::Nodes(vec![(14, false)]),
document_node_path: vec![],
skip_deduplication: false,

View file

@ -11,9 +11,12 @@ use graph_craft::Type;
use crate::node_registry;
/// An executor of a node graph that does not require an online compilation server, and instead uses `Box<dyn ...>`.
pub struct DynamicExecutor {
output: NodeId,
/// Stores all of the dynamic node structs.
tree: BorrowTree,
/// Stores the types of the protonodes.
typing_context: TypingContext,
// This allows us to keep the nodes around for one more frame which is used for introspection
orphaned_nodes: Vec<NodeId>,
@ -45,6 +48,7 @@ impl DynamicExecutor {
})
}
/// Updates the existing [`BorrowTree`] to reflect the new [`ProtoNetwork`], reusing nodes where possible.
pub async fn update(&mut self, proto_network: ProtoNetwork) -> Result<(), String> {
self.output = proto_network.output;
self.typing_context.update(&proto_network)?;
@ -58,6 +62,7 @@ impl DynamicExecutor {
Ok(())
}
/// Calls the `Node::serialize` for that specific node, returning for example the cached value for a monitor node. The node path must match the document node path.
pub fn introspect(&self, node_path: &[NodeId]) -> Option<Option<Arc<dyn std::any::Any>>> {
self.tree.introspect(node_path)
}
@ -78,8 +83,11 @@ impl<'a, I: StaticType + 'a> Executor<I, TaggedValue> for &'a DynamicExecutor {
}
#[derive(Default)]
/// A store of the dynamically typed nodes and also the source map.
pub struct BorrowTree {
/// A hashmap of node IDs and dynamically typed nodes.
nodes: HashMap<NodeId, SharedNodeContainer>,
/// A hashmap from the document path to the protonode ID.
source_map: HashMap<Vec<NodeId>, NodeId>,
}
@ -114,6 +122,7 @@ impl BorrowTree {
self.nodes.insert(id, node);
}
/// Calls the `Node::serialize` for that specific node, returning for example the cached value for a monitor node. The node path must match the document node path.
pub fn introspect(&self, node_path: &[NodeId]) -> Option<Option<Arc<dyn std::any::Any>>> {
let id = self.source_map.get(node_path)?;
let node = self.nodes.get(id)?;
@ -124,11 +133,14 @@ impl BorrowTree {
self.nodes.get(&id).cloned()
}
/// Evaluate the output node of the [`BorrowTree`].
pub async fn eval<'i, I: StaticType + 'i, O: StaticType + 'i>(&'i self, id: NodeId, input: I) -> Option<O> {
let node = self.nodes.get(&id).cloned()?;
let output = node.eval(Box::new(input));
dyn_any::downcast::<O>(output.await).ok().map(|o| *o)
}
/// Evaluate the output node of the [`BorrowTree`] and cast it to a tagged value.
/// This ensures that no borrowed data can escape the node graph.
pub async fn eval_tagged_value<'i, I: StaticType + 'i>(&'i self, id: NodeId, input: I) -> Result<TaggedValue, String> {
let node = self.nodes.get(&id).cloned().ok_or("Output node not found in executor")?;
let output = node.eval(Box::new(input));
@ -139,6 +151,7 @@ impl BorrowTree {
self.nodes.remove(&id);
}
/// Insert a new node into the borrow tree, calling the constructor function from `node_registry.rs`.
pub async fn push_node(&mut self, id: NodeId, proto_node: ProtoNode, typing_context: &TypingContext) -> Result<(), String> {
let ProtoNode {
construction_args,

View file

@ -90,7 +90,7 @@ mod tests {
inputs: vec![0],
outputs: vec![NodeOutput::new(1, 0)],
nodes: [
// Simple identity node taking a number as input from ouside the graph
// Simple identity node taking a number as input from outside the graph
(
0,
DocumentNode {