DreamShaderLang
Plugin referenceGeneration

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 onUMaterial (including the hidden ThinCustom base) and UMaterialFunction
Runs afterthe graph is built and the outputs are connected; before the material is recompiled
Source controlthe optional Layout section and #Region / #EndRegion
Producesnode 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.

ConditionBehaviour
fewer than 2 expressions in the graphskipped
1200 or more expressions, and no Layout sectionskipped, with a log line
a Layout section is presentexplicit path
otherwiseautomatic 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.0 removes 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.

StyleReadsIn short
Blocks (default)the compiler's IRa page of boxes in source order, one per #pragma region or run of statements; no wire leaves a box — see below
Classicthe finished engine graphthe 1.x layout: blocks recovered from the material's outputs, DS_Shared_* named reroutes between them
SourceBandsthe compiler's IRone 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
Layeredthe compiler's IRthe 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.x Region) 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>, or DS_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 the DS_<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 layout is obeyed to the unit. A Node hint moves the named value's node to the given position and carries the nodes that hang off it; a Comment hint is passed through as the box it describes.
  • #pragma region is a box around its nodes, nested regions inside their parents, each with a ring of padding so that titles do not collide.
  • SourceBands and Layered insert no reroute. A wire that spans six columns or more is a long edge: it is counted in the dump-layout JSON and left as a wire. Blocks is 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 -Json

Sizes 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:

  1. Pinned nodes. Each Node(Var=…) writes its expression's editor X/Y exactly, and the graph node's position when one exists. A Var that names no generated variable is ignored silently.
  2. Comment boxes. Each Comment(…) creates a UMaterialExpressionComment at the literal X/Y with the given W/H. An empty or whitespace-only Name is skipped.
  3. Unpinned nodes are placed by iterative propagation from their already-positioned neighbours.
  4. Reroutes are inserted for distant connections that cross a block boundary.
  5. The material root is positioned near the outputs it consumes, then the output-feeding reroute usages are regrouped under a Material Output box.

Comment box properties

PropertyExplicit Comment(…)Automatic box
TextDreamShader: + NameDreamShader: + block title
Positionliteral X, Yblock bounds minus padding (110, 90)
Sizemax(120, W) × max(80, H)max(420, width + 220) × max(240, height + 180)
ColourColor=float4(…) if given, otherwise (0.10, 0.16, 0.22, 0.35)(0.10, 0.16, 0.22, 0.35)
Font size2424
Group modeonon
Comment bubblevisible in the details panel, colouredvisible 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. Writing Comment(Name="Sampling", …) produces a box reading DreamShader: 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 neighboursPosition
both dependencies and consumersthe midpoint between them
consumers onlyconsumerX - (nodeWidth + 140), same Y
dependencies onlydependencyX + 360, same Y
neither, everfallback 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.

OrderBlockTitleSort key
1one per distinct #Region namethe region nameregion order
2(material) one per connected material property inputOutput: <PropertyName>the material-property enum index
3(material function) one per UMaterialExpressionFunctionOutputOutput: <OutputName>, or Output: FunctionOutput when unnamed1000 + declaration order
4every expression with no consumers that is not already a sinkGenerated Outputs100000
5(only if nothing above produced a block) everythingGraph0
6expressions used by more than one block that no block ownsShared 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.

NodeNamePosition
UMaterialExpressionNamedRerouteDeclarationDS_Shared_<n>, n counting from 0source.X + 360, source.Y
UMaterialExpressionNamedRerouteUsageone per (declaration, consumer block) pairconsumer.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.

TermContribution
Title bar46, plus 20 per extra caption line from GetCaption
Pin rows28 × max(inputCount, outputCount)
Expression preview124 when UMaterialExpression::ShouldShowPreview() — i.e. !bHidePreviewWindow && !bCollapsed
Body padding22
Widthmax(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.

  1. 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.
  2. Lane every edge that spans more than one rank. An edge from rank r to rank r + n gets n - 1 dummy 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 reserves 34 of vertical room, so the wire gets a corridor rather than crossing whatever happens to be in the way.
  3. 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.
  4. Stack each column by real heights with a 60 gutter, centred on the block's mid-line.
  5. 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.
  6. 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 at X = 900; each further column sits 150 to 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 budgetmax(2400, sqrt(totalBlockArea / 1.6))
Next block in a columnprevious block's bounds.MaxY + 420
Next columnprevious column's widest block + 520
First blockY = -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:

  1. RootX = combinedBounds.MaxX + 520, RootY = (MinY + MaxY) / 2 - 240. The material graph's root node position is written too.
  2. 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 further widestUsage + 360 to the right; and the whole group gets its own comment box titled Material Output. The root is centred on the column by its own 420 height rather than a fixed 240.

Removing those usages from the computation blocks first is what keeps the per-property boxes tight.

Layout constants

ConstantValueApplies to
Output column X900left edge of the widest rank-0 node, and FunctionOutput nodes
Column gutter150gap between two columns — the wire corridor
Row gutter60gap between two stacked nodes in a column
Lane height34vertical room one long edge reserves per column crossed
Block gap Y420gap between two blocks in the same column
Block gap X520gap between two block columns
First block top-620Y of the first block
Column height budgetmax(2400, sqrt(area / 1.6))block packing
Node footprintestimated per node, [140, 520] widebounds and spacing
Fallback footprint320 × 150measuring a null expression
Root footprint320 × 420the material result node
Comment padding110 X, 90 Yautomatic boxes
Minimum automatic box420 × 240automatic boxes
Minimum explicit box120 × 80Comment(…)
Reroute offset360declaration right of source, usage left of consumer
Reroute fan-out±((slot + 1) / 2) * 80stacked usages
Root gap520root right of the graph bounds
Output-usage columnbodyBounds.MaxX + 420, spacing max(130, tallest + 40), root + widest + 360the Material Output group
Distant threshold`ΔX
Crossing-reduction passes6per block
Straightening passes4per block
Owner-assignment passes≤ 16ambiguous expressions
Propagation passesmax(32, pending)explicit path, unpinned nodes
Large-graph threshold1200automatic-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 .dsm depends on them except through a Layout section, 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.

NodeXFirst YY step
every node the emitter creates, in the order it creates them0-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" / #EndRegion in a Graph block do not move anything by themselves — they name blocks for the automatic path, and boxes for the IR styles. Regions nest; a #Region without a name is DSH2216, and an unbalanced pair DSH2217 / DSH2218. Grammar is on the Layout page.
  • A Layout section with only Node(Var=…) entries that match nothing, and no Comment(…), does not engage the explicit path — the automatic path runs instead.
  • A second Layout section in the same block replaces the first entirely, with the warning DSH2258 (since 2.0.0).
  • Layout positions are not preserved across regeneration. A Layout section is the only way to make a node's position stable. See Regeneration.
  • The decompiler emits a Layout section 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 reading Positioning 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 blocks

Pinning 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 neighbours

See also

  • Layout — Node / Comment grammar, 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

On this page