← All posts

A shader graph that compiles to the same thing you'd type by hand

Awaken had a wind material before it had a way to author one. The wind was baked into the mesh shader: a sin on the vertex position, gated by a flag. It worked and it was a dead end, because the next effect anyone wanted was not wind.

This post is about what replaced it: a material system where a hand-written WGSL shader and a node graph are the same artifact, and the graph is an authoring form rather than a second code path.

Materials are hooks, not shaders

The obvious design is to let a material be a whole shader. I did not do that, because then every material owns lighting, shadows, fog, tonemapping and the instance layout, and every renderer change breaks every material anyone ever wrote.

Instead a material is four optional strings, each a fragment of WGSL spliced into the standard PBR shader at a named point:

export interface MaterialHooks {
  vertexPosition?: string;   // displace the vertex, in world space
  surfaceColor?: string;     // replace albedo before lighting
  surfaceNormal?: string;    // replace the shading normal
  discard?: string;          // cut the fragment out
}

Plus a list of typed params the hooks can read. That is the whole surface. Lighting, shadows, fog and the tonemap stay in the host shader, so a material written a month ago picks up a lighting fix for free.

The trade is that a hook cannot do everything a full shader could. In exchange it cannot break, and the renderer stays free to change underneath it. Two years of that trade is why Unity has surface shaders.

The graph is a compiler front end

The node graph does not render anything. It compiles to a Material, the same struct a hand-written one produces:

flowchart TD
  A["Node graph (persisted JSON)"] --> C["emitGraph"]
  B["Hand-written WGSL hooks"] --> M
  C --> M["Material { hooks, params }"]
  M --> P["composeShader → pipeline"]

Nothing downstream knows which it was. There is no graph runtime, no interpreter, no second pipeline path to keep in step. Open a graph, wire a node, and what changes is a WGSL string.

That has a practical consequence I use constantly: you can author a material as a graph and then read the WGSL it produced, or write WGSL by hand and never touch the graph. Both are first-class because there is only one thing to be first-class.

The node types are deliberately not Unity's

The registry has 91 node types, and every key is generic: multiply, sampleTexture, lerp, fresnel, staticSwitch. None of them is named after a source engine's node.

That is a rule, not an aesthetic. Unity graphs, Unreal graphs and hand-authored graphs all map onto this one registry, so a converter's job is to translate constructs into the registry rather than to bring its engine's vocabulary along. It is the same rule the scene importers follow: translate, do not interpret. A UnityFresnelNode key would have made the first converter easier and every subsequent one harder.

Subgraphs are inlined, so the compiler never learns about them

A subgraph is a reusable graph fragment with declared inputs and one output. Unity calls it a Sub Graph, Unreal a Material Function.

The compiler knows nothing about them. expandSubgraphs runs first and inlines every subgraph node into the parent as a pure structural rewrite: prefix the internal ids, rewire each input node to whatever the parent wired in, splice the output in place of the subgraph node. Nested subgraphs expand recursively.

This is how a real compiler flattens function calls, and the payoff is that emitGraph is byte-identical whether or not subgraphs were used. Every feature added to the compiler works inside subgraphs without being taught to.

The same trick handles the static switch. It is not a runtime branch: it is picked at compile time, and only the live side is emitted. The dead side never gets a type, never gets a width, never reaches the driver.

Three places the values can live

A graph property becomes a param, and where the value lives is the interesting choice:

Kind Lives Use
Shared one value on the material a constant the whole material shares
perObject a slot in the per-instance buffer each object overrides it - a per-crate tint
global outside the material entirely set by name at runtime, shared by every material that declares it

A global costs no per-object slot and has no per-material value at all. The hooks read globals.<var> instead of params.<name>, and a script sets it with api.setGlobalVec("windDir", …). That is how one wind direction drives every plant in a scene without touching a single material.

The 36% that was library nobody called

The first version of the composed shader was toMaterial(MESH_WGSL) + SG_MATERIAL_HELPERS: the template, plus all 27 node helper functions, glued on before any graph was known.

So every hook material carried the whole node library whether it called one node or none. The voxel wind material calls none, and measured 36% of its shader was node library.

Bytes are the least of it. That is WGSL the driver parses and compiles for every material variant in a scene, and it is the part of the renderer with no ceiling: 27 helpers today, and a vendor graph can reach for any of hundreds. composeShader now appends only the nodes a material actually calls.

The lesson generalises past shaders. A library that concatenates itself onto every consumer is fine at 27 entries and quietly becomes the dominant cost at 300, and nothing about the code gets worse in between. It is worth asking early whether a thing is linked or pasted.

Bad WGSL must not take the frame with it

Users write shaders that do not compile. That has to be survivable, and in WebGPU it is awkward: createRenderPipeline reports a bad shader asynchronously, so a synchronous try/catch around it catches nothing.

The material cache is built around that. get() returns the fallback pipeline immediately and swaps in the real one when compilation resolves; a rejected compile keeps the fallback for that material and routes the error to the editor's console with the line number. Only resolved pipelines are memoised, so a material that failed while you were mid-edit recompiles on the next keystroke rather than being cached as broken.

The visible behaviour is what you want from an editor: type an invalid line, the object keeps rendering with default shading, the error appears, fix the line, it comes back.

What the editor adds on top

The graph editor is a canvas over that model, and the two features I actually use are both about reading a graph rather than building one:

  • Contribution tracing. Click a Surface port and every node that feeds it lights up, back-traced through the wires. An imported graph is a wall of nodes; this answers "which of these forty things actually affects the colour" in one click.
  • Auto-layout. Imported graphs arrive with no positions, or with positions from an editor whose coordinate conventions are not mine. Laying them out by dependency depth is the difference between a graph you can read and a pile.

Where it is honest

The four hooks are a real ceiling. You cannot write a material that changes how lighting works, because lighting is not yours. Vertex hooks run in the vertex stage, so a fragment-only node (a texture sample, the scene depth) wired into vertexOffset fails to compile, with a message saying so rather than silently producing something wrong.

And a graph is only as portable as its nodes. When a converter meets a source node with no equivalent in the registry, the honest options are to build the node or to refuse the graph. Guessing produces a material that looks plausible and is wrong, which is the one outcome worth more than the work of avoiding it.

← All posts