fix(world): Flackernden Boden behoben (Tiefenpuffer-Versatz je Schicht, Brücke angehoben)

Downtown aus der Luft: Boden, Stadtfläche, Park, Strand und Straße lagen 1-2 cm
auseinander (geometry.js: SURFACE_OFFSET=0.02 für alle drei Flächen gleich,
ROAD_OFFSET=0.03), bei 150-300 m Kameraabstand unter der Auflösung eines
24-Bit-Tiefenpuffers (~1,3 cm bei 150 m, ~5 cm bei 300 m, gemessen mit dem alten
Nahwert 0.1) — Strand/Park/Stadtfläche lagen zusätzlich exakt aufeinander und
fighteten unabhängig von der Entfernung. Die Brückenoberkante lag exakt auf der
Bodenoberkante (beide y=0).

Vier Änderungen, freigegebener Entwurf:
- Kamera-Nahwert 0.1 -> 0.5 m (src/main.js): fünffache Tiefenauflösung auf jede
  Entfernung, far bleibt 2500.
- src/world/materials.js: POLYGON_OFFSETS/POLYGON_OFFSET_ORDER, eine feste,
  streng zunehmend negative Stufe je Schicht (Boden 0, Stadt -1, Park -2, Strand -3,
  Straße -4, für Faktor und Units gleich) — wirkt im Tiefenpuffer selbst, hält die
  Rangfolge deshalb auf jede Entfernung, unabhängig vom geometrischen Abstand.
  applyPolygonOffsets() ist eine separate, reine Funktion, damit sie ohne
  createMaterials()/Texturladen testbar ist und beweisbar nur diese fünf Materialien
  trifft (Brücken/Gebäude/Wasser/Meeresboden bleiben unberührt).
- Strand, Park und Stadtfläche bekommen dabei je eine eigene Stufe, statt sich wie
  bisher (SURFACE_OFFSET) eine zu teilen.
- src/world/geometry.js: Brückendeck-Oberkante jetzt BRIDGE_OFFSET (=SURFACE_OFFSET,
  0.02 m) statt 0 — derselbe Größenordnungsentscheid wie bei den Flächen, klein genug
  für einen unbemerkten Schritt, groß genug für die Tiefenpuffer-Auflösung bei 300 m.

TDD: Rangfolge/Kein-Teilen-Regeln sind rein und zuerst als fehlschlagende Tests
geschrieben (tests/materials.test.js, 5 neue), ebenso die Brückenanhebung
(tests/geometry.test.js, 1 neu) — beide zunächst rot, dann grün. 65 -> 71 Tests,
alle grün.

Browser-Verifikation (Downtown, Chunk 2_9, gleiche Kameraposition vorher/nachher):
das zuvor durchgängig zackige/zerstückelte Straßen-/Flächenbild ist jetzt glatt und
durchgängig lesbar. Ego-Perspektive dicht an einer Gebäudewand zeigt mit near=0.5
kein sichtbares Durchsehen. Details, Screenshot-Pfade und offene Punkte (Brücken-
Nahaufnahme nicht separat bestätigt) im Bericht:
.superpowers/sdd/2026-09-12-leonida-vice-slice/schichten-report.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 09:01:36 +02:00
1 parent efe15c4b64
commit 5c8df78246
5 files changed
+143 -3

No files matched your search

+49
View File
@@ -72,6 +72,54 @@ async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) {
return material;
}
// -------------------------------------------------------------------------------------------
// Tiefenpuffer-Versatz gegen Flackern zwischen Boden, Stadtfläche, Park, Strand und Straße
// -------------------------------------------------------------------------------------------
// Befund (Diagnose "Flackernder Boden"): geometry.js legt Boden bei y=0, Strand/Park/
// Stadtfläche bei SURFACE_OFFSET=0.02 und Straßen bei ROAD_OFFSET=0.03 an — 1-2 cm Abstand.
// Bei 150-300 m Kameraentfernung (Downtown von oben, wie im gemeldeten Screenshot) liegt das
// unter der Auflösung eines 24-Bit-Tiefenpuffers (~1,3 cm bei 150 m, ~5 cm bei 300 m, gemessen
// mit dem alten Nahwert 0.1 — s. main.js); Strand, Park und Stadtfläche liegen zusätzlich exakt
// aufeinander (identischer y-Wert) und fighten deshalb IMMER, unabhängig von der Entfernung.
//
// polygonOffset verschiebt den Tiefenwert einer Fläche im Tiefenpuffer selbst, nicht in der
// Geometrie — die Rangfolge gilt deshalb auf jede Entfernung, nicht nur dort, wo der
// geometrische 1-2cm-Abstand noch auflösbar wäre. Reihenfolge vom Boden zur Straße, von der
// allgemeinsten zur spezifischsten Fläche: eine Stadtfläche deckt die größte Fläche ab und
// darf am ehesten unbemerkt unter einer Nachbarfläche verschwinden, eine Straße ist die
// kleinteiligste und eindeutig erwartete Linie und muss immer sichtbar obenauf liegen. Strand,
// Park und Stadtfläche bekommen dabei je eine eigene Stufe, statt sich (wie den geometrischen
// SURFACE_OFFSET) eine zu teilen — sonst fighten sie weiterhin gegeneinander, nur auf einer
// anderen Höhe.
//
// Jede Stufe: derselbe Betrag für Faktor und Units, streng steigend Richtung Kamera (negativ,
// laut WebGL-Spezifikation zu glPolygonOffset: offset = factor*Steigung + units*r — r ist per
// Spezifikation bereits "die kleinste von der Hardware garantiert auflösbare Tiefendifferenz",
// ein Versatz von 1 Unit trennt zwei Flächen also auf jede Entfernung, ohne von einem
// geometrischen Abstand abzuhängen, der bei 300 m längst unter der Auflösung verschwunden
// wäre). factor deckt dabei schräge Sichtwinkel ab (von der Seite, hohe Tiefensteigung über die
// Fläche), units den senkrechten Blick von oben (Steigung ~ 0) — der Spieler sieht im Spiel
// beides, je nach Kamerawinkel (Vogelperspektive in fly mode wie im Befund, aber auch normale
// Bodenperspektive).
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "roads"];
export const POLYGON_OFFSETS = Object.fromEntries(
POLYGON_OFFSET_ORDER.map((key, i) => [
key,
{ polygonOffset: true, polygonOffsetFactor: -i, polygonOffsetUnits: -i },
]),
);
/** Trägt POLYGON_OFFSETS in die passenden Materialien ein. Nur die fünf benannten Schichten —
* Brücken (eigene, geometrische Lösung, s. geometry.js BRIDGE-Kommentar), Gebäude, Wasser und
* Meeresboden sind eigene Materialobjekte ohne Coplanar-Risiko und bleiben unberührt. */
export function applyPolygonOffsets(materials) {
for (const [key, offset] of Object.entries(POLYGON_OFFSETS)) {
if (materials[key]) Object.assign(materials[key], offset);
}
return materials;
}
export async function createMaterials(maxAnisotropy) {
const loader = new TextureLoader();
const tints = { roads: ROAD_TINT, bridges: ROAD_TINT };
@@ -81,6 +129,7 @@ export async function createMaterials(maxAnisotropy) {
);
const materials = Object.fromEntries(entries);
materials.buildings.vertexColors = true; // pastellige Tönung je Gebäude
applyPolygonOffsets(materials);
const normal = new DataTexture(waterNormalData(128), 128, 128, RGBAFormat);
normal.wrapS = normal.wrapT = RepeatWrapping;