Building a Graph Tool with gs_graphcanvas
This lesson walks through the full process of creating a new visual graph editor tool using the gs_graphcanvas framework. By the end, you will have a working editor with nodes, a palette, save/load, undo/redo, variables, and runtime execution.
The pipeline is proven across three implementations: the Dialogue Editor (FlowGraph), the Audio Event Graph (DataFlowGraph), and the Unit Action Graph (StateMachineGraph).
What You Provide vs What the Framework Gives You
You provide (per tool):
- A
GraphSystemDescriptor— identity and topology choice - A
GraphContextsubclass — domain-specific data types (if any) - Node types — registered via macros
- An editor window — MainWindow subclass (or direct use)
- An editor system component — reflects nodes, registers the view pane
- A runtime component — (optional) bridges graphs to game systems
The framework gives you (free):
- Full editor window with menus, toolbar, palette, canvas
- Multi-document tabs with dirty tracking
- Save/load (XML wrapper + binary graph buffer)
- Undo/redo (snapshot-based, per-graph)
- Variable system (declarations, Get/Set nodes, references, drag-to-canvas)
- Inspector panel (auto-generated from node EditContext)
- Node palette with drag-drop
- Full-window page system
- Three execution engines (flow, data-flow, state machine)
- Copy/paste, selection, bookmarks
Pages
- Step 1: Choose Your Topology
- Step 2: Create the Descriptor
- Step 3: Create the GraphContext
- Step 4: Create Nodes
- Step 5: Create the Editor Window
- Step 6: Register the System Component
- Step 7: Build Runtime (Optional)
- Pitfalls Checklist
Step 1: Choose Your Topology
This is the fundamental design decision. It determines connections, execution model, and slot types.
| Topology | Use Case | Execution | Connections |
|---|---|---|---|
| FlowGraph | Sequential processes, dialogue trees, scripting | Step/wait/resume via FlowIn/FlowOut | Directional (left-to-right) |
| DataFlowGraph | Signal processing, audio, shaders, procedural generation | Topological dirty-propagation | Directional (left-to-right) |
| StateMachineGraph | State machines, behavior trees, HFSM | Tick-based state transitions | Multidirectional (perimeter arrows) |
Key constraints:
- DataFlowGraph requires a DAG (no cycles) for topological sorting
- StateMachineGraph inherently supports cycles
- FlowGraph optionally supports cycles via
allowConnectionLoopback
Step 2: Create the Descriptor
Create a header that returns a filled-out GraphSystemDescriptor:
#pragma once
#include <GS_GraphCanvas/GraphSystemDescriptor.h>
namespace GS_MyTool
{
inline GS_GraphCanvas::GraphSystemDescriptor CreateMyToolDescriptor()
{
GS_GraphCanvas::GraphSystemDescriptor desc;
// ===== Identity =====
desc.systemId = "mytool";
desc.systemName = "My Tool Editor";
desc.fileExtension = ".mytool";
desc.mimeType = "application/x-gs-mytool-node";
desc.saveIdentifier = "MyToolEditor";
desc.editorId = GraphCanvas::EditorId("MyToolEditor");
// ===== Topology =====
desc.topology = GS_GraphCanvas::GraphTopology::DataFlowGraph;
// ===== Features =====
desc.variablesEnabled = true;
desc.allowConnectionLoopback = false;
// ===== UI =====
desc.windowTitle = "My Tool Editor";
desc.menuCategory = "GS Tools";
return desc;
}
}
Every field in the descriptor is documented in the Descriptor & Topology reference.
Step 3: Create the GraphContext
Register any domain-specific data types beyond the built-in set (Bool, Int, Float, String, Vector2, Vector3, Color, EntityId).
#pragma once
#include <GS_GraphCanvas/Core/CommonDataTypes.h>
#include <GraphModel/Model/GraphContext.h>
namespace GS_MyTool
{
class MyToolGraphContext : public GraphModel::GraphContext
{
public:
MyToolGraphContext();
static void RegisterDataTypes(GraphModel::GraphContextPtr context)
{
// Built-in CommonDataTypes are registered automatically.
// Add domain-specific types here if needed:
// context->RegisterDataType(MyCustomEnum, azrtti_typeid<MyStruct>(),
// MyStruct{}, "MyCustomType");
}
};
}
If your tool only uses the built-in types, the context subclass can be minimal.
Step 4: Create Nodes
Basic Node Template
#pragma once
#include <GS_GraphCanvas/Nodes/BaseNode.h>
#include <GS_GraphCanvas/Widgets/SimpleNodeMacros.h>
#include <GS_GraphCanvas/NodeRegistry.h>
namespace GS_MyTool
{
class MyTool_ExampleNode : public GS_GraphCanvas::BaseNode
{
public:
AZ_RTTI(MyTool_ExampleNode, "{GENERATE-UNIQUE-UUID}", GS_GraphCanvas::BaseNode);
AZ_CLASS_ALLOCATOR(MyTool_ExampleNode, AZ::SystemAllocator);
static constexpr const char* TITLE = "Example Node";
static constexpr const char* CATEGORY = "Processing";
static void Reflect(AZ::ReflectContext* context);
const char* GetTitle() const override { return TITLE; }
protected:
void RegisterSlots() override
{
GS_INPUT_SLOT_TYPED("value_in", "Value", CommonDataTypes::Float, 0.0f);
GS_OUTPUT_SLOT_TYPED("result_out", "Result", CommonDataTypes::Float);
}
float m_multiplier = 1.0f;
};
GS_AUTO_REGISTER_NODE_FOR(MyTool_ExampleNode, "mytool")
}
Reflect Implementation
void MyTool_ExampleNode::Reflect(AZ::ReflectContext* context)
{
if (auto* sc = azrtti_cast<AZ::SerializeContext*>(context))
{
if (sc->FindClassData(azrtti_typeid<MyTool_ExampleNode>())) { return; }
sc->Class<MyTool_ExampleNode, GS_GraphCanvas::BaseNode>()
->Version(1)
->Field("Multiplier", &MyTool_ExampleNode::m_multiplier)
;
}
if (auto* ec = azrtti_cast<AZ::EditContext*>(context))
{
ec->Class<MyTool_ExampleNode>("Example Node", "A processing node")
->ClassElement(AZ::Edit::ClassElements::EditorData, "")
->Attribute(AZ::Edit::Attributes::AutoExpand, true)
->DataElement(AZ::Edit::UIHandlers::Default, &MyTool_ExampleNode::m_multiplier,
"Multiplier", "Scale factor")
;
}
}
Implementing Execution
Choose the interface matching your topology:
FlowGraph — IExecutableNode:
FlowResult Execute(GraphExecutionContext& context) override
{
float input = context.GetInputValue<float>(this, "value_in");
context.SetOutputValue(this, "result_out", input * m_multiplier);
return FlowResult::Continue; // or Wait, Stop
}
DataFlowGraph — IDataFlowNode:
void Process(GraphExecutionContext& context) override
{
auto inputAny = context.GetInputValueAny(this, "value_in");
if (inputAny.empty())
{
context.SetOutputValueAny(this, "result_out", AZStd::any{});
return;
}
float input = AZStd::any_cast<float>(inputAny);
context.SetOutputValue(this, "result_out", input * m_multiplier);
}
StateMachineGraph — IStateMachineNode:
void OnEnter(GraphExecutionContext& context) override { /* ... */ }
void OnTick(GraphExecutionContext& context, float deltaTime) override { /* ... */ }
void OnExit(GraphExecutionContext& context) override { /* ... */ }
Step 5: Create the Editor Window
Simple: Standalone File Editor
If each graph is its own file (like the Audio Event Graph), use MainWindow directly:
class MyToolEditorWindow : public GS_GraphCanvas::MainWindow
{
public:
MyToolEditorWindow(const GS_GraphCanvas::GraphSystemDescriptor& desc)
: GS_GraphCanvas::MainWindow(desc)
{}
};
This gives you a fully functional editor with no additional code.
Advanced: Container/Database Editor
If multiple graphs live in one file (like dialogue sequences or unit action layers), override file operations and add management UI:
class MyToolEditorWindow : public GS_GraphCanvas::MainWindow
{
protected:
// Override file menu for container-level operations
void AddFileNewAction() override; // New container
void AddFileOpenAction() override; // Open container
void AddFileSaveAction() override; // Save entire container
void AddFileSaveAsAction() override;
// Add non-graph pages
void SetupPages()
{
SetGraphPageTitle("Graphs");
AddFullWindowPage("Settings", m_settingsPage);
}
// Container save pattern
void SaveContainer()
{
for (auto& [graphId, entry] : m_openGraphs)
CaptureGraphToAsset(graphId, entry.graphData, serializeContext);
AZ::Utils::SaveObjectToFile(m_path, AZ::DataStream::ST_XML, &m_container);
ClearAllDocumentDirty();
}
};
Step 6: Register the System Component
The editor system component reflects all node types and registers the view pane:
class GS_MyToolEditorSystemComponent : public AZ::Component
{
public:
AZ_COMPONENT(GS_MyToolEditorSystemComponent, "{UNIQUE-UUID}");
static void Reflect(AZ::ReflectContext* context)
{
// Reflect all node types
MyTool_ExampleNode::Reflect(context);
// ... all your nodes ...
// Reflect MIME events for palette drag-drop
GS_GraphCanvas::NodeRegistry::Get().ReflectAllMimeEvents(context);
// Reflect the component
if (auto* sc = azrtti_cast<AZ::SerializeContext*>(context))
{
sc->Class<GS_MyToolEditorSystemComponent, AZ::Component>()
->Version(1);
}
}
void Activate() override
{
AzToolsFramework::ViewPaneOptions options;
options.paneRect = QRect(50, 50, 1200, 800);
AzToolsFramework::RegisterViewPane<MyToolEditorWindow>(
"My Tool Editor", "GS Tools", options);
}
void Deactivate() override
{
AzToolsFramework::UnregisterViewPane("My Tool Editor");
}
};
Add the component descriptor to your gem’s editor module:
// In GS_MyToolEditorModule.cpp
m_descriptors.push_back(GS_MyToolEditorSystemComponent::CreateDescriptor());
Step 7: Build Runtime (Optional)
If your tool needs runtime execution (not just an editor-only design tool):
- Load the graph asset (directly or via a template cache for pooling)
- Create a
GraphInstanceviaGraphInstance::CreateFromAsset() - Create the appropriate evaluator (
FlowGraphEvaluator,DataFlowGraphEvaluator, orStateMachineEvaluator) - Expose an API via EBus for triggering and controlling execution
Pooling Pattern (High-Frequency Execution)
For graphs that are instantiated frequently (like audio events), follow the template cache pattern:
TemplateCache
-> Caches GraphDocumentAsset per file path
-> Pools idle GraphInstance objects
-> CreateFromAsset() for new instances
-> Return to idle pool on release
Pitfalls Checklist
Reflection
- Every
Reflect()must have aFindClassDataguard (gs_graphcanvas is a static lib) - Every
sc->Enum<T>()must be guarded — enums crash on double-registration - All UUIDs must be globally unique (collision = crash in
ResolvePointer) - Polymorphic containers must use
AZStd::vector<Base*>(raw pointers, not smart pointers) EnableForAssetEditorgoes on SerializeContext only, never EditContext- Field names must be unique, non-empty PascalCase strings
- Use
AZ_DISABLE_COPYon classes with polymorphic pointer vectors - Do not use
idas a member variable name (conflicts withAZ_RTTImacro)
Data Flow
- Inactive nodes must output
AZStd::any{}, not default-constructed structs - Multi-input slots must be declared explicitly per-slot
- Node auto-registration must use the correct systemId string
Editor
NodeRegistry.hmust be included beforeStandardNodePaletteItem.h(MSVC requirement)- Editor system component must reflect all node types and call
ReflectAllMimeEvents() - View pane must be registered in
Activate()and unregistered inDeactivate()
Save/Load
- Graph document asset must be a plain struct (not
AZ::Data::AssetData) - Graphs must be serialized as byte buffers (not as
shared_ptrfields) - Transient display data (cached titles, etc.) must be rebuilt after deserialization
See Also
- gs_graphcanvas Framework Reference — Full API documentation
- Dialogue Editor — FlowGraph example
- Audio Event Graph — DataFlowGraph example
- Unit Action Graph — StateMachineGraph example