Files
gta6/tests/materials.test.js
T
knso 3f4fa7f1ed fix(world): Gehweg lädt seine Diffuse-Textur auch ohne Normal- und ARM-Karte
loadSet() lud Diffuse-, Normal- und ARM-Karte in einem gemeinsamen
Promise.all und fiel schon beim Fehlen einer einzigen Datei auf den
einfarbigen Ersatz zurück. modular_sidewalk_curb_kit liefert für den
Gehweg nur sidewalk_Diffuse.jpg (keine Normal-Map, und die
metallicRoughness-Karte ist laut Entwurf eine 605-Byte-Einfarbfläche,
die bewusst nicht übernommen wird) — Folge: 0,298 km² rendern einfarbig
#d8d2c4, bei jedem Laden "Textur sidewalk fehlt", die committete Kachel
blieb ungenutzt.

Nur die Diffuse-Karte ist jetzt Pflicht; Normal- und ARM-Karte werden
einzeln geladen und fallen bei einem Fehler je für sich auf null zurück,
ohne die anderen beiden mitzureißen. Ohne ARM-Karte bleibt roughness auf
0.9 (derselbe Wert wie der bisherige einfarbige Ersatz).

loadSet() ist jetzt exportiert, damit tests/materials.test.js sie mit
einem Attrappenloader ohne echte Dateien prüfen kann: Diffuse allein,
Diffuse+Normal ohne ARM, der volle Satz, und weiterhin der einfarbige
Ersatz, wenn die Diffuse-Karte selbst fehlt.
2026-09-18 06:16:12 +02:00

184 lines
9.8 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);
});
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", "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);
for (const key of ["carriageway", "curb", "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 aus dem Kit", () => {
expect(TEXTURES.curb).toBe("curb");
expect(TEXTURES.sidewalk).toBe("sidewalk");
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: -1 });
// Fahrbahn und Bordstein liegen 15 cm tiefer und sind echte Geometrie — kein Versatz nötig.
expect(POLYGON_OFFSETS.carriageway).toBeUndefined();
expect(POLYGON_OFFSETS.curb).toBeUndefined();
});
});
// 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);
});
});