Graph layout
Graph layout
Graph layout
DreamShader » Generation » Graph layout
The pass that assigns editor coordinates and comment boxes to the nodes a compile just created.
| Runs on | UMaterial (including the hidden ThinCustom base) and UMaterialFunction |
| Runs after | the graph is built and the outputs are connected; before the material is recompiled |
| Source control | the optional Layout section and #Region / #EndRegion |
| Produces | node positions, UMaterialExpressionComment boxes, named reroutes (DS_<variable> under Blocks, DS_Shared_* under Classic) |
Which pass that is, is the project's Graph Layout Style. Blocks, the default, and the
two other styles computed on the compiler's IR are described under that heading. When layout runs,
Explicit layout and the sections after it describe Classic, the 1.x pass, which works on the
finished graph and which a project can still choose.
When layout runs
Classic only. A style computed on the IR has no such guard: it is laid out before a node exists, and
always runs.
| Condition | Behaviour |
|---|---|
| fewer than 2 expressions in the graph | skipped |
1200 or more expressions, and no Layout section | skipped, with a log line |
a Layout section is present | explicit path |
| otherwise | automatic path |
The large-graph bail-out logs:
Skipping automatic layout for large DreamShader graph ({Count} nodes). Existing generated positions
will be used.The threshold is on the expression count of the finished graph, not on source length. It is checked
only on the automatic path — an explicit Layout section is always honoured, at any node count.
[!NOTE] Layout always runs, since
2.0.0. It used to be skippable: the graph you saw in the preview or on a not-yet-materialized material was unlaid out — nodes sat in the tall single column the construction pass leaves them in — and layout only appeared once the asset was materialized, cooked or generated by the commandlet. A Lay Out In-Memory Graphs project setting was added to turn it back on, defaulting on;2.0.0removes the setting and the skip with it. The only remaining opt-out is the large-graph guard above. See Ephemeral materials and Project settings.(since 2.0.0) The pass reports one slow-task frame per node on every compile. Through 1.x an interactive compile ran it quiet, one frame per block; the 2.0 emitter does not use that mode.
Layout styles
(since 2.0.0) Project Settings ▸ DreamShader ▸ Graph Layout Style chooses what lays a generated
graph out. Blocks is the default. A graph takes the chosen style the next time its source is rebuilt;
node positions are not part of the divergence digest, so an asset built under another
style never reads as hand-edited.
| Style | Reads | In short |
|---|---|---|
Blocks (default) | the compiler's IR | a page of boxes in source order, one per #pragma region or run of statements; no wire leaves a box — see below |
Classic | the finished engine graph | the 1.x layout: blocks recovered from the material's outputs, DS_Shared_* named reroutes between them |
SourceBands | the compiler's IR | one horizontal band per statement of the source, top to bottom in the order of the text; inside a band, a tidy tree that grows leftward from the statement's value |
Layered | the compiler's IR | the whole graph in layers by distance from the outputs (Sugiyama): crossings reduced by barycentre ordering, rows aligned on the median of their neighbours |
Blocks
The graph the way a person lays one out by hand: a box per step, the boxes in reading order, and a named reroute wherever a value goes from one box to another.
- A box is a region, or a run of statements. Consecutive statements of one innermost
#pragma region(a 1.xRegion) are one box, titled with the region's name; statements outside every region are cut into runs of at most forty nodes, titled after the first and last variable they define. A region that holds other regions is a box around their boxes, so nested boxes never overlap. A function's outputs are a box of their own, to the right of the page. - Inside a box the nodes are one small layered drawing, left to right.
- No wire leaves a box. A value another box reads gets a named reroute declaration beside it —
DS_<variable>, orDS_Shared_<n>for a value no variable names — and one usage in every box that reads it. A constant is not rerouted: it is written again where it is read. What drives the material, or a function output, leaves its box through theDS_<output>pair that stands in front of every output in any style; the usages stand in a column beside the material's node. - The page packs the boxes like words in a paragraph — left to right, then the next line — in source order, to a page about 1.8 times wider than tall.
The reroutes and the repeated constants are nodes of the generated graph, as the DS_Shared_* reroutes of
Classic are: the decompiler looks through them, and so does the graph parity comparator.
What the IR styles share
- Columns are distances. A node's column is its longest path to a root — the material's outputs, a
function's outputs, a statement node; in
Blocks, to whatever nothing else in its box reads — so every wire runs left to right and never backwards. #pragma layoutis obeyed to the unit. ANodehint moves the named value's node to the given position and carries the nodes that hang off it; aCommenthint is passed through as the box it describes.#pragma regionis a box around its nodes, nested regions inside their parents, each with a ring of padding so that titles do not collide.SourceBandsandLayeredinsert no reroute. A wire that spans six columns or more is a long edge: it is counted in thedump-layoutJSON and left as a wire.Blocksis the style that reroutes.- Nodes the IR does not know about are stacked beside their owner: an IR node can become several engine expressions (a parameter with its mask, a sample with its coordinates), and the extra ones are placed in a column to the left of the one the IR value lives on.
- Deterministic. The same IR gives the same coordinates, on any machine; everything snaps to a 16-unit grid.
Look before choosing — this draws every IR style for a source, builds nothing, and takes a second:
./dsc.ps1 dump-layout DShader/Materials/M_Foo.dss # …/Saved/DreamShader/Layout/**.svg, one per product and style
./dsc.ps1 dump-layout -All -Style Blocks -JsonSizes in the picture are estimated from the IR (a node's pins and title); on a real compile the engine's own node sizes are used, so spacing differs slightly and arrangement does not.
Explicit layout
A Layout section pins named variables and declares comment boxes. Full grammar, argument tables and
parse diagnostics are on the Layout page; this section covers what the
placement pass does with them.
Layout = {
Comment(Name="Sampling", X=-1200, Y=-200, W=900, H=400);
Node(Var="UV", X=-1100, Y=-120);
}The explicit path is taken when the Layout block matched at least one Node(Var=…) against a
generated variable, or declared at least one Comment(…). Then, in order:
- Pinned nodes. Each
Node(Var=…)writes its expression's editor X/Y exactly, and the graph node's position when one exists. AVarthat names no generated variable is ignored silently. - Comment boxes. Each
Comment(…)creates aUMaterialExpressionCommentat the literal X/Y with the given W/H. An empty or whitespace-onlyNameis skipped. - Unpinned nodes are placed by iterative propagation from their already-positioned neighbours.
- Reroutes are inserted for distant connections that cross a block boundary.
- The material root is positioned near the outputs it consumes, then the output-feeding reroute
usages are regrouped under a
Material Outputbox.
Comment box properties
| Property | Explicit Comment(…) | Automatic box |
|---|---|---|
| Text | DreamShader: + Name | DreamShader: + block title |
| Position | literal X, Y | block bounds minus padding (110, 90) |
| Size | max(120, W) × max(80, H) | max(420, width + 220) × max(240, height + 180) |
| Colour | Color=float4(…) if given, otherwise (0.10, 0.16, 0.22, 0.35) | (0.10, 0.16, 0.22, 0.35) |
| Font size | 24 | 24 |
| Group mode | on | on |
| Comment bubble | visible in the details panel, coloured | visible in the details panel, coloured |
[!WARNING] Every comment box DreamShader creates has its text prefixed with the literal
DreamShader:, and that prefix is exactly what regeneration deletes. WritingComment(Name="Sampling", …)produces a box readingDreamShader: Sampling; renaming it in the material editor to something without the prefix makes it survive the next rebuild — and a second box appears beside it. See Regeneration.
Placing the unpinned nodes
Propagation runs until nothing moves, capped at max(32, pending count) passes.
| Known neighbours | Position |
|---|---|
| both dependencies and consumers | the midpoint between them |
| consumers only | consumerX - (nodeWidth + 140), same Y |
| dependencies only | dependencyX + 360, same Y |
| neither, ever | fallback column |
Collisions are resolved by bucketing positions into 80 × 80 slots; the n-th occupant of a slot is
offset vertically by ±((n + 1) / 2) * (nodeHeight + 40), alternating sign — the node's own
estimated height, so two nodes in one slot clear each other whatever they are.
Anything still unplaced goes into a fallback column at bounds.MinX - 480 (or -1200 when nothing
was positioned), starting at bounds.MaxY + 240 (or -620) and stepping Y by each node's height
plus 40.
Automatic layout
With no Layout section, the pass partitions the graph into blocks, lays each block out
independently, and stacks the blocks downward.
Block construction
Blocks are built in this order and then stable-sorted by sort key, ascending.
| Order | Block | Title | Sort key |
|---|---|---|---|
| 1 | one per distinct #Region name | the region name | region order |
| 2 | (material) one per connected material property input | Output: <PropertyName> | the material-property enum index |
| 3 | (material function) one per UMaterialExpressionFunctionOutput | Output: <OutputName>, or Output: FunctionOutput when unnamed | 1000 + declaration order |
| 4 | every expression with no consumers that is not already a sink | Generated Outputs | 100000 |
| 5 | (only if nothing above produced a block) everything | Graph | 0 |
| 6 | expressions used by more than one block that no block owns | Shared Inputs | -1000, inserted first |
An expression appearing in exactly one block is assigned to it immediately. Ambiguous ones are
resolved over at most 16 passes by "which block already owns the most of my direct consumers";
leftovers take their first candidate. Every NamedRerouteDeclaration is finally forced into its
source's block.
Cross-block reroutes
Any input that crosses a block boundary is rewired through a shared named reroute.
| Node | Name | Position |
|---|---|---|
UMaterialExpressionNamedRerouteDeclaration | DS_Shared_<n>, n counting from 0 | source.X + 360, source.Y |
UMaterialExpressionNamedRerouteUsage | one per (declaration, consumer block) pair | consumer.X - 360, fanned out vertically by ±((slot + 1) / 2) * 80 |
Input channel masks are preserved across the rewire. On the automatic path every crossing connection
is bridged; on the explicit path only distant ones are, where distant means |ΔX| ≥ 900 or
|ΔY| ≥ 540. The explicit path additionally bails out before inserting anything unless it built at
least two blocks and assigned owners to at least two expressions.
Node footprints
Every step below works in real node sizes rather than a flat footprint. There is no widget to
measure at generation time — the commandlet has no editor UI, and even in the editor the graph's
node widgets do not exist yet — so the size is estimated from what SGraphNodeMaterialBase
assembles.
| Term | Contribution |
|---|---|
| Title bar | 46, plus 20 per extra caption line from GetCaption |
| Pin rows | 28 × max(inputCount, outputCount) |
| Expression preview | 124 when UMaterialExpression::ShouldShowPreview() — i.e. !bHidePreviewWindow && !bCollapsed |
| Body padding | 22 |
| Width | max(48 + captionChars × 10, 56 + (inputNameChars + outputNameChars) × 8), clamped to [140, 520] |
Output pin names count toward the width only when bShowOutputNameOnPin is set. The estimate
deliberately errs high: over-estimating only loosens the graph, while under-estimating overlaps
nodes. Measurements are memoised for the duration of one layout pass.
This is what keeps a TextureSample — a node the preview thumbnail makes roughly twice the height
of a Constant — from being stacked on top of its neighbour, and what makes the comment boxes big
enough to actually contain what they are drawn around.
Placement inside a block
A layered, Sugiyama-style layout.
- Rank each expression by its longest path to a consumer: sinks are rank 0, and
rank(e) = max(rank(consumer) + 1). A cycle resolves to rank 0. - Lane every edge that spans more than one rank. An edge from rank
rto rankr + ngetsn - 1dummy slots, one per column it crosses, chained between its endpoints. Without them step 3 is blind to long edges — it only ever compares neighbours one rank apart — which is what let a graph read as a ball of wire even though each column was individually tidy. A lane reserves34of vertical room, so the wire gets a corridor rather than crossing whatever happens to be in the way. - Reduce crossings: 6 passes, each sweeping toward the outputs ordering every column by the average position of its consumers, then back toward the inputs ordering by its dependencies. Lanes take part on equal terms with real nodes. Barycentres are computed for a whole column before it is sorted, so the ordering cannot depend on how the sort pairs elements up; ties break on creation order.
- Stack each column by real heights with a
60gutter, centred on the block's mid-line. - Straighten: 4 passes, alternating direction. Each pulls every slot toward the average centre of its neighbours on the settled side, then repairs the column with an isotonic regression — the closest set of centres that still keeps the column's order and its minimum gaps. This is what turns a chain into a straight horizontal line instead of a staircase.
- Assign X: a column is as wide as its widest node, and nodes are right-aligned within it,
so a column's outputs line up and the wires into the next one stay short. Column
0's widest node has its left edge atX = 900; each further column sits150to the left of the previous column's left edge.
Packing the blocks
Blocks used to stack in one column, which turned a material with several connected outputs into a ribbon tens of thousands of units tall — readable only fully zoomed out. They are now laid out around their own origins first, then packed into columns.
| Column height budget | max(2400, sqrt(totalBlockArea / 1.6)) |
| Next block in a column | previous block's bounds.MaxY + 420 |
| Next column | previous column's widest block + 520 |
| First block | Y = -620 |
A block is wrapped to a new column when adding it would overrun the budget, unless the column is empty — a block taller than the whole budget gets a column to itself rather than being split. With few enough blocks to fit one column the result is the old top-to-bottom stack.
Comment boxes are drawn only after packing: a box placed before the move would be left behind by the nodes it is supposed to contain.
The material root
Two passes touch the root node, in order:
RootX = combinedBounds.MaxX + 520,RootY = (MinY + MaxY) / 2 - 240. The material graph's root node position is written too.- Output-feeding named-reroute usages are then pulled out of their computation blocks and
stacked in a single column at
bodyBounds.MaxX + 420, spaced by the tallest usage node's height+ 40(never less than 130); the root moves a furtherwidestUsage + 360to the right; and the whole group gets its own comment box titledMaterial Output. The root is centred on the column by its own420height rather than a fixed 240.
Removing those usages from the computation blocks first is what keeps the per-property boxes tight.
Layout constants
| Constant | Value | Applies to |
|---|---|---|
| Output column X | 900 | left edge of the widest rank-0 node, and FunctionOutput nodes |
| Column gutter | 150 | gap between two columns — the wire corridor |
| Row gutter | 60 | gap between two stacked nodes in a column |
| Lane height | 34 | vertical room one long edge reserves per column crossed |
| Block gap Y | 420 | gap between two blocks in the same column |
| Block gap X | 520 | gap between two block columns |
| First block top | -620 | Y of the first block |
| Column height budget | max(2400, sqrt(area / 1.6)) | block packing |
| Node footprint | estimated per node, [140, 520] wide | bounds and spacing |
| Fallback footprint | 320 × 150 | measuring a null expression |
| Root footprint | 320 × 420 | the material result node |
| Comment padding | 110 X, 90 Y | automatic boxes |
| Minimum automatic box | 420 × 240 | automatic boxes |
| Minimum explicit box | 120 × 80 | Comment(…) |
| Reroute offset | 360 | declaration right of source, usage left of consumer |
| Reroute fan-out | ±((slot + 1) / 2) * 80 | stacked usages |
| Root gap | 520 | root right of the graph bounds |
| Output-usage column | bodyBounds.MaxX + 420, spacing max(130, tallest + 40), root + widest + 360 | the Material Output group |
| Distant threshold | ` | ΔX |
| Crossing-reduction passes | 6 | per block |
| Straightening passes | 4 | per block |
| Owner-assignment passes | ≤ 16 | ambiguous expressions |
| Propagation passes | max(32, pending) | explicit path, unpinned nodes |
| Large-graph threshold | 1200 | automatic-layout bail-out |
[!NOTE] These constants are not part of the stable surface. The generated-asset layout may change in any release; nothing in a
.dsmdepends on them except through aLayoutsection, which pins absolute coordinates and is unaffected.
Output: block titles
The material-property block titles come from a fixed table. Every name below produces
Output: <name>; anything not listed produces Output: MaterialProperty<enum index>.
BaseColor, MaterialAttributes, EmissiveColor, Opacity, OpacityMask, Metallic,
Specular, Roughness, Normal, AmbientOcclusion, Refraction, WorldPositionOffset,
PixelDepthOffset, SubsurfaceColor, CustomData0, CustomData1, DiffuseColor,
SpecularColor, SurfaceThickness, Displacement, CustomizedUV0, CustomizedUV1,
CustomizedUV2, CustomizedUV3, CustomizedUV4, CustomizedUV5, CustomizedUV6,
CustomizedUV7, Anisotropy, Tangent.
A custom engine build that defines MOON_ENGINE adds five more:
MoonEncodedAttribute0 … MoonEncodedAttribute4 (the pre-rename MooaEncodedAttribute… spelling is an
accepted alias). Stock UE 5.3 – 5.8 does not.
Construction-time positions
These are the coordinates nodes carry when layout is skipped — a graph of fewer than two nodes, or
1200 or more nodes with no Layout section, under Classic. Note the shape: X is fixed and Y just
steps down per node, so a skipped graph is one tall column with wires across it, not a bad layout.
| Node | X | First Y | Y step |
|---|---|---|---|
| every node the emitter creates, in the order it creates them | 0 | -120 | +180 |
(since 2.0.0) The 1.x table of per-kind positions — the whole-surface Custom node, the
MakeMaterialAttributes seed, property nodes, FunctionInput / FunctionOutput — went with the 1.x
generator: the emitter places every node it creates the same way, and the layout pass rewrites them.
Every Outputs binding also creates a named-reroute pair at construction time: the declaration at
source.X + 420, source.Y, and the usage at X = 720, Y = -120 + max(routeIndex, 0) * 180. The
declaration is named DS_<sanitized route name>, plus _<index> when the route is indexed;
a blank route name falls back to DS_Output.
Notes
#Region "Name"/#EndRegionin aGraphblock do not move anything by themselves — they name blocks for the automatic path, and boxes for the IR styles. Regions nest; a#Regionwithout a name isDSH2216, and an unbalanced pairDSH2217/DSH2218. Grammar is on the Layout page.- A
Layoutsection with onlyNode(Var=…)entries that match nothing, and noComment(…), does not engage the explicit path — the automatic path runs instead. - A second
Layoutsection in the same block replaces the first entirely, with the warningDSH2258(since 2.0.0). - Layout positions are not preserved across regeneration. A
Layoutsection is the only way to make a node's position stable. See Regeneration. - The decompiler emits a
Layoutsection in exactly the parse format, so a hand-arranged material can be exported and re-imported with its arrangement intact. This is controlled by the Export Decompiled Layout project setting, default on. - Automatic layout reports a slow task titled
Laying out DreamShader material graph..., with one frame per node readingPositioning node {N} of {Total}....
Example
Shader(Name="Docs/M_Layout")
{
Properties {
vec3 Tint = vec3(1.0, 0.4, 0.1);
float Intensity = 2.0;
}
Settings { Domain = "UI"; ShadingModel = "Unlit"; }
Outputs {
vec3 Color;
float Alpha;
Base.EmissiveColor = Color;
Base.Opacity = Alpha;
}
Graph {
#Region "Colour"
Color = Tint * Intensity;
#EndRegion
#Region "Alpha"
Alpha = Intensity * 0.5;
#EndRegion
}
}Automatic layout produces:
comment DreamShader: Shared Inputs sort key -1000
comment DreamShader: Colour sort key region order
comment DreamShader: Alpha sort key region order
comment DreamShader: Output: EmissiveColor
comment DreamShader: Output: Opacity
comment DreamShader: Material Output root + the output reroute usages
reroute DS_Shared_0 Intensity, used by two blocksPinning one node instead:
Layout = {
Comment(Name="Colour", X=-1200, Y=-200, W=900, H=400);
Node(Var="Color", X=-600, Y=-120);
}comment DreamShader: Colour at (-1200, -200), 900 x 400
node Color at (-600, -120), exactly
every other node propagated from its neighboursSee also
- Layout —
Node/Commentgrammar, arguments, and parse diagnostics - Regeneration — why positions are not preserved, and which comments survive
- Ephemeral materials — the two ThinCustom states; layout runs in both
- Graph — the statements that create the nodes being placed
- Node reuse — why repeated subexpressions become one node to place
- Output bindings — the bindings that create the output reroutes
- Decompiler — exporting an existing material's layout back to source
- Project settings — Export Decompiled Layout
- Generation — where layout sits in the pipeline