Tier 2 · Rendering

Rendering & URP

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

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). 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 #includes 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 BlockFieldDescriptors 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 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 ShaderVariantCollections, 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 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.