ADR-0013: Tide-Tint Render Pipeline — Global-Scope Shader Properties, DriftLayer Taxonomy, Build Overlay
Status
Accepted
Accepted 2026-09-01 (
/architecture-review2026-09-01 — blocking findings E14/E15/E16 and note F2 applied; see Amendment History)
Date
2026-09-01
Engine Compatibility
| Field | Value |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) · URP 2D Renderer (Render Graph) · Shader Graph 17.x |
| Domain | Rendering |
| Knowledge Risk | ⚠️ HIGH — architecture.md row 13 flags this domain and names the verification: "verify Shader Graph 2D global-property read in 6.3". URP Compatibility Mode was removed in 6.3. |
| References Consulted | docs/engine-reference/unity/VERSION.md, breaking-changes.md (URP/Graphics, §6.2 SetupRenderPasses), deprecated-apis.md, modules/rendering.md; live docs (below); design/art/art-bible.md §4.3, §8.A.4, §8.B.1–B.4; ADR-0001 ITideClock.TintFloats |
| Post-Cutoff APIs Used | None that require a post-cutoff surface. Shader.SetGlobalColor/SetGlobalFloat are pre-cutoff and unchanged; Shader Graph blackboard properties are pre-cutoff. The only version-sensitive item is the name of the blackboard Scope control (§1). This is deliberate: the decision's whole point is to stay off 6.3's changed surfaces. |
| Verification Required | V1 a Global-scope blackboard property receives Shader.SetGlobal* on 6.3 and does not appear in the Material Inspector · V2 SRP-batcher compatibility asserted structurally: ShaderUtil.GetSRPBatcherCompatibilityCode(DriftLayerSprite) == 0 after one rendered frame, beside a deliberately incompatible control shader that must read non-zero (observed 10) — UnityStats.batches is not the meter (it scales 1:1 with sprite count on 6.3 URP 2D; the SRP Batcher removes per-draw CBUFFER setup, it does not merge draws; review E21) — run against the Global-scope shader · V3 a DriftLayer sprite tints while a character sprite in the same frame does not · V4 entering build mode at max reach with the ghost dashed adds ≤ 2 draw calls in the editor (grid ≤ 1, ghost ≤ 1) as a marginal delta; the atlas-batched ghost-0 is a built-player claim deferred to a Frame Debugger check (review E20) · V5 violet is held through EvictionGrace (ADR-0001 F7) with no pop at teardown · V8 EditorSettings.spritePackerMode = 3 (build-time packing only) is a load-bearing project setting — no Play Mode measurement can observe atlas batching; any change re-opens V2/V4 (review E23) |
Live documentation verified (2026-09-01)
The HIGH-risk claim architecture.md row 13 demanded was verified, and it changed the design:
Unity shader property precedence is per-instance → material → global. A value set with Shader.SetGlobalColor/SetGlobalFloat is used only if the material does not define that property: "Global properties are used if a shader needs them but the material does not have them defined", and "per-instance data overrides everything; then Material data is used; and finally if shader property does not exist in these two places then global property value is used." Sources: Shader.SetGlobalColor, Shader.SetGlobalFloat, Accessing shader properties in Cg/HLSL.
Consequence — the naïve implementation fails silently. A Shader Graph blackboard property added the ordinary way defaults to Per Material scope: it is declared in the shader's Properties block and serialized onto the material. Under the precedence rule the material's own value then wins, and Shader.SetGlobalColor("_TideTint", …) does nothing — no error, no warning, no tint. The art bible says "tint is a global-property read in-shader" (§8.B.3), which is true but not sufficient: it does not say the property must be declared at Global scope for that sentence to hold. §1 makes it a rule.
The resolution — set the blackboard property's Scope to Global. On the Shader Graph blackboard, Scope and Show In Inspector are two different controls, and only one of them does what this design needs:
| Control | What it actually does | ||
|---|---|---|---|
Scope = Global \ | Per Material \ | Hybrid Per Instance | Declaration scope. Global — "Makes the property editable at a global level, through a C# script only" — takes the property out of the material's property block, so nothing shadows the global and Shader.SetGlobal* reaches it. This is the control the design requires. |
Show In Inspector | Visibility only. Disabling it "includes an [HideInInspector] attribute to the material property" — the property is still a material property, merely hidden. |
Hiding is not excluding, and this distinction is the whole finding (architecture review E14, 2026-09-01). A [HideInInspector] property remains declared in the shader's Properties block, so the material does have it defined — and by the precedence rule quoted above, the material's value wins and Shader.SetGlobal is a silent no-op. A property does not need anyone to write* it to shadow a global: it is serialized with the shader's default the moment the material is created. So Show In Inspector alone would ship precisely the bug this section exists to prevent.
Global scope is also the batching-safe path the art bible's hard rule requires: the value leaves the UnityPerMaterial CBuffer entirely rather than diverging per material. Because changing scope alters that CBuffer's layout, V2 must be run against the Global-scope shader specifically, not treated as a formality.
V1 confirms this empirically before anything is built on it — the mechanism is the load-bearing assumption of this entire ADR. The blackboard Reference name (not the display name) is what Shader.SetGlobal* addresses.
Sources (verified 2026-09-01): Shader Graph 17.0 Property Types for both control definitions; Shader.SetGlobalColor (6000.3) for the precedence rule.
Why this domain's HIGH risk does not bite
6.3 removed URP Compatibility Mode and all custom rendering must be Render Graph-native (AddRenderPasses; SetupRenderPasses deprecated in 6.2). This decision uses no custom render pass at all — the tint is a global property read inside an ordinary Shader Graph sprite shader. The HIGH-risk surface is avoided rather than navigated, which is exactly why the art bible chose it (§8.B.3). Any future full-screen tide post-process is Render-Graph-native from day one (§6).
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0001 (Accepted — ITideClock.TintFloats(socket) is the sole tint source), ADR-0003 (assembly boundaries — the tint driver is Presentation), ADR-0005 (Accepted — per-template atlases, the batching unit) |
| Enables | The friend-group playtest's anchor test ("the world says it, no UI timer") — the minimal pipeline must ship at MVP for that test to be runnable at all (art bible §8.B.3, systems index row 14) |
| Blocks | The tide-tint shader story; the BB ghost/grid overlay story (TR-bb-009) |
| Ordering Note | Independent of ADR-0014 (UI stack) — world-space UI sprites are DriftLayer-untagged by construction (§2), so the two never touch the same material. Independent of ADR-0008: nothing here crosses the wire (§7). |
Context
Problem Statement
The tide is the game's central mechanic, and the anchor promise is that the world states it — no UI timer. That promise is only testable if the world visibly changes colour with the tide, which makes the minimal tint pipeline an MVP requirement, not polish (ruled 2026-08-27, CD + AD). The art bible specifies the approach in detail; what no document settles is (a) the exact mechanism that makes a global property actually reach the shader on 6.3, and (b) what happens when more than one land is docked at once. Both turn out to be load-bearing.
Constraints
- Batching (art bible §8.B.1, hard rule):
_TideTint/_TideAmountmust be global shader properties, never per-renderer MaterialPropertyBlocks — MPBs break SRP batching and fragment the draw budget. - No custom render pass (§8.B.3) — the 6.3 Render Graph mandate is avoided, not managed.
- One shader variant shared by every
DriftLayersprite across all atlases — no per-land forks. - Authoring rule (§8.A.4): sprites are authored in neutral home-palette values only; no baked tide-state variants ever;
_low/_mid/_highnaming is rejected at intake. - Draw calls < 100; texture ceiling 2 GB (ample — the estimate is 25–35 MB).
- Purity (ADR-0003): the tint driver reads
ITideClockand writes shader globals; it computes no tide math of its own (raw_tide_time_on_consumer_surfaceandsecond_authority_clockare forbidden patterns).
Requirements
- TR-tint-001 — one Shader Graph sprite shader for
DriftLayersprites reading globals_TideTint(color) and_TideAmount(float) viaShader.SetGlobal*; never per-renderer MPBs. - TR-tint-002 —
DriftLayertag taxonomy: land terrain, nodes, tide props; combat/character sprites and home structures excluded by construction. - TR-tint-003 — consumes tide floats each frame; no custom Render Graph pass at MVP; any future post-process Render-Graph-native.
- TR-tide-008 —
amberIntensity/violetIntensityfor the render layer; violet held at 1.0 throughEvictionGrace(F7). - TR-bb-009 — dashed ghost outline + reach-radius grid overlay within the < 100 draw-call budget.
- TR-cam-001 — orthographic, 64 px/u at 1080p, PPU 32, point filter (the tint must not disturb this).
Decision
1. The two globals are Global-scope blackboard properties (the rule that makes this work)
One Shader Graph sprite shader, DriftLayerSprite, shared by every DriftLayer sprite in every atlas. It declares exactly two tint inputs on the blackboard:
| Blackboard Reference | Type | Scope |
Set by |
|---|---|---|---|
_TideTint | Color | Global | Shader.SetGlobalColor |
_TideAmount | Float | Global | Shader.SetGlobalFloat |
Both must be declared at Global scope. This is not a style preference — a Per Material property is serialized onto the material, the material's value then wins under Unity's precedence rule, and the global write becomes a silent no-op. It is the specific bug this ADR exists to prevent, because it fails with no error message and looks like "the tint doesn't work" long after the shader was signed off.
**Ticking Show In Inspector off is not a substitute** and must not be mistaken for one: it only adds [HideInInspector], leaving the property in the material's property block, still shadowing the global. The property would be invisible and broken — the worst combination, because the symptom hides the cause. Set Scope, not visibility.
Enforcement: V1 (a Global-scope property receives the global write) and an editor test asserting the shipped shader's property block does not contain _TideTint/_TideAmount at all (§8) — absence from the block, not merely hidden-ness, is the invariant.
2. The DriftLayer taxonomy is an exclusion by construction
Tagged (tinted): land-template terrain, harvest nodes, tide-affected props on docked lands.
Never tagged, never tinted: players, enemies, gear, telegraph VFX (combat GDD, telegraph contract: "Combat sprites and telegraph VFX are never tide-tinted … telegraphs must read identically under amber, neutral, and violet" — tinting one would make threat-reading tide-dependent, which is a gameplay defect, not a visual one), home-island structures (untagged by category), and all UI — including the world-space UI sprites ADR-0014 §1 introduces.
"By construction" means the exclusion is a material fact, not a runtime branch: excluded sprites use a different shader that has no tint inputs at all, so there is no code path that could tint them by mistake and no per-frame check to get wrong. V3 asserts it in a single frame.
3. MVP is N = 1, and the globals are exactly right there
ITideClock.TintFloats(int socket) (ADR-0001, Accepted) returns (amber, violet) per socket. At MVP the tide has N = 1 (tide GDD Tuning Knobs: range 1–8, "MVP 1, Tier 1 3–4") — exactly one shore socket, therefore at most one docked land. One global pair represents TintFloats(0) faithfully, costs one SetGlobalColor + one SetGlobalFloat per frame, and keeps SRP batching whole.
There is no contradiction between the art bible's "one shader global per land instance" (§4.3) and "must be global properties" (§8.B.1): at N = 1 those are the same statement.
4. ⚠️ The N > 1 cliff — named now, not discovered at Tier 1
At N > 1 the globals approach breaks by construction. Tide GDD Rule 4 draws socket phase offsets as a permutation of evenly-spaced C/N slots, so no two sockets are ever in phase — that is structural, not a tuning artefact. With 3–4 sockets, several lands are docked simultaneously at different tide phases, and a single frame-global _TideAmount cannot represent more than one of them. Every docked land would show the same phase, which is precisely the information the tint exists to convey.
This is a hard architectural cliff with a known trigger (N > 1), not a deferred nicety. The three ways across it, assessed now so Tier 1 does not rediscover them under time pressure:
| Route | Batching | Verdict |
|---|---|---|
| Per-material instances (one material per land) | breaks SRP batching — §8.B.1's flagged risk | Rejected unless measured to fit |
| Per-renderer MPBs | breaks SRP batching — banned outright by §8.B.1 | Forbidden |
| Per-sprite phase attribute — carry the socket index in a vertex stream / UV2, keep one material, and index small global arrays in-shader | preserved — one material, one batch per atlas | The only batching-preserving path identified. Design it before N > 1 ships — with the three caveats below, which are the difference between a plan and a trap. |
Route 3 caveats (verified 2026-09-01 — read these before building it):
Shader.SetGlobalColorArraydoes not exist. The 6.3Shaderclass exposes onlySetGlobalFloatArray,SetGlobalVectorArrayandSetGlobalMatrixArray— there is a scalarSetGlobalColor, but no Color-array overload. The tint array must be written withShader.SetGlobalVectorArrayasVector4[](cast fromColor[]);_TideAmountBySocket[8]viaSetGlobalFloatArrayis fine as named. A Tier 1 developer reaching for the obvious symmetric API would find nothing.- Shader Graph cannot declare an array property. The 17.x Property Types list (Float, Vector2/3/4, Color, Boolean, the Texture family, Matrix2/3/4) has no array type. Reading a global array requires a Custom Function node in File mode referencing hand-written HLSL, with the arrays declared as uniforms outside
UnityPerMaterial. This is buildable but it is not a pure node-graph solution — there is no built-in "array lookup" node. - ⚠️ LIKELY UNAVAILABLE: UV2 / custom vertex streams on
SpriteRenderer.SpriteRenderergenerates its own quad mesh internally from the Sprite/atlas data and exposes no public vertex-stream authoring API — there is noSpriteRendererequivalent ofParticleSystemRenderer.SetActiveVertexStreamson 6.3. Sharpened from "unverified" by architecture review E16: this is not a coin flip, and Tier 1 should not plan as though it were. The realistic reading is that route 3 requires replacingSpriteRendererwith a custom mesh renderer for DriftLayer sprites, which is a substantially larger change than "add a UV2 channel". The spike's question is therefore not "does UV2 work on SpriteRenderer?" (almost certainly no) but "what does it cost to render DriftLayer sprites off a custom mesh, and does that survive the batching rule?" If that cost is unacceptable, the routes reduce to "accept broken batching" versus "redesign" — a decision worth reaching early.
MVP ships route 0 (single global pair). The array form is deliberately not built now — YAGNI, and N = 1 cannot exercise or test it. But the cliff is recorded with its trigger so the Tier 1 story is "implement the designed route", not "discover the design is wrong".
5. The tint driver
One TideTintDriver in Driftlands.Presentation (ADR-0003: Presentation may reference Rules/Tide; the driver is an adapter, not a formula).
- Per frame: reads
ITideClock.TintFloats(0)→ maps(amber, violet)to a colour + amount → twoShader.SetGlobal*calls. Two calls per frame, no allocation. - The per-frame
TintFloatscall is the path ADR-0001 already budgeted — its ≤ 50 µs / 0 bytes allocated warmed-call gate explicitly covers "per-frame for tint floats". Stated so a future profiler-driven "optimisation" does not cache the result to save twoSetGlobalcalls and thereby tripcaching_evaluate_across_ticks. - It computes no tide math.
TintFloatsis the sole source; the driver never seest, never derives a phase, never caches across frames (caching_evaluate_across_ticks,second_authority_clockandraw_tide_time_on_consumer_surfaceare all forbidden patterns from ADR-0001). - Amber/violet → one colour: the two floats are mutually exclusive by construction (F7: amber is nonzero only for
p < 0.15, violet only forp ≥ 0.75), so the driver picks the live one and writes_TideTint= that state's locked hex from §4.1,_TideAmount= its intensity. Neutral (amount = 0) needs no colour. - ⚠️ This ships F7's two-lobe shape, not art bible §4.3's authored curve — deliberately. §4.3 describes "lerp toward Tide Amber over the first 60% of the arrival window; hold near-peak through docking; on departure lerp from current tint toward Tide Violet". F7 instead ramps amber in, returns to neutral for ~60% of every docking ("the fantasy's 'normal sky' majority"), then ramps violet at departure. The authority for shipping F7's shape is the art bible's own MVP/Tier-1 split (§8.B.3): the minimal pipeline driven by
amberIntensity/violetIntensityships at MVP, and "the multi-stop §4.3 curves" are Tier 1 "Tide-Tint Polish". §4.3's "hold" language describes the Tier 1 target, not the MVP one. Stated here so the first person comparing the shipped tint to §4.3 files it as designed, not as a bug. EvictionGrace(TR-tide-008 / F7): violet is held at 1.0 through the grace window — the land stays violet until it is actually torn down, so it never snaps back to neutral in front of the player.TintFloatsalready implements the hold; the driver must not "helpfully" fade it (V5).LandTornDownends the tint (ADR-0005's contract, honoured here). ADR-0005 (Accepted) specifies the teardown sequence asEvictionExpired→ despawnLandNet→LandTornDown(socket)— "(tint stops, ADR-0013)", and names this ADR in its Enables row. This is not a no-op for a global: F7 states "after teardown, no values", soTintFloatsstops supplying while_TideTint/_TideAmountretain their last written values — which is violet 1.0, the peak. An unhandled teardown therefore leaves the whole world tinted violet with no land present, indefinitely. The driver subscribes toLandTornDown(socket)and writes neutral (_TideAmount = 0) on that edge, which is the single write that closes the loop. (Type note, review F2: ADR-0005 declaresevent Action<SocketId> LandTornDownwhile ADR-0001'sITideClock.TintFloats(int socket)takes a bareint— the surviving exception to ADR-0003's "SocketIdeverywhere" rule. The driver unwraps:TintFloats(s.Value). Do not "fix" this by changingITideClock, which is Accepted.) It is already a registeredtide_transitionsconsumer (forSoftClose), so the event surface exists. V5 extends past teardown, not just through the grace window.- Presentation-role clock only — clients read their own clock (ADR-0001); nothing about the tint crosses the wire.
6. No custom render pass at MVP
The tint is a property read inside the sprite shader. No Scriptable Renderer Feature, no custom Render Graph pass, no post-process. This is what keeps a HIGH-risk domain at effectively zero risk.
⚠️ Do not use docs/engine-reference/unity/modules/rendering.md as the reference for that future work — it mixes a Unity-6-correct RecordRenderGraph snippet with a pre-Render-Graph AddRenderPasses(ScriptableRenderer, ref RenderingData) signature in the same document and cites 6000.0 URLs. It would reintroduce the exact deprecated shape this ADR avoids. Flagged for a doc fix-pass.
If a full-screen tide atmosphere effect is ever added (Tier 1 "Tide-Tint Polish", systems index row 15), it must be Render-Graph-native (AddRenderPasses) from day one — Compatibility Mode is gone in 6.3 and SetupRenderPasses was deprecated in 6.2. Recorded as a forbidden pattern so nobody ports a tutorial written against the old API.
7. Networking: none
The tint is pure presentation derived from a deterministic schedule. Every client computes the same tide from the same seed and wall-clock (ADR-0001), so the tint needs no replication, no authority and no reconciliation. Nothing in this ADR touches NGO, HostCommitQueue, or any command. It is independent of ADR-0008.
8. The build overlay (TR-bb-009, QQ-06's remainder)
The BB ghost and reach-grid overlay are bundled here by architecture.md row 13 because they are the other thing drawing into the world outside the sprite batch.
- Ghost: outline-only, footprint-sized, solid ↔ dashed flipping truthfully every frame (ADR-0014 §6 — the reason-string hysteresis governs the string, never the outline). Drawn as a sprite-based 9-slice outline from the home
SpriteAtlas— named explicitly, because building is legal only on the home island (BB Rule 3) and ADR-0005 makes the home atlas a scene-referenced, non-addressableSpriteAtlas(BC7), a different material from the per-template land atlases (BC3, Addressable). Batching with "the atlas" is only true of the right one. - The dashed state is pre-authored sprite frames in that same home atlas — not a shader-generated dash pattern. This is the load-bearing half of the "0 draw calls" claim: a procedural dash would need its own material and shader variant, costing +1 call and a second variant, and the flip happens at interactive rates (every frame the validity changes). Two sprite frames on one material cost nothing extra. V4 measures the marginal cost with the outline actually dashed, which is the case the BB GDD escalated to the TD.
- Reach grid: drawn within reach radius only (
ui-hudquick-spec Rule 4, Base Building row: "grid within reach only") as one procedurally-generated mesh with a single unlit material — 1 draw call, not one per cell. A per-cell quad approach at max reach would be dozens of calls and is rejected. - Budget (revised 2026-09-07, review E20): grid 1 + ghost 1 = 2 draw calls in the editor; 1 in a built player if the home atlas batches the ghost as designed (unverified — deferred to a Frame Debugger check).
EditorSettings.spritePackerMode = 3packs atlases at build time only, so Play Mode never renders from an atlas and the ghost-0 is unobservable in the editor by construction. Entered only in build mode. Against < 100 total this is immaterial, which is the answer QQ-06 was waiting for. V4 measures it at max reach rather than assuming it. - The overlay is not
DriftLayer-tagged: a ghost must read identically under any tide state, for the same reason telegraphs must (§2).
Citation note:
art-bible.md§8.A.4 attributes the telegraph exemption to "combat GDD Rule 11". That label is wrong — the requirement is in the combat GDD's telegraph contract (two statements, one in the telegraph rules and one in the hit-feedback section), while Rule 11 is the enemy-interface rule. The requirement is real and binding; only the pointer is stale. Worth correcting in the art bible's next pass so a reader chasing "Rule 11" does not conclude it was dropped.
Alternatives Considered
Alternative A: Exposed properties + Material.SetColor per material
- Description: Ordinary exposed blackboard properties; the driver sets values on each material.
- Pros: Visible and tweakable in the Inspector; obvious to a newcomer.
- Cons: Breaks SRP batching (§8.B.1's hard rule) as materials diverge; requires the driver to enumerate materials; and with one shared material it cannot express per-land variance anyway. It is also the shape people reach for by accident — which is why §1 states the opposite as a rule.
- Rejection Reason: Violates the batching rule outright, and buys nothing at
N = 1.
Alternative B: Per-renderer MaterialPropertyBlocks
- Description: An MPB per
DriftLayerrenderer carrying its land's tint. - Pros: The textbook way to vary a property per renderer; would solve
N > 1directly. - Cons: Explicitly banned by art bible §8.B.1 — MPBs break SRP batching and fragment the draw budget across every sprite on every docked land.
- Rejection Reason: Forbidden by a hard rule, and the
N > 1problem it solves does not exist at MVP.
Alternative C: Baked per-tide-state sprite variants
- Description: Author
_low/_mid/_highsprites; swap at runtime. - Pros: No shader work; trivially predictable.
- Cons: Triples atlas memory; makes every future palette change a re-export of every sprite; and produces stepped transitions where §4.3 specifies a Turner-style graduated bleed.
- Rejection Reason: Banned at intake by §8.A.4 ("no per-tide-state baked sprite variants, ever";
_low/_mid/_highnaming is rejected).
Alternative D: Full-screen post-process tint (custom Render Graph pass)
- Description: Tint the whole frame in a renderer feature, mask out excluded sprites.
- Pros: One place to change; naturally handles atmosphere effects.
- Cons: Lands squarely in 6.3's HIGH-risk removed-Compatibility-Mode surface; and the taxonomy is an exclusion (§2) — masking players, enemies, telegraphs, home structures and all UI out of a full-screen effect is strictly harder than tagging what should be tinted.
- Rejection Reason: Maximum engine risk for a harder version of the problem. Reserved, Render-Graph-native, for Tier 1 atmosphere only (§6).
Alternative E: Global array indexed per sprite (the N > 1 route) at MVP
- Description: Build route 3 of §4 now — vertex-stream socket index +
_TideTintBySocket[8]. - Pros: Would make
N > 1a config change rather than a rewrite. - Cons: Cannot be exercised or tested at
N = 1; adds a vertex-stream authoring requirement to every land atlas before anything needs it. - Rejection Reason: YAGNI. Building an untestable generalisation for a Tier 1 trigger is how MVPs slip. The cliff is documented (§4) instead, which delivers the real benefit — nobody is surprised — at no cost.
Consequences
Positive
- The anchor test ("the world says it, no UI timer") becomes runnable at the friend-group playtest, which is the reason this is MVP scope at all.
- TR-tint-001/002/003, TR-tide-008 and TR-bb-009 close;
traceability-index.mdclears three ❌ rows plus the BB overlay gap. - A silent-failure mode is converted into a stated rule with a test (§1) — the failure this design was most likely to ship with.
- The HIGH-risk rendering domain is avoided rather than navigated: no custom pass means 6.3's Render Graph mandate has no surface to bite.
- QQ-06's remainder (build-overlay draw-call cost) is answered with a number: +1 draw call.
- The
N > 1cliff is on record with its trigger, so Tier 1 plans it instead of discovering it.
Negative
- No per-land tide variance at MVP. Invisible at
N = 1(there is only one land), but the shipped pipeline is not the Tier 1 pipeline. The step between them is content work across every shipped land template — route 3's per-sprite attribute is an authoring requirement on each atlas, not just a code change — plus hand-written HLSL and an unverified premise (§4). That is the cost that will actually hurt at Tier 1, and it is why building it now (Alternative E) would have meant paying it on every asset authored between now and then, for a feature nothing exercises. _TideTint/_TideAmountare not tweakable in the Material Inspector — deliberate (§1 — they areGlobal-scope, so they are not material properties at all), but artists must change tint values in the shader graph or via the driver's data, not by clicking a material. Worth stating in the art pipeline docs.- One more shader variant to keep alive across URP upgrades. The untinted side of §2's exclusion costs nothing extra — excluded sprites use URP's stock 2D sprite shader, not a second authored graph, so "exclusion by construction" is free to maintain as well as safe.
Risks
- Someone "fixes" the invisible properties by switching either one to
Per Materialscope (or by reaching forShow In Inspectorin order to see them, and then also flipping scope), silently disabling the tint. Mitigation: §1 states it as a rule with the failure mode spelled out and explicitly warns that visibility is the wrong knob; the §8 editor test asserts absence from the property block and fails the build; the ADR is cited from the art bible. - SRP batching broken by an unrelated change (a stray MPB, a diverging material). Mitigation: V2 asserts SRP-batcher compatibility code 0 with a non-zero control (batch counts are not observable — review E21);
mpb_on_driftlayer_spriteis registered as a forbidden pattern. Nis raised to 3–4 without route 3 — every land would show the same phase, and the symptom ("the tint looks wrong on lands you are not standing on") is low-visibility and easy to misattribute to the shader. Mitigation: §4 names the trigger, and the guard fails the boot loudly — not a warning. A warning would contradict this project's own Principle 5 ("fail loudly at load, never silently at runtime") and thesilent_default_on_missing_keyban. The driver/shader pair declares amaxSocketsSupported = 1capability; ADR-0006 validation errors ifN > maxSocketsSupported, so raisingNcannot ship without route 3. Added with the MVP story, not the Tier 1 one — atN = 1it is inert and free, and the Tier 1 story is the one that removes the need for it.- Shader Graph renames the control again, making §1 read as stale. Mitigation: §1 names the behaviour — the property is not declared in the material's property block, so the global is what the shader sees — as the rule, and
Scope = Globalonly as the current spelling. Note the wording must stay precise here: an earlier draft treated "un-exposed" and "not material-serialized" as synonyms, and that conflation was the bug (review E14). Hidden ≠ absent; only the declaration scope decides whether a global is shadowed. - Violet-hold regression — a future "smooth fade" refactor breaking F7's held violet. Mitigation: V5 is a named test, not an inspection.
GDD Requirements Addressed
| GDD / Spec | Requirement | How This ADR Addresses It |
|---|---|---|
art-bible.md §8.B.1 | Globals via Shader.SetGlobal*, never per-renderer MPBs | §1 — and §1 supplies the missing precondition (Global scope) without which the globals silently do nothing (TR-tint-001) |
art-bible.md §8.A.4 | DriftLayer covers terrain/nodes/props; combat + character sprites and home structures never tinted | §2 — exclusion by material, not by runtime branch; V3 (TR-tint-002) |
art-bible.md §8.B.3 | One Shader Graph sprite shader; no custom Render Graph pass; future post-process Render-Graph-native | §1, §6 (TR-tint-003) |
art-bible.md §4.3 | Amber/violet curve; t=0 at dock open; one shader global per land | §3 (at N = 1 "per land" and "global" coincide) + §4 (they diverge at N > 1) |
design/gdd/tide-scheduler.md F7 | amberIntensity/violetIntensity; violet held through EvictionGrace | §5 — the driver must not fade the held violet; V5 (TR-tide-008) |
design/gdd/base-building.md Visual Requirements | Dashed ghost + reach grid within < 100 draw calls | §8 — ghost batches with the atlas (0), grid is one mesh (1); V4 (TR-bb-009) |
design/quick-specs/camera-2026-08-27.md Rule 1 | Orthographic, 64 px/u, PPU 32, point filter | §1 — a fragment-stage tint changes no sampling or geometry (TR-cam-001) |
Performance Implications
- CPU: two
Shader.SetGlobal*calls per frame. No allocation, no per-renderer iteration. - GPU: one extra lerp/multiply in the sprite fragment shader. Immaterial at 2D silhouette fill rates.
- Draw calls: tint adds zero (one shader, one material per atlas, batching preserved). Build overlay adds +1, only in build mode. Against < 100.
- Memory: one shader variant. No per-tide-state textures (Alternative C rejected), so the 25–35 MB atlas envelope is unchanged.
- Network: none — nothing crosses the wire (§7).
Migration Plan
No production code exists yet.
- V1 spike first — author a throwaway Shader Graph with one
Global-scope property, set it viaShader.SetGlobalColor, confirm it tints and is absent from the Material Inspector (and thatFindPropertyIndexreturns −1 for it). Everything else assumes this; it is an hour's work and the whole decision rests on it. DriftLayerSpriteshader graph with the twoGlobal-scope properties + the editor test asserting they are absent from the material property block (§1, §8).TideTintDriverinPresentationreadingITideClock.TintFloats(0); V5 violet-hold test.DriftLayertagging on land-template atlases; the excluded classes get the untinted sprite shader (§2); V3.- Build overlay: ghost on the shared atlas material, grid as one procedural mesh; V4 draw-call measurement at max reach.
- V2 Frame Debugger batching check on a docked land.
architecture.md§4 + registry sync in the same change.
Validation Criteria
- V1 / EDITOR: a
Global-scope blackboard property receivesShader.SetGlobalColorand tints, and does not appear in the Material Inspector. Plus an editor test: the shippedDriftLayerSpriteshader's material property block contains neither_TideTintnor_TideAmount— asserted asshader.FindPropertyIndex("_TideTint") == -1(and likewise_TideAmount), which is true exactly when the properties areGlobal-scope. Fails the build if either is present. - ⚠️ Assert absence, never
HideInInspector(architecture review E15). An earlier draft assertedGetPropertyFlagsreturnsHideInInspector— but a brokenPer Material+ hidden property satisfies that too, so the gate would have passed on the exact bug it exists to catch. Absence from the property block is the real invariant. FindPropertyIndex(string)andGetPropertyFlags(int)are instance methods on aShaderobject, not statics — call them on the loaded shader.- V2 / EDITOR + PLAYMODE (revised 2026-09-07, review E21): SRP-batcher compatibility is asserted structurally, not by counting:
ShaderUtil.GetSRPBatcherCompatibilityCode(DriftLayerSprite)returns 0 after one rendered frame ("Not initialized" before the shader compiles for the running API), measured alongside a deliberately incompatible control shader that must return non-zero (observed 10: property in a cbuffer other thanUnityPerMaterial) — the meter must read non-zero before a zero is evidence. Code 0 is the statement that_TideTint/_TideAmountsit outsideUnityPerMaterial, which the V1 spike left open. ⚠️UnityStats.batchescannot verify this and must not be used: on 6000.3.18f1 / URP 17.3.0 2D it scales 1:1 with sprite count in all four SRP-Batcher × dynamic-batching settings, for the stock URP sprite shader as well as ours (32 sprites → 33) — the SRP Batcher does not merge draws, it removes per-draw CBUFFER setup. "One batch per sprite atlas" describes the Frame Debugger's grouping display and survives only as a manual check on a built player (V8). CPU cost, if wanted, isUnityStats.setPassCalls, which isUnityEditor-internal and absent from a player build. - V4 / PLAYMODE (revised 2026-09-07, review E20): entering build mode at maximum reach with the ghost dashed adds ≤ 2 draw calls in the editor — grid ≤ 1 (one procedural line mesh, one unlit material), ghost ≤ 1 (one sprite) — measured as a marginal delta before vs. after, never "total < 100". §8's ghost-0 is a built-player claim: DEFERRED to a Frame Debugger check on a built player; not a gate until then. The structural guarantees (one material, pre-authored dashed frames, no MPB) are asserted by
BuildOverlaySetupTestsandTideTintNegativesTests. - V8 / PROJECT SETTING:
EditorSettings.spritePackerMode == 3is asserted by an EditMode test so a change to it re-opens V2/V4 visibly. - V3 / PLAYMODE: in one frame, a
DriftLayerterrain sprite is tinted while a player, an enemy, a telegraph VFX, a home structure and a world-space UI sprite are not. - V4 / PLAYMODE: entering build mode at maximum reach with the ghost in its dashed state adds ≤ 1 draw call — measured as a marginal delta (count immediately before vs. after entering the mode), not as "total < 100", which passes trivially when the total is 40.
- V5 / UNIT + PLAYMODE:
TintFloatsreturns violet 1.0 for the wholeEvictionGracewindow and the driver writes it unmodified — no fade, no early neutral. The test runs past teardown: onLandTornDownthe driver writes_TideAmount = 0, and a frame after teardown shows a neutral world — the global does not retain violet (§5). - UNIT (reflection, ADR-0003):
TideTintDriverlives inDriftlands.Presentation; noUnityEnginereference appears inDriftlands.Tide; the driver contains no tide arithmetic — its only clock call isTintFloats. - UNIT (schema-negative): no
MaterialPropertyBlockis constructed anywhere in theDriftLayerrender path. - PLAYMODE (taxonomy): every sprite in a shipped land template resolves to the tinted shader; every character/home/UI sprite resolves to the untinted one — asserted over the whole atlas, not sampled.
Applied on Acceptance — ✅ ALL APPLIED 2026-09-01
Historical record. While this ADR was Proposed, no registry stances were registered and no other document was amended — amending Accepted documents on the strength of a Proposed one strands those edits if
/architecture-reviewreturns it. That deferral ended when this ADR was Accepted on 2026-09-01: every item below is now applied and committed (dc7c1b0). The list is kept as the record of what landed, not as outstanding work.
Registry stances claimed (now registered in docs/registry/architecture.yaml):
- forbidden:
mpb_on_driftlayer_sprite(breaks SRP batching — art bible §8.B.1);exposed_shader_global_read— named for the class, not these two properties: any shader property intended to be driven byShader.SetGlobal*must not be written per-material, or the material silently shadows the global (§1). The next global added (a Tier 1 atmosphere parameter) hits the identical trap;non_render_graph_pass(Compatibility Mode is gone in 6.3 — §6);baked_tide_state_variant(§8.A.4). - api_decision:
tide_tint_globals— twoGlobal-scope Shader Graph blackboard properties written withShader.SetGlobal*once per frame byTideTintDriver; rejected:Per Materialproperties (incl. merely hidden ones), MPBs, baked variants, custom pass. - interface:
TideTintDriverconsumesITideClock.TintFloats(socket)(ADR-0001) — presentation-only, never replicated. - budget: build overlay +1 draw call, entered only in build mode (answers QQ-06's remainder).
Documents amended (all ✅ applied 2026-09-01): architecture.md (§4 + Required-ADR row 13 → Accepted; QQ-06 fully closed), traceability-index.md (TR-tint-001/002/003, TR-tide-008, TR-bb-009), and two art-bible.md notes: §8.B.3 gains the Global-scope precondition (§1) — the one place the art bible is silent on something its own design needs — and §4.3 gains the N > 1 caveat, since its "one shader global _TideTint … per land instance" phrasing becomes false the moment a second socket exists (§4), and §4.3 is where an artist would look.
Related Decisions
- ADR-0001 (Accepted —
ITideClock.TintFloats(socket)is the only tint source; the driver adds no clock) - ADR-0003 (assemblies — driver in
Presentation, no tide math) - ADR-0005 (Accepted — per-template atlases are the batching unit)
- ADR-0014 (UI stack, Proposed — world-space UI sprites are
DriftLayer-untagged; no shared surface) - ADR-0008 (netcode wire — no dependency: the tint never crosses the wire)
design/art/art-bible.md§4.1/§4.3/§8,design/gdd/tide-scheduler.md(F7, Rule 4,N),design/gdd/base-building.mdVisual Requirements,design/gdd/combat-system.md(telegraph contract),design/ux/build-mode.md
Amendment History
- 2026-09-01, architecture review E14/E15 — §1 named the wrong Shader Graph control (the load-bearing mechanism). The draft made "Show in Inspector unchecked" the rule, reasoning that a hidden property is "never set per-material" and so falls through to the global. That reasoning is wrong, and it prescribed the exact bug §1 exists to prevent. Two independent engine audits split on this claim; it was adjudicated against primary sources. Shader Graph 17.0's Property Types page defines two separate controls:
Show In Inspector(disabling it merely "includes an[HideInInspector]attribute to the material property" — still a material property) andScope(Global= "editable at a global level, through a C# script only"). PerShader.SetGlobalColor(6000.3), globals apply only when "the material does not have them defined" — and a[HideInInspector]property is defined, serialized with the shader's default the moment the material is created. So the drafted mechanism would have shipped a silent no-op that is also invisible. Corrected toScope = Globalthroughout (title, §1 heading/table/rule, Post-Cutoff cell, Verification Required, requirements table, migration steps 1–2, registryapi_decision, art-bible amendment note). - E15, same finding: the §8 editor test asserted
GetPropertyFlagsreturnsHideInInspector— which a brokenPer Material+hidden property also satisfies, so the gate would have passed on the bug it exists to catch. Inverted to assert absence from the property block (FindPropertyIndex(...) == -1), which is true exactly when the property isGlobal-scope. Also noted: both are instance methods on aShader, not statics. - V2 re-scoped: changing declaration scope alters the
UnityPerMaterialCBuffer layout, so V2 must run against theGlobal-scope shader specifically rather than as a formality. - Terminology risk reworded: the draft's mitigation treated "un-exposed" and "not material-serialized" as synonyms — that conflation was the bug. The rule now names the behaviour (not declared in the material's property block) with
Scope = Globalas the current spelling. - 2026-09-01, architecture review E16 —
SpriteRendererUV2 premise sharpened. §4 route 3 marked custom vertex streams onSpriteRenderermerely "UNVERIFIED".SpriteRendererbuilds its own mesh and exposes no public vertex-stream API, so the honest framing is "likely unavailable — route 3 probably requires a custom mesh renderer". Restated so the Tier-1 spike asks the right question. No MVP impact (N = 1does not use route 3). - 2026-09-01, architecture review F2 —
SocketIdunwrap noted. §5 now states thatLandTornDowncarriesSocketId(ADR-0005) whileITideClock.TintFloatstakesint(ADR-0001, Accepted), and that the driver unwraps via.Valuerather than changing the Accepted interface. - 2026-09-03, V1 spike PASSED (
prototypes/tide-tint-v1-spike-2026-09-03/REPORT.md). On 6000.3.18f1 / URP 17.3.0 / Shader Graph 17.3.0 / Metal / 2D Renderer: a Global-scope property is absent from the property block (FindPropertyIndex == -1) and receivesShader.SetGlobalColor(pixel-verified); the hiddenPer Materialtwin is in the block and ignores the global write — the E14/E15 bug reproduced on purpose. The serialized spelling ofScope = GlobalisoverrideHLSLDeclaration: true+hlslDeclarationOverride: 1 (Global)+m_GeneratePropertyBlock: falseon the blackboard property. Not yet inspected: theUnityPerMaterialCBuffer layout claim — that remains V2's job. Side effect: URP + Shader Graph are now installed and a 2D pipeline asset is assigned (Driftlands.Editor.UrpSetup).
Amendments — 2026-09-07 (architecture-review-2026-09-07.md)
- E21 / #14 V2 rewritten: the Frame-Debugger "one batch per atlas" criterion was unmeasurable as an automated gate (
UnityStats.batchescounts draws); replaced by the compatibility-code gate with a control that the tide-tint epic had already shipped. - E20 / #15 V4 and the §8 budget line corrected from +1 to +2 in the editor; the ghost-0 is deferred to a built-player check.
- E23 new V8 names
EditorSettings.spritePackerMode = 3as load-bearing. - The control manifest's guardrail rows for V2/V4 must be regenerated (
/create-control-manifest) to name these meters (review E22).