leafy_grass ist roh sandfarben (Mittel der Diffuse-Kachel R 151, G 131, B 89). Seit der Park seine echte Fläche als eigene Schicht trägt (102 790 statt 16 798 m² im Parkrechteck), las er sich aus der Vogelperspektive wie eine Brache, während ihn die Minimap grün zeichnet. PARK_TINT 0xb4dc94 tönt Rot und Blau stärker ab als Grün; die Felder behalten ihren helleren FIELD_TINT und heben sich weiter ab. Im Browser abgestimmt (Task 12, Schritt 7) und vom Nutzer so gewählt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
10 KiB
JavaScript
177 lines
10 KiB
JavaScript
import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader,
|
|
Vector2 } from "three";
|
|
|
|
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking", "paths",
|
|
"roads", "bridges", "buildings"];
|
|
export const TEXTURES = {
|
|
ground: "grass_path_2", // unbebautes Land
|
|
sand: "sand_01", // Strand
|
|
park: "leafy_grass", // Grünflächen
|
|
urban: "concrete_pavement_03", // versiegelte Stadtfläche
|
|
// Flamingo Park (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): Felder im Gras des Parks, der
|
|
// Parkplatz im Asphalt der Straßen. Beide lädt createMaterials nicht neu, sondern klont park und roads.
|
|
field: "leafy_grass",
|
|
parking: "asphalt_02",
|
|
paths: "concrete_pavement", // helle Betonplatten, auch für die Gehwege aus Teil 2 gedacht
|
|
roads: "asphalt_02",
|
|
// Ruling 56: eine Brücke ist in den Daten dasselbe Straßenstück, nur über Wasser
|
|
// (geometry.js trennt sie einzig über roads.filter(r => r.bridge)) — dieselbe
|
|
// Textur wie roads, sonst zeigt jeder Straße-Brücke-Übergang eine Farbnaht.
|
|
bridges: "asphalt_02",
|
|
buildings: "white_stucco",
|
|
};
|
|
// Plätze und Pool sind einfarbig: Sandplätze, Hartplätze und Poolwasser hat Poly Haven nicht.
|
|
export const SURFACE_COLORS = { court: 0x6f8c79, pool: 0x3f9fd8 };
|
|
const FALLBACK_COLORS = {
|
|
ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8, paths: 0xd8d2c4,
|
|
roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc,
|
|
};
|
|
// Ruling 55: asphalt_02 misst roh heller (Ø 89.5) als concrete_pavement_03 (Ø 65.9) —
|
|
// Stadtflächen wirkten dadurch wie dunkle Flecken auf der Straße, das Straßennetz
|
|
// verschwand optisch. material.color kann eine Textur nur abdunkeln, nie aufhellen,
|
|
// also wird hier die hellere Straße (und die jetzt identisch texturierte Brücke)
|
|
// abgetönt statt die Stadtfläche zu verändern.
|
|
const ROAD_TINT = 0x999999;
|
|
// leafy_grass ist roh sandfarben (Mittel der Diffuse-Kachel: R 151, G 131, B 89): aus der
|
|
// Vogelperspektive las sich der Park wie eine Brache, während ihn die Minimap (overview.py) grün
|
|
// zeichnet. material.color kann nur abdunkeln, also Rot und Blau stärker als Grün. Im Browser
|
|
// abgestimmt (Task 12, Schritt 7).
|
|
const PARK_TINT = 0xb4dc94;
|
|
// Felder heben sich vom Rasen ab: dieselbe Textur, aber heller getönt als PARK_TINT — gemähter Platz
|
|
// im Rasen. material.color kann nur abdunkeln, also Rot und Blau stärker als Grün.
|
|
const FIELD_TINT = 0xc8f0b0;
|
|
// Klone statt zweiter Ladevorgang: dieselben Texturen, eigene Tönung und eigener polygonOffset.
|
|
const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "roads" } };
|
|
|
|
/** Kachelbare Wellen-Normalmap, weil Poly Haven keine Wassertextur hat. */
|
|
export function waterNormalData(size = 128) {
|
|
const data = new Uint8Array(size * size * 4);
|
|
for (let y = 0; y < size; y++) {
|
|
for (let x = 0; x < size; x++) {
|
|
const i = (y * size + x) * 4;
|
|
const u = (x / size) * Math.PI * 2;
|
|
const v = (y / size) * Math.PI * 2;
|
|
const dx = Math.sin(u * 3) * 0.5 + Math.sin(v * 2 + 1.3) * 0.3;
|
|
const dz = Math.cos(v * 3) * 0.5 + Math.cos(u * 2 + 0.7) * 0.3;
|
|
data[i] = 128 + dx * 40;
|
|
data[i + 1] = 128 + dz * 40;
|
|
data[i + 2] = 245;
|
|
data[i + 3] = 255;
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) {
|
|
let map, normalMap, arm;
|
|
try {
|
|
[map, normalMap, arm] = await Promise.all(
|
|
["Diffuse", "nor_gl", "arm"].map((m) => loader.loadAsync(`./textures/${slug}_${m}.jpg`)),
|
|
);
|
|
} catch (err) {
|
|
console.warn(`Textur ${slug} fehlt, einfarbiger Ersatz:`, err);
|
|
return new MeshStandardMaterial({ color: fallback, roughness: 0.9 });
|
|
}
|
|
for (const t of [map, normalMap, arm]) {
|
|
t.wrapS = t.wrapT = RepeatWrapping;
|
|
// Ruling 58: Straßen laufen flach in die Tiefe — dort flimmert Standard-
|
|
// filterung zuerst. createMaterials() kennt den Renderer nicht selbst, daher
|
|
// als optionaler Parameter von außen; ohne ihn bleibt three beim Standard (1).
|
|
if (maxAnisotropy) t.anisotropy = maxAnisotropy;
|
|
}
|
|
map.colorSpace = SRGBColorSpace;
|
|
// arm packt Ambient Occlusion und Rauheit in R und G. metalnessMap fehlt bewusst
|
|
// (Ruling 57): metalness steht fest auf 0, three multipliziert texelMetalness nur
|
|
// noch dazu (metalnessmap_fragment.glsl.js) und könnte das Ergebnis nie von 0
|
|
// wegbewegen — reine Kosten (Texturbindung, Shader-Permutation) ohne Wirkung.
|
|
const material = new MeshStandardMaterial({ map, normalMap, aoMap: arm, roughnessMap: arm, metalness: 0 });
|
|
// Ruling 55/56: nur der geladene Erfolgsfall bekommt die Tönung — der einfarbige
|
|
// Ersatz oben hat schon seinen eigenen, passenden Grundton.
|
|
if (tint) material.color.set(tint);
|
|
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).
|
|
// Flamingo Park: Felder, Plätze, Pool und Parkplatz liegen über dem Rasen, die Wege über allen Flächen
|
|
// und unter den Straßen, die sie an der Parkkante berühren.
|
|
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "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 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, park: PARK_TINT };
|
|
const textured = MATERIAL_KEYS.filter((key) => !CLONES[key] && !SURFACE_COLORS[key]);
|
|
const entries = await Promise.all(
|
|
textured.map(async (key) =>
|
|
[key, await loadSet(loader, TEXTURES[key], FALLBACK_COLORS[key], { tint: tints[key], maxAnisotropy })]),
|
|
);
|
|
const materials = Object.fromEntries(entries);
|
|
for (const [key, { from, tint }] of Object.entries(CLONES)) {
|
|
materials[key] = materials[from].clone();
|
|
if (tint) materials[key].color.set(tint);
|
|
}
|
|
for (const [key, color] of Object.entries(SURFACE_COLORS)) {
|
|
materials[key] = new MeshStandardMaterial({ color, roughness: key === "pool" ? 0.25 : 0.9, metalness: 0 });
|
|
}
|
|
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;
|
|
normal.repeat.set(60, 60);
|
|
normal.needsUpdate = true;
|
|
// Seit das HDRI als scene.environment spiegelt (src/world/sky.js), zeigte die 128er-Kachel mit
|
|
// roughness 0.15 und voller Normalstärke von oben ein Fliesenmuster aus Wellenringen. Rauer und mit
|
|
// flacheren Normalen wirkt sie als ruhige See, von oben wie vom Strand.
|
|
materials.water = new MeshStandardMaterial({
|
|
color: 0x2c67a4, normalMap: normal, normalScale: new Vector2(0.35, 0.35), roughness: 0.3, metalness: 0.2,
|
|
transparent: true, opacity: 0.92,
|
|
});
|
|
materials.seafloor = new MeshStandardMaterial({ color: 0x1d4468, roughness: 1 });
|
|
return materials;
|
|
}
|