Files
gta6/tests/materials.test.js
knsoandClaude Opus 5 4e201d2b79 fix(world): Bordstein bekommt einen eigenen Tiefenversatz
Er war die einzige Straßengeometrie neben den Belägen ohne Versatz und
konnte beim flachen Blick gegen den Gehwegbelag verlieren. Jetzt trägt er
denselben kleinen Faktor wie die Beläge und steht eine Einheitenstufe über
dem obersten von ihnen.

Gemessen im Browser (Schicht aus- und wieder eingeblendet, Bild Pixel für
Pixel verglichen): Der Bordstein zeichnet bis 504 m, dieselbe Reichweite
wie Fahrbahn, Gehweg und Markierung. Das erreichte er auch ohne den
Versatz — er ist Absicherung gegen den Tiefenstreit, keine Reichweite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:43:33 +02:00

217 lines
12 KiB
JavaScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { applyPolygonOffsets, loadSet, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, SURFACE_COLORS,
TEXTURES, waterNormalData } from "../src/world/materials.js";
import { buildChunkGeometry } from "../src/world/geometry.js";
describe("materials", () => {
it("deckt genau die Geometrie-Teile ab", () => {
const chunk = { chunk: [0, 0], origin: [0, 0], land: [], buildings: [], palms: [] };
expect(Object.keys(buildChunkGeometry(chunk)).sort()).toEqual([...MATERIAL_KEYS].sort());
});
it("nennt für jedes Material genau eine Textur oder eine Farbe", () => {
for (const key of MATERIAL_KEYS) expect(Boolean(TEXTURES[key]) !== Boolean(SURFACE_COLORS[key]), key).toBe(true);
});
it("gibt Feldern das Gras des Parks, dem Parkplatz den Asphalt der Straßen und Wegen Betonplatten", () => {
expect(TEXTURES.field).toBe(TEXTURES.park);
expect(TEXTURES.parking).toBe(TEXTURES.carriageway);
expect(TEXTURES.paths).toBe("concrete_pavement");
expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "markings", "pool"]);
});
it("deckt Strand und Park mit eigenen Materialien ab", () => {
expect(MATERIAL_KEYS).toContain("sand");
expect(MATERIAL_KEYS).toContain("park");
});
it("gibt Brücken dieselbe Textur wie Straßen, damit an der Übergangsnaht kein Farbsprung entsteht (Ruling 56)", () => {
// Brücken sind in den Daten dasselbe Straßenstück, nur über Wasser.
expect(TEXTURES.bridges).toBe(TEXTURES.carriageway);
});
it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => {
const data = waterNormalData(32);
expect(data.length).toBe(32 * 32 * 4);
// Normalen zeigen im Mittel nach oben: Blaukanal nahe 255.
const blue = [...data].filter((_, i) => i % 4 === 2);
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 < Feld < Platz < Pool < Parkplatz < Restfläche < Gehweg < Weg", () => {
expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool",
"parking", "lots", "sidewalk", "paths"]);
});
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);
});
// Im Browser gemessen (Bordstein-Befund): Der Faktor wird mit der Tiefensteigung multipliziert,
// und die geht beim streifenden Blick über eine Bodenfläche gegen unendlich. Mit einem Faktor je
// Rangstufe zog der Gehwegbelag (-9) den 18 cm hohen Bordstein daneben aus 18 m vollständig zu;
// von oben und aus der Nähe war er da. Deshalb trägt die Rangfolge jetzt allein die Units.
it("trägt die Rangfolge in den Units, nicht im Faktor", () => {
const units = POLYGON_OFFSET_ORDER.map((key) => POLYGON_OFFSETS[key].polygonOffsetUnits);
for (let i = 1; i < units.length; i++) {
// Zunehmend negativ = zunehmend Richtung Kamera gezogen, s. Kommentar in materials.js.
expect(units[i], POLYGON_OFFSET_ORDER[i]).toBeLessThan(units[i - 1]);
}
});
// Zweiter Teil des Bordstein-Befunds: Mit Faktor 0 blieb er zwar sichtbar, aber nur etwa 40 m
// weit - danach zog ihn der Gehwegbelag beim flachen Blick doch zu. Der Bordstein grenzt an die
// Beläge und braucht deshalb denselben Faktor; über die Units steht er eine Stufe über dem
// obersten Belag, damit auch seine 20 cm breite Oberkante auf dem Gehweg sichtbar bleibt.
it("nimmt den Bordstein in den Versatz auf, eine Stufe über dem obersten Belag", () => {
const oberster = POLYGON_OFFSET_ORDER[POLYGON_OFFSET_ORDER.length - 1];
expect(POLYGON_OFFSETS.curb.polygonOffsetFactor).toBe(POLYGON_OFFSETS[oberster].polygonOffsetFactor);
expect(POLYGON_OFFSETS.curb.polygonOffsetUnits)
.toBeLessThan(POLYGON_OFFSETS[oberster].polygonOffsetUnits);
expect(POLYGON_OFFSET_ORDER).not.toContain("curb");
});
it("gibt allen Belägen denselben kleinen Faktor, nur die Bodenplatte bleibt bei null", () => {
const [boden, ...belaege] = POLYGON_OFFSET_ORDER;
expect(POLYGON_OFFSETS[boden].polygonOffsetFactor).toBe(0);
const faktoren = new Set(belaege.map((key) => POLYGON_OFFSETS[key].polygonOffsetFactor));
expect([...faktoren]).toEqual([-1]);
// Die Markierung liegt außerhalb der Rangfolge, folgt aber derselben Regel.
expect(POLYGON_OFFSETS.markings.polygonOffsetFactor).toBe(-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", "field", "court", "pool", "parking", "lots", "sidewalk",
"paths", "markings", "carriageway", "curb", "bridges", "buildings", "water", "seafloor"]
.map((k) => [k, {}]),
);
applyPolygonOffsets(fake);
for (const key of POLYGON_OFFSET_ORDER) {
expect(fake[key]).toMatchObject(POLYGON_OFFSETS[key]);
}
// markings steht nicht in POLYGON_OFFSET_ORDER (eigener Versatz außerhalb der Rangfolge,
// s. Kommentar dort), bekommt seinen Versatz aber trotzdem über den zweiten Eintrag im
// POLYGON_OFFSETS-Objekt — einziger Beleg, dass der bis in ein Materialobjekt durchreicht.
expect(fake.markings).toEqual(POLYGON_OFFSETS.markings);
expect(fake.curb).toEqual(POLYGON_OFFSETS.curb);
for (const key of ["carriageway", "bridges", "buildings", "water", "seafloor"]) {
expect(fake[key]).toEqual({});
}
});
});
describe("Schichten aus Teil 2", () => {
it("hat für jede neue Schicht ein Material", () => {
for (const key of ["carriageway", "curb", "sidewalk", "markings", "lots"]) {
expect(MATERIAL_KEYS).toContain(key);
}
expect(MATERIAL_KEYS).not.toContain("roads");
});
it("nimmt Bordstein und Gehweg von Poly Haven, nicht mehr aus dem Kit", () => {
// Korrektur: sidewalk_Diffuse.jpg aus dem Kit ist ein Platzhalterbild (roter Schriftzug),
// keine Betontextur (im Browser geprüft) - der Gehweg benutzt deshalb denselben Belag wie
// die Parkwege, nicht den Kit-Slug.
expect(TEXTURES.curb).toBe("brushed_concrete_04");
expect(TEXTURES.sidewalk).toBe("concrete_pavement");
expect(TEXTURES.sidewalk).toBe(TEXTURES.paths);
expect(TEXTURES.carriageway).toBe("asphalt_02");
expect(TEXTURES.lots).toBe("asphalt_02");
});
it("gibt der Markierung eine Farbe statt einer Textur", () => {
expect(SURFACE_COLORS.markings).toBeDefined();
expect(TEXTURES.markings).toBeUndefined();
});
it("gibt der Markierung einen eigenen kleinen Versatz statt der obersten Stufe der Beläge", () => {
// Ihr einziger Nachbar ist die Fahrbahn 1 cm darunter, und die hat keinen Versatz. Die oberste
// Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte.
expect(POLYGON_OFFSET_ORDER).not.toContain("markings");
expect(POLYGON_OFFSETS.markings).toEqual({ polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -2 });
// Eine Stufe wie der unterste Belag, nicht die oberste: Ihr Nachbar ist die Fahrbahn ohne Versatz.
expect(POLYGON_OFFSETS.markings.polygonOffsetUnits)
.toBe(POLYGON_OFFSETS[POLYGON_OFFSET_ORDER[1]].polygonOffsetUnits);
// Die Fahrbahn liegt 15 cm tiefer und berührt keinen Belag — sie braucht keinen Versatz.
// Der Bordstein dagegen schon: Er grenzt an den Gehweg und verlor ohne Faktor beim flachen
// Blick gegen ihn (s. Test "nimmt den Bordstein in den Versatz auf").
expect(POLYGON_OFFSETS.carriageway).toBeUndefined();
expect(POLYGON_OFFSETS.curb).toBeDefined();
});
});
// Befund der Gesamtprüfung: loadSet() lud Diffuse-, Normal- und ARM-Karte in einem einzigen
// Promise.all und fiel auf den einfarbigen Ersatz zurück, sobald eine davon fehlte. Für den Gehweg
// liefert modular_sidewalk_curb_kit nur `sidewalk_Diffuse.jpg` (der Entwurf sagt das selbst,
// Abschnitt "Die beiden Kits") — 0,298 km² rendern deshalb einfarbig, und die committete Kachel
// lag ungenutzt im Repository. Die Tests laufen ohne echte Dateien: loadSet() bekommt seinen Loader
// von außen, hier ein Attrappenloader, der je nach Dateiname auflöst oder abweist.
describe("Texturen laden: Diffuse ist Pflicht, Normal und ARM sind freiwillig", () => {
/** Loader-Attrappe: löst für jede Kachel auf, deren Kürzel in `vorhanden` steht. */
const fakeLoader = (vorhanden) => ({
loadAsync: (url) => {
// Über die Endung vergleichen, nicht über den letzten Unterstrich: "nor_gl" trägt selbst einen.
const teil = vorhanden.find((t) => url.endsWith(`_${t}.jpg`));
return teil ? Promise.resolve({ name: teil }) : Promise.reject(new Error(`404 ${url}`));
},
});
afterEach(() => { vi.restoreAllMocks(); });
it("nimmt eine Diffuse-Karte ohne Normal- und ARM-Karte an, statt einfarbig zu werden", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const material = await loadSet(fakeLoader(["Diffuse"]), "sidewalk", 0xd8d2c4);
expect(material.map).toMatchObject({ name: "Diffuse" });
expect(material.normalMap).toBeNull();
expect(material.aoMap).toBeNull();
expect(material.roughnessMap).toBeNull();
// Ohne ARM-Karte kommt die Rauheit aus dem Material selbst, nicht aus der Textur.
expect(material.roughness).toBe(0.9);
expect(warn).not.toHaveBeenCalled();
});
it("nimmt auch eine fehlende ARM-Karte allein hin, ohne die Normal-Map mitzureißen", async () => {
const material = await loadSet(fakeLoader(["Diffuse", "nor_gl"]), "sidewalk", 0xd8d2c4);
expect(material.normalMap).toMatchObject({ name: "nor_gl" });
expect(material.roughnessMap).toBeNull();
});
it("hängt den vollen Satz weiterhin vollständig ein", async () => {
const material = await loadSet(fakeLoader(["Diffuse", "nor_gl", "arm"]), "asphalt_02", 0x505050,
{ maxAnisotropy: 8 });
expect(material.map).toMatchObject({ name: "Diffuse", anisotropy: 8 });
expect(material.normalMap).toMatchObject({ name: "nor_gl", anisotropy: 8 });
expect(material.aoMap).toMatchObject({ name: "arm" });
expect(material.roughnessMap).toBe(material.aoMap);
});
it("fällt nur dann auf den einfarbigen Ersatz zurück, wenn die Diffuse-Karte fehlt", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const material = await loadSet(fakeLoader(["nor_gl", "arm"]), "fehlt", 0x123456);
expect(material.map).toBeNull();
expect(material.color.getHex()).toBe(0x123456);
expect(warn).toHaveBeenCalledTimes(1);
});
});