---
title: "Rendering & URP"
slug: /workflows/rendering-urp/
markdown: /workflows/rendering-urp.md
section: art-audio-animation
tier: 2
summary: "URP, shaders and materials that fail silently, and the look pipeline."
sources: ['unity/rendering.md']
canonical: https://unityworkflowlibrary.dev/workflows/rendering-urp/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# Rendering & URP

> URP, shaders and materials that fail silently, and the look pipeline.

URP configuration, shader/material state that fails silently, and the look pipeline. The recurring theme: the thing renders, it is simply rendering nothing.

- **NESTED-RENDER-PASS-RESTORES-TARGET-AND-VIEWPORT (amended)** - a custom pass that binds smaller intermediate color/depth targets restores the owning camera targets AND its actual-size viewport before returning. HDRP restoration is configuration-dependent: in the inspected Unity 6000.5 HDRP source, output is reset only when the constrained depth target differs from Camera. A nested MRT binding is not automatically covered. `SetRenderTarget` does not reset viewport: SetViewport to that target's pixel size at bind time, then restore with min(actualWidth, colorBuffer.width). Documenting the bind-time SetViewport without a call site is not the patch. Trace installed pipeline source and both buffer dimensions; compilation alone cannot prove that this state leak caused a reported visual artifact.

- **EDITOR-PLAY-SETRESOLUTION-IS-NOT-THE-GAME-VIEW ** - `Screen.SetResolution` in Editor Play changes `Screen.width` and can change `HDCamera.actualWidth` while `Camera.pixelWidth` stays the Game view panel. Nested passes sized from pixelWidth then restored from actualWidth leave a rectangle that appears or vanishes as the Game view is resized. Apply resolution in the player; in the Editor only set `fullScreenMode`.

- **CUSTOM-PASS-CULL-HOLDS-ONLY-REQUESTED-LAYERS ** - an HDRP custom pass's `ctx.cullingResults` is a SHARED cull that starts at layer mask 0 and ORs in only the layers the volume's passes request outside the camera's culling mask. A pass drawing layer-0 bodies (filtered by rendering layer, not GameObject layer) got a cull holding only the two layers other passes asked for, so it drew nothing in Play while Edit-mode stills and a stock pass set to Everything drew fine. Renaming or re-numbering the rendering layer changes nothing. Draw GameObject layers inside the camera mask from `ctx.cameraCullingResults` and the rest from `ctx.cullingResults` (disjoint sets, no extra cull), in one shared helper every layer pass calls. A pass that draws only when a sibling pass exists is reading someone else's cull.

- **EARLY-CUSTOM-PASS-DEPTH-IS-THE-PREVIOUS-CAMERA ** - at HDRP `AfterOpaqueDepthAndNormal`, `LoadCameraDepth` / `_CameraDepthTexture` is the depth pyramid, built later in the frame, so it still holds the previously rendered camera. In the Editor that is the Scene view: a rectangle the size of the docked Scene view (measured 483x773 = 386 pt x 1.25 DPI) reads NaN/far depth and depth-occluded effects vanish there; maximizing the Game view hides it. Bind `ctx.cameraDepthBuffer` as your own global and LOAD it. A rectangle whose size tracks ANOTHER view is a stale shared buffer, not a viewport - measure the other views before touching viewport code.

- **PATCH-THE-SHADER-SOURCE-NOT-THE-GENERATED-OUTPUT ** - a vendor shader generated from block/template sources (stacked-shader tools, Shader Graph output) is rewritten on regeneration. A fix landed only in the generated `.shader` was silently reverted three days later by a regeneration committed for an unrelated reason. Patch the source block and the output in the same change, put a marker comment in both, and grep the output for the marker after any regeneration.
- **GENERATED-SHADER-CHECKSUM-CAN-BE-BLIND ** - a shader generator that stamps a checksum of its include files can hash nothing in the Editor: an include imported as a `ShaderInclude` asset returned empty `text`, so the stamp equalled hash(editor version + pipeline) after every include edit and never flagged a stale output. Mirror an include edit into every inlined copy in the generated shader by hand and count the marker across copies (all N of N); never trust the stamp to catch drift.

- **URP `m_RenderingMode` in Unity 6 is not 0/1 = Forward/Forward+ (HARD).**
  `RenderingMode` is `Forward=0`, `Deferred=1`, `ForwardPlus=2`, `DeferredPlus=3`.
  YAML `m_RenderingMode: 1` is Deferred. Set via `UniversalRendererData.renderingMode =
  RenderingMode.ForwardPlus` (or write `2`). Entities Graphics URP **only supports Forward+**
  — classic Forward still boots but logs the warning and entity lights are unofficial.

- **URP-SHADER-ARRAYS-PIN-FIRST-SIZE — switching Android ↔ Standalone in one Editor
  session floods `exceeds previous array size (256 vs 32)` until restart (HARD).**
  `Shader.SetGlobalVectorArray` cannot grow. The first URP pass this session pins
  `_AdditionalLights*` and `urp_ReflProbes_*` to
  `UniversalRenderPipeline.maxVisibleAdditionalLights` (mobile 32 / desktop 256;
  probes `min(that, 64)`). With Android as the active build target, editor DX11
  still takes the mobile size because `PlatformAutoDetect.isShaderAPIMobileDefined`
  is true (`SHADER_API_MOBILE`). Switching to Standalone then requests 256/64 every
  camera, every frame; Unity only recreates the arrays on a full Editor restart.
  Not a compile error, not a missing light, not a scene bug. After any
  `SwitchActiveBuildTarget`, restart before trusting the console. Do not write a
  grow-workaround — there isn't one in-process.

- **One `new Material(...)` per UI object is one unbatchable draw call — and an eager clone in
  `Awake` is the usual culprit.** UGUI batches on `Graphic.materialForRendering` identity, so N
  cloned materials = N forced batch splits even when every clone is byte-identical, uses the same
  shader, and samples the same atlas. Symptom: batch count tracks object count almost 1:1 while the
  Frame Debugger shows the same shader and texture on every draw, and `dynamicBatchedDrawCalls` is
  0. Diagnose in play mode by grouping active graphics by `g.materialForRendering` identity (`GetEntityId` on Unity 6.5+, `GetInstanceID` before that) —
  if the distinct count ≈ the object count, that is the whole problem, and no amount of atlasing
  will fix it (check `g.mainTexture` separately: few distinct textures + many batches proves the
  material, not the texture, is the boundary). Fix shape: never clone in `Awake`; clone LAZILY on
  first per-instance effect and destroy it in `OnDestroy`, so objects at rest keep sharing the
  asset. Watch for TWO cloners stacked — an eager one plus a correct lazy one — where the eager one
  silently makes the lazy one moot. After removing an eager clone the shared material asset becomes
  the live runtime material: any `img.material.SetX` outside the lazy-clone owner then mutates the
  ASSET, and in-editor that persists to disk. (Measured on a mobile card game: `UI_Card.Awake` cloned
  the shared Card material per card; 66 cards → 67 distinct materials → 122 batches. Deleting the
  clone left 64 of 66 cards sharing one instance: **122 → 59 batches, 116 → 52 setPass**, with the
  existing lazy `CardFx.Instance` clone still serving the 2 cards actually running an effect.)

- **SKY-PROCEDURAL-BAKE-MOBILE — a procedural skybox shader (FBM nebulas, noise starfields)
  ships on mobile as a BAKED cubemap, not as live per-pixel math.** Domain-warped FBM + multi-
  scale star loops cost dozens of noise evaluations per sky pixel; the moment the camera angle
  exposes a lot of sky (RTS full zoom-out past the board edge), a handheld GPU drops frames on
  pure fill-rate. If the shader is static (no `_Time`), a bake is a 1:1 snapshot: render the sky
  material with a temp camera via `Camera.RenderToCubemap` (works under URP), write the six
  faces into a 6:1 horizontal strip PNG (cubemap faces read back top-down — flip rows for
  Texture2D), import with `textureShape = TextureCube` + AutoCubemap + Compressed (strip dims
  multiples of 4 so mobile compression actually lands), and swap `RenderSettings.skybox` to a
  `Skybox/Cubemap` material on `Application.isMobilePlatform` (neutral = _Tint 0.5 gray,
  _Exposure 1). Keep the bake on the profile/authoring surface, re-bake inside the map's
  Generate-All so it can't go stale, and fail LOUD (LogError) when mobile boots without a bake —
  a silent procedural fallback is the classic dead-knob trap. LDR clamp on star highlights only
  matters if mobile runs bloom. (Proven a mobile strategy game: baked vs procedural screenshots
  pixel-equivalent; nebula sky was the remaining zoom-out frame drop on Android.)

- **VENDOR-RENDERFEATURE-GLOBALS-DEAD — a vendor `ScriptableRendererFeature` that publishes shader
  globals in `OnCameraSetup` goes SILENTLY dead on a Unity version bump, and its `OnCameraCleanup`
  usually keeps running and ZEROES them.** The shape: the asset guards its legacy override
  (`#if !UNITY_6000_4_OR_NEWER public override void OnCameraSetup(...)`) and ships the RenderGraph
  replacement (`RecordRenderGraph`) as an EMPTY body, so every global it used to set stays at
  `(0,0,0,0)` with no error, no warning, and no missing-reference marker. Whatever reads those
  globals silently takes its fallback branch — a feature you paid for quietly stops running while
  its material knobs still read "on". Verify which overrides the COMPILED assembly declares by
  reflection; do not trust the source's `#if` ladder, and do not conclude "the feature is enabled,
  so it works".
  **The trap that makes the obvious fix fail:** `OnCameraCleanup` is typically NOT inside the same
  version guard, so it still executes and actively resets those globals to zero after EVERY camera.
  A once-per-frame re-push from `LateUpdate` is therefore wiped between cameras (scene view, overlay,
  reflection probe), producing an intermittent failure that reads as a race. Re-arm from
  `RenderPipelineManager.beginCameraRendering` instead — per-camera, which is exactly the seam the
  vendor used, and it gives each camera its own correct value (a per-camera forward vector rather
  than one shared guess). Pair the `+=` in `OnEnable` with `-=` in `OnDisable` as always.
  **Two companions from the same class:**
  (1) A `[Toggle]` material float whose keyword the vendor syncs ONLY from its own material
  inspector GUI (`MaterialUI.SetMaterialKeywords` and friends) will NOT enable the shader variant
  when the float is written from code — a bake that does `material.SetFloat("_FadingOn", 1)` leaves
  `_FADING` off forever. Set the keyword explicitly (`CoreUtils.SetKeyword`) in the same writer.
  (2) When a dither/fade path's globals go missing the failure is not "no fade", it is TOTAL CLIP:
  `alpha = min(alpha - cutoff, dither - 0.5)` with an unset scale/offset collapses every pixel onto
  one texel of an unbound texture, so the whole surface disappears. Prefer BUILDING such a lookup
  (an 8x8 ordered-dither matrix is 64 bytes) in code over wiring a generated asset that can go null
  on an older prefab — a constant table cannot be unwired, lost to import settings, or missed by a
  re-bake. (Seen in a mobile strategy game: Stylized Grass Shader 1.4.7 on Unity 6000.5 — perspective
  correction had already been patched from LateUpdate, and the view-fade path would have clipped
  every blade had the same globals not been re-armed per camera.)

- **SHARED-LOOK-LIGHTING — a surface that tints from terrain albedo but lights with a
  different model will un-melt on every look pass.** Sampling a colormap / splat is not
  enough. If the ground uses a shared stylized header (wrapped diffuse, floored umbra,
  hemisphere ambient, cloud), every lawn/decal/overlay shader must include that header
  **and** be stamped from the same catalog when the look is applied. A custom Lambert
  island looks right until the next terrain retune, then reads as its own carpet.
  Do not treat a shader you own as "vendor" just because it replaced a vendor one —
  `_BotwAmount` missing is how the look sweep skips it forever.
  (Seen in a mobile strategy game: grass colormap UV was correct; blades stayed a dark
  green carpet because `Game/GrassBladeTint` never joined `Stylized`.)

- **HDRP-DEFAULT-VOLUME-IGNORES-LAYER-MASK — a camera whose `volumeLayerMask` excludes the scene
  volumes still gets the project's Default Volume Profile, post effects included.** The HDRP Global
  Settings default profile is the base of EVERY camera's stack; the layer mask only filters scene
  Volumes on top. A space/celestial camera masked to its own layer therefore inherits the template's
  MotionBlur 0.5, automatic exposure and bloom while the scene volume that zeroed them no longer
  applies - the developer sees "motion blur out of nowhere" the moment the scene camera is left behind.
  Own the stack: give the isolated camera its own global Volume on its layer and override every
  effect you rely on being off (MotionBlur intensity 0, Exposure fixed, Fog off), or fix the default
  profile itself. Verify by dumping `VolumeManager.instance.stack` for that camera, not by reading the
  scene volume. Native: a space sandbox game gameplay camera in space mode, celestial camera blurred planets.

- **VISUAL-FIX-NAMES-ITS-STACK (HARD)** — a look, exposure, AA-mode, lux, or
  shader-keyword change names the camera (or volume, or material) that showed the
  symptom, enumerates every other stack that shares the writer, and does not write
  those others. The failure signature is one reported surface maybe-improves while
  sibling surfaces go black, over-bright, wrong-sun, or one-square volumetrics in
  the same diff. Shared helpers (`ApplyLookGrade`, `Apply*CameraSettings`) are the
  usual blast radius: either the helper is per-stack, or the other stacks do not
  call it. An EditMode test on a throwaway VolumeProfile is not a sibling-camera
  check. Verify the named camera and one other live stack (map vs menu vs
  gameplay). Observed: one "gray grade + cloud sections" pass
  rewrote every HDRP volume, doubled menu lux, and swapped TAA for SMAA on a
  spinning globe.
  **Companions:**
  (1) **EXECUTEALWAYS-SHARED-PROFILE-IS-AN-ASSET-WRITE** — `volume.sharedProfile`
  plus `Override` from `[ExecuteAlways]` / `OnValidate` bakes the runtime look
  into the authored asset. `volume.profile` clones; writing the clone in Play is
  fine, writing `sharedProfile` is a content change. Read the asset back.
  (2) **KEYWORD-STRIP-IS-THE-SECOND-CLOUD-HYPOTHESIS** — coverage islands / one
  square have three owners: volumetric checkerboard without its reconstruction
  Taa, coverage texture / tile spawn, then `shader_feature` variants stripped
  from the player. Check the first two before editing pragmas. A keyword patch
  that ships in the same diff as a global grade/AA rewrite has not isolated the
  owner. Do not Always-Include a megashader.
  (3) **STOP-THE-ZERO-DO-NOT-INVENT-A-GRADE** — a sat-0 / contrast-0 override that
  made one stack look gray is fixed by deleting that override. Switching ACES to
  Neutral and writing ColorAdjustments contrast ~28 plus a warm filter onto every
  volume crushes low-albedo lit meshes to black and recolors an HDR sun / sky
  transition. The recorded lux/EV on a sibling stack is not a knob for this fix.

## Materials, shaders and textures that fail silently

- **UNLIT-EMISSIVE-IS-EXPOSED  - an HDRP unlit emissive can be scaled to nothing by the camera
  exposure.** An HDRP/Unlit sun disc carried an emissive of 34 as its "glow" and it added nothing: `_EmissiveColor`
  at `_EmissiveExposureWeight` 1 is multiplied by the exposure (1 / (1.2 x 2^14) = 5e-5 at a fixed EV100 14, so 34
  became 0.0017), while `_UnlitColor` is written with no exposure at all. Read the exposure weight before tuning an
  unlit emissive, and author a bright object in ONE convention (display units with no exposure, like `_UnlitColor`
  and a VFX output with its exposure weight at 0, or physical units at weight 1), never a mix.

- **SHADER-SWITCH-KEEPS-OLD-VALUES  - a material moved onto a new shader keeps every property saved for
  the old one.** Setting `material.shader` (or picking a shader in the Inspector) leaves the old saved properties in
  the .mat, and any new-shader property with the same name reads them instead of its own default: a disc material
  moved off HDRP/Unlit still held `_Color` 1.7, which the new shader's `_Color` would have read. Found by reading the
  .mat before the switch. Reset in code with `EditorUtility.CopySerialized(new Material(shader), material)`, which
  keeps the asset GUID and every reference to it.

- **BUILDER-NEVER-WRITES-LOOK-VALUES  - a scene builder that sets a material's look on every rebuild is a
  second source.** A map builder's get-or-create helper wrote the disc colour (1.35 / 1.18 / 0.85) on every rebuild
  while the tuned asset held 1.7 / 1.35 / 0.95, so the next rebuild would have dimmed the tuned look with no error.
  A builder creates a look asset only when it is missing and never writes its values afterwards; the asset (and the
  Inspector that edits it) is the one source.

- **FEATURE-ATLAS-TAKES-THE-LAST-TEXTURE-SIZE  - a shared atlas sized from its last texture resamples
  every other one.** A vendor terrain's feature atlases (Space Graphics Toolkit landscape: albedo, normal, gradient
  and mask Texture2DArrays) allocate at the size of the LAST registered texture and blit the rest into it, and the
  registration lists are static across every landscape in the scene. One tiny texture added last (a 4 x 4 solid
  colour for an overlay) would drop every 2048² splat on every planet to 4 x 4, with no error. Add textures at the
  size the atlas already uses (there: albedo 2048², gradient 64 x 1024, masks 1024 x 512), and read the atlas
  allocation before registering anything new. Found reading the allocation code before an overlay shipped.

- **MASK-ATLAS-COSTS-A-HALF-FLOAT-SLICE-PER-MASK  - a feature mask costs its atlas format, not its
  source format.** A vendor terrain (Space Graphics Toolkit landscape) copies every registered mask into one
  RGBA16F Texture2DArray slice with a full mip chain, whatever the source format or channel count. Nine R8
  1024 x 512 masks costed at about 4 MiB each measured 48 MiB in Play (5.3 MiB per slice). Cost a masked feature
  at width x height x 8 bytes x 4/3 per mask, read the atlas allocation in Play before claiming a budget, and size
  or count masks from that number.

- **A Shader Graph material built in code is not configured until you validate it.** `new
  Material(graphShader)` copies property DEFAULTS and stops, so the keywords and cull mode the
  Material Inspector derives from the graph's surface options are never set — the material ends up
  internally contradictory and silent (`_DoubleSidedEnable = 1` with `_CullMode = 2` and no
  `_DOUBLESIDED_ON`), and double-sided foliage renders single-sided with no error. Call the
  pipeline's `ValidateMaterial(mat)` after configuring ANY code-built material. (A hand-written shader
  with `Cull Off` in the pass has no derived state and cannot drift this way.)

- **Code-created materials must be saved as assets BEFORE a prefab references them** — an inline
  `new Material(...)` loses its shader reference on prefab save and the renderer comes back pink on
  the next load, which reads as a shader/pipeline problem rather than a serialization one.

- **A fullscreen pass that fails to compile is a SILENT no-op.** `Shader.isSupported` still returns
  true, the draw runs and rasterizes nothing, and there are ZERO console errors. Read per-pass status
  with `ShaderUtil.GetShaderMessages(shader)`, never the console. (Concrete case: a pass that
  `#include`s URP `Blit.hlsl` must include URP `Core.hlsl` FIRST — `Common.hlsl` does not define the
  XR `TEXTURE2D_X` macros.)

- **HLSL-GEOMETRY-KEYWORDS-ARE-RESERVED  - never name an HLSL variable or parameter `point`, `line`,
  `triangle`, `lineadj` or `triangleadj`.** These are geometry-shader primitive keywords. The pass fails to
  compile (`syntax error: unexpected token 'line'`), and the only place it shows is
  `ShaderUtil.GetShaderMessages`. C# compile, a fresh Console and `Shader.isSupported` all report clean.
  Renaming one hit exposed the next (`line`, then `point`), so read the messages again after every fix.
  Measured on a procedural grid shader: `float line` and `float2 point` failed, and renaming them to
  `coverage` and `position` compiled and rendered.

- **SHADERGRAPH-TARGET-MUST-MATCH-THE-PIPELINE — an HD-only graph in a URP project draws NOTHING,
  under every draw path, in total silence.** A `.shadergraph` carries its target list inside the asset;
  one built against `HDTarget` / `HDLitSubTarget` compiles in a URP project with NO URP passes, so a
  material on it rasterizes nothing from a `MeshRenderer`, `RenderMeshInstanced`, or anything else.
  There is no console error and `Shader.isSupported` does not save you. The cheap tell is
  `material.passCount`: a real URP Lit material has ~9, a pipeline-mismatched graph has 1. Confirm by
  reading the asset — `grep -c Universal foo.shadergraph` returning 0 next to a `HighDefinition`
  target IS the diagnosis. Enabling GPU instancing on such a graph changes nothing, because the
  missing thing is the pass, not the variant. (one shipped project: `com.game.foundation`
  "Foundation Vertex Color Lit" + "Foundation Stylized Grass" are HD-only; 306 baked trees drew 0 in a
  URP project, empty console. The package's URP answer is a HOST-shipped shader the factory looks up
  by name first — ship that, do not edit the shared graph.) Prevention regardless of cause: an instanced
  draw path counts the instances it actually submitted and you read that counter BEFORE the screenshot.

- **SUBSTITUTION-TEST-MUST-CHANGE-ONE-THING — a substitution test that changes two things proves
  NEITHER.** Swapping a suspect material for a
  stock one and seeing geometry appear does NOT isolate the variable you had in mind: a stock URP Lit
  differs from a broken graph in shader AND pipeline target AND pass set at once. Change ONE property
  and keep the rest fixed — same graph with instancing toggled, or the same material under a
  `MeshRenderer` instead of an instanced draw — or the experiment only tells you "the replacement
  works". Corollary for silent draw paths: an instanced renderer must COUNT the instances it actually
  submitted and expose that counter, and you read the counter BEFORE the screenshot; absence of an
  error is not evidence of a draw. (one shipped project: a magenta URP-Lit swap was read as
  "instancing is missing" when the real cause was an HD-only target; the counter would have said 0
  drawn immediately and the wrong hypothesis cost a full diagnostic pass.)

- **ADDITIVE-NAME-IS-NOT-ADDITIVE-BLEND  - a converted or ripped HDRP Unlit material named
  "Additive" can carry `_BlendMode` 0 (Alpha).** With SrcBlend One, DstBlend OneMinusSrcAlpha and an
  atlas whose background alpha is 1, each glow quad draws as a black square with the glow in its
  middle. Measured on one ship prefab: 4 such materials, black squares across a 29 m light-projection
  quad and the turret glows. The name, the shader and the transparent surface type all look right in
  the Inspector. After any conversion, read `_BlendMode` (0 Alpha, 1 Additive, 2 Premultiply) on every
  transparent material and capture the asset against a light background before calling it done.

- **IMPORT-FIXES-RENDERING-FIRST (HARD, project policy) - converting a model pack's materials
  to the project's pipeline is step one of every import, and it covers the WHOLE pack, not the prefab
  in use.**  A bake that converted only the 4 materials its one
  prefab used left 15 of the pack's 19 on another pipeline's Lit shader (not installed, so the error
  shader): the model files, 11 spare prefabs and the demo scene all drew pink while the in-game view
  looked right. Ship one editor pass that takes a pack folder, converts every convertible material
  (built-in Standard, and another pipeline's Lit, which reports `Hidden/InternalErrorShader`) and lists
  what is left; the import is not started until it reports 0 unrenderable, and any bake that consumes
  a pack calls it on that pack's folder. A material embedded in a model is read-only, so extract it
  first. An unresolved material needs a source shader rebound before the upgrader runs: the remap is
  guarded by `HasProperty`, so otherwise it swaps the shader and carries no textures across.

- **FORWARD-PLUS-PASS-NEEDS-THE-CLUSTER-KEYWORD  - a hand-written URP forward pass without
  `#pragma multi_compile _ _CLUSTER_LIGHT_LOOP` is lit by ambient only under a Forward+ renderer.**
  Forward+ enables that keyword globally; a pass that does not declare it compiles a variant where
  `GetMainLight` returns zero colour, so the surface shows only SH/ambient with no error. Measured
  on a stylised grass shader: blades averaged RGB (51,59,29) against the ground they sat on at
  (134,136,58) - read as dark specks - and matched it once the keyword was declared. Any custom
  `UniversalForward` pass copies the renderer's full light keyword set, `_CLUSTER_LIGHT_LOOP` included.

- **EMISSIVE-MAPS-NEED-AN-EMISSIVE-COLOR - an emission map alone shows nothing.** Source: learned in shipped-project VFX work (emissive VFX materials); the rule applies to any emissive material setup, not only VFX. Status: locked (empirical).
  An emission map is multiplied by the emissive colour, which defaults to black, so binding the map alone renders no
  glow. Set a non-black emissive colour and intensity and enable emission: on URP Lit that is the `_EMISSION`
  keyword, a `shader_feature`, so make sure the variant is included in builds (ship at least one material with
  emission enabled, or list it in a shader variant collection). Animate glow, such as a pulse, by setting the
  intensity through a `MaterialPropertyBlock`, not by editing the shared material. Why: the map looks correctly
  assigned in the inspector, so the missing glow reads as a shader bug, and a variant present in the editor can be
  stripped from the player (SHADER-VARIANTS-STRIP-IN-THE-PLAYER). Check: in a player build, not only in the editor,
  the emissive areas glow at full intensity and go dark at zero.

- **SOFT-PARTICLES-NEED-THE-DEPTH-TEXTURE  - a soft-particle material draws nothing when the
  URP camera depth texture is off.** The fade samples `_CameraDepthTexture`; with Depth Texture off
  (common on mobile renderer assets) the sample reads the far/cleared value and the fade drives
  alpha to zero, so stock effect packs with `_SOFTPARTICLES_ON` render blank with no error while the
  Scene view (which forces depth) looks right. Either strip the soft-particle keyword from every
  effect material the tier ships (a build step, validated), or turn the depth texture on for that
  tier and pay its cost - never ship soft particles against a renderer without depth.

## HDRP: the Editor renders it, the preview or the build does not

- **HDRP-PREVIEW-CAMERA-IS-SANITIZED — a `CameraType.Preview` camera cannot show exposure,
  post-processing or your sky, and no Volume can reach it.** `FrameSettings.Sanitize` hard-disables
  ExposureControl and Postprocess for `HDUtils.IsRegularPreviewCamera`, swaps in the default preview
  sky and clears to the EDITOR-PREFERENCE colour — an eight-stop exposure sweep renders byte-identical
  frames, and days go into proving a Volume that was never the problem. Render authoring previews in
  `EditorSceneManager.NewPreviewScene` through an ordinary `CameraType.Game` camera (`enabled =
  false`, `camera.scene = previewScene`, rendered by hand); a preview-scene Volume cannot leak into
  the Game view. Three follow-ups once a real rig works: `HDAdditionalLightData.lightUnit` defaults
  to Candela while a directional light is authored in Lux (`intensity = 10000` is silently
  reinterpreted, and `Type.GetType("…LightUnit, …HighDefinition.Runtime")` returns null, so resolve
  the enum from the property's own type); HDRP carries exposure state ACROSS frames, so the first
  render after a change shows the previous settings — render twice; and the project's Default Volume
  Profile grades every preview unless the rig pins Tonemapping / Bloom off. (DIGIBON 2026-07,
  `TreePreviewStage`.)

- **RENDER-REQUEST-CAMERA-NEEDS-PERSISTENT-HISTORY - a disabled Game camera rendered by
  `RenderPipeline.SubmitRenderRequest` starts HDRP auto-exposure over on every request.** In daylight
  every capture sat at the same blown value (mean luma about 145/255 across a 44-frame walk, rendered
  every 4th frame). With `HDAdditionalCameraData.hasPersistentHistory = true` the same camera started dark
  (22) and adapted over the walk (46 by frame 24). Set it when the rig is built, and render every frame for
  a second or more before saving. Unlike HDRP-PREVIEW-CAMERA-IS-SANITIZED, exposure runs here; only its
  history is dropped between requests.

- **PREVIEW-SCENE-DOES-NOT-ISOLATE-TERRAIN — `camera.scene` plus `cullingMask = 0` do not stop a
  loaded `Terrain` drawing into an off-screen rig.** Terrain is not an ordinary renderer. Build the
  rig where no world content exists (10 km up) instead of trusting isolation; that took a black blob
  from 12,090 stray pixels to 0 with the subject unchanged. Diagnostic worth keeping: a stray region
  covering the same NORMALISED rectangle at 128 / 256 / 512 px is being rendered or resampled —
  stale GPU memory sits at fixed PIXEL offsets — so scale-invariance rules out the RTHandle theory
  before you clear targets or swap render requests for nothing.

- **CARD-ATLAS-CELLS-NEED-DILATION — a baked card atlas must match the UV grid the mesh samples:
  one distinct variant per cell, alpha-dilated INSIDE each cell, cards kept as value masks.** A mesh
  mapping foliage onto a 4x2 grid against a baker that emitted one full-frame card renders grey
  plates with no error — every quad samples a quarter-crop of empty space, and mesh and texture
  simply disagree. Transparent texels leave a rasterizer black, and mips then average leaf colour
  against black (measured saturation 0.50 → 0.22); a value mask keeps the species tint from being
  applied twice. (DIGIBON 2026-07, `TreeMeshGenerator`; present in a project too.)

- **EG-LODFADE-AND-BRG-CULLINGMASK — a `[MaterialProperty("unity_LODFade")]` override plus
  `LOD_FADE_CROSSFADE` renders INVISIBLE geometry at full triangle cost** (Entities Graphics never
  packs the fade, so every fragment clips); and a hand-rolled `BatchRendererGroup` needs
  `BatchFilterSettings.sceneCullingMask = DefaultSceneCullingMask` — the default 0 is "member of no
  editor view" and the editor filters every draw. (a survival game)
- **HDRP-ATMOSPHERICS-READ-AS-BUGS — four HDRP defaults look exactly like camera or content bugs.**
  Fog `maxFogDistance` at its 5,000 m default renders altitude as a flat wall; aerial perspective
  washes any high perspective shot to blue mush (clean to 2,500 m, washed at 7,000 m — frame big
  things with an ORTHOGRAPHIC camera); emission is a MULTIPLY with a black default `_EmissiveColor`,
  so a bound emissive map emits nothing until the authored hue is normalised and multiplied by nits;
  water renders nothing without an enabled `WaterRendering` volume override (persist it as a
  sub-asset — `VolumeProfile.Add<T>` alone never reaches the `.asset`) and caps at 16 surfaces,
  the 17th silently `surfaceIndex = -1`. (a survival game)

## GENERATED-SHADERGRAPH-NEEDS-A-SHIPPED-TEMPLATE - a bare pipeline Target does not compile

Authoring a Shader Graph programmatically (reflection over `GraphData`, `MultiJson.Serialize`) from a
freshly constructed `new HDTarget` produces a graph that reports `isSupported = true` and generates a
shader - whose ShadowCaster pass then fails with `undeclared identifier 'UnpackVaryingsMeshToFragInputs'`.
The target's own subshader templates are not fully initialised by construction alone, and nothing in the
graph API says so.

Do not debug the nodes. Build an EMPTY graph on the same bare target as a control: it fails identically,
which isolates the fault to target construction in one step. The fix is to stop constructing the target -
`MultiJson.Deserialize` the pipeline's own shipped graph (HDRP's `HDRP Lit.shadergraph`) and inject nodes
into that. Same nodes, zero shader messages.

Reflection-authoring shapes that cost iterations on the way, all silent rather than throwing usefully:

- Nodes need `UpdateNodeAfterDeserialization` BEFORE `AddNode`, or they have no slots and `Connect`
  throws a bare NRE from inside the graph.
- `GraphData` has no `nodes` property - the accessor is `GetNodes<T>`. `GetProperty("nodes")` returns
  null and the NRE points at your code, not at the wrong API.
- Internal node types need `Activator.CreateInstance(t, true)`; the public overload throws
  `MissingMethodException` on the parameterless-but-non-public constructor.
- Match `BlockFieldDescriptor`s by REFERENCE against the statics (`BlockFields.SurfaceDescription.BaseColor`),
  never by `ToString` - the descriptor's `ToString` returns its type name, so every comparison "matches"
  and the block silently wires to the wrong slot.

## Shader variants: the Editor never strips, the player always does

- **SHADER-VARIANT-PREP-RUNS-BEFORE-STRIPPING (HARD) - a scriptable stripper cannot save you from a
  variant-space explosion; the build dies ENUMERATING keywords before `OnProcessShader` is ever
  called.** `ShaderCompilation::PrepareStageVariantsForSinglePlatform` builds the full local-keyword
  set for a pass first, so an `IPreprocessShaders` callback receives an already-enumerated list: it
  can shrink what SHIPS, never what is enumerated. Measured (a space sandbox game): a Windows client
  build hard-crashed - native, no managed exception, so the `finally` restoring the build profile
  never ran - in `core::base_hash_set<keywords::LocalKeywordState>::resize` at `5700M / ~40768M
  prepared`, 212 s into ONE pass with a **40.7 billion** variant space. Proof the stripper is
  irrelevant: the VERTEX half of that identical pass logged `After scriptable stripping: 0` in
  0.06 s - already reduced to nothing - and the build still died on the fragment half. So a stripper
  is an audit tool here, never the mitigation; the fix has to cut the space at the source.
  - **The usual source is a vendor multi-pipeline "megashader" plus a render-pipeline package nobody
    removed.** A third-party shader that ships BIRP + URP + HDRP SubShaders in one file keeps a
    foreign pipeline's pass ALIVE for as long as that pipeline's PACKAGE is installed, because the
    keywords resolve and the SubShader stays viable. Concrete: SGT's `SGT_Landscape.shader` (57k
    lines) declares 2 `"RenderPipeline"="HDRenderPipeline"` SubShaders and 1 `"UniversalPipeline"`;
    the exploding pass was the **URP** `ForwardLit`, live only because
    `com.unity.render-pipelines.universal` still sat in `Packages/manifest.json` beside HDRP,
    left over from the `com.unity.template.urp-blank` project template. Its ~24 URP `multi_compile`
    sets (`_MAIN_LIGHT_SHADOWS_CASCADE`, `_ADDITIONAL_LIGHTS`, `_SHADOWS_SOFT_*`, `_DBUFFER_MRT*`,
    `_CLUSTER_LIGHT_LOOP`, `EVALUATE_SH_*`, ...) multiply to ~10^8 before the vendor's own
    `shader_feature_local` pairs. **An unused render-pipeline package is not inert** - auditing for
    one is a single grep of the manifest, though REMOVING it is a stack call and the developer's
    (the new project page).
  - **The variant numbers are already in the build log - offline, no editor, no rebuild.** Each
    `Compiling shader "<name>"` block carries a per-pass table: `Full variant space` / `After
    settings filtering` / `After built-in stripping` / `After scriptable stripping`. Grep
    **`Compiling shader`** (present tense, build-time). `Compiled shader` is the *runtime-load*
    message and returns 0 in a build log - never read that zero as "variant logging is off" and go
    re-run the build to collect numbers you already have.

- **SHADER-VARIANTS-STRIP-IN-THE-PLAYER — a shader or variant the build cannot PROVE is used is
  stripped, silently, and only in the player.** Three faces of one mechanism: (1) a shader with no
  material ASSET in a built scene/prefab — resolved by `Shader.Find` at runtime, or drawn only via
  `DrawMeshInstanced` / a BRG with no scene renderer — returns null in the build and the feature
  just does not render: pin it in `GraphicsSettings.m_AlwaysIncludedShaders` (`.shadergraph`
  counts; an `AssetPostprocessor` over your shader folder + `SaveAssetIfDirty` auto-pins). (2)
  Always Included keeps the ASSET, not every keyword combo: `shader_feature(_local)` variants are
  kept per MATERIAL usage, so a keyword chosen only at runtime with no pre-authored material
  (`EnableKeyword` on a code-built material) has no variant to resurrect — those keywords are
  `multi_compile_local`. The editor lies here: `EnableKeyword` on a runtime clone works in-editor
  because nothing was stripped. (3) Pipeline preprocessors strip per QUALITY ASSET: HDRP drops
  `TerrainLit`'s `_ALPHATEST_ON` unless `supportTerrainHole` is on EVERY HDRP quality asset (default
  false), so holes render as solid ground in Low and work in High. (4) `GraphicsSettings
  m_InstancingStripping` is an enum where **1 is Strip ALL**, not Strip Unused (0 = Strip Unused,
  2 = Keep All), and a Build Profile can override it: at 1 every `INSTANCING_ON` variant leaves the
  player, so every `RenderMeshInstanced` draw vanishes while non-instanced materials on the same shader
  still draw, with no log line (measured: HDRP/Lit shipped 0 of 144 GBuffer variants with instancing;
  procedural ground invisible, its colliders and non-instanced sign letters present). Fixing the
  committed value is not enough when a build script re-applies "stripping defaults" before every build:
  grep the build tooling for the property name, or the next build writes 1 back. Fail the build on it
  with an `IPreprocessBuildWithReport` guard (it caught exactly that writer on the first rebuild). After adding any feature that depends on a variant,
  ask what strips it at build time — every one of these traps looks identical in the Editor.
  (a project `shader-stripping.md`, `world-authoring.md:111`; DIGIBON render probe.)
- **PLAYER-PATH-MUST-ARM-SHADER-GLOBALS — a global-driven look (`_ColorMap*`) needs its host resolved
  on the PLAYER path too, and a failed re-bake re-arms the last good globals.** An editor-only
  `GameObject.Find` of the colormap host left every player session allocating an empty renderer,
  and a failed bake left `_ColorMapParams.x = 0` — grass on pure `_BaseColor` lime, "not matching
  the ground", editor fine. Halves: EDITOR-GUARD-FORKS-SHARED-DATA (the [C# style](/workflows/csharp-style/) page) and
  VENDOR-RENDERFEATURE-GLOBALS-DEAD above. (a project `docs/pitfalls/detail-spawning-grass-trees-rocks.md:44`.)
- **URP-DAY-NIGHT-GLOBALS — five URP lighting facts a day/night cycle trips.** The main directional
  light must NEVER point below the horizon (a full-sweep sun direction lights undersides at night);
  main-light shadow bias comes from the PIPELINE asset, not the light, and zero bias on single-sided
  terrain reads as light through the ground in daylight; darken night via `ambientIntensity`,
  never by switching `ambientMode` (a lighting-keyword flip recompiles every lit variant on first
  render); set `fog` + `fogMode` ONCE and write only `fogColor` / `fogDensity` per frame (same
  recompile class); and with `RenderSettings.sun` unset URP promotes the BRIGHTEST directional —
  a portrait key light — to main light, so key lights are range-limited spots. (a project `docs/pitfalls/sky.md:14-26`,
  `pawn-portraits.md:17`.)
- **URP-CUSTOM-PASS-FACTS — what a custom URP pass / feature actually needs.** A silhouette comes
  from re-rendering the object's OWN shader, not a Render Objects override;
  `RenderingUtils.CreateRendererListWithRenderStateBlock` is internal; `RenderFilterSettings` is a
  SHARED component (changing `.Layer` moves the chunk); an enableable tag must be PRESENT before
  `SetComponentEnabled`; adding a `ScriptableRendererFeature` to a renderer asset has no public API
  (editor code); a custom URP shader needs the full SubShader tag set and pass set
  (`UniversalForward`, `ShadowCaster`, `DepthOnly`, `DepthNormals`); depth-sampling effects need
  `intermediateTextureMode = Always`; and `URP_COMPATIBILITY_MODE` blocks RenderGraph-only vendor
  water. (a project `docs/pitfalls/highlight-outline.md`, `splat-shader.md:21`, `water-sw3.md:6,18`.)

## First play compiles a VFX Graph, and no preload list covers it

- **VFX-GRAPH-WARMS-BY-BEING-PLAYED (HARD) - the first `enabled = true` on a graph compiles its shaders
  and builds its GPU buffers on that frame, and neither preload surface in Graphics Settings can warm
  it.** Measured: a beam effect's first play froze the frame **1437 ms** (2.9 s across its
  first three frames) and its second play cost 45 ms; after a load-time warm of the same asset, the
  first play's worst frame was **29.7 ms**. Why the settings do not help: `m_PreloadedShaders` holds
  `ShaderVariantCollection`s, which record variants seen through MATERIALS - a graph's generated
  shaders are not in one - and both that list and `m_GraphicsStateCollection` are **player-only**, so
  the Editor ignores them and the hitch you profile in Play is the hitch the build ships.
  - **The warm is a MonoBehaviour that plays the list, not a settings entry.** A serialized
    `VisualEffectAsset[]` on an asset, ONE serialized `VisualEffect` player to run them through
    (assign `visualEffectAsset`, `Play`, hold 1-2 frames, `Stop`, next), on the rig that comes up
    inside the loading gate. One player, not one per effect: a warm that adds components at runtime is
    the banned-API defect in a different coat.
  - **An effect nothing RENDERS never compiles its shaders.** Park the player a few tens of
    centimetres in front of the camera at ~0.0005 scale so the warm frames cover well under a pixel;
    a graph reading the main camera logs "requires the main camera but there are no active cameras
    with the MainCamera tag" and warms nothing when none is tagged, so warm when a camera is up.
  - **Report the warm in FRAMES, not milliseconds.** Its wall clock spans loading frames, not its own
    cost: the same six effects measured 21.8 s in one session and 1.0 s in another. A bare elapsed
    number reads as a cost the warm never had.

## Planetary-scale cameras: the projection matrix rounds to an infinite one

- **FAR-OVER-NEAR-PAST-2^24-KILLS-FAR-PLANE-UNPROJECTION (HARD) - once far/near exceeds about 1.6e7, the
  projection's `m22 = -(f+n)/(f-n)` is exactly `-1` in float32, which is the infinite-far-plane matrix, and
  any shader that rebuilds a ray or a position by unprojecting the FAR plane (`float4(ndc.xy, 1, 1)` through
  the inverse projection, or a depth-buffer value at the far clip through `_ViewFromScreen`) divides by
  `w = 0`.** Measured on a 70,000 km far / 0.03 m near gameplay camera: a vendor volumetric-cloud raymarch
  wrote alpha 0 on every pixel (rays NaN), its height fog wrote NaN on every sky pixel, and the NaNs reached
  HDRP's exposure histogram so whole frames went black at some view angles; the Scene view, with a normal far
  plane, looked perfect, which is the tell. Textures, coverage, sphere-intersection precision were all red
  herrings; the G channel of the raymarch output reading exactly the `max(distance, 1)` floor was the clue
  that the ray length was zero. Fix in the shader, never by shrinking the far plane: unproject the NEAR plane
  (`z = UNITY_NEAR_CLIP_VALUE`, or `-1` for `camera.projectionMatrix.inverse`, which is always GL-style),
  which lies on the same eye ray and always has finite `w`, then scale by `LinearEyeDepth` for positions and
  cap sky pixels at a finite atmosphere distance. `LinearEyeDepth(0)` stays finite (it returns far).
  Audit every vendor screen-space shader for `/ .w` after an inverse projection before trusting it on a
  planetary camera; a `pow(0, 0)` from zeroed globals (a module you did not enable) is NaN too and `lerp`
  keeps it at weight 0, so guard such terms with a branch rather than a weight.

## Copying an HDRP diffusion profile: the hash follows the GUID, and only the Editor can re-derive it

- **DIFFUSION-PROFILE-HASH-FOLLOWS-THE-GUID (HARD) - an HDRP diffusion profile identifies itself to
  materials by a uint hash derived from its asset GUID, which every material stores as that hash's bits
  reinterpreted as a float in `_DiffusionProfileHash`. Duplicate the profile asset - to own a copy of a
  vendor one, to split a shared profile, to move it between projects - and the copy has a new GUID, so
  the Editor re-derives a different hash and rewrites the referencing materials to match, in memory. The
  copy itself is not marked dirty, so the re-derived hash never reaches disk; a player build has no
  AssetDatabase to re-derive with, loads the stale serialized hash, matches no material, and renders
  every surface with subsurface scattering or translucency as if it had no profile.** Mark the copied
  profile dirty and save it, then verify by reading the asset's `hash:` back from the YAML and comparing
  it to the material's `_DiffusionProfileHash` bits - equal bits or the build is wrong. Two traps
  alongside it: `DiffusionProfileSettings.profile` is internal in current HDRP, so a script that needs
  the hash reads the `hash:` line out of the YAML and reinterprets the bits (`BitConverter`), and
  matching a copy to a material BY NAME picks the wrong profile whenever a pack ships near-identical
  names - match on the hash. The profile also has to appear in the Diffusion Profile List of the default
  settings volume profile; a material's own reference list keeps it in the build but does not register
  it for rendering.


## A material read-back cannot prove a texture is unbound

- **MATERIAL-READBACK-CANNOT-DISPROVE-A-BINDING (HARD) - `Material.GetTexture(name)` returns null for a
  texture property the shader declares only in HLSL, no matter what `SetTexture` bound to it, because the
  material's serialized property sheet holds only what the `Properties` block declares. `HasProperty`
  still answers true for the HLSL-only name, so the pair reads exactly like a binding that failed.** Float
  and vector properties set alongside it DO read back, which makes the texture's null look like the one
  thing that did not take. Measured on a vendor terrain shader: `ShaderUtil.GetPropertyCount` returned 2
  while `HasProperty` was true for six atlas names, and every `GetTexture` on them returned null in the
  same frame the owning code's `SetTexture` ran - the atlases existed, were created, and were drawing.
  Diagnose a suspected binding failure at the SOURCE (does the texture object exist, is it `IsCreated`,
  did the writer run) or by a rendering A/B, never by reading the material back. A vector property that
  survives a texture release is a stale VALUE, not evidence the texture was bound: `ReleaseAtlas`-style
  code destroys the texture and leaves the size vector behind, so size-set + texture-null is the expected
  reading in both the healthy and the broken case and distinguishes nothing. Same family as
  DRIVEN-VALUE-IS-NOT-THE-SOURCE-OF-TRUTH in the scene flow page, one step worse: here even the
  driven object's own read-back is structurally incapable of returning the positive.


## A fog pass after the transparents fogs a flame by what stands behind it

- **WEATHER-COMPOSITES-BEFORE-NEAR-TRANSPARENTS (HARD) - a full-screen fog or volumetric-cloud custom
  pass injected after the transparents (HDRP `BeforePostProcess`) composites by the opaque depth
  pyramid. Anything transparent that writes no depth (particles, VFX flames, glass, holograms) then
  receives the fog and clouds of whatever lies behind it. Over the sky it goes see-through, and over
  near geometry it shows the background's silhouettes inside it.** Inject the weather pass at
  `BeforeTransparent`, so every near transparent draws over the finished weather. A far transparent
  shell that must sit UNDER the weather (a sky dome, an atmosphere or ocean shell at queue 3000) goes
  on its own layer. Remove that layer from the camera's culling mask, and draw it with a
  `DrawRenderersCustomPass` (AllTransparent, no material or depth override) at the same injection
  point with a higher priority than the weather pass. Higher priority runs first inside one
  injection point. A DrawRenderers pass adds its layer mask to the culling, and it picks up
  `Graphics.DrawMesh*` procedural draws on that layer too. Verify the pass draws the shell: disable
  it and the shell's region must change (a sea turned grey). Measured on HDRP 6000.5: the flame
  washed out over the sky before, solid after. Sky, ocean and island mean luminance stayed within
  0.2 of 255 (213.8/168.7/177.7 against 213.8/168.8/177.9). The A/B needs no code: flip the layer,
  the mask and the two priorities at runtime and capture both orders at the same spot and clock.
- **AFTER-POST-MARKER-NEEDS-ITS-OWN-DAYLIGHT  - a point, marker or disc stamped after
  post-processing (HDRP `AfterPostProcess`) is in display space: exposure and tonemapping never dim it,
  so a fixed display luminance shows at noon exactly as at midnight.** Anything drawn there that stands
  for a real light source (a far planet, a distant beacon, a star) must carry its own visibility,
  computed from the physical quantity the eye would compare: its apparent magnitude against the sky's
  limit toward it, or its luminance against the exposed sky. Give it as opacity, not as a darker
  colour: a dimmed opaque marker draws a black dot on a bright sky. Keep the "is a marker" flag apart
  from the opacity, or floor the opacity above zero when one channel carries both. A naked-eye limit
  model has TWO thresholds: Garstang's dark-adapted (rod) branch alone puts the day limit near -16
  and hides even Venus; take the better of the rod and cone branches, which gives about -4 on a
  5000 cd/m2 day sky. Found: a planet held at two pixels at display luminance 0.88 sat beside the
  sun at noon; faded by the two-branch limit it draws at about 1% by day and fully at night.

## FRESH-MATERIAL-RENDERS-BLANK-UNDER-ASYNC-SHADERS

An editor script that creates a material and renders it the same pass (item icons, thumbnails,
preview sheets) gets the async-compile placeholder, not the shader. HDRP draws that placeholder as
nothing, so an alpha or coverage gate reads 0 opaque pixels and blames the model. Found: five new
icons all failed at 0 opaque; the same render with async shader compilation off drew all five.

Turn `EditorSettings.asyncShaderCompilation` off around the render and restore the old value in a
`finally`. The setting is serialized into `ProjectSettings/EditorSettings.asset`, so a forgotten
restore ships as a project change.

- **HDRP-WATER-IS-LIT-BY-THE-SKY  - two gates, one fix does not get the other.** HDRP water has
  no light of its own: body colour and foam are multiplied by the sky ambient probe, specular comes from the
  sky render. A project that draws its sky as geometry and clears the camera to black starves both. The
  ambient probe needs only a valid sky on the camera's volume stack with ambient mode Dynamic (an authored
  lighting-only physically based sky fixed it: the probe went from the static fallback to a live value
  about 4x brighter). Specular needs `SkyManager.RenderSky`, which returns early unless the camera clear
  mode is Sky. Measure each half separately.
- **LIGHTING-SKY-SEES-CULLED-LIGHTS .** A physically based sky scatters every directional light
  with `interactsWithSky` on, whatever its layer or the camera culling mask. A layer-culled 2500 lux fill
  light still lit every surface through ambient and reflection, so exposure swung with camera pitch. When
  a surface brightens with view angle, list the lights that reach the lighting sky before touching
  exposure; turn `interactsWithSky` off on fill lights.
- **VENDOR-VFX-DURATION-IS-A-LIFETIME .** A one-shot VFX Graph reads its `Duration` as a lifetime:
  raising it to 600 s to "hold" a beam kept the width curve (0 at age 0) near zero for a minute and let
  particles outlive `Stop` by minutes. Keep the authored duration, re-`Play` at 0.85 x Duration while
  held, and set `VisualEffect.enabled = false` to clear on release. Read a vendor graph's exposed curves and
  lifetimes before driving it from code; third-person effects need about 0.05 scale in first person.
- **HDRP-PROBE-ATLAS-TILE-IS-A-MULTIPLE .** HDRP stores a cube reflection probe in its atlas at 4x
  its resolution below 512 and 2x from 512 up, so the atlas fills long before the probe sizes suggest: 14
  probes up to 1024 px plus one 70 m exterior box logged 358 "No more space in Reflection Probe Atlas" errors
  per Play. The fix held at most 10 probes, 256 px, realtime On Enable, atlas 8192x4096 (0 errors over three
  Plays); gate the budget with a validator. Realtime probes never render in an Edit-mode capture, so judge a
  probe-lit interior in Play. HDRP has no box offset: an offset probe captures from its own position, so move
  the probe to its box centre.
- **CROSSED-CARD-IMPOSTOR-NEEDS-TWO-SIDES .** A single-sided crossed-card impostor under a shader with
  `_CullMode: 2` (back) culls each card from behind, so scattered stones lost geometry as the camera orbited.
  Use closed meshes or a two-sided material, and prove it with a full orbit in Play (323 degrees, fully drawn).
- **INSTANCED-DRAW-HOLDS-511 .** `Graphics.RenderMeshInstanced` draws at most 511 matrices per call by
  default; 1023 needs a shader verified with `#pragma instancing_options assumeuniformscaling`. Batch by 511
  unless the shader is checked.
- **GLTF-ORM-IS-NOT-AN-HDRP-MASKMAP .** A glTF metallicRoughness texture bound as HDRP `_MaskMap` turned an
  olive albedo into a black blob: glTF packs R occlusion, G roughness, B metallic; HDRP reads R metallic, G
  occlusion, B detail mask, A smoothness. Repack the channels (smoothness = 1 - roughness) or ship albedo plus
  a low metallic/smoothness pair.
- **DECAL-FACES-INTO-THE-HIT .** HDRP decal projectors project along their +Z: impact decals whose +Z
  pointed out of the contact missed or floated off the mesh. Point +Z into the surface, and parent each mark
  to what it sits on (the ground root, so it survives an origin rebase; the victim's view for body marks)
  instead of leaving it in world space. Verify with live shots.

## SINGLE-CHANNEL-TEXTURE-SAMPLES-RED

- **A particle shader that samples RGB draws a single-channel texture (BC4 / R8) as a solid red quad.** Measured:
  five element-coloured area-effect materials on the URP unlit particle shader rendered as identical red and orange
  squares, so every element looked the same. Use a shader with a single-channel mode (a keyword that reads R as
  alpha or luminance), or re-import the texture as RGBA.
- **`Material.CopyPropertiesFromMaterial` copies properties, not keywords.** When you rebind to a shader by copying
  a working material, copy its keywords too, and judge the render, not the inspector.
- **A tint only works on a tintable material.** Some effect-pack materials bake colour into the texture, so start
  colour or vertex colour changes nothing. Check every element variant under its own tint before shipping a
  per-element palette; rebind the ones that must follow the tint to a single-channel material.

## LAYERED-HIT-CORE-IS-ADDITIVE

- **Stacked alpha-blended HDR white saturates to an opaque fill.** Measured: a crit burst with 8 of 12 emitters on
  alpha-blended HDR materials (colour multiplier 4-6) read as a flat opaque white disc, and its soft glow edge turned
  hard on the tier that strips soft particles. A layered core (flare, glow, ring, sparks) uses additive materials;
  keep alpha-blended HDR for a single element over the scene.
- **A stretched streak texture needs Stretched Billboard render mode and velocity.** On zero-velocity billboard
  particles it shows hard quad edges; drop the layer rather than tune it.
- Judge a hit at its peak frame (about 0.07 s into a 0.12-0.22 s core) in a Play capture, not in an editor thumbnail.

## FLASH-AND-LIGHT-ADD-UP

- A flash particle drawn on top of a live point light doubles the brightness of the burst. Measured: a flash sized
  0.35 x light range at alpha 0.85 nearly blew out beside the driven light; 0.25 x range at alpha 0.65 read clean.
  Size the flash from the light's range and sample the two together in Play before accepting. On a tier that strips
  lights, the flash alone is the replacement.

## FOG-BELONGS-TO-THE-ACTIVE-SCENE

See also: the scene flow page - the app & scene flow hub (bootstrap -> load -> transitions/additive -> runtime lifecycle -> teardown).

- **`RenderSettings` (fog, ambient, skybox) are per scene, and only the ACTIVE scene's values render.** Fog set from
  `OnEnable` in a scene that loads additively, or before it becomes active, never shows. Measured: fog written in
  `OnEnable` while the bootstrap scene was still active did nothing in Play.
- Apply on `SceneManager.activeSceneChanged` when your scene becomes active; clear on disable only while your scene
  is still the active one; keep the authored values on an asset (a Volume component or settings asset), not saved
  into the scene's lighting block. Strip the fog modes you do not use in Graphics settings.

## DRAW-DISTANCE-FROM-THE-GROUND-FOCUS

- **A top-down or orbit camera zoomed out puts the eye far from what it looks at, so eye-distance culling empties
  the view.** Measured: at max zoom the eye stood 62 m from the arena centre, and the low tier's 40-55 m draw
  distance culled the whole tree ring the player was looking at.
- Measure the draw-distance origin from the point where the pass camera's view ray meets the ground plane, clamped
  along the ray to the authored draw distance; keep LOD and screen-size tests eye-based. Take the pose from the
  camera being rendered (game, capture and Scene view alike), not from a camera manager, so every pass agrees.

## PRIMITIVE-SHAREDMATERIAL-IS-THE-BUILTIN

- `sharedMaterial.color` on a `GameObject.CreatePrimitive` marker edits the built-in `Default-Material` for the whole
  editor session, so every primitive turns that colour. Tint markers and debug primitives with a
  `MaterialPropertyBlock`; if it already happened, set the colour back to white.

## Procedural generators: new knobs, vertex masks, facet edges

- **GENERATOR-KNOB-KEEPS-OLD-OUTPUT.** A new knob on a seeded generator must not shift existing assets. Roll new
  randomness only when the knob is off its default, reuse existing rolls where the feature can follow them, and add
  no float op to the default path. Prove it with before/after hashes of the generated meshes (vertices, normals,
  colours, UVs, indices) over seeds x LOD caps x shading modes. Measured: 310 of 310 hashes identical after seven
  generator files changed.
- **VERTEX-ALPHA-MASK-SURVIVES-THE-SHADE-PASS.** A colour-shading pass that rebuilds `new Color(r, g, b, 1f)` silently
  erases a mask stored in vertex alpha (wind weight, wetness, wear). Carry the incoming alpha through, and first
  audit every tint source for alpha other than 1 so you know the change is output-neutral.
- **FAN-FACET-HAS-NO-EDGE-SIGNAL.** Edge-wear or curvature paint that compares vertex direction with the face normal
  marks every vertex of a fan-triangulated facet, because the facet has no centre vertex. Take edge identity from
  the bevel-strip geometry the generator already emits.

## PROCEDURAL-PARTICLE-INSTANCING-SKIPS-ON-VULKAN

- A particle shader with procedural instancing (URP Particles/Unlit) on a Mesh-mode emitter with GPU instancing on
  (here with custom vertex streams) logged on an Android Vulkan device: "Shader requires a compute buffer
  unity_ParticleInstanceData, but none provided. Skipping draw calls". The effect does not draw, and the Editor shows
  nothing wrong. Seen once on one build; device proof of the fix is still pending.
- Turn GPU instancing off on single-burst mesh emitters (there is nothing to batch), or keep it only where the
  instanced path is proven on the device. Find candidates statically: renderers with `m_RenderMode: 4` and
  `m_EnableGPUInstancing: 1` whose material shader declares `instancing_options procedural`.
- Gate: grep the device log (logcat) for `unity_ParticleInstanceData` after triggering each suspect effect.

## JUDGE-EFFECTS-AT-GAMEPLAY-DISTANCE

- A chained beam effect looked right zoomed in but read as one white blob at the gameplay camera. Three things caused
  it: short hops inside a cluster, an additive glow wider than the core, and a full impact prefab (flash plus ground
  circle) replayed at every hop.
- Size line widths from the gameplay camera's pixels per metre, taper short links, and cap fork length at a fraction
  of the link length. Let a link's arrival stand in for per-hop impacts instead of stacking a full impact per hop.
- Review every effect at the gameplay camera distance. A close-up capture is supporting evidence only.

## HIT-FLASH-CAPS-TINT-PLUS-EMISSION

- A shared hit flash that combined a strong tint (0.55 toward near-white) with emission (1.2) turned struck units into
  a pure white silhouette once bloom ran. That hid the unit and read as an effect blob.
- Cap tint plus emission together, not each one alone, so the result stays below the bloom threshold's white-out.
- Because the flash is shared, prove a change with paired captures of the other weapons at the same moment in the run.

## AB-THE-SUSPECT-ON-ONE-PAUSED-FRAME

- When a visual defect (white wash, halo, flicker) could come from several layers, pause Play and toggle only the
  suspect renderers at runtime, then capture the same frame with each one on and off. Captures from different frames or
  runs differ in too many ways to assign the cause.
- Keep the toggle runtime-only (a probe call, never an asset edit). Measured: the first suspect, a smoke layer, was
  cleared this way, and the fix went to the real cause (see HIT-FLASH-CAPS-TINT-PLUS-EMISSION).
- Refined: toggle the suspected layer before rebuilding anything for a visual defect. A "jagged shoreline" was
  about to get a finer water mesh. Building that mesh in memory in Play (never saved) and swapping it in changed
  nothing; the jaggedness was a pattern in the terrain texture. The toggle names the layer; the rebuild goes there.

## PIXEL-PROOF-WARMS-UP-THE-SHADERS

- The first off-screen render after a recompile or domain reload can draw with the Editor's asynchronous shader
  compilation placeholders, so a pixel comparison of that render reports a false difference (or a false match).
- Give every node or preset one warm-up render that is discarded, then compare render 2 against render 3 for
  stability before comparing across nodes. Run the whole set twice; both passes must agree.
- Refined: one warm-up is not enough after a shader reimport, because a variant can still land late. Render
  three times per node and keep the third. Compare PNG deltas (max delta and % of pixels changed), not raw hashes,
  and measure the noise floor first with a second before-run. Measured: noise floor 0, change delta 0.
- Refined: freeze time-driven animation first. With wind driven by `_Time`, two renders of the same unchanged
  scene seconds apart differed on 19.4 % of pixels (max 249/255), as large as the "failure" being gated, so the gate
  measured motion. For pixel proofs, set the animation to rest on in-memory copies (for example wind strength 0) or pin
  the shader time, and take the same-state noise floor under those conditions. Where freezing is impossible, prove at
  the source instead: compare material and asset properties exactly, not pixels.

## IDENTITY-PRESERVING-LIGHTING-REBUILD

- To change how a lit shader takes light without moving today's pixels, copy the render pipeline's own PBR body for the
  pinned pipeline version into the shader and change only the light input (for example shadow strength or a per-tier
  skip). Leave GGX, spherical harmonics and additional lights untouched.
- Add every new term as a guarded blend or additive term that is exactly identity at its default (x1, +0, a lerp by 0
  behind `if (k > 0)`). Measured: 0 pixel delta on 6 preview nodes at defaults.
- Even identity math can change codegen: a x1.0h or +0 term moved some foliage pixels by 1/255 on under 1 % of pixels.
  Accept a stated bound, or put the new terms behind a keyword when the proof must be exactly 0.
- Re-copy the body when the pipeline version changes; the copy is pinned to one version.

## FOLIAGE-EXTRA-LIGHT-TAKES-SHADOW-ATTENUATION

- Foliage that opts out of shadows at both ends (materials set to not receive, and tier profiles that never let it
  cast) and whose extra leaf terms read the main light without shadow attenuation lights itself as if it stood in
  full sun. Symptom: flat, glowing canopies, and a tree whose only shadow is its trunk.
- Receive shadows per quality tier, and multiply every added light term (translucency, rim, wrap) by the main light's
  shadow attenuation.

## DOUBLE-SIDED-CASTER-FLIPS-THE-NORMAL-TO-THE-LIGHT

- A double-sided card (`Cull Off`) in the shadow-caster pass applies the pipeline's normal bias along the unflipped
  vertex normal, so back-facing cards bias toward the light and their shadows detach or acne.
- Flip the normal to face the light before calling the pipeline's shadow bias function.

## FACETED-FOLIAGE-USES-A-CANOPY-NORMAL

- Low-poly flat-shaded foliage bands per facet under stock Lambert lighting.
- Without touching the mesh: blend the shading normal toward an ellipsoid canopy normal taken from the material's
  crown volume, and use wrap diffuse. Keep the blend identity at 0 (see IDENTITY-PRESERVING-LIGHTING-REBUILD).

## SOFT-EDGE-GATE-CLEARS-BY-ITS-WIDTH

- A procedural layer or placement rule gated on "height below X" with a soft edge still scores past X, because the
  soft ramp extends beyond the limit. Measured: a riverbed mud layer at height <= -0.15 m with a 0.2 m soft edge put
  19 % mud on the whole flat floor at height 0, which read as a dark smear.
- Put the hard limit at least one soft width clear of the surface you mean to exclude (here -0.35 m with a 0.15 m
  soft edge). The same applies to slope, distance and any other smoothstep gate.
- Check after the bake: sample the layer weight (the alphamap or mask) at a few points on the excluded surface, and it
  must read 0.00.

## BENT-CARD-NORMALS-FLIP-ONLY-WHEN-FACING-AWAY

- The lit-pass counterpart to DOUBLE-SIDED-CASTER-FLIPS-THE-NORMAL-TO-THE-LIGHT above. A `Cull Off` foliage shader
  must not flip the normal on every back face when the mesh carries bent card normals (authored pointing outward from
  the crown).
- The two wings of a crossed card wind opposite ways but share one outward normal, and vertex dedupe keeps a single
  winding. An unconditional back-face flip then turns one wing's normal inward, so that wing lights from inside and
  crown tops read as shadowed. Turning shadow receiving off never fixes it, because shadows are not the cause.
- Flip only when the face is a back face and the normal points away from the viewer:
  `if (!isFrontFace && dot(N, V) < 0) N = -N;`.
- Proof: mesh hashes identical, pixels change only on card species, and an under-crown view is not brighter.

## EFFECT-IS-PROVEN-BY-ITS-OUTPUT

- A settings readback does not prove an effect renders. A pass reported a colour grade "on" for two tiers because the
  Volume was enabled and the camera had post-processing turned on, but nothing rendered: the URP renderer's
  `postProcessData` was null. The pass's own counters showed it, with SetPass identical with post-processing on and
  off.
- Prove a rendering effect by its output. Diff the same static frame with the effect on and off (the delta must be
  non-zero where the effect applies), or show a render-pass or SetPass count that changes.
- A "no delta" cost reading is a finding to explain, never an Editor limitation to note and move past. In URP, check
  the renderer asset's post-processing data and the camera's renderer index before blaming the Editor.

## RUNTIME-PIPELINE-CLONE-DIES-AFTER-THE-SWITCH

- Every Play exit cost 126-149 s: an asset refresh with 620 imports and 0 file changes, which was VFX Graph rebuilding
  every graph (`VFXAssetManager.Build`).
- Cause: a quality system ran on a runtime clone of the render pipeline asset. On release it restored the authored
  asset and destroyed the clone at once. The pipeline manager switches only at the next render, so it found its old
  asset null. It reported the change as a pipeline type change (`activeRenderPipelineTypeChanged`), and VFX Graph's
  handler for that event rebuilds every graph.
- Fix: destroy the clone only in `RenderPipelineManager.activeRenderPipelineAssetChanged`, after the switch has
  landed. Measured on 3 Play exits: the old exit fired the type change; the 2 fixed exits logged a clone-to-authored
  asset change and 0 imports.
- Gate: probe both `RenderPipelineManager` events across a Play exit whenever code swaps the pipeline asset at runtime.
  Read the per-project `Logs/Editor.log` for the refresh line (see the Editor-log rule in the [Live play testing via Unity MCP](/workflows/live-play-testing-unity-mcp/) page).

## SPEC-STATES-COVERAGE

- A procedural feature spec states its expected pixel coverage, and the bake reports the measured share. Measured: a
  rock style put cracks where ridged noise `1 - |2n - 1|` is low, which is almost nowhere. The cracks covered 0.4 % of
  pixels, and the style would have shipped without visible cracks.
- Write the target in the spec (for example "cracks 5-10 % of pixels"). The bake or its probe prints the measured
  share, and a value outside the range fails. After the fix, driven by the ridge crests, the share was 6.4 %.
- Refined: calibrate the metric on the accepted reference first. A crack metric (share of albedo pixels darker
  than 0.75 x mid) scored an accepted granite at 0 % and a new rock at 1.2 %, because it cannot see thin cracks, so a
  "4-10 %" window was meaningless. Run the metric on the known-good sample before setting a window; if it does not
  separate good from bad, judge by the frame and say so.

## FORCE-THE-PLATFORM-PATH-IN-PLACE

- Test a claim that a defect is "invisible in the Editor" (mobile-only) by forcing the claimed platform path in the same
  Play frame, not by accepting it. Extends EDITOR-CAPTURE-IS-THE-DESKTOP-TIER in the platform build page.
- Measured: a terrain ambient defect was reported as mobile-only (per-vertex spherical harmonics). Forcing the
  per-vertex mode through reflection gave pixels identical to per-pixel, because the SH input was zeroed before either
  path ran. The desktop path was losing ambient too.
- Measure the whole frame before and after the fix, not only the region where the defect was seen. Here the fix
  lifted all ground by about 58 luma, not only the shadowed faces.
- Refined: a source read is a hypothesis in both directions. Reading the pipeline source showed a second terrain
  shader computing vertex SH, so "it lacks ambient" looked disproven. Toggling the per-pixel keyword on it in the
  same frame moved a flat ground sample from 92.7 to 145.4 luma: it had almost no ambient either. After reading the
  code, toggle the suspect in place and measure before ruling it in or out (see RATE-STAYS-INFERRED-UNTIL-MEASURED).

## RT-CAPTURE-SKIPS-RENDER-SCALE

- `Camera.Render` into a RenderTexture ignores the URP asset's render scale and the quality tier's MSAA. A "phone
  tier" capture made that way is full resolution, and it hides the tier's aliasing and texture shimmer.
- Emulate the tier instead: render into a temporary RT sized render scale × target size with the tier's MSAA sample
  count, then Blit (bilinear) to the target size, the same upscale the device does. The target size is the developer's
  device (CAPTURES-SHOWN-USE-THE-TARGET-DEVICE-RESOLUTION).
- Label the capture with the render scale and MSAA it emulates. Device captures stay the proof for anything
  device-specific (EDITOR-CAPTURE-IS-THE-DESKTOP-TIER).

## VFX-AUTHORED-MODELS-ARE-RESOURCE-CONTENTS

- `AssetDatabase.LoadAllAssetsAtPath` on a `.vfx` can expose compiled shaders/buffers rather than authored particle
  data. Before claiming capacity limits, inspect the actual installed Editor graph resource contents, write through
  its native resource writer, import, and read back every authored capacity model by stable file ID. If that API
  requires reflection, keep it Editor-only, verify the installed signatures and fail on missing coverage; do not
  rewrite graph YAML or treat compiled buffer capacities as authoring settings.
