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