references/critique.md
# Critique rubric: judge a shader, a renderer feature or a variant plan before calling it done
Score each line 0 (missing), 1 (claimed), 2 (measured), 3 (measured and looked at). A deliverable
ships at 2 or more on every line that applies; any 0 on a "must" line blocks it. Write the score
with its evidence (job id, number, contact sheet path) in the handoff packet.
## A. Correctness (must)
| # | Check | How | Pass |
| --- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| A1 | compiles for the target API | `ShaderJobs.ValidateShaders` with the target `platform` | 0 import errors, 0 compile errors, every pass |
| A2 | lint clean | `ut_shaders.lint_shader_file` / `lint_custom_function` / `lint_compute` | 0 errors; every warning answered |
| A3 | passes agree | opaque: DepthOnly, DepthNormals, ShadowCaster present; displacement and clip shared (`AGENT_CLIP`/`AGENT_DISPLACE`) | a shadow probe follows the clipped or displaced shape (dissolve at 1: shadow gone) |
| A4 | Depth Priming safe | a `Shots` op `{"urp": {"depth_priming": "Forced"}}` | the object still renders |
| A5 | lights under the project's paths | shots with an extra point light and an extra directional light, Forward and Forward+ (`rendering_mode` op) | lit side changes in both paths; the `PathProbe` idea proves the switch happened |
| A6 | shadows cast and received | probe under the object vs open ground | shadow luma below 0.75x open ground |
| A7 | SRP Batcher | `Shots` result `srp_batcher` | compatible (read after a render) |
| A8 | camera textures exist | URP asset Depth or Opaque Texture on every asset that renders it; test with SSAO OFF | effect unchanged with SSAO off |
| A9 | no magenta, no blank | `ut_review.image_checks` on every frame | `magenta` and `blank` false |
## B. Look (must for visible work)
| # | Check | Pass |
| --- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B1 | contact sheet of every shot opened | the change is visible, nothing else changed |
| B2 | toon | hard band where intended (transition share well below PBR), cast shadows inside the band, tint floor not black, rim only on the lit side, no leftover PBR shine |
| B3 | water | shallow to deep hue shift, calm shore (the depth mask also scales the detail normals: a `_NormalStrength` A/B changes the shore far less than deep water, P7), foam broken by noise, motion between two times, no visible normal-map grid at distance |
| B4 | dissolve | thin edge that keeps its hue under the project's tonemapping, no UV seams, inside faces lit, shadow matches the cut |
| B5 | outline | silhouettes and creases, nothing on flat faces, width holds at the target resolution |
| B6 | full-screen | at the intended injection point; off-state identical to the no-feature frame |
## C. Cost and variants (must before a build)
| # | Check | How | Pass |
| --- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | variant budget | `VariantJobs.BuildShaderBundle` for the shipping materials, `parse_variant_funnel`, `variant_budget_check` | under budget, no growth over the last build without a reason |
| C2 | keyword types | `multi_compile` only for runtime toggles; no `shader_feature(_local)` toggled from runtime C# or by a Shader Graph Shader Feature keyword; `dynamic_branch` tested with `if`, declared without a stage suffix | `ut_shaders.lint_project_keywords` 0 errors; `lint_shader` 0 errors |
| C3 | strict matching | Strict Shader Variant Matching on for test builds | set and read (`StrippingSettings`); player tour clean (scenario-unity-performance) |
| C4 | GPU cost | `GpuCostJobs.MeasureFullscreenCost` A/B, same resolution, interleaved; half vs float only under Unified or on the device | the new version is faster in ms beyond the run-to-run noise (about 10 % here); device numbers from a player |
| C5 | first-use stutter | one `.graphicsstate` per graphics API and platform, traced in a development player; warm-up behind a loading screen (P18) | cold control shows first-use uploads, warm run shows 0 `Shader.CreateGPUProgram` samples and 0 logged uploads in gameplay (`pso_gate` clean) |
| C6 | Shader Build Settings | overrides written by `VariantJobs.KeywordOverrides`, counted with a bundle build, cleared or committed on purpose | the count moved as intended; no `dynamic_branch` override on an `#if`-tested keyword (ignored) and no `shader_feature` override on a global runtime keyword (stripped) |
| C7 | post-processing variants | `VariantJobs.PostProcessingAudit` | no stray VolumeProfile under Assets/ (samples, tests, abandoned scenes); kept families match the game's effects |
| C8 | Shader Graph Graph Settings | `shadergraph_summary` findings; `ShaderGraphJobs.GeneratedCode` | Alpha Clipping only where the object always clips, or Allow Material Override on |
## D. Maintainability and agent fitness
| # | Check | Pass |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| D1 | one CBUFFER in `HLSLINCLUDE`, properties named `_BaseColor`/`_BaseMap` style, `[HDR]` on emissive colors, thresholds with a floor above 0, additive masks default black | reviewed |
| D2 | graphs: exposed references documented (`ShaderProperties`), logic in a File-mode `.hlsl` with guard and suffixes | reviewed |
| D3 | renderer features: runtime assembly, serialized material (no `Shader.Find`), gated in `AddRenderPasses`, no allocation per frame, present on every renderer of every quality level that needs it (`ListFeatures`) | reviewed |
| D4 | deterministic captures: time through a property (`_TimeScale` 0, `_TimeOffset`), fixed bookmarks | reviewed |
| D5 | report: each step with its engine call, Verified and Assumed lists | present |
## Red flags that fail a review on sight
- "Looks right in the Scene view" with no capture, or a capture nobody opened.
- An optimization justified by instruction count.
- A `.shader` with `CGPROGRAM`, `UnityCG.cginc`, `_FORWARD_PLUS`, `SHADOWS_SCREEN`,
`ComputeScreenPos`, or a DepthNormals pass returning `n * 0.5 + 0.5`.
- A custom light loop without the directional pre-loop.
- A renderer feature using `Execute`, `OnCameraSetup`, `cmd.Blit` (pre-Render-Graph API: does not
run in 6.3).
- Variant counts quoted from the Inspector as if they were the build's.
- A Shader Graph Boolean keyword presented as the way to turn Alpha Clipping off (it cannot:
`#define _ALPHATEST_ON 1` in every pass).
- "PSO warm-up added" with no cold and warm player runs, or one collection reused across graphics
APIs.
- `EnableKeyword` on a `shader_feature` keyword in gameplay code (the dissolve "on hit" pattern).
references/expert-notes.md
# Expert notes: shading judgment by source
Principles with their source and timestamp (`[hh:mm:ss]` in the video), grouped by expert. Notes
live in `notes/shaders/`; digests `_digest_docs_shaders.md`, `_digest_videos_shaders.md`,
`_digest_visual_shaders.md`. "Observed" = run in Unity 6000.3.21f1 on this Mac on 2026-09-24
(`references/procedures.md`). My own additions are marked [added]. When sources disagree, the
deciding condition is given.
## 0. Observed here (strongest evidence, shapes the stance)
- The Manual's DepthNormals example (`return float4(normalWS * 0.5 + 0.5, 1)`) disagrees with URP
17.3: URP's `DepthNormalsPass.hlsl` writes the raw normal into a signed target and
`SampleSceneNormals` does not decode. In a normals view the Manual-style sphere reads (222,
251, 207) where the correct one reads (177, 246, 135). SSAO and normal outlines inherit the error.
- `LIGHT_LOOP_BEGIN` skips extra directional lights under Forward+ (installed `RealtimeLights.hlsl`:
`lightIndex += URP_FP_DIRECTIONAL_LIGHTS_COUNT`). A toon shader without the directional pre-loop
lost a second directional light under Forward+ and kept it under Forward. Unity's own Shader Graph
17.3 Custom Lighting sample (Toon) has the same gap; repointing its String-mode node to a
File-mode `.hlsl` with the pre-loop fixed it, no graph editor involved.
- Depth Priming Forced hides an opaque shader without a DepthOnly pass (confirms the 6.3 Manual);
the same scene keeps every shader built on `AgentPasses.hlsl`.
- Depth Texture off on the URP asset changed nothing while SSAO still requested depth; with SSAO off
too, the water turned to solid foam (every probe 209). A hidden dependency like the one
VGEz8oKyMpY suspects for normals.
- Variants: `AgentKit/Lit` has a 7,688-variant full space; Unity's funnel for a macOS AssetBundle
build: 4,046 per stage, 62 after settings filtering, 13 after built-in stripping, 13 shipped. A
`shader_feature` keyword ships variants only when a material in the build enables it (0 then 6
`_NORMALMAP` variants).
- Cost: per 1080p full-screen draw on an Apple M5 Max, 64 multiply-adds cost 0.007 to 0.029 ms, 64
`sin` 0.29 to 0.34 ms, 64 float4 `sin` 0.89 to 1.19 ms (4 runs; the v0.2 full run: 0.033 / 0.42 /
1.40 ms, same ranking). Timing needs interleaving, warm-up and synchronous shader compilation
(`ShaderUtil.allowAsyncCompilation = false`), or the editor's placeholder shader gets timed.
- An HDR emissive edge at (4, 1.1, 0.15) washed to cream under the template's Neutral tonemapping;
(1.2, 0.35, 0.03) kept its orange (four-value sweep).
- `Renderer.SetShaderUserValue` does not exist; `MeshRenderer` and `SkinnedMeshRenderer` have it
(6.3). One material, three renderers, three dissolve amounts rendered correctly.
- v0.2 (after the Y4 blind grade), all run here:
- PSO warm-up in a development macOS player (Metal): one traced `OSXPlayer_Metal.graphicsstate`
(23 variants, 26 to 27 graphics states, 4 AgentKit shaders plus URP post) took gameplay
first-use uploads from 14 (twice, cold) to 0, by `Shader.CreateGPUProgram` marker counts and by
the player log; warm-up 1.8 to 2.4 ms in one frame. `CreateGraphicsGraphicsPipelineImpl` (the
Manual's PSO marker) does not exist on Metal; Metal names its PSO work
`CreateGraphicsPipelineAsync` and `GpuProgramMetal.CreateCachedPipelineAsync`. The 6.3 Log
Shader Compilation line is
`Uploaded shader variant to the GPU driver: <shader> (instance ..), pass, stage, keywords, time`,
not the blog's `Compiled Shader:`.
- Shader Build Settings (scripted through `EditorGraphicsSettings.SetShaderBuildSettings`):
excluding AgentLit's soft shadow set 13 to 11 shipped variants; a Type Override to
`shader_feature` on the same GLOBAL runtime keywords strips them (soft shadows turn hard); a
Type Override to `dynamic_branch` is honored only for a keyword the code tests with `if()` (10
to 6 on a probe shader) and silently ignored for one tested with `#if` (URP 17.3's soft shadow
and SSAO code), and `if()`-tested keywords need a declaration without a stage suffix or the
other stage fails to compile.
- Post-processing variants follow every VolumeProfile ASSET under Assets/ (installed
`ShaderBuildPreprocessor`: `FindAssets("t:VolumeProfile")`, `Has<T>()`), not the scenes: an
unused Bloom HQ + dirt profile in no scene took UberPost from 433 to 577 variants, moving it out
restored 433. The Default Volume Profile holds all 19 overrides (URP re-adds them before each
build), so film grain, distortion and chromatic aberration variants (216 each) ship although no
scene uses them.
- Shader Graph Alpha Clipping without Allow Material Override puts `#define _ALPHATEST_ON 1` in
all 8 generated passes and no `_ALPHATEST_ON` keyword exists; with the override every pass
declares the keyword (installed `UniversalTarget.cs`, read in the generated code).
- Water: the shore mask calms the detail normals: normal-strength A/B changes 0.7 (mean |RGB|) at
the shore vs 15.5 in deep water.
- Precision: desktop PlatformDefault defines `half` as `float` (engine doc), measured half/float
0.92 to 1.00 over three runs (noise); Unified 0.77 to 0.89 on the M5 Max.
- SRP Batcher compatibility reads "Not initialized" until the shader has rendered once with the
pipeline. With `-nographics`, the active subshader is the FallBack's. Unity reports the
ShadowCaster LightMode tag as `SHADOWCASTER`. The 6.3 build log prints variant counts with the OS
thousands separator (`2.560`).
## 1. Unity 6.3 Manual and package docs (Unity Technologies)
Writing custom shaders in URP (note `doc-urp-custom-shaders`):
- ShaderLab plus `HLSLPROGRAM`; never mix SRP and Built-in includes (HLSLPROGRAM block note).
- SRP Batcher: every material property in one `UnityPerMaterial` CBUFFER, engine built-ins in
`UnityPerDraw` (SRP Batcher page).
- An opaque shader needs DepthOnly (Depth Priming) and DepthNormals (SSAO); every pass must produce
the same fragments (Depth-only page, "Important").
- Screen UV = `positionHCS.xy / _ScaledScreenParams.xy` (dynamic resolution); depth code branches on
`UNITY_REVERSED_Z`; sky mask at `0.0001` or `0.9999` (Reconstruct world position).
- Forward+ and Deferred+ ignore `_ADDITIONAL_LIGHTS`: declare `_CLUSTER_LIGHT_LOOP` and use
`LIGHT_LOOP_BEGIN` with `InputData inputData` in scope (Keywords and macros reference). Nuance
from the installed code: `Core.hlsl` defines `_ADDITIONAL_LIGHTS 1` whenever `_CLUSTER_LIGHT_LOOP`
is on, so `#ifdef _ADDITIONAL_LIGHTS` blocks still run (videos digest).
- `_Color` and `_MainTex` are treated as main color and texture even without attributes.
Render graph, renderer features, Full Screen Pass (note `doc-urp-render-graph`):
- `RecordRenderGraph` declares resources and never records commands; render functions static;
`AddRenderPasses` runs per camera per frame and must not allocate; `Create()` reruns on every
Inspector change (Write a render pass; Inject a render pass).
- Blit once and set `resourceData.cameraColor = destination` (Blit, "Avoid blitting back"); fewer
passes is the optimization; `AddUnsafePass` forfeits merging; framebuffer fetch
(`SetInputAttachment`, `LOAD_FRAMEBUFFER_X_INPUT`) keeps TBDR passes merged (Optimize).
- A pass nobody reads is culled; `AllowPassCulling(false)` is for debugging only.
- Full Screen Pass: zero code, three injection points, Requirements declare camera textures.
Correction from the installed `FullScreenPassRendererFeature.cs` (visual digest): Fetch Color
Buffer (default on) binds `_BlitTexture`; Requirements Color only declares a read of the opaque
texture; keep Pass index 0 (the Fullscreen graph's DrawProcedural pass).
Shader variants and PSO warm-up (note `doc-shader-variants`, Manual part):
- `shader_feature` by default, `multi_compile` for runtime toggles; sets group exclusive keywords;
stage suffixes cut combinations but are ignored on GL, GLES and Vulkan.
- `dynamic_branch`: test with `if`, never `#if` (always false); writing every keyword test as `if`
lets Shader Build Settings convert a keyword between variants and dynamic branching without code
changes.
- More than 128 keywords per shader slows the project; 4 are reserved. 8 sets of 3 `multi_compile`
keywords exceed 6,000 variants.
- Unity 6 Shader Build Settings (Graphics settings, overridable per build profile) set a keyword's
type or exclude it.
- Post-processing variants are kept for Volume overrides found in any scene of the PROJECT (Manual;
the installed URP 17.3 code is stricter: any VolumeProfile asset under Assets/, section 0);
disable unused URP features in EVERY URP asset of the build; never ship URP assets with different
rendering paths (doubles variants per keyword).
- First-use stutter: trace PSOs with `GraphicsStateCollection` in a development build, one
collection per graphics API and platform ("GPU states can vary per API"), save `.graphicsstate`,
warm up with `WarmUp` or `WarmUpProgressively` behind a loading screen; confirm no
`Shader.CreateGPUProgram` or `CreateGraphicsGraphicsPipelineImpl` markers in gameplay (on Metal
the second marker does not exist: section 0). Proven here: `procedures.md` P18.
Shader Graph 17.3 (note `doc-shadergraph-custom-function`):
- File mode: unique include guard per file, `_float`/`_half` suffix on every function, Name without
the suffix; inputs then `out` outputs; uniforms declared in the file are globals only
(`Shader.SetGlobalX`).
- Keyword Reference names get a leading underscore; Enum keywords are enabled as `REFERENCE_SUFFIX`
with the others disabled by hand; Allow Definition Override emits `if` for Shader Build Settings
overrides.
- Custom lighting: Unlit graph, Keep Lighting Variants on, Default Decal Blending and Default SSAO
off; Forward and Forward+ only; multiple lights need a Custom Function loop (no `for` in graphs).
- Dynamic Branch during development for build speed, variants late for runtime speed (Introduction
to keywords). Deciding condition vs the Manual: target GPU class (old mobile suffers from dynamic
branches), branch symmetry, project phase.
Compute shaders (note `doc-compute-shaders`):
- Judge on the least capable platform: out-of-bounds access can crash non-DX GPUs; new buffers can
hold NaN; bind every declared resource.
- Metal: no texture atomics, no `GetDimensions` on buffers (pass sizes as constants). `RWTexture<T>`
must match the RenderTexture format for GL, GLES and Vulkan. GLES 3.1 guarantees 4 compute
buffers.
- No `//` comment on a `#pragma kernel` line.
## 2. Cyanilux, "Writing Shader Code in URP v2" (note `doc-cyanilux-urp-shader-code`, URP 10 to 12 era)
- Put the CBUFFER in `HLSLINCLUDE` so every pass shares it; `UsePass` and copying `LitInput.hlsl`
break the SRP Batcher or redefine the CBUFFER (UnityPerMaterial CBUFFER; ShadowCaster).
- Values that are not per material go through `Shader.SetGlobalX`; `material.SetX` on a property
outside Properties and the CBUFFER glitches under the SRP Batcher. Arrays are global only, max
1023 elements, else StructuredBuffer.
- Multi-pass: extra unnamed passes break the batcher; alternatives are a second material (few
objects), RenderObjects with an override material (many objects, loses properties), or a custom
LightMode rendered by RenderObjects (keeps properties, code only). Deciding condition: object
count and whether properties must be kept.
- Precision: float for positions, UVs, trig; half for directions and colors; `fixed` does not exist
in HLSL.
- Built-in to URP table: `UnityObjectToClipPos` to `TransformObjectToHClip`, `ShadeSH9` to
`SampleSH`/`SAMPLE_GI`, `UNITY_APPLY_FOG` to `MixFog`, `tex2Dlod` to `SAMPLE_TEXTURE2D_LOD`,
`SHADOW_ATTENUATION` to `GetMainLight(shadowCoord)`, `GrabPass` to `SampleSceneColor` (transparent
queue only).
- Outdated for 6.3: `_FORWARD_PLUS`, 256/64 keyword limits, `_MainTex` + `CommandBuffer.Blit` for
image effects (6.3: `_BlitTexture`, `AddBlitPass`), `ComputeScreenPos`.
## 3. Attilio Carotenuto, Technical Lead at Unity, "Shader Variants Optimization and Troubleshooting Tips" (blog 2024)
- Variants are a build-time, load-time and memory tax: builds hours longer and shader memory above 1
GB when unmanaged.
- Never strip blind: Strict Shader Variant Matching shows a missing variant as the error shader with
a console message instead of a silent closest match.
- Read the funnel in `Editor.log` (Full variant space, After settings filtering, After built-in
stripping, After scriptable stripping), per-API program counts and compressed sizes; track them
per build and fail on growth.
- Scriptable stripping: `IPreprocessShaders`, iterate backwards, `RemoveAt`; order with
`callbackOrder`; skip in development builds if debug variants matter. Playthrough-recorded Shader
Variant Collections are for investigation, not a strip whitelist (they miss device-only paths and
rot). Deciding condition vs the Manual: preload or warm-up is fine, whitelist stripping is risky.
- Android caches compiled shaders: reinstall before a Log Shader Compilation pass. Deduplication
saves disk only.
## 4. Daniel Ilett, author of "Building Quality Shaders for Unity" (Apress, 2022)
Your First URP Shader (eMWrMRdP5jY, Unity 6.0):
- Pick the pipeline before writing code: pipeline libraries differ [00:00:00]; the stock Unlit
template is Built-in [00:00:34] (6.3 ships Create > Shader > URP Unlit Shader).
- Zero-initialize outputs with `(v2f)0` [00:11:04]. Make mistakes on purpose to learn the errors
[00:14:21].
Lighting and Shadows in Code (bH--RU6qyTw, Unity 6.0):
- "Keyword soup": copy the keywords from URP's own `Lit.shader` [00:13:46] [00:14:51]; declare all
three main light shadow keywords [00:16:30].
- `NormalizeNormalPerPixel` [00:07:11]; `exp2(glossiness)` for an even slider [00:12:38]; ambient
from `SampleSH` and a Fresnel rim to sit next to URP Lit [00:17:35] [00:19:14]; bitangent times
`tangent.w * unity_WorldTransformParams.w` for mirrored meshes [00:24:45].
- ShadowCaster: `_LightDirection`/`_LightPosition` outside the CBUFFER, `ApplyShadowBias` then
`ApplyShadowClamping` [00:35:06] [00:36:12]; displacement and discard repeated in shadow and depth
passes [00:37:52]. Corrections from the installed URP 17.3: the shadow mask uses the static
lightmap UV (TEXCOORD1), and the cluster loop misses extra directional lights (confirmed
observed).
Shader Graph Custom Functions (F8bAI6dIrto, 2022.3):
- File mode as soon as code grows; `_float` suffix; Name without it; inputs then outputs [00:02:13]
[00:04:25]; `SHADERGRAPH_PREVIEW` branch [00:03:17]; sanity check that the graph looks unchanged
after swapping in the node [00:05:32]; loop over existing lights in HLSL, not duplicated sub
graphs [00:10:29] [00:11:01].
Post Processing and Render Graph (26gbtRTokVo, Unity 6.0):
- Read the Render Graph Viewer first [00:01:06]; render functions static [00:13:18]; never read and
write one texture in one pass [00:11:03]; temp copies without MSAA or depth [00:16:01];
`ConfigureInput(Depth)` makes depth exist [00:29:56]; a Volume effect that does nothing means the
feature is missing from the renderer [00:26:38]; a shader reached through `Shader.Find` needs
`Resources` or a serialized reference [00:22:11]. Disagreement: he copies then blits back; the
docs and Unity's sample blit once and repoint `cameraColor`. Deciding condition: teaching clarity
vs one fewer full-screen pass on bandwidth-bound GPUs.
Fullscreen Outline Post Process (VGEz8oKyMpY, 2022.2):
- Combine normal and color edges; a threshold of 0 makes every pixel an edge (floor 0.01)
[00:03:44]; color edges get noisy on textured scenes [00:02:09]; Requirements decide which buffers
exist [00:07:07]; Fullscreen graphs cannot use Volumes [00:07:29]. Deciding condition for edge
sources: flat-colored scenes favor color edges, textured scenes depth and normals.
## 5. Freya Holmér, creator of Shader Forge (kfM-yu0iQBk, 2021 course)
- Shaders never crash: values are unclamped, NaN spreads silently; output intermediates as colors,
`frac` reveals overshoot, `saturate` clamps, `smoothstep` adds a cubic you may not want [01:39:44]
[01:57:25] [02:01:40] [02:04:24].
- Work goes where it runs least often: vertex before fragment unless the mesh is dense and far
[01:45:54].
- Float everywhere until you must optimize; half on mobile, where it can produce hard-to-debug
artifacts [01:19:53] [01:20:26]. Deciding condition vs Ilett ("tiny" savings, F8bAI6dIrto
[00:02:45]) and Cloward (vector width and instruction type, measured, E82XxlXMJs4 [00:16:19]):
target GPU and whether a device profiler shows ALU-bound. Observed: a desktop A/B cannot answer it
(PlatformDefault compiles half as float).
- Transparent = `ZWrite Off` AND the Transparent queue; RenderType only tags [02:44:37] [02:45:44];
`ZTest GEqual` draws only the hidden part (ghost behind walls) [02:51:46]; stacked transparent
quads near the camera cost fill rate [02:43:31].
- Integer-period waves loop seamlessly: `cos(x * TAU * n)` [02:12:05]; vertex texture fetch needs an
explicit mip [03:48:58]; aniso and trilinear pay under low cameras, not top-down [03:45:42].
- Corrections [added]: `_Time` is (t/20, t, 2t, 3t); a mip chain adds about one third of memory.
## 6. Ben Cloward, Senior Technical Artist on Unity's Shader Graph team (E82XxlXMJs4)
- Instruction count is "a very inaccurate measure" [00:01:06]; loops count at max trip count
[00:15:44]; float4 about 4x float [00:11:34] [00:16:19]; sine 16 cycles per component vs multiply
4 on Radeon [00:10:25]; tanh and atan2 64+ [00:13:50] [00:15:30]; cycle costs are hardware
specific [00:16:52]. Gate on measured GPU time [00:17:59].
## 7. Joyce (MinionsArt), shader artist, solo developer of Astro Kat (FIP6I1x6lMA)
- Receiving shadows in a stylized graph needs a Custom Function [00:00:26]; File mode past one line
[00:00:57].
- Half-Lambert into `smoothstep(offset, offset + smoothness)` times `shadowAttenuation`, then a tint
floor: cast shadows join the band [00:04:34] [00:05:11] [00:09:32]; output the light direction for
a lit-side rim (Fresnel times N.L, Step 0.5) [00:11:09]; default additive masks to black
[00:13:54].
- Outdated in 6.3: `SHADOWS_SCREEN` (Built-in keyword), `ComputeScreenPos`, the
Lit-graph-as-container trick (6.3: Unlit plus Keep Lighting Variants). Smoothness 0 divides by
zero inside smoothstep [added]: floor it.
## 8. Unity (official), URP Water Shader Graph (gRq-IdShxpU, 2019.3)
- Color and opacity follow depth: one 0..1 mask from scene depth minus surface depth, clamped,
drives color, alpha and shore normal strength [00:01:15] [00:03:06] [00:06:12] (Normal Strength =
Lerp(Normal Strength property, depth gradient), step 10 of the note; measured here, section 0).
- Two normal maps in opposite directions at different speeds (Time/50, Time/-25) hide tiling [frame
00:05:43]; subdivided mesh for waves [00:00:09]; Cast Shadows off, alpha clip threshold 0
[00:03:56] [00:04:28].
- Corrections [added]: blend normals (RNM or Normal Blend), do not add them; add wave height to Y,
do not replace it; world XZ UVs keep water tiles continuous; perspective cameras only for eye
depth.
## 9. Brackeys (Asbjørn Thirslund), Dissolve Shader Graph (taMp1g1pBeE, 2018)
- Dissolve = noise vs threshold with alpha clip, then an emissive band from the same test shifted by
an edge width, or the band is clipped away [00:02:56] [00:05:31] [00:06:04]; HDR color plus Bloom
makes the glow [00:07:11]; render both faces [00:08:15].
- [added] Gameplay needs a per-object amount, not Sine Time; object- or world-space noise avoids UV
seams; edge width is in noise units, so the band's width varies with the gradient (observed: a
height ramp makes wide bands).
## 10. Unity (official), Render Graph intro and dither feature (U8PygjYAF7A, project by Nik Lever, 6.0)
- Feature = manager, pass = worker [00:06:18]; the pass declares `requiresIntermediateTexture`
itself [00:08:32]; `GetTextureDesc(source)`, rename, `clearBuffer = false`, `AddBlitPass`, swap
`cameraColor` [00:09:06] [00:10:42] [00:11:15].
- Guard the back buffer; post effects at After Rendering Post Processing, never After Rendering
[00:10:10] [00:16:10] (observed: the guard fires). Local volumes need a local profile [00:13:03].
Viewer legend: green read, red write, blue line merged, globe global [00:01:40]; framebuffer fetch
keeps TBDR data on chip [00:17:54].
- [inference in the note, adopted] check the Volume in `AddRenderPasses`, not in
`RecordRenderGraph`, or the intermediate texture stays forced.
## 11. Compute and GPU-driven rendering
Matt Gambell (Game Dev Guide), BrZ4pWwkpto: compute pays at scale because of transfer overhead
[00:06:38]; `enableRandomWrite` before binding [00:04:41]; structs and stride must match [00:08:55];
nothing changes until `GetData` [00:09:35]. [added] ceil-divided dispatch with bounds checks;
`AsyncGPUReadback` per frame.
Sebastian Lague, X-iSQQgOd1A: kernels per domain (per agent, per pixel) [00:11:52] [00:12:24];
spatial fields in textures, diffusion as a blur, four species in RGBA [00:12:57] [00:16:02]; look at
a GPU hash at large size before trusting it [00:11:52]; prototype small (320 x 180, 250 agents) then
scale [00:13:30]. [added] never blur in place: ping-pong.
Garrett Gunnell (Acerola), Y0Ko0kvwfgA: the per-frame CPU to GPU copy is the bottleneck; compute
writes instance data into a GPU buffer the draw reads [00:03:39] [00:04:46]; GPU instances do not
exist for the CPU [00:04:46]; 2.16 M triangles and 4.32 M vertex invocations per frame at about 500
fps [00:06:29]; one instruction scales by millions [00:13:29]; couple parameters physically (tall
grass sways slower, yellows at the tips) [00:09:15] [00:11:16]; billboards fail from above
[00:14:07]. Unity 6.3 names: `Graphics.RenderMeshIndirect`, `GraphicsBuffer.IndirectDrawIndexedArgs`
(installed CoreModule). Geometry shaders do not run on Metal [added].
## 12. Disagreements and their deciding conditions
| Question | Positions | Decide by |
| ---------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Shader Graph or HLSL | graphs for artists, HLSL for passes and loops (Ilett); Built-in: code, URP: graphs (Freya) | who edits it; lighting loops or custom passes needed; for an agent, text authorability |
| Full Screen Pass or custom feature | zero code (Manual, Ilett 2023) vs Volume-driven multi-pass (Ilett 2026, Unity sample) | Volume blending, several passes, custom textures, depth-only input |
| Blit back or repoint | copy then blit back (Ilett) vs `cameraColor = destination` (docs, Unity sample) | bandwidth-bound target (mobile, TBDR): repoint |
| Dynamic branch or variants | development speed (Shader Graph docs) vs runtime speed on old mobile (Manual) | target GPU class, branch symmetry, project phase |
| Luminance weights | Rec. 601 (dither sample) vs Rec. 709 (Manual) | stylized threshold: either; perceptual grayscale on linear color: Rec. 709 |
| Time in shader or property | Sine Time (Brackeys), Time (water) vs a Dissolve property, Volume value | ambient loop vs gameplay-triggered per-object event |
| Edge source for outlines | normals plus color (Ilett 2023) vs depth plus normals Roberts (Ilett 2026) | flat-colored vs textured scenes |
| Shader Variant Collections | debugging only (Carotenuto) vs preload to avoid closest match (Manual) | preload and warm-up fine; strip whitelist risky |
| Half precision | float until you must (Freya), savings tiny (Ilett), vector width and instruction type (Cloward) | mobile target with a profiler showing ALU-bound; never judged on desktop (PlatformDefault = float) |
| Which scenes keep post variants | any scene of the project (Manual) vs any VolumeProfile asset under Assets/ (installed URP 17.3) | trust the installed code: audit profiles (`PostProcessingAudit`) |
## 13. PSO warm-up (run here, `procedures.md` P18)
1. Development build; a boot component (`AgentPsoProbe`) calls `BeginTrace()` at startup and
`EndTrace()` + `SaveToFile(path)` after a content tour (`UnityEngine.Experimental.Rendering`,
signatures checked in 6000.3.21f1).
2. One collection per graphics API and platform, named `<platform>_<api>.graphicsstate`
(`ut_shaders.pso_collection_name`); retrieve with `adb pull` or `SendToEditor` on devices; after
shader changes re-trace and check coverage (`PsoJobs.InspectCollection` lists shaders and passes;
`ContainsVariant` per variant).
3. Loading scene: `LoadFromFile`, refuse a collection traced on another API or platform, `WarmUp` or
`WarmUpProgressively(n)` per frame until `isWarmedUp`, then activate gameplay.
4. Gate: gameplay frames show 0 `Shader.CreateGPUProgram` samples and 0 logged uploads (both
measured by the probe and `run_pso_player`); the cold control must show some, or the bench proves
nothing. scenario-unity-performance owns device captures.
references/gui-paths.md
# GUI paths for the shading procedures (computer-use agent or human)
Unity 6.3 (6000.3.21f1) on macOS (Cmd for Ctrl). Labels from the installed editor and packages where
noted [verified: installed]; verify others before scripting clicks. The scripted route is in
`procedures.md`; the GUI is required for what has no scripting surface (wiring Shader Graph nodes,
Graph Settings, the Render Graph Viewer and Frame Debugger).
## Shader Graph (visual editor: the agent's gap)
| Task | GUI path | Scripted substitute |
| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New graph | Assets > Create > Shader Graph > URP > Lit / Unlit / Fullscreen Shader Graph [verified: installed menu items]; 6.3 also offers templates in the Create Shader Graph from template window | duplicate an existing graph: `ShaderGraphJobs.DuplicateGraph` |
| Import a sample (Custom Lighting, etc.) | Window > Package Management > Package Manager > Shader Graph > Samples > Import | `ShaderGraphJobs.ImportPackageSample` (copies `Samples~` with .meta) |
| Add a node | open the graph, Space (Create Node), search | none; ask for a one-time wiring or reuse a graph |
| Custom Function node | Create Node > Utility > Custom Function; Graph Inspector > Node Settings: Type File, Source (drag the .hlsl), Name WITHOUT `_float`, Inputs and Outputs with + in the function's parameter order | edit the `.hlsl` only; repoint an existing node: `ut_shaders.shadergraph_repoint_custom_function` (editor closed) |
| Convert to Sub Graph | select nodes > right-click > Convert To Sub Graph | none |
| Graph Settings (Surface Type, Render Face Both, Alpha Clipping, Allow Material Override, Keep Lighting Variants, Default Decal Blending, Default SSAO) | Graph Inspector > Graph Settings | URP target fields by text edit, editor closed: `ut_shaders.shadergraph_set_target_settings(p, alpha_clip=, allow_material_override=, surface_type=, render_face=, cast_shadows=)` (P17); custom lighting in 6.3: Unlit + Keep Lighting Variants ON, Default Decal Blending and Default SSAO OFF (subtarget fields, GUI) |
| Blackboard property or keyword | Blackboard > + > Float / Color / Texture2D / Keyword (Boolean or Enum); Definition, Scope, Stages, Generate Material Property | set values: `Material.SetFloat` with the Reference name (`ShaderProperties` lists them); keywords: `EnableKeyword("_REF")`, Enum `"_REF_SUFFIX"` |
| View generated code | select the .shadergraph > Inspector > View Generated Shader [verified: installed `ShaderGraphImporterEditor.cs`] | `ShaderGraphJobs.GeneratedCode` writes the same text to the job's `out/generated/` (P17); `ShaderJobs.ValidateShaders` for compile messages |
## Materials and shaders
| Task | GUI path | Scripted substitute |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| New hand-written shader | Assets > Create > Shader > URP Unlit Shader [verified: installed template] | write the `.shader` text; `ValidateShaders` |
| SRP Blit shader (full-screen) | Assets > Create > Shader > SRP Blit Shader [verified: installed Core RP template] | `AgentGrayscale.shader` pattern |
| Compute shader | Assets > Create > Shader > Compute Shader | write the `.compute`; `ValidateShaders` lists its messages |
| Shader facts | select the shader > Inspector: SRP Batcher line, keywords (Overridable / Not Overridable), Compile and show code (platform dropdown, Show, Preprocess only) | `ValidateShaders` (`srp_batcher` after a render via `Shots`, `inspector_variant_counts`) |
| Material keywords and values | select the material > Inspector | `ShaderJobs.SetupMaterials` (a `[Toggle(KW)]` float does not enable the keyword from script: add `keywords_on`) |
| Texture as normal map | select the texture > Texture Type: Normal map; sRGB off for data textures | `ShaderJobs.ImportTextures` |
## URP settings a shader depends on
| Task | GUI path | Scripted substitute |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Depth / Opaque Texture | select the URP asset (Assets/Settings/PC_RPAsset, Mobile_RPAsset) > Rendering > Depth Texture / Opaque Texture | `ShaderJobs.ConfigureUrp {"depth_texture": true}` for each asset |
| Rendering path, Depth Priming | select the Universal Renderer Data (Assets/Settings/PC_Renderer) > Rendering > Rendering Path; Depth Priming Mode | `ConfigureUrp {"rendering_mode", "depth_priming"}` |
| Add a renderer feature | Universal Renderer Data > Add Renderer Feature > (Full Screen Pass Renderer Feature, Screen Space Ambient Occlusion, custom) | `RendererFeatureJobs.AddFeature` (writes the list AND the map) |
| Full Screen Pass fields | the feature in the renderer Inspector: Pass Material, Injection Point, Requirements, Fetch Color Buffer; Pass index under ⋮ > Advanced Properties | `AddFeature` fields `passMaterial`, `injectionPoint`, `requirements`, `fetchColorBuffer`, `passIndex` |
| Toggle a feature | checkbox on the feature header | `RendererFeatureJobs.SetFeatureActive`, or a `Shots` op |
| Volume and profile for a custom effect | GameObject > Volume > Global Volume; Create > Rendering > Volume Profile; Add Override | `VolumeProfile.Add<T>()`, `overrideState` (not scripted in this skill) |
## Variants and stripping
| Task | GUI path | Scripted substitute |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Strict Shader Variant Matching | Edit > Project Settings > Player > Other Settings > Strict Shader Variant Matching | `VariantJobs.StrippingSettings {"strict_variant_matching": true}` |
| Log Shader Compilation | Edit > Project Settings > Graphics > Shader Loading > Log Shader Compilation | `StrippingSettings {"log_shader_compilation": true}` |
| Shader Build Settings (keyword Type Override, exclusions) | Edit > Project Settings > Graphics > Shader Build Settings > +, type the full set with `_`, Type Override or untick keywords, Apply; per build profile in File > Build Profiles | `VariantJobs.KeywordOverrides` (`EditorGraphicsSettings.SetShaderBuildSettings`, writes the active set; P15); build-profile overrides stay a GUI step here |
| URP stripping options | Edit > Project Settings > Graphics > URP section: Strip Unused Variants, Strip Unused Post Processing Variants, Strip Screen Coord Override Variants; Shader Stripping: Export Shader Variants, Shader Variant Log Level | read by `StrippingSettings` |
| Variant collection from the editor | Graphics > Shader Loading > Save to asset... | not recommended as a strip whitelist (Carotenuto) |
| Post-processing variants kept | Graphics > URP > Strip Unused Post Processing Variants; the Default Volume Profile (Graphics > URP) | `VariantJobs.PostProcessingAudit` lists every VolumeProfile under Assets/ and what it keeps (P16) |
| PSO trace and warm-up | File > Build Profiles > Development Build (tracing needs it), Autoconnect Profiler for `SendToEditor`; Profiler > search `Shader.CreateGPUProgram` in gameplay frames | `PsoJobs.BuildPsoScene`, `ut_run.build(development=True)`, `ut_shaders.run_pso_player` trace / cold / warm, `pso_gate` (P18) |
## Debuggers (GUI only)
| Window | Path | Scripted proxy |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Render Graph Viewer | Window > Analysis > Render Graph Viewer [verified: installed menu]; pick the camera, refresh after changes; green read, red write, blue line merged, globe global | readback of on/off captures; pass names via `ProfilingSampler` |
| Frame Debugger | Window > Analysis > Frame Debugger; "(RP n:m)" labels show native render passes | none |
| Rendering Debugger | Window > Analysis > Rendering Debugger (Map Overlays: Depth, Normals; Overdraw) | a full-screen view shader (the ShowNormals test shader) |
| Profiler GPU module | Window > Analysis > Profiler | `GpuCostJobs.MeasureFullscreenCost` (A/B on this Mac); device captures belong to scenario-unity-performance |
| Xcode Metal capture (macOS, iOS) | Xcode > Debug > Capture GPU Workload on a development player | outside Unity |
references/procedures.md
# scenario-unity-shaders procedures (copyable, each with its live test and result)
All ran in Unity 6000.3.21f1 (URP 17.3, Shader Graph 17.3) on macOS 26.5.1, Apple M5 Max, Metal, on
2026-09-24, project `tests/projects/unity-shaders` (APFS clone of `Base3D_URP`, path with spaces).
Tests: `tests/code/unity-shaders/` (`test_live_shaders.py` stages, `test_live_variants.py` sections,
`test_live_pso.py`, `test_offline.py`, `run_all.sh --live`). v0.1 had 55 live checks; the v0.2
refactor (after the Y4 blind grade) added P15 to P19 and live checks in P7 and P13 (73 live checks
in total, all passing). Results: `archive/tests/unity-shaders/live_results.jsonl`; frames, generated
code, player logs and contact sheets: `archive/tests/unity-shaders/out/<stage>/`.
Header for every snippet:
```python
import sys
sys.path.insert(0, "<skills>/scenario-unity-expert/scripts"); sys.path.insert(0, "<skills>/scenario-unity-shaders/scripts")
import ut_env, ut_run, ut_review, ut_shaders
P = ut_env.base_project("3d", "<project>/tests/projects/<skill>") # APFS clone, AgentKit installed
ut_shaders.install(P) # Assets/Editor/AgentKit/Shaders (jobs), Assets/AgentShaders/Runtime (feature), Assets/AgentShaders/Shaders
```
If a GUI editor holds the project, run the same jobs through `ut_live.call(P, "<method>", args)`
(scenario-unity-expert); the text edits of assets in P9 need the editor closed.
---
## P1. Pipeline inventory before touching a shader
```python
r = ut_run.run_method(P, "AgentKit.Shaders.RendererFeatureJobs.ListFeatures")
# per quality level: URP asset, depth/opaque texture, every renderer with rendering mode and features
for q in r["result"]["quality_levels"]:
print(q["quality_level"], q["asset"], q["depth_texture"], [(rd["path"], rd["rendering_mode"], rd["features"]) for rd in q["renderers"]])
kw = ut_shaders.urp_lit_keywords() # multi_compile lines of the INSTALLED Lit.shader forward pass (44 lines on 6000.3.21f1)
```
Engine calls: `QualitySettings.GetRenderPipelineAssetAt`,
`UniversalRenderPipelineAsset.rendererDataList`, `ScriptableRendererData.rendererFeatures`. Test:
stage `feature` (`list_features_all_quality_levels`), offline `urp_lit_keywords_installed`. Result:
pass. Template facts: active quality PC uses `PC_RPAsset` (Depth and Opaque Texture on, soft shadows
High) with `PC_Renderer` in Forward+ and SSAO (plus the 4 features added in P10); quality Mobile
uses `Mobile_RPAsset` (Depth Texture off) with `Mobile_Renderer` in Forward and 0 features: an
effect added only to `PC_Renderer` does nothing on the Mobile tier.
## P2. Hand-written URP lit shader with all passes (the template)
Files: `scripts/Shaders/AgentLit.shader`, `AgentPasses.hlsl`. The load-bearing parts:
```hlsl
SubShader {
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
CBUFFER_START(UnityPerMaterial) // every non-texture property, identical in every pass
float4 _BaseMap_ST; half4 _BaseColor; half _Metallic; half _Smoothness; half _UseNormalMap; half _BumpScale; half4 _EmissionColor;
CBUFFER_END
ENDHLSL
Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" }
HLSLPROGRAM
#pragma shader_feature_local _NORMALMAP
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
#pragma multi_compile _ _CLUSTER_LIGHT_LOOP
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Fog.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
// fragment: fill SurfaceData and InputData (positionWS, NormalizeNormalPerPixel(normal), view dir,
// TransformWorldToShadowCoord, fog, SampleSH, GetNormalizedScreenSpaceUV(positionCS)), then
// half4 c = UniversalFragmentPBR(inputData, surface); c.rgb = MixFog(c.rgb, inputData.fogCoord);
ENDHLSL }
Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0
HLSLPROGRAM
#pragma vertex AgentShadowVertex
#pragma fragment AgentShadowFragment
#pragma multi_compile_instancing
#pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
#include "AgentPasses.hlsl" // ApplyShadowBias + ApplyShadowClamping, runs AGENT_DISPLACE/AGENT_CLIP
ENDHLSL }
Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R ... AgentDepthOnlyVertex / AgentDepthOnlyFragment }
Pass { Name "DepthNormals" Tags { "LightMode"="DepthNormals" } ZWrite On ... AgentDepthOnlyVertex / AgentDepthNormalsFragment }
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
```
`AgentDepthNormalsFragment` returns `half4(NormalizeNormalPerPixel(normalWS), 0)` (octahedral-packed
under `_GBUFFER_NORMALS_OCT`), the encoding URP's own pass writes and `SampleSceneNormals` reads.
`UniversalFragmentPBR` already walks the cluster loop and the extra directional lights. Not covered
on purpose: lightmaps and APV (`LIGHTMAP_ON`, `ProbeVolumeVariants.hlsl`, the `SAMPLE_GI` branches
of `LitForwardPass.hlsl`), light layers, decals, a Meta pass, a GBuffer pass for Deferred. Test:
stages `validate` and `main`. Result: pass. `validate_shaders_zero_errors`: 6 shaders and 1 compute,
0 import and 0 compile errors for Metal (vertex and fragment of every pass), passes
ForwardLit/UniversalForward, ShadowCaster/SHADOWCASTER, DepthOnly, DepthNormals, 20.5 s.
`lit_casts_and_receives_shadow`: ground under the sphere luma 81.7 vs 155.6 open ground.
`agentlit_matches_urp_lit`: center pixel (192, 40, 34) vs URP Lit (193, 40, 35). SRP Batcher:
compatible (read after the capture).
## P3. Static lint before compiling (seconds, no Unity)
```python
f = ut_shaders.lint_shader_file(P + "/Assets/AgentShaders/Shaders/AgentLit.shader")
assert not [x for x in f if x["severity"] == "error"], f
ut_shaders.lint_custom_function(open(hlsl).read(), "X.hlsl", used_function_names=["MyFunc_float"])
ut_shaders.lint_compute(open(compute).read(), "X.compute")
```
Rules: CGPROGRAM, UnityCG, Surface Shader, GrabPass, UsePass, `fixed`; RenderPipeline tag;
Core.hlsl; one UnityPerMaterial CBUFFER holding every used property and `_ST`; opaque passes
(DepthOnly error, DepthNormals and ShadowCaster warn); `_CLUSTER_LIGHT_LOOP` and shadow keywords in
lit shaders; `inputData` name and directional pre-loop around `LIGHT_LOOP_BEGIN`; clip shared across
passes; Blend implies ZWrite Off and the Transparent queue; no non-LOD texture fetch in a vertex
function; no `#if` on a `dynamic_branch` keyword; no `if (KW)` on a keyword declared with a
`_vertex`/`_fragment` suffix (v0.2, observed compile error, P15); `_FORWARD_PLUS`, `SHADOWS_SCREEN`,
`ComputeScreenPos` (deprecated since URP 11: the installed 17.3 keeps it in
`ShaderVariablesFunctions.deprecated.hlsl`; use `GetNormalizedScreenSpaceUV(positionCS)` or
`positionCS.xy / _ScaledScreenParams.xy`). Custom Function: guard, `_float`/`_half`, inputs before
outputs, `SHADERGRAPH_PREVIEW` branch, file-scope uniforms, directional pre-loop. Compute: `//` on a
`#pragma kernel` line, buffer `GetDimensions`, texture atomics, missing bounds check. Test:
`test_offline.py`. Result: pass, 32 checks (v0.2; 22 in v0.1): a Built-in shader and a broken URP
shader trip every expected rule; all shipped templates, `AgentCustomLighting.hlsl` and
`AgentCompute.compute` lint clean; the v0.2 helpers (P15 to P19) have their own checks.
## P4. Compile a shader for the target API and read its facts
```python
r = ut_run.run_method(P, "AgentKit.Shaders.ShaderJobs.ValidateShaders",
{"paths": ["Assets/AgentShaders/Shaders"], "platform": "Metal", "build_target": "StandaloneOSX"})
assert r["ok"] and r["result"]["bad"] == 0
for s in r["result"]["shaders"]:
own = [ss for ss in s["subshaders"] if ss["own"]][0] # a FallBack adds its subshaders to ShaderData
print(s["name"], [(p["name"], p["light_mode"], p["keyword_count"]) for p in own["passes"]], s["inspector_variant_counts"])
```
Engine calls: `AssetDatabase.ImportAsset(ForceSynchronousImport)`, `ShaderUtil.GetShaderMessages`,
`ShaderUtil.GetShaderData(shader).GetSubshader(i).GetPass(j).CompileVariant(ShaderType, keywords, ShaderCompilerPlatform.Metal, BuildTarget.StandaloneOSX)`,
`ShaderUtil.GetPassKeywords(shader, new PassIdentifier(si, pi))`,
`Shader.FindPassTagValue(subshader, pass, "LightMode")`; internal `ShaderUtil.GetVariantCount` and
`GetSRPBatcherCompatibilityCode` through reflection (null if they move). With `-nographics` the
ACTIVE subshader is the FallBack's (no GPU): judge the shader's own subshader, compiled for the
platform you pass. `ShaderJobs.ApiProbe {"names": [...]}` lists where a member lives in THIS editor
before you write code against it (a compile error aborts every batch job). Test: stage `validate`
(`validate_shaders_zero_errors`, `api_probe`). Result: pass (see P2).
`variant_estimate_vs_inspector`: offline estimate 7,688 vs the Inspector's 7,697 (it counts the
FallBack passes). `ApiProbe` located `SetShaderUserValue(UInt32)` on `MeshRenderer`,
`SkinnedMeshRenderer`, `SpriteShapeRenderer`, `TilemapRenderer` (not `Renderer`).
## P5. Materials, a test bench and many shots in one editor boot
```python
ut_run.run_method(P, "AgentKit.Shaders.ShaderJobs.SetupMaterials", {"materials": [
{"path": "Assets/AgentShaders/Materials/M_Toon.mat", "shader": "AgentKit/Toon",
"colors": {"_BaseColor": [1, 0.55, 0.2, 1], "_ShadowTint": [0.32, 0.3, 0.55, 1]}},
{"path": "Assets/AgentShaders/Materials/M_Lit_N.mat", "shader": "AgentKit/Lit", "floats": {"_UseNormalMap": 1},
"keywords_on": ["_NORMALMAP"]}]}) # a [Toggle(KW)] float does NOT enable the keyword from script
ut_run.run_method(P, "AgentKit.Shaders.ShaderLab.BuildScene", {
"scene": "Assets/AgentShaders/Scenes/Lab.unity", # copy of SampleScene: camera, sun, Global Volume (Bloom, Tonemapping)
"ground": {"material": "Assets/AgentShaders/Materials/M_Ground.mat", "size": 40},
"objects": [{"name": "Hero", "mesh": "Capsule", "material": "Assets/AgentShaders/Materials/M_Toon.mat", "position": [0, 1.3, 0]},
{"name": "Water", "mesh": "Grid:160:24", "material": "...", "cast_shadows": False}], # Grid:N:size = mesh asset
"sun": {"rotation": [50, 30, 0], "intensity": 2, "shadows": "Soft"},
"lights": [{"type": "Directional", "name": "FillDir", "rotation": [20, -120, 0], "intensity": 0.8}],
"bookmarks": [{"name": "front", "position": [0, 4.2, -9.5], "look_at": [0, 0.8, 0.8], "fov": 50}]})
r = ut_run.run_method(P, "AgentKit.Shaders.ShaderLab.Shots", {
"scene": "Assets/AgentShaders/Scenes/Lab.unity", "width": 1280, "height": 720,
"probes": [{"name": "shadow", "world": [0.7, 0, 1.2]}], # returned per view as pixel (x, y, depth)
"shots": [{"name": "base"},
{"name": "fill_off", "set": [{"object": "FillDir", "active": False}]},
{"name": "d05", "set": [{"material": "Assets/.../M_Dissolve.mat", "float": "_Dissolve", "value": 0.5},
{"renderer": "Hero", "user_value": 29491}]},
{"name": "primed", "set": [{"urp": {"depth_priming": "Forced"}}]},
{"name": "fwd", "set": [{"urp": {"rendering_mode": "Forward"}}]}]}, graphics=True)
rev = ut_review.review_capture(r) # then OPEN rev["sheet"]
s = r["result"]["shots"][0]; x, y, _ = s["probes"]["shadow"]; rgb = ut_shaders.sample(s["path"], x, y, 3)
```
Material, renderer-value, feature and URP edits are reverted at the end of the job
(`keep_changes: true` to keep). SRP Batcher compatibility per shader is in
`r["result"]["srp_batcher"]`. Shadow probe points: `shadow_point()` in `test_live_shaders.py`
projects an object center along the sun's forward vector to y = 0. Test: stage `main`. Result: pass,
16 frames (8 shots x 2 views) in 17.6 s, 0 image-check errors, 0 magenta. Probe results: see P2, P6,
P7, P8.
## P6. Custom lighting with the 6.3 cluster loop (toon)
File: `scripts/Shaders/AgentToon.shader`. The loop every custom-lit shader or Custom Function needs:
```hlsl
InputData inputData = (InputData)0; // LIGHT_LOOP_BEGIN reads exactly this name
inputData.positionWS = input.positionWS;
inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);
inputData.shadowMask = half4(1, 1, 1, 1);
Light mainLight = GetMainLight(TransformWorldToShadowCoord(input.positionWS), input.positionWS, inputData.shadowMask);
half ramp = smoothstep(_RampOffset, _RampOffset + max(_RampSmoothness, 0.001h), dot(N, mainLight.direction) * 0.5h + 0.5h)
* mainLight.shadowAttenuation; // cast shadows join the toon band (MinionsArt)
half3 color = albedo * mainLight.color * lerp(_ShadowTint.rgb, 1.0h, ramp); // tint = shadow floor
#if defined(_ADDITIONAL_LIGHTS)
uint lightCount = GetAdditionalLightsCount();
#if USE_CLUSTER_LIGHT_LOOP // Forward+: extra directional lights are NOT in the cluster loop
[loop] for (uint i = 0; i < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); i++)
additional += ToonAdditional(GetAdditionalLight(i, inputData.positionWS, inputData.shadowMask), N);
#endif
LIGHT_LOOP_BEGIN(lightCount)
additional += ToonAdditional(GetAdditionalLight(lightIndex, inputData.positionWS, inputData.shadowMask), N);
LIGHT_LOOP_END
#endif
```
Test: stages `main` and `stylized`. Result: pass. `forward_plus_extra_directional_light`: toon right
side blue 101 with the fill directional light vs 82 without (AgentLit 38 vs 28).
`forward_plus_point_light_cluster_loop`: green 182 vs 169 with the point light off.
`forward_path_extra_directional_light`: identical numbers under Forward, and the `PathProbe` cube
proves the switch (red under Forward+ = `_CLUSTER_LIGHT_LOOP` variant, green under Forward).
Negative control `no_preloop_loses_directional_under_forward_plus`: the same shader without the
pre-loop reads blue 60.2 vs 59.5 (light lost) under Forward+ and 67.6 vs 59.5 under Forward.
`toon_bands_sharper_than_pbr`: 2.2 % of scanline pixels in the transition band vs 30 % for AgentLit.
## P7. Stylized water with depth fade (transparent)
File: `scripts/Shaders/AgentWater.shader`. Core:
```hlsl
// vertex: waves ADDED to world Y with an analytic normal; eyeDepth = -TransformWorldToView(positionWS).z
float2 screenUV = input.positionCS.xy / _ScaledScreenParams.xy; // dynamic-resolution safe
float waterDepth = max(LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams) - input.eyeDepth, 0);
half g = saturate(waterDepth / _DepthDistance); // one mask: color, alpha, shore calm
half4 water = lerp(_ShallowColor, _DeepColor, g);
half3 nTS = BlendNormalRNM(UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, s, uv + _ScrollA.xy * t)),
UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, s, uv * 1.37 + _ScrollB.xy * t))); // blend, not add
half foam = step(AgentGradientNoise2D(positionWS.xz + t * 0.05, _FoamNoiseScale), 1 - saturate(waterDepth / _FoamDistance));
// t = _Time.y * _TimeScale + _TimeOffset (TimeScale 0 = deterministic captures); UVs from world XZ
```
Setup: Depth Texture on in every URP asset that renders water
(`ShaderJobs.ConfigureUrp {"depth_texture": true}`); renderer Cast Shadows off;
`Blend SrcAlpha OneMinusSrcAlpha`, `ZWrite Off`, Transparent queue; a subdivided mesh
(`Grid:160:24`). Tileable normal map from integer-period waves: `write_water_normal()` in
`test_live_shaders.py`, imported with `ShaderJobs.ImportTextures {"type": "NormalMap"}`. Test: stage
`water`. Result: pass. `water_depth_fade_monotonic`: blue/red ratio 0.96, 1.05, 2.19, 2.08, 2.28
from shore to deep (luma is not monotonic: fresnel brightens grazing water). `water_shore_foam`:
shore luma 190 vs 162 nearby. `water_moves_with_time`: 13.7 % of pixels change between t = 3 and
3.8. `water_depth_texture_hidden_dependency`: Depth Texture off on the URP asset changes nothing
while SSAO requests depth; with SSAO off too every probe reads 209 (the water turns to solid foam).
Shore calm (v0.2, stage `shore`): the same depth mask scales the detail normals,
`nTS.xy *= _NormalStrength * saturate(g * 2)` (Unity water tutorial gRq-IdShxpU [00:06:12]: Normal
Strength lerped by the depth gradient). `water_normals_fade_at_shore`: mean |RGB| change when
`_NormalStrength` goes 0.6 to 0, 25x25 px box per probe, shore to deep: 0.69, 3.41, 7.71, 15.45,
13.93 (shallow/deep 0.045). Contact sheet `out/shore/contact_sheet.png`: the shallow band is calm in
both shots.
## P8. Dissolve that clips in every pass, per material or per renderer
File: `scripts/Shaders/AgentDissolve.shader`:
```hlsl
float DissolveAmount() { return _UseRendererValue > 0.5 ? (unity_RendererUserValue & 0xFFFFu) / 65535.0 : _Dissolve; }
float DissolveField(float3 positionOS) { // object-space 3D noise: no UV seams
float n = AgentFbm3D(positionOS * _NoiseScale);
float h = saturate((positionOS.y - _HeightMin) / max(_HeightMax - _HeightMin, 1e-4));
return saturate(lerp(n, h * 0.85 + n * 0.15, _DirectionWeight)); }
#define AGENT_CLIP(uv, positionOS, positionWS) clip(DissolveField(positionOS) - DissolveAmount()) // all passes
// forward: edge = step(field, amount + _EdgeWidth) * step(0.001, amount); emission = _EdgeColor.rgb * edge; Cull Off, flip N on back faces
```
Per renderer on ONE shared material (SRP Batcher and GPU Resident Drawer friendly,
`unity_RendererUserValue` is in UnityPerDraw):
```csharp
meshRenderer.SetShaderUserValue((uint)(amount * 65535f)); // MeshRenderer / SkinnedMeshRenderer, 6.3; runtime-only value
```
For a character that normally renders opaque, swap to the dissolve material when the effect starts
(both materials in the build) rather than toggling a `shader_feature` keyword [added from the
variant rules]: alpha test costs on tile GPUs and a runtime-toggled `shader_feature` variant may be
stripped (P11 proves the stripping, P19 lints for it). A Shader Graph dissolve with Alpha Clipping
on clips in every pass all the time, whatever Boolean keyword it has (P17). Test: stages `main` and
`stylized`. Result: pass. `dissolve_shadow_follows_clip`: shadow luma 82 at amount 0, 158 at amount
1 (open ground 162). `per_renderer_user_value`: three spheres, one material, values 0 / 0.45 / 0.8:
green-minus-red at the probe 131 / 5 / -10 (base 130). `dissolve_hdr_edge_visible`: 268 saturated
orange samples with edge (1.2, 0.35, 0.03); (4, 1.1, 0.15) washed to cream under the template's
Neutral tonemapping (observed in a four-value sweep, `out/edge_test/sheet.png`). Tested on
`MeshRenderer`; `SkinnedMeshRenderer.SetShaderUserValue` exists (ApiProbe) but was not rendered
here.
## P9. Shader Graph without the graph editor
```python
# 1. bring graphs in (the scripted twin of Package Manager > Samples > Import): files are copied WITH .meta (GUID refs)
r = ut_run.run_method(P, "AgentKit.Shaders.ShaderGraphJobs.ImportPackageSample",
{"package": "com.unity.shadergraph", "sample": "CustomLighting", "dest": "Assets/Samples/ShaderGraph/CustomLighting"})
# 2. duplicate a template graph and read the references Material.SetX must use
d = ut_run.run_method(P, "AgentKit.Shaders.ShaderGraphJobs.DuplicateGraph",
{"source": "Assets/Samples/ShaderGraph/CustomLighting/Custom Lighting Toon.shadergraph",
"dest": "Assets/AgentShaders/Graphs/HeroToon.shadergraph"})
refs = [(p["reference"], p["type"]) for p in d["result"]["properties"]]
summary = ut_shaders.shadergraph_summary(P + "/Assets/AgentShaders/Graphs/HeroToon.shadergraph") # offline: targets, properties, keywords, Custom Functions
# 3. material from the graph (a .shadergraph path loads as a Shader), then bench and shots as in P5
ut_run.run_method(P, "AgentKit.Shaders.ShaderJobs.SetupMaterials", {"materials": [
{"path": "Assets/AgentShaders/Materials/M_HeroToonGraph.mat", "shader": "Assets/AgentShaders/Graphs/HeroToon.shadergraph",
"colors": {"_EmissionColor": [1, 0.45, 0.25, 1]}}]})
# 4. change logic without the editor: repoint a Custom Function node to an agent-owned File-mode .hlsl
# (editor CLOSED: this edits the asset text; keep a copy of the original)
guid = ut_shaders.asset_guid(P + "/Assets/AgentShaders/Shaders/AgentCustomLighting.hlsl")
ut_shaders.shadergraph_repoint_custom_function(sub_graph_path, "AddAdditionalLightsHalfLambert", guid, "AgentAdditionalLightsHalfLambert")
ut_run.run_method(P, "AgentKit.Shaders.ShaderGraphJobs.GraphReport", {"paths": ["Assets/AgentShaders/Graphs/HeroToon.shadergraph"]})
```
File-mode rules (`AgentCustomLighting.hlsl`): one guard per file, `Name_float` and `Name_half`, the
node's Name field without the suffix, parameters in the node's port order (inputs, then `out`),
`SHADERGRAPH_PREVIEW` branch. Custom lighting graphs in 6.3: Unlit target with Keep Lighting
Variants on (GUI, `gui-paths.md`). Test: stage `graph` (also `shader_properties_of_graph`:
`ShaderJobs.ShaderProperties` on the `.shadergraph` path). Result: pass. 4 sample graphs imported
without errors (125 files, 13.6 s); HeroToon duplicated, SRP Batcher compatible, references
`_BaseMap`, `_BumpMap`, `_MetallicGlossMap`, `_EmissionMap`, `_EmissionColor`.
`sample_graph_ignores_extra_directional_forward_plus`: Unity's Toon sample graph reads blue 152.6
with and without the fill directional light under Forward+, 162.7 vs 152.6 under Forward.
`custom_function_file_mode_fix`: after repointing the String-mode node of
`AdditionalLightsHalfLambert.shadersubgraph` to `AgentCustomLighting.hlsl` (same ports, plus the
directional pre-loop), Forward+ reads 162.7 vs 152.6: fixed. `custom_function_hlsl_compiles`: every
entry point, both precisions, compiles in a URP pass.
## P10. Render Graph renderer feature: full-screen effect, added by script, on and off
Runtime file (outside any Editor folder): `scripts/Runtime/AgentFullscreenFeature.cs`. The pass:
```csharp
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
var resourceData = frameData.Get<UniversalResourceData>();
if (resourceData.isActiveTargetBackBuffer) { Debug.LogError($"{m_Name}: ... back buffer ..."); return; }
TextureHandle source = resourceData.activeColorTexture;
TextureDesc desc = renderGraph.GetTextureDesc(source); // graph-derived, change only what differs
desc.name = "CameraColor-" + m_Name; desc.clearBuffer = false;
TextureHandle destination = renderGraph.CreateTexture(desc);
var blit = new RenderGraphUtils.BlitMaterialParameters(source, destination, m_Material, m_PassIndex);
if (m_Inputs == ScriptableRenderPassInput.None) renderGraph.AddBlitPass(blit, passName: m_Name);
else using (var b = renderGraph.AddBlitPass(blit, passName: m_Name, returnBuilder: true)) {
if ((m_Inputs & ScriptableRenderPassInput.Depth) != 0) b.UseTexture(resourceData.cameraDepthTexture);
if ((m_Inputs & ScriptableRenderPassInput.Normal) != 0) b.UseTexture(resourceData.cameraNormalsTexture); }
resourceData.cameraColor = destination; // no blit back
}
// Setup (called from AddRenderPasses after the gates): renderPassEvent = evt; ConfigureInput(inputs); requiresIntermediateTexture = true;
// Feature.AddRenderPasses: return if no material, bad pass index, Preview/Reflection camera, or _Strength <= 0.
```
Shaders: SRP Blit style (`Core.hlsl` + Core RP `Blit.hlsl`, `#pragma vertex Vert`, sample
`_BlitTexture` with `input.texcoord`): `AgentGrayscale.shader`, `AgentEdgeOutline.shader` (depth and
normals Roberts cross, needs `Depth|Normal`).
```python
ut_run.run_method(P, "AgentKit.Shaders.RendererFeatureJobs.AddFeature", {"renderer": "Assets/Settings/PC_Renderer.asset", "features": [
{"type": "AgentKit.Rendering.AgentFullscreenFeature", "name": "AgentOutline", "active": False,
"fields": {"material": "Assets/AgentShaders/Materials/M_EdgeOutline.mat",
"injectionPoint": "BeforeRenderingPostProcessing", "requirements": "Depth|Normal"}},
{"type": "UnityEngine.Rendering.Universal.FullScreenPassRendererFeature", "name": "FSP_Grayscale", "active": False,
"fields": {"passMaterial": "Assets/AgentShaders/Materials/M_Grayscale.mat", "injectionPoint": "AfterRenderingPostProcessing",
"fetchColorBuffer": True}}]}) # idempotent by name; adds to m_RendererFeatures AND m_RendererFeatureMap
# toggle in shots: {"renderer_data": ".../PC_Renderer.asset", "feature": "AgentOutline", "active": True, "fields": {...}}
g = ut_shaders.region_stats(png)["gray_share"] # 1.0 when the grayscale pass ran
```
Full Screen Pass facts (installed code): keep Pass index 0; Fetch Color Buffer (default on) is what
binds `_BlitTexture`; Requirements Color only declares a read of the opaque texture. Test: stage
`feature`. Result: pass. 4 features added to `PC_Renderer` (5 serialized features, 5 map entries).
`feature_grayscale_on_off`: gray share 0.008 off, 1.0 on. `full_screen_pass_feature_by_script`: 1.0.
`feature_outline_on_off`: 1.1 % of pixels change; the outline also draws with SSAO off (the pass's
own `ConfigureInput(Normal)` produces the normals texture). `after_rendering_refuses_back_buffer`:
injection After Rendering logs the back-buffer error and draws nothing (gray share 0.008).
`manual_depthnormals_example_is_encoded`: in a normals view the Manual-style sphere reads (222,
251, 207) vs (177, 246, 135) for AgentLit at the same normal. Contact sheet:
`out/feature/contact_sheet.png`.
## P11. Variant count, funnel, stripping and the shader_feature rule
```python
ut_run.run_method(P, "AgentKit.Shaders.VariantJobs.StrippingSettings", {"strict_variant_matching": True, "log_shader_compilation": True})
a = ut_run.run_method(P, "AgentKit.Shaders.VariantJobs.BuildShaderBundle",
{"assets": ["Assets/AgentShaders/Materials/M_AgentLit_Red.mat", "..."], "target": "StandaloneOSX", "bundle": "base"})
shipped = {k: v["total"] for k, v in a["result"]["shaders"].items()} # IPreprocessShaders logger, callbackOrder int.MaxValue
funnel = ut_shaders.parse_variant_funnel(open(a["log"]).read())["totals"] # Unity's own numbers per shader
b = ut_run.run_method(P, "AgentKit.Shaders.VariantJobs.BuildShaderBundle", {"assets": [...], "bundle": "stripped",
"strip_rules": [{"shader": "AgentKit/Lit", "keyword": "_SCREEN_SPACE_OCCLUSION", "skip_development": False}]})
findings = ut_shaders.variant_budget_check(funnel, {"AgentKit/Lit": 16, "*": 64}, baseline=previous_funnel)
est = ut_shaders.variant_estimate(open(shader_path).read()) # offline full space per pass
```
Stripper pattern (`AgentStripRules`): `IPreprocessShaders.OnProcessShader(shader, snippet, data)`,
iterate `data` backwards, `data.RemoveAt(i)` when `data[i].shaderKeywordSet.GetShaderKeywords()`
holds the keyword; `callbackOrder` 0. The Unity 6.3 log prints the funnel per pass and stage, with
the OS thousands separator (`Full variant space: 2.560` here). Test: `test_live_variants.py`.
Result: pass. `AgentKit/Lit` funnel 4,046 (per stage) to 62 after settings filtering to 13 after
built-in stripping, 13 shipped; the logger total equals Unity's "After scriptable stripping" (13).
Strip rule: 13 to 9 (4 removed), Toon unchanged. `shader_feature_needs_a_material`: 13 variants and
0 with `_NORMALMAP` without a normal-mapped material, 19 and 6 with one. First bundle build 63.9 s,
cached rebuilds about 3 s. Not run: a development player tour with Strict Shader Variant Matching
and Log Shader Compilation (needs a player run; the settings are set and read here).
## P12. Compute shader with a GPU vs CPU test
```python
r = ut_run.run_method(P, "AgentKit.Shaders.ComputeJobs.RunComputeTemplate",
{"compute": "Assets/AgentShaders/Shaders/AgentCompute.compute", "count": 100003, "out_png": "/abs/pattern.png"},
graphics=True) # -nographics has no GPU device
```
Kernel rules (`AgentCompute.compute`): `if (id.x >= _Count) return;` with
`ceil(count / threadGroupX)` groups from `GetKernelThreadGroupSizes`; atomics on
`RWStructuredBuffer<uint>` (no texture atomics on Metal); `RWTexture2D<float4>` into an `ARGBFloat`
RenderTexture with `enableRandomWrite` set before `Create()`; buffers filled before use and released
in `finally`; `GetData` in tests only (`AsyncGPUReadback` per frame). Test: stage `compute`. Result:
pass on Metal: SquareAdd over 100,003 elements (1,563 groups of 64) max error 6e-8, 0 NaN; Histogram
bins sum 100,003, 0 mismatched with the CPU; Pattern PNG std 0.117.
## P13. Shader cost as GPU time (A/B)
```python
r = ut_run.run_method(P, "AgentKit.Shaders.GpuCostJobs.MeasureFullscreenCost", {
"baseline": "Assets/.../M_Cost_Base.mat", "materials": ["Assets/.../A.mat", "Assets/.../B.mat"],
"width": 1920, "height": 1080, "draws": 100, "samples": 11}, graphics=True)
for m in r["result"]["materials"]:
print(m["material"], m["net_ms_per_fullscreen_draw"], m["net_ns_per_pixel"])
```
Method: each sample executes `draws` full-screen triangles (additive blend so every fragment is
kept) into an offscreen target and blocks on a 1-pixel `AsyncGPUReadback`; materials interleaved
round by round; three warm-up rounds; median; `ShaderUtil.allowAsyncCompilation = false` during the
run (otherwise early samples time the editor's placeholder shader). `SystemInfo.supportsGpuRecorder`
was false here, so no `Recorder` GPU time. Test: stage `cost` (probe shader
`tests/code/unity-shaders/unity/Shaders/CostProbe.shader`). Result: pass on 4 consecutive runs after
the fix (the last one in the final full run); ms per 1080p draw on Apple M5 Max: 64 multiply-adds
0.007 to 0.029, 64 `sin` 0.29 to 0.34, 64 float4 `sin` 0.89 to 1.19 (v0.2 full run: 0.033 / 0.42 /
1.40; absolute times drift between runs, the interleaved ranking holds). The first version (no
interleaving, async compilation on) failed once with inverted results: kept as a lesson. Half vs
float (v0.2, stage `precision`, arg `"precision_model": "PlatformDefault" | "Unified"`, restored
after the run): the same 64-iteration `sin` loop on a float4 and on a half4 (`_COST_SIN4H`). The
engine doc of `ShaderPrecisionModel` says PlatformDefault defines `half` as `min16float` on mobile
targets and `float` elsewhere, so on this Mac the PlatformDefault pair is the same code: its ratio
is the noise. `half_precision_measured_per_model`, three runs: half/float 1.003, 0.92 and 0.963
under PlatformDefault, 0.82, 0.886 and 0.772 under Unified (float4 1.25 to 1.76 ms per 1080p draw
across runs). Verdict: a desktop A/B says nothing about mobile half; justify half on the device with
its profiler (Freya Holmér kfM-yu0iQBk [01:20:26]: float until you must optimize; Ben Cloward
E82XxlXMJs4 [00:16:19]: vector width and instruction type, measured).
## P14. Renderer and URP settings a shader depends on
```python
ut_run.run_method(P, "AgentKit.Shaders.ShaderJobs.ConfigureUrp", {"asset": "active", "depth_texture": True, "renderer": "Assets/Settings/PC_Renderer.asset",
"rendering_mode": "ForwardPlus", "depth_priming": "Disabled",
"features_active": {"ScreenSpaceAmbientOcclusion": True}})
```
Engine calls: `UniversalRenderPipelineAsset.supportsCameraDepthTexture/OpaqueTexture`,
`UniversalRendererData.renderingMode/depthPrimingMode`, `ScriptableRendererFeature.SetActive`,
`PlayerSettings.strictShaderVariantMatching`, `GraphicsSettings.logWhenShaderIsCompiled`.
`RendererFeatureJobs.SetFeatureActive {"renderer", "name", "active"}` toggles one feature and saves.
Test: stage `setup` (`configure_urp`), stage `feature` (`set_feature_active`), and the same setters
inside `ShaderLab.Shots` URP ops (stages `main`, `water`). Result: pass: PC_RPAsset, Forward+, depth
priming Disabled, SSAO active; `depth_priming_needs_depthonly`: the forward-only control cube
vanished under Forced, AgentLit unchanged.
## P15. Shader Build Settings by script (keyword exclusion and Type Override, Unity 6)
```python
ut_run.run_method(P, "AgentKit.Shaders.VariantJobs.KeywordOverrides", {"overrides": [
{"keywords": ["_", "_SHADOWS_SOFT", "_SHADOWS_SOFT_LOW", "_SHADOWS_SOFT_MEDIUM", "_SHADOWS_SOFT_HIGH"], # the declared set, exactly, with "_"
"mode": "Default", "exclude": ["_SHADOWS_SOFT", "_SHADOWS_SOFT_LOW", "_SHADOWS_SOFT_MEDIUM", "_SHADOWS_SOFT_HIGH"]}],
"shaders": ["AgentKit/Lit"]})
# mode: Default | AllVariants (multi_compile) | MaterialUsageBasedVariants (shader_feature) | SingleVariantWithDynamicBranching (dynamic_branch)
# then BuildShaderBundle (P11) to count; always finish with {"clear": True} or the next build inherits it
```
Engine calls: `EditorGraphicsSettings.GetShaderBuildSettings/SetShaderBuildSettings`,
`ShaderBuildSettings.KeywordDeclarationOverride` (`keywords` of
`KeywordOverrideInfo(name, keepInBuild)`, `variantGenerationMode`), `IsValid(out error)`; saved in
`ProjectSettings/GraphicsSettings.asset` under `m_ShaderBuildSettings`. The setter writes the ACTIVE
settings; a build profile with its own Shader Build Settings overrides them (6.3 Manual). The
editor's `keywordSpace` does not change (`isDynamic` stays false): the overrides act when a build
enumerates variants. Test: `test_live_variants.py sbs`. Result: pass (4 checks). Exclude the soft
set: AgentLit 13 to 11 shipped, soft variants 2 to 0, full space 4,046 to 974 (the soft shadow code
is compiled out). Type Override to shader_feature on the same set: 13 to 11, soft variants 0 (URP
enables these keywords globally at runtime, no material carries them, so the build strips them: soft
shadows turn hard). Type Override to dynamic_branch: on the probe shader `AgentTests/DynOverride` 10
to 6 variants for the keyword tested with `if (AGENT_KW_IF)`, unchanged (10) for the keyword tested
with `#if defined(AGENT_KW_PP)`, and unchanged on AgentLit's soft set (13): URP 17.3 tests the soft
set (`Shadows.hlsl`) and SSAO (`AmbientOcclusion.hlsl`) with `#if`, so the override is silently
ignored there; URP writes fog as `if (FOG_LINEAR)` and already declares it `dynamic_branch` in
`Fog.hlsl`. A first probe version declared `multi_compile_fragment _ AGENT_KW_IF`: the vertex stage
failed with "undeclared identifier 'AGENT_KW_IF' (a known keyword but not declared in this pass)":
`if()`-style keywords must be declared without a stage suffix (lint rule `if_keyword_stage_suffix`).
Settings cleared at the end (`shader_build_settings_cleared`).
## P16. Post-processing variants: audit the Volume Profiles, not the scenes
```python
a = ut_run.run_method(P, "AgentKit.Shaders.VariantJobs.PostProcessingAudit")
a["result"]["profiles"] # every VolumeProfile under Assets/: overrides, default?, used by a build scene?, families it keeps
a["result"]["profiles_outside_build_scenes"] # candidates to move out of Assets/ (samples, tests, abandoned scenes)
a["result"]["kept_features"] # union: what Strip Unused Post Processing Variants must keep
# UberPost variants actually shipped: BuildShaderBundle {"assets": ["Packages/com.unity.render-pipelines.universal/Shaders/PostProcessing/UberPost.shader"]}
```
What URP 17.3 does (installed `ShaderBuildPreprocessor.GetSupportedFeaturesFromVolumes`):
`AssetDatabase.FindAssets("t:VolumeProfile")`, keeps paths under `Assets`, and for each profile
`Has<LensDistortion>()`, `Has<Tonemapping>()`, `Has<FilmGrain>()`, `Has<DepthOfField>()`,
`Has<MotionBlur>()`, `Has<PaniniProjection>()`, `Has<ChromaticAberration>()` and the Bloom
quality/dirt choice; scenes are not read and an override's active state does not matter. The 6.3
Manual says "Volume Overrides found in any scene of the project"; the installed code is stricter:
any profile ASSET under Assets/. `URPPreprocessBuild.EnsureVolumeProfile` re-adds every override to
the Default Volume Profile before a build ("required to avoid missing overrides at runtime"), so
film grain, lens distortion, chromatic aberration, depth of field, motion blur and Panini variants
are always kept; the stripping can only drop Bloom HQ/LQ/dirt variants no profile uses (a scriptable
stripper is the lever for the rest [added], proven with strict matching). Test:
`test_live_variants.py post`. Result: pass (3 checks). The template's Default Volume Profile has 19
overrides and keeps 8 families; UberPost ships 433 variants (film grain 216, distortion 216,
chromatic aberration 216, although no scene uses them). An unused profile with Bloom HQ + dirt, in
no build scene: 433 to 577 (`_BLOOM_HQ_DIRT` 0 to 144); moved out of Assets/ (to
`archive/tests/unity-shaders/moved/`): back to 433. UberPost bundle about 10 s.
## P17. Shader Graph Graph Settings without the editor, and the generated code
```python
ut_shaders.shadergraph_set_target_settings(src, out_path=dst, alpha_clip=True, allow_material_override=True) # editor CLOSED
ut_shaders.shadergraph_summary(dst)["urp_target"], ["findings"] # sg_alpha_clip_always when clip is on without override
g = ut_run.run_method(P, "AgentKit.Shaders.ShaderGraphJobs.GeneratedCode", {"paths": [dst_asset_path]})
rep = ut_shaders.generated_alpha_clip_report(open(g["result"]["graphs"][0]["file"]).read()) # per pass: defined / keyword / absent
```
Alpha Clipping in a URP target (installed `UniversalTarget.cs`, `AddAlphaClipControlToPass`): with
Allow Material Override OFF the target adds `#define _ALPHATEST_ON 1` to every pass (plus
`AlphaToMask On` for opaque); with it ON, `_ALPHATEST_ON` becomes a `shader_feature_local_fragment`
keyword driven by the material's `_AlphaClip`. A Blackboard Boolean keyword cannot remove the
define. For "clip only while dissolving": Allow Material Override plus
`BaseShaderGUI.SetMaterialKeywords`, or a second opaque material swapped in (P8). `GeneratedCode`
reads Shader Graph's internal
`ShaderGraphImporter.GetShaderText(path, out textures, AssetCollection, out GraphData)` by
reflection: the text the Inspector's "View Generated Shader" shows. Test: stage `graphclip`. Result:
pass. Toon sample copied twice with Alpha Clipping on: override OFF, 8 of 8 passes (Universal
Forward, DepthOnly, MotionVectors, DepthNormalsOnly, ShadowCaster, GBuffer, SceneSelectionPass,
ScenePickingPass) carry `#define _ALPHATEST_ON 1` and the keyword space has no `_ALPHATEST_ON`;
override ON, 8 of 8 declare the keyword and the keyword space lists `_ALPHATEST_ON`. Offline:
`shadergraph_alpha_clip_finding`.
## P18. PSO warm-up proven in a development player (GraphicsStateCollection)
```python
ut_run.run_method(P, "AgentKit.Shaders.PsoJobs.BuildPsoScene", {"scene": "Assets/AgentShaders/Scenes/PsoBench.unity",
"ground": ".../M_Ground.mat", "volume_profile": "Assets/Settings/SampleSceneProfile.asset",
"materials": [".../M_AgentLit_Red.mat", ".../M_Toon.mat", ".../M_Dissolve.mat", ".../M_Water.mat"]}) # "Content" inactive until warmed
ut_run.build(P, "macos", out="Builds/PsoBench/PsoBench.app", development=True, scenes=[scene]) # tracing needs a development player
gsc = out + "/" + ut_shaders.pso_collection_name("OSXPlayer", "Metal") # ONE file per graphics API and platform
trace = ut_shaders.run_pso_player(app, "trace", out + "/trace.json", gsc=gsc)
ut_run.run_method(P, "AgentKit.Shaders.PsoJobs.InspectCollection", {"file": gsc, "expect_shaders": ["AgentKit/Lit", ...]}, graphics=True)
cold = ut_shaders.run_pso_player(app, "cold", out + "/cold.json")
warm = ut_shaders.run_pso_player(app, "warm", out + "/warm.json", gsc=gsc)
assert not [f for f in ut_shaders.pso_gate(trace, cold, warm) if f["severity"] == "error"]
# shipping: copy gsc to StreamingAssets/PSO/<platform>_<api>.graphicsstate; AgentPsoProbe (mode "ship" or no flag) finds it by name
```
Runtime (`scripts/Runtime/AgentPsoProbe.cs`, component in the boot scene):
`new GraphicsStateCollection()`, `BeginTrace()` at boot (returns false outside a development
player), `EndTrace()` + `SaveToFile(path)`; shipping side `LoadFromFile`, refuse a collection whose
`graphicsDeviceType` or `runtimePlatform` differs from the device, `WarmUp(default).Complete()` or
`WarmUpProgressively(n, default).Complete()` once per loading frame until `isWarmedUp`, then
activate the gameplay root. Proof, two channels over the gameplay frames: `ProfilerRecorder` sample
counts of `Shader.CreateGPUProgram` (plus the PSO markers), and the player log with Log Shader
Compilation on, whose 6.3 lines read
`Uploaded shader variant to the GPU driver: AgentKit/Lit (instance 0x19C), pass: ForwardLit, stage: fragment, keywords ..., time: 1.4 ms`
(the blog's `Compiled Shader:` format is gone; `count_compiled_shaders` reads both). Test:
`test_live_pso.py` (development macOS player, 311 MB, 141 s first build, 12 s incremental; 5
windowed runs of 60 gameplay frames). Result: pass (8 checks). Trace: 23 variants, 26 to 27 graphics
states, Metal, OSXPlayer, quality PC, 262 to 266 KB, covering the 4 AgentKit shaders plus URP Bloom,
UberPost, LutBuilderLdr, SSAO, CoreBlit and the skybox. Cold, twice per run over 4 runs: 14
`Shader.CreateGPUProgram` samples in gameplay (2.0 to 4.5 ms) and the same 14 uploads in the log
(DepthNormals and forward passes of the 4 shaders), worst gameplay frame 7.1 to 11.1 ms. Warm: 0 and
0; warm-up of 26 to 27 states in one frame, 1.8 to 2.4 ms; worst frame 5.5 to 8.4 ms. Ship (file
found in `StreamingAssets/PSO/OSXPlayer_Metal.graphicsstate`): 0 and 0. On Metal
`CreateGraphicsGraphicsPipelineImpl` does not exist (recorder invalid); the Metal PSO markers are
`CreateGraphicsPipelineAsync` and `GpuProgramMetal.CreateCachedPipelineAsync`, valid but 0 in
gameplay in every run, so on Metal the evidence is the GPU program marker and the log.
scenario-unity-performance owns device captures.
## P19. Runtime keyword toggles against shader_feature declarations
```python
findings = ut_shaders.lint_project_keywords(P) # .shader/.hlsl/.shadergraph declarations vs runtime .cs (Editor folders skipped)
# or: ut_shaders.lint_runtime_keyword_toggles({"X.cs": text}, ut_shaders.keyword_declarations({"Y.shader": text}, [graph_paths]))
```
Rules: error `runtime_toggle_shader_feature` when runtime code calls
`EnableKeyword`/`DisableKeyword`/`SetKeyword` (string literal, `const string`, or
`new LocalKeyword(shader, "X")`) on a keyword declared only as `shader_feature` or
`shader_feature_local`, in code or as a Shader Graph Blackboard keyword with Definition Shader
Feature: the variant ships only if a material in the build enables it (P11: 0 then 6 `_NORMALMAP`
variants), so the player falls back to the closest variant. Warn `global_toggle_local_keyword` for
`Shader.EnableKeyword` / `CommandBuffer.EnableShaderKeyword` on a `_local` keyword. Editor scripts
are skipped on purpose: saving the keyword into a material asset is how a `shader_feature` variant
is kept. Fix: swap to a second material (both in the build), or `multi_compile_local`, or
`dynamic_branch` tested with `if()`. Test: `test_offline.py` (`lint_runtime_shader_feature_toggle`,
`lint_runtime_toggle_shadergraph_keyword`). Result: pass: the Y4 baseline's dissolve controller
pattern (`shader_feature_local _DISSOLVE`, `m.EnableKeyword("_DISSOLVE")`) is flagged three ways, a
`multi_compile_local` toggle is not.
## Not run here, and why
- Device GPU timings (Xcode Metal capture, Android GPU Inspector, Mali Offline Compiler) and mobile
half precision: outside the editor, on the device.
- A second graphics API for the PSO proof: macOS players run Metal only; the Vulkan (Android) and
DX12 collections need those players. The API guard (`api_match`) and the per-API file name are
tested.
- Strict matching errors in a player tour: Strict Shader Variant Matching is set and read (P11); the
P18 player logs show the uploads, not a strict-matching failure path.
- Deferred and Deferred+ paths (GBuffer pass, `UniversalForwardOnly`): the templates target Forward
and Forward+.
- GPU-driven instancing (`Graphics.RenderMeshIndirect`): names verified in the installed CoreModule,
no live test in this skill.
references/sources.md
# Sources for scenario-unity-shaders
What this skill rests on: the shaders cluster (`sources/research_shaders.json`: 16 videos, 6 docs),
its 17 notes and 3 digests in `notes/shaders/`, the saved docs `sources/docs/shaders__*.md`,
`sources/unity-version-deltas.md` (sections 2, 5, 13, 14), the installed 6000.3.21f1 packages (URP
17.3, Core RP 17.3, Shader Graph 17.3, read-only), and what ran on this Mac. Timestamps are
`[hh:mm:ss]` in the video. Views as of the research date (2026-09-24).
## 1. Observed on this Mac (primary evidence)
| Source | What | Best for |
| ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tests/code/unity-shaders/` (run 2026-09-24) | v0.1: 55 live checks in Unity 6000.3.21f1 (Metal, Apple M5 Max); v0.2: 73 live checks (adds Shader Build Settings, post-processing profiles, Shader Graph alpha clip, shore normals, precision, and 8 checks of a development-player PSO proof) plus 32 offline checks, all passing | every procedure, the observed findings in `expert-notes.md` section 0 |
| `archive/tests/unity-shaders/out/pso/` (v0.2) | traced `OSXPlayer_Metal.graphicsstate`, player logs and JSON of the trace, cold, warm and ship runs | the PSO warm-up proof (P18) |
| `archive/tests/unity-shaders/live_results.jsonl`, `out/<stage>/` | results, PNG frames, contact sheets (`main`, `water`, `stylized`, `feature`, `graph`, `edge_test`), variant logs, cost JSON | evidence to reproduce before building on it |
| Installed packages (`Unity.app/Contents/Resources/PackageManager/BuiltInPackages/`) | `Lit.shader`, `LitForwardPass.hlsl`, `ShadowCasterPass.hlsl`, `DepthNormalsPass.hlsl`, `RealtimeLights.hlsl`, `Lighting.hlsl`, `Core.hlsl`, `DeclareNormalsTexture.hlsl`, `FullScreenPassRendererFeature.cs`, `ScriptableRendererData.cs`, `ScriptableRendererDataEditor.cs`, `RenderGraphUtilsBlit.cs`, Shader Graph `Samples~/CustomLighting` | exact 6.3 keywords, macros, pass encodings, feature fields, the renderer feature map |
| Installed packages, read for v0.2 | URP `Editor/ShaderGraph/Targets/UniversalTarget.cs` (`AddAlphaClipControlToPass`, Alpha Clipping tooltip), `Editor/ShaderBuildPreprocessor.cs` (`GetSupportedFeaturesFromVolumes`), `Editor/BuildProcessors/URPPreprocessBuild.cs` (`EnsureVolumeProfile`), `Editor/ShaderScriptableStripper.cs`, `ShaderLibrary/AmbientOcclusion.hlsl`, `Shadows.hlsl`, `Fog.hlsl`, `ShaderVariablesFunctions.hlsl` and `.deprecated.hlsl`, `Shaders/PostProcessing/UberPost.shader`; Shader Graph `Editor/Importers/ShaderGraphImporter.cs` (`GetShaderText`) and `ShaderGraphImporterEditor.cs` | alpha clip in every pass, post-processing variants per VolumeProfile asset, Default Volume Profile enforcement, `#if` vs `if()` keyword tests, deprecated `ComputeScreenPos`, generated graph code |
| Engine API docs (`Unity.app/Contents/Resources/Scripting/Managed/UnityEditor.xml`, `UnityEngine.xml`) | `EditorGraphicsSettings.Get/SetShaderBuildSettings`, `ShaderBuildSettings.KeywordDeclarationOverride`, `ShaderVariantGenerationMode`, `ShaderPrecisionModel` (PlatformDefault: `half` is `min16float` on mobile, `float` elsewhere), `GraphicsStateCollection` members | exact 6.3 signatures used by P15, P18 and the precision test |
| Unity-Technologies/skills (GitHub, read 2026-09-24): `validate-urp-render-graph-renderer-feature`, `shader-graph-create-custom-node` | Unity's official agent skills on the same topics | renderer-feature review checklist (graph-derived descriptors, `AddBlitPass` over hand-written blits, explicit texture reads); reflected HLSL nodes need Shader Graph 17.5, not in 6.3 |
## 2. Official documentation
| Source | URL | Best for | Best sections |
| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Unity 6.3 Manual, Writing custom shaders in URP (note `doc-urp-custom-shaders`) | https://docs.unity3d.com/6000.3/Documentation/Manual/urp/writing-custom-shaders-urp.html | skeleton, CBUFFER, depth passes, screen UV, `_CLUSTER_LIGHT_LOOP` | Depth-only pass, Reconstruct world position, Keywords and macros reference |
| Unity 6.3 Manual, Render graph, renderer features, Full Screen Pass (note `doc-urp-render-graph`) | https://docs.unity3d.com/6000.3/Documentation/Manual/urp/render-graph.html | `RecordRenderGraph`, `AddBlitPass`, frame data, pass merging | Write a render pass, Blit, Optimize a render graph, Full Screen Pass reference |
| Shader Graph 17.3 manual (note `doc-shadergraph-custom-function`) | https://docs.unity3d.com/Packages/com.unity.shadergraph@17.3/manual/Custom-Function-Node.html | File-mode rules, keywords, custom lighting | Custom Function Node, Keyword parameter reference, Get started with Custom Lighting |
| Unity 6.3 Manual, compute shaders (note `doc-compute-shaders`) | https://docs.unity3d.com/6000.3/Documentation/Manual/class-ComputeShader.html | Metal and GLES limits, format qualifiers | Writing compute shaders for multiple platforms |
| Unity 6.3 Manual, variants, stripping, PSO (note `doc-shader-variants`, part 2) | https://docs.unity3d.com/6000.3/Documentation/Manual/shader-variant-stripping.html (also `shader-conditionals-choose-a-type.html`, `SL-MultipleProgramVariants-declare.html`, `urp/shader-stripping-features.html`, `shader-pso-trace.html`, `shader-prewarm.html`) | keyword types, dynamic branching, Shader Build Settings, URP stripping, PSO tracing and warm-up | Reduce shader variants, Strip shader variants in URP, Trace and manage PSO data collections, Warm up PSOs |
## 3. Written expert sources
| Expert, credential | Source | URL | Best for |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Attilio Carotenuto, Technical Lead at Unity | "Unity Shader Variants Optimization and Troubleshooting Tips" (blog, 2024-05-28; note `doc-shader-variants`, part 1) | https://unity.com/blog/engine-platform/shader-variants-optimization-troubleshooting-tips | Editor.log funnel, strict matching, scriptable stripping, runtime compile logging, memory |
| Cyanilux, URP shader author recommended on Unity Discussions | "Writing Shader Code in Universal RP (v2)" (2021, updated 2022; note `doc-cyanilux-urp-shader-code`) | https://www.cyanilux.com/tutorials/urp-shader-code/ | pass recipes, SRP Batcher rules, Built-in to URP table, multi-pass options; keywords outdated for 6.3 |
## 4. Videos
| Expert, credential | Video (id) | Views, date, version | Best for | Best timestamps |
| -------------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| Freya Holmér, creator of Shader Forge, co-founder of Neat Corp | Shader Basics, Blending & Textures (kfM-yu0iQBk), 3:53:10 | 1.90 M, 2021-02, Built-in | debugging by color, value ranges, blending, depth, queues, vertex offset, mips | [01:39:44] [01:57:25] [02:01:40] [02:44:37] [02:51:46] [03:48:58] |
| Daniel Ilett, author of "Building Quality Shaders for Unity" | Your First Shader, Shader Code Basics 01 (eMWrMRdP5jY) | 14 k, 2025-10, 6.0 | URP skeleton, zero-init | [00:00:34] [00:11:04] [00:14:21] |
| Daniel Ilett | Lighting & Shadows, Shader Code Basics 06 (bH--RU6qyTw) | 2 k, 2026-04, 6.0 | lit shader by hand, keywords from Lit.shader, ShadowCaster, pass consistency | [00:14:51] [00:16:30] [00:31:17] [00:35:40] [00:37:52] [00:38:24] |
| Daniel Ilett | Post Processing & Render Graph, Shader Code Basics 09 (26gbtRTokVo) | 2 k, 2026-09, 6.0 | Volume-driven Render Graph effects, failure modes | [00:01:06] [00:11:03] [00:13:18] [00:22:11] [00:26:38] [00:29:56] |
| Daniel Ilett | Shader Graph Basics Part 10, Custom Functions (F8bAI6dIrto) | 22 k, 2024-07, 2022.3 | File-mode Custom Functions, light loops in HLSL | [00:02:13] [00:03:17] [00:04:25] [00:07:13] [00:11:01] |
| Daniel Ilett | Outline Post Process in Shader Graph (VGEz8oKyMpY) | 85 k, 2023-03, 2022.2 | Fullscreen graph + Full Screen Pass, edge detection | [00:03:44] [00:04:43] [00:07:07] [00:07:29] |
| Unity (official), project by Nik Lever | Introduction to the Render Graph in Unity 6 (U8PygjYAF7A) | 92 k, 2024-11, 6.0 preview | Render Graph Viewer, `AddBlitPass`, back-buffer guard, dither | [00:01:40] [00:09:06] [00:10:10] [00:16:10] [00:17:54] |
| Joyce (MinionsArt), shader artist, Astro Kat | Making a Lit Toon Shader in Shader Graph (FIP6I1x6lMA) | 119 k, 2021-08, URP 11 | toon ramp with shadows, lit-side rim | [00:04:34] [00:05:11] [00:11:09] [00:13:54]; code [frame 00:07:35] |
| Unity (official) | Making a Water Shader in Unity with URP (gRq-IdShxpU) | 529 k, 2020-07, 2019.3 | depth-faded water order of operations | [00:01:15] [00:03:06] [00:06:12]; values [frame 00:05:43] |
| Brackeys (Asbjørn Thirslund) | DISSOLVE using Unity Shader Graph (taMp1g1pBeE) | 715 k, 2018-06, LWRP | noise clip plus offset HDR edge | [00:03:30] [00:05:31] [00:07:11] [00:08:15] |
| Ben Cloward, Senior Technical Artist, Unity Shader Graph team | Shader Performance Measurement (E82XxlXMJs4) | 18 k, 2021-11 | why instruction count is not cost | [00:10:25] [00:11:34] [00:15:44] [00:16:52] [00:17:26] |
| Matt Gambell (Game Dev Guide) | Getting Started with Compute Shaders (BrZ4pWwkpto) | 233 k, 2020-12 | compute round trip in C# | [00:04:41] [00:06:38] [00:08:55] [00:09:35] |
| Sebastian Lague, Coding Adventures | Ant and Slime Simulations (X-iSQQgOd1A) | 2.19 M, 2021-03 | GPU agent simulation structure, visual PRNG test | [00:11:52] [00:12:57] [00:13:30] [00:16:02] |
| Garrett Gunnell (Acerola), graphics programmer | How Do Games Render So Much Grass? (Y0Ko0kvwfgA) | 390 k, 2021-12, Built-in | GPU-driven instancing, scale budgets | [00:03:39] [00:04:46] [00:06:29] [00:13:29] [00:14:07] |
| Unity (official) | Customize the Lighting Model With Shader Graph's New Sample (4v2fgeJa8z4) | 10 k, 2025-09 | the 6.3 Custom Lighting sample | not distilled into a note (captions unavailable); the sample itself was read and tested (P9) |
| Ben Cloward | Triplanar Projection, Shader Graph Basics 28 (sjpszGetM40) | 29 k, 2021-12 | cheap vs full triplanar | not distilled into a note; not used |
## 5. Version facts
| Source | Best for |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sources/unity-version-deltas.md` section 2 (translation table), 5 (Rendering: Render Graph, keywords, Shader Graph, variants), 12 (WebGPU limits), 13 (6.4 to 6.6), 14 (traps 8 and 9) | Compatibility Mode removal, `_CLUSTER_LIGHT_LOOP` since 6.1, After Rendering timing since 6.2, Shader Build Settings and `SetShaderUserValue` in 6.3, Shader Graph Preprocessor Directives in 6.6, WebGPU status, Built-in deprecation in 6.5 |
## Revision history
- 2026-09-24 v0.1: first version, 55 live checks.
- 2026-09-24 v0.2 (refactor after the Y4 blind grade, v0.1 kept in
`skills/_versions/unity-shaders v1 2026-09-24/`): added the PSO warm-up proof in a development
player (P18, `PsoJobs`, `AgentPsoProbe`, one collection per API, marker and log channels), Shader
Build Settings by script (P15, `KeywordOverrides`: exclusion works, `dynamic_branch` override
ignored for `#if` keywords, `shader_feature` override strips global keywords), post-processing
variants per VolumeProfile asset (P16, `PostProcessingAudit`), Shader Graph Alpha Clipping
compiled into every pass (P17, `shadergraph_set_target_settings`, `GeneratedCode`), the runtime
`shader_feature` toggle lint (P19), shore normal fade and half-precision measurements (P7, P13),
the `if()` stage-suffix lint rule; corrected the compute platform claim (compute on GLES 3.1+,
none on GLES 3.0 or WebGL 2; GPU Resident Drawer and STP not on GLES) and the 6.3 Log Shader
Compilation line format.
scripts/AgentKit/Shaders/ComputeJobs.cs
// AgentKit.Shaders v0.1 (scenario-unity-shaders skill, 2026-09-24). Compute shader test harness: dispatch a
// kernel headless (needs graphics: ut_run.run_method(..., graphics=True); -nographics has no GPU device)
// and compare the GPU result with a CPU reference.
//
// Job RunComputeTemplate args: compute (asset path of AgentCompute.compute), count (default 100003, deliberately
// not a multiple of 64), bins (16), size (256), out_png.
// SquareAdd: GraphicsBuffer (Target.Structured) filled from C# (never trust zeroed memory), dispatch
// ceil(count / threadGroupX) groups (GetKernelThreadGroupSizes, not a hard-coded 8), readback, max abs error.
// Histogram: RWStructuredBuffer<uint> atomics (Metal has no texture atomics), bin sum must equal count.
// Pattern: RWTexture2D<float4> into a RenderTexture with enableRandomWrite, saved to PNG to LOOK at.
// Synchronous GetData is fine in a test; per-frame code uses AsyncGPUReadback.
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py.
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace AgentKit.Shaders
{
public static class ComputeJobs
{
public static void RunComputeTemplate()
{
AgentJob.Run(() =>
{
AgentCapture.RequireGraphics();
if (!SystemInfo.supportsComputeShaders) throw new InvalidOperationException("no compute support on " + SystemInfo.graphicsDeviceType);
var cs = AssetDatabase.LoadAssetAtPath<ComputeShader>(AgentJob.Str("compute"));
if (cs == null) throw new ArgumentException("compute shader not found: " + AgentJob.Str("compute"));
int count = AgentJob.Int("count", 100003), bins = AgentJob.Int("bins", 16), size = AgentJob.Int("size", 256);
float offset = 0.25f;
var result = new Dictionary<string, object> { { "graphics", SystemInfo.graphicsDeviceType.ToString() }, { "count", count } };
var input = new float[count];
var rng = new System.Random(1234);
for (int i = 0; i < count; i++) input[i] = (float)rng.NextDouble();
var inBuf = new GraphicsBuffer(GraphicsBuffer.Target.Structured, count, sizeof(float));
var outBuf = new GraphicsBuffer(GraphicsBuffer.Target.Structured, count, sizeof(float));
var binBuf = new GraphicsBuffer(GraphicsBuffer.Target.Structured, bins, sizeof(uint));
RenderTexture rt = null;
try
{
inBuf.SetData(input);
outBuf.SetData(new float[count]); // initialise: new buffers can hold garbage or NaN
binBuf.SetData(new uint[bins]);
int k = cs.FindKernel("SquareAdd");
cs.GetKernelThreadGroupSizes(k, out uint gx, out _, out _);
int groups = (int)((count + gx - 1) / gx); // ceiling division; the kernel bounds-checks the tail
cs.SetBuffer(k, "_Input", inBuf);
cs.SetBuffer(k, "_Output", outBuf);
cs.SetInt("_Count", count);
cs.SetFloat("_Offset", offset);
cs.Dispatch(k, groups, 1, 1);
var output = new float[count];
outBuf.GetData(output);
double maxErr = 0; int nan = 0;
for (int i = 0; i < count; i++)
{
if (float.IsNaN(output[i]) || float.IsInfinity(output[i])) nan++;
maxErr = Math.Max(maxErr, Math.Abs(output[i] - (input[i] * input[i] + offset)));
}
result["square_add"] = new Dictionary<string, object>
{
{ "thread_group_x", (int)gx }, { "groups", groups }, { "max_abs_error", maxErr }, { "nan_or_inf", nan },
{ "sample", new List<object> { input[0], output[0], input[count - 1], output[count - 1] } },
};
int kh = cs.FindKernel("Histogram");
cs.GetKernelThreadGroupSizes(kh, out uint hx, out _, out _);
cs.SetBuffer(kh, "_Input", inBuf);
cs.SetBuffer(kh, "_Bins", binBuf);
cs.SetInt("_Count", count);
cs.SetInt("_BinCount", bins);
cs.Dispatch(kh, (int)((count + hx - 1) / hx), 1, 1);
var gpuBins = new uint[bins];
binBuf.GetData(gpuBins);
var cpuBins = new int[bins];
foreach (var v in input) cpuBins[Math.Min((int)(v * bins), bins - 1)]++;
long sum = 0; int mismatched = 0;
for (int b = 0; b < bins; b++) { sum += gpuBins[b]; if (gpuBins[b] != cpuBins[b]) mismatched++; }
result["histogram"] = new Dictionary<string, object> { { "sum", sum }, { "mismatched_bins", mismatched }, { "bins", new List<uint>(gpuBins) } };
int kp = cs.FindKernel("Pattern");
cs.GetKernelThreadGroupSizes(kp, out uint px, out uint py, out _);
rt = new RenderTexture(size, size, 0, RenderTextureFormat.ARGBFloat) { enableRandomWrite = true };
rt.Create(); // enableRandomWrite BEFORE Create
cs.SetTexture(kp, "_Target", rt);
cs.SetInts("_Size", size, size);
cs.SetFloat("_Time01", 0.25f);
cs.Dispatch(kp, (int)((size + px - 1) / px), (int)((size + py - 1) / py), 1);
var prev = RenderTexture.active;
RenderTexture.active = rt;
var tex = new Texture2D(size, size, TextureFormat.RGBA32, false, true);
tex.ReadPixels(new Rect(0, 0, size, size), 0, 0);
tex.Apply();
RenderTexture.active = prev;
var png = AgentJob.Has("out_png") ? AgentJob.ResolvePath(AgentJob.Str("out_png")) : Path.Combine(AgentJob.OutDir("compute"), "pattern.png");
Directory.CreateDirectory(Path.GetDirectoryName(png));
File.WriteAllBytes(png, tex.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(tex);
result["pattern"] = new Dictionary<string, object> { { "png", png }, { "groups", new List<int> { (int)((size + px - 1) / px), (int)((size + py - 1) / py) } } };
}
finally
{
inBuf.Release(); outBuf.Release(); binBuf.Release(); // always release GPU buffers
if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
}
return result;
});
}
}
}
scripts/AgentKit/Shaders/GpuCostJobs.cs
// AgentKit.Shaders v0.1 (scenario-unity-shaders skill, 2026-09-24). Shader cost measured as GPU time, not
// instruction count (Ben Cloward, E82XxlXMJs4 [00:17:26]: instruction counts are "relatively worthless"
// for comparing shaders; loops count at max trip count, float4 costs about 4x float, sin is 4x a multiply
// on Radeon).
//
// Job MeasureFullscreenCost (needs graphics: ut_run.run_method(..., graphics=True)):
// args: materials[] (full-screen materials whose pass uses Core RP Blit.hlsl Vert, e.g. an SRP Blit shader),
// baseline (material path, a trivial shader; its time is subtracted), width 1920, height 1080,
// format (ARGBHalf), draws (per sample, default 20), samples (default 9), pass (0),
// precision_model ("PlatformDefault" | "Unified", optional, v0.2): Player setting Shader Precision Model for
// the run (restored after). With PlatformDefault a desktop build compiles `half` as float, so a half vs
// float A/B on this Mac only means something under Unified (or on the mobile device itself).
// Each sample records `draws` full-screen triangles of one material into an offscreen target, executes the
// command buffer, then blocks on a 1-pixel AsyncGPUReadback: wall time from submit to readback is
// GPU-bound once draws x pixels dominate. Materials are measured interleaved, round by round (noise and
// thermal drift hit all alike). Give the probe shaders additive blending (Blend One One) so no draw
// overwrites the previous one. Reports median ms per full-screen draw, minus the baseline, and ns per pixel. When SystemInfo.supportsGpuRecorder, the "AgentCost" sampler's GPU time is reported too
// (often 0 in batch mode: the recorder updates at the end of a player-loop frame).
// This is an A/B tool on THIS Mac (Apple Silicon, Metal): ranks two shader versions at a fixed resolution.
// Target-device budgets still come from a development player on the device (Xcode Metal capture on
// iOS and macOS, Mali Offline Compiler or Android GPU Inspector on Android).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py (stage cost; v0.2 adds precision_model).
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.Rendering;
namespace AgentKit.Shaders
{
public static class GpuCostJobs
{
static readonly int BlitScaleBias = Shader.PropertyToID("_BlitScaleBias");
static double Median(List<double> v)
{
var s = v.OrderBy(x => x).ToList();
return s.Count == 0 ? 0 : (s.Count % 2 == 1 ? s[s.Count / 2] : 0.5 * (s[s.Count / 2 - 1] + s[s.Count / 2]));
}
static CommandBuffer Record(Material m, int pass, RenderTexture rt, int draws)
{
var cmd = new CommandBuffer { name = "AgentCost" };
cmd.SetRenderTarget(rt);
cmd.ClearRenderTarget(false, true, Color.black);
cmd.SetGlobalVector(BlitScaleBias, new Vector4(1, 1, 0, 0));
cmd.BeginSample("AgentCost");
for (int i = 0; i < draws; i++)
cmd.DrawProcedural(Matrix4x4.identity, m, pass, MeshTopology.Triangles, 3, 1);
cmd.EndSample("AgentCost");
return cmd;
}
static double Time(CommandBuffer cmd, RenderTexture rt)
{
var sw = Stopwatch.StartNew();
Graphics.ExecuteCommandBuffer(cmd);
AsyncGPUReadback.Request(rt, 0, 0, 1, 0, 1, 0, 1).WaitForCompletion(); // blocks until the GPU finished
sw.Stop();
return sw.Elapsed.TotalMilliseconds;
}
static Dictionary<string, object> Stats(List<double> times)
{
return new Dictionary<string, object>
{
{ "median_ms_per_sample", Math.Round(Median(times), 4) },
{ "min_ms_per_sample", Math.Round(times.Min(), 4) },
{ "max_ms_per_sample", Math.Round(times.Max(), 4) },
{ "samples", times.Select(t => (object)Math.Round(t, 3)).ToList() },
};
}
public static void MeasureFullscreenCost()
{
AgentJob.Run(() =>
{
AgentCapture.RequireGraphics();
int w = AgentJob.Int("width", 1920), h = AgentJob.Int("height", 1080);
int draws = AgentJob.Int("draws", 100), samples = AgentJob.Int("samples", 11), pass = AgentJob.Int("pass", 0);
var fmt = (RenderTextureFormat)Enum.Parse(typeof(RenderTextureFormat), AgentJob.Str("format", "ARGBHalf"));
var rt = new RenderTexture(w, h, 0, fmt) { name = "AgentCostRT" };
rt.Create();
// The editor compiles shaders asynchronously and draws a placeholder meanwhile: early samples would
// time the placeholder. Compile synchronously for the measurement, restore afterwards.
bool wasAsync = ShaderUtil.allowAsyncCompilation;
ShaderUtil.allowAsyncCompilation = false;
var prevPrecision = PlayerSettings.GetShaderPrecisionModel();
var precisionArg = AgentJob.Str("precision_model");
if (!string.IsNullOrEmpty(precisionArg))
PlayerSettings.SetShaderPrecisionModel((ShaderPrecisionModel)Enum.Parse(typeof(ShaderPrecisionModel), precisionArg));
Recorder rec = null;
if (SystemInfo.supportsGpuRecorder) { rec = Recorder.Get("AgentCost"); rec.enabled = true; }
var results = new Dictionary<string, object>();
try
{
// Interleave materials round by round (A B C A B C ...) so machine noise and thermal drift hit all
// of them alike; three discarded warm-up rounds (PSO creation, caches); median per material.
var paths = new List<string>();
var basePath = AgentJob.Str("baseline");
if (!string.IsNullOrEmpty(basePath)) paths.Add(basePath);
paths.AddRange(AgentJob.List("materials").Select(Convert.ToString));
var cmds = new List<CommandBuffer>();
foreach (var p in paths)
{
var m = AssetDatabase.LoadAssetAtPath<Material>(p);
if (m == null) throw new ArgumentException("material not found: " + p);
var sp = AssetDatabase.GetAssetPath(m.shader);
if (!string.IsNullOrEmpty(precisionArg) && sp.StartsWith("Assets")) // recompile under the chosen model
AssetDatabase.ImportAsset(sp, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
cmds.Add(Record(m, pass, rt, draws));
}
var times = paths.Select(_ => new List<double>()).ToList();
var recMs = paths.Select(_ => new List<double>()).ToList();
try
{
for (int round = -3; round < samples; round++)
for (int i = 0; i < cmds.Count; i++)
{
double t = Time(cmds[i], rt);
if (round < 0) continue;
times[i].Add(t);
if (rec != null && rec.gpuElapsedNanoseconds > 0) recMs[i].Add(rec.gpuElapsedNanoseconds / 1e6);
}
}
finally { foreach (var cmd in cmds) cmd.Release(); }
double baseMs = 0;
int first = 0;
if (!string.IsNullOrEmpty(basePath))
{
results["baseline"] = Stats(times[0]);
baseMs = Median(times[0]);
first = 1;
}
var list = new List<object>();
for (int i = first; i < paths.Count; i++)
{
var r = Stats(times[i]);
double net = Math.Max(0, Median(times[i]) - baseMs);
r["material"] = paths[i];
r["net_ms_per_fullscreen_draw"] = Math.Round(net / draws, 5);
r["net_ns_per_pixel"] = Math.Round(net * 1e6 / ((double)draws * w * h), 5);
r["gpu_recorder_ms"] = recMs[i].Count > 0 ? (object)Math.Round(Median(recMs[i]), 4) : null;
list.Add(r);
}
results["materials"] = list;
}
finally
{
ShaderUtil.allowAsyncCompilation = wasAsync;
if (!string.IsNullOrEmpty(precisionArg)) PlayerSettings.SetShaderPrecisionModel(prevPrecision);
if (rec != null) rec.enabled = false;
rt.Release();
UnityEngine.Object.DestroyImmediate(rt);
}
results["resolution"] = new List<int> { w, h };
results["draws_per_sample"] = draws;
results["gpu"] = SystemInfo.graphicsDeviceName;
results["graphics"] = SystemInfo.graphicsDeviceType.ToString();
results["supports_gpu_recorder"] = SystemInfo.supportsGpuRecorder;
results["precision_model"] = string.IsNullOrEmpty(precisionArg) ? prevPrecision.ToString() : precisionArg;
return results;
});
}
}
}
scripts/AgentKit/Shaders/PsoJobs.cs
// AgentKit.Shaders v0.2 (scenario-unity-shaders skill, 2026-09-24). Editor side of the PSO warm-up proof
// (GraphicsStateCollection, Unity 6, experimental). The runtime side is AgentKit.Rendering.AgentPsoProbe
// (scripts/Runtime/AgentPsoProbe.cs, installed to Assets/AgentShaders/Runtime).
//
// Jobs:
// BuildPsoScene args: scene ("Assets/AgentShaders/Scenes/PsoBench.unity"), materials[] (one object each,
// in a row), ground (material path, optional), volume_profile (path, optional: post-processing
// PSOs join the trace), warmup_per_frame (0), loading_frames (5), log_shader_compilation (true).
// Builds a scene whose gameplay root "Content" is INACTIVE until the probe has warmed up,
// and sets the player to run windowed and in the background. Build it afterwards with
// ut_run.build(P, "macos", out=..., development=True, scenes=[scene]) (tracing needs a
// development player).
// InspectCollection args: file (.graphicsstate), expect_shaders[] (optional) -> api, platform, quality,
// variants and graphics states, per shader: variant count, pass names, keyword sets; which
// expected shaders are missing (re-trace the content that uses them).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_pso.py.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
namespace AgentKit.Shaders
{
public static class PsoJobs
{
public static void BuildPsoScene()
{
AgentJob.Run(() =>
{
var path = AgentJob.Str("scene", "Assets/AgentShaders/Scenes/PsoBench.unity");
var probeType = RendererFeatureJobs.FindType("AgentKit.Rendering.AgentPsoProbe");
if (probeType == null) throw new InvalidOperationException("AgentKit.Rendering.AgentPsoProbe not compiled: run ut_shaders.install(P) first");
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var camGo = new GameObject("Main Camera") { tag = "MainCamera" };
var cam = camGo.AddComponent<Camera>();
var camData = camGo.AddComponent<UniversalAdditionalCameraData>();
camData.renderPostProcessing = true;
camGo.transform.position = new Vector3(0f, 2.4f, -7.5f);
camGo.transform.LookAt(new Vector3(0f, 0.9f, 0f));
cam.fieldOfView = 50f;
var sunGo = new GameObject("Sun");
var sun = sunGo.AddComponent<Light>();
sun.type = LightType.Directional;
sun.shadows = LightShadows.Soft;
sun.intensity = 2f;
sunGo.transform.eulerAngles = new Vector3(50f, 30f, 0f);
var profilePath = AgentJob.Str("volume_profile");
if (!string.IsNullOrEmpty(profilePath))
{
var vgo = new GameObject("Global Volume");
var vol = vgo.AddComponent<Volume>();
vol.isGlobal = true;
vol.sharedProfile = AssetDatabase.LoadAssetAtPath<VolumeProfile>(profilePath)
?? throw new ArgumentException("volume profile not found: " + profilePath);
}
var content = new GameObject("Content");
var ground = AgentJob.Str("ground");
if (!string.IsNullOrEmpty(ground))
{
var g = GameObject.CreatePrimitive(PrimitiveType.Plane);
g.name = "Ground";
g.transform.SetParent(content.transform, false);
g.GetComponent<Renderer>().sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(ground);
}
var mats = AgentJob.List("materials").Select(Convert.ToString).ToList();
var placed = new List<object>();
for (int i = 0; i < mats.Count; i++)
{
var m = AssetDatabase.LoadAssetAtPath<Material>(mats[i]) ?? throw new ArgumentException("material not found: " + mats[i]);
var go = GameObject.CreatePrimitive(i % 2 == 0 ? PrimitiveType.Sphere : PrimitiveType.Capsule);
go.name = Path.GetFileNameWithoutExtension(mats[i]);
go.transform.SetParent(content.transform, false);
go.transform.localPosition = new Vector3((i - (mats.Count - 1) * 0.5f) * 1.6f, 1f, 0f);
go.GetComponent<Renderer>().sharedMaterial = m;
placed.Add(new Dictionary<string, object> { { "object", go.name }, { "material", mats[i] }, { "shader", m.shader.name } });
}
content.SetActive(false); // gameplay starts after the warm-up
var probeGo = new GameObject("PsoProbe");
var probe = probeGo.AddComponent(probeType);
var so = new SerializedObject(probe);
so.FindProperty("content").objectReferenceValue = content;
so.FindProperty("warmupPerFrame").intValue = AgentJob.Int("warmup_per_frame", 0);
so.FindProperty("loadingFrames").intValue = AgentJob.Int("loading_frames", 5);
so.ApplyModifiedPropertiesWithoutUndo();
Directory.CreateDirectory(Path.GetDirectoryName(AgentJob.ResolvePath(path)));
if (!EditorSceneManager.SaveScene(scene, path)) throw new IOException("could not save " + path);
PlayerSettings.runInBackground = true;
PlayerSettings.visibleInBackground = true;
PlayerSettings.fullScreenMode = FullScreenMode.Windowed;
PlayerSettings.defaultScreenWidth = 640;
PlayerSettings.defaultScreenHeight = 360;
PlayerSettings.usePlayerLog = true;
if (AgentJob.Bool("log_shader_compilation", true)) GraphicsSettings.logWhenShaderIsCompiled = true;
AssetDatabase.SaveAssets();
return new Dictionary<string, object>
{
{ "scene", path }, { "objects", placed }, { "volume_profile", profilePath },
{ "log_shader_compilation", GraphicsSettings.logWhenShaderIsCompiled },
{ "strict_shader_variant_matching", PlayerSettings.strictShaderVariantMatching },
};
});
}
public static void InspectCollection()
{
AgentJob.Run(() =>
{
var file = AgentJob.ResolvePath(AgentJob.Str("file"));
var gsc = new GraphicsStateCollection();
if (!gsc.LoadFromFile(file)) throw new IOException("GraphicsStateCollection.LoadFromFile failed: " + file);
var variants = new List<GraphicsStateCollection.ShaderVariant>();
gsc.GetVariants(variants);
var perShader = new SortedDictionary<string, Dictionary<string, object>>();
foreach (var v in variants)
{
var name = v.shader != null ? v.shader.name : "(missing shader)";
if (!perShader.TryGetValue(name, out var d))
perShader[name] = d = new Dictionary<string, object> { { "variants", 0 }, { "passes", new SortedSet<string>() }, { "keyword_sets", new List<object>() } };
d["variants"] = (int)d["variants"] + 1;
string pass = v.passId.SubshaderIndex + ":" + v.passId.PassIndex;
if (v.shader != null)
{
var sd = ShaderUtil.GetShaderData(v.shader);
if ((int)v.passId.SubshaderIndex < sd.SubshaderCount)
{
var ss = sd.GetSubshader((int)v.passId.SubshaderIndex);
if ((int)v.passId.PassIndex < ss.PassCount) pass = ss.GetPass((int)v.passId.PassIndex).Name;
}
}
((SortedSet<string>)d["passes"]).Add(pass);
var kw = v.keywords == null ? "" : string.Join(" ", v.keywords.Select(k => k.name).OrderBy(n => n));
var sets = (List<object>)d["keyword_sets"];
if (sets.Count < 12) sets.Add(pass + " | " + kw);
}
var shaders = perShader.ToDictionary(kv => kv.Key, kv => (object)new Dictionary<string, object>
{
{ "variants", kv.Value["variants"] }, { "passes", ((SortedSet<string>)kv.Value["passes"]).Cast<object>().ToList() },
{ "keyword_sets", kv.Value["keyword_sets"] },
});
var expect = AgentJob.List("expect_shaders").Select(Convert.ToString).ToList();
return new Dictionary<string, object>
{
{ "file", file }, { "bytes", new FileInfo(file).Length }, { "api", gsc.graphicsDeviceType.ToString() },
{ "platform", gsc.runtimePlatform.ToString() }, { "quality", gsc.qualityLevelName }, { "version", gsc.version },
{ "variants", gsc.variantCount }, { "graphics_states", gsc.totalGraphicsStateCount }, { "shaders", shaders },
{ "missing_expected", expect.Where(e => !perShader.ContainsKey(e)).Cast<object>().ToList() },
};
});
}
}
}
scripts/AgentKit/Shaders/RendererFeatureJobs.cs
// AgentKit.Shaders v0.1 (scenario-unity-shaders skill, 2026-09-24). Add, configure, toggle and list URP renderer
// features on a UniversalRendererData asset by script, the way URP's own "Add Renderer Feature" button
// does it (ScriptableRendererDataEditor.AddComponent, URP 17.3): CreateInstance, AddObjectToAsset,
// append to m_RendererFeatures AND to the parallel m_RendererFeatureMap (local file ids) through
// SerializedObject, then save. Appending to rendererFeatures alone leaves the map stale until URP
// repairs it on validation.
//
// Jobs:
// AddFeature args: renderer (UniversalRendererData path; default: first renderer of the active URP
// asset), type (full type name, e.g. "AgentKit.Rendering.AgentFullscreenFeature" or
// "UnityEngine.Rendering.Universal.FullScreenPassRendererFeature"), name, active (bool),
// fields {fieldName: value}: public fields or [SerializeField] fields; Material fields take an
// asset path, enums take the member name, ScriptableRenderPassInput takes "Depth|Normal".
// Idempotent: an existing feature with the same name is updated, not duplicated.
// SetFeatureActive args: renderer, name, active
// ListFeatures args: renderer -> every renderer of every URP asset in Quality and Graphics settings, with
// its features (the audit: a feature missing from ONE renderer used by a quality level is the
// top reason a custom effect "does nothing", Daniel Ilett 26gbtRTokVo [00:26:38]).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
namespace AgentKit.Shaders
{
public static class RendererFeatureJobs
{
public static Type FindType(string fullName)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var t = asm.GetType(fullName, false);
if (t != null) return t;
}
return null;
}
static object ConvertValue(object v, Type t)
{
if (typeof(UnityEngine.Object).IsAssignableFrom(t))
return v == null ? null : AssetDatabase.LoadAssetAtPath(Convert.ToString(v), t);
if (t.IsEnum)
{
var s = Convert.ToString(v);
if (t.GetCustomAttribute<FlagsAttribute>() != null)
{
long acc = 0;
foreach (var part in s.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries))
acc |= Convert.ToInt64(Enum.Parse(t, part.Trim()));
return Enum.ToObject(t, acc);
}
return Enum.Parse(t, s);
}
if (t == typeof(bool)) return v is bool b ? b : AgentJson.ToDouble(v) > 0;
if (t == typeof(int)) return (int)AgentJson.ToDouble(v);
if (t == typeof(float)) return (float)AgentJson.ToDouble(v);
if (t == typeof(string)) return Convert.ToString(v);
if (t == typeof(Color)) return AgentJson.ToColor(v, Color.white);
if (t == typeof(Vector3)) return AgentJson.ToVector3(v, Vector3.zero);
return Convert.ChangeType(v, t);
}
public static void SetFields(object target, Dictionary<string, object> fields, List<string> set)
{
var t = target.GetType();
foreach (var kv in fields)
{
var fi = t.GetField(kv.Key, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (fi == null) throw new ArgumentException(t.Name + " has no field " + kv.Key);
fi.SetValue(target, ConvertValue(kv.Value, fi.FieldType));
set.Add(kv.Key);
}
}
public static ScriptableRendererFeature AddOrUpdate(UniversalRendererData rd, Type type, string name, Dictionary<string, object> fields, bool active, List<string> set, out bool created)
{
if (!typeof(ScriptableRendererFeature).IsAssignableFrom(type)) throw new ArgumentException(type + " is not a ScriptableRendererFeature");
var feature = rd.rendererFeatures.FirstOrDefault(f => f != null && f.name == name && f.GetType() == type);
created = feature == null;
if (created)
{
feature = (ScriptableRendererFeature)ScriptableObject.CreateInstance(type);
feature.name = name;
AssetDatabase.AddObjectToAsset(feature, rd); // sub-asset of the renderer
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(feature, out string _, out long localId);
var so = new SerializedObject(rd);
var list = so.FindProperty("m_RendererFeatures");
var map = so.FindProperty("m_RendererFeatureMap");
list.arraySize++;
list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = feature;
map.arraySize++;
map.GetArrayElementAtIndex(map.arraySize - 1).longValue = localId;
so.ApplyModifiedPropertiesWithoutUndo();
}
SetFields(feature, fields, set);
feature.SetActive(active);
feature.Create(); // what the Inspector triggers after a change
EditorUtility.SetDirty(feature);
rd.SetDirty();
EditorUtility.SetDirty(rd);
AssetDatabase.SaveAssets();
return feature;
}
static UniversalRendererData ResolveRenderer()
{
var rd = ShaderJobs.RendererData(AgentJob.Str("renderer"), ShaderJobs.ActiveUrpAsset("active"));
if (rd == null) throw new InvalidOperationException("UniversalRendererData not found: " + AgentJob.Str("renderer", "(active asset)"));
return rd;
}
public static Dictionary<string, object> Describe(ScriptableRendererData rd)
{
return new Dictionary<string, object>
{
{ "path", AssetDatabase.GetAssetPath(rd) }, { "type", rd.GetType().Name },
{ "rendering_mode", rd is UniversalRendererData u ? u.renderingMode.ToString() : null },
{ "features", rd.rendererFeatures.Select(f => f == null ? (object)"<missing script>" : new Dictionary<string, object>
{ { "name", f.name }, { "type", f.GetType().FullName }, { "active", f.isActive } }).ToList() },
};
}
public static void AddFeature()
{
AgentJob.Run(() =>
{
var rd = ResolveRenderer();
// one feature (type, name, fields, active) or several: features[{type, name, fields, active}]
var specs = AgentJob.List("features").OfType<Dictionary<string, object>>().ToList();
if (specs.Count == 0)
specs.Add(new Dictionary<string, object> { { "type", AgentJob.Str("type") }, { "name", AgentJob.Str("name") },
{ "fields", AgentJob.Dict("fields") }, { "active", AgentJob.Bool("active", true) } });
var done = new List<object>();
foreach (var spec in specs)
{
var typeName = Convert.ToString(spec["type"]);
var type = FindType(typeName);
if (type == null) throw new ArgumentException("type not found (did it compile? is it in a runtime assembly?): " + typeName);
var set = new List<string>();
var name = spec.TryGetValue("name", out var n) && n != null ? Convert.ToString(n) : type.Name;
var fields = spec.TryGetValue("fields", out var fo) && fo is Dictionary<string, object> fd ? fd : new Dictionary<string, object>();
bool active = !spec.TryGetValue("active", out var a) || a == null || (a is bool b ? b : AgentJson.ToDouble(a) > 0);
var f = AddOrUpdate(rd, type, name, fields, active, set, out bool created);
done.Add(new Dictionary<string, object> { { "feature", f.name }, { "type", type.FullName }, { "created", created }, { "fields_set", set }, { "active", f.isActive } });
}
return new Dictionary<string, object> { { "renderer", Describe(rd) }, { "added", done } };
});
}
public static void SetFeatureActive()
{
AgentJob.Run(() =>
{
var rd = ResolveRenderer();
var name = AgentJob.Str("name");
var f = rd.rendererFeatures.FirstOrDefault(x => x != null && x.name == name);
if (f == null) throw new ArgumentException("feature not found: " + name);
f.SetActive(AgentJob.Bool("active", true));
EditorUtility.SetDirty(f);
rd.SetDirty();
AssetDatabase.SaveAssets();
return Describe(rd);
});
}
public static void ListFeatures()
{
AgentJob.Run(() =>
{
var assets = new List<object>();
var seen = new HashSet<ScriptableRendererData>();
var names = QualitySettings.names;
for (int q = 0; q < names.Length; q++)
{
var rp = QualitySettings.GetRenderPipelineAssetAt(q) ?? GraphicsSettings.defaultRenderPipeline;
var urp = rp as UniversalRenderPipelineAsset;
if (urp == null) continue;
var rds = new List<object>();
foreach (var rd in urp.rendererDataList.ToArray())
{
if (rd == null) continue;
rds.Add(Describe(rd));
seen.Add(rd);
}
assets.Add(new Dictionary<string, object>
{
{ "quality_level", names[q] }, { "asset", AssetDatabase.GetAssetPath(urp) },
{ "depth_texture", urp.supportsCameraDepthTexture }, { "opaque_texture", urp.supportsCameraOpaqueTexture },
{ "renderers", rds },
});
}
return new Dictionary<string, object>
{
{ "active_quality", names[QualitySettings.GetQualityLevel()] }, { "quality_levels", assets },
};
});
}
}
}
scripts/AgentKit/Shaders/ShaderGraphJobs.cs
// AgentKit.Shaders v0.1 (scenario-unity-shaders skill, 2026-09-24). What an agent without a mouse CAN do with
// Shader Graph 17.3: bring in graphs that already exist (package samples, templates, a team's graphs),
// duplicate them, and drive them through their exposed properties and keywords. Wiring nodes needs the
// graph editor (GUI path in references/gui-paths.md); logic that must change goes into an agent-owned
// .hlsl behind a File-mode Custom Function node.
//
// Jobs:
// ImportPackageSample args: package ("com.unity.shadergraph"), sample folder under Samples~ ("CustomLighting"),
// dest ("Assets/Samples/ShaderGraph/CustomLighting"). Copies files WITH their .meta (sub
// graph references are GUIDs), refreshes, and reports every graph's shader and errors.
// The scripted twin of Package Manager > Samples > Import.
// DuplicateGraph args: source (.shadergraph path, Assets/ or Packages/), dest -> AssetDatabase.CopyAsset
// (new GUID; sub graph references stay valid).
// GraphReport args: paths[] (.shadergraph) -> generated shader name, errors, exposed properties
// (reference names for Material.SetX), keywords, passes.
// GeneratedCode args: paths[] (.shadergraph) -> writes the generated ShaderLab text (what the Inspector's
// "View Generated Shader" shows) to out/generated/<name>.shader and reports per graph:
// pass blocks, `#define _ALPHATEST_ON 1` lines, _ALPHATEST_ON keyword pragmas, keywordSpace.
// Reads internal ShaderGraphImporter.GetShaderText by reflection (Shader Graph 17.3; the job
// throws a clear error if the method moves). Proves Graph Settings effects such as Alpha
// Clipping, which a Boolean keyword cannot undo (URP UniversalTarget AddAlphaClipControlToPass).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py (v0.2 adds GeneratedCode).
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
namespace AgentKit.Shaders
{
public static class ShaderGraphJobs
{
static void CopyDir(string src, string dst, List<string> copied)
{
Directory.CreateDirectory(dst);
foreach (var f in Directory.GetFiles(src))
{
var d = Path.Combine(dst, Path.GetFileName(f));
if (!File.Exists(d)) { File.Copy(f, d); copied.Add(d); }
}
foreach (var sub in Directory.GetDirectories(src))
CopyDir(sub, Path.Combine(dst, Path.GetFileName(sub)), copied);
}
public static Dictionary<string, object> Report(string graphPath)
{
var shader = AssetDatabase.LoadAssetAtPath<Shader>(graphPath);
if (shader == null)
return new Dictionary<string, object> { { "path", graphPath }, { "error", "no Shader generated (import failed?)" } };
var msgs = ShaderUtil.GetShaderMessages(shader);
return new Dictionary<string, object>
{
{ "path", graphPath }, { "shader", shader.name }, { "supported", shader.isSupported },
{ "has_error", ShaderUtil.ShaderHasError(shader) },
{ "errors", msgs.Where(m => m.severity == UnityEditor.Rendering.ShaderCompilerMessageSeverity.Error).Select(m => (object)(m.message + " @" + m.file + ":" + m.line)).Take(10).ToList() },
{ "pass_count", shader.passCount },
{ "properties", ShaderJobs.PropertyList(shader) },
{ "keywords", shader.keywordSpace.keywordNames.ToList() },
};
}
public static void ImportPackageSample()
{
AgentJob.Run(() =>
{
var package = AgentJob.Str("package", "com.unity.shadergraph");
var sample = AgentJob.Str("sample", "CustomLighting");
var dest = AgentJob.Str("dest", "Assets/Samples/ShaderGraph/" + sample);
var info = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/" + package);
if (info == null) throw new ArgumentException("package not found in the project: " + package);
var src = Path.Combine(info.resolvedPath, "Samples~", sample);
if (!Directory.Exists(src)) throw new DirectoryNotFoundException(src + " (samples: " +
string.Join(", ", Directory.GetDirectories(Path.Combine(info.resolvedPath, "Samples~")).Select(Path.GetFileName)) + ")");
var copied = new List<string>();
CopyDir(src, AgentJob.ResolvePath(dest), copied);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
var graphs = Directory.GetFiles(AgentJob.ResolvePath(dest), "*.shadergraph", SearchOption.AllDirectories)
.Select(ShaderJobs.ToAssetPath).OrderBy(p => p).ToList();
return new Dictionary<string, object>
{
{ "package", package + "@" + info.version }, { "source", src }, { "dest", dest },
{ "files_copied", copied.Count }, { "graphs", graphs.Select(g => (object)Report(g)).ToList() },
};
});
}
public static void DuplicateGraph()
{
AgentJob.Run(() =>
{
var src = AgentJob.Str("source");
var dst = AgentJob.Str("dest");
Directory.CreateDirectory(Path.GetDirectoryName(AgentJob.ResolvePath(dst)));
if (!File.Exists(AgentJob.ResolvePath(dst)) && !AssetDatabase.CopyAsset(src, dst))
throw new InvalidOperationException("CopyAsset failed: " + src + " -> " + dst);
AssetDatabase.ImportAsset(dst, ImportAssetOptions.ForceSynchronousImport);
return Report(dst);
});
}
// Generated ShaderLab text of a graph, through Shader Graph's internal importer entry point.
public static string GeneratedText(string graphPath)
{
var t = Type.GetType("UnityEditor.ShaderGraph.ShaderGraphImporter, Unity.ShaderGraph.Editor");
if (t == null) throw new InvalidOperationException("UnityEditor.ShaderGraph.ShaderGraphImporter not found (Shader Graph package missing?)");
var mi = t.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)
.FirstOrDefault(m => m.Name == "GetShaderText" && m.GetParameters().Length == 4 && m.GetParameters()[3].ParameterType.IsByRef);
if (mi == null) throw new MissingMethodException("ShaderGraphImporter.GetShaderText(path, out textures, AssetCollection, out GraphData) moved in this Shader Graph version");
var assetCollection = Activator.CreateInstance(mi.GetParameters()[2].ParameterType);
var a = new object[] { graphPath, null, assetCollection, null };
return (string)mi.Invoke(null, a);
}
public static void GeneratedCode()
{
AgentJob.Run(() =>
{
var list = new List<object>();
var outDir = AgentJob.OutDir("generated");
foreach (var p in ShaderJobs.ExpandPaths(AgentJob.List("paths"), ".shadergraph"))
{
AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceSynchronousImport);
var text = GeneratedText(p);
var file = Path.Combine(outDir, Path.GetFileNameWithoutExtension(p) + ".shader");
File.WriteAllText(file, text);
var shader = AssetDatabase.LoadAssetAtPath<Shader>(p);
list.Add(new Dictionary<string, object>
{
{ "path", p }, { "file", file }, { "chars", text.Length },
{ "pass_blocks", Regex.Matches(text, @"^\s*Pass\s*$", RegexOptions.Multiline).Count },
{ "alphatest_defines", Regex.Matches(text, @"#define\s+_ALPHATEST_ON\s+1\b").Count },
{ "alphatest_keyword_pragmas", Regex.Matches(text, @"#pragma\s+(shader_feature|multi_compile)\w*\s+[^\n]*\b_ALPHATEST_ON\b").Count },
{ "keywords", shader != null ? shader.keywordSpace.keywordNames.ToList() : new List<string>() },
});
}
return new Dictionary<string, object> { { "graphs", list } };
});
}
public static void GraphReport()
{
AgentJob.Run(() =>
{
var list = new List<object>();
foreach (var p in ShaderJobs.ExpandPaths(AgentJob.List("paths"), ".shadergraph"))
{
AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceSynchronousImport);
list.Add(Report(p));
}
return new Dictionary<string, object> { { "graphs", list } };
});
}
}
}
scripts/AgentKit/Shaders/ShaderJobs.cs
// AgentKit.Shaders v0.1 (scenario-unity-shaders skill, 2026-09-24). Shader validation, materials and URP
// settings as batch jobs. Copied into Assets/Editor/AgentKit/Shaders/ by ut_shaders.install().
//
// Jobs (ut_run.run_method(P, "AgentKit.Shaders.ShaderJobs.<Job>", args)):
// ValidateShaders args: paths[] (.shader/.compute or folders), platform ("Metal"), compile (bool, default true)
// -> per shader: import errors and warnings (ShaderUtil.GetShaderMessages), passes with
// LightMode, per-pass keywords, a forced compile of the default variant of every pass
// (ShaderData.Pass.CompileVariant) for the target platform, SRP Batcher code, Inspector
// variant counts. ok=false when any shader has an error.
// SetupMaterials args: materials[{path, shader, floats{}, colors{}, vectors{}, textures{}, keywords_on[],
// keywords_off[], render_queue}] -> creates or updates material assets.
// ConfigureUrp args: asset ("active" or path), depth_texture, opaque_texture, renderer (path), rendering_mode
// (Forward|ForwardPlus|Deferred|DeferredPlus), depth_priming (Disabled|Auto|Forced),
// features_active{name: bool}, strict_variant_matching, log_shader_compilation.
// ShaderProperties args: shader (name or asset path, including a .shadergraph) -> exposed properties with
// reference names, types, flags and defaults (what Material.SetX must use).
// ImportTextures args: textures[{path, type, srgb, wrap, filter, aniso, mips, max_size}] (TextureImporter).
// ApiProbe args: names[] -> where each member lives in THIS editor (a compile error aborts every job).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEditor.Rendering;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
namespace AgentKit.Shaders
{
public static class ShaderJobs
{
// ------------------------------------------------------------------ helpers
public static IEnumerable<string> ExpandPaths(IEnumerable<object> items, params string[] exts)
{
foreach (var o in items)
{
var p = Convert.ToString(o);
if (string.IsNullOrEmpty(p)) continue;
var abs = AgentJob.ResolvePath(p);
if (Directory.Exists(abs))
{
foreach (var f in Directory.GetFiles(abs, "*.*", SearchOption.AllDirectories))
if (exts.Any(e => f.EndsWith(e, StringComparison.OrdinalIgnoreCase)))
yield return ToAssetPath(f);
}
else yield return p.Replace('\\', '/');
}
}
public static string ToAssetPath(string abs)
{
var root = AgentJob.ProjectRoot.Replace('\\', '/').TrimEnd('/') + "/";
abs = abs.Replace('\\', '/');
return abs.StartsWith(root) ? abs.Substring(root.Length) : abs;
}
public static Shader LoadShader(string nameOrPath)
{
if (string.IsNullOrEmpty(nameOrPath)) return null;
if (nameOrPath.StartsWith("Assets/") || nameOrPath.StartsWith("Packages/"))
{
var s = AssetDatabase.LoadAssetAtPath<Shader>(nameOrPath);
if (s != null) return s;
}
return Shader.Find(nameOrPath);
}
static object CallInternal(Type t, string method, params object[] args)
{
var mi = t.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)
.FirstOrDefault(m => m.Name == method && m.GetParameters().Length == args.Length);
if (mi == null) return null;
try { return mi.Invoke(null, args); } catch { return null; }
}
/// <summary>The numbers the shader Inspector shows ("Compile and show code" variants): internal
/// ShaderUtil.GetVariantCount through reflection; null if the member moved.</summary>
public static Dictionary<string, object> InspectorVariantCounts(Shader s)
{
var sceneOnly = CallInternal(typeof(ShaderUtil), "GetVariantCount", s, true);
var all = CallInternal(typeof(ShaderUtil), "GetVariantCount", s, false);
return new Dictionary<string, object>
{
{ "used_by_open_scenes", sceneOnly == null ? null : (object)Convert.ToInt64(sceneOnly) },
{ "all_keywords", all == null ? null : (object)Convert.ToInt64(all) },
};
}
public static Dictionary<string, object> SrpBatcher(Shader s, int subshader)
{
var code = CallInternal(typeof(ShaderUtil), "GetSRPBatcherCompatibilityCode", s, subshader);
if (code == null) return new Dictionary<string, object> { { "known", false } };
int c = Convert.ToInt32(code);
var reason = c == 0 ? "" : Convert.ToString(CallInternal(typeof(ShaderUtil), "GetSRPBatcherCompatibilityIssueReason", s, subshader, c));
return new Dictionary<string, object> { { "known", true }, { "compatible", c == 0 }, { "code", c }, { "reason", reason } };
}
static List<object> Messages(IEnumerable<ShaderMessage> msgs)
{
var l = new List<object>();
foreach (var m in msgs)
l.Add(new Dictionary<string, object>
{
{ "severity", m.severity.ToString() }, { "message", m.message }, { "file", m.file },
{ "line", m.line }, { "platform", m.platform.ToString() },
{ "details", string.IsNullOrEmpty(m.messageDetails) ? null : m.messageDetails.Substring(0, Math.Min(400, m.messageDetails.Length)) },
});
return l;
}
// ------------------------------------------------------------------ ValidateShaders
public static Dictionary<string, object> ValidateShader(Shader s, string path, ShaderCompilerPlatform platform, BuildTarget target, bool compile)
{
var msgs = ShaderUtil.GetShaderMessages(s);
int errors = msgs.Count(m => m.severity == ShaderCompilerMessageSeverity.Error);
int warnings = msgs.Count(m => m.severity == ShaderCompilerMessageSeverity.Warning);
var data = ShaderUtil.GetShaderData(s);
var subs = new List<object>();
int compileErrors = 0;
// A FallBack adds its subshaders to ShaderData: the active one is the first the GPU supports.
var activeObj = CallInternal(typeof(ShaderUtil), "GetShaderActiveSubshaderIndex", s);
int active = activeObj == null ? 0 : Convert.ToInt32(activeObj);
for (int si = 0; si < data.SubshaderCount; si++)
{
var sub = data.GetSubshader(si);
var passes = new List<object>();
for (int pi = 0; pi < sub.PassCount; pi++)
{
var pass = sub.GetPass(pi);
var pid = new PassIdentifier((uint)si, (uint)pi);
var kws = ShaderUtil.GetPassKeywords(s, pid).Select(k => k.name).OrderBy(n => n).ToList();
var passDict = new Dictionary<string, object>
{
{ "index", pi }, { "name", pass.Name },
{ "light_mode", si < s.subshaderCount ? s.FindPassTagValue(si, pi, new ShaderTagId("LightMode")).name : "(fallback)" },
{ "keywords", kws }, { "keyword_count", kws.Count },
};
if (compile)
{
var stages = new List<object>();
foreach (var st in new[] { UnityEditor.Rendering.ShaderType.Vertex, UnityEditor.Rendering.ShaderType.Fragment })
{
var info = pass.CompileVariant(st, new string[0], platform, target);
var errs = info.Messages.Where(m => m.severity == ShaderCompilerMessageSeverity.Error).ToList();
compileErrors += errs.Count;
stages.Add(new Dictionary<string, object>
{
{ "stage", st.ToString() }, { "success", info.Success },
{ "bytes", info.ShaderData == null ? 0 : info.ShaderData.Length },
{ "messages", Messages(info.Messages) },
});
}
passDict["default_variant_compile"] = stages;
}
passes.Add(passDict);
}
subs.Add(new Dictionary<string, object>
{
// with -nographics the active subshader is the FallBack's (no GPU): judge subshader 0, compiled for the platform arg
{ "index", si }, { "active", si == active }, { "own", si < s.subshaderCount }, { "passes", passes },
// "Not initialized" until the shader has rendered once with the pipeline: read it after a capture
// (ShaderLab.Shots reports it for every shader in the scene).
{ "srp_batcher", SrpBatcher(s, si) },
});
}
return new Dictionary<string, object>
{
{ "path", path }, { "name", s.name }, { "supported", s.isSupported },
{ "has_error", ShaderUtil.ShaderHasError(s) || errors > 0 || compileErrors > 0 },
{ "import_errors", errors }, { "import_warnings", warnings }, { "compile_errors", compileErrors },
{ "messages", Messages(msgs) }, { "subshaders", subs },
{ "keyword_space", s.keywordSpace.keywordNames.OrderBy(n => n).ToList() },
{ "inspector_variant_counts", InspectorVariantCounts(s) },
{ "render_queue", s.renderQueue },
};
}
public static void ValidateShaders()
{
AgentJob.Run(() =>
{
var platform = (ShaderCompilerPlatform)Enum.Parse(typeof(ShaderCompilerPlatform), AgentJob.Str("platform", "Metal"));
var target = (BuildTarget)Enum.Parse(typeof(BuildTarget), AgentJob.Str("build_target", "StandaloneOSX"));
bool compile = AgentJob.Bool("compile", true);
var paths = ExpandPaths(AgentJob.List("paths"), ".shader", ".compute", ".shadergraph").Distinct().ToList();
if (paths.Count == 0) throw new ArgumentException("paths is empty");
var shaders = new List<object>();
var computes = new List<object>();
int bad = 0;
foreach (var p in paths)
{
AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
if (p.EndsWith(".compute", StringComparison.OrdinalIgnoreCase))
{
var cs = AssetDatabase.LoadAssetAtPath<ComputeShader>(p);
if (cs == null) { bad++; computes.Add(new Dictionary<string, object> { { "path", p }, { "error", "not a ComputeShader" } }); continue; }
var msgs = ShaderUtil.GetComputeShaderMessages(cs);
int errs = msgs.Count(m => m.severity == ShaderCompilerMessageSeverity.Error);
if (errs > 0) bad++;
computes.Add(new Dictionary<string, object>
{
{ "path", p }, { "errors", errs }, { "messages", Messages(msgs) },
{ "keyword_space", cs.keywordSpace.keywordNames.ToList() },
});
continue;
}
var s = AssetDatabase.LoadAssetAtPath<Shader>(p);
if (s == null) { bad++; shaders.Add(new Dictionary<string, object> { { "path", p }, { "error", "no Shader at path (a .shadergraph that failed to import?)" } }); continue; }
var r = ValidateShader(s, p, platform, target, compile);
if ((bool)r["has_error"]) bad++;
shaders.Add(r);
}
var result = new Dictionary<string, object>
{
{ "platform", platform.ToString() }, { "shaders", shaders }, { "computes", computes }, { "bad", bad },
};
if (bad > 0) AgentJob.Fail(bad + " shader(s) with errors", result);
return result;
});
}
// ------------------------------------------------------------------ materials
public static Material CreateOrUpdateMaterial(Dictionary<string, object> spec)
{
var path = Convert.ToString(spec["path"]);
var shaderName = spec.TryGetValue("shader", out var sn) ? Convert.ToString(sn) : null;
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
var shader = LoadShader(shaderName);
if (mat == null)
{
if (shader == null) throw new ArgumentException("shader not found: " + shaderName);
Directory.CreateDirectory(Path.GetDirectoryName(AgentJob.ResolvePath(path)));
mat = new Material(shader) { name = Path.GetFileNameWithoutExtension(path) };
AssetDatabase.CreateAsset(mat, path);
}
else if (shader != null && mat.shader != shader) mat.shader = shader;
if (spec.TryGetValue("floats", out var f) && f is Dictionary<string, object> fd)
foreach (var kv in fd) mat.SetFloat(kv.Key, (float)AgentJson.ToDouble(kv.Value));
if (spec.TryGetValue("colors", out var c) && c is Dictionary<string, object> cd)
foreach (var kv in cd) mat.SetColor(kv.Key, AgentJson.ToColor(kv.Value, Color.white));
if (spec.TryGetValue("vectors", out var v) && v is Dictionary<string, object> vd)
foreach (var kv in vd) { var col = AgentJson.ToColor(kv.Value, Color.clear); mat.SetVector(kv.Key, new Vector4(col.r, col.g, col.b, col.a)); }
if (spec.TryGetValue("textures", out var t) && t is Dictionary<string, object> td)
foreach (var kv in td) mat.SetTexture(kv.Key, kv.Value == null ? null : AssetDatabase.LoadAssetAtPath<Texture>(Convert.ToString(kv.Value)));
if (spec.TryGetValue("keywords_on", out var on) && on is List<object> onl)
foreach (var k in onl) mat.EnableKeyword(Convert.ToString(k));
if (spec.TryGetValue("keywords_off", out var off) && off is List<object> offl)
foreach (var k in offl) mat.DisableKeyword(Convert.ToString(k));
if (spec.TryGetValue("render_queue", out var rq) && rq != null) mat.renderQueue = (int)AgentJson.ToDouble(rq, -1);
EditorUtility.SetDirty(mat);
return mat;
}
public static void SetupMaterials()
{
AgentJob.Run(() =>
{
var done = new List<object>();
AssetDatabase.StartAssetEditing();
try
{
foreach (var o in AgentJob.List("materials"))
{
var spec = o as Dictionary<string, object>;
if (spec == null) continue;
var m = CreateOrUpdateMaterial(spec);
done.Add(new Dictionary<string, object>
{
{ "path", AssetDatabase.GetAssetPath(m) }, { "shader", m.shader.name },
{ "keywords", m.shaderKeywords.ToList() }, { "render_queue", m.renderQueue },
{ "shader_supported", m.shader.isSupported },
});
}
}
finally { AssetDatabase.StopAssetEditing(); } // a missing Stop freezes the AssetDatabase
AssetDatabase.SaveAssets();
return new Dictionary<string, object> { { "materials", done } };
});
}
// ------------------------------------------------------------------ textures
/// <summary>ImportTextures args: textures[{path, type (Default|NormalMap|SingleChannel), srgb, wrap (Repeat|Clamp),
/// filter (Point|Bilinear|Trilinear), aniso, mips, max_size}]. Data textures (masks, noise, flow) want srgb false.</summary>
public static void ImportTextures()
{
AgentJob.Run(() =>
{
var done = new List<object>();
foreach (var o in AgentJob.List("textures").OfType<Dictionary<string, object>>())
{
var path = Convert.ToString(o["path"]);
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport);
var ti = AssetImporter.GetAtPath(path) as TextureImporter;
if (ti == null) throw new ArgumentException("no TextureImporter at " + path);
if (o.TryGetValue("type", out var t)) ti.textureType = (TextureImporterType)Enum.Parse(typeof(TextureImporterType), Convert.ToString(t));
if (o.TryGetValue("srgb", out var s)) ti.sRGBTexture = s is bool b ? b : AgentJson.ToDouble(s) > 0;
if (o.TryGetValue("wrap", out var w)) ti.wrapMode = (TextureWrapMode)Enum.Parse(typeof(TextureWrapMode), Convert.ToString(w));
if (o.TryGetValue("filter", out var f)) ti.filterMode = (FilterMode)Enum.Parse(typeof(FilterMode), Convert.ToString(f));
if (o.TryGetValue("aniso", out var a)) ti.anisoLevel = (int)AgentJson.ToDouble(a);
if (o.TryGetValue("mips", out var mp)) ti.mipmapEnabled = mp is bool mb ? mb : AgentJson.ToDouble(mp) > 0;
if (o.TryGetValue("max_size", out var ms)) ti.maxTextureSize = (int)AgentJson.ToDouble(ms);
ti.SaveAndReimport();
done.Add(new Dictionary<string, object>
{
{ "path", path }, { "type", ti.textureType.ToString() }, { "srgb", ti.sRGBTexture },
{ "wrap", ti.wrapMode.ToString() }, { "mips", ti.mipmapEnabled }, { "aniso", ti.anisoLevel },
});
}
return new Dictionary<string, object> { { "textures", done } };
});
}
// ------------------------------------------------------------------ URP settings
public static UniversalRenderPipelineAsset ActiveUrpAsset(string pathOrActive)
{
if (string.IsNullOrEmpty(pathOrActive) || pathOrActive == "active")
return (QualitySettings.renderPipeline ?? GraphicsSettings.defaultRenderPipeline) as UniversalRenderPipelineAsset;
return AssetDatabase.LoadAssetAtPath<UniversalRenderPipelineAsset>(pathOrActive);
}
public static UniversalRendererData RendererData(string path, UniversalRenderPipelineAsset asset)
{
if (!string.IsNullOrEmpty(path)) return AssetDatabase.LoadAssetAtPath<UniversalRendererData>(path);
if (asset == null) return null;
var list = asset.rendererDataList;
return list.Length > 0 ? list[0] as UniversalRendererData : null;
}
public static void ConfigureUrp()
{
AgentJob.Run(() =>
{
var asset = ActiveUrpAsset(AgentJob.Str("asset", "active"));
if (asset == null) throw new InvalidOperationException("no URP asset (Built-in pipeline project?)");
var changes = new List<string>();
if (AgentJob.Has("depth_texture")) { asset.supportsCameraDepthTexture = AgentJob.Bool("depth_texture"); changes.Add("depth_texture"); }
if (AgentJob.Has("opaque_texture")) { asset.supportsCameraOpaqueTexture = AgentJob.Bool("opaque_texture"); changes.Add("opaque_texture"); }
EditorUtility.SetDirty(asset);
var rd = RendererData(AgentJob.Str("renderer"), asset);
if (rd != null)
{
if (AgentJob.Has("rendering_mode")) { rd.renderingMode = (RenderingMode)Enum.Parse(typeof(RenderingMode), AgentJob.Str("rendering_mode")); changes.Add("rendering_mode"); }
if (AgentJob.Has("depth_priming")) { rd.depthPrimingMode = (DepthPrimingMode)Enum.Parse(typeof(DepthPrimingMode), AgentJob.Str("depth_priming")); changes.Add("depth_priming"); }
foreach (var kv in AgentJob.Dict("features_active"))
{
var feat = rd.rendererFeatures.FirstOrDefault(x => x != null && x.name == kv.Key);
if (feat == null) { AgentJob.Warn("renderer feature not found: " + kv.Key); continue; }
feat.SetActive(AgentJson.ToDouble(kv.Value) > 0 || (kv.Value is bool b && b));
EditorUtility.SetDirty(feat);
changes.Add("feature:" + kv.Key);
}
rd.SetDirty();
EditorUtility.SetDirty(rd);
}
if (AgentJob.Has("strict_variant_matching")) { PlayerSettings.strictShaderVariantMatching = AgentJob.Bool("strict_variant_matching"); changes.Add("strict_variant_matching"); }
if (AgentJob.Has("log_shader_compilation")) { GraphicsSettings.logWhenShaderIsCompiled = AgentJob.Bool("log_shader_compilation"); changes.Add("log_shader_compilation"); }
AssetDatabase.SaveAssets();
return new Dictionary<string, object>
{
{ "asset", AssetDatabase.GetAssetPath(asset) }, { "changes", changes },
{ "depth_texture", asset.supportsCameraDepthTexture }, { "opaque_texture", asset.supportsCameraOpaqueTexture },
{ "renderer", rd == null ? null : AssetDatabase.GetAssetPath(rd) },
{ "rendering_mode", rd == null ? null : rd.renderingMode.ToString() },
{ "depth_priming", rd == null ? null : rd.depthPrimingMode.ToString() },
{ "features", rd == null ? null : rd.rendererFeatures.Where(x => x != null).Select(x => (object)new Dictionary<string, object> { { "name", x.name }, { "type", x.GetType().Name }, { "active", x.isActive } }).ToList() },
{ "strict_variant_matching", PlayerSettings.strictShaderVariantMatching },
{ "log_shader_compilation", GraphicsSettings.logWhenShaderIsCompiled },
};
});
}
// ------------------------------------------------------------------ API probe
/// <summary>Does this API exist in THIS editor? args: names[] (member names, e.g. "SetShaderUserValue").
/// Lists every public or internal member with that name in UnityEngine/UnityEditor/Unity.* assemblies,
/// with its declaring type and signature. Cheaper than a compile error, which aborts every batch job.</summary>
public static void ApiProbe()
{
AgentJob.Run(() =>
{
var names = new HashSet<string>(AgentJob.List("names").Select(Convert.ToString));
var found = new Dictionary<string, object>();
foreach (var n in names) found[n] = new List<object>();
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var an = asm.GetName().Name;
if (!(an.StartsWith("UnityEngine") || an.StartsWith("UnityEditor") || an.StartsWith("Unity."))) continue;
Type[] types;
try { types = asm.GetTypes(); } catch (ReflectionTypeLoadException e) { types = e.Types.Where(t => t != null).ToArray(); }
foreach (var t in types)
foreach (var m in t.GetMembers(all))
if (names.Contains(m.Name))
((List<object>)found[m.Name]).Add(t.FullName + " :: " + m.ToString() +
(m is MethodBase mb ? (mb.IsPublic ? " [public]" : " [non-public]") : ""));
}
return found;
});
}
// ------------------------------------------------------------------ properties (Shader Graph too)
public static List<object> PropertyList(Shader s)
{
var props = new List<object>();
for (int i = 0; i < s.GetPropertyCount(); i++)
{
var type = s.GetPropertyType(i);
var d = new Dictionary<string, object>
{
{ "reference", s.GetPropertyName(i) }, { "display", s.GetPropertyDescription(i) },
{ "type", type.ToString() }, { "flags", s.GetPropertyFlags(i).ToString() },
{ "attributes", s.GetPropertyAttributes(i).ToList() },
};
if (type == ShaderPropertyType.Float || type == ShaderPropertyType.Range) d["default"] = s.GetPropertyDefaultFloatValue(i);
if (type == ShaderPropertyType.Range) d["range"] = s.GetPropertyRangeLimits(i);
if (type == ShaderPropertyType.Color || type == ShaderPropertyType.Vector) d["default"] = s.GetPropertyDefaultVectorValue(i);
if (type == ShaderPropertyType.Texture) d["default"] = s.GetPropertyTextureDefaultName(i);
props.Add(d);
}
return props;
}
public static void ShaderProperties()
{
AgentJob.Run(() =>
{
var key = AgentJob.Str("shader");
if (key != null && key.EndsWith(".shadergraph")) AssetDatabase.ImportAsset(key, ImportAssetOptions.ForceSynchronousImport);
var s = LoadShader(key);
if (s == null) throw new ArgumentException("shader not found: " + key);
return new Dictionary<string, object>
{
{ "name", s.name }, { "path", AssetDatabase.GetAssetPath(s) }, { "properties", PropertyList(s) },
{ "keywords", s.keywordSpace.keywordNames.ToList() }, { "pass_count", s.passCount },
};
});
}
}
}
scripts/AgentKit/Shaders/ShaderLab.cs
// AgentKit.Shaders v0.1 (scenario-unity-shaders skill, 2026-09-24). A shader test bench: build a lit scene of
// primitives with given materials, then render many "shots" in ONE editor boot, each shot changing
// material values, per-renderer values, renderer features or objects before it captures.
//
// Jobs:
// BuildScene args: scene (new path), base_scene (default Assets/Scenes/SampleScene.unity: camera, sun,
// Global Volume with Bloom + Tonemapping), ground {material, size, position}, objects[{name, mesh
// (Sphere|Cube|Capsule|Cylinder|Plane|Quad), material, position, rotation, scale, cast_shadows,
// receive_shadows}], sun {rotation, intensity, color, shadows (Hard|Soft|None)}, lights[{type
// (Point|Spot|Directional), name, position, rotation, color, intensity, range, spot_angle, shadows}],
// camera {position, look_at, fov}, bookmarks[{name, position, look_at, fov}]
// Shots args: scene, width, height, msaa, out_dir, views[{name, position, look_at, fov}] (default: bookmarks),
// probes[{name, world:[x,y,z]}] (viewport coordinates returned per view, for pixel sampling),
// shots[{name, set:[ops]}], where an op is one of
// {material, float|color|vector|keyword_on|keyword_off, value}
// {renderer: objectName, user_value: uint} (Renderer.SetShaderUserValue, 6.3)
// {renderer_data: path, feature: name, active: bool} (ScriptableRendererFeature.SetActive)
// {object: name, active: bool}
// {urp: {depth_priming|rendering_mode|depth_texture: value}} (applied then captured)
// Material edits are reverted at the end of the job unless keep_changes is true.
// Capture goes through AgentKit.AgentCapture (warm-up frame included: the first frame after a scene load
// renders materials white, observed 2026-09-24).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
namespace AgentKit.Shaders
{
public static class ShaderLab
{
static PrimitiveType Prim(string mesh)
{
switch ((mesh ?? "Sphere").ToLowerInvariant())
{
case "cube": return PrimitiveType.Cube;
case "capsule": return PrimitiveType.Capsule;
case "cylinder": return PrimitiveType.Cylinder;
case "plane": return PrimitiveType.Plane;
case "quad": return PrimitiveType.Quad;
default: return PrimitiveType.Sphere;
}
}
static T Get<T>(Dictionary<string, object> d, string k, T def)
{
if (d == null || !d.TryGetValue(k, out var v) || v == null) return def;
if (typeof(T) == typeof(float)) return (T)(object)(float)AgentJson.ToDouble(v);
if (typeof(T) == typeof(bool)) return (T)(object)(v is bool b ? b : AgentJson.ToDouble(v) > 0);
if (typeof(T) == typeof(string)) return (T)(object)Convert.ToString(v);
if (typeof(T) == typeof(Vector3)) return (T)(object)AgentJson.ToVector3(v, (Vector3)(object)def);
if (typeof(T) == typeof(Color)) return (T)(object)AgentJson.ToColor(v, (Color)(object)def);
return def;
}
static GameObject GetOrCreatePrimitive(string name, PrimitiveType type)
{
var go = GameObject.Find(name);
if (go != null) return go;
go = GameObject.CreatePrimitive(type);
go.name = name;
return go;
}
/// <summary>A flat N x N quad grid of `size` metres centred on the origin (y up), saved as a mesh asset:
/// water and vertex displacement need vertices (Unity's Plane is only 10 x 10 quads).</summary>
public static Mesh GridMesh(int n, float size)
{
var path = "Assets/AgentShaders/Meshes/Grid_" + n + "_" + size.ToString(System.Globalization.CultureInfo.InvariantCulture) + "m.asset";
var existing = AssetDatabase.LoadAssetAtPath<Mesh>(path);
if (existing != null) return existing;
var verts = new Vector3[(n + 1) * (n + 1)];
var uvs = new Vector2[verts.Length];
var normals = new Vector3[verts.Length];
for (int z = 0; z <= n; z++)
for (int x = 0; x <= n; x++)
{
int i = z * (n + 1) + x;
verts[i] = new Vector3((x / (float)n - 0.5f) * size, 0, (z / (float)n - 0.5f) * size);
uvs[i] = new Vector2(x / (float)n, z / (float)n);
normals[i] = Vector3.up;
}
var tris = new int[n * n * 6];
int t = 0;
for (int z = 0; z < n; z++)
for (int x = 0; x < n; x++)
{
int i = z * (n + 1) + x;
tris[t++] = i; tris[t++] = i + n + 1; tris[t++] = i + 1;
tris[t++] = i + 1; tris[t++] = i + n + 1; tris[t++] = i + n + 2;
}
var mesh = new Mesh { name = "Grid_" + n, indexFormat = verts.Length > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16 };
mesh.vertices = verts; mesh.uv = uvs; mesh.normals = normals; mesh.triangles = tris;
mesh.RecalculateTangents();
mesh.RecalculateBounds();
Directory.CreateDirectory(Path.GetDirectoryName(AgentJob.ResolvePath(path)));
AssetDatabase.CreateAsset(mesh, path);
return mesh;
}
static GameObject GetOrCreateObject(string name, string mesh)
{
var go = GameObject.Find(name);
if (go != null) return go;
if (mesh != null && mesh.StartsWith("Grid", StringComparison.OrdinalIgnoreCase))
{
// "Grid:128:20" = 128 x 128 quads over 20 m
var parts = mesh.Split(':');
int n = parts.Length > 1 ? int.Parse(parts[1]) : 128;
float size = parts.Length > 2 ? float.Parse(parts[2], System.Globalization.CultureInfo.InvariantCulture) : 20f;
go = new GameObject(name, typeof(MeshFilter), typeof(MeshRenderer));
go.GetComponent<MeshFilter>().sharedMesh = GridMesh(n, size);
return go;
}
go = GameObject.CreatePrimitive(Prim(mesh));
go.name = name;
return go;
}
static void ApplyObject(GameObject go, Dictionary<string, object> o)
{
go.transform.position = Get(o, "position", go.transform.position);
go.transform.eulerAngles = Get(o, "rotation", go.transform.eulerAngles);
go.transform.localScale = Get(o, "scale", go.transform.localScale);
var r = go.GetComponent<Renderer>();
var matPath = Get<string>(o, "material", null);
if (r != null && matPath != null)
{
var m = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (m == null) throw new ArgumentException("material not found: " + matPath);
r.sharedMaterial = m;
}
if (r != null)
{
r.shadowCastingMode = Get(o, "cast_shadows", true) ? ShadowCastingMode.On : ShadowCastingMode.Off;
r.receiveShadows = Get(o, "receive_shadows", true);
}
}
public static void BuildScene()
{
AgentJob.Run(() =>
{
var scenePath = AgentJob.Str("scene");
if (string.IsNullOrEmpty(scenePath)) throw new ArgumentException("scene is required");
var basePath = AgentJob.Str("base_scene", "Assets/Scenes/SampleScene.unity");
var scene = EditorSceneManager.OpenScene(basePath, OpenSceneMode.Single);
Directory.CreateDirectory(Path.GetDirectoryName(AgentJob.ResolvePath(scenePath)));
EditorSceneManager.SaveScene(scene, scenePath); // work on the copy from now on
scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
var made = new List<object>();
var ground = AgentJob.Dict("ground");
if (ground.Count > 0)
{
var g = GetOrCreatePrimitive("AgentLab_Ground", PrimitiveType.Plane);
float size = Get(ground, "size", 20f);
g.transform.localScale = new Vector3(size / 10f, 1, size / 10f);
g.transform.position = Get(ground, "position", Vector3.zero);
ApplyObject(g, new Dictionary<string, object> { { "material", Get<string>(ground, "material", null) } }.Where(kv => kv.Value != null).ToDictionary(kv => kv.Key, kv => kv.Value));
made.Add(g.name);
}
foreach (var o in AgentJob.List("objects").OfType<Dictionary<string, object>>())
{
var name = Get(o, "name", "AgentLab_Object" + made.Count);
var go = GetOrCreateObject(name, Get<string>(o, "mesh", "Sphere"));
ApplyObject(go, o);
made.Add(name);
}
var sunSpec = AgentJob.Dict("sun");
var sun = UnityEngine.Object.FindObjectsByType<Light>(FindObjectsSortMode.None).FirstOrDefault(l => l.type == LightType.Directional);
if (sunSpec.Count > 0 && sun != null)
{
sun.transform.eulerAngles = Get(sunSpec, "rotation", sun.transform.eulerAngles);
sun.intensity = Get(sunSpec, "intensity", sun.intensity);
sun.color = Get(sunSpec, "color", sun.color);
var sh = Get(sunSpec, "shadows", "Soft");
sun.shadows = sh == "None" ? LightShadows.None : sh == "Hard" ? LightShadows.Hard : LightShadows.Soft;
}
foreach (var l in AgentJob.List("lights").OfType<Dictionary<string, object>>())
{
var name = Get(l, "name", "AgentLab_Light" + made.Count);
var go = GameObject.Find(name) ?? new GameObject(name, typeof(Light));
var light = go.GetComponent<Light>();
light.type = (LightType)Enum.Parse(typeof(LightType), Get(l, "type", "Point"));
go.transform.position = Get(l, "position", go.transform.position);
go.transform.eulerAngles = Get(l, "rotation", go.transform.eulerAngles);
light.color = Get(l, "color", Color.white);
light.intensity = Get(l, "intensity", 1f);
light.range = Get(l, "range", 10f);
light.spotAngle = Get(l, "spot_angle", 45f);
light.shadows = Get(l, "shadows", false) ? LightShadows.Soft : LightShadows.None;
made.Add(name);
}
var camSpec = AgentJob.Dict("camera");
var cam = Camera.main;
if (camSpec.Count > 0 && cam != null)
{
cam.transform.position = Get(camSpec, "position", cam.transform.position);
if (camSpec.ContainsKey("look_at"))
cam.transform.rotation = Quaternion.LookRotation(Get(camSpec, "look_at", Vector3.zero) - cam.transform.position, Vector3.up);
cam.fieldOfView = Get(camSpec, "fov", cam.fieldOfView);
}
foreach (var b in AgentJob.List("bookmarks").OfType<Dictionary<string, object>>())
AgentCapture.SaveBookmark(Get(b, "name", "view"), Get(b, "position", Vector3.zero), Get(b, "look_at", Vector3.zero), Get(b, "fov", 50f));
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
return new Dictionary<string, object>
{
{ "scene", scene.path }, { "objects", made }, { "bookmarks", AgentCapture.SceneBookmarks().Count },
{ "camera", cam ? cam.name : null }, { "sun", sun ? sun.name : null },
};
});
}
// ------------------------------------------------------------------ shots
class MatBackup { public Material m; public Material copy; }
static Material LoadMat(string path)
{
var m = AssetDatabase.LoadAssetAtPath<Material>(path);
if (m == null) throw new ArgumentException("material not found: " + path);
return m;
}
/// <summary>6.3 per-renderer shader value (unity_RendererUserValue in UnityPerDraw). Found by reflection
/// so this file compiles on editors where the member lives elsewhere.</summary>
public static string SetUserValue(Renderer r, uint value)
{
var mi = r.GetType().GetMethod("SetShaderUserValue", new[] { typeof(uint) });
if (mi == null) throw new MissingMethodException("SetShaderUserValue(uint) not found on " + r.GetType().Name + " (run ShaderJobs.ApiProbe)");
mi.Invoke(r, new object[] { value });
return mi.DeclaringType.FullName;
}
static void ApplyOp(Dictionary<string, object> op, Dictionary<string, MatBackup> backups, List<Action> undo)
{
if (op.ContainsKey("material"))
{
var path = Convert.ToString(op["material"]);
var m = LoadMat(path);
if (!backups.ContainsKey(path)) backups[path] = new MatBackup { m = m, copy = new Material(m) };
if (op.ContainsKey("float")) m.SetFloat(Convert.ToString(op["float"]), (float)AgentJson.ToDouble(op["value"]));
if (op.ContainsKey("color")) m.SetColor(Convert.ToString(op["color"]), AgentJson.ToColor(op["value"], Color.white));
if (op.ContainsKey("vector")) { var c = AgentJson.ToColor(op["value"], Color.clear); m.SetVector(Convert.ToString(op["vector"]), new Vector4(c.r, c.g, c.b, c.a)); }
if (op.ContainsKey("keyword_on")) m.EnableKeyword(Convert.ToString(op["keyword_on"]));
if (op.ContainsKey("keyword_off")) m.DisableKeyword(Convert.ToString(op["keyword_off"]));
return;
}
if (op.ContainsKey("renderer"))
{
var go = GameObject.Find(Convert.ToString(op["renderer"]));
var r = go ? go.GetComponent<Renderer>() : null;
if (r == null) throw new ArgumentException("renderer not found: " + op["renderer"]);
SetUserValue(r, (uint)AgentJson.ToDouble(op["user_value"]));
undo.Add(() => SetUserValue(r, 0)); // runtime-only value: reset at the end of the job
return;
}
if (op.ContainsKey("feature"))
{
var rd = AssetDatabase.LoadAssetAtPath<ScriptableRendererData>(Convert.ToString(op["renderer_data"]));
var f = rd ? rd.rendererFeatures.FirstOrDefault(x => x != null && x.name == Convert.ToString(op["feature"])) : null;
if (f == null) throw new ArgumentException("feature not found: " + op["feature"]);
bool was = f.isActive;
if (op.ContainsKey("active")) f.SetActive(op["active"] is bool b ? b : AgentJson.ToDouble(op["active"]) > 0);
if (op.TryGetValue("fields", out var fo) && fo is Dictionary<string, object> fields)
{
// remember the old values, set the new ones, rebuild the pass (what an Inspector edit does)
var old = new Dictionary<string, object>();
foreach (var k in fields.Keys)
{
var fi = f.GetType().GetField(k, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic);
if (fi != null) old[k] = fi.GetValue(f);
}
RendererFeatureJobs.SetFields(f, fields, new List<string>());
f.Create();
undo.Add(() =>
{
foreach (var kv in old) f.GetType().GetField(kv.Key, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic).SetValue(f, kv.Value);
f.Create();
});
}
rd.SetDirty();
undo.Add(() => { f.SetActive(was); rd.SetDirty(); });
return;
}
if (op.ContainsKey("object"))
{
var name = Convert.ToString(op["object"]);
var go = Resources.FindObjectsOfTypeAll<GameObject>().FirstOrDefault(g => g.name == name && g.scene.IsValid());
if (go == null) throw new ArgumentException("object not found: " + name);
go.SetActive(op["active"] is bool b ? b : AgentJson.ToDouble(op["active"]) > 0);
return;
}
if (op.ContainsKey("urp"))
{
var d = op["urp"] as Dictionary<string, object>;
var asset = ShaderJobs.ActiveUrpAsset("active");
var rd = ShaderJobs.RendererData(null, asset) ;
if (d == null || asset == null) return;
if (d.ContainsKey("depth_priming") && rd != null)
{
var was = rd.depthPrimingMode;
rd.depthPrimingMode = (DepthPrimingMode)Enum.Parse(typeof(DepthPrimingMode), Convert.ToString(d["depth_priming"]));
undo.Add(() => rd.depthPrimingMode = was);
}
if (d.ContainsKey("rendering_mode") && rd != null)
{
var was = rd.renderingMode;
rd.renderingMode = (RenderingMode)Enum.Parse(typeof(RenderingMode), Convert.ToString(d["rendering_mode"]));
undo.Add(() => rd.renderingMode = was);
}
if (d.ContainsKey("depth_texture"))
{
var was = asset.supportsCameraDepthTexture;
asset.supportsCameraDepthTexture = d["depth_texture"] is bool b ? b : AgentJson.ToDouble(d["depth_texture"]) > 0;
undo.Add(() => asset.supportsCameraDepthTexture = was);
}
return;
}
throw new ArgumentException("unknown op: " + AgentJson.Serialize(op));
}
public static Dictionary<string, object> ProjectProbes(Camera template, Dictionary<string, object> view, int w, int h, List<Dictionary<string, object>> probes)
{
var res = new Dictionary<string, object>();
if (probes.Count == 0) return res;
var go = new GameObject("AgentProbeCam", typeof(Camera)) { hideFlags = HideFlags.HideAndDontSave };
try
{
var cam = go.GetComponent<Camera>();
if (template) { cam.nearClipPlane = template.nearClipPlane; cam.farClipPlane = template.farClipPlane; cam.fieldOfView = template.fieldOfView; }
go.transform.position = AgentJson.ToVector3(view.TryGetValue("position", out var p) ? p : null, template ? template.transform.position : Vector3.zero);
if (view.TryGetValue("look_at", out var la) && la != null)
go.transform.rotation = Quaternion.LookRotation(AgentJson.ToVector3(la, Vector3.zero) - go.transform.position, Vector3.up);
else if (view.TryGetValue("rotation", out var r) && r != null) go.transform.rotation = Quaternion.Euler(AgentJson.ToVector3(r, Vector3.zero));
else if (template) go.transform.rotation = template.transform.rotation;
if (view.TryGetValue("fov", out var fov) && fov != null) cam.fieldOfView = (float)AgentJson.ToDouble(fov, 60);
cam.aspect = (float)w / h;
foreach (var pr in probes)
{
var vp = cam.WorldToViewportPoint(AgentJson.ToVector3(pr["world"], Vector3.zero));
// PNG rows run top to bottom: pixel y = (1 - viewport y) * height
res[Convert.ToString(pr["name"])] = new List<object> { (double)(vp.x * w), (double)((1 - vp.y) * h), (double)vp.z };
}
}
finally { UnityEngine.Object.DestroyImmediate(go); }
return res;
}
public static void Shots()
{
AgentJob.Run(() =>
{
AgentCapture.RequireGraphics();
var scene = AgentJob.Str("scene");
if (!string.IsNullOrEmpty(scene)) EditorSceneManager.OpenScene(scene, OpenSceneMode.Single);
int w = AgentJob.Int("width", 960), h = AgentJob.Int("height", 540), msaa = AgentJob.Int("msaa", 4);
var outDir = AgentJob.Has("out_dir") ? AgentJob.ResolvePath(AgentJob.Str("out_dir")) : AgentJob.OutDir("shots");
Directory.CreateDirectory(outDir);
var template = Camera.main;
var views = AgentJob.List("views").OfType<Dictionary<string, object>>().ToList();
if (views.Count == 0) views = AgentCapture.SceneBookmarks();
if (views.Count == 0) throw new InvalidOperationException("no views and no AgentView_ bookmarks in the scene");
var probes = AgentJob.List("probes").OfType<Dictionary<string, object>>().ToList();
var shots = AgentJob.List("shots").OfType<Dictionary<string, object>>().ToList();
if (shots.Count == 0) shots.Add(new Dictionary<string, object> { { "name", "base" } });
var backups = new Dictionary<string, MatBackup>();
var undo = new List<Action>();
var results = new List<object>();
try
{
foreach (var shot in shots)
{
var shotName = Get(shot, "name", "shot" + results.Count);
if (shot.TryGetValue("set", out var setObj) && setObj is List<object> ops)
foreach (var op in ops.OfType<Dictionary<string, object>>()) ApplyOp(op, backups, undo);
foreach (var v in views)
{
var vname = Get(v, "name", "view");
var path = Path.Combine(outDir, shotName + "__" + vname + ".png");
var info = AgentCapture.CaptureView(template, v, w, h, path, msaa);
info["shot"] = shotName;
info["view"] = vname;
info["probes"] = ProjectProbes(template, v, w, h, probes);
results.Add(info);
}
}
}
finally
{
if (!AgentJob.Bool("keep_changes"))
{
foreach (var b in backups.Values) { b.m.CopyPropertiesFromMaterial(b.copy); b.m.shaderKeywords = b.copy.shaderKeywords; }
for (int i = undo.Count - 1; i >= 0; i--) undo[i]();
}
else foreach (var b in backups.Values) EditorUtility.SetDirty(b.m);
}
// SRP Batcher compatibility is only known once a shader has rendered with the pipeline: read it now.
var srp = new Dictionary<string, object>();
foreach (var r in UnityEngine.Object.FindObjectsByType<Renderer>(FindObjectsInactive.Include, FindObjectsSortMode.None))
foreach (var m in r.sharedMaterials)
if (m != null && m.shader != null && !srp.ContainsKey(m.shader.name))
{
var activeObj = typeof(ShaderUtil).GetMethod("GetShaderActiveSubshaderIndex", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)?.Invoke(null, new object[] { m.shader });
srp[m.shader.name] = ShaderJobs.SrpBatcher(m.shader, activeObj == null ? 0 : Convert.ToInt32(activeObj));
}
return new Dictionary<string, object>
{
{ "scene", EditorSceneManager.GetActiveScene().path }, { "out_dir", outDir },
{ "graphics", SystemInfo.graphicsDeviceType.ToString() }, { "shots", results }, { "srp_batcher", srp },
};
});
}
}
}
scripts/AgentKit/Shaders/VariantJobs.cs
// AgentKit.Shaders v0.1 (scenario-unity-shaders skill, 2026-09-24). Shader variant counting and stripping that an
// agent can run headless, without a full player build:
// - AgentVariantLogger: IPreprocessShaders + IPreprocessComputeShaders with callbackOrder int.MaxValue,
// so it runs after every stripper and records exactly the variants that ship (shader, pass, stage,
// keywords). Active only while a job points it at a log file.
// - AgentStripRules: an IPreprocessShaders stripper (callbackOrder 0) driven by
// ProjectSettings/AgentShaderStripRules.json: [{"shader": "AgentKit/Lit", "keyword": "_ADDITIONAL_LIGHTS_VERTEX",
// "skip_development": true}]. Iterates backwards and RemoveAt (Carotenuto, Unity blog 2024).
// - BuildShaderBundle job: builds an AssetBundle holding the given materials or shaders for a target.
// IPreprocessShaders runs for AssetBundle builds too, and the job log gets Unity's own
// "Compiling shader" funnel (Full variant space, After settings filtering, After built-in stripping,
// After scriptable stripping) that ut_shaders.parse_variant_funnel reads.
// args: assets[] (Assets/ paths), target (StandaloneOSX), out_dir, bundle ("agent_shaders"),
// strip_rules[] (optional, written for this build then restored), development (bool)
// - StrippingSettings job: Strict Shader Variant Matching, Log Shader Compilation, URP and Core stripping
// settings (Strip Unused Variants, post-processing, screen coord override, export, log level).
// - KeywordOverrides job (v0.2): Unity 6 Shader Build Settings through the public API
// (EditorGraphicsSettings.Get/SetShaderBuildSettings): per keyword set a Type Override
// (Default, AllVariants = multi_compile, MaterialUsageBasedVariants = shader_feature,
// SingleVariantWithDynamicBranching = dynamic_branch) and keywords excluded from the build.
// args: overrides[] {keywords: ["_", "_SCREEN_SPACE_OCCLUSION"], mode, exclude: [..]}, clear (bool),
// shaders[] (names or paths to reimport and report keywordSpace isDynamic).
// The setter writes the ACTIVE settings (the active build profile's when it overrides Graphics settings).
// - PostProcessingAudit job (v0.2): every VolumeProfile under Assets/ (what URP 17.3 reads to keep
// post-processing variants, ShaderBuildPreprocessor.GetSupportedFeaturesFromVolumes: `Has<T>()` on
// each profile, scenes are not consulted), whether a build scene or the Default Volume Profile uses it,
// and the post families it keeps. args: create_profile {path, overrides: {Bloom: {highQualityFiltering:
// true, dirtIntensity: 1, dirtTexture: "Assets/..."}, FilmGrain: {}}} (optional, for tests and fixes).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_variants.py.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Rendering;
using UnityEditor.SceneManagement;
using System.Reflection;
using SBS = UnityEditor.Shaders.ShaderBuildSettings;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
namespace AgentKit.Shaders
{
public class AgentVariantLogger : IPreprocessShaders, IPreprocessComputeShaders
{
public static string LogPath; // set by BuildShaderBundle; null = inactive
public int callbackOrder => int.MaxValue; // after every stripper: what ships
static void Append(string line)
{
if (string.IsNullOrEmpty(LogPath)) return;
File.AppendAllText(LogPath, line + "\n");
}
static string Keywords(ShaderCompilerData d)
{
var names = d.shaderKeywordSet.GetShaderKeywords().Select(k => k.name).OrderBy(n => n);
return string.Join(" ", names);
}
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
{
if (string.IsNullOrEmpty(LogPath)) return;
var sb = new StringBuilder();
foreach (var d in data)
sb.Append("S\t").Append(shader.name).Append('\t').Append(snippet.passName).Append('\t')
.Append(snippet.passType).Append('\t').Append(snippet.shaderType).Append('\t')
.Append(d.shaderCompilerPlatform).Append('\t').Append(Keywords(d)).Append('\n');
if (data.Count == 0)
sb.Append("S0\t").Append(shader.name).Append('\t').Append(snippet.passName).Append('\t')
.Append(snippet.passType).Append('\t').Append(snippet.shaderType).Append("\t\t\n");
File.AppendAllText(LogPath, sb.ToString());
}
public void OnProcessComputeShader(ComputeShader shader, string kernelName, IList<ShaderCompilerData> data)
{
if (string.IsNullOrEmpty(LogPath)) return;
foreach (var d in data)
Append("C\t" + shader.name + "\t" + kernelName + "\tCompute\tCompute\t" + d.shaderCompilerPlatform + "\t" + Keywords(d));
}
}
[Serializable] class StripRule { public string shader; public string keyword; public bool skip_development; }
[Serializable] class StripRuleList { public StripRule[] rules; }
public class AgentStripRules : IPreprocessShaders
{
public const string RulesPath = "ProjectSettings/AgentShaderStripRules.json";
public static int Removed;
public int callbackOrder => 0;
static List<StripRule> Load()
{
var path = Path.Combine(Directory.GetParent(Application.dataPath).FullName, RulesPath);
if (!File.Exists(path)) return new List<StripRule>();
var json = File.ReadAllText(path).Trim();
if (json.StartsWith("[")) json = "{\"rules\":" + json + "}";
var list = JsonUtility.FromJson<StripRuleList>(json);
return list?.rules?.ToList() ?? new List<StripRule>();
}
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
{
var rules = Load().Where(r => r.shader == shader.name || r.shader == "*").ToList();
if (rules.Count == 0) return;
bool dev = EditorUserBuildSettings.development;
for (int i = data.Count - 1; i >= 0; i--) // backwards: RemoveAt keeps indices valid
{
var kws = data[i].shaderKeywordSet.GetShaderKeywords().Select(k => k.name).ToList();
foreach (var r in rules)
{
if (r.skip_development && dev) continue;
if (kws.Contains(r.keyword)) { data.RemoveAt(i); Removed++; break; }
}
}
}
}
public static class VariantJobs
{
public static void BuildShaderBundle()
{
AgentJob.Run(() =>
{
var assets = AgentJob.List("assets").Select(Convert.ToString).ToArray();
if (assets.Length == 0) throw new ArgumentException("assets is empty");
foreach (var a in assets)
if (AssetDatabase.LoadMainAssetAtPath(a) == null) throw new ArgumentException("asset not found: " + a);
var target = (BuildTarget)Enum.Parse(typeof(BuildTarget), AgentJob.Str("target", "StandaloneOSX"));
var outDir = AgentJob.Has("out_dir") ? AgentJob.ResolvePath(AgentJob.Str("out_dir")) : AgentJob.OutDir("bundles");
Directory.CreateDirectory(outDir);
var logPath = Path.Combine(AgentJob.OutDir(), "variants.tsv");
File.WriteAllText(logPath, "");
// optional strip rules for this build only
var rulesAbs = Path.Combine(AgentJob.ProjectRoot, AgentStripRules.RulesPath);
string previousRules = File.Exists(rulesAbs) ? File.ReadAllText(rulesAbs) : null;
bool wroteRules = false;
if (AgentJob.Has("strip_rules"))
{
File.WriteAllText(rulesAbs, AgentJson.Serialize(AgentJob.List("strip_rules")));
wroteRules = true;
}
AgentStripRules.Removed = 0;
AgentVariantLogger.LogPath = logPath;
var sw = System.Diagnostics.Stopwatch.StartNew();
AssetBundleManifest manifest;
try
{
var build = new AssetBundleBuild { assetBundleName = AgentJob.Str("bundle", "agent_shaders"), assetNames = assets };
var opts = BuildAssetBundleOptions.ForceRebuildAssetBundle | BuildAssetBundleOptions.StrictMode;
manifest = BuildPipeline.BuildAssetBundles(outDir, new[] { build }, opts, target);
}
finally
{
AgentVariantLogger.LogPath = null;
if (wroteRules)
{
if (previousRules == null) File.Move(rulesAbs, rulesAbs + ".used-" + DateTime.Now.ToString("yyyyMMdd-HHmmss"));
else File.WriteAllText(rulesAbs, previousRules);
}
}
sw.Stop();
if (manifest == null) throw new InvalidOperationException("BuildAssetBundles returned null (see log)");
// summarise: shader -> pass -> stage -> variant count, plus keyword frequency
var perShader = new Dictionary<string, Dictionary<string, object>>();
int total = 0;
foreach (var line in File.ReadAllLines(logPath))
{
var c = line.Split('\t');
if (c.Length < 6) continue;
var kind = c[0];
var shader = c[1];
var key = c[2] + " | " + c[4]; // pass | stage
if (!perShader.TryGetValue(shader, out var s))
perShader[shader] = s = new Dictionary<string, object> { { "total", 0 }, { "passes", new Dictionary<string, object>() }, { "keyword_frequency", new Dictionary<string, object>() } };
var passes = (Dictionary<string, object>)s["passes"];
if (!passes.ContainsKey(key)) passes[key] = 0;
if (kind == "S0") continue;
passes[key] = (int)passes[key] + 1;
s["total"] = (int)s["total"] + 1;
total++;
var freq = (Dictionary<string, object>)s["keyword_frequency"];
if (c.Length > 6)
foreach (var k in c[6].Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
freq[k] = (freq.TryGetValue(k, out var n) ? (int)n : 0) + 1;
}
return new Dictionary<string, object>
{
{ "target", target.ToString() }, { "bundle_dir", outDir }, { "seconds", Math.Round(sw.Elapsed.TotalSeconds, 1) },
{ "variants_log", logPath }, { "variants_total", total }, { "stripped_by_rules", AgentStripRules.Removed },
{ "shaders", perShader.ToDictionary(kv => kv.Key, kv => (object)kv.Value) },
{ "bundles", manifest.GetAllAssetBundles().ToList() },
};
});
}
public static Dictionary<string, object> Settings()
{
var d = new Dictionary<string, object>
{
{ "strict_shader_variant_matching", PlayerSettings.strictShaderVariantMatching },
{ "log_shader_compilation", GraphicsSettings.logWhenShaderIsCompiled },
};
if (GraphicsSettings.TryGetRenderPipelineSettings<ShaderStrippingSetting>(out var core))
{
d["export_shader_variants"] = core.exportShaderVariants;
d["shader_variant_log_level"] = core.shaderVariantLogLevel.ToString();
d["strip_runtime_debug_shaders"] = core.stripRuntimeDebugShaders;
}
if (GraphicsSettings.TryGetRenderPipelineSettings<URPShaderStrippingSetting>(out var urp))
{
d["strip_unused_variants"] = urp.stripUnusedVariants;
d["strip_unused_post_processing_variants"] = urp.stripUnusedPostProcessingVariants;
d["strip_screen_coord_override_variants"] = urp.stripScreenCoordOverrideVariants;
}
return d;
}
public static void StrippingSettings()
{
AgentJob.Run(() =>
{
if (AgentJob.Has("strict_variant_matching")) PlayerSettings.strictShaderVariantMatching = AgentJob.Bool("strict_variant_matching");
if (AgentJob.Has("log_shader_compilation")) GraphicsSettings.logWhenShaderIsCompiled = AgentJob.Bool("log_shader_compilation");
AssetDatabase.SaveAssets();
return Settings();
});
}
// ------------------------------------------------------------------ Shader Build Settings (Unity 6)
static List<object> DescribeOverrides(SBS s)
{
var arr = s.GetKeywordDeclarationOverridesCopy() ?? new SBS.KeywordDeclarationOverride[0];
return arr.Select(o => (object)new Dictionary<string, object>
{
{ "keywords", (o.keywords ?? new SBS.KeywordOverrideInfo[0]).Select(k => (object)k.name).ToList() },
{ "excluded", (o.keywords ?? new SBS.KeywordOverrideInfo[0]).Where(k => !k.keepInBuild).Select(k => (object)k.name).ToList() },
{ "mode", o.variantGenerationMode.ToString() },
}).ToList();
}
static Shader FindShader(string nameOrPath)
{
var s = nameOrPath.Contains("/") && (nameOrPath.EndsWith(".shader") || nameOrPath.EndsWith(".shadergraph"))
? AssetDatabase.LoadAssetAtPath<Shader>(nameOrPath) : Shader.Find(nameOrPath);
if (s == null) throw new ArgumentException("shader not found: " + nameOrPath);
return s;
}
public static void KeywordOverrides()
{
AgentJob.Run(() =>
{
var s = EditorGraphicsSettings.GetShaderBuildSettings();
var before = DescribeOverrides(s);
bool write = AgentJob.Has("overrides") || AgentJob.Bool("clear");
if (write)
{
var list = new List<SBS.KeywordDeclarationOverride>();
if (!AgentJob.Bool("clear"))
foreach (var o in AgentJob.List("overrides"))
{
var d = (Dictionary<string, object>)o;
var kws = ((List<object>)d["keywords"]).Select(Convert.ToString).ToList();
var excl = d.ContainsKey("exclude") && d["exclude"] != null
? ((List<object>)d["exclude"]).Select(Convert.ToString).ToList() : new List<string>();
var ov = new SBS.KeywordDeclarationOverride();
ov.keywords = kws.Select(k => new SBS.KeywordOverrideInfo(k, !excl.Contains(k))).ToArray();
ov.variantGenerationMode = (SBS.ShaderVariantGenerationMode)Enum.Parse(typeof(SBS.ShaderVariantGenerationMode),
d.ContainsKey("mode") ? Convert.ToString(d["mode"]) : "Default");
list.Add(ov);
}
var arr = list.ToArray();
foreach (var ov in arr)
if (!ov.IsValid(out var error))
throw new ArgumentException("Shader Build Settings rejected an override: " + error);
// KeywordDeclarationOverrides is a property; set it through reflection so the job compiles whether
// ShaderBuildSettings is a class or a struct in this editor.
object boxed = s;
var prop = typeof(SBS).GetProperty("KeywordDeclarationOverrides", BindingFlags.Public | BindingFlags.Instance);
if (prop != null && prop.CanWrite) prop.SetValue(boxed, arr);
else
{
var field = typeof(SBS).GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
.FirstOrDefault(f => f.FieldType == typeof(SBS.KeywordDeclarationOverride[]));
if (field == null) throw new MissingMemberException("no writable KeywordDeclarationOverrides in this editor");
field.SetValue(boxed, arr);
}
EditorGraphicsSettings.SetShaderBuildSettings((SBS)boxed);
AssetDatabase.SaveAssets();
}
var after = DescribeOverrides(EditorGraphicsSettings.GetShaderBuildSettings());
var watch = new HashSet<string>(after.Concat(before).SelectMany(o => ((List<object>)((Dictionary<string, object>)o)["keywords"]).Select(Convert.ToString)));
var shaders = new List<object>();
foreach (var n in AgentJob.List("shaders").Select(Convert.ToString))
{
var sh = FindShader(n);
var path = AssetDatabase.GetAssetPath(sh);
if (write && path.StartsWith("Assets")) AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
sh = FindShader(n);
shaders.Add(new Dictionary<string, object>
{
{ "shader", sh.name }, { "path", path },
{ "keywords", sh.keywordSpace.keywords.Where(k => watch.Contains(k.name))
.Select(k => (object)new Dictionary<string, object> { { "name", k.name }, { "dynamic", k.isDynamic }, { "overridable", k.isOverridable }, { "type", k.type.ToString() } }).ToList() },
});
}
return new Dictionary<string, object> { { "before", before }, { "after", after }, { "written", write }, { "shaders", shaders } };
});
}
// ------------------------------------------------------------------ post-processing variants
static readonly (string type, string feature)[] k_PostFeatures =
{
("LensDistortion", "LensDistortion"), ("Tonemapping", "ToneMapping"), ("FilmGrain", "FilmGrain"),
("DepthOfField", "DepthOfField"), ("MotionBlur", "CameraMotionBlur"), ("PaniniProjection", "PaniniProjection"),
("ChromaticAberration", "ChromaticAberration"),
};
// Mirrors URP 17.3 ShaderBuildPreprocessor.GetSupportedFeaturesFromVolumes for one profile.
static List<string> KeptFeatures(VolumeProfile p)
{
var kept = new List<string>();
foreach (var (typeName, feature) in k_PostFeatures)
if (p.components.Any(c => c != null && c.GetType().Name == typeName)) kept.Add(feature);
if (p.TryGet<Bloom>(out var bloom))
{
bool dirt = bloom.dirtIntensity.value > 0f && bloom.dirtTexture.value != null;
kept.Add(bloom.highQualityFiltering.value ? (dirt ? "BloomHQDirt" : "BloomHQ") : (dirt ? "BloomLQDirt" : "BloomLQ"));
}
return kept;
}
static object ConvertParam(object v, Type t)
{
if (t == typeof(bool)) return Convert.ToBoolean(v);
if (t == typeof(float)) return Convert.ToSingle(v);
if (t == typeof(int)) return Convert.ToInt32(v);
if (typeof(UnityEngine.Object).IsAssignableFrom(t)) return AssetDatabase.LoadAssetAtPath(Convert.ToString(v), t);
if (t.IsEnum) return Enum.Parse(t, Convert.ToString(v));
return Convert.ChangeType(v, t);
}
static string CreateProfile(Dictionary<string, object> spec)
{
var path = Convert.ToString(spec["path"]);
Directory.CreateDirectory(Path.GetDirectoryName(AgentJob.ResolvePath(path)));
var profile = ScriptableObject.CreateInstance<VolumeProfile>();
AssetDatabase.CreateAsset(profile, path);
var types = TypeCache.GetTypesDerivedFrom<VolumeComponent>();
var overrides = spec.ContainsKey("overrides") ? (Dictionary<string, object>)spec["overrides"] : new Dictionary<string, object>();
foreach (var kv in overrides)
{
var t = types.FirstOrDefault(x => x.Name == kv.Key);
if (t == null) throw new ArgumentException("no VolumeComponent type named " + kv.Key);
var comp = profile.Add(t, true);
comp.name = t.Name;
AssetDatabase.AddObjectToAsset(comp, profile);
var fields = kv.Value as Dictionary<string, object> ?? new Dictionary<string, object>();
foreach (var f in fields)
{
var fi = t.GetField(f.Key, BindingFlags.Public | BindingFlags.Instance);
if (fi == null) throw new ArgumentException(kv.Key + " has no parameter " + f.Key);
var param = fi.GetValue(comp);
var valueType = param.GetType().GetProperty("value").PropertyType;
param.GetType().GetMethod("Override", new[] { valueType }).Invoke(param, new[] { ConvertParam(f.Value, valueType) });
}
}
EditorUtility.SetDirty(profile);
AssetDatabase.SaveAssets();
return path;
}
public static void PostProcessingAudit()
{
AgentJob.Run(() =>
{
string created = AgentJob.Has("create_profile") ? CreateProfile(AgentJob.Dict("create_profile")) : null;
var buildScenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
var used = new HashSet<string>(buildScenes.Length > 0 ? AssetDatabase.GetDependencies(buildScenes, true) : new string[0]);
string defaultProfile = null;
if (GraphicsSettings.TryGetRenderPipelineSettings<URPDefaultVolumeProfileSettings>(out var dvs) && dvs.volumeProfile != null)
defaultProfile = AssetDatabase.GetAssetPath(dvs.volumeProfile);
var profiles = new List<object>();
var union = new SortedSet<string>();
foreach (var guid in AssetDatabase.FindAssets("t:VolumeProfile"))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
if (!path.StartsWith("Assets")) continue; // URP reads only Assets/
var p = AssetDatabase.LoadAssetAtPath<VolumeProfile>(path);
if (p == null) continue;
var kept = KeptFeatures(p);
foreach (var k in kept) union.Add(k);
profiles.Add(new Dictionary<string, object>
{
{ "path", path }, { "overrides", p.components.Where(c => c != null).Select(c => (object)c.GetType().Name).ToList() },
{ "is_default_volume_profile", path == defaultProfile }, { "used_by_build_scene", used.Contains(path) },
{ "keeps", kept.Cast<object>().ToList() },
});
}
var orphans = profiles.Cast<Dictionary<string, object>>()
.Where(d => !(bool)d["used_by_build_scene"] && !(bool)d["is_default_volume_profile"]).Select(d => d["path"]).ToList();
return new Dictionary<string, object>
{
{ "created", created }, { "strip_unused_post_processing_variants", Settings().TryGetValue("strip_unused_post_processing_variants", out var sp) ? sp : null },
{ "default_volume_profile", defaultProfile }, { "build_scenes", buildScenes.Cast<object>().ToList() },
{ "profiles", profiles }, { "kept_features", union.Cast<object>().ToList() },
{ "profiles_outside_build_scenes", orphans },
};
});
}
}
}
scripts/Runtime/AgentFullscreenFeature.cs
// AgentFullscreenFeature (scenario-unity-shaders skill v0.1, 2026-09-24). A Render Graph renderer feature for
// one-material full-screen effects in URP 17.3 (Unity 6000.3), written the way the 6.3 docs and
// Unity's own dither sample do it:
// - gate in AddRenderPasses (no material, preview or reflection camera, strength 0): a pass that is not
// enqueued costs nothing and does not force an intermediate texture (the 6.3 template warns that
// requiresIntermediateTexture and ConfigureInput break TBDR render passes on mobile),
// - RecordRenderGraph only declares; no commands, no allocations (the graph owns the textures),
// - refuse the back buffer (After Rendering in 6.2+ always follows the final blit: use
// AfterRenderingPostProcessing),
// - ONE blit into a new texture, then resourceData.cameraColor = destination (no blit back),
// - depth, normals or motion inputs: ConfigureInput(...) so URP produces them, and UseTexture(...) on the
// builder that AddBlitPass returns in 6.3 (returnBuilder: true) so the graph knows the pass reads them.
// Runtime code: it must live OUTSIDE an Editor folder (the renderer asset references it in players).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py (on/off captures,
// grayscale readback R == G == B).
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.RenderGraphModule.Util;
using UnityEngine.Rendering.Universal;
namespace AgentKit.Rendering
{
public class AgentFullscreenFeature : ScriptableRendererFeature
{
[Tooltip("Serialized material: keeps the shader in builds (Shader.Find does not).")]
public Material material;
public int passIndex = 0;
public RenderPassEvent injectionPoint = RenderPassEvent.AfterRenderingPostProcessing;
[Tooltip("Camera textures the shader samples. Only request what it reads: each costs a pass or a copy.")]
public ScriptableRenderPassInput requirements = ScriptableRenderPassInput.None;
AgentFullscreenPass m_Pass;
public override void Create()
{
// Create() reruns on every Inspector change: build the pass here, never in AddRenderPasses.
m_Pass = new AgentFullscreenPass(string.IsNullOrEmpty(name) ? "AgentFullscreen" : name);
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (material == null || passIndex < 0 || passIndex >= material.passCount)
return;
var cameraType = renderingData.cameraData.cameraType;
if (cameraType == CameraType.Preview || cameraType == CameraType.Reflection)
return;
if (material.HasProperty(AgentFullscreenPass.StrengthId) && material.GetFloat(AgentFullscreenPass.StrengthId) <= 0f)
return;
m_Pass.Setup(material, passIndex, injectionPoint, requirements);
renderer.EnqueuePass(m_Pass);
}
}
public class AgentFullscreenPass : ScriptableRenderPass
{
public static readonly int StrengthId = Shader.PropertyToID("_Strength");
readonly string m_Name;
Material m_Material;
int m_PassIndex;
ScriptableRenderPassInput m_Inputs;
public AgentFullscreenPass(string name)
{
m_Name = name;
profilingSampler = new ProfilingSampler(name); // readable name in the Profiler and Render Graph Viewer
}
public void Setup(Material material, int passIndex, RenderPassEvent evt, ScriptableRenderPassInput inputs)
{
m_Material = material;
m_PassIndex = passIndex;
m_Inputs = inputs;
renderPassEvent = evt;
ConfigureInput(inputs);
requiresIntermediateTexture = true; // the effect samples the camera colour
}
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
var resourceData = frameData.Get<UniversalResourceData>();
if (resourceData.isActiveTargetBackBuffer)
{
Debug.LogError($"{m_Name}: the active target is the back buffer, which cannot be sampled. " +
"Use RenderPassEvent.AfterRenderingPostProcessing or earlier.");
return;
}
TextureHandle source = resourceData.activeColorTexture;
TextureDesc desc = renderGraph.GetTextureDesc(source); // graph-derived descriptor, change only what differs
desc.name = "CameraColor-" + m_Name;
desc.clearBuffer = false; // we overwrite every pixel
TextureHandle destination = renderGraph.CreateTexture(desc);
var blit = new RenderGraphUtils.BlitMaterialParameters(source, destination, m_Material, m_PassIndex);
if (m_Inputs == ScriptableRenderPassInput.None)
{
renderGraph.AddBlitPass(blit, passName: m_Name);
}
else
{
using (var builder = renderGraph.AddBlitPass(blit, passName: m_Name, returnBuilder: true))
{
if ((m_Inputs & ScriptableRenderPassInput.Depth) != 0 && resourceData.cameraDepthTexture.IsValid())
builder.UseTexture(resourceData.cameraDepthTexture);
if ((m_Inputs & ScriptableRenderPassInput.Normal) != 0 && resourceData.cameraNormalsTexture.IsValid())
builder.UseTexture(resourceData.cameraNormalsTexture);
if ((m_Inputs & ScriptableRenderPassInput.Motion) != 0 && resourceData.motionVectorColor.IsValid())
builder.UseTexture(resourceData.motionVectorColor);
if ((m_Inputs & ScriptableRenderPassInput.Color) != 0 && resourceData.cameraOpaqueTexture.IsValid())
builder.UseTexture(resourceData.cameraOpaqueTexture);
}
}
resourceData.cameraColor = destination; // later passes read our output: no blit back
}
}
}
scripts/Runtime/AgentPsoProbe.cs
// AgentKit.Rendering v0.2 (scenario-unity-shaders skill, 2026-09-24). PSO warm-up with GraphicsStateCollection
// (Unity 6, experimental API, UnityEngine.Experimental.Rendering) plus the proof that it worked.
//
// Shipping use: put the component in the boot or loading scene, `content` = the gameplay root, inactive in
// the scene. On Start it loads StreamingAssets/<folder>/<platform>_<api>.graphicsstate (ONE collection per
// graphics API and platform: 6.3 Manual, "Trace a new PSO data collection"), refuses a collection traced on
// another API or platform, warms it up (all at once, or warmupPerFrame PSOs per frame behind the loading
// screen), then activates `content`.
//
// Test probe (development player, command line):
// <player> -agentPso trace|cold|warm -agentPsoOut /abs/out.json [-agentPsoFile /abs/x.graphicsstate]
// [-agentPsoFrames 60] [-logFile /abs/player.log]
// trace: BeginTrace at boot, EndTrace + SaveToFile after the gameplay frames (tracing needs a development build).
// cold : no warm-up. warm: LoadFromFile + warm-up before gameplay.
// ship : the shipping path (the file for THIS platform and API found in StreamingAssets/<folder>/), probed.
// Every mode counts the samples of the Shader.CreateGPUProgram and CreateGraphicsGraphicsPipelineImpl markers
// (6.3 Manual, "Warm up PSOs") during the gameplay frames with ProfilerRecorder, logs AGENT_PSO
// gameplay_start / gameplay_end around them (so "Compiled Shader:" lines from Log Shader Compilation can be
// counted in the player log), lists the marker names this platform registers, writes JSON and quits.
// Without -agentPso only the shipping behaviour runs. Run in Unity 6000.3.21f1 on 2026-09-24:
// tests/code/unity-shaders/test_live_pso.py (macOS development player, Metal).
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Unity.Profiling;
using Unity.Profiling.LowLevel.Unsafe;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
namespace AgentKit.Rendering
{
public class AgentPsoProbe : MonoBehaviour
{
[Tooltip("Gameplay root, inactive in the scene: activated after the warm-up.")]
public GameObject content;
[Tooltip("0 = WarmUp everything in one frame; N > 0 = WarmUpProgressively(N) per frame (smoother loading screen).")]
public int warmupPerFrame = 0;
[Tooltip("Folder under StreamingAssets holding <platform>_<api>.graphicsstate files.")]
public string folder = "PSO";
[Tooltip("Frames rendered before content activates (the engine's own passes create their PSOs here).")]
public int loadingFrames = 5;
// GPU program creation, then PSO creation: CreateGraphicsGraphicsPipelineImpl is the 6.3 Manual's name (Vulkan,
// DX12); on Metal it does not exist and PSOs show as CreateGraphicsPipelineAsync /
// GpuProgramMetal.CreateCachedPipelineAsync (observed on 6000.3.21f1, macOS player).
static readonly string[] k_Markers = { "Shader.CreateGPUProgram", "CreateGraphicsGraphicsPipelineImpl",
"CreateGraphicsPipelineAsync", "GpuProgramMetal.CreateCachedPipelineAsync" };
static readonly string[] k_Discover = { "GPUProgram", "Pipeline", "PSO", "GraphicsState", "WarmUp", "Shader.Create" };
readonly Dictionary<string, object> m_Report = new Dictionary<string, object>();
public static string CollectionName(RuntimePlatform platform, GraphicsDeviceType api, string quality = null)
{
var n = platform + "_" + api;
if (!string.IsNullOrEmpty(quality)) n += "_" + new string(quality.Where(char.IsLetterOrDigit).ToArray());
return n + ".graphicsstate";
}
static string Arg(string flag)
{
var a = Environment.GetCommandLineArgs();
for (int i = 0; i < a.Length - 1; i++) if (a[i] == flag) return a[i + 1];
return null;
}
IEnumerator Start()
{
string mode = Arg("-agentPso");
string outPath = Arg("-agentPsoOut");
int frames = int.TryParse(Arg("-agentPsoFrames"), out var f) ? f : 60;
string shipped = Path.Combine(Application.streamingAssetsPath, folder,
CollectionName(Application.platform, SystemInfo.graphicsDeviceType));
string file = (mode == null || mode == "ship") ? shipped : (Arg("-agentPsoFile") ?? shipped);
m_Report["mode"] = mode ?? "ship";
m_Report["api"] = SystemInfo.graphicsDeviceType.ToString();
m_Report["platform"] = Application.platform.ToString();
m_Report["quality"] = QualitySettings.names[QualitySettings.GetQualityLevel()];
m_Report["development"] = Debug.isDebugBuild;
m_Report["unity"] = Application.unityVersion;
m_Report["file"] = file;
if (content) content.SetActive(false);
GraphicsStateCollection trace = null;
if (mode == "trace")
{
trace = new GraphicsStateCollection();
m_Report["trace_started"] = trace.BeginTrace(); // false in a non-development player
}
for (int i = 0; i < loadingFrames; i++) yield return null; // loading screen frames
if (mode == "warm" || ((mode == null || mode == "ship") && File.Exists(file)))
yield return WarmUp(file);
var recorders = StartRecorders(frames + 8);
Debug.Log("AGENT_PSO gameplay_start");
if (content) content.SetActive(true);
float worst = 0f;
for (int i = 0; i < frames; i++)
{
yield return null;
worst = Mathf.Max(worst, Time.unscaledDeltaTime);
}
Debug.Log("AGENT_PSO gameplay_end");
m_Report["gameplay_frames"] = frames;
m_Report["gameplay_worst_frame_ms"] = Math.Round(worst * 1000.0, 2);
var markers = new Dictionary<string, object>();
long pipelines = 0;
var pipelineMarkers = new List<object>();
foreach (var kv in recorders)
{
var r = kv.Value;
long count = 0, ns = 0;
bool valid = r.Valid;
if (valid)
for (int i = 0; i < r.Count; i++) { var s = r.GetSample(i); count += s.Count; ns += s.Value; }
markers[kv.Key] = new Dictionary<string, object> { { "valid", valid }, { "count", count }, { "ms", Math.Round(ns / 1e6, 3) } };
if (kv.Key == k_Markers[0])
{
m_Report["gameplay_create_gpu_program"] = count;
m_Report["gameplay_create_gpu_program_ms"] = Math.Round(ns / 1e6, 3);
}
else if (valid) { pipelines += count; pipelineMarkers.Add(kv.Key); }
r.Dispose();
}
m_Report["gameplay_markers"] = markers;
m_Report["gameplay_create_pipeline"] = pipelineMarkers.Count > 0 ? (object)pipelines : null; // null = no PSO marker on this API
m_Report["pipeline_markers"] = pipelineMarkers;
m_Report["marker_names"] = DiscoverMarkers();
if (trace != null)
{
trace.EndTrace();
Directory.CreateDirectory(Path.GetDirectoryName(file));
bool saved = trace.SaveToFile(file);
m_Report["trace"] = new Dictionary<string, object>
{
{ "saved", saved }, { "file", file }, { "variants", trace.variantCount },
{ "graphics_states", trace.totalGraphicsStateCount }, { "api", trace.graphicsDeviceType.ToString() },
{ "platform", trace.runtimePlatform.ToString() }, { "quality", trace.qualityLevelName },
{ "bytes", saved && File.Exists(file) ? new FileInfo(file).Length : 0 },
};
}
if (!string.IsNullOrEmpty(outPath))
{
File.WriteAllText(outPath, ToJson(m_Report));
Application.Quit(0);
}
}
IEnumerator WarmUp(string file)
{
var info = new Dictionary<string, object> { { "file", file } };
m_Report["warmup"] = info;
var gsc = new GraphicsStateCollection();
bool loaded = File.Exists(file) && gsc.LoadFromFile(file);
info["loaded"] = loaded;
if (!loaded) yield break;
info["collection_api"] = gsc.graphicsDeviceType.ToString();
info["collection_platform"] = gsc.runtimePlatform.ToString();
info["variants"] = gsc.variantCount;
info["graphics_states"] = gsc.totalGraphicsStateCount;
bool match = gsc.graphicsDeviceType == SystemInfo.graphicsDeviceType && gsc.runtimePlatform == Application.platform;
info["api_match"] = match;
if (!match) { Debug.LogWarning("AgentPsoProbe: collection traced on " + gsc.graphicsDeviceType + "/" + gsc.runtimePlatform + ", running " + SystemInfo.graphicsDeviceType + "/" + Application.platform + ": skipped"); yield break; }
var sw = System.Diagnostics.Stopwatch.StartNew();
int steps = 0;
if (warmupPerFrame <= 0) { gsc.WarmUp(default).Complete(); steps = 1; }
else
while (!gsc.isWarmedUp && steps < 100000)
{
gsc.WarmUpProgressively(warmupPerFrame, default).Complete();
steps++;
yield return null; // one loading-screen frame per batch
}
sw.Stop();
info["warmed_up"] = gsc.isWarmedUp;
info["completed"] = gsc.completedWarmupCount;
info["frames"] = steps;
info["ms"] = Math.Round(sw.Elapsed.TotalMilliseconds, 2);
}
static Dictionary<string, ProfilerRecorder> StartRecorders(int capacity)
{
var handles = new List<ProfilerRecorderHandle>();
ProfilerRecorderHandle.GetAvailable(handles);
var res = new Dictionary<string, ProfilerRecorder>();
var opts = ProfilerRecorderOptions.Default | ProfilerRecorderOptions.StartImmediately;
foreach (var name in k_Markers)
{
var h = handles.FirstOrDefault(x => ProfilerRecorderHandle.GetDescription(x).Name == name);
res[name] = h.Valid ? new ProfilerRecorder(h, capacity, opts)
: ProfilerRecorder.StartNew(ProfilerCategory.Render, name, capacity, opts);
}
return res;
}
static List<object> DiscoverMarkers()
{
var handles = new List<ProfilerRecorderHandle>();
ProfilerRecorderHandle.GetAvailable(handles);
return handles.Select(h => ProfilerRecorderHandle.GetDescription(h))
.Where(d => k_Discover.Any(k => d.Name.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0))
.Select(d => (object)(d.Category.Name + "/" + d.Name)).Distinct().OrderBy(x => x).Take(80).ToList();
}
// Small JSON writer (no package dependency in the player).
static string ToJson(object o)
{
var sb = new StringBuilder();
Write(sb, o);
return sb.ToString();
}
static void Write(StringBuilder sb, object o)
{
switch (o)
{
case null: sb.Append("null"); break;
case string s: sb.Append('"').Append(s.Replace("\\", "\\\\").Replace("\"", "\\\"")).Append('"'); break;
case bool b: sb.Append(b ? "true" : "false"); break;
case IDictionary<string, object> d:
sb.Append('{');
bool first = true;
foreach (var kv in d) { if (!first) sb.Append(','); first = false; Write(sb, kv.Key); sb.Append(':'); Write(sb, kv.Value); }
sb.Append('}');
break;
case IEnumerable<object> list:
sb.Append('[');
bool f1 = true;
foreach (var x in list) { if (!f1) sb.Append(','); f1 = false; Write(sb, x); }
sb.Append(']');
break;
case IFormattable n: sb.Append(n.ToString(null, CultureInfo.InvariantCulture)); break;
default: Write(sb, o.ToString()); break;
}
}
}
}
scripts/Shaders/AgentCompute.compute
// AgentCompute.compute (scenario-unity-shaders skill v0.1, 2026-09-24): the portable compute template.
// Rules from the 6.3 Manual (compute, multiple platforms) that bite on this Mac (Metal):
// - no "//" comment on a #pragma kernel line (compile error), so kernel comments go above;
// - bounds check every thread against a count passed as a constant (Metal has no GetDimensions on
// buffers, and out-of-bounds access can hang non-DX GPUs);
// - atomics on a RWStructuredBuffer, never on an RWTexture (Metal has no texture atomics);
// - RWTexture2D<float4> matches an ARGBFloat / ARGBHalf / ARGB32 target (format qualifier rule for GL and Vulkan).
// Kernels: SquareAdd (1D, 64 threads), Histogram (1D, buffer atomics), Pattern (2D, 8x8 into a texture).
#pragma kernel SquareAdd
#pragma kernel Histogram
#pragma kernel Pattern
StructuredBuffer<float> _Input;
RWStructuredBuffer<float> _Output;
RWStructuredBuffer<uint> _Bins;
RWTexture2D<float4> _Target;
uint _Count;
uint _BinCount;
float _Offset;
uint2 _Size;
float _Time01;
[numthreads(64, 1, 1)]
void SquareAdd(uint3 id : SV_DispatchThreadID)
{
if (id.x >= _Count) return;
float v = _Input[id.x];
_Output[id.x] = v * v + _Offset;
}
[numthreads(64, 1, 1)]
void Histogram(uint3 id : SV_DispatchThreadID)
{
if (id.x >= _Count) return;
uint bin = min((uint)(saturate(_Input[id.x]) * _BinCount), _BinCount - 1);
InterlockedAdd(_Bins[bin], 1u);
}
[numthreads(8, 8, 1)]
void Pattern(uint3 id : SV_DispatchThreadID)
{
if (id.x >= _Size.x || id.y >= _Size.y) return;
float2 uv = (id.xy + 0.5) / (float2)_Size;
float2 c = uv * 2.0 - 1.0;
float r = length(c);
float rings = 0.5 + 0.5 * cos(r * 40.0 - _Time01 * 6.2831853);
float3 col = lerp(float3(0.05, 0.10, 0.30), float3(1.0, 0.55, 0.15), rings * saturate(1.2 - r));
_Target[id.xy] = float4(col, 1.0);
}
scripts/Shaders/AgentCustomLighting.hlsl
//UNITY_SHADER_NO_UPGRADE
// AgentCustomLighting.hlsl (scenario-unity-shaders skill v0.1, 2026-09-24): agent-owned HLSL for Shader Graph 17.3
// Custom Function nodes in File mode. Rules (Shader Graph manual; Daniel Ilett F8bAI6dIrto; MinionsArt
// FIP6I1x6lMA): one include guard per file, every entry point suffixed _float and _half, the node's Name
// field WITHOUT the suffix, inputs first then out parameters in the node's port order, and a
// SHADERGRAPH_PREVIEW branch (previews have no lights). Graph Settings for custom lighting in 6.3: Unlit
// target, Keep Lighting Variants ON (without it the shadow and light keywords are not compiled).
//
// AgentAdditionalLightsHalfLambert: drop-in for the node of Unity's Custom Lighting sample
// (Components/AdditionalLights/AdditionalLightsHalfLambert.shadersubgraph, same ports, same maths) that
// ALSO walks extra directional lights under Forward+: the sample's cluster loop skips them (observed in
// 6000.3.21f1). Repoint the node with ut_shaders.shadergraph_repoint_custom_function.
// AgentToonMainLight: MinionsArt's toon main light, updated for URP 17.3 (no SHADOWS_SCREEN, no
// ComputeScreenPos: TransformWorldToShadowCoord handles screen-space shadows itself).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/test_live_shaders.py stage graph.
#ifndef AGENT_CUSTOM_LIGHTING_INCLUDED
#define AGENT_CUSTOM_LIGHTING_INCLUDED
#ifndef SHADERGRAPH_PREVIEW
float3 AgentHalfLambertLight(Light light, float3 WorldPosition, float3 WorldNormal, float3 WorldView, float SpecPower,
float3 Reflectance, inout float Diffuse, inout float3 Specular)
{
float NdotL = dot(WorldNormal, light.direction);
float atten = light.distanceAttenuation * light.shadowAttenuation;
float thisDiffuse = atten * (NdotL * 0.5 + 0.5);
float3 halfAngle = normalize(light.direction + WorldView);
float3 thisSpecular = pow(saturate(dot(halfAngle, WorldNormal)), SpecPower) * Reflectance * atten;
Diffuse += thisDiffuse;
Specular += thisSpecular;
return light.color * (thisDiffuse + thisSpecular);
}
#endif
void AgentAdditionalLightsHalfLambert_float(float SpecPower, float3 WorldPosition, float3 WorldNormal, float3 WorldView,
float MainDiffuse, float3 MainSpecular, float3 MainColor, float3 Reflectance,
float2 ScreenPosition, out float Diffuse, out float3 Specular, out float3 Color)
{
Diffuse = MainDiffuse;
Specular = MainSpecular;
Color = MainColor * (MainDiffuse + MainSpecular);
#ifndef SHADERGRAPH_PREVIEW
#if defined(_ADDITIONAL_LIGHTS) || defined(_CLUSTER_LIGHT_LOOP)
uint pixelLightCount = GetAdditionalLightsCount();
// LIGHT_LOOP_BEGIN reads a local named exactly inputData (positionWS, normalizedScreenSpaceUV).
InputData inputData = (InputData)0;
inputData.normalizedScreenSpaceUV = ScreenPosition;
inputData.positionWS = WorldPosition;
#if USE_CLUSTER_LIGHT_LOOP
// Forward+ / Deferred+: extra directional lights are not in the cluster loop below.
[loop] for (uint dirIndex = 0; dirIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); dirIndex++)
{
Light dirLight = GetAdditionalLight(dirIndex, WorldPosition);
dirLight.shadowAttenuation = AdditionalLightRealtimeShadow(dirIndex, WorldPosition, dirLight.direction);
Color += AgentHalfLambertLight(dirLight, WorldPosition, WorldNormal, WorldView, SpecPower, Reflectance, Diffuse, Specular);
}
#endif
LIGHT_LOOP_BEGIN(pixelLightCount)
Light light = GetAdditionalLight(lightIndex, WorldPosition);
light.shadowAttenuation = AdditionalLightRealtimeShadow(lightIndex, WorldPosition, light.direction);
#if defined(_LIGHT_COOKIES)
light.color *= SampleAdditionalLightCookie(lightIndex, WorldPosition);
#endif
Color += AgentHalfLambertLight(light, WorldPosition, WorldNormal, WorldView, SpecPower, Reflectance, Diffuse, Specular);
LIGHT_LOOP_END
float total = Diffuse + dot(Specular, float3(0.333, 0.333, 0.333));
Color = total <= 0 ? MainColor : Color / total;
#endif
#endif
}
void AgentAdditionalLightsHalfLambert_half(half SpecPower, half3 WorldPosition, half3 WorldNormal, half3 WorldView,
half MainDiffuse, half3 MainSpecular, half3 MainColor, half3 Reflectance,
half2 ScreenPosition, out half Diffuse, out half3 Specular, out half3 Color)
{
float d; float3 s; float3 c;
AgentAdditionalLightsHalfLambert_float(SpecPower, WorldPosition, WorldNormal, WorldView, MainDiffuse, MainSpecular,
MainColor, Reflectance, ScreenPosition, d, s, c);
Diffuse = d; Specular = s; Color = c;
}
// Toon main light. Ports: Normal (Vector3, world), WorldPos (Vector3), RampSmoothness (Float), RampOffset (Float),
// ShadowTint (Vector3) -> ToonRamp (Vector3), Direction (Vector3). Smoothness is floored so smoothstep never divides by 0.
void AgentToonMainLight_float(float3 Normal, float3 WorldPos, float RampSmoothness, float RampOffset, float3 ShadowTint,
out float3 ToonRamp, out float3 Direction)
{
#ifdef SHADERGRAPH_PREVIEW
ToonRamp = float3(0.5, 0.5, 0.5);
Direction = float3(0.5, 0.5, 0);
#else
Light light = GetMainLight(TransformWorldToShadowCoord(WorldPos));
float halfLambert = dot(Normal, light.direction) * 0.5 + 0.5;
float ramp = smoothstep(RampOffset, RampOffset + max(RampSmoothness, 1e-3), halfLambert) * light.shadowAttenuation;
ToonRamp = light.color * lerp(ShadowTint, 1.0, ramp);
Direction = light.direction;
#endif
}
void AgentToonMainLight_half(half3 Normal, half3 WorldPos, half RampSmoothness, half RampOffset, half3 ShadowTint,
out half3 ToonRamp, out half3 Direction)
{
float3 r; float3 d;
AgentToonMainLight_float(Normal, WorldPos, RampSmoothness, RampOffset, ShadowTint, r, d);
ToonRamp = r; Direction = d;
}
#endif // AGENT_CUSTOM_LIGHTING_INCLUDED
scripts/Shaders/AgentDissolve.shader
// AgentKit/Dissolve (scenario-unity-shaders skill v0.1, 2026-09-24): hand-written URP 17.3 lit dissolve, the
// text twin of Brackeys' Dissolve Shader Graph (taMp1g1pBeE) made production-safe:
// field = object-space 3D noise (no UV seams), optionally blended with a bottom-to-top height ramp
// clip(field - amount) runs in EVERY pass (forward, ShadowCaster, DepthOnly, DepthNormals) through
// AGENT_CLIP, so the shadow and the depth texture dissolve with the mesh
// edge = step(field, amount + _EdgeWidth): the emissive band is the clip test shifted by the edge
// width (a band equal to the clip test is entirely clipped, Brackeys [00:05:31]); HDR colour above 1
// plus Bloom makes it glow (intensity about 4, [00:07:11]); Cull Off shows the inside.
// amount = _Dissolve (per material instance) or, with _UseRendererValue, the per-renderer value from
// Renderer.SetShaderUserValue (6.3, unity_RendererUserValue in UnityPerDraw: many renderers share ONE
// material and stay SRP Batcher and GPU Resident Drawer friendly; MaterialPropertyBlock breaks the
// SRP Batcher). Encoding: low 16 bits = amount * 65535.
Shader "AgentKit/Dissolve"
{
Properties
{
[MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
[MainColor] _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.4
_Dissolve ("Dissolve Amount", Range(0, 1)) = 0
[Toggle] _UseRendererValue ("Amount From Renderer (SetShaderUserValue)", Float) = 0
_NoiseScale ("Noise Scale (per object unit)", Float) = 4
_DirectionWeight ("Height Ramp Weight", Range(0, 1)) = 0
_HeightMin ("Height Min (object space)", Float) = -1
_HeightMax ("Height Max (object space)", Float) = 1
[HDR] _EdgeColor ("Edge Color (HDR)", Color) = (4, 1.3, 0.25, 1)
_EdgeWidth ("Edge Width", Range(0.001, 0.2)) = 0.05
}
SubShader
{
Tags { "RenderType" = "TransparentCutout" "Queue" = "AlphaTest" "RenderPipeline" = "UniversalPipeline" }
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "AgentNoise.hlsl"
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
half4 _BaseColor;
half _Metallic;
half _Smoothness;
half _Dissolve;
half _UseRendererValue;
float _NoiseScale;
half _DirectionWeight;
float _HeightMin;
float _HeightMax;
half4 _EdgeColor;
half _EdgeWidth;
CBUFFER_END
float DissolveAmount()
{
float fromRenderer = (unity_RendererUserValue & 0xFFFFu) / 65535.0;
return _UseRendererValue > 0.5 ? fromRenderer : _Dissolve;
}
float DissolveField(float3 positionOS)
{
float n = AgentFbm3D(positionOS * _NoiseScale);
float h = saturate((positionOS.y - _HeightMin) / max(_HeightMax - _HeightMin, 1e-4));
return saturate(lerp(n, h * 0.85 + n * 0.15, _DirectionWeight));
}
// One definition used by all passes (AgentPasses.hlsl reads this macro).
#define AGENT_CLIP(uv, positionOS, positionWS) clip(DissolveField(positionOS) - DissolveAmount())
ENDHLSL
Pass
{
Name "DissolveForward"
Tags { "LightMode" = "UniversalForward" }
ZWrite On
Cull Off
HLSLPROGRAM
#pragma target 2.0
#pragma vertex DissolveVert
#pragma fragment DissolveFrag
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile _ _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
#pragma multi_compile _ _CLUSTER_LIGHT_LOOP
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Fog.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 positionWS : TEXCOORD1;
float3 normalWS : TEXCOORD2;
float3 positionOS : TEXCOORD3;
half fogFactor : TEXCOORD4;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings DissolveVert(Attributes input)
{
Varyings output = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
output.positionCS = pos.positionCS;
output.positionWS = pos.positionWS;
output.positionOS = input.positionOS.xyz;
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
output.fogFactor = ComputeFogFactor(pos.positionCS.z);
return output;
}
half4 DissolveFrag(Varyings input, bool isFrontFace : SV_IsFrontFace) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
float field = DissolveField(input.positionOS);
float amount = DissolveAmount();
clip(field - amount);
half edge = step(field, amount + _EdgeWidth) * step(0.001, amount); // no edge at amount 0
half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor;
SurfaceData surface = (SurfaceData)0;
surface.albedo = albedo.rgb;
surface.alpha = 1;
surface.metallic = _Metallic;
surface.smoothness = _Smoothness;
surface.occlusion = 1;
surface.normalTS = half3(0, 0, 1);
surface.emission = _EdgeColor.rgb * edge;
float3 normalWS = NormalizeNormalPerPixel(input.normalWS);
normalWS = isFrontFace ? normalWS : -normalWS; // Cull Off: light the inside correctly
InputData inputData = (InputData)0;
inputData.positionWS = input.positionWS;
inputData.positionCS = input.positionCS;
inputData.normalWS = normalWS;
inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
inputData.shadowCoord = TransformWorldToShadowCoord(input.positionWS);
inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactor);
inputData.bakedGI = SampleSH(normalWS);
inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);
inputData.shadowMask = half4(1, 1, 1, 1);
half4 color = UniversalFragmentPBR(inputData, surface);
color.rgb = MixFog(color.rgb, inputData.fogCoord);
return half4(color.rgb, 1);
}
ENDHLSL
}
Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
ZTest LEqual
ColorMask 0
Cull Off
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentShadowVertex
#pragma fragment AgentShadowFragment
#pragma multi_compile_instancing
#pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
#include "AgentPasses.hlsl"
ENDHLSL
}
Pass
{
Name "DepthOnly"
Tags { "LightMode" = "DepthOnly" }
ZWrite On
ColorMask R
Cull Off
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentDepthOnlyVertex
#pragma fragment AgentDepthOnlyFragment
#pragma multi_compile_instancing
#include "AgentPasses.hlsl"
ENDHLSL
}
Pass
{
Name "DepthNormals"
Tags { "LightMode" = "DepthNormals" }
ZWrite On
Cull Off
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentDepthOnlyVertex
#pragma fragment AgentDepthNormalsFragment
#pragma multi_compile_instancing
#include "AgentPasses.hlsl"
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
scripts/Shaders/AgentEdgeOutline.shader
// AgentKit/Fullscreen/EdgeOutline (scenario-unity-shaders skill v0.1, 2026-09-24): full-screen outline for a
// Render Graph blit (AgentFullscreenFeature or the Full Screen Pass Renderer Feature).
// Edges from depth AND normals (Roberts cross, 2x2, like Daniel Ilett 26gbtRTokVo [00:33:50]):
// depth and normal edges survive textured scenes, where colour edges turn noisy (VGEz8oKyMpY [00:02:09]).
// Needs ConfigureInput(Depth | Normal) on the pass, or Requirements Depth + Normal on the Full Screen
// Pass feature; test with SSAO OFF, because SSAO renders the normals texture anyway and hides a
// missing Normal requirement. Thresholds keep a floor above 0 (0 makes every pixel an edge).
Shader "AgentKit/Fullscreen/EdgeOutline"
{
Properties
{
_OutlineColor ("Outline Color", Color) = (0.05, 0.04, 0.08, 1)
_Thickness ("Thickness (pixels)", Range(0.5, 4)) = 1
_DepthThreshold ("Depth Threshold (relative)", Range(0.001, 1)) = 0.08
_NormalThreshold ("Normal Threshold", Range(0.01, 2)) = 0.4
_Strength ("Strength", Range(0, 1)) = 1
}
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }
ZWrite Off ZTest Always Cull Off
Pass
{
Name "EdgeOutline"
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareNormalsTexture.hlsl"
CBUFFER_START(UnityPerMaterial)
half4 _OutlineColor;
float _Thickness;
float _DepthThreshold;
float _NormalThreshold;
half _Strength;
CBUFFER_END
float EyeDepth(float2 uv) { return LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams); }
half4 Frag(Varyings input) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
float2 uv = input.texcoord;
half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv);
float2 texel = _Thickness / _ScaledScreenParams.xy; // pixel offsets that respect render scale
float2 uvTR = uv + float2( texel.x, texel.y), uvBL = uv + float2(-texel.x, -texel.y);
float2 uvTL = uv + float2(-texel.x, texel.y), uvBR = uv + float2( texel.x, -texel.y);
float dTR = EyeDepth(uvTR), dBL = EyeDepth(uvBL), dTL = EyeDepth(uvTL), dBR = EyeDepth(uvBR);
float dMin = min(min(dTR, dBL), min(dTL, dBR));
float depthEdge = (abs(dTR - dBL) + abs(dTL - dBR)) / max(dMin, 1e-3); // relative: same look near and far
float3 nTR = SampleSceneNormals(uvTR), nBL = SampleSceneNormals(uvBL);
float3 nTL = SampleSceneNormals(uvTL), nBR = SampleSceneNormals(uvBR);
float normalEdge = length(nTR - nBL) + length(nTL - nBR);
half edge = saturate(step(_DepthThreshold, depthEdge) + step(_NormalThreshold, normalEdge));
// No outline on the sky: far plane on all four taps.
#if UNITY_REVERSED_Z
half sky = step(max(max(SampleSceneDepth(uvTR), SampleSceneDepth(uvBL)), max(SampleSceneDepth(uvTL), SampleSceneDepth(uvBR))), 0.0001);
#else
half sky = step(0.9999, min(min(SampleSceneDepth(uvTR), SampleSceneDepth(uvBL)), min(SampleSceneDepth(uvTL), SampleSceneDepth(uvBR))));
#endif
edge *= (1.0h - sky) * _Strength;
return half4(lerp(color.rgb, _OutlineColor.rgb, edge * _OutlineColor.a), color.a);
}
ENDHLSL
}
}
}
scripts/Shaders/AgentGrayscale.shader
// AgentKit/Fullscreen/Grayscale (scenario-unity-shaders skill v0.1, 2026-09-24): the numeric test effect for
// any full-screen pass. Rec. 709 luminance on the linear camera colour (6.3 Manual low-code
// post-processing example: 0.2126, 0.7152, 0.0722). At _Strength 1 every pixel has R == G == B, so a
// readback proves the pass ran; at 0 the frame is unchanged.
Shader "AgentKit/Fullscreen/Grayscale"
{
Properties
{
_Strength ("Strength", Range(0, 1)) = 1
}
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }
ZWrite Off ZTest Always Cull Off
Pass
{
Name "Grayscale"
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
CBUFFER_START(UnityPerMaterial)
half _Strength;
CBUFFER_END
half4 Frag(Varyings input) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, input.texcoord);
half luma = dot(color.rgb, half3(0.2126h, 0.7152h, 0.0722h));
return half4(lerp(color.rgb, luma.xxx, _Strength), color.a);
}
ENDHLSL
}
}
}
scripts/Shaders/AgentLit.shader
// AgentKit/Lit (scenario-unity-shaders skill v0.1, 2026-09-24): the hand-written URP 17.3 lit template.
// SurfaceData + InputData filled by hand, lighting by URP's own UniversalFragmentPBR (which already
// walks the Forward+ cluster light loop AND the extra directional lights), plus ShadowCaster,
// DepthOnly and DepthNormals passes from AgentPasses.hlsl. Keywords copied from the installed
// URP 17.3 Lit.shader (grep it, never a tutorial list: Daniel Ilett, bH--RU6qyTw [00:14:51]).
// Not covered on purpose (add when the project needs them): lightmaps and APV (LIGHTMAP_ON,
// ProbeVolumeVariants.hlsl, the SAMPLE_GI branches of LitForwardPass.hlsl), light layers,
// decals (_DBUFFER), a Meta pass for baking, a UniversalGBuffer pass for Deferred.
Shader "AgentKit/Lit"
{
Properties
{
[MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
[MainColor] _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.5
[Toggle(_NORMALMAP)] _UseNormalMap ("Use Normal Map", Float) = 0
[Normal][NoScaleOffset] _BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale ("Normal Scale", Range(0, 2)) = 1
[HDR] _EmissionColor ("Emission", Color) = (0, 0, 0, 1)
}
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue" = "Geometry" }
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap);
// SRP Batcher contract: every non-texture property, in one UnityPerMaterial CBUFFER,
// identical in every pass (it lives in HLSLINCLUDE so all passes share it).
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
half4 _BaseColor;
half _Metallic;
half _Smoothness;
half _UseNormalMap;
half _BumpScale;
half4 _EmissionColor;
CBUFFER_END
ENDHLSL
Pass
{
Name "ForwardLit"
Tags { "LightMode" = "UniversalForward" }
ZWrite On
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex LitVert
#pragma fragment LitFrag
// Material keyword: shader_feature, because a material decides it (never toggle it from C# at runtime).
#pragma shader_feature_local _NORMALMAP
// URP keywords (installed Lit.shader, 6000.3.21f1). Soft shadows have four quality variants in 6.3.
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
#pragma multi_compile _ _CLUSTER_LIGHT_LOOP
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Fog.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 tangentOS : TANGENT;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 positionWS : TEXCOORD1;
float3 normalWS : TEXCOORD2;
float4 tangentWS : TEXCOORD3; // w = sign, mirrored and negatively scaled meshes
half4 fogAndVertexLight : TEXCOORD4; // x fog factor, yzw per-vertex additional lights
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings LitVert(Attributes input)
{
Varyings output = (Varyings)0; // zero-init: no "not completely initialized" errors
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);
output.positionCS = pos.positionCS;
output.positionWS = pos.positionWS;
output.normalWS = nrm.normalWS;
output.tangentWS = float4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
output.fogAndVertexLight = half4(ComputeFogFactor(pos.positionCS.z),
VertexLighting(pos.positionWS, nrm.normalWS));
return output;
}
half4 LitFrag(Varyings input) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor;
SurfaceData surface = (SurfaceData)0;
surface.albedo = albedo.rgb;
surface.alpha = 1;
surface.metallic = _Metallic;
surface.smoothness = _Smoothness;
surface.occlusion = 1;
surface.emission = _EmissionColor.rgb;
surface.normalTS = half3(0, 0, 1);
float3 normalWS = input.normalWS;
#if defined(_NORMALMAP)
surface.normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv), _BumpScale);
float3 bitangentWS = input.tangentWS.w * cross(input.normalWS, input.tangentWS.xyz);
normalWS = TransformTangentToWorld(surface.normalTS, half3x3(input.tangentWS.xyz, bitangentWS, input.normalWS));
#endif
InputData inputData = (InputData)0;
inputData.positionWS = input.positionWS;
inputData.positionCS = input.positionCS;
inputData.normalWS = NormalizeNormalPerPixel(normalWS);
inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
inputData.shadowCoord = TransformWorldToShadowCoord(input.positionWS); // handles screen-space shadows
inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogAndVertexLight.x);
inputData.vertexLighting = input.fogAndVertexLight.yzw;
inputData.bakedGI = SampleSH(inputData.normalWS); // ambient probe (no APV, no lightmaps)
inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS); // SSAO + cluster lookup
inputData.shadowMask = half4(1, 1, 1, 1);
half4 color = UniversalFragmentPBR(inputData, surface);
color.rgb = MixFog(color.rgb, inputData.fogCoord);
color.a = 1;
return color;
}
ENDHLSL
}
Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
ZTest LEqual
ColorMask 0
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentShadowVertex
#pragma fragment AgentShadowFragment
#pragma multi_compile_instancing
#pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
#include "AgentPasses.hlsl"
ENDHLSL
}
Pass
{
Name "DepthOnly"
Tags { "LightMode" = "DepthOnly" }
ZWrite On
ColorMask R
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentDepthOnlyVertex
#pragma fragment AgentDepthOnlyFragment
#pragma multi_compile_instancing
#include "AgentPasses.hlsl"
ENDHLSL
}
Pass
{
Name "DepthNormals"
Tags { "LightMode" = "DepthNormals" }
ZWrite On
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentDepthOnlyVertex
#pragma fragment AgentDepthNormalsFragment
#pragma multi_compile_instancing
#include "AgentPasses.hlsl"
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
scripts/Shaders/AgentNoise.hlsl
// AgentNoise.hlsl (scenario-unity-shaders skill v0.1, 2026-09-24). Noise that matches Shader Graph 17.3.
// Port the graph's own node code instead of improvising a noise (visual notes on gRq-IdShxpU and
// taMp1g1pBeE): AgentGradientNoise2D = Gradient Noise node (Deterministic hash), AgentSimpleNoise2D =
// Simple Noise node (3 octaves of value noise). AgentValueNoise3D is object- or world-space noise:
// no UV seams or stretching on a dissolving mesh [added].
// Uses Core RP Hashes.hlsl (Hash_Tchou_*), the same hashes Shader Graph generates.
#ifndef AGENT_NOISE_INCLUDED
#define AGENT_NOISE_INCLUDED
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Hashes.hlsl"
float2 AgentGradientDir(float2 p)
{
float x; Hash_Tchou_2_1_float(p, x);
return normalize(float2(x - floor(x + 0.5), abs(x) - 0.5));
}
// Shader Graph "Gradient Noise" (Deterministic): output about 0..1, centred on 0.5.
float AgentGradientNoise2D(float2 uv, float scale)
{
float2 p = uv * scale;
float2 ip = floor(p);
float2 fp = frac(p);
float d00 = dot(AgentGradientDir(ip), fp);
float d01 = dot(AgentGradientDir(ip + float2(0, 1)), fp - float2(0, 1));
float d10 = dot(AgentGradientDir(ip + float2(1, 0)), fp - float2(1, 0));
float d11 = dot(AgentGradientDir(ip + float2(1, 1)), fp - float2(1, 1));
fp = fp * fp * fp * (fp * (fp * 6 - 15) + 10);
return lerp(lerp(d00, d01, fp.y), lerp(d10, d11, fp.y), fp.x) + 0.5;
}
float AgentValueNoise2D(float2 uv)
{
float2 i = floor(uv);
float2 f = frac(uv);
f = f * f * (3.0 - 2.0 * f);
float r0, r1, r2, r3;
Hash_Tchou_2_1_float(i, r0);
Hash_Tchou_2_1_float(i + float2(1, 0), r1);
Hash_Tchou_2_1_float(i + float2(0, 1), r2);
Hash_Tchou_2_1_float(i + float2(1, 1), r3);
return lerp(lerp(r0, r1, f.x), lerp(r2, r3, f.x), f.y);
}
// Shader Graph "Simple Noise": three octaves, amplitudes 0.125, 0.25, 0.5 (sum 0.875).
float AgentSimpleNoise2D(float2 uv, float scale)
{
float o = 0;
[unroll] for (int octave = 0; octave < 3; octave++)
{
float freq = pow(2.0, float(octave));
float amp = pow(0.5, float(3 - octave));
o += AgentValueNoise2D(uv * (scale / freq)) * amp;
}
return o;
}
float AgentValueNoise3D(float3 p)
{
float3 i = floor(p);
float3 f = frac(p);
f = f * f * (3.0 - 2.0 * f);
float n000, n100, n010, n110, n001, n101, n011, n111;
Hash_Tchou_3_1_float(i, n000);
Hash_Tchou_3_1_float(i + float3(1, 0, 0), n100);
Hash_Tchou_3_1_float(i + float3(0, 1, 0), n010);
Hash_Tchou_3_1_float(i + float3(1, 1, 0), n110);
Hash_Tchou_3_1_float(i + float3(0, 0, 1), n001);
Hash_Tchou_3_1_float(i + float3(1, 0, 1), n101);
Hash_Tchou_3_1_float(i + float3(0, 1, 1), n011);
Hash_Tchou_3_1_float(i + float3(1, 1, 1), n111);
float x00 = lerp(n000, n100, f.x), x10 = lerp(n010, n110, f.x);
float x01 = lerp(n001, n101, f.x), x11 = lerp(n011, n111, f.x);
return lerp(lerp(x00, x10, f.y), lerp(x01, x11, f.y), f.z);
}
// Two-octave 3D value noise, 0..1.
float AgentFbm3D(float3 p)
{
return AgentValueNoise3D(p) * 0.6667 + AgentValueNoise3D(p * 2.03 + 17.1) * 0.3333;
}
#endif // AGENT_NOISE_INCLUDED
scripts/Shaders/AgentPasses.hlsl
// AgentPasses.hlsl (scenario-unity-shaders skill v0.1, 2026-09-24). Shared ShadowCaster, DepthOnly and
// DepthNormals passes for hand-written URP 17.3 shaders (Unity 6000.3).
//
// Why: every pass of one shader must produce the same fragments in the same positions (6.3 Manual,
// depth-only page), so vertex displacement and clip() must run in ShadowCaster, DepthOnly and
// DepthNormals too (Daniel Ilett, bH--RU6qyTw [00:37:52]). Writing them once here keeps them in sync.
//
// Contract. Before including this file the shader:
// 1. includes URP Core.hlsl and declares its CBUFFER_START(UnityPerMaterial) (identical in every
// pass: put it in the SubShader's HLSLINCLUDE),
// 2. optionally #defines AGENT_DISPLACE(positionOS, normalOS, uv) as a statement that edits the
// float3 positionOS / normalOS in place, and AGENT_CLIP(uv, positionOS, positionWS) as a
// statement that calls clip(). Both default to nothing.
// Then a pass selects its functions with #pragma vertex/fragment:
// ShadowCaster : AgentShadowVertex / AgentShadowFragment (+ multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW)
// DepthOnly : AgentDepthOnlyVertex / AgentDepthOnlyFragment (ColorMask R, like URP Lit)
// DepthNormals : AgentDepthOnlyVertex / AgentDepthNormalsFragment
// DepthNormals writes the RAW world normal (URP's _CameraNormalsTexture is signed, and
// SampleSceneNormals does not decode). The 6.3 Manual example returns normal * 0.5 + 0.5, which
// SSAO and normal-based outlines then read as a different normal (verified in URP 17.3 source).
// Run in Unity 6000.3.21f1 on 2026-09-24: tests/code/unity-shaders/ (all shaders built on it compile
// with zero errors and cast, receive and clip shadows).
#ifndef AGENT_URP_PASSES_INCLUDED
#define AGENT_URP_PASSES_INCLUDED
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"
#ifndef AGENT_DISPLACE
#define AGENT_DISPLACE(positionOS, normalOS, uv)
#endif
#ifndef AGENT_CLIP
#define AGENT_CLIP(uv, positionOS, positionWS)
#endif
// Set by URP (ShadowUtils.SetupShadowCasterConstantBuffer), never material properties: outside the CBUFFER.
float3 _LightDirection;
float3 _LightPosition;
struct AgentDepthAttributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct AgentDepthVaryings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 positionOS : TEXCOORD1;
float3 positionWS : TEXCOORD2;
float3 normalWS : TEXCOORD3;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
AgentDepthVaryings AgentDepthCommon(AgentDepthAttributes input, out float3 positionWS, out float3 normalWS)
{
AgentDepthVaryings output = (AgentDepthVaryings)0;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
float3 positionOS = input.positionOS.xyz;
float3 normalOS = input.normalOS;
float2 uv = input.uv;
AGENT_DISPLACE(positionOS, normalOS, uv);
positionWS = TransformObjectToWorld(positionOS);
normalWS = TransformObjectToWorldNormal(normalOS);
output.uv = uv;
output.positionOS = positionOS;
output.positionWS = positionWS;
output.normalWS = normalWS;
return output;
}
AgentDepthVaryings AgentShadowVertex(AgentDepthAttributes input)
{
float3 positionWS, normalWS;
AgentDepthVaryings output = AgentDepthCommon(input, positionWS, normalWS);
#if _CASTING_PUNCTUAL_LIGHT_SHADOW
float3 lightDirectionWS = normalize(_LightPosition - positionWS);
#else
float3 lightDirectionWS = _LightDirection;
#endif
// ApplyShadowBias pushes the caster along its normal (no acne); clamping keeps it in the map.
float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));
output.positionCS = ApplyShadowClamping(positionCS);
return output;
}
half4 AgentShadowFragment(AgentDepthVaryings input) : SV_TARGET
{
UNITY_SETUP_INSTANCE_ID(input);
AGENT_CLIP(input.uv, input.positionOS, input.positionWS);
return 0;
}
AgentDepthVaryings AgentDepthOnlyVertex(AgentDepthAttributes input)
{
float3 positionWS, normalWS;
AgentDepthVaryings output = AgentDepthCommon(input, positionWS, normalWS);
output.positionCS = TransformWorldToHClip(positionWS);
return output;
}
half AgentDepthOnlyFragment(AgentDepthVaryings input) : SV_TARGET
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
AGENT_CLIP(input.uv, input.positionOS, input.positionWS);
return input.positionCS.z;
}
half4 AgentDepthNormalsFragment(AgentDepthVaryings input, bool isFrontFace : SV_IsFrontFace) : SV_TARGET
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
AGENT_CLIP(input.uv, input.positionOS, input.positionWS);
float3 normalWS = NormalizeNormalPerPixel(input.normalWS);
normalWS = isFrontFace ? normalWS : -normalWS; // double-sided (Cull Off) shaders
#if defined(_GBUFFER_NORMALS_OCT)
float2 octNormalWS = PackNormalOctQuadEncode(normalWS);
float2 remapped = saturate(octNormalWS * 0.5 + 0.5);
return half4(PackFloat2To888(remapped), 0.0);
#else
return half4(normalWS, 0.0);
#endif
}
#endif // AGENT_URP_PASSES_INCLUDED
scripts/Shaders/AgentToon.shader
// AgentKit/Toon (scenario-unity-shaders skill v0.1, 2026-09-24): hand-written URP 17.3 toon shading with
// custom lighting, the text twin of MinionsArt's Lit Toon Shader Graph (FIP6I1x6lMA) updated to 6.3.
// ramp = smoothstep(offset, offset + smoothness, halfLambert) * shadowAttenuation (cast shadows join the band)
// color = albedo * lightColor * lerp(shadowTint, 1, ramp) + ambient (tint is the shadow floor)
// rim = step(threshold, fresnel * N.L) (rim only on the lit side)
// Additional lights under Forward+ need the 6.3 cluster loop: LIGHT_LOOP_BEGIN skips directional
// lights, so extra directional lights get their own pre-loop, as URP's Lighting.hlsl does; the macro
// needs a local variable named exactly `inputData` (installed RealtimeLights.hlsl lines 27 to 33).
Shader "AgentKit/Toon"
{
Properties
{
[MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
[MainColor] _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
_ShadowTint ("Shadow Tint (floor)", Color) = (0.35, 0.38, 0.55, 1)
_RampOffset ("Ramp Offset", Range(0, 1)) = 0.5
_RampSmoothness ("Ramp Smoothness", Range(0.001, 0.5)) = 0.03
_AmbientStrength ("Ambient Strength", Range(0, 1)) = 0.35
[HDR] _SpecColor ("Specular Color", Color) = (1, 1, 1, 1)
_SpecSize ("Specular Size", Range(0, 1)) = 0.12
[HDR] _RimColor ("Rim Color", Color) = (1, 1, 1, 1)
_RimPower ("Rim Power", Range(0.5, 12)) = 3
_RimThreshold ("Rim Threshold", Range(0.01, 1)) = 0.35
}
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue" = "Geometry" }
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
half4 _BaseColor;
half4 _ShadowTint;
half _RampOffset;
half _RampSmoothness;
half _AmbientStrength;
half4 _SpecColor;
half _SpecSize;
half4 _RimColor;
half _RimPower;
half _RimThreshold;
CBUFFER_END
ENDHLSL
Pass
{
Name "ToonForward"
Tags { "LightMode" = "UniversalForward" }
ZWrite On
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex ToonVert
#pragma fragment ToonFrag
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile _ _ADDITIONAL_LIGHTS // no per-vertex variant: toon bands need per-pixel light
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile _ _CLUSTER_LIGHT_LOOP
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Fog.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 positionWS : TEXCOORD1;
float3 normalWS : TEXCOORD2;
half fogFactor : TEXCOORD3;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings ToonVert(Attributes input)
{
Varyings output = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
output.positionCS = pos.positionCS;
output.positionWS = pos.positionWS;
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
output.fogFactor = ComputeFogFactor(pos.positionCS.z);
return output;
}
half ToonRamp(half ndl)
{
half halfLambert = ndl * 0.5h + 0.5h;
return smoothstep(_RampOffset, _RampOffset + max(_RampSmoothness, 0.001h), halfLambert);
}
// One additional light: a hard band scaled by its (clamped) attenuation.
half3 ToonAdditional(Light light, half3 normalWS)
{
half atten = saturate(light.distanceAttenuation * light.shadowAttenuation);
return light.color * ToonRamp(dot(normalWS, light.direction)) * atten;
}
half4 ToonFrag(Varyings input) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
half3 albedo = (SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor).rgb;
// LIGHT_LOOP_BEGIN reads inputData.normalizedScreenSpaceUV and inputData.positionWS.
InputData inputData = (InputData)0;
inputData.positionWS = input.positionWS;
inputData.normalWS = NormalizeNormalPerPixel(input.normalWS);
inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);
inputData.shadowMask = half4(1, 1, 1, 1);
half3 N = inputData.normalWS;
half3 V = inputData.viewDirectionWS;
// Main light: toon band times the shadow term.
Light mainLight = GetMainLight(TransformWorldToShadowCoord(input.positionWS), input.positionWS, inputData.shadowMask);
half ndl = dot(N, mainLight.direction);
half ramp = ToonRamp(ndl) * mainLight.shadowAttenuation;
half3 lightColor = mainLight.color * mainLight.distanceAttenuation;
half3 color = albedo * lightColor * lerp(_ShadowTint.rgb, 1.0h, ramp);
// Stylised specular dot and lit-side rim (Fresnel x N.L, then a step).
half3 H = SafeNormalize(mainLight.direction + V);
half spec = step(1.0h - _SpecSize * 0.1h, dot(N, H)) * ramp;
half fresnel = pow(1.0h - saturate(dot(N, V)), _RimPower);
half rim = step(_RimThreshold, fresnel * saturate(ndl)) * ramp;
color += spec * _SpecColor.rgb * lightColor + rim * _RimColor.rgb * lightColor;
// Ambient once (indirect light), never per light.
color += albedo * SampleSH(N) * _AmbientStrength;
#if defined(_ADDITIONAL_LIGHTS)
half3 additional = 0;
uint lightCount = GetAdditionalLightsCount();
#if USE_CLUSTER_LIGHT_LOOP
// Forward+: extra directional lights are NOT in the cluster loop below.
[loop] for (uint dirIndex = 0; dirIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); dirIndex++)
{
additional += ToonAdditional(GetAdditionalLight(dirIndex, inputData.positionWS, inputData.shadowMask), N);
}
#endif
LIGHT_LOOP_BEGIN(lightCount)
additional += ToonAdditional(GetAdditionalLight(lightIndex, inputData.positionWS, inputData.shadowMask), N);
LIGHT_LOOP_END
color += albedo * additional;
#endif
color = MixFog(color, InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactor));
return half4(color, 1);
}
ENDHLSL
}
Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
ZTest LEqual
ColorMask 0
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentShadowVertex
#pragma fragment AgentShadowFragment
#pragma multi_compile_instancing
#pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
#include "AgentPasses.hlsl"
ENDHLSL
}
Pass
{
Name "DepthOnly"
Tags { "LightMode" = "DepthOnly" }
ZWrite On
ColorMask R
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentDepthOnlyVertex
#pragma fragment AgentDepthOnlyFragment
#pragma multi_compile_instancing
#include "AgentPasses.hlsl"
ENDHLSL
}
Pass
{
Name "DepthNormals"
Tags { "LightMode" = "DepthNormals" }
ZWrite On
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex AgentDepthOnlyVertex
#pragma fragment AgentDepthNormalsFragment
#pragma multi_compile_instancing
#include "AgentPasses.hlsl"
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
scripts/Shaders/AgentWater.shader
// AgentKit/StylizedWater (scenario-unity-shaders skill v0.1, 2026-09-24): hand-written URP 17.3 transparent
// water, the text twin of Unity's URP Water Shader Graph (gRq-IdShxpU) with its beginner shortcuts
// fixed. One 0..1 depth mask drives colour, alpha and normal strength at the shore:
// waterDepth = sceneEyeDepth - surfaceEyeDepth (metres along the view ray), g = saturate(waterDepth / _DepthDistance)
// Needs the camera Depth Texture on EVERY URP asset that renders it (URP asset > Depth Texture).
// Fixes versus the tutorial [added]: waves are ADDED to Y with an analytic normal (not a replaced Y),
// two scrolled normal samples are blended with RNM (not added), UVs come from world XZ so tiles stay
// continuous, time is _Time.y * _TimeScale + _TimeOffset so captures are deterministic
// (_TimeScale = 0 freezes it). Transparent: no ShadowCaster, renderer Cast Shadows Off.
// Perspective cameras only (orthographic eye depth is linear in raw depth).
Shader "AgentKit/StylizedWater"
{
Properties
{
_ShallowColor ("Shallow Color", Color) = (0.20, 0.80, 0.80, 0.30)
_DeepColor ("Deep Color", Color) = (0.02, 0.16, 0.34, 0.92)
_DepthDistance ("Depth Distance (m)", Float) = 2.0
_FoamColor ("Foam Color", Color) = (1, 1, 1, 1)
_FoamDistance ("Foam Distance (m)", Float) = 0.35
_FoamNoiseScale ("Foam Noise Scale", Float) = 6
[Normal][NoScaleOffset] _NormalMap ("Normal Map", 2D) = "bump" {}
_NormalTiling ("Normal Tiling (per m)", Float) = 0.35
_NormalStrength ("Normal Strength", Range(0, 1)) = 0.6
_ScrollA ("Scroll A (m/s, xy)", Vector) = (0.035, 0.02, 0, 0)
_ScrollB ("Scroll B (m/s, xy)", Vector) = (-0.02, 0.03, 0, 0)
_Smoothness ("Smoothness", Range(0, 1)) = 0.9
_WaveAmplitude ("Wave Amplitude (m)", Float) = 0.06
_WaveLength ("Wave Length (m)", Float) = 5
_WaveSpeed ("Wave Speed (m/s)", Float) = 1.2
_TimeScale ("Time Scale (0 = frozen)", Float) = 1
_TimeOffset ("Time Offset (s)", Float) = 0
}
SubShader
{
Tags { "RenderType" = "Transparent" "Queue" = "Transparent" "RenderPipeline" = "UniversalPipeline" "IgnoreProjector" = "True" }
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
TEXTURE2D(_NormalMap); SAMPLER(sampler_NormalMap);
CBUFFER_START(UnityPerMaterial)
half4 _ShallowColor;
half4 _DeepColor;
float _DepthDistance;
half4 _FoamColor;
float _FoamDistance;
float _FoamNoiseScale;
float _NormalTiling;
half _NormalStrength;
float4 _ScrollA;
float4 _ScrollB;
half _Smoothness;
float _WaveAmplitude;
float _WaveLength;
float _WaveSpeed;
float _TimeScale;
float _TimeOffset;
CBUFFER_END
ENDHLSL
Pass
{
Name "WaterForward"
Tags { "LightMode" = "UniversalForward" }
Blend SrcAlpha OneMinusSrcAlpha // traditional alpha blend
ZWrite Off // transparent = ZWrite Off AND the Transparent queue (Freya Holmer, kfM-yu0iQBk [02:44:37])
Cull Back
HLSLPROGRAM
#pragma target 3.0
#pragma vertex WaterVert
#pragma fragment WaterFrag
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Fog.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
#include "AgentNoise.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float3 positionWS : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float eyeDepth : TEXCOORD2;
half fogFactor : TEXCOORD3;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
float WaterTime() { return _Time.y * _TimeScale + _TimeOffset; }
// Two crossing sine waves in world XZ; returns height and writes the analytic normal.
float WaveHeight(float2 xz, float t, out float3 normalWS)
{
float k = TWO_PI / max(_WaveLength, 0.01);
float2 d1 = float2(0.8, 0.6), d2 = float2(-0.45, 0.89);
float p1 = k * dot(d1, xz) + k * _WaveSpeed * t;
float p2 = 1.7 * k * dot(d2, xz) + 1.3 * k * _WaveSpeed * t;
float h = _WaveAmplitude * (sin(p1) + 0.5 * sin(p2));
float2 dh = _WaveAmplitude * (k * cos(p1) * d1 + 0.5 * 1.7 * k * cos(p2) * d2);
normalWS = normalize(float3(-dh.x, 1.0, -dh.y));
return h;
}
Varyings WaterVert(Attributes input)
{
Varyings output = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
float3 waveNormal;
positionWS.y += WaveHeight(positionWS.xz, WaterTime(), waveNormal); // added, not replaced
output.positionWS = positionWS;
output.normalWS = waveNormal;
output.positionCS = TransformWorldToHClip(positionWS);
output.eyeDepth = -TransformWorldToView(positionWS).z;
output.fogFactor = ComputeFogFactor(output.positionCS.z);
return output;
}
half4 WaterFrag(Varyings input) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
float t = WaterTime();
// Depth fade. Screen UV from _ScaledScreenParams (dynamic resolution safe, 6.3 Manual).
float2 screenUV = input.positionCS.xy / _ScaledScreenParams.xy;
float sceneEye = LinearEyeDepth(SampleSceneDepth(screenUV), _ZBufferParams);
float waterDepth = max(sceneEye - input.eyeDepth, 0.0);
half g = saturate(waterDepth / max(_DepthDistance, 0.001));
half4 water = lerp(_ShallowColor, _DeepColor, g);
// Detail normals: one map, two scroll directions and scales, RNM blend, calm at the shore.
float2 uv = input.positionWS.xz * _NormalTiling;
half3 n1 = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv + _ScrollA.xy * t));
half3 n2 = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv * 1.37 + _ScrollB.xy * t));
half3 nTS = BlendNormalRNM(n1, n2);
nTS.xy *= _NormalStrength * saturate(g * 2.0);
half3 N = normalize(input.normalWS + half3(nTS.x, 0, nTS.y));
// Shore foam: a noise-broken band where the water is shallower than _FoamDistance.
half shore = 1.0h - saturate(waterDepth / max(_FoamDistance, 0.001));
half foamNoise = AgentGradientNoise2D(input.positionWS.xz + float2(t * 0.05, t * 0.03), _FoamNoiseScale);
half foam = step(foamNoise, shore);
// Lighting: main light (with shadows), sky from the ambient probe, Blinn specular, Schlick-style fresnel.
Light mainLight = GetMainLight(TransformWorldToShadowCoord(input.positionWS));
half3 V = GetWorldSpaceNormalizeViewDir(input.positionWS);
half3 H = SafeNormalize(mainLight.direction + V);
half ndl = saturate(dot(N, mainLight.direction));
half shadow = mainLight.shadowAttenuation;
half3 ambient = SampleSH(N);
half3 color = water.rgb * (ambient + mainLight.color * (0.4h + 0.6h * ndl) * shadow);
half specPower = exp2(10.0h * _Smoothness + 1.0h);
color += mainLight.color * shadow * pow(saturate(dot(N, H)), specPower) * _Smoothness * 2.0h;
half fresnel = pow(1.0h - saturate(dot(N, V)), 5.0h);
color += ambient * fresnel * 0.6h;
half alpha = saturate(water.a + fresnel * 0.4h);
color = lerp(color, _FoamColor.rgb * (ambient + mainLight.color * shadow), foam * _FoamColor.a);
alpha = max(alpha, foam * _FoamColor.a);
color = MixFog(color, InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactor));
return half4(color, alpha);
}
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
scripts/ut_shaders.py
"""
ut_shaders: the runner-side tools of the scenario-unity-shaders skill (technical artist, shading).
System python3 (3.9+), stdlib only (Pillow optional through ut_review). Imports the shared toolkit of
the scenario-unity-expert skill; never copies it.
import sys
sys.path.insert(0, "<skills>/scenario-unity-expert/scripts"); sys.path.insert(0, "<skills>/scenario-unity-shaders/scripts")
import ut_env, ut_run, ut_review, ut_shaders
P = ut_env.base_project("3d", "<project>/tests/projects/unity-shaders")
ut_shaders.install(P) # AgentKit/Shaders C# + runtime feature + shader templates
lint = ut_shaders.lint_shader_file(P + "/Assets/AgentShaders/Shaders/AgentLit.shader")
r = ut_run.run_method(P, "AgentKit.Shaders.ShaderJobs.ValidateShaders", {"paths": ["Assets/AgentShaders/Shaders"]})
Functions
install(project) copy C# jobs (Assets/Editor/AgentKit/Shaders), the runtime renderer
feature (Assets/AgentShaders/Runtime) and shader templates
(Assets/AgentShaders/Shaders); returns files written
lint_shader(text, name) static checks of a hand-written URP .shader (findings list)
lint_shader_file(path) same, from a file
lint_custom_function(text, name) Shader Graph File-mode Custom Function .hlsl rules
lint_compute(text, name) portable compute rules (Metal, GLES, Vulkan)
variant_estimate(text) per pass: keyword sets and the full variant space before any stripping
parse_variant_funnel(log_text) Unity's "Compiling shader" funnel from an Editor or job log (6.3 format)
variant_budget_check(totals, budgets, baseline) CI gate: over budget or growing
shadergraph_summary(path) target, properties, keywords, Custom Function nodes, URP Graph Settings
(alpha clip, material override) and findings of a .shadergraph
shadergraph_repoint_custom_function(path, fn, guid, new_fn) String-mode node -> File mode on an agent .hlsl
shadergraph_set_target_settings(path, alpha_clip=, allow_material_override=, ...) URP Graph Settings by text
generated_alpha_clip_report(text) per pass: _ALPHATEST_ON compiled in, keyword-controlled, or absent
keyword_declarations(shaders, graphs) keyword -> shader_feature / multi_compile / dynamic_branch, local
lint_runtime_keyword_toggles(cs, decl) runtime C# toggling a shader_feature keyword (stripped in players)
lint_project_keywords(project) the same over a project (Editor folders skipped)
pso_collection_name(platform, api) one .graphicsstate per graphics API and platform
pso_player_command / run_pso_player run a development player with AgentPsoProbe (trace, cold, warm)
count_compiled_shaders(log) first-use shader uploads logged in gameplay (Log Shader Compilation, 6.3 format)
pso_gate(trace, cold, warm) findings for the warm-up proof
asset_guid(asset_path) GUID from the .meta
urp_lit_keywords(editor=None) the multi_compile lines of the INSTALLED URP Lit.shader forward pass
sample(png, x, y, r=2) mean RGB (0-255) of a (2r+1)^2 block of a PNG
region_stats(png, box) mean RGB, R==G==B share, luma of a box (x0, y0, x1, y1)
findings_summary(findings) counts per severity
Every function is exercised by tests/code/unity-shaders/test_offline.py; the Unity-side jobs by
tests/code/unity-shaders/test_live_*.py (Unity 6000.3.21f1, 2026-09-24).
"""
__version__ = "0.1" # scenario-unity-shaders v0.1 (2026-09-24, refactor after the Y4 blind grade)
import json
import math
import os
import re
from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
SKILLS = os.path.dirname(os.path.dirname(HERE))
EXPERT_SCRIPTS = os.path.join(SKILLS, "scenario-unity-expert", "scripts")
import sys # noqa: E402
if EXPERT_SCRIPTS not in sys.path:
sys.path.insert(0, EXPERT_SCRIPTS)
import ut_env # noqa: E402
AGENTKIT_SRC = os.path.join(HERE, "AgentKit")
RUNTIME_SRC = os.path.join(HERE, "Runtime")
SHADERS_SRC = os.path.join(HERE, "Shaders")
# ============================================================================ install
def _copy_if_changed(src, dst, written, root):
with open(src, "rb") as f:
data = f.read()
if os.path.isfile(dst):
with open(dst, "rb") as f:
if f.read() == data:
return
os.makedirs(os.path.dirname(dst), exist_ok=True)
with open(dst, "wb") as f:
f.write(data)
written.append(os.path.relpath(dst, root))
def install(project):
"""Install the scenario-unity-shaders assets into a project (idempotent, content-compared):
- scripts/AgentKit/Shaders/*.cs -> Assets/Editor/AgentKit/Shaders/ (editor jobs; via ut_env.install_agentkit)
- scripts/Runtime/*.cs -> Assets/AgentShaders/Runtime/ (renderer feature: runtime assembly,
an Editor folder would break players)
- scripts/Shaders/* -> Assets/AgentShaders/Shaders/ (.shader, .hlsl, .compute templates)
The core AgentKit is installed too (ut_env.install_agentkit). Unity compiles and imports on the next
batch start. Returns the list of project-relative files written."""
root = ut_env.find_project(project)["root"]
written = list(ut_env.install_agentkit(root))
written += ut_env.install_agentkit(root, src=AGENTKIT_SRC)
for src_dir, rel in ((RUNTIME_SRC, os.path.join("Assets", "AgentShaders", "Runtime")),
(SHADERS_SRC, os.path.join("Assets", "AgentShaders", "Shaders"))):
for fn in sorted(os.listdir(src_dir)):
if fn.startswith(".") or fn.endswith(".meta"):
continue
_copy_if_changed(os.path.join(src_dir, fn), os.path.join(root, rel, fn), written, root)
return written
# ============================================================================ shader text helpers
_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.S)
_COMMENT_LINE = re.compile(r"//[^\n]*")
def strip_comments(text):
"""Remove // and /* */ comments (keeps line count for block comments approximately)."""
text = _COMMENT_BLOCK.sub(lambda m: "\n" * m.group(0).count("\n"), text)
return _COMMENT_LINE.sub("", text)
def _blocks(text, keyword):
"""Yield (start, body) of every `keyword { ... }` block with balanced braces."""
for m in re.finditer(r"\b%s\b\s*\{" % keyword, text):
depth, i = 1, m.end()
while i < len(text) and depth:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
yield m.start(), text[m.end():i - 1]
def _between(text, start_tok, end_tok):
out = []
for m in re.finditer(re.escape(start_tok), text):
e = text.find(end_tok, m.end())
if e > 0:
out.append(text[m.end():e])
return out
def _properties(text):
"""Properties block entries -> [(name, type)]."""
props = []
for _s, body in _blocks(text, "Properties"):
body = re.sub(r"\[[^\]]*\]", "", body) # drop [HDR], [Toggle(X)], [MainColor]...
for m in re.finditer(r"(_\w+)\s*\(\s*\"[^\"]*\"\s*,\s*([A-Za-z0-9]+)", body):
props.append((m.group(1), m.group(2)))
break
return props
def _passes(text):
"""Pass blocks -> list of dicts {name, light_mode, body, state}."""
res = []
for _s, body in _blocks(text, "Pass"):
name = re.search(r"\bName\s+\"([^\"]+)\"", body)
lm = re.search(r"\"LightMode\"\s*=\s*\"([^\"]+)\"", body)
res.append({"name": name.group(1) if name else None, "light_mode": lm.group(1) if lm else None, "body": body})
return res
def _finding(findings, severity, code, message, fix, where=""):
findings.append({"severity": severity, "code": "shaders." + code, "path": where, "message": message, "fix": fix})
# ============================================================================ lint: URP .shader
def lint_shader(text, name="shader"):
"""Static checks for a hand-written URP 17.3 shader (the rules the experts and the 6.3 docs agree on).
Returns findings in the AgentKit format {severity, code, path, message, fix}. A clean lint is a
precondition, not proof: compile with ShaderJobs.ValidateShaders and render the result."""
f = []
raw = text
t = strip_comments(text)
passes = _passes(t)
props = _properties(t)
is_fullscreen = "Blit.hlsl" in t or "_BlitTexture" in t
tags = " ".join(re.findall(r"Tags\s*\{[^}]*\}", t))
transparent = bool(re.search(r"\"Queue\"\s*=\s*\"Transparent", tags)) or bool(re.search(r"\"RenderType\"\s*=\s*\"Transparent\"", tags))
if re.search(r"\bCGPROGRAM\b|\bCGINCLUDE\b", t):
_finding(f, "error", "cgprogram", "CGPROGRAM/CGINCLUDE pulls Built-in includes that clash with the URP library",
"use HLSLPROGRAM/HLSLINCLUDE and Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl", name)
if "UnityCG.cginc" in t:
_finding(f, "error", "unitycg", "UnityCG.cginc is Built-in: redefinition errors next to URP includes",
"include URP Core.hlsl; map functions with the Built-in to URP table (references/procedures.md)", name)
if re.search(r"#pragma\s+surface\b", t):
_finding(f, "error", "surface_shader", "Surface Shaders do not exist in URP", "vertex/fragment + SurfaceData/InputData + UniversalFragmentPBR", name)
if re.search(r"\bGrabPass\b", t):
_finding(f, "error", "grabpass", "GrabPass does not exist in URP", "DeclareOpaqueTexture.hlsl + SampleSceneColor (transparent queue only)", name)
if re.search(r"\bUsePass\b", t):
_finding(f, "error", "usepass", "UsePass breaks SRP Batcher compatibility (different UnityPerMaterial)",
"write the pass with the shared AgentPasses.hlsl or URP's pass includes", name)
if re.search(r"\bfixed[234]?\b", t):
_finding(f, "warn", "fixed_type", "`fixed` is not an HLSL type", "use half", name)
if "RenderPipeline" not in tags or "UniversalPipeline" not in tags:
_finding(f, "error", "rp_tag", "SubShader lacks \"RenderPipeline\"=\"UniversalPipeline\"",
"add the tag (the value is UniversalPipeline, not UniversalRenderPipeline)", name)
if "Core.hlsl" not in t:
_finding(f, "error", "no_core", "URP Core.hlsl is not included", "#include \"Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl\"", name)
if "_FORWARD_PLUS" in t:
_finding(f, "warn", "forward_plus_keyword", "_FORWARD_PLUS is deprecated since 6.1 (compiles with a warning, slower compiles)",
"declare #pragma multi_compile _ _CLUSTER_LIGHT_LOOP", name)
if "SHADOWS_SCREEN" in re.sub(r"_MAIN_LIGHT_SHADOWS_SCREEN", "", t):
_finding(f, "warn", "built_in_shadow_keyword", "SHADOWS_SCREEN is a Built-in keyword; URP never defines it (dead branch)",
"call TransformWorldToShadowCoord + GetMainLight(shadowCoord) unconditionally", name)
if "ComputeScreenPos" in t:
_finding(f, "warn", "compute_screen_pos", "ComputeScreenPos is deprecated in URP", "GetVertexPositionInputs().positionNDC or positionCS.xy / _ScaledScreenParams.xy", name)
# SRP Batcher: one UnityPerMaterial CBUFFER holding every non-texture property
cbuffers = _between(t, "CBUFFER_START(UnityPerMaterial)", "CBUFFER_END")
if props and not cbuffers:
_finding(f, "error", "no_cbuffer", "properties are not in a UnityPerMaterial CBUFFER: not SRP Batcher compatible",
"declare them between CBUFFER_START(UnityPerMaterial) and CBUFFER_END in HLSLINCLUDE", name)
distinct = {re.sub(r"\s+", " ", c.strip()) for c in cbuffers}
if len(distinct) > 1:
_finding(f, "error", "cbuffer_mismatch", "UnityPerMaterial differs between passes: SRP Batcher incompatible",
"declare it once in the SubShader HLSLINCLUDE", name)
if cbuffers:
cb = cbuffers[0]
code = "\n".join(_between(t, "HLSLPROGRAM", "ENDHLSL") + _between(t, "HLSLINCLUDE", "ENDHLSL"))
for pname, ptype in props:
if ptype.lower() in ("2d", "3d", "cube", "2darray", "cubearray", "any"):
if re.search(r"TRANSFORM_TEX\s*\([^,]+,\s*%s\s*\)" % re.escape(pname), code) and (pname + "_ST") not in cb:
_finding(f, "error", "st_missing", "%s uses TRANSFORM_TEX but %s_ST is not in the CBUFFER" % (pname, pname),
"add float4 %s_ST; to UnityPerMaterial" % pname, name)
continue
in_cb = re.search(r"\b%s\b" % re.escape(pname), cb)
used = re.search(r"\b%s\b" % re.escape(pname), code)
if used and not in_cb:
_finding(f, "error", "prop_outside_cbuffer", "property %s is used in HLSL but not in UnityPerMaterial" % pname,
"declare it inside the CBUFFER (values bleed between batched materials otherwise)", name)
# passes an opaque lit or unlit object needs
modes = [p["light_mode"] for p in passes]
if not is_fullscreen and not transparent:
if "UniversalForward" not in modes and "UniversalForwardOnly" not in modes and "SRPDefaultUnlit" not in modes and None not in modes:
_finding(f, "warn", "no_forward", "no UniversalForward pass", "tag the forward pass \"LightMode\"=\"UniversalForward\"", name)
if "DepthOnly" not in modes:
_finding(f, "error", "no_depthonly", "opaque shader without a DepthOnly pass renders INVISIBLE with Depth Priming (6.3 Manual)",
"add a DepthOnly pass (AgentPasses.hlsl AgentDepthOnlyVertex/Fragment)", name)
if "DepthNormals" not in modes:
_finding(f, "warn", "no_depthnormals", "no DepthNormals pass: missing from SSAO and normal-based outlines",
"add a DepthNormals pass writing the raw world normal", name)
if "ShadowCaster" not in modes:
_finding(f, "warn", "no_shadowcaster", "no ShadowCaster pass: the object casts no shadow", "add a ShadowCaster pass with ApplyShadowBias", name)
lit = any("Lighting.hlsl" in p["body"] or "GetMainLight" in p["body"] for p in passes) or "UniversalFragmentPBR" in t
if lit and not transparent and "_CLUSTER_LIGHT_LOOP" not in t:
_finding(f, "error", "no_cluster_loop", "lit shader without _CLUSTER_LIGHT_LOOP: additional lights missing under Forward+/Deferred+",
"#pragma multi_compile _ _CLUSTER_LIGHT_LOOP and loop with LIGHT_LOOP_BEGIN (or UniversalFragmentPBR)", name)
if lit and "_MAIN_LIGHT_SHADOWS" not in t:
_finding(f, "warn", "no_shadow_keywords", "lit shader without the main light shadow keywords: shadowAttenuation stays 1",
"#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN", name)
if re.search(r"_SHADOWS_SOFT\b", t) and "_SHADOWS_SOFT_LOW" not in t:
_finding(f, "warn", "soft_shadow_set", "6.3 soft shadows have quality variants", "#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH", name)
if "LIGHT_LOOP_BEGIN" in t:
if not re.search(r"InputData\s+inputData\b", t):
_finding(f, "error", "light_loop_inputdata", "LIGHT_LOOP_BEGIN reads a local named exactly `inputData`",
"declare InputData inputData with positionWS and normalizedScreenSpaceUV set", name)
if "URP_FP_DIRECTIONAL_LIGHTS_COUNT" not in t:
_finding(f, "warn", "no_directional_preloop", "cluster loop without the directional pre-loop: extra directional lights are skipped under Forward+",
"loop lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS) first, under #if USE_CLUSTER_LIGHT_LOOP", name)
# pass consistency: displacement or clip in forward must reach depth and shadow passes
fwd = [p for p in passes if p["light_mode"] in ("UniversalForward", "UniversalForwardOnly")]
others = [p for p in passes if p["light_mode"] in ("ShadowCaster", "DepthOnly", "DepthNormals")]
shared = "AGENT_CLIP" in t or "AGENT_DISPLACE" in t
for p in fwd:
if re.search(r"\bclip\s*\(", p["body"]) and not shared:
for o in others:
if not re.search(r"\bclip\s*\(|Alpha\(", o["body"]) and "AgentPasses.hlsl" not in o["body"]:
_finding(f, "error", "clip_not_shared", "forward pass clips but %s does not: shadows and depth keep the clipped parts" % o["light_mode"],
"share the clip through AGENT_CLIP (AgentPasses.hlsl) or repeat it", name)
# transparency hygiene
for p in passes:
b = p["body"]
blend = re.search(r"\bBlend\s+(\w+)\s+(\w+)", b)
if blend and (blend.group(1), blend.group(2)) != ("One", "Zero") and not re.search(r"\bZWrite\s+Off\b", b) and not is_fullscreen:
_finding(f, "warn", "blend_zwrite", "pass %s blends but writes depth" % (p["name"] or p["light_mode"]),
"ZWrite Off and the Transparent queue (both, Freya Holmer)", name)
if blend and not transparent and not is_fullscreen and (blend.group(1), blend.group(2)) != ("One", "Zero"):
_finding(f, "warn", "blend_queue", "blending pass in a non-Transparent queue", "Tags { \"Queue\"=\"Transparent\" }", name)
# vertex texture fetch without an explicit LOD
for fn in re.finditer(r"\w+\s+(\w+)\s*\(\s*Attributes[^)]*\)\s*\{", t):
depth, i = 1, fn.end()
while i < len(t) and depth:
depth += {"{": 1, "}": -1}.get(t[i], 0)
i += 1
body = t[fn.end():i]
if re.search(r"SAMPLE_TEXTURE2D(_X)?\s*\(", body):
_finding(f, "error", "vertex_fetch_lod", "%s samples a texture without LOD in the vertex stage" % fn.group(1),
"SAMPLE_TEXTURE2D_LOD(tex, sampler, uv, 0): the vertex stage has no derivatives", name)
# dynamic_branch keywords must be tested with if, not #if
for kw_line in re.findall(r"#pragma\s+dynamic_branch\w*\s+([^\n]+)", t):
for kw in kw_line.split():
if kw != "_" and re.search(r"#\s*(?:if|ifdef|elif)\b[^\n]*\b%s\b" % re.escape(kw), t):
_finding(f, "error", "dynamic_branch_preprocessor", "%s is dynamic_branch but tested with #if: always false" % kw,
"use if (%s) in HLSL" % kw, name)
# if (KEYWORD) needs the keyword declared for every stage: a _vertex/_fragment-suffixed declaration makes the
# other stage fail with "a known keyword but not declared in this pass" (observed on 6000.3.21f1)
for m in re.finditer(r"#pragma\s+(?:multi_compile|shader_feature|dynamic_branch)(?:_local)?_(vertex|fragment|hull|domain|geometry|raytracing)\s+([^\n]+)", t):
for kw in m.group(2).split():
if kw != "_" and re.search(r"\bif\s*\(\s*!?\s*%s\b" % re.escape(kw), t):
_finding(f, "error", "if_keyword_stage_suffix",
"%s is declared for the %s stage only but tested with if(): the other stage does not compile "
"(\"a known keyword but not declared in this pass\")" % (kw, m.group(1)),
"declare it without the stage suffix (the price of if-style keywords), or test it with #if", name)
if re.search(r"_Color\b|_MainTex\b", raw) and not is_fullscreen:
_finding(f, "info", "reserved_names", "_Color/_MainTex are treated as the main color/texture even without attributes", "prefer _BaseColor/_BaseMap with [MainColor]/[MainTexture]", name)
return f
def lint_shader_file(path):
with open(path, encoding="utf-8", errors="replace") as fh:
return lint_shader(fh.read(), os.path.basename(path))
# ============================================================================ lint: Custom Function .hlsl
def lint_custom_function(text, name="custom_function.hlsl", used_function_names=None):
"""Rules for a Shader Graph 17.3 File-mode Custom Function include (Shader Graph manual, Daniel Ilett
F8bAI6dIrto): unique include guard, every entry function suffixed _float or _half, void return with
inputs first then `out` parameters, a SHADERGRAPH_PREVIEW branch when scene data (lights, depth) is
read, no uniforms meant per material, no Built-in shadow keywords."""
f = []
t = strip_comments(text)
g = re.search(r"#ifndef\s+(\w+)\s*\n\s*#define\s+(\w+)", t)
if not g or g.group(1) != g.group(2):
_finding(f, "error", "cf_guard", "no include guard: a second node including the file redefines every function",
"#ifndef MYFILE_INCLUDED / #define MYFILE_INCLUDED / #endif, unique per file", name)
funcs = re.findall(r"\bvoid\s+(\w+)\s*\(([^)]*)\)", t)
if not funcs:
_finding(f, "error", "cf_no_void", "no void function: Custom Function entry points return through out parameters", "void Name_float(in..., out ...)", name)
for fn, params in funcs:
if not re.search(r"_(float|half)$", fn):
if used_function_names and fn in used_function_names:
_finding(f, "error", "cf_suffix", "%s has no _float/_half suffix" % fn, "rename to %s_float (the node Name field stays %s)" % (fn, fn), name)
else:
_finding(f, "info", "cf_helper", "%s has no precision suffix (fine for a helper, not for a node entry point)" % fn, "", name)
ps = [p.strip() for p in params.split(",") if p.strip()]
seen_out = False
for p in ps:
is_out = p.startswith("out ") or p.startswith("inout ")
if seen_out and not is_out:
_finding(f, "warn", "cf_param_order", "%s: an input follows an out parameter" % fn, "inputs first, then out parameters, matching the node ports", name)
break
seen_out = seen_out or is_out
reads_scene = re.search(r"GetMainLight|GetAdditionalLight|SampleSceneDepth|SampleSceneNormals|_CameraDepthTexture|LIGHT_LOOP_BEGIN", t)
if reads_scene and "SHADERGRAPH_PREVIEW" not in t:
_finding(f, "error", "cf_preview", "reads lights or camera textures without a SHADERGRAPH_PREVIEW branch: node previews fail to compile",
"#ifdef SHADERGRAPH_PREVIEW constant outputs #else real code #endif", name)
if "SHADOWS_SCREEN" in re.sub(r"_MAIN_LIGHT_SHADOWS_SCREEN", "", t) or "ComputeScreenPos" in t:
_finding(f, "warn", "cf_legacy", "SHADOWS_SCREEN / ComputeScreenPos are Built-in era", "TransformWorldToShadowCoord(positionWS) + GetMainLight(shadowCoord)", name)
if "LIGHT_LOOP_BEGIN" in t and "URP_FP_DIRECTIONAL_LIGHTS_COUNT" not in t:
_finding(f, "warn", "cf_directional_preloop", "cluster loop without the directional pre-loop: extra directional lights are skipped under Forward+",
"loop min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS) first", name)
top = re.sub(r"\{[^{}]*\}", "", t) # crude: drop innermost bodies
for m in re.finditer(r"^\s*(?:uniform\s+)?(float[234]?(?:x[234])?|half[234]?|int|uint)\s+(_\w+)\s*;", top, re.M):
_finding(f, "warn", "cf_uniform", "%s is a file-scope uniform: global only (Shader.SetGlobalX), never per material" % m.group(2),
"feed per-material values through a node input from a Blackboard property", name)
return f
# ============================================================================ lint: compute
def lint_compute(text, name="compute"):
"""Portable compute rules (6.3 Manual, multiple platforms; matters on this Mac's Metal)."""
f = []
for i, line in enumerate(text.splitlines(), 1):
if re.match(r"\s*#pragma\s+kernel\b.*//", line):
_finding(f, "error", "cs_kernel_comment", "line %d: // comment on a #pragma kernel line is a compile error" % i, "move the comment to its own line", name)
t = strip_comments(text)
kernels = re.findall(r"#pragma\s+kernel\s+(\w+)", t)
if not kernels:
_finding(f, "error", "cs_no_kernel", "no #pragma kernel", "#pragma kernel Name", name)
if re.search(r"\.GetDimensions\s*\(", t) and re.search(r"(StructuredBuffer|ByteAddressBuffer|Buffer)<?", t):
for m in re.finditer(r"(\w+)\.GetDimensions", t):
decl = re.search(r"(RW)?(Structured|ByteAddress)?Buffer<[^>]*>\s+%s\b|(RW)?ByteAddressBuffer\s+%s\b" % (m.group(1), m.group(1)), t)
if decl:
_finding(f, "error", "cs_getdimensions", "%s.GetDimensions on a buffer: unsupported on Metal" % m.group(1), "pass the element count as a constant", name)
for m in re.finditer(r"Interlocked\w+\s*\(\s*(\w+)", t):
if re.search(r"RWTexture\w*<[^>]*>\s+%s\b" % re.escape(m.group(1)), t):
_finding(f, "error", "cs_texture_atomic", "atomic on RWTexture %s: Metal has no texture atomics" % m.group(1), "atomics on a RWStructuredBuffer<uint>", name)
if re.search(r"\bCGPROGRAM\b|\bGLSLPROGRAM\b", t):
_finding(f, "warn", "cs_platform_block", "CGPROGRAM/GLSLPROGRAM blocks are skipped or emitted verbatim per platform", "plain HLSL", name)
for k in kernels:
m = re.search(r"\bvoid\s+%s\s*\(([^)]*)\)\s*\{" % k, t)
if not m:
_finding(f, "error", "cs_kernel_missing", "kernel %s declared but not defined" % k, "", name)
continue
body = t[m.end(): m.end() + 3000]
if "SV_DispatchThreadID" in m.group(1) and not re.search(r"if\s*\([^)]*>=[^)]*\)\s*return", body):
_finding(f, "warn", "cs_bounds", "kernel %s has no bounds check: out-of-bounds access can hang non-DX GPUs" % k,
"if (id.x >= _Count) return; with ceil-divided dispatch", name)
return f
# ============================================================================ variants
_PRAGMA_KW = re.compile(r"#pragma\s+(multi_compile|shader_feature|dynamic_branch)(_local)?(_vertex|_fragment|_hull|_domain|_geometry|_raytracing)?\s+([^\n]+)")
_PRAGMA_BUILTIN = {
"multi_compile_instancing": ["_", "INSTANCING_ON"],
"multi_compile_fog": ["_", "FOG_LINEAR", "FOG_EXP", "FOG_EXP2"],
"multi_compile_fwdbase": None,
}
def variant_estimate(text):
"""Per pass, the keyword sets and the FULL variant space (product of set sizes; shader_feature sets
count fully here because the upper bound is what a material combination could reach). Includes
multi_compile_instancing and, for `#include_with_pragmas ".../Fog.hlsl"`, the fog set (4). Stage
suffixes are reported but not applied (they are ignored on GL, GLES and Vulkan). This is the
'Full variant space' before settings filtering and stripping; compare with parse_variant_funnel."""
t = strip_comments(text)
shared = "".join(b for b in _between(t, "HLSLINCLUDE", "ENDHLSL"))
out = []
for p in _passes(t):
body = p["body"] + "\n" + shared
sets = []
for m in _PRAGMA_KW.finditer(body):
kws = m.group(4).split()
n = len(kws) if m.group(1) != "dynamic_branch" else 1
if m.group(1) == "shader_feature" and len(kws) == 1:
n = 2 # shader_feature X == shader_feature _ X
sets.append({"directive": m.group(1) + (m.group(2) or "") + (m.group(3) or ""), "keywords": kws, "variants": n})
for d, kws in _PRAGMA_BUILTIN.items():
if kws and re.search(r"#pragma\s+%s\b" % d, body):
sets.append({"directive": d, "keywords": kws, "variants": len(kws)})
if re.search(r"#include_with_pragmas\s+\"[^\"]*Fog\.hlsl\"", body):
sets.append({"directive": "multi_compile_fog (Fog.hlsl)", "keywords": _PRAGMA_BUILTIN["multi_compile_fog"], "variants": 4})
total = 1
for s in sets:
total *= max(1, s["variants"])
out.append({"pass": p["name"], "light_mode": p["light_mode"], "sets": sets, "full_space": total})
return {"passes": out, "total_full_space": sum(p["full_space"] for p in out)}
_FUNNEL_SHADER = re.compile(r"^Compiling shader \"(?P<shader>[^\"]+)\"(?: pass \"(?P<pass>[^\"]*)\" \((?P<stage>[^)]+)\))?\s*$", re.M)
_FUNNEL_PASS = re.compile(r"^\s+Pass \"(?P<pass>[^\"]*)\" \((?P<stage>[^)]+)\)\s*$", re.M)
_FUNNEL_NUM = re.compile(r"^\s*(Full variant space|After settings filtering|After built-in stripping|After scriptable stripping)\s*:\s*([\d.,\u00a0\u202f ]+?)\s*$", re.M)
_FUNNEL_API = re.compile(r"^\s*Target graphics API:\s*(\S+)", re.M)
def _count(s):
"""Variant counts are integers printed with the machine's thousands separator ("2.560" = 2560 here)."""
digits = re.sub(r"[^\d]", "", s)
return int(digits) if digits else None
def parse_variant_funnel(log_text):
"""Unity's per shader, pass and stage stripping funnel, written to the Editor or job log of a player
or AssetBundle build (Carotenuto, Unity blog 2024). Unity 6.3 prints (observed 2026-09-24):
Compiling shader "AgentKit/Dissolve"
Pass "DissolveForward" (fp)
Target graphics API: metal
Full variant space: 2.560 <- thousands separator follows the OS locale
After settings filtering: 32
After built-in stripping: 4
After scriptable stripping: 4
The older one-line header `Compiling shader "X" pass "Y" (vp)` is parsed too.
Returns {"rows": [{shader, pass, stage, api, full, settings, builtin, scriptable}], "totals": {shader: {...}}}."""
rows = []
marks = []
for m in _FUNNEL_SHADER.finditer(log_text):
marks.append((m.start(), "shader", m))
for m in _FUNNEL_PASS.finditer(log_text):
marks.append((m.start(), "pass", m))
marks.sort(key=lambda t: t[0])
shader = None
for i, (pos, kind, m) in enumerate(marks):
if kind == "shader":
shader = m.group("shader")
if not m.group("pass"):
continue
pass_name, stage = m.group("pass"), m.group("stage")
else:
pass_name, stage = m.group("pass"), m.group("stage")
if shader is None:
continue
end = marks[i + 1][0] if i + 1 < len(marks) else min(len(log_text), m.end() + 1500)
chunk = log_text[m.end():end]
nums = {k: _count(v) for k, v in _FUNNEL_NUM.findall(chunk)}
if not nums:
continue
api = _FUNNEL_API.search(chunk)
rows.append({"shader": shader, "pass": pass_name, "stage": stage, "api": api.group(1) if api else None,
"full": nums.get("Full variant space"), "settings": nums.get("After settings filtering"),
"builtin": nums.get("After built-in stripping"), "scriptable": nums.get("After scriptable stripping")})
totals = {}
for r in rows:
t = totals.setdefault(r["shader"], {"full": 0, "settings": 0, "builtin": 0, "scriptable": 0})
for k in ("full", "settings", "builtin", "scriptable"):
t[k] += r[k] or 0
return {"rows": rows, "totals": totals}
def variant_budget_check(funnel_totals, budgets, baseline=None, growth_limit=0.10):
"""Fail a shader over its budget (post-strip variants) or growing more than growth_limit versus a
baseline funnel (the CI gate the Unity blog recommends). budgets: {shader: max} or {"*": max}."""
findings = []
for shader, t in funnel_totals.items():
limit = budgets.get(shader, budgets.get("*"))
after = t.get("scriptable") or 0
if limit is not None and after > limit:
findings.append({"severity": "error", "code": "shaders.variant_budget", "path": shader,
"message": "%d variants after stripping > budget %d" % (after, limit), "fix": "strip, shader_feature, dynamic_branch"})
if baseline and shader in baseline:
before = baseline[shader].get("scriptable") or 0
if before and after > before * (1 + growth_limit):
findings.append({"severity": "error", "code": "shaders.variant_growth", "path": shader,
"message": "%d -> %d variants (+%.0f%%)" % (before, after, 100.0 * (after - before) / before),
"fix": "find the new keyword or URP feature that multiplied variants"})
return findings
# ============================================================================ Shader Graph (offline)
def _sg_objects(text):
dec = json.JSONDecoder()
i, objs = 0, []
while i < len(text):
while i < len(text) and text[i].isspace():
i += 1
if i >= len(text):
break
o, i = dec.raw_decode(text, i)
objs.append(o)
return objs
def shadergraph_summary(path):
"""Read a .shadergraph or .shadersubgraph (Shader Graph 17 multi-JSON) without Unity: targets and
material type, exposed properties with reference names, keywords with definition and scope,
Custom Function nodes (File or String, function name, source GUID) and sub graph references.
Use it to plan Material.SetX calls and to find the .hlsl an agent may edit."""
with open(path, encoding="utf-8") as fh:
objs = _sg_objects(fh.read())
by_type = {}
for o in objs:
by_type.setdefault(o.get("m_Type", "?").split(".")[-1], []).append(o)
props = []
for o in objs:
tp = o.get("m_Type", "")
if tp.startswith("UnityEditor.ShaderGraph.Internal.") and tp.endswith("ShaderProperty"):
ref = o.get("m_OverrideReferenceName") or o.get("m_DefaultReferenceName")
props.append({"name": o.get("m_Name"), "reference": ref, "type": tp.split(".")[-1].replace("ShaderProperty", ""),
"exposed": not o.get("m_Hidden", False) and o.get("m_GeneratePropertyBlock", True),
"value": o.get("m_Value")})
keywords = []
for o in by_type.get("ShaderKeyword", []):
keywords.append({"name": o.get("m_Name"), "reference": o.get("m_OverrideReferenceName") or o.get("m_DefaultReferenceName"),
"definition": {0: "ShaderFeature", 1: "MultiCompile", 2: "Predefined", 3: "DynamicBranch"}.get(o.get("m_KeywordDefinition"), o.get("m_KeywordDefinition")),
"scope": {0: "Local", 1: "Global"}.get(o.get("m_KeywordScope"), o.get("m_KeywordScope"))})
cfs = []
for o in by_type.get("CustomFunctionNode", []):
cfs.append({"node": o.get("m_Name"), "mode": {0: "File", 1: "String"}.get(o.get("m_SourceType"), o.get("m_SourceType")),
"function": o.get("m_FunctionName"), "source_guid": o.get("m_FunctionSource") or None,
"body_chars": len(o.get("m_FunctionBody") or "")})
subgraphs = []
for o in by_type.get("SubGraphNode", []):
sg = o.get("m_SerializedSubGraph")
if isinstance(sg, str):
try:
sg = json.loads(sg)
except ValueError:
pass
guid = sg.get("subGraph", {}).get("guid") if isinstance(sg, dict) else None
subgraphs.append({"node": o.get("m_Name"), "guid": guid})
targets = [t for t in by_type if t.endswith("Target") or t.endswith("SubTarget")]
urp = None
findings = []
for o in by_type.get("UniversalTarget", []):
urp = {k: o.get(v) for k, v in _SG_URP_FIELDS.items()}
if urp["alpha_clip"] and not urp["allow_material_override"]:
_finding(findings, "warn", "sg_alpha_clip_always",
"Alpha Clipping is ON without Allow Material Override: URP 17.3 writes `#define _ALPHATEST_ON 1` into EVERY "
"pass (UniversalTarget.cs AddAlphaClipControlToPass), so no Boolean keyword or material toggle can give a "
"non-clipped variant; the whole object pays alpha test (and AlphaToMask with MSAA) all the time",
"clip only while dissolving: Allow Material Override ON (then _ALPHATEST_ON is a keyword driven by the "
"material's _AlphaClip) or a second opaque material swapped in; check with ShaderGraphJobs.GeneratedCode", path)
return {"path": path, "objects": len(objs), "targets": targets, "properties": props, "keywords": keywords,
"custom_functions": cfs, "subgraphs": subgraphs, "urp_target": urp, "findings": findings,
"node_types": {k: len(v) for k, v in by_type.items() if k.endswith("Node")}}
# friendly name -> field of the UnityEditor.Rendering.Universal.ShaderGraph.UniversalTarget JSON object (Graph Settings)
_SG_URP_FIELDS = {"alpha_clip": "m_AlphaClip", "allow_material_override": "m_AllowMaterialOverride",
"surface_type": "m_SurfaceType", "render_face": "m_RenderFace", "cast_shadows": "m_CastShadows",
"receive_shadows": "m_ReceiveShadows", "alpha_mode": "m_AlphaMode", "zwrite_control": "m_ZWriteControl"}
def shadergraph_set_target_settings(path, out_path=None, **settings):
"""Set URP Graph Settings in a .shadergraph without the graph editor, e.g.
shadergraph_set_target_settings(p, alpha_clip=True, allow_material_override=True).
Keys: alpha_clip, allow_material_override, surface_type (0 Opaque, 1 Transparent), render_face
(0 Both, 1 Back, 2 Front), cast_shadows, receive_shadows, alpha_mode, zwrite_control.
Undocumented text edit of the asset's JSON: only while NO editor holds the project, keep a copy of the
original, then reimport and read the result with ShaderGraphJobs.GeneratedCode. Returns targets changed."""
unknown = set(settings) - set(_SG_URP_FIELDS)
if unknown:
raise ValueError("unknown settings %s (known: %s)" % (sorted(unknown), sorted(_SG_URP_FIELDS)))
with open(path, encoding="utf-8") as fh:
objs = _sg_objects(fh.read())
changed = 0
for o in objs:
if o.get("m_Type", "").endswith(".UniversalTarget"):
for k, v in settings.items():
o[_SG_URP_FIELDS[k]] = v
changed += 1
if changed:
with open(out_path or path, "w", encoding="utf-8") as fh:
fh.write("\n\n".join(json.dumps(o, indent=4) for o in objs) + "\n")
return changed
def generated_alpha_clip_report(shader_text):
"""Per pass of a generated (or hand-written) shader: is alpha test compiled in (`#define _ALPHATEST_ON 1`),
declared as a keyword (a material can turn it off), or absent. Feed it the text written by
ShaderGraphJobs.GeneratedCode."""
t = strip_comments(shader_text)
rows = []
for p in _passes(t):
b = p["body"]
rows.append({"pass": p["name"], "light_mode": p["light_mode"],
"alphatest_defined": bool(re.search(r"#define\s+_ALPHATEST_ON\s+1\b", b)),
"alphatest_keyword": bool(re.search(r"#pragma\s+(?:shader_feature|multi_compile)\w*\s+[^\n]*\b_ALPHATEST_ON\b", b))})
return {"passes": rows, "always_clipped": bool(rows) and all(r["alphatest_defined"] for r in rows),
"keyword_controlled": bool(rows) and all(r["alphatest_keyword"] and not r["alphatest_defined"] for r in rows)}
def asset_guid(asset_path):
"""GUID of an imported asset, read from its .meta (Unity writes it on import)."""
with open(asset_path + ".meta", encoding="utf-8") as fh:
m = re.search(r"^guid:\s*([0-9a-f]{32})", fh.read(), re.M)
if not m:
raise ValueError("no guid in %s.meta" % asset_path)
return m.group(1)
def shadergraph_repoint_custom_function(path, function_name, hlsl_guid, new_function, out_path=None):
"""Switch the Custom Function node named `function_name` in a .shadergraph/.shadersubgraph to File mode,
reading `new_function` (Name WITHOUT the _float/_half suffix) from the .hlsl with GUID `hlsl_guid`.
The node's ports stay as they are, so the file's function must take them in the same order.
Text edit of an asset: only while NO editor holds the project (batch jobs reimport it); keep a copy
of the original. Returns the number of nodes changed."""
with open(path, encoding="utf-8") as fh:
objs = _sg_objects(fh.read())
changed = 0
for o in objs:
if o.get("m_Type", "").endswith("CustomFunctionNode") and o.get("m_FunctionName") == function_name:
o["m_SourceType"] = 0 # 0 = File, 1 = String
o["m_FunctionSource"] = hlsl_guid
o["m_FunctionName"] = new_function
changed += 1
if changed:
with open(out_path or path, "w", encoding="utf-8") as fh:
fh.write("\n\n".join(json.dumps(o, indent=4) for o in objs) + "\n")
return changed
# ============================================================================ runtime keyword toggles
_KW_DECL = re.compile(r"#pragma\s+(multi_compile|shader_feature|dynamic_branch)(_local)?(?:_vertex|_fragment|_hull|_domain|_geometry|_raytracing)?\s+([^\n]+)")
_CS_TOGGLE = re.compile(r"(?P<recv>[\w\.\]\)]+)\s*\.\s*(?P<fn>EnableKeyword|DisableKeyword|SetKeyword|EnableShaderKeyword|DisableShaderKeyword)"
r"\s*\((?P<args>[^;]*?)\"(?P<kw>[A-Za-z_][A-Za-z0-9_]*)\"")
def keyword_declarations(shader_texts=None, graph_paths=None):
"""keyword -> {"types": {shader_feature|multi_compile|dynamic_branch}, "local": bool, "where": [...]}
from .shader/.hlsl texts ({name: text}) and .shadergraph Blackboard keywords (ShaderFeature,
MultiCompile, DynamicBranch definitions; Predefined skipped)."""
decl = {}
def add(kw, typ, local, where):
d = decl.setdefault(kw, {"types": set(), "local": True, "where": []})
d["types"].add(typ)
d["local"] = d["local"] and local
if where not in d["where"]:
d["where"].append(where)
for name, text in (shader_texts or {}).items():
for m in _KW_DECL.finditer(strip_comments(text)):
for kw in m.group(3).split():
if kw != "_" and not kw.startswith("__"):
add(kw, m.group(1), bool(m.group(2)), name)
for gp in graph_paths or []:
for k in shadergraph_summary(gp)["keywords"]:
typ = {"ShaderFeature": "shader_feature", "MultiCompile": "multi_compile", "DynamicBranch": "dynamic_branch"}.get(k["definition"])
if typ and k.get("reference"):
add(k["reference"], typ, k.get("scope") == "Local", os.path.basename(gp))
return decl
def lint_runtime_keyword_toggles(cs_texts, declarations):
"""Runtime C# (never Editor/ folders: editor code that saves keywords INTO material assets is how a
shader_feature variant gets kept) that toggles keywords by string. Findings:
error runtime_toggle_shader_feature: EnableKeyword/SetKeyword on a keyword declared only as
shader_feature: the variant ships only if a material in the build enables it (6.3 Manual,
observed here: 0 then 6 _NORMALMAP variants), so the toggle silently falls back in players.
warn global_toggle_local_keyword: Shader.EnableKeyword / CommandBuffer.EnableShaderKeyword on a
keyword declared _local: global state never reaches a local keyword.
cs_texts: {name: text}; declarations: keyword_declarations(...)."""
f = []
for name, text in cs_texts.items():
t = strip_comments(text)
# resolve keywords held in string constants: const string k = "_X"; ... mat.EnableKeyword(k)
consts = dict(re.findall(r"(?:const|static\s+readonly)\s+string\s+(\w+)\s*=\s*\"([A-Za-z_]\w*)\"", t))
for ident, kw in consts.items():
t = re.sub(r"(\.\s*(?:EnableKeyword|DisableKeyword|EnableShaderKeyword|DisableShaderKeyword)\s*\(\s*)%s\s*\)" % re.escape(ident),
r'\1"%s")' % kw, t)
t = re.sub(r"(new\s+(?:Local|Global)Keyword\s*\([^;]*?)\b%s\s*\)" % re.escape(ident), r'\1"%s")' % kw, t)
hits = [(m.start(), m.group("recv"), m.group("fn"), m.group("kw")) for m in _CS_TOGGLE.finditer(t)]
if re.search(r"\.\s*SetKeyword\s*\(", t): # LocalKeyword objects built from a string, toggled later
hits += [(m.start(), "material", "SetKeyword(LocalKeyword)", m.group(1))
for m in re.finditer(r"new\s+LocalKeyword\s*\([^;]*?\"([A-Za-z_]\w*)\"\s*\)", t)]
for pos, recv, fn, kw in hits:
d = declarations.get(kw)
if not d:
continue
line = t[:pos].count("\n") + 1
glob = recv.split(".")[-1] == "Shader" or fn in ("EnableShaderKeyword", "DisableShaderKeyword")
if glob and d["local"]:
_finding(f, "warn", "global_toggle_local_keyword",
"%s:%d %s.%s(\"%s\"): %s is declared _local (%s); global keyword state never reaches it"
% (name, line, recv, fn, kw, kw, ", ".join(d["where"])),
"set it on the material (Material.EnableKeyword / SetKeyword(LocalKeyword)) or declare it global", name)
elif d["types"] == {"shader_feature"}:
_finding(f, "error", "runtime_toggle_shader_feature",
"%s:%d %s(\"%s\") at runtime, but %s is shader_feature only (%s): the variant is stripped unless a "
"material in the build already enables it; players fall back to the closest variant or pink"
% (name, line, fn, kw, kw, ", ".join(d["where"])),
"swap to a second material that has the keyword on (both in the build), or declare it "
"multi_compile_local (costs variants), or dynamic_branch tested with if()", name)
return f
def lint_project_keywords(project, roots=("Assets",)):
"""lint_runtime_keyword_toggles over a whole project: every .shader/.hlsl/.cginc and .shadergraph under
`roots` gives the declarations, every .cs outside an Editor folder is scanned."""
root = ut_env.find_project(project)["root"]
shaders, graphs, cs = {}, [], {}
for r in roots:
for dp, _dn, files in os.walk(os.path.join(root, r)):
for fn in files:
p = os.path.join(dp, fn)
rel = os.path.relpath(p, root)
if fn.endswith((".shader", ".hlsl", ".cginc")):
shaders[rel] = Path(p).read_text(encoding="utf-8", errors="replace")
elif fn.endswith(".shadergraph"):
graphs.append(p)
elif fn.endswith(".cs") and "Editor" not in rel.split(os.sep):
cs[rel] = Path(p).read_text(encoding="utf-8", errors="replace")
decl = keyword_declarations(shaders, graphs)
return lint_runtime_keyword_toggles(cs, decl)
# ============================================================================ PSO warm-up proof
def pso_collection_name(platform, api, quality=None):
"""File name of a traced GraphicsStateCollection: ONE per graphics API and platform (6.3 Manual,
"Trace a new PSO data collection"), optionally per quality level. platform: Application.platform
name (OSXPlayer, Android, IPhonePlayer, WindowsPlayer), api: SystemInfo.graphicsDeviceType name
(Metal, Vulkan, Direct3D12). AgentPsoProbe looks for exactly this name in StreamingAssets/PSO/."""
name = "%s_%s" % (platform, api)
if quality:
name += "_" + re.sub(r"[^A-Za-z0-9]+", "", quality)
return name + ".graphicsstate"
_UPLOAD_LINE = re.compile(r"(?:Uploaded shader variant to the GPU driver:\s*(?P<new>.+?) \(instance [^)]*\)|Compiled Shader:\s*(?P<old>[^,]+)),"
r"(?:.*?time:\s*(?P<ms>[\d.]+)\s*ms)?")
def count_compiled_shaders(log_text, after_marker="AGENT_PSO gameplay_start", before_marker="AGENT_PSO gameplay_end"):
"""Player-log proof independent of profiler markers: with Log Shader Compilation on (development builds)
every first-use GPU program is logged. Unity 6.3 prints `Uploaded shader variant to the GPU driver: <shader>
(instance 0x..), pass: P, stage: vertex, keywords K, time: 0.23 ms` (observed); the 2022-era
`Compiled Shader: <shader>, pass: ..` (Carotenuto's blog) is accepted too. Counts them between the probe's
gameplay markers (or over the whole log when a marker is missing) and sums their upload time."""
lines = log_text.splitlines()
start = next((i for i, ln in enumerate(lines) if after_marker and after_marker in ln), -1)
end = next((i for i, ln in enumerate(lines) if before_marker and before_marker in ln and i > start), len(lines))
all_c = [ln.strip() for ln in lines if _UPLOAD_LINE.search(ln)]
game = [ln.strip() for ln in lines[start + 1:end] if _UPLOAD_LINE.search(ln)]
shaders, ms = {}, 0.0
for ln in game:
m = _UPLOAD_LINE.search(ln)
name = (m.group("new") or m.group("old")).strip()
shaders[name] = shaders.get(name, 0) + 1
ms += float(m.group("ms") or 0)
return {"total": len(all_c), "gameplay": len(game), "gameplay_by_shader": shaders, "gameplay_upload_ms": round(ms, 3),
"marker_found": start >= 0, "sample": game[:5]}
def pso_player_command(app, mode, out_json, gsc=None, frames=60, log=None, width=640, height=360, extra=None):
"""argv to run a development player built with AgentPsoProbe in the first scene (macOS .app or a binary).
mode: trace (BeginTrace, play, EndTrace + SaveToFile gsc), cold (no warm-up), warm (LoadFromFile gsc,
WarmUp or WarmUpProgressively before gameplay). Windowed on purpose: batch-mode players do not render
their cameras by themselves."""
exe = app
if app.endswith(".app"):
macos = os.path.join(app, "Contents", "MacOS")
exe = os.path.join(macos, sorted(os.listdir(macos))[0])
argv = [exe, "-screen-fullscreen", "0", "-screen-width", str(width), "-screen-height", str(height),
"-agentPso", mode, "-agentPsoOut", out_json, "-agentPsoFrames", str(frames)]
if gsc:
argv += ["-agentPsoFile", gsc]
if log:
argv += ["-logFile", log]
return argv + list(extra or [])
def run_pso_player(app, mode, out_json, gsc=None, frames=60, log=None, timeout=180, extra=None):
"""Run the player once (see pso_player_command) and return {"probe": <json it wrote>, "log": path,
"compiled": count_compiled_shaders(log), "exit_code", "seconds"}."""
import subprocess
import time
log = log or os.path.splitext(out_json)[0] + ".log"
for p in (out_json,):
if os.path.isfile(p):
os.replace(p, p + ".prev") # never delete: keep the previous run beside it
t0 = time.time()
try:
code = subprocess.call(pso_player_command(app, mode, out_json, gsc, frames, log, extra=extra), timeout=timeout)
except subprocess.TimeoutExpired:
code = "timeout"
probe = json.loads(Path(out_json).read_text()) if os.path.isfile(out_json) else None
text = Path(log).read_text(errors="replace") if os.path.isfile(log) else ""
return {"mode": mode, "probe": probe, "log": log, "compiled": count_compiled_shaders(text), "exit_code": code,
"seconds": round(time.time() - t0, 1)}
def pso_gate(trace, cold, warm):
"""Findings for the PSO warm-up proof (6.3 Manual, "Warm up PSOs"): the trace saved a collection for the
running API, the cold run shows first-use compiles in gameplay (else the bench proves nothing), the
warm run shows none by BOTH channels (profiler marker counts, GPU program and PSO, and player-log upload lines)."""
f = []
tp, cp, wp = (trace or {}).get("probe") or {}, (cold or {}).get("probe") or {}, (warm or {}).get("probe") or {}
if not tp.get("trace", {}).get("saved"):
_finding(f, "error", "pso_no_trace", "no .graphicsstate saved by the trace run", "development player, BeginTrace at boot, EndTrace + SaveToFile at the end", "")
if cp.get("gameplay_create_gpu_program", 0) + (cold or {}).get("compiled", {}).get("gameplay", 0) == 0:
_finding(f, "warn", "pso_cold_clean", "the cold run compiled nothing in gameplay: the bench does not exercise first use", "activate content only after the loading frames", "")
if wp.get("warmup", {}).get("api_match") is False:
_finding(f, "error", "pso_api_mismatch", "collection traced on %s used on %s" % (wp["warmup"].get("collection_api"), wp.get("api")),
"one collection per graphics API and platform", "")
if (wp.get("gameplay_create_gpu_program") or 0) > 0 or (wp.get("gameplay_create_pipeline") or 0) > 0 \
or (warm or {}).get("compiled", {}).get("gameplay", 0) > 0:
_finding(f, "error", "pso_warm_compiles", "warm run still compiles in gameplay: %s GPU program and %s PSO marker samples, %s log lines"
% (wp.get("gameplay_create_gpu_program"), wp.get("gameplay_create_pipeline"), (warm or {}).get("compiled", {}).get("gameplay")),
"re-trace the missing content into the same collection (ContainsVariant) and ship it", "")
return f
# ============================================================================ installed URP facts
def urp_lit_keywords(editor=None):
"""The #pragma multi_compile/shader_feature lines of the INSTALLED URP Lit.shader forward pass:
the keyword list to copy into a hand-written lit shader (Daniel Ilett: read the pipeline's own
shader, tutorials lag). editor: ut_env.find_editor() result or None."""
ed = editor or ut_env.find_editor()
app = ed["app"] if "app" in ed else os.path.dirname(os.path.dirname(os.path.dirname(ed["binary"])))
path = os.path.join(app, "Contents", "Resources", "PackageManager", "BuiltInPackages",
"com.unity.render-pipelines.universal", "Shaders", "Lit.shader")
with open(path, encoding="utf-8") as fh:
text = fh.read()
fwd = next((p for p in _passes(strip_comments(text)) if p["light_mode"] == "UniversalForward"), None)
lines = re.findall(r"#pragma\s+(?:multi_compile|shader_feature|dynamic_branch)\w*[^\n]*|#include_with_pragmas[^\n]*", fwd["body"]) if fwd else []
return {"path": path, "lines": [ln.strip() for ln in lines]}
# ============================================================================ pixels
def _img(png):
import ut_review
w, h, rgb, _a, _m = ut_review.load_image(png)
return w, h, rgb
def sample(png, x, y, r=2):
"""Mean RGB (0-255) of the (2r+1)x(2r+1) block centred on pixel (x, y), PNG rows top to bottom
(ShaderLab.Shots probes already return (x, (1 - viewport y) * height))."""
w, h, rgb = _img(png)
x, y = int(round(x)), int(round(y))
acc, n = [0, 0, 0], 0
for yy in range(max(0, y - r), min(h, y + r + 1)):
for xx in range(max(0, x - r), min(w, x + r + 1)):
i = 3 * (yy * w + xx)
acc[0] += rgb[i]
acc[1] += rgb[i + 1]
acc[2] += rgb[i + 2]
n += 1
return [round(c / max(n, 1), 2) for c in acc]
def luma(rgb):
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
def region_stats(png, box=None, tol=2):
"""Mean RGB, mean luma and the share of pixels with |R-G| and |G-B| <= tol (grey pixels) in a box
(x0, y0, x1, y1) or the whole image. A grayscale full-screen pass at strength 1 gives gray_share 1.0."""
w, h, rgb = _img(png)
x0, y0, x1, y1 = box or (0, 0, w, h)
acc, n, grey = [0, 0, 0], 0, 0
step = max(1, int(math.sqrt(((x1 - x0) * (y1 - y0)) / 40000.0)))
for yy in range(y0, y1, step):
for xx in range(x0, x1, step):
i = 3 * (yy * w + xx)
r_, g_, b_ = rgb[i], rgb[i + 1], rgb[i + 2]
acc[0] += r_
acc[1] += g_
acc[2] += b_
n += 1
if abs(r_ - g_) <= tol and abs(g_ - b_) <= tol:
grey += 1
mean = [round(c / max(n, 1), 2) for c in acc]
return {"mean_rgb": mean, "mean_luma": round(luma(mean), 2), "gray_share": round(grey / float(max(n, 1)), 4), "samples": n}
def findings_summary(findings):
out = {"error": 0, "warn": 0, "info": 0}
for f in findings:
out[f["severity"]] = out.get(f["severity"], 0) + 1
return out
SKILL.md
---
name: scenario-unity-shaders
description: "Use when a Unity 6.3 URP task involves shaders: hand-written HLSL, Shader Graph water, toon, dissolve or outline, Custom Function nodes, a renderer feature or full-screen effect on Render Graph, compute shaders, shader variants, keywords, stripping, Shader Build Settings, PSO warm-up, or shader cost; or when a material turns pink or invisible, casts no shadow, ignores extra lights under Forward+, an effect 'does nothing', the game stutters the first time something appears, or builds bloat with variants."
license: MIT
---
# Unity shaders (technical artist, shading)
Expert shading in Unity 6.3 is a shader you can prove: it compiles for the target graphics API,
every pass agrees, its variants are counted after stripping, its first use is shown warm, its cost
is measured in GPU milliseconds, and its look is checked in a capture with numbers. An agent without
a mouse gets there through text: hand-written URP HLSL is the primary route, Shader Graph is used
through existing graphs driven by exposed properties plus agent-owned `.hlsl` behind Custom Function
nodes, and new graph wiring is a GUI step ([`references/gui-paths.md`](references/gui-paths.md)).
Target: 6000.3.21f1, URP 17.3, Shader Graph 17.3, Metal on Apple Silicon. If a sibling skill named
here is missing from your available skills, load it with `skill_get` (Scenario MCP) or ask the user
to install it (https://github.com/scenario-labs/skills#install); unattended, proceed from tool
schemas and flag the gap.
**REQUIRED BACKGROUND:** scenario-unity-expert (channels, review loop, 6.3 traps). Its toolkit
(`ut_env`, `ut_run`, `ut_review`, AgentKit) is imported, never copied.
**Status (2026-09-24, after the blind-grade refactor):** every procedure ran in Unity 6000.3.21f1
here: `tests/code/unity-shaders/run_all.sh --live` (73 live checks including a development-player
PSO proof, 32 offline checks, all passing; evidence in `archive/tests/unity-shaders/`).
## Stance (the expert delta)
1. **Text first, graphs by template.** Shader Graph has no loops and almost no lighting nodes, so
lights and loops live in HLSL (Daniel Ilett, F8bAI6dIrto [00:07:13]); its one advantage is
generated depth and shadow passes (bH--RU6qyTw [00:38:24]), which `AgentPasses.hlsl` gives
hand-written shaders too. With graphs, duplicate one, set its exposed references, put changing
logic in a File-mode `.hlsl`. Graph Settings bind every pass: Alpha Clipping without Allow
Material Override writes `#define _ALPHATEST_ON 1` into all 8 generated passes, and no Boolean
keyword removes it (installed `UniversalTarget.cs`, observed).
2. **Every pass must agree.** Displacement and `clip()` run in ShadowCaster, DepthOnly and
DepthNormals too (6.3 Manual; Ilett [00:37:52]). A missing DepthOnly pass makes an opaque shader
invisible with Depth Priming (observed). DepthNormals writes the raw world normal: the Manual's
`normal * 0.5 + 0.5` example reads back wrong in SSAO and outlines (observed).
3. **Read the installed pipeline, not the tutorial.** Copy keywords from the installed `Lit.shader`
(Ilett [00:14:51]; `ut_shaders.urp_lit_keywords()`): 6.3 has `_CLUSTER_LIGHT_LOOP` and four soft
shadow variants. `LIGHT_LOOP_BEGIN` skips directional lights, so a custom loop needs a
directional pre-loop under Forward+; Unity's own Custom Lighting sample lacks it (observed).
4. **Variants are a budget counted after stripping.** `shader_feature` for material toggles,
`multi_compile` only for runtime C# toggles, `dynamic_branch` tested with `if`, never `#if` (6.3
Manual). Strict Shader Variant Matching before stripping, then the funnel in the build log
(Carotenuto, Unity blog 2024). A `shader_feature` (or `_local`) variant ships only if a material
in the build enables it (observed 0 vs 6): never toggle one from runtime C#. Unity 6 Shader Build
Settings exclude or retype keywords with zero code, but a `dynamic_branch` override is silently
ignored for `#if`-tested keywords, URP's soft shadow and SSAO sets included (observed).
5. **First use is proven warm.** Trace one `GraphicsStateCollection` per graphics API and platform
in a development player, warm it behind the loading screen, and show gameplay frames with zero
`Shader.CreateGPUProgram` samples and zero logged uploads (6.3 Manual; observed 14 to 0 on
Metal).
6. **Cost is GPU time, not instruction count.** Loops count at max trip, float4 costs about 4x
float, transcendentals cost many cycles (Ben Cloward, E82XxlXMJs4 [00:15:44]). Measured here: 64
`sin` on a float4 about 3x a float, 10x or more a multiply-add loop. Half pays only where a
device profiler shows ALU-bound (Freya Holmér, kfM-yu0iQBk [01:20:26]): desktop PlatformDefault
compiles `half` as `float`.
7. **Render Graph shape.** `RecordRenderGraph` declares, never records; blit once into a new texture
then `resourceData.cameraColor = destination`; gate in `AddRenderPasses`; refuse the back buffer
(6.3 docs; Unity dither sample, U8PygjYAF7A [00:09:06] [00:10:10]).
8. **One scalar field, one threshold, two colors; look and measure.** Toon, water, dissolve, outline
and dither threshold a 0..1 value (visual digest); the water's depth mask also calms its shore
normals (gRq-IdShxpU [00:06:12]). Offset an emissive edge from the clip test (Brackeys,
taMp1g1pBeE [00:05:31]). Output intermediates as colors (Freya [01:57:25]), then assert pixels at
projected probes.
## Establish first
- **Platforms and graphics APIs**: Metal, Vulkan, DX12, GLES 3.x, WebGL 2 or WebGPU. Compute runs on
Metal, Vulkan, DX11/12 and GLES 3.1+ (4 buffers guaranteed on GLES 3.1), not on GLES 3.0 or WebGL
2; the Web gets it through WebGPU (experimental in 6.3). The GPU Resident Drawer and STP exclude
GLES entirely.
- **Rendering path per renderer** (Forward, Forward+, Deferred+): decides the light-loop code;
Shader Graph custom lighting works in Forward and Forward+ only.
- **URP assets per quality level**: Depth and Opaque Texture, soft shadows, SSAO. The template's
`Mobile_RPAsset` has Depth Texture off: depth-faded water needs it on or a baked shore mask.
- **Style and authors**: toon or PBR; artists editing graphs (keep a graph, agent owns the `.hlsl`)
or code only.
- **Per-object values**: material instance, `MeshRenderer`/`SkinnedMeshRenderer.SetShaderUserValue`
(6.3, SRP Batcher friendly), or `MaterialPropertyBlock` (breaks the SRP Batcher).
- **Budgets**: GPU ms for the effect, variants per shader, overdraw, first-use hitches. Defaults
when silent: URP 17.3 Forward+, PC and mobile tiers from the template, HLSL, shaders under
`Assets/AgentShaders/`.
## Workflow
1. **Install and inventory.** `ut_shaders.install(P)`; `RendererFeatureJobs.ListFeatures` (every
quality level, asset, renderer, feature); `lint_shader_file`, `shadergraph_summary` (targets,
properties, Graph Settings findings), `lint_project_keywords`. GATE: pipeline facts in the plan;
lint errors listed.
2. **Author.** Start from [`scripts/Shaders/`](scripts/Shaders/): `AgentLit`, `AgentToon`,
`AgentWater`, `AgentDissolve`, `AgentEdgeOutline`, `AgentGrayscale`, `AgentCompute`, shared
`AgentPasses.hlsl` (`AGENT_CLIP`/`AGENT_DISPLACE`) and `AgentNoise.hlsl`. Graph route:
`ShaderGraphJobs.ImportPackageSample`, `DuplicateGraph`, `shadergraph_set_target_settings`,
`GeneratedCode`, `AgentCustomLighting.hlsl`. GATE: lint zero errors.
3. **Compile for the target.** `ShaderJobs.ValidateShaders` (`ShaderUtil.GetShaderMessages` plus
`CompileVariant` per pass for Metal). GATE: zero errors, the four opaque passes present.
4. **Bench and look.** `SetupMaterials`, `ShaderLab.BuildScene`, `ShaderLab.Shots` (many shots per
boot: material, renderer, feature and URP changes; probes projected to pixels). GATE: `ut_review`
flags clean, contact sheet opened, probe numbers (shadow darker than open ground, dissolve shadow
gone at 1, extra lights change the lit side).
5. **Full-screen effects.** `RendererFeatureJobs.AddFeature` with `AgentFullscreenFeature` or the
zero-code `FullScreenPassRendererFeature`. GATE: on/off captures differ as intended; no
back-buffer error.
6. **Variants.** `VariantJobs.StrippingSettings` (strict matching on), `BuildShaderBundle` for the
shipping materials, `parse_variant_funnel`, `variant_budget_check`, strip rules or
`KeywordOverrides` (Shader Build Settings), `PostProcessingAudit` (every VolumeProfile under
Assets/ keeps its post variants). GATE: shipped count per shader under budget; logger total
equals Unity's "After scriptable stripping"; overrides cleared or committed on purpose.
7. **First-use stutter.** `PsoJobs.BuildPsoScene`, `ut_run.build(development=True)`,
`ut_shaders.run_pso_player` trace, cold, warm; `PsoJobs.InspectCollection`; ship
`StreamingAssets/PSO/<platform>_<api>.graphicsstate` read by `AgentPsoProbe`. GATE: `pso_gate`
clean (cold shows uploads, warm shows none by marker and by log).
8. **Cost.** `GpuCostJobs.MeasureFullscreenCost` A/B, interleaved, async compilation off
(`precision_model` for half tests). GATE: faster in ms beyond noise; device numbers from a
development player (scenario-unity-performance).
9. **Deliver.** Shaders, materials, feature assets, `.graphicsstate` per API, and the evidence
packet (Handoffs).
## Numbers
| Value | Relative to | Source |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------- |
| 7,688 full; 4,046 per stage; 62 after settings; 13 shipped | `AgentKit/Lit` in a macOS AssetBundle build with the template's URP assets | observed |
| 13 to 11 | AgentLit shipped variants after excluding the soft shadow set in Shader Build Settings | observed |
| 433 to 577 | UberPost variants when one unused Bloom HQ + dirt profile sits under Assets/ | observed |
| 14 to 0; 1.8 to 2.4 ms | first-use GPU program uploads in gameplay, cold vs warm; warm-up of 26 to 27 PSOs in one frame, macOS player | observed |
| 0.007 to 0.033 / 0.29 to 0.42 / 0.89 to 1.66 ms | one 1080p full-screen draw: 64 multiply-adds / 64 `sin` / 64 float4 `sin`, M5 Max; compare within one run | observed |
| 0.92 to 1.00 / 0.77 to 0.89 | half4 over float4 `sin` time, PlatformDefault / Unified precision model, M5 Max (noise about 10 %) | observed |
| more than 128 keywords, 4 reserved | per shader: compile slows | 6.3 Manual |
| 2000 / 2450 / 3000 | Geometry / AlphaTest / Transparent queues | Cyanilux |
| 64x1x1, 8x8x1 | compute thread groups; ceil-divided dispatch plus bounds check | 6.3 Manual, [added] |
| (1.2, 0.35, 0.03) vs (4, 1.1, 0.15) | HDR dissolve edge that stays orange vs washes to cream under Neutral tonemapping | observed |
| 0.2126, 0.7152, 0.0722 | Rec. 709 luminance on linear color | 6.3 Manual |
## Quality gates
- **Measurable:** lint and `lint_project_keywords` zero errors; `ValidateShaders` zero errors for
the target API; SRP Batcher compatible (read after a render); probes (shadow luma under 0.75x open
ground; dissolve shadow restored at 1; extra lights change the lit side in Forward and Forward+;
water normals calmer at the shore than deep); feature on/off difference; shipped variants under
budget and stable; no stray VolumeProfile; `pso_gate` clean per API; GPU ms A/B; compute GPU
equals CPU (max error below 1e-5).
- **Visual:** contact sheet opened; toon bands sharp; water hue shift, calm shore, foam, motion;
dissolve edge keeps its hue; outline on silhouettes only; no magenta.
## Common mistakes
| Mistake | What it looks like | Fix |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| no DepthOnly pass | object vanishes with Depth Priming Forced | `AgentDepthOnlyVertex/Fragment` |
| clip only in the forward pass | dissolved object still casts a full shadow | `AGENT_CLIP` shared by all passes |
| DepthNormals returns `n * 0.5 + 0.5` | wrong SSAO and outlines | return the raw normal |
| `LIGHT_LOOP_BEGIN` without a directional pre-loop | a second directional light lights nothing under Forward+ | loop `URP_FP_DIRECTIONAL_LIGHTS_COUNT` first |
| `_FORWARD_PLUS`, `SHADOWS_SCREEN`, `ComputeScreenPos` (deprecated since URP 11) | compile warning, dead shadow branch | `_CLUSTER_LIGHT_LOOP`, `GetMainLight(shadowCoord)`, `GetNormalizedScreenSpaceUV(positionCS)` |
| `shader_feature_local` toggled from runtime C# (dissolve "on hit") | variant missing in the player: pink or closest match | material swap, or `multi_compile_local`; `lint_project_keywords` |
| a Boolean keyword to skip Shader Graph Alpha Clipping | whole object alpha-tested in every pass, all the time | Allow Material Override (`_ALPHATEST_ON` keyword) or a second opaque material |
| `dynamic_branch` Type Override on an `#if`-tested keyword | variant count unchanged | test with `if()`, declare without a stage suffix |
| `if (KW)` on a `_fragment`-suffixed keyword | "a known keyword but not declared in this pass" | declare it for all stages |
| stray VolumeProfile assets (samples, tests) | post variants for effects no scene uses | move them out of Assets/; `PostProcessingAudit` |
| one PSO collection for every API, or none proven | first-use hitches on device | per-API trace, cold vs warm runs |
| Depth Texture off on the URP asset | water turns to solid foam; hidden while SSAO still requests depth | enable on every URP asset that renders it; test with SSAO off |
| feature missing from one renderer | "the effect does nothing" on one quality level | `ListFeatures` across quality levels |
| After Rendering injection | nothing drawn, back-buffer error | After Rendering Post Processing |
| HDR edge color 4 and up | emissive edge washes to cream | 1 to 1.5, Bloom carries the glow |
| judging cost by instruction count, or half on desktop | "optimized" shader no faster | `MeasureFullscreenCost`; half on the device |
| properties outside `UnityPerMaterial` | SRP Batcher off, values bleed | one CBUFFER in `HLSLINCLUDE` |
## Handoffs
- **Receives** from scenario-unity-rendering-lighting: URP assets per quality level, rendering path,
Depth/Opaque Texture, post stack, tonemapping, the Default Volume Profile. From
scenario-unity-vfx: particle shader needs (blend mode, soft particles, flipbooks);
scenario-unity-vfx owns VFX Graph, the Particle System and effect budgets. From
scenario-unity-world-building: water and terrain surfaces. From scenario-unity-architecture:
gameplay code that drives materials.
- **Delivers** to scenario-unity-vfx and scenario-unity-world-building: shaders, materials with
documented references, feature assets. To scenario-unity-performance: GPU ms tables, variant
funnels, `.graphicsstate` files with the cold/warm proof for device captures. To
scenario-unity-pipeline-automation: the variant budget and PSO gates for CI. To
scenario-unity-mobile and scenario-unity-web: API limits (compute from GLES 3.1, none on GLES 3.0
or WebGL 2), half precision to profile on device, mobile Depth Texture cost, alpha-test cost on
tile GPUs.
- **Packet:** paths, `ValidateShaders` JSON, contact sheet, probes, funnel, PSO runs, cost table,
Verified and Assumed.
## Unity 6.3 notes
- Render Graph only: Compatibility Mode removed (`URP_COMPATIBILITY_MODE` only to port, gone in
6.4); `AddBlitPass` returns a builder (`returnBuilder: true`); After Rendering always follows the
final blit (6.2+).
- `_CLUSTER_LIGHT_LOOP` replaced `_FORWARD_PLUS` (6.1); soft shadows have `_LOW/_MEDIUM/_HIGH`
variants; fog is declared `dynamic_branch` in URP's `Fog.hlsl`.
- Shader Build Settings: `EditorGraphicsSettings.Get/SetShaderBuildSettings` (the active set; a
build profile can override). `GraphicsStateCollection` is experimental; tracing needs a
development player; on Metal `CreateGraphicsGraphicsPipelineImpl` does not exist. Log Shader
Compilation prints `Uploaded shader variant to the GPU driver: ...`.
- URP re-adds every override to the Default Volume Profile before a build, so most post-processing
variant families always ship.
- `SetShaderUserValue(uint)` exists on `MeshRenderer` and `SkinnedMeshRenderer`, not `Renderer`; the
shader reads `unity_RendererUserValue`.
- Shader Graph 17.3: templates, custom lighting via Unlit plus Keep Lighting Variants; reflected
HLSL nodes need 17.5, Preprocessor Directives arrive in 6.6. WebGPU is supported from 6.6;
Built-in RP is deprecated from 6.5.
## References
- [`references/procedures.md`](references/procedures.md): P1 to P19, copyable code with live test
and result.
- [`references/expert-notes.md`](references/expert-notes.md): principles by expert, observed
findings, disagreements.
- [`references/critique.md`](references/critique.md): the rubric before calling work done.
- `references/gui-paths.md`: the same procedures in the GUI.
- [`references/sources.md`](references/sources.md): sources, credentials, timestamps, revision
history.
- [`scripts/ut_shaders.py`](scripts/ut_shaders.py): install, lints, variant and PSO helpers, Shader
Graph reader and editor, pixel probes.
- [`scripts/AgentKit/Shaders/`](scripts/AgentKit/Shaders/): `ShaderJobs`, `ShaderLab`,
`RendererFeatureJobs`, `VariantJobs`, `PsoJobs`, `ComputeJobs`, `GpuCostJobs`, `ShaderGraphJobs`.
- [`scripts/Runtime/`](scripts/Runtime/): `AgentFullscreenFeature.cs`, `AgentPsoProbe.cs` (runtime
assembly); `scripts/Shaders/` (templates).