feat(ui): T-1161 — per-rung composite filter policy (Region NEAREST, District/Quarter LINEAR)

Araminta's ruling (PR #192 follow-up): filter keyed on rung IDENTITY via
the window's own echoed granularity_v2 — Region (incl. the orbital tile
mosaic, whose tiles are all Region-rung requests) samples NEAREST because
GPU bilinear at 204.8 km/cell reads as smoothing-over-absence; District/
Quarter keep LINEAR where cell density earns the blend. One shared helper
(_filter_for_granularity_v2) at both draw call sites; unknown/missing
wire tags fall back to LINEAR (never trusted into NEAREST). COMPOSITE_
SMOOTH survives as the independent compile-time pipeline axis — the
two-axes split is documented in the file header. Washes/border fades
untouched per the ruling. Focused suite 44/44; revert-verified (helper
hardcoded LINEAR -> exactly the three Region-NEAREST tests fail).

Tickets: T-1161

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:36:05 +02:00
co-authored by Claude Fable 5
parent bbe9099e22
commit c9028af77e
2 changed files with 206 additions and 20 deletions
@@ -25,18 +25,33 @@ extends Node2D
## COMPOSITE_SMOOTH := true renders the composite as an n x n Image (one
## pixel per district, EXACT same per-cell color pipeline this file always
## had — _cell_color()/_apply_glaciation() are UNCHANGED) converted to an
## ImageTexture and drawn scaled with LINEAR filtering, instead of n*n flat
## draw_rect() calls. GPU bilinear sampling between adjacent district pixels
## reads as a terrain gradient rather than hard-edged blocks — the same
## treatment the planetary heightmap already gets (Godot's engine-default
## CanvasItem.texture_filter is LINEAR_WITH_MIPMAPS project-wide, which is
## what AtlasViewer's draw_texture_rect() calls already inherit for free;
## this node sets texture_filter explicitly rather than relying on that
## default, so the choice is visible in code, not implicit). The crisp
## per-cell rect path SURVIVES behind the const (COMPOSITE_SMOOTH := false)
## so T-1143's design pass can compare both renderings directly — this is
## explicitly an INTERIM presentation, not the final answer on district-tier
## legibility (T-1143 owns that design).
## ImageTexture and drawn scaled with texture-filtered sampling, instead of
## n*n flat draw_rect() calls. The crisp per-cell rect path SURVIVES behind
## the const (COMPOSITE_SMOOTH := false) so T-1143's design pass can compare
## both renderings directly — this is explicitly an INTERIM presentation, not
## the final answer on district-tier legibility (T-1143 owns that design).
##
## T-1161 (Araminta's per-rung filter ruling, PR #192 review follow-up): the
## smoothed path's PIPELINE (texture-vs-flat-rects) and its SAMPLING FILTER
## (how the GPU reads that texture) are now two INDEPENDENT axes, not one
## bundled choice:
## - Axis 1 — PIPELINE: COMPOSITE_SMOOTH (compile-time const, unchanged by
## this ticket). true = draw a texture; false = per-cell draw_rect(). The
## composite is a TEXTURE at every rung when COMPOSITE_SMOOTH is true —
## this axis does not vary per rung.
## - Axis 2 — FILTER: _filter_for_granularity_v2() (runtime, keyed on rung
## IDENTITY via granularity_v2, T-1161). Region (incl. the orbital tile
## mosaic) samples TEXTURE_FILTER_NEAREST — GPU bilinear stretch at
## 204.8 km/cell reads as a near-featureless soft gradient, technically
## honest LoD but visually indistinguishable from the coarse-composite
## smoothing-over-absence the mandate was written to kill (T-1161's own
## description). District and Quarter sample TEXTURE_FILTER_LINEAR — cell
## density there reads as texture, not smoothing-over-absence, so the
## bilinear blend is earned. No hysteresis, no px-per-cell threshold —
## the filter is a pure function of which rung's data is being drawn.
## The crisp draw_rect() path has no sampling-filter concept at all (no
## texture involved) — its comparison/debug role per the paragraph above is
## unaffected by this axis.
##
## The texture is REBUILT only when its inputs change (the window object
## itself — a new DistrictWindowLayer arriving is a new Dictionary, checked
@@ -252,6 +267,13 @@ func _draw_tile_mosaic() -> void:
## `tile_index` — sharing ONE `_cached_texture` slot across all tiles (the
## single-window field) would thrash on every draw call as different tiles'
## windows compete for it.
##
## T-1161: every mosaic tile is a Region-rung request (atlas_window_tile_set.gd
## requests tiles at AtlasWindowRequest.GRANULARITY_V2_REGION), so the mosaic
## as a whole is in scope for the Region -> NEAREST ruling. As with the
## single-window path, the filter is read from THIS tile's own echoed `w`
## rather than assumed, via the shared `_filter_for_granularity_v2()` helper
## — one policy, two call sites, no duplicated match statement.
func _draw_one_tile(
tile_index: int,
w: Dictionary,
@@ -268,7 +290,8 @@ func _draw_one_tile(
)
if tile_texture == null:
return
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT))
texture_filter = _filter_for_granularity_v2(granularity_v2)
draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false)
@@ -367,19 +390,43 @@ static func cell_grid_side_for_window(w: Dictionary) -> int:
return n # District — 1:1
## T-1161 (Araminta's per-rung filter ruling): the sampling filter to use for
## the smoothed composite's texture, keyed on RUNG IDENTITY alone via
## `granularity_v2` — no hysteresis, no px-per-cell/zoom threshold. Region
## (204.8 km/cell — the same rung the orbital tile mosaic draws at, since
## every mosaic tile is itself a Region-rung window per `_draw_tile_mosaic()`)
## reads NEAREST: at that density, GPU bilinear blending between real derived
## samples is technically honest LoD but visually indistinguishable from the
## coarse-composite-stretched smoothing-over-absence the mandate was written
## to kill — the spirit is violated even though the letter ("never magnified
## interpolation") is not. District and Quarter read LINEAR: cell density at
## those rungs is high enough that the blend reads as texture, not as papering
## over sparse data. Mirrors `cell_grid_side_for_window()`'s own posture on an
## unknown/missing tag — an unrecognized wire value must never be trusted into
## the crisp NEAREST treatment, so it falls back to District's LINEAR instead
## of Region's NEAREST (fail toward the safer/already-shipped look).
static func _filter_for_granularity_v2(granularity_v2: String) -> CanvasItem.TextureFilter:
match granularity_v2:
AtlasWindowRequest.GRANULARITY_V2_REGION:
return CanvasItem.TEXTURE_FILTER_NEAREST
_:
return CanvasItem.TEXTURE_FILTER_LINEAR # District, Quarter, and unknown/missing fallback
## T-1145 item 3: the smoothed path — build/reuse a `grid_side` x `grid_side`
## ImageTexture (one pixel per DERIVED CELL, T-1152 — not per district, see
## cell_grid_side_for_window()'s doc) and draw it scaled to (n*cell_px), n
## being the window's DISTRICT extent, with LINEAR filtering. texture_filter
## is set on `self` (a CanvasItem property) once per draw — cheap (a property
## write, not a texture rebuild) and correct even the first time this runs
## (Godot's engine default already IS linear, but this makes the choice
## explicit rather than relying on an implicit project-wide default that
## could change).
## being the window's DISTRICT extent. texture_filter is set on `self` (a
## CanvasItem property) once per draw — cheap (a property write, not a
## texture rebuild). T-1161: the filter itself is now PER-RUNG, read from
## `w`'s own echoed `granularity_v2` (the same "response is the source of
## truth" posture cell_grid_side_for_window() already uses) via
## `_filter_for_granularity_v2()`, rather than an unconditional LINEAR.
func _draw_smoothed_composite(
w: Dictionary, n: int, grid_side: int, cell_px: float, active_toggle: String
) -> void:
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT))
texture_filter = _filter_for_granularity_v2(granularity_v2)
_rebuild_texture_if_needed(w, grid_side, active_toggle)
if _cached_texture == null:
return
@@ -430,6 +477,13 @@ func _rebuild_texture_if_needed(w: Dictionary, grid_side: int, active_toggle: St
## DERIVED cell-grid side (see cell_grid_side_for_window()); `n` (the
## window's district extent) sizes the on-screen cell pitch so the total
## drawn footprint stays `n * cell_px` regardless of rung.
##
## Note (T-1161): this path never touches the node-level `texture_filter`
## property — draw_rect() has no texture/sampling-filter concept, so there is
## nothing to set. That is inert today only because nothing else reads
## `texture_filter` while this path is active; it is not a bug to fix here,
## just worth stating since the smoothed path now sets that property
## per-rung and a reader might otherwise wonder why this path doesn't.
func _draw_crisp_composite(
w: Dictionary, grid_side: int, n: int, cell_px: float, active_toggle: String
) -> void: