I'm working on a material/shader graph system. I came up with a simple solution, but nodes that weren't connected were used in some situations. So, I thought of using Kahn's algorithm to execute nodes' tasks. I'm not quite sure if it's a proper implementation.
I've only played with it, so it's a single file to avoid jumping between many files.
https://godbolt.org/z/8hYEWbGsv
includes
#include <iostream>
#include <ranges>
#include <vector>
#include <cinttypes>
#include <memory_resource>
There is a generic DAG
class that references Node
s and Edge
s.
It also sorts and executes nodes' code - DAG::Execute()
function
struct DAG
{
DAG()
{
nodes.reserve(16);
edges.reserve(32);
}
~DAG() = default;
DAG(const DAG&) = delete;
DAG& operator=(const DAG&) = delete;
struct Node
{
Node(DAG& dag)
: id(dag.AddNode(this))
{
}
virtual ~Node() = default;
Node(const Node&) = delete;
Node& operator=(const Node&) = delete;
virtual void Execute() = 0;
[[nodiscard]] inline std::size_t getID() const { return id; }
[[nodiscard]] inline std::uint32_t getRefCount() const { return refCount; }
inline void MakeTarget() { target = true; }
[[nodiscard]] inline bool isTarget() const { return target; }
private:
friend struct DAG;
std::uint32_t refCount = 0;
const std::size_t id = 0;
bool target = false;
};
struct Edge
{
Edge(DAG& dag, const std::size_t fromNode, const std::size_t toNode)
: fromNode(fromNode), toNode(toNode)
{
dag.AddEdge(this);
}
Edge(const Edge&) = delete;
Edge& operator=(const Edge& rhs) = delete;
const std::size_t fromNode = 0;
const std::size_t toNode = 0;
};
[[nodiscard]] inline auto getOutgoingEdges(const Node* const node)
{
return edges | std::views::filter([node](const Edge* const edge) { return edge->fromNode == node->getID(); });
}
[[nodiscard]] inline auto getIncomingEdges(const Node* const node)
{
return edges | std::views::filter([node](const Edge* const edge) { return edge->toNode == node->getID(); });
}
void Execute();
private:
[[nodiscard]] inline std::size_t AddNode(Node* node)
{
std::size_t id = nodes.size();
nodes.push_back(node);
return id;
}
inline void AddEdge(Edge* edge)
{
edges.push_back(edge);
}
std::vector<Node*> nodes;
std::vector<Edge*> edges;
};
void DAG::Execute()
{
//Reset nodes refCount before sorting
for (Node* const node : nodes) {
node->refCount = 0;
}
for (const Edge* const edge : edges) {
Node* const node = nodes[edge->fromNode];
node->refCount++;
}
std::vector<Node*> stack;
for (Node* const node : nodes) {
if (node->getRefCount() == 0) {
stack.push_back(node);
}
}
while (!stack.empty()) {
Node* const node = stack.back();
stack.pop_back();
//If the current node is not a target
//and if it hasn't got out edges
//don't execute the Node::Execute() function
//and don't process it's incoming edges
auto&& outEdges = getOutgoingEdges(node);
if (outEdges.empty() && !node->isTarget()) {
continue;
}
node->Execute();
auto&& edges = getIncomingEdges(node);
for (const Edge* const edge : edges) {
Node* const linkedNode = nodes[edge->fromNode];
if (--linkedNode->refCount == 0) {
stack.push_back(linkedNode);
}
}
}
}
What I'm not sure in DAG::Execute()
function is this part:
auto&& outEdges = getOutgoingEdges(node);
if (outEdges.empty() && !node->isTarget()) {
continue;
}
I don't even know why, but it just feels not right. It's not a standard Kahn algorithm, but I need it to cull not connected nodes. See the results at the end of this question.
The final Node
, Edge
and Pin
look like this:
struct Graph;
struct Node : DAG::Node
{
Node(Graph& g, const std::string_view name);
void Execute() override;
inline void AddInput(const std::size_t pinID) { inputs.push_back(pinID); }
inline void AddOuput(const std::size_t outputID) { outputs.push_back(outputID); }
[[nodiscard]] inline const std::string& getName() const { return name; }
[[nodiscard]] inline const std::vector<size_t>& getInputs() const { return inputs; }
[[nodiscard]] inline const std::vector<size_t>& getOutputs() const { return outputs; }
[[nodiscard]] inline bool hasInputs() const { return !inputs.empty(); }
[[nodiscard]] inline bool hasOutputs() const { return !outputs.empty(); }
private:
std::vector<std::size_t> inputs;
std::vector<std::size_t> outputs;
Graph& g;
const std::string name;
};
struct Pin
{
Pin(const std::string_view name, const std::size_t id, const std::size_t nodeID)
: name(name), id(id), nodeID(nodeID)
{
}
[[nodiscard]] inline std::size_t getID() const { return id; }
[[nodiscard]] inline std::size_t getNodeID() const { return nodeID; }
[[nodiscard]] inline std::uint32_t getRefCount() const { return refCount; }
[[nodiscard]] inline bool isCulled() const { return refCount == 0; }
[[nodiscard]] inline const std::string& getName() const { return name; }
inline void IncrementRefCount() { refCount++; }
inline void Reset() { refCount = 0; }
inline void AddOutputEdge(const std::size_t id) { outputEdges.push_back(id); }
inline void setInputEdge(const std::size_t id) { inputEdge = id; }
[[nodiscard]] inline const std::vector<std::size_t>& getOutputEdges() const { return outputEdges; }
[[nodiscard]] inline const std::optional<std::size_t>& getInputEdge() const { return inputEdge; }
private:
std::vector<std::size_t> outputEdges;
std::optional<std::size_t> inputEdge = std::nullopt;
std::string name;
std::uint32_t refCount = 0;
const std::size_t id = 0;
const std::size_t nodeID = 0;
};
struct Edge : DAG::Edge
{
Edge(Graph& g,
const std::size_t fromPin,
const std::size_t toPin,
const std::size_t fromNode,
const std::size_t toNode);
const std::size_t fromPin = 0;
const std::size_t toPin = 0;
};
Note that Pin
is not a part of DAG
.
I wanted to implement some abstraction like
struct PinNode : DAG::Node
- holds connections
struct VirtualPin
- actual pin type, has a reference/id to PinNode
but after a while it didn't seem like a good idea
Also, I'm not sure how I should return std::optional<std::size_t> inputEdge
in getInputEdge()
function. const &
is ok or should I just return a copy?
Node
and Edge
constructors will be defined after Graph
implementation
Here it is:
struct Graph
{
Graph(std::pmr::monotonic_buffer_resource& linearArena)
: linearArena(linearArena)
{
nodes.reserve(16);
edges.reserve(32);
pins.reserve(64);
}
Graph(const Graph&) = delete;
Graph& operator=(const Graph&) = delete;
~Graph() = default;
[[nodiscard]] Node* AddNode(const std::string_view name);
[[nodiscard]] Pin* AddInput(const std::string_view name, Node* const owner);
[[nodiscard]] Pin* AddOutput(const std::string_view name, Node* const owner);
bool Connect(Pin* const from, Pin* const to);
void Execute();
[[nodiscard]] inline const Node* getNode(const std::size_t id) const { return nodes[id]; }
[[nodiscard]] inline const Pin* getPin(const std::size_t id) const { return pins[id]; }
[[nodiscard]] inline const DAG& getDag() const { return dag; }
[[nodiscard]] inline DAG& getDag() { return dag; }
[[nodiscard]] inline const std::pmr::vector<Pin*>& getPins() const { return pins; }
private:
[[nodiscard]] Pin* AddPin(const std::string_view name, Node* const owner);
template<typename T, size_t alignment = alignof(T), typename ... Args>
[[nodiscard]] T* ArenaAllocate(Args&& ... args)
{
void* const p = linearArena.allocate(sizeof(T), alignment);
return p ? new(p) T(std::forward<Args>(args)...) : nullptr;
}
std::pmr::monotonic_buffer_resource& linearArena;
DAG dag;
std::pmr::vector<Node*> nodes{&linearArena};
std::pmr::vector<Edge*> edges{&linearArena};
std::pmr::vector<Pin*> pins{&linearArena};
};
Node* Graph::AddNode(const std::string_view name)
{
auto node = ArenaAllocate<Node>(*this, name);
nodes.push_back(node);
return node;
}
Pin* Graph::AddInput(const std::string_view name, Node* const owner)
{
auto pin = AddPin(name, owner);
owner->AddInput(pin->getID());
return pin;
}
Pin* Graph::AddOutput(const std::string_view name, Node* const owner)
{
auto pin = AddPin(name, owner);
owner->AddOuput(pin->getID());
return pin;
}
Pin* Graph::AddPin(const std::string_view name, Node* const owner)
{
std::size_t id = pins.size();
auto pin = ArenaAllocate<Pin>(name, id, owner->getID());
pins.push_back(pin);
return pin;
}
bool Graph::Connect(Pin* const from, Pin* const to)
{
if (to->getInputEdge().has_value()) {
return false;
}
const std::size_t id = edges.size();
auto edge = ArenaAllocate<Edge>(
*this,
from->getID(),
to->getID(),
from->getNodeID(),
to->getNodeID()
);
edges.push_back(edge);
from->AddOutputEdge(id);
to->setInputEdge(id);
return true;
}
void Graph::Execute()
{
//Reset pins refCount before each DAG::Execute() call
for(auto pin : pins) {
pin->Reset();
}
//Calculate pins refCount
for (const Edge* const edge : edges) {
Pin& pin = *pins[edge->fromPin];
pin.IncrementRefCount();
}
dag.Execute();
}
Node::Node(Graph& g, const std::string_view name)
: DAG::Node(g.getDag()), g(g), name(name)
{
}
Edge::Edge(Graph& g,
const std::size_t fromPin,
const std::size_t toPin,
const std::size_t fromNode,
const std::size_t toNode)
: DAG::Edge(g.getDag(), fromNode, toNode), fromPin(fromPin), toPin(toPin)
{
}
right now Node::Execute()
simply prints inputs and outputs
void Node::Execute()
{
std::cout << "\nNode: " << name << "\n";
std::cout << " Inputs:\n";
if(inputs.empty()) {
std::cout << " empty \n";
} else {
for (auto inputID : inputs) {
auto input = g.getPin(inputID);
std::cout << " " << input->getName() << "\n";
}
}
std::cout << " Outputs:\n";
auto unculledOutputs = getOutputs() | std::views::filter([&pins = g.getPins()](auto pinID) { return !pins[pinID]->isCulled(); });
for (auto outputID : unculledOutputs) {
auto output = g.getPin(outputID);
std::cout << " " << output->getName() << "\n";
}
}
but in a future it'll convert outputs into some variables. I haven't fully written it yet.
main function:
int main()
{
std::pmr::monotonic_buffer_resource arena{1024 * 1024};
Graph sg{arena};
auto lit = sg.AddNode("Lit");
[[maybe_unused]] auto basePin = sg.AddInput("material.baseColor", lit);
[[maybe_unused]] auto normalPin = sg.AddInput("material.normal", lit);
[[maybe_unused]] auto materialAlphaPin = sg.AddInput("material.alpha", lit);
[[maybe_unused]] auto metallicPin = sg.AddInput("material.metallic", lit);
[[maybe_unused]] auto roughnessPin = sg.AddInput("material.roughness", lit);
lit->MakeTarget();
auto alphaNode = sg.AddNode("Alpha");
auto alphaPin = sg.AddOutput("alpha", alphaNode);
auto multiplierNode = sg.AddNode("Multiplier");
auto multiplierPin = sg.AddOutput("multiplier", multiplierNode);
auto addNode = sg.AddNode("AddNode");
auto a = sg.AddInput("a", addNode);
auto b = sg.AddInput("b", addNode);
auto out = sg.AddOutput("out", addNode);
sg.Connect(alphaPin, a);
sg.Connect(multiplierPin, b);
sg.Connect(out, materialAlphaPin);
sg.Execute();
return 0;
}
Output of this program:
Node: Lit
Inputs:
material.baseColor
material.normal
material.alpha
material.metallic
material.roughness
Outputs:
Node: AddNode
Inputs:
a
b
Outputs:
out
Node: Multiplier
Inputs:
empty
Outputs:
multiplier
Node: Alpha
Inputs:
empty
Outputs:
alpha
It looks good. Now let's disconnect alphaPin
from a
pin, just comment out this line in the main function: sg.Connect(alphaPin, a);
and run again:
Node: Lit
Inputs:
material.baseColor
material.normal
material.alpha
material.metallic
material.roughness
Outputs:
Node: AddNode
Inputs:
a
b
Outputs:
out
Node: Multiplier
Inputs:
empty
Outputs:
multiplier
Also looks good. Alpha
Node hasn't been printed since its pin is not connected.
If DAG::Execute
wouldn't have:
auto&& outEdges = getOutgoingEdges(node);
if (outEdges.empty() && !node->isTarget()) {
continue;
}
the it would look like this:
Node: Alpha
Inputs:
empty
Outputs:
Node: Lit
Inputs:
material.baseColor
material.normal
material.alpha
material.metallic
material.roughness
Outputs:
Node: AddNode
Inputs:
a
b
Outputs:
out
Node: Multiplier
Inputs:
empty
Outputs:
multiplier
which is not correct, because Alpha
node is processed.
Could you do a review of this code and say if this code in DAG::Execute()
:
auto&& outEdges = getOutgoingEdges(node);
if (outEdges.empty() && !node->isTarget()) {
continue;
}
is corrected and I'm just freaking out?
if(outEdges.empty() ...
thing \$\endgroup\$