From 5c8df7824651ce809b4c8877d9a34f3560c7e42a Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 09:01:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(world):=20Flackernden=20Boden=20behoben=20(?= =?UTF-8?q?Tiefenpuffer-Versatz=20je=20Schicht,=20Br=C3=BCcke=20angehoben)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.js | 10 +++++++- src/world/geometry.js | 10 +++++++- src/world/materials.js | 49 ++++++++++++++++++++++++++++++++++++ tests/geometry.test.js | 22 +++++++++++++++++ tests/materials.test.js | 55 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/main.js b/src/main.js index 8028523..33e6aca 100644 --- a/src/main.js +++ b/src/main.js @@ -22,7 +22,15 @@ renderer.toneMapping = ACESFilmicToneMapping; renderer.shadowMap.enabled = true; container.appendChild(renderer.domElement); -const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 2500); +// Nahwert 0.1 -> 0.5 (Diagnose "Flackernder Boden"): ein 24-Bit-Tiefenpuffer löst bei 150-300 m +// Kameraabstand (Downtown von oben, s. Diagnose-Report) nur 1,3-5 cm auf — in derselben +// Größenordnung wie die 1-2 cm, die Boden/Stadtfläche/Park/Strand/Straße in geometry.js +// auseinanderliegen. Fünffacher Nahwert = fünffache Tiefenauflösung auf jede Entfernung +// (Auflösung skaliert mit dem Nahwert), macht die verbleibenden Abstände wieder auflösbar, +// ohne selbst etwas an der Fern-/Sichtweite (far=2500) zu ändern. 0.5 m liegt deutlich unter +// Kapsel-/Kameraradius und Wandstärken (s. Ersichtsprüfung im Bericht) — Ego-Perspektive an +// einer Wand clippt damit nicht sichtbar. +const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.5, 2500); // Pflichtoption für player.init: der Controller übernimmt die Kamera, braucht aber controls.domElement als Eingabeziel. const controls = new OrbitControls(camera, renderer.domElement); scene.add(new AmbientLight(0xffffff, 1.5)); diff --git a/src/world/geometry.js b/src/world/geometry.js index 35a8d6d..be2a71d 100644 --- a/src/world/geometry.js +++ b/src/world/geometry.js @@ -4,6 +4,14 @@ const GROUND_THICKNESS = 1.0; const SURFACE_OFFSET = 0.02; // Sand, Park und Stadtfläche liegen knapp über dem Boden const ROAD_OFFSET = 0.03; // Straßen wiederum knapp über den Flächen const BRIDGE_THICKNESS = 0.6; +// Brückendeck lag bisher exakt bei y=0, identisch mit der Bodenoberkante (top weiter unten) — +// garantiertes Z-Fighting überall dort, wo eine Brücke an Land endet, unabhängig von Kamera- +// abstand oder Tiefenpuffer-Versatz (der hilft nur gegen knappe, aber von 0 verschiedene +// Abstände). Dieselbe Größenordnung wie SURFACE_OFFSET: groß genug, um auch bei 300 m +// Kameraabstand im Tiefenpuffer aufzulösen (s. materials.js-Kommentar zu POLYGON_OFFSETS), +// klein genug, dass die Brücke weiterhin flach und ohne spürbare Stufe befahrbar bleibt — wie +// die von SURFACE_OFFSET bereits unangetastet gebliebene Kollision auf den Bodenflächen. +const BRIDGE_OFFSET = SURFACE_OFFSET; export const SURFACE_KINDS = ["sand", "park", "urban"]; /** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */ @@ -122,7 +130,7 @@ export function buildChunkGeometry(chunk) { const out = { ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }), roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }), - bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: 0, uvScale: 8 }), + bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: BRIDGE_OFFSET, uvScale: 8 }), buildings: buildBuildings(chunk.buildings), }; for (const kind of SURFACE_KINDS) { diff --git a/src/world/materials.js b/src/world/materials.js index 4a1d72c..470fcb9 100644 --- a/src/world/materials.js +++ b/src/world/materials.js @@ -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; diff --git a/tests/geometry.test.js b/tests/geometry.test.js index 23ac1ea..7516416 100644 --- a/tests/geometry.test.js +++ b/tests/geometry.test.js @@ -63,6 +63,28 @@ describe("geometry", () => { expect(box.max.y).toBeCloseTo(0.03); }); + // Flackernder Boden (Diagnose-Report, Punkt 4): Brückendeck und Boden lagen beide exakt bei + // y=0 — identische Höhe, garantiertes Z-Fighting an jeder Stelle, an der eine Brücke an Land + // endet, unabhängig von Kameraabstand oder Tiefenpuffer-Versatz. Die Brückenoberseite muss + // jetzt oberhalb von y=0 liegen, ohne eine für den Spieler wahrnehmbare Stufe zu erzeugen. + it("hebt die Brückenoberseite über den Boden (kein gemeinsames y=0 mehr)", () => { + const chunk = { + chunk: [0, 0], origin: [0, 0], + land: [PLOT], surfaces: [], + roads: [{ poly: PLOT, bridge: true }], + centerlines: [], palms: [], buildings: [], + }; + const out = buildChunkGeometry(chunk); + const groundTop = new Box3().setFromBufferAttribute(out.ground.getAttribute("position")).max.y; + const bridgeTop = new Box3().setFromBufferAttribute(out.bridges.getAttribute("position")).max.y; + expect(groundTop).toBeCloseTo(0); + expect(bridgeTop).toBeGreaterThan(groundTop); + // Bewusst klein (Größenordnung der bestehenden SURFACE_OFFSET/ROAD_OFFSET-Abstände, + // s. Kommentar in geometry.js): groß genug, um im Tiefenpuffer aufzulösen, klein genug, + // um beim Gehen/Fahren nicht als Stufe aufzufallen — "flach und begehbar wie bisher". + expect(bridgeTop - groundTop).toBeLessThan(0.1); + }); + it("baut alle Teile eines Chunks", () => { const chunk = { chunk: [0, 0], origin: [0, 0], diff --git a/tests/materials.test.js b/tests/materials.test.js index 013210c..4b4e8db 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { MATERIAL_KEYS, TEXTURES, waterNormalData } from "../src/world/materials.js"; +import { applyPolygonOffsets, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, TEXTURES, + waterNormalData } from "../src/world/materials.js"; import { buildChunkGeometry } from "../src/world/geometry.js"; describe("materials", () => { @@ -31,3 +32,55 @@ describe("materials", () => { expect(blue.reduce((a, b) => a + b, 0) / blue.length).toBeGreaterThan(200); }); }); + +// Flackernder Boden (Diagnose-Report): 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, der bei +// 150-300 m Kameraentfernung unter der Auflösung eines 24-Bit-Tiefenpuffers liegt. Rein und +// ganz ohne Browser testbar (wie im Auftrag gefordert): POLYGON_OFFSETS ist eine reine +// Datenstruktur, applyPolygonOffsets() eine reine Funktion auf Platzhalterobjekten — beide +// brauchen weder createMaterials() noch echtes Texturladen. +describe("Tiefenpuffer-Versatz je Schicht (gegen Flackern zwischen Boden/Stadt/Park/Strand/Straße)", () => { + it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Straße", () => { + expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "roads"]); + }); + + it("aktiviert polygonOffset für jede der fünf Schichten", () => { + for (const key of POLYGON_OFFSET_ORDER) { + expect(POLYGON_OFFSETS[key]?.polygonOffset).toBe(true); + } + }); + + it("kein Paar unter den fünf Schichten teilt sich Faktor und Units — Strand, Park und " + + "Stadtfläche bekommen je eine eigene Stufe statt sich eine zu teilen", () => { + const sigs = POLYGON_OFFSET_ORDER.map((key) => { + const o = POLYGON_OFFSETS[key]; + return `${o.polygonOffsetFactor}/${o.polygonOffsetUnits}`; + }); + expect(new Set(sigs).size).toBe(sigs.length); + }); + + it("Betrag von Faktor und Units steigt streng von Boden zu Straße (Richtung Kamera)", () => { + const factors = POLYGON_OFFSET_ORDER.map((key) => POLYGON_OFFSETS[key].polygonOffsetFactor); + const units = POLYGON_OFFSET_ORDER.map((key) => POLYGON_OFFSETS[key].polygonOffsetUnits); + for (let i = 1; i < factors.length; i++) { + // Zunehmend negativ = zunehmend Richtung Kamera gezogen, s. Kommentar in materials.js. + expect(factors[i]).toBeLessThan(factors[i - 1]); + expect(units[i]).toBeLessThan(units[i - 1]); + } + }); + + it("trägt den Versatz nur in Boden/Stadtfläche/Park/Strand/Straße ein — Brücken, Gebäude, " + + "Wasser und Meeresboden bleiben unberührt (eigene Materialobjekte, s. Auftrag)", () => { + const fake = Object.fromEntries( + ["ground", "urban", "park", "sand", "roads", "bridges", "buildings", "water", "seafloor"] + .map((k) => [k, {}]), + ); + applyPolygonOffsets(fake); + for (const key of POLYGON_OFFSET_ORDER) { + expect(fake[key]).toMatchObject(POLYGON_OFFSETS[key]); + } + for (const key of ["bridges", "buildings", "water", "seafloor"]) { + expect(fake[key]).toEqual({}); + } + }); +});