From b4a616e4236f99014db791e300da5da17a81aab7 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Wed, 19 Aug 2026 08:23:46 +0200 Subject: [PATCH] fix(ui): fill build-zone overlay masks in one pass, not per texel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play-observation finding: the game hitched and felt 'delayed' exactly while placing buildings. Root cause: the 21.4 overlay asked IsInsideBuildInfluence and HasMinimumBuildingSpacing PER TEXEL — every texel rescanned the placement register, and each entry paid a linear site-register probe for the D-108 finished-only rule. That is several hundred million operations per repaint at the 4 Hz cadence — a main-thread hitch four times a second in the placement phase. FillBuildZoneMasks fills both masks in ONE pass: both predicates are rectangle-Chebyshev distances between uniform footprints, so the exact answer set per register entry is a square of candidate origins (influence half-extent BuildInfluenceRadiusCells + f - 1; spacing half-extent MinimumBuildingDistanceCells + f - 2). Filters mirror the per-cell reads (finished own anchors for influence, all sites and living placements for spacing). The per-cell reads stay the validator path; an EditMode test pins mask == direct reads over the full grid, including an active site and an enemy building. Same picture, no hitch. No simulation behaviour change, no baseline movement. Verified: dotnet test 726/726; Unity EditMode 606/606 (1 new). --- .../Simulation/BuildZoneOverlayQueryTests.cs | 52 +++++++++++ .../Presentation/UI/BuildZoneOverlayView.cs | 30 +++++-- .../Construction/ConstructionSystem.cs | 90 +++++++++++++++++++ CHANGELOG.md | 11 +++ 4 files changed, 178 insertions(+), 5 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs index 1a843ce..931e1a5 100644 --- a/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs +++ b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs @@ -104,5 +104,57 @@ public void TheTwoReads_DistinguishOutsideFromInsideButSpacingBlocked() Is.EqualTo(CommandResultCode.Applied), "inside + unblocked validates"); } + + /// + /// The overlay's repaint reads MASKS, not per-cell queries (main-thread + /// hitch fix): the masks must equal the per-cell reads on EVERY origin + /// of the grid — the picture may never drift from the rule. The fixture + /// deliberately mixes the four register cases: a completed own anchor, + /// a second completed own building, an ACTIVE own site (blocks spacing, + /// never anchors — D-108) and an enemy building (blocks spacing for + /// everyone, anchors for nobody here). + /// + [Test] + public void FillBuildZoneMasks_MatchesThePerCellReads_OnEveryCell() + { + var entities = new EntityManager(64); + var economy = new EconomySystem(entities, 5000); + var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize); + var construction = new ConstructionSystem(entities, economy, costField); + var kernel = new SimulationKernel(new SimRandom(42UL)); + kernel.RegisterSystem(economy); + kernel.RegisterSystem(construction); + kernel.Start(); + + Assert.That(construction.PlaceCompletedBuilding(Slot, DefHQAlliance, 4, 4).IsValid, Is.True, "own HQ anchor"); + Assert.That(construction.PlaceCompletedBuilding(Slot, DefPowerAlliance, 13, 13).IsValid, Is.True, "own completed Power anchor"); + Assert.That(construction.TryPlaceBuilding(Slot, DefPowerAlliance, 20, 4), Is.True, "active own site"); + Assert.That(construction.PlaceCompletedBuilding(1, 20, 40, 40).IsValid, Is.True, "enemy HQ (Legion def 20)"); + + int size = ConstructionSystem.GridSize; + var influence = new bool[size * size]; + var spacingBlocked = new bool[size * size]; + construction.FillBuildZoneMasks(Slot, influence, spacingBlocked); + + int mismatches = 0; + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + int cell = y * size + x; + bool expectedInfluence = construction.IsInsideBuildInfluence(Slot, x, y); + bool expectedBlocked = !construction.HasMinimumBuildingSpacing(x, y); + if (influence[cell] != expectedInfluence || spacingBlocked[cell] != expectedBlocked) + { + if (mismatches++ < 5) + { + TestContext.Out.WriteLine( + $"({x},{y}): mask=({influence[cell]},{spacingBlocked[cell]}) direct=({expectedInfluence},{expectedBlocked})"); + } + } + } + } + Assert.That(mismatches, Is.EqualTo(0), "the repaint masks must equal the per-cell reads on every origin of the grid"); + } } } diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs index cb9d998..dab46e7 100644 --- a/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs +++ b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs @@ -84,6 +84,12 @@ public sealed class BuildZoneOverlayView : MonoBehaviour private Material _material; private GameObject _quad; private float _nextRepaintTime; + // Repaint scratch (21.4 follow-up): the two zone masks are filled by + // the construction system in ONE register pass per repaint — the + // per-texel query loop used to rescan that register per texel, which + // hitched the main thread four times a second while placing. + private bool[] _influenceMask; + private bool[] _spacingBlockedMask; private void Awake() { @@ -121,24 +127,29 @@ private void LateUpdate() /// whose 3x3 footprint would leave the map stay clear as well — /// painting them buildable would promise a placement the validator /// rejects on bounds alone (map geometry, not the zone rule). + /// The two masks come from the construction system pre-filled for the + /// whole grid () — + /// texels only read them. /// private void Repaint(ConstructionSystem construction, byte viewerSlot) { int size = ConstructionSystem.GridSize; int lastOrigin = size - SimDefinitions.BuildingFootprintCells; + EnsureMasks(size); + construction.FillBuildZoneMasks(viewerSlot, _influenceMask, _spacingBlockedMask); + for (int y = 0; y < size; y++) { int row = y * size; for (int x = 0; x < size; x++) { Color32 color = Clear; - if (x <= lastOrigin && y <= lastOrigin - && construction.IsInsideBuildInfluence(viewerSlot, x, y)) + if (x <= lastOrigin && y <= lastOrigin && _influenceMask[row + x]) { - color = construction.HasMinimumBuildingSpacing(x, y) - ? _buildableColor - : _spacingBlockedColor; + color = _spacingBlockedMask[row + x] + ? _spacingBlockedColor + : _buildableColor; } _pixels[row + x] = color; } @@ -148,6 +159,15 @@ private void Repaint(ConstructionSystem construction, byte viewerSlot) _texture.Apply(); } + private void EnsureMasks(int size) + { + if (_influenceMask == null || _influenceMask.Length != size * size) + { + _influenceMask = new bool[size * size]; + _spacingBlockedMask = new bool[size * size]; + } + } + /// /// Builds texture, material and quad lazily on the first show. /// Everything is runtime-generated and HideAndDontSave — the slice diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 53e0510..5c976ab 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -1177,6 +1177,96 @@ public bool HasMinimumBuildingSpacing(int originX, int originY) return true; } + /// + /// The build-zone overlay's two reads, hoisted from PER TEXEL to ONE + /// PASS (21.4 follow-up — the per-cell calls of + /// and + /// cost the overlay several + /// hundred million operations per repaint: every texel rescanned the + /// placement register, and each entry paid a linear site-register + /// probe for the finished-only rule. At the 4 Hz repaint cadence + /// while a placement ghost is armed, that was a main-thread hitch + /// four times a second in exactly the phase the player was placing). + /// + /// The fill is EXACT, not an approximation: both predicates are + /// rectangle-Chebyshev distances between uniform f×f footprints, so + /// the set of candidate origins they answer for is a square around + /// each register entry. With RectDist(o, p) = max(0, |o−p| − (f−1)) + /// per axis: inside-influence ⟺ |o−a| ≤ BuildInfluenceRadiusCells + + /// f − 1 per axis; spacing-blocked ⟺ |o−b| ≤ + /// MinimumBuildingDistanceCells + f − 2 per axis. The per-cell reads + /// stay the validators' own path — this mask exists for the overlay's + /// repaint loop only, and the edit-mode suite pins their equivalence + /// over the full grid (BuildZoneOverlayQueryTests). + /// + /// + /// Filters mirror the per-cell reads precisely: influence anchors are + /// own, living, COMPLETED placements (a site never anchors — D-108); + /// spacing blockers are every active site and every placement with a + /// living entity, of ANY owner. Both masks are cleared first and must + /// cover the full grid (GridSize × GridSize). + /// + /// + public void FillBuildZoneMasks(byte playerSlot, bool[] influenceMask, bool[] spacingBlockedMask) + { + int size = GridSize; + if (influenceMask == null || influenceMask.Length < size * size) + { + throw new ArgumentException("influenceMask must cover the full grid", nameof(influenceMask)); + } + if (spacingBlockedMask == null || spacingBlockedMask.Length < size * size) + { + throw new ArgumentException("spacingBlockedMask must cover the full grid", nameof(spacingBlockedMask)); + } + Array.Clear(influenceMask, 0, size * size); + Array.Clear(spacingBlockedMask, 0, size * size); + + int f = SimDefinitions.BuildingFootprintCells; + int influenceHalf = BuildInfluenceRadiusCells + f - 1; + int spacingHalf = MinimumBuildingDistanceCells + f - 2; + + for (int i = 0; i < MaxSites; i++) + { + ref readonly SiteState site = ref _sites[i]; + if (site.IsActive) + { + FillOriginBox(spacingBlockedMask, size, site.OriginX, site.OriginY, spacingHalf); + } + } + + for (int i = 0; i < MaxBuildings; i++) + { + ref readonly PlacementState placement = ref _buildings[i]; + if (!placement.IsActive) continue; + EntityId id = UnitCommandStateView.ToEntityId(placement.RawEntityId); + if (_entityManager.IsValid(id)) + { + FillOriginBox(spacingBlockedMask, size, placement.OriginX, placement.OriginY, spacingHalf); + } + + if (IsActiveSite(id)) continue; // finished only: a site never extends the zone + if (!_entityManager.TryGetUnit(id, out UnitState unit) || unit.PlayerId != playerSlot) continue; + FillOriginBox(influenceMask, size, placement.OriginX, placement.OriginY, influenceHalf); + } + } + + /// Marks the clamped square of candidate origins (cx±half, cy±half) in a row-major grid mask. + private static void FillOriginBox(bool[] mask, int size, int centreX, int centreY, int half) + { + int minX = Math.Max(0, centreX - half); + int maxX = Math.Min(size - 1, centreX + half); + int minY = Math.Max(0, centreY - half); + int maxY = Math.Min(size - 1, centreY + half); + for (int y = minY; y <= maxY; y++) + { + int row = y * size; + for (int x = minX; x <= maxX; x++) + { + mask[row + x] = true; + } + } + } + private bool HasValidFieldSpacing(UnitRole role, int originX, int originY) { bool refineryHasFieldInRange = false; diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5f02c..6044afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -269,6 +269,17 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Ruckler in der Bauphase: das Baubereich-Overlay hat den Hauptthread gebremst.** + Das 21.4-Overlay fragte die beiden Zonen-Reads (`IsInsideBuildInfluence`, + `HasMinimumBuildingSpacing`) **pro Texel** ab — jeder Texel scannte das + Platzierungsregister erneut, jeder Eintrag zahlte eine lineare + Baustellen-Probe. Das sind mehrere hundert Millionen Operationen pro + Repaint, viermal pro Sekunde, ausgerechnet während ein Bau-Ghost armiert + ist — Spielbericht: „komisch verzögert". Jetzt füllt + `ConstructionSystem.FillBuildZoneMasks` beide Masken in **einem** Pass + (exakte Rechtecks-Chebyshev-Boxen statt Texel-Scan, Äquivalenz über das + ganze Raster gepinnt); der Repaint liest nur noch. Selbes Bild, kein + Hitch. Simulationsverhalten unverändert, keine Baseline bewegt - **#85: Die KI erntet nicht länger endlos auf dem leeren Feld.** Aus dem Betatest vom 10.08.2026: die KI kam nach Erschöpfung ihres Startvorkommens wirtschaftlich zum Stillstand. Das war kein Strategiemangel, sondern ein