How to Create Port Arrays Using IPortArrayNode for Dynamic Connections in Ceres
Implement the IPortArrayNode interface on your node class, expose an array field decorated with [InputPort] or [OutputPort], and store the array length in a hidden integer field to enable dynamic port addition and removal in the Ceres graph editor.
Ceres is an open-source visual scripting framework for Unity that supports dynamic port configurations through the IPortArrayNode interface. When you need to create port arrays using IPortArrayNode for dynamic connections, you enable designers to add, remove, and reorder ports at edit-time without modifying code. This article explains the architecture and provides complete implementation examples from the akikurisu/ceres repository.
Understanding the IPortArrayNode Architecture
The dynamic port system relies on a clear separation between the runtime node logic and the editor visualization. When a node implements IPortArrayNode, the Ceres editor automatically switches to the ExecutablePortArrayNodeView resolver, which handles the variable port count.
Core Interface Components
The IPortArrayNode interface, defined in Runtime/Core/Models/Graph/Nodes/PortArrayNodeReflection.cs, requires three members:
GetPortArrayLength(): Returns the current number of ports to display.GetPortArrayFieldName(): Returns the string name of the field that holds the port array (e.g.,"outputs").SetPortArrayLength(int length): Called during compilation to allocate the runtime array.
A read-only variant, IReadOnlyPortArrayNode, exists for nodes that only need to query port counts without mutation.
Editor Integration and Visualization
The ExecutablePortArrayNodeView class in Editor/Flow/UIElements/Nodes/Core/ExecutablePortArrayNodeView.cs manages the visual representation. When initializing, it queries GetPortArrayLength and creates the corresponding number of port widgets. The view automatically adds context-menu items for Add new port, Remove last port, and Remove unconnected ports, invoking the interface methods to keep the node state synchronized.
During graph compilation, the view's CompileNode method calls SetPortArrayLength before resolving field values, ensuring the runtime node allocates the correct sized array.
Step-by-Step Implementation Guide
Follow these steps to create port arrays using IPortArrayNode for dynamic connections in your own Ceres nodes.
1. Define the Port Array Field
Declare a public array field using NodePort[] for flow connections or CeresPort<T>[] for data. Apply the [InputPort] or [OutputPort] attribute, plus [CeresLabel] for display names and [CeresMetadata] to specify the default length.
[OutputPort(false), CeresLabel("Then"), CeresMetadata("DefaultLength = 2")]
public NodePort[] outputs;
2. Store the Array Length
Add a hidden integer field to persist the current port count. The [HideInGraphEditor] attribute keeps this value invisible in the node UI while allowing serialization.
[HideInGraphEditor]
public int outputCount;
3. Implement the IPortArrayNode Interface
Implement the three required members to bridge the editor and runtime. Return the hidden length field in GetPortArrayLength, provide the field name in GetPortArrayFieldName, and update the length in SetPortArrayLength.
public int GetPortArrayLength() => outputCount;
public string GetPortArrayFieldName() => nameof(outputs);
public void SetPortArrayLength(int length) => outputCount = length;
4. Handle Runtime Execution
In your Execute method, iterate over the array to process connections. Ensure you handle null checks since unconnected ports may contain null references.
protected sealed override async UniTask Execute(ExecutionContext ctx)
{
foreach (var outPort in outputs)
{
var next = outPort.GetT<ExecutableNode>();
if (next != null) await ctx.Forward(next);
}
}
Complete Code Examples from the Ceres Repository
These concrete implementations from akikurisu/ceres demonstrate the pattern in production code.
FlowNode_Sequence: Output Port Array
Located in Runtime/Flow/Models/Nodes/Utilities/FlowNode_Sequence.cs, this node executes multiple output branches sequentially using a dynamic outputs array.
using System;
using Cysharp.Threading.Tasks;
using Ceres.Annotations;
using Ceres.Graph.Flow;
using Ceres.Graph.Flow.Utilities;
using UnityEngine;
namespace Ceres.Graph.Flow.Utilities
{
[Serializable]
[CeresGroup("Utilities")]
[CeresLabel("Sequence")]
public class FlowNode_Sequence : ForwardNode,
ISerializationCallbackReceiver,
IPortArrayNode
{
[OutputPort(false), CeresLabel("Then"), CeresMetadata("DefaultLength = 2")]
public NodePort[] outputs;
[HideInGraphEditor] public int outputCount;
protected sealed override async UniTask Execute(ExecutionContext ctx)
{
foreach (var outPort in outputs)
{
var next = outPort.GetT<ExecutableNode>();
if (next != null) await ctx.Forward(next);
}
}
public int GetPortArrayLength() => outputCount;
public string GetPortArrayFieldName() => nameof(outputs);
public void SetPortArrayLength(int length) => outputCount = length;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize()
{
if (outputs == null || outputs.Length != outputCount)
outputs = new NodePort[outputCount];
}
}
}
FlowNode_MakeArrayT: Generic Input Port Array
Found in Runtime/Flow/Models/Nodes/Utilities/Array/FlowNode_MakeArrayT.cs, this generic node aggregates multiple inputs into an array, demonstrating input port arrays with type safety.
using System;
using Cysharp.Threading.Tasks;
using Ceres.Annotations;
using Ceres.Graph.Flow;
using UnityEngine;
namespace Ceres.Graph.Flow.Utilities
{
[Serializable]
[CeresGroup("Utilities/Array")]
[CeresLabel("Make {0} Array")]
public class FlowNode_MakeArrayT<T> : FlowNode_MakeArray,
ISerializationCallbackReceiver,
IPortArrayNode
{
[InputPort, CeresMetadata("DefaultLength = 1")]
public CeresPort<T>[] items;
[HideInGraphEditor] public int inputCount;
[OutputPort] public CeresPort<T[]> array;
protected sealed override UniTask Execute(ExecutionContext ctx)
{
array.Value = new T[inputCount];
for (int i = 0; i < inputCount; i++)
array.Value[i] = items[i].Value;
return UniTask.CompletedTask;
}
public int GetPortArrayLength() => inputCount;
public string GetPortArrayFieldName() => nameof(items);
public void SetPortArrayLength(int length)
{
inputCount = length;
items = new CeresPort<T>[inputCount];
for (int i = 0; i < length; i++) items[i] = new CeresPort<T>();
}
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
}
Editor-Side Compilation Process
While you do not need to write editor code, understanding the compilation process helps debug array allocation issues. The ExecutablePortArrayNodeView class handles the transition from editor visualization to runtime instance.
When the graph compiles, the view performs these operations:
- Instantiates the runtime node using
Activator.CreateInstance - Checks if the instance implements
IPortArrayNode - Calls
SetPortArrayLengthwith the current editor port count - Proceeds with field resolution and port value committing
This sequence ensures the backing array exists before the runtime attempts to access it.
// From Editor/Flow/UIElements/Nodes/Core/ExecutablePortArrayNodeView.cs
public override ExecutableNode CompileNode()
{
var nodeInstance = (ExecutableNode)Activator.CreateInstance(NodeType);
if (nodeInstance is IPortArrayNode portArrayNode)
portArrayNode.SetPortArrayLength(PortLength);
// …field resolution & port committing…
return nodeInstance;
}
Key Source Files
| File | Purpose | Location |
|---|---|---|
PortArrayNodeReflection.cs |
Defines IPortArrayNode and IReadOnlyPortArrayNode interfaces |
Runtime/Core/Models/Graph/Nodes/PortArrayNodeReflection.cs |
ExecutablePortArrayNodeView.cs |
Editor resolver that builds the dynamic port UI | Editor/Flow/UIElements/Nodes/Core/ExecutablePortArrayNodeView.cs |
FlowNode_Sequence.cs |
Concrete example of output port arrays | Runtime/Flow/Models/Nodes/Utilities/FlowNode_Sequence.cs |
FlowNode_MakeArrayT.cs |
Generic example of input port arrays | Runtime/Flow/Models/Nodes/Utilities/Array/FlowNode_MakeArrayT.cs |
Summary
- Implement
IPortArrayNodeto enable dynamic port arrays in Ceres nodes. - Store array length in a hidden field marked with
[HideInGraphEditor]to persist the port count. - Return the field name via
GetPortArrayFieldName()so the editor knows which array to manipulate. - Allocate arrays in
SetPortArrayLength()during compilation to ensure runtime availability. - Use
NodePort[]for flow connections andCeresPort<T>[]for typed data inputs or outputs.
Frequently Asked Questions
What is the difference between IPortArrayNode and IReadOnlyPortArrayNode?
IPortArrayNode provides full read-write access to the port array length, allowing the editor to add and remove ports via SetPortArrayLength. IReadOnlyPortArrayNode only declares GetPortArrayLength and GetPortArrayFieldName, suitable for nodes that need to report dynamic port counts without allowing external mutation.
How does the editor know which field represents the port array?
The editor calls GetPortArrayFieldName() on your node instance, which must return the exact string name of the array field (e.g., "outputs" or "items"). The ExecutablePortArrayNodeView uses this name to locate the field via reflection and to generate the correct number of port widgets in the UI.
Can I use IPortArrayNode with generic type parameters?
Yes, as demonstrated by FlowNode_MakeArrayT<T> in FlowNode_MakeArrayT.cs. The interface implementation works identically for generic classes. You declare the array using CeresPort<T>[] and implement the three interface members normally, with the generic type parameter propagating to the port types automatically.
When is SetPortArrayLength called during the node lifecycle?
SetPortArrayLength is called during graph compilation in the editor, specifically within ExecutablePortArrayNodeView.CompileNode() before field values are committed. It is not called at runtime during execution; by the time Execute runs, the array should already be allocated and populated. This method essentially transfers the editor's port count to the runtime instance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →