Editor · guide
Baking a material
How a texture graph's outputs become files the engine already understands — the nine usages and the seven files they land in, the ORM packing, PNG or KTX2, the .vxmat, the GUID dance, and the provenance block that stops a painted-over map being regenerated.
Edit this page on GitHubDocuments
What it is
A texture graph produces pictures. This is what turns them into a material: MaterialBake packs and
encodes them, ProjectMaterialBaker puts them in the project, MaterialProvenance records what made
them, and MaterialMapNaming is the vocabulary all three agree on. vixen texture bake is the same
code from a command line.
⚠ The content build never evaluates a graph. A bake happens on a person's machine, when they
press the button, and writes ordinary files into Assets/ — from there it is an ordinary texture
asset with the existing importer, the existing .meta, the existing streaming and the existing block
compression. That is doc 48 § D4, and it is why determinism is a non-question: the artefact is a file
somebody can open, diff and paint on.
What it is for
Everything an author makes in the texture tool is a picture in a device's memory until this runs. The
bake is what turns a set of them into assets the rest of the engine already understands — so that the
importer, the .meta sidecar, streaming, block compression, the bindless material table and the
.vxmat need to learn nothing about texture graphs at all.
Reach for it when you want to:
- ship what a graph or a folder of maps produced, as files a build can consume;
- re-bake a set after editing the graph, keeping every GUID so nothing that referenced the material breaks;
- know what made a file, because the provenance block records it and the digest notices when somebody has painted over an output.
It is not how a material gets its parameters — that is the .vxmat and the shader graph. This
writes maps.
Using it
Two entry points over one implementation:
vixen texture bake --from, for a folder of maps named by usage. That is the command line below, and it is what a build script or a headless machine uses.vixen texture bake --graph, for a.vxtexgraphcompiled and evaluated on a GPU. ⚠ It refuses when there is no adapter rather than falling back to the device that draws nothing, because that fallback writes black maps and exits 0.ProjectMaterialBaker, for code inside the editor.MaterialBake.Encodeturns a dictionary of bitmaps keyed byMaterialMapUsageinto the files to write — that is where the ORM packing and the PNG-or-KTX2 decision happen — andWriteputs them in the project, mints the identities and writes the.vxmatand the provenance.
The seam between the three is that dictionary: whatever fills it, the write below it is the same.
⚠ This paragraph said there was no bake verb in the editor and that ProjectMaterialBaker's only
callers were the CLI and the asset tests. Both stopped being true on 2026-09-08. A Bake Material
verb takes the open .vxtexgraph to a .vxmat, a Bake Material from Layers verb writes one per
texture set of a .vxlayers, and the CLI grew its graph half — one baker, three callers. The
mesh-map bake does have a panel, which is
the shape it will take.
The nine usages and the seven files
An Output node declares a usage. A bake writes files, and the two lists are not the same
length, because three usages share one file.
| Usage | File | What a material calls it |
|---|---|---|
baseColor | <name>_baseColor | baseColorMap |
normal | <name>_normal | normalMap |
occlusion | <name>_orm — red | ormMap, or occlusionMap behind a layered surface — same file, different reader |
roughness | <name>_orm — green | ormMap, and nothing behind a layered surface |
metalness | <name>_orm — blue | ormMap, and nothing behind a layered surface |
emissive | <name>_emissive | emissiveMap |
opacity | <name>_opacity | opacityMap |
height | <name>_height | parallaxHeightMap, only where the material carries ParallaxOcclusionFeature |
mask | <name>_mask | — nothing samples it |
⚠ The R, G, B order of the packed map is TexturedOrmFeature's and not a preference. That
feature reads occlusion from red, roughness from green and metalness from blue; a bake that packed
them in the order the enum happens to list them would produce a material that is shiny where it
should be occluded, and nothing anywhere would say so.
⚠ A channel the graph did not produce is not zero. A graph that outputs roughness alone still
needs the other two channels, and zeros there write a fully occluded conductor — a material that is
black and shades. The absent values are read off the runtime features' own defaults:
OcclusionFeature.OcclusionMap for occlusion, and MetalRoughnessFeature's roughness and metalness
for the other two.
⚠ One of the nine binds to nothing on every material, and is written anyway. A mask is § 4.10's input to another graph or to a layer stack, and a material never samples one.
⚠ And on a layered material the table above is the plain case. A material carrying a
TexturedMaterialLayersFeature gets no base surface composed behind it (the layered surface is the
base surface) and, since 2026-09-09, no TexturedOrmFeature either — that feature assigns
d.perceptualRoughness and re-splits the albedo by metalness, over per-layer values the author's
surface has already answered. So MaterialBake.Material returns a null parameter for baseColor,
and a re-bake of a layered material writes that file and does not name it in textures:.
⚠ The packed map is the one that is renamed rather than dropped, and until 2026-09-09 it was
dropped too. Its red channel is the baked occlusion, which the layered surface does not write and
which TexturedOrmFeature was the only carrier of — so dropping the feature left a layered material
with no route to a baked AO map at all
(#1130). A TexturedOcclusionFeature takes its place:
it reads the map's red and multiplies it into d.occlusion, has no opinion about roughness,
metalness or the albedo split, and so composes behind a layered surface where the packed one cannot.
The bake binds the same file under occlusionMap — one import, one bundle entry, one resident
page, and two channels nothing looks at. ⚠ Not by re-composing ORM, which is the arithmetic above and
is stated as a requirement rather than a preference.
⚠ The height map's name is the author's answer and not the target's, which is why it is not in the
naming table. A material that carries ParallaxOcclusionFeature gets its height output bound to
parallaxHeightMap; a material that does not gets the file and no texture entry. Returning the name
unconditionally would put bytes the build imports, a bundle carries and a pool makes resident on
every baked material with a height output, sampled by nothing. Composing the feature unconditionally
is worse: it would march every such material per pixel, and — since the base surface is appended
first — produce a .vxmat MaterialCompiler refuses, because the coordinate stage has to be first.
⚠ This paragraph said "there is no textured height feature" until 2026-09-09, which was true when
#615 was decided and stopped being true when parallax
landed.
⚠ So a first bake needs somebody to ask, and both hosts now have the ask. The preservation rule
above keeps a feature the material already carries, which by construction cannot help a material that
does not exist yet — the route was bake, hand-edit the .vxmat, bake again.
MaterialBakeParallax.Requested is that ask, and it is one rule with two front doors: the command
line's vixen texture bake --parallax and the editor's Bake Material with Parallax, on the Tools
menu under Bake Material. ⚠ It decides nothing but the ask: it re-reads the .vxmat the bake
just wrote, puts a ParallaxOcclusionFeature on it and re-composes through MaterialBake.Material,
which is what seats the feature at index 0, re-points its HeightMap, and drops it again where no
height map was written. Appending the feature instead produces a file the verb itself wrote and the
compiler then refuses with CoordinateFeatureOutOfOrder. ⚠ It is applied after the write and
never seeded before it, because the path the material lands at is ProjectMaterialBaker's: a name a
different source already owns becomes Name_2, so a seed guessing the path can land on somebody
else's material. ⚠ And it is a second verb rather than a tick-box, for the reason *Bake Material
(Force)* is: these bakes run from command handlers and there is no bake pane to put a control in.
⚠ A material may not rename its maps. WorldRenderer.Paired pairs one shader parameter with one
material-side name and keys that on the feature's default, so a renamed map resolves nothing, takes
slot zero, and is shaded by the bindless table's fallback checker — a lit surface whose shading is
merely wrong, on every device, with nothing reported. MaterialMapNaming.Parameter therefore reads
the names off the feature records rather than holding literals.
PNG, or KTX2 above 2K
| Largest edge | File | Mips and compression |
|---|---|---|
| up to 2048 | .png | the sidecar names them; TextureImporter applies them at build time |
| over 2048 | .ktx2 | already in the file; the importer copies a compressed source straight through |
⚠ 2048 is a PNG and 2049 is a container. "Over 2K" is an exclusive ceiling, because 2K is the size most texture sets are authored at and a ceiling that caught it would put the ordinary case on the exceptional path.
⚠ One table drives both paths. MaterialMapNaming.CompressionOf names BC7 for the colour and
packed maps, BC5 for the normal map and BC4 for the one-channel maps, and that value is both what the
sidecar says below the limit and what the bake encodes above it. Leaving it at Automatic would give
an opacity mask four channels of BC7 below the limit and one channel of BC4 above it — a resolution
slider that changes the compression.
The write
- The name is sanitised, and the set's name is resolved against what is already in the folder.
- Any output whose bytes are not what the last bake wrote stops the run — see below.
- Files under this set's names that the bake can prove it wrote and is not writing again are removed — see below.
- The maps are written, and the database is scanned.
- Each map's GUID is read back and its sidecar is finished with the settings that say what it is.
- The
.vxmatis written, naming those GUIDs, and the database is scanned again. - The material's GUID is read back and the
texturing:block is written into its sidecar.
⚠ A scan rather than an import, and the difference is what mints the identity. A file in
Assets/ has no AssetId until the database has seen it and written a .meta beside it. That is
ProjectMeshBaker's dance and § D11 names it as the sequence a material bake must use too.
⚠ Two scans, and the second is not tidiness. The .vxmat names its maps by AssetId, and those
ids do not exist until the maps have been scanned.
⚠ Re-baking overwrites and keeps every GUID, so every entity already pointing at the material picks up the new maps.
⚠ Which is why a set is keyed on its source and not on its name. "The same graph again" and
"another graph called the same thing" produce identical file names, and keying on those turns the
correct behaviour above into a silent swap of one material's maps for another's, GUIDs and all — the
defect #681 records against the mesh-map baker. A
second source baking under a taken name gets <name>_2 and a warning.
⚠ A folder is a source, and that is what makes the sentence above true of vixen texture bake.
The key is the graph's AssetId where a graph produced the maps and the --from path where the
command line did — MaterialProvenance.KeyOf. Keyed on the asset alone it was a guard the only
caller in existence could not reach: every command-line bake adopted whatever set was under --name,
so two --from folders under one name overwrote each other and shared GUIDs
(#725).
⚠ The bake owns the features and the textures; the shading model and the pass are the author's. A
re-bake replaces what the graph says and keeps what it cannot say, so a material somebody switched to
SubsurfaceShading stays that way and a feature whose map the graph stopped producing goes.
⚠ A map the database did not pick up stops the bake rather than becoming a null. Recording
AssetReference.Null and carrying on wrote a .vxmat naming a texture that resolves to nothing —
the feature is keyed on the map being present, so it was added with a null beside it, the index
stayed zero, and the surface shaded from slot zero on every device with nothing reported
(#724). It is a state a project can be in: a scan will
not replace a .meta whose GUID it cannot read, because minting a new one would break every
reference through the old one. --force is not offered for it, because forcing cannot make the
database name a file it would not read.
Provenance, and the painted-over check
The material's sidecar carries what doc 48 § D4 asks for:
extensions: texturing.source: Assets/Materials/ship-hull.vxtexgraph texturing.sourceAsset: 6f1e… texturing.set: Body texturing.outputs: baseColor, normal, orm texturing.resolution: 2048 texturing.parameter.rust: 0.6 texturing.adapter: AMD Radeon RX 7900 XT texturing.digest.baseColor: sha256:… texturing.writtenDigest: sha256:… texturing.at: 2026-09-05T…⚠ Flat dotted keys rather than § D4's nested mapping. A sidecar's extensions are a
Dictionary<string, string>, which is what meshMap.usage and every other extension in the tree
writes into. What the sketch is about is which facts are recorded, and all of them are.
⚠ texturing.set says which texture set of the source produced this material, and it is absent
rather than blank when there was none. A layer stack writes one .vxmat per texture set and every
other fact in the block is identical across them — same source, same asset, same adapter, and a stack
exposes no parameters — so without it Hull_Body and Hull_Trim recorded character-identical blocks
and the only thing telling them apart was the file name. A graph bake and a folder bake have no
texture sets and write no key, so "this bake did not say" and "this bake had no set" are not the same
sidecar.
⚠ It is not part of the key a re-bake matches on. That key exists to stop a different source adopting a name, and two sets of one stack are the same source — so their shared key is correct rather than a collision, and narrowing it would change which files a re-bake is allowed to overwrite.
⚠ The adapter is recorded and never compared. A re-bake on the same machine is byte-identical and that is asserted; a re-bake on a different card is not, and pretending otherwise would make the first artist with a different GPU a bug report.
⚠ A file somebody painted over is refused, not regenerated. Before writing anything, the bake
compares each output's bytes with the digest it recorded. A mismatch stops the run and names the
maps, because the most common reason for one is that a person painted on the file — and a bake that
overwrote it would destroy that work in the moment it looked like success. --force is how somebody
says they meant it.
⚠ A recorded map is looked for under both extensions, and the one that agrees with the digest wins. A set that used to be 4K is a container and the same set at 2K is a PNG, so a check that only looked under the extension this bake is about to write would call every output of a resized re-bake "not there". Taking the first extension found instead was just as wrong in the other direction: a PNG sitting beside a container this bake wrote was compared against the container's digest, disagreed by construction, and refused every further bake of that material as painted over — for a file the bake does not write.
⚠ The digest covers the maps and not the .vxmat. A material an artist edited in the inspector
is not a painted-over map, and including it would make raising an emissive intensity look identical
to painting on a normal map.
⚠ What "byte-identical" covers is the outputs and the material. The sidecar carries the time the bake ran, so two runs differ there by construction.
Which files on disk this material owns
Two things leave a file behind under a set's names: a map that crosses the 2K limit changes extension, and a re-bake stops producing an output. Either way the leftover is a project asset holding the previous bake's pixels under a name that says it is this one's — what a generator or a second material picks up by accident — and the dropped output is the worse of the two, because its digest key goes with the output and the painted-over check no longer covers it.
⚠ A file is removed only when the digest proves this bake wrote it. The material's own sidecar
recorded texturing.digest.<suffix> for the previous run; if the bytes on disk still hash to it, a
previous run of this bake wrote that file, and it goes along with its .meta. Anything else is named
in a warning and left where it is.
⚠ Removing on the name alone destroyed data. The names come from the material's name, so the
first bake of a material called Rock deleted a hand-authored Rock_baseColor.png and its
.meta — and with it the AssetId every scene resolved that texture through
(#723). An orphan an artist can see and delete is a
strictly better failure than one the bake deletes for them.
⚠ --force does not widen the removal. Forcing says "overwrite what I painted", not "delete what
I painted", and a painted file's bytes are by definition not the ones the bake wrote — so a painted
output the bake has stopped producing survives a forced run, with a warning.
From the command line
vixen texture bake --project . --from authored/ --name ShipHullvixen texture bake --project . --from authored/ --name ShipHull --forcevixen texture bake --project . --graph Assets/Hull.vxtexgraph --name ShipHullThe inputs are named <anything>_<usage>.png — hull_roughness.png, hull_baseColor.png — and
everything else in the folder is ignored. Two files claiming one usage is refused rather than
resolved by enumeration order.
Exactly one of --from and --graph is passed; neither is guessed, because two sources for one
material is a script that bakes whichever the parser preferred. ⚠ --adapter is refused beside
--graph: it records what ran a bake this tool did not do, and a graph bake ran on the device this
process opened.
⚠ --from is the set's identity, not just a note in the sidecar. Baking the same folder again
overwrites that set and keeps its GUIDs, which is what re-baking means; baking a different folder
under the same --name writes <name>_2 and says so on stderr, rather than handing the second folder
the first one's GUIDs.
⚠ The verb reads a folder of maps and does not evaluate a graph. ⚠ This used to say a
.vxtexgraph did not exist; it does — the texturing plugin registers the document and claims the
extension — so what is missing is narrower and worth naming exactly: nothing joins a compiled graph's
outputs to this dictionary, and --graph would still be the flag that parses and then apologises.
Everything below the argument parsing is the code a panel will call, so the graph arrives as a second
way of filling the same dictionary rather than as a second baker.
⚠ The input vocabulary is usages and the output vocabulary is files, so re-reading a bake's own
output folder does not round-trip: hull_roughness.png is an input and hull_orm.png is an output,
because packing three inputs into one output is the work the verb exists to do.
What is not here yet
- A height feature. #615.
- A
Source/Bitmapnaming a project asset, from the command line. The graph verb refuses one rather than resolving it: that resolver reads a live editor session's unsaved paint canvases, and a second copy in a CLI would be the copy that forgot a case (#1087).
⚠ Two entries came off this list on 2026-09-08 and one of them had been wrong for a batch. "A bake panel — nothing in the editor calls it" was false once Bake Material landed (#570, #1009), and "evaluating a graph — nothing yet fills the dictionary from a compiled graph's outputs" was false in the editor from that day and is now false from the command line too (#1020).
⚠ "A frame that draws one of these" used to be on that list and has come off it.
BakedMaterialImageTests renders a material whose maps the evaluator made and this bake packed,
through the real StandardFrame — doc 48's twelfth exit criterion. The oracle is a differential
rather than an eyeball: flat maps are a constant, and a constant surface is what MetalRoughnessFeature
spells with three numbers, so the whole textured path has to reduce to the untextured one — and a
second colour that must not agree is asserted beside it, because a pass lit by an authored 0–1 tint
in a cd/m² frame is pixel-identical to a pass that never ran.
⚠ Two caveats the criterion does not cover, and this guide would rather say than imply: the mesh is a
TierScene box rather than an imported asset, and it is photographed at 64 px, which is below the
threshold where the KTX2 and block-compression path is in the picture at all.
Examples
Packing a graph's outputs and putting them in the project:
// Whatever produced the pictures — an evaluator, an importer, a test — the seam is this dictionary.var outputs = new Dictionary<MaterialMapUsage, Bitmap> { [MaterialMapUsage.BaseColor] = baseColor, [MaterialMapUsage.Roughness] = roughness, [MaterialMapUsage.Normal] = normal};// Encode packs occlusion/roughness/metalness into one ORM file and picks PNG or KTX2 per size.var images = MaterialBake.Encode(outputs);var baker = new ProjectMaterialBaker(project);var set = baker.Write("ShipHull", images, record);foreach (var warning in set.Warnings) { report(warning);}⚠ Note what is not in that dictionary: metalness and occlusion. They are written anyway, from the runtime features' own defaults, because zeros in those channels are a fully occluded conductor rather than an absence.
Re-baking the same set from a command line, over outputs somebody may have painted:
# Refuses, naming the painted files, if any output's bytes are not the ones the last bake wrote.vixen texture bake --project . --from authored/ --name ShipHull# Says "overwrite what I painted" — and never "delete what I painted".vixen texture bake --project . --from authored/ --name ShipHull --forceSee also
- Texture graph evaluation — what makes the pictures this packs.
- Mesh map assets — the other bake that writes into
Assets/, and the provenance pattern this one follows. - Meshes and materials — the features whose defaults decide what an absent channel is worth, and the naming rule a renamed map breaks.