Translating Unreal's material graphs, and knowing when to refuse
Post #3 got Unreal geometry out of a .uasset. That left a level rendering in flat colours: the Shaders panel was empty and the ocean was a solid blue rectangle, because nothing turned a material's node graph into a shader.
An Unreal material is not a texture and a few sliders. It is a node graph, sometimes hundreds of nodes deep, calling material functions that call other material functions. Translating that into Awaken's shader graph is the most interesting importer work I have done, mostly because of when it says no.
Flatten first
The graph as authored is not the graph you translate. Three passes come first:
- Reroutes collapse. Unreal's reroute nodes are visual tidying; they carry no semantics and are spliced out.
- Material functions inline. A function call becomes its body, wired to the caller's inputs. Nested functions expand recursively. This is exactly what subgraph expansion does on the Awaken side, and for the same reason: it means the translator never learns about functions.
- Engine functions transcribe. These are the ones Epic ships. There is no body to inline because they live in the engine, not in your pack, so each has to be written out by hand from its published definition.
After that you have one flat graph of primitive nodes, which is a thing a translator can walk.
Total or nothing
The rule that shapes everything else: a material either translates completely or it is declined and keeps the plain material it already had.
Partial translation is the tempting option, and it is a trap. A graph missing one node silently drops whatever that node contributed, and renders something the author never wrote. It does not look broken. It looks like a slightly different artistic choice, which is worse, because nobody investigates it.
So a node class with no faithful equivalent aborts that material, and every declined material is named with its reason. On the Pirate Realm pack: 18 materials translate and compile to WGSL, and 139 placements now render through a node material, including the ocean, the skydome, the skybox, the clouds, the waterfall, the light rays, the glass and the ice.
The bug I am most glad I caught
CustomRotator appears 9 times in that pack. Its published definition takes a rotation angle, and I wrote it in radians, because that is what a rotation is measured in.
Unreal measures it in turns. One full rotation is 1.0, not 6.283.
Get that wrong and nothing errors. No shader fails to compile, no material is declined, nothing appears in a log. Every affected surface rotates by the wrong amount and still looks plausible, because a rotated texture rotated differently is still a rotated texture. You would ship it, and you would only find it by putting the two engines side by side on the one asset where the difference is obvious.
That is the whole category of bug this importer is built to avoid. A crash tells you where it is. A plausible wrong answer does not tell you anything, and importers are full of opportunities to produce them.
Two more of the same shape, both handled by refusing to guess:
Ifbecomes a comparison plus a branch, nesting a second branch only when theAEqualsBinput is actually wired. Unreal folds an unwired equal case into the less-than branch, and one branch expresses that exactly. Adding a second branch anyway would change behaviour on the equality boundary.Transformmaps only World and Local. Any other space is left untranslated rather than silently moved into the wrong frame, which is another thing that looks plausible and is wrong everywhere.
28 of 39, and the eleven that are not
Across a surveyed pack, 39 distinct engine functions were called. 28 are transcribed, each with the formula written out in its entry. Adding four of them (CustomRotator, Blend_Overlay, AppendMany, LinearGradient) meant the expression language itself grew sin, cos, floor and step.
The remaining eleven split into two groups, and the split is the point:
Documented by behaviour, not by formula. SimpleGrassWind (7 calls) and BlendAngleCorrectedNormals (3 calls) are described in Epic's documentation by what they achieve, not by what they compute. I could write something that produces grass moving in wind. It would not be a translation, it would be my grass wind wearing the name of theirs, and it would diverge from the original in ways nobody could predict from the pack. These are declined, deliberately, and I do not intend to change that.
Have a formula, simply not done yet. FlipBook (3 calls), RGBtoHSV (2) and RadialGradientExponential (1). These are work, not a decision.
Writing that distinction down was worth as much as the code. Six months from now, the question "why doesn't grass wind import?" has an answer that is not "nobody got round to it".
The unglamorous half
Most of the actual effort was not graph theory:
- Parameter names become WGSL identifiers. Unreal parameter names contain spaces, punctuation and unicode. Sanitising them is dull and is the difference between a shader that compiles and one that does not.
- Normal maps unpack to -1..1. A normal sample read as raw 0..1 gives you a surface lit from a direction nobody chose.
- Masks are multi-channel. A mask texture packs four unrelated things into RGBA. Reading it as a single value takes one and discards three.
- Textures bind through the instance chain, nearest override winning, which is the same rule albedo resolution already used.
- Everything compiles under the GPU harness. A translated material is not trusted because it looks like valid WGSL; it is compiled on a real device in the test suite. "Looks like valid WGSL" is precisely the standard that lets a bad translation through.
Stating the losses out loud
The ocean's WorldPositionOffset is dropped, and the import says so in its diagnostics. Its chain samples a texture, and this renderer binds material textures to the fragment stage only, so the displacement cannot be evaluated where it is needed. The surface shades and animates correctly; it does not displace.
Dropping one output beats losing the whole material. But it only beats it because it is stated. An unstated drop is the plausible-wrong-answer failure again, one level down.
What generalises
Two things, neither of them about Unreal.
Flatten before you translate. Reroutes, function calls and engine functions are three different problems, and collapsing them into one flat graph first means the translator solves one.
Make refusal cheap and loud. The instinct on an importer is to get something on screen, because something looks like progress. In my experience the material that renders as flat grey and says "declined: BlendAngleCorrectedNormals has no faithful equivalent" is worth more than the one that renders beautifully and is quietly not what the artist made.