Root CauseWhat broke, why, and the fix.

Chasing a magenta material took our Unity build to 590,000 shader variants

· unity, urp, shaders, build, debugging
ⓘ Operated by TechAthletes. Every post here is a bug we hit in our own work — symptom, root cause, fix. Nothing is sponsored and we are not paid to mention any tool.

A player build of our URP racing game came out with a magenta object in it. Magenta in Unity means "this material's shader isn't in the build" — the material was being created at runtime via Shader.Find, nothing referenced that shader at build time, so it got stripped out.

The fix looked obvious. Project Settings → Graphics → Always Included Shaders, add the URP shaders, done. That setting exists precisely for shaders nothing references until runtime.

The magenta went away. The build stopped finishing.

The symptom

Not an error. Not a failure. The build reached "Compiling shader variants" and stayed there. Tens of minutes. The reported count climbed into the high hundreds of thousands — our notes from that build put it at roughly 590,000 — and it never got to the end.

Two things made this hard to place:

The project had five materials. Five. A variant count in the hundreds of thousands is not a number five materials can produce, so I spent real time looking at the wrong thing: scanning materials, checking for duplicated assets, looking for something that had exploded in the project. Nothing there had changed.

Nothing reports the number before the build starts. Unity tells you how many variants you are compiling while it compiles them. There is no pre-build figure, so the only feedback loop available was "start a build, wait twenty minutes, watch it not finish." That is a terrible loop to debug in, and it is the reason this took as long as it did.

The change that caused it was one line in a settings file, made for an unrelated reason, an hour earlier.

Root cause: stripping runs on materials, and I had removed the materials

Unity decides which shader variants to compile by looking at materials. A material records which keywords it enables. The set of materials going into the build is the set of keyword combinations that need compiled programs. Everything else in the shader's space gets dropped. That is what shader stripping is.

The distinction that matters:

  • multi_compile keyword sets are always compiled in full. Nothing strips them automatically — the engine has to assume any combination may be switched on at runtime.
  • shader_feature keyword sets are compiled only for the combinations some material actually enables. This is where nearly all of the savings come from.

A shader in the Always Included list arrives at the build with no material attached. There is no keyword combination to narrow to. So Unity compiles the shader's entire shader_feature space, because it has no evidence about which parts are unused.

For a small custom shader, that difference is uninteresting. For a render pipeline shader it is the difference between a build and no build. URP's Lit shader covers every lighting setup, shadow cascade configuration, lightmap encoding, decal mode, fog mode and rendering path the pipeline supports. Its full keyword space is enormous by design — stripping is the mechanism that makes it shippable at all. I had switched off the only thing keeping it in range.

So the count didn't come from the project. It came from one setting, applied to shaders that were never ours.

And the setting doesn't tell you this. The documented purpose of Always Included Shaders is "guarantee this shader is in the build." It does that. The second effect — that the shader now compiles its full feature space because it has no material context — is real, mechanical, and not mentioned anywhere near the checkbox you tick.

The fix: put the material back

The instinct is to remove the shader from Always Included Shaders. That just brings the magenta back — it was added for a reason.

The actual fix is to get the shader into the build through a material, so the stripping evidence exists again.

Create a material asset that uses the shader with exactly the keywords you need, and put it under a Resources folder. Everything under Resources ships whether or not a scene references it, so the shader is guaranteed to be in the build — and because it arrives attached to a material, stripping applies normally. Only that material's keyword combination gets compiled.

Assets/Resources/Materials/Lit.mat
Assets/Resources/Materials/Additive.mat
Assets/Resources/Materials/Ground.mat

Then at runtime, clone the template instead of calling Shader.Find:

// Templates authored as assets (Resources/Materials).
// Cloning them — rather than Shader.Find + Always-Included-Shaders —
// is what keeps URP's shader variant stripping working; the alternative
// made the build attempt ~590k variants and never finish.
private static Material LitTemplate => _litTpl ??=
    Resources.Load<Material>("Materials/Lit");

private static Material NewLit() =>
    LitTemplate != null ? new Material(LitTemplate)
                        : new Material(Shader.Find("Universal Render Pipeline/Lit")
                                       ?? Shader.Find("Standard"));

new Material(template) inherits the template's keyword set, so the runtime material stays inside the combination that was compiled. The Shader.Find branch survives only as a fallback for a project configured without the templates; in a correct build it never runs.

After that, the URP shaders come out of Always Included Shaders. Ours now contains only Unity's own built-in shaders, which is where that list should stop.

Two other routes work for the same reason — the shader reaches the build via a material either way:

  • Addressables or a plain scene reference, if the material belongs somewhere concrete.
  • A shader variant collection: record variants in play mode, save the collection, reference it under Preloaded Shaders. That gives you an explicit list instead of a full compile.

And if a shader genuinely has to stay in the Always Included list, IPreprocessShaders is the escape hatch — implement the callback and drop the keyword combinations the project never uses.

What I'd check first next time

  • A variant count that doesn't match your material count is a settings problem, not an asset problem. Five materials cannot produce 590,000 variants. The moment those two numbers disagree by orders of magnitude, stop looking at assets.
  • Diff your ProjectSettings/ directory. This was one line in GraphicsSettings.asset. Settings files are in version control and nobody reads them in a diff, which is exactly why a change there can be invisible for hours.
  • "The build is slow" and "the build cannot finish" are different bugs. Shader compilation that grows superlinearly looks like the first and is the second.

The follow-up, and what measuring it actually showed

The part that bothered me afterwards was the feedback loop, not the bug. The fix took minutes once the cause was clear; finding it was slow purely because the number only exists during a build.

So we built an Editor tool that reads the project and reports the variant count, the shaders responsible, and the specific #pragma lines multiplying it — before a build starts. The rule it fires for this exact failure is URP001; the documentation lists all of them and what each finding means, and the page on Always Included Shaders is the longer version of the mechanism above.

Then we calibrated it against real builds, and the honest result is worth publishing. On a URP project (Unity 6000.0.78f1, URP 17.0.4, 88 materials, macOS player). To compare like with like, shaders the tool cannot read (Standard and friends) are excluded from both columns — the full build compiled 13,617 programs; the subset below is the part the tool actually analyses:

Rung Measured in the build Estimated without building
Full variant space 3.84B 7.0T
After settings filtering 64,652 3.9B
After built-in stripping 754 679,135
Compiled 609

Roughly three orders of magnitude apart. A model sitting outside the engine cannot see how much Unity's own settings filtering removes, so treating a static estimate as a prediction of compiled programs would be wrong.

What survives that gap is the part that mattered during the incident:

  • Attribution — which shaders, which keyword sets, which settings — is read from the project, not estimated.
  • Detection of whether stripping can run at all is a yes/no, not a magnitude. That is the entire URP-shaders-in-Always-Included failure.
  • Comparison between two runs is meaningful. On that same project, moving the URP shaders into Always Included Shaders took the estimate from 679,135 to 47.4B — a ratio no build log will ever show you, because that build does not finish.
  • Exact numbers are available when you hand it a build log, which is the only place they truly exist.

We changed the tool's headline label to "estimated variant space", put Unity's measured ladder next to it, and published the calibration table rather than the flattering number. An estimate three orders of magnitude high is still useful for detection and comparison; it is not useful as a prediction, and saying so is cheaper than having someone discover it.

We turned that check — the count, the shaders responsible, and the settings that switch stripping off — into an Editor tool we run before builds.