Skip to content

Add a shader graph pass to flatten compound nodes - #3057

Open
ld-kerley wants to merge 1 commit into
AcademySoftwareFoundation:mainfrom
ld-kerley:shadergen/compound-flattening-refactor
Open

ld-kerley wants to merge 1 commit into
AcademySoftwareFoundation:mainfrom
ld-kerley:shadergen/compound-flattening-refactor

Conversation

@ld-kerley

Copy link
Copy Markdown
Contributor

(This work is extracted and refined from #2739)

Compound nodes wrap a nodegraph and are emitted as a function call by backends that support them. Backends that cannot do so need the nodegraph inlined into the parent graph instead.

CompoundFlatteningRefactor expands each compound node in place, replacing it with copies of the nodes inside its graph rewired onto the compound node's external connections. Nested compound graphs are expanded in the same run, using a worklist seeded from the graph and extended as copies that are themselves compound are created. Compound nodes are identified through the existing ShaderNodeImpl::getGraph() virtual, so the pass has no dependency on any concrete implementation type.

The pass is deliberately not registered by default, since inlining a nodegraph discards the emission behavior of its compound implementation. Registering it puts the expansion in ShaderGraph::finalize() ahead of removeUnusedNodes(), topologicalSort() and setVariableNames().

Values crossing the compound boundary are propagated in both directions, and in each case the authored state of the source port is preserved, since backends use it to decide which parameters to emit. A port carrying no value leaves the target's own default alone, and unit, color space and element path override the target only where the source has something to say.

Inbound, a compound node input with no upstream connection applies its value to the interior inputs it feeds. Outbound, an output socket can be left carrying a value rather than a connection: eliding a constant pushes the constant's value onto whatever it fed and breaks the connection, and NodeElisionRefactor runs by default while the compound graph is finalized. A socket like that has no interior node to splice onto the external consumers, so its value is forwarded to them before they are disconnected. Both cases mirror how ShaderGraph::bypass() pushes a value downstream when it has no upstream connection to re-route.

@fallenmi fallenmi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed exact head 0a93a590dcca71cf89e9d56ced3bc53a18f081ad and the current GitHub merge aad34959fa1452b327280ff21bfdd9c233962e67. I found no blocking issue.

The flattening pass preserves the graph's ownership and connection invariants while expanding nested compound implementations: copied nodes keep their implementation, classification, metadata, port values and authored flags; external values, pass-through sockets, value-only outputs and output fan-out are all spliced before the compound nodes are removed. The new duplicate-ID rejection and batched removal also keep the owning map and raw-pointer order synchronized.

I built the exact head and current merge with AppleClang 21 and ran the four owning cases ten times on each tree (40/40 per tree). The full local GenShader slice passed 11 cases / 3,509 assertions on the head and 13 cases / 3,525 assertions on the current merge. A separate public-API oracle registered CompoundFlatteningRefactor on the generator before graph construction, expanded a two-level nested compound graph to zero remaining compound nodes, and preserved distinct node IDs and both interior values in 10/10 merge runs. git diff --check is clean. Live CI is fully green across all 35 check runs plus EasyCLA, including the dynamic-analysis and cross-platform build jobs.

Disclosure: I used OpenAI Codex to assist with exact-diff inspection, builds, tests, the independent registered-refactor oracle, live policy and interaction checks, and drafting this review. I independently verified the cited revisions, outputs, and conclusion.

Compound nodes wrap a nodegraph and are emitted as a function call by
backends that support them. Backends that cannot do so need the nodegraph
inlined into the parent graph instead.

CompoundFlatteningRefactor expands each compound node in place, replacing it
with copies of the nodes inside its graph rewired onto the compound node's
external connections. Nested compound graphs are expanded in the same run,
using a worklist seeded from the graph and extended as copies that are
themselves compound are created. Compound nodes are identified through the
existing ShaderNodeImpl::getGraph() virtual, so the pass has no dependency on
any concrete implementation type.

The pass is deliberately not registered by default, since inlining a nodegraph
discards the emission behavior of its compound implementation. Registering it
puts the expansion in ShaderGraph::finalize() ahead of removeUnusedNodes(),
topologicalSort() and setVariableNames().

Copies are keyed by unique id rather than by name, since "<parent>_<child>"
concatenates ambiguously and two distinct copies can produce the same key.
Names are separately uniquified against the names already in use, since
backends key on them.

Values crossing the compound boundary are propagated in both directions, and
in each case the authored state of the source port is preserved, since
backends use it to decide which parameters to emit. A port carrying no value
leaves the target's own default alone, and unit, color space and element path
override the target only where the source has something to say.

Inbound, a compound node input with no upstream connection applies its value
to the interior inputs it feeds. Outbound, an output socket can be left
carrying a value rather than a connection: eliding a constant pushes the
constant's value onto whatever it fed and breaks the connection, and
NodeElisionRefactor runs by default while the compound graph is finalized. A
socket like that has no interior node to splice onto the external consumers,
so its value is forwarded to them before they are disconnected. Both cases
mirror how ShaderGraph::bypass() pushes a value downstream when it has no
upstream connection to re-route.

Supporting changes:

- ShaderGraph::createNode() gains an overload taking a resolved node
  implementation, and both overloads now route through addNode(), which
  rejects duplicate unique ids. Previously a duplicate silently replaced the
  owning entry in the node map while the node order kept a raw pointer to the
  released node.
- ShaderGraph::removeNode() and removeNodes() erase nodes and break their
  connections, the latter compacting the node order in a single pass.
- ShaderPort::copyToPort() copies a port's value, flags and descriptive data,
  applying the flags last so an unauthored value stays unauthored.
- ShaderNode::getImplementationPtr() exposes the implementation so copies can
  share it.
@ld-kerley
ld-kerley force-pushed the shadergen/compound-flattening-refactor branch from 0a93a59 to 292d858 Compare August 31, 2026 23:06
@ashwinbhat

ashwinbhat commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

I'm tagging @bernardkwok for his input as well. How does this new workflow work with existing flattenSubgraphs

void GraphElement::flattenSubgraphs(const string& target, NodePredicate filter)

Wouldn't the flattenSubgraphs (byfilter) -> codegen be better that doing this in Shadergraph?

@ld-kerley

Copy link
Copy Markdown
Contributor Author

flattenSubgraphs is part of the MaterialX document model - but we want to be able to process the constructed ShaderGraph object - without mutating the original document. Also by having this feature part of the shader generator refactor toolkit - it allows shader generators like the OSL Network generator use it - while not imposing its use on a document that might also need to serve a different backend simultaneously.

Also in the context where we're surfacing the MaterialX shader generation system via a visitor pattern at some point in the future - we don't want to be dependent on logic that only operates on the document model.

@kwokcb

kwokcb commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

@ld-kerley, @ashwinbhat : I think I mentioned this in some earlier TSC meeting.

I believe you can create it already "pre-flattened" by not introducing compounds except the top level container. To a certain extent this already occurs forcompound (document) nodegraphs. I haven't looked at this review so just an external cursory thought.

The ability to flatten a specific compound still feels like a useful mechanism to have and also agree pre-document manipulation is possible but can't really comment as to what may be "better" - but I think we'd want a check to make
sure they produce the same end result.

The flip-side that I believe I mentioned is enforcing hierarchys of compounds for nested nodegraphs (if that ever get's in :)).

@kwokcb

kwokcb commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

BTW, It would be useful to have this for the PR I put up for shadergraph API access. @ld-kerley : having Python / JS wrapper access would allow for flattening to occur before calling to get shadergraph connectivity info. @ashwinbhat,
if we use this for your hasing work then I think you'd want flattening to run, then you can hash during graph traversal (i.e. should simplify any logic you require).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants