fix(world): geteilte Requisiten-Geometrie beim Chunk-Abbau verschonen

Ruling 62: PropLayer-Geometrien (Palme, Laternenteile, Stammkollider) sind
Singletons über alle Chunks; #unload gab bei jedem Nachbarabbau ihre
GPU-Puffer frei. mesh.userData.sharedGeometry markiert sie, #unload
überspringt sie jetzt beim Entsorgen. Ruling 63: geschlossene Ringe
(Laufzeitform) waren in keiner Testvorrichtung gepinnt, obwohl der Code sie
bereits korrekt behandelte. Ruling 64: der Zweig `if (this.props)` bekommt
direkte Testabdeckung statt nur der manuellen Probe aus Fix-Runde 1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 01:58:06 +02:00
1 parent d6a2f6d269
commit 02d0f36580
4 files changed
+152 -25

No files matched your search

+10
View File
@@ -168,10 +168,17 @@ export class PropLayer {
const trunk = new Mesh(this.trunkGeometry);
trunk.visible = false;
trunk.position.set(p.x, 3, p.z);
// sharedGeometry: this.trunkGeometry ist wie this.palmGeometry ein einziges,
// von PropLayer.load() gehaltenes Objekt für alle Chunks (Ruling 62).
trunk.userData.sharedGeometry = true;
colliders.push(trunk);
});
mesh.instanceMatrix.needsUpdate = true;
mesh.userData.excludeFromCollider = true;
// sharedGeometry: this.palmGeometry entsteht einmal in load() und wird von jedem
// Chunk instanziert — #unload() in streaming.js darf es nicht mit entsorgen,
// solange andere Chunks noch darauf zeigen (Ruling 62, Fix-Runde 2).
mesh.userData.sharedGeometry = true;
visual.add(mesh);
}
this.#buildLamps(chunk, visual);
@@ -197,6 +204,9 @@ export class PropLayer {
const mesh = new InstancedMesh(part.geometry, part.material, points.length);
mesh.castShadow = true;
mesh.userData.excludeFromCollider = true;
// sharedGeometry: part.geometry gehört this.lampParts, einmalig aus load()
// (Ruling 62, Fix-Runde 2 — dieselbe Begründung wie bei den Palmen oben).
mesh.userData.sharedGeometry = true;
points.forEach((p, i) => {
dummy.position.set(p.x, 0, p.z);
dummy.rotation.set(0, p.angle, 0);
+7 -1
View File
@@ -115,7 +115,13 @@ export class ChunkStreamer {
// Ruling 51: collider-lose Chunks (offenes Wasser) haben kein handle abzumelden.
if (entry.handle) this.player.removeCollider(entry.handle);
this.scene.remove(entry.group);
entry.group.traverse((o) => o.geometry?.dispose());
// Ruling 62: this.palmGeometry, das Palmenmaterial und die Laternenteile entstehen
// einmal in PropLayer.load() und werden von jedem Chunk benutzt (props.visual landet
// hier in derselben Gruppe). Ohne die Wache würde jeder Chunk-Abbau ihre GPU-Puffer
// freigeben, während Nachbarchunks sie noch anzeigen — kein Absturz (dispose() lässt
// die CPU-Daten stehen, der nächste Zugriff lädt still neu hoch), aber unnötige Arbeit
// bei jedem Abbau. mesh.userData.sharedGeometry markiert das, analog excludeFromCollider.
entry.group.traverse((o) => { if (o.geometry && !o.userData.sharedGeometry) o.geometry.dispose(); });
this.active.delete(k);
}
+23
View File
@@ -52,6 +52,18 @@ describe("pointInPolygon", () => {
it("Punkt im Loch (im Außenring, aber auch im Loch)", () => {
expect(pointInPolygon(10, 10, [outer, hole])).toBe(false);
});
// Fix-Runde 2, Befund B / Ruling 63: bis hierher benutzte jede Vorrichtung offene Ringe;
// die echten Chunk-Daten schließen ihre Ringe ausnahmslos (letzter Punkt = erster, siehe
// Kommentar oben und in props.js) — dieser Fall stand bisher in keinem Test. Ein künftiger
// Umbau, der das Schließen kaputt macht, ginge sonst durch alle Tests.
it("behandelt geschlossene Ringe genauso wie offene (Laufzeitdaten schließen ihre Ringe)", () => {
const closedOuter = [...outer, outer[0]];
const closedHole = [...hole, hole[0]];
expect(pointInPolygon(2, 2, [closedOuter, closedHole])).toBe(true);
expect(pointInPolygon(25, 25, [closedOuter, closedHole])).toBe(false);
expect(pointInPolygon(10, 10, [closedOuter, closedHole])).toBe(false);
});
});
describe("vergeLampPoints", () => {
@@ -81,4 +93,15 @@ describe("vergeLampPoints", () => {
expect(out[0].z).toBeCloseTo(6);
expect(out[1].z).toBeCloseTo(-6);
});
// Fix-Runde 2, Befund B / Ruling 63: siehe Kommentar bei pointInPolygon — derselbe
// ungeprüfte Fall (offene statt geschlossene Ringe) steckte auch hier in jeder Vorrichtung.
it("versetzt eine Laterne auch dann, wenn der Straßenring geschlossen ist", () => {
const points = [{ x: 0, z: 0, angle: 0 }];
const ring = [[-50, -5], [50, -5], [50, 5], [-50, 5]];
const roads = [{ poly: [[...ring, ring[0]]], bridge: false }];
const out = vergeLampPoints(points, roads);
expect(out).toHaveLength(1);
expect(out[0].z).toBeCloseTo(6);
});
});
+112 -24
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { BufferGeometry, Group, Mesh } from "three";
import { ChunkStreamer, wantedChunks } from "../src/world/streaming.js";
describe("wantedChunks", () => {
@@ -28,35 +29,36 @@ describe("wantedChunks", () => {
});
});
describe("ChunkStreamer", () => {
// Realer Chunk ohne jede Geometrie — offenes Wasser. Entspricht wörtlich chunk_4_6.json
// (eines von vier: 4_6, 4_10, 5_6, 5_10 — per public/data/vice-a nachgezählt, Ruling 51).
const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [], roads: [], buildings: [] };
// Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE).
const emptyChunkCenter = { x: 125, z: -875 };
// Gemeinsame Vorrichtung für beide ChunkStreamer-describe-Blöcke unten (mit und ohne PropLayer).
// Realer Chunk ohne jede Geometrie — offenes Wasser. Entspricht wörtlich chunk_4_6.json
// (eines von vier: 4_6, 4_10, 5_6, 5_10 — per public/data/vice-a nachgezählt, Ruling 51).
const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [], roads: [], buildings: [] };
// Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE).
const emptyChunkCenter = { x: 125, z: -875 };
function makeStreamer(chunkData, props) {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData })));
const player = {
addCollider: vi.fn(() => ({ id: 42 })),
removeCollider: vi.fn(),
// ready:false hier ist bewusst: ein echter (nicht-null) Handle wäre damit NIE bereit,
// sodass "isReadyAt sofort true" nur bestehen kann, wenn Ruling 51 tatsächlich greift
// und gar kein Handle erst registriert wird — sonst wäre der Test kein Regressionstest.
collisionWorld: { get: vi.fn(() => ({ ready: false })) },
};
const scene = { add: vi.fn(), remove: vi.fn() };
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props }), player, scene };
}
// #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um
// fetch() -> res.json() -> Geometrieaufbau vollständig abzuwarten (siehe #fetchChunk/#load).
const flush = () => new Promise((r) => setTimeout(r, 0)).then(() => new Promise((r) => setTimeout(r, 0)));
describe("ChunkStreamer", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function makeStreamer(chunkData) {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData })));
const player = {
addCollider: vi.fn(() => ({ id: 42 })),
removeCollider: vi.fn(),
// ready:false hier ist bewusst: ein echter (nicht-null) Handle wäre damit NIE bereit,
// sodass "isReadyAt sofort true" nur bestehen kann, wenn Ruling 51 tatsächlich greift
// und gar kein Handle erst registriert wird — sonst wäre der Test kein Regressionstest.
collisionWorld: { get: vi.fn(() => ({ ready: false })) },
};
const scene = { add: vi.fn(), remove: vi.fn() };
return { streamer: new ChunkStreamer({ scene, player, materials: {} }), player, scene };
}
// #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um
// fetch() -> res.json() -> Geometrieaufbau vollständig abzuwarten (siehe #fetchChunk/#load).
const flush = () => new Promise((r) => setTimeout(r, 0)).then(() => new Promise((r) => setTimeout(r, 0)));
it("registriert für einen Chunk ganz ohne Kollisionsgeometrie keinen Collider (Ruling 51)", async () => {
const { streamer, player } = makeStreamer(emptyChunk);
streamer.update(emptyChunkCenter); // Zentrum -> garantiert der nächstliegende, zuerst geladene Chunk.
@@ -86,4 +88,90 @@ describe("ChunkStreamer", () => {
expect(player.removeCollider).not.toHaveBeenCalled();
expect(streamer.active.has("4_6")).toBe(false);
});
// Fix-Runde 2, Befund A / Ruling 62: this.palmGeometry, das Palmenmaterial und die
// Laternenteile entstehen einmal in PropLayer.load() und werden von jedem Chunk benutzt;
// #unload lief zuvor über die ganze Chunk-Gruppe und entsorgte sie mit, während
// Nachbarchunks sie noch anzeigten. mesh.userData.sharedGeometry markiert, was verschont
// bleibt — chunk-eigene Geometrie (hier: die Landfläche aus buildChunkGeometry) muss
// weiterhin entsorgt werden.
it("entsorgt beim Abmelden chunk-eigene Geometrie, aber keine als geteilt markierte (Ruling 62)", async () => {
const landChunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [], buildings: [],
};
const sharedGeometry = new BufferGeometry();
const sharedDispose = vi.spyOn(sharedGeometry, "dispose");
const sharedMesh = new Mesh(sharedGeometry);
sharedMesh.userData.sharedGeometry = true;
const props = { build: () => { const visual = new Group(); visual.add(sharedMesh); return { visual, colliders: [] }; } };
const { streamer } = makeStreamer(landChunk, props);
streamer.update(emptyChunkCenter);
await flush();
// Chunk-eigene Geometrie (aus buildChunkGeometry, nicht als geteilt markiert) abgreifen
// und ihr dispose bespitzeln, bevor das Abmelden läuft.
const entry = streamer.active.get("4_6");
let ownGeometry;
entry.group.traverse((o) => {
if (o.isMesh && o !== sharedMesh && o.geometry && !ownGeometry) ownGeometry = o.geometry;
});
expect(ownGeometry).toBeTruthy(); // sonst testet dieser Test nichts
const ownDispose = vi.spyOn(ownGeometry, "dispose");
// Weit weg -> jenseits des Entladeradius (600) -> #unload("4_6") im selben update().
streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z });
expect(ownDispose).toHaveBeenCalledTimes(1);
expect(sharedDispose).not.toHaveBeenCalled();
});
});
// Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine
// automatische Abdeckung — nur von Hand belegt (Entfernen -> drei Tests fallen), siehe
// Task-10-Bericht. Ein Platzhalter-PropLayer im Konstruktor deckt das jetzt ab.
describe("ChunkStreamer mit PropLayer", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("ruft props.build() je geladenem Chunk einmal auf und hängt sein visual in die Szene", async () => {
const propsVisual = new Group();
const props = { build: vi.fn(() => ({ visual: propsVisual, colliders: [] })) };
const { streamer, scene } = makeStreamer(emptyChunk, props);
streamer.update(emptyChunkCenter);
await flush();
expect(props.build).toHaveBeenCalledTimes(1);
expect(props.build).toHaveBeenCalledWith(emptyChunk);
expect(scene.add).toHaveBeenCalledTimes(1);
const sceneGroup = scene.add.mock.calls[0][0];
expect(sceneGroup.children).toContain(propsVisual);
});
it("hängt colliders von props.build() in dieselbe BVH-Gruppe wie die Chunk-Kollision", async () => {
const trunk = new Mesh();
const props = { build: vi.fn(() => ({ visual: new Group(), colliders: [trunk] })) };
const { streamer, player } = makeStreamer(emptyChunk, props);
streamer.update(emptyChunkCenter);
await flush();
// emptyChunk hat sonst keine Kollisionsgeometrie (Ruling 51) — der Stamm allein
// macht den Chunk-Collider nicht-leer, addCollider muss also aufgerufen werden.
expect(player.addCollider).toHaveBeenCalledTimes(1);
const source = player.addCollider.mock.calls[0][0].shape.source;
expect(source.children).toContain(trunk);
});
it("läuft ohne props weiterhin fehlerfrei (props ist seit Task 8 optional)", async () => {
const { streamer } = makeStreamer(emptyChunk /* kein props */);
streamer.update(emptyChunkCenter);
await flush();
expect(streamer.active.get("4_6")).toMatchObject({ handle: null });
});
});