diff --git a/src/main.js b/src/main.js index dad4b1e..62bd894 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ import { playerController } from "three-player-controller"; import { ChunkStreamer } from "./world/streaming.js"; import { createMaterials } from "./world/materials.js"; import { createSky } from "./world/sky.js"; -import { createSunShadows } from "./world/shadows.js"; +import { createSunShadows, prepareLitMaterials } from "./world/shadows.js"; import { PropLayer } from "./world/props.js"; import { PlaceLayer } from "./world/places.js"; import { chunkForWorld, threeToWorld } from "./world/coords.js"; @@ -237,9 +237,28 @@ await props.load(); // endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt // es nur noch eine einmalige Warnung, kein Verhaltenswechsel. const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS }); +// warmUp fürs Vorladen eines Orts (PlaceLayer unten): lädt seine Texturen schon hier auf die GPU und +// übersetzt seine CSM-Shader, statt das im ersten Bild nach dem Einsetzen zu tun — dort kostete genau +// das gemessen rund 130 ms auf einmal (docs/superpowers/specs/2026-09-14-hood-yard-design.md, +// "Fehlerfälle und Risiken"). +async function warmUpPlace(root) { + prepareLitMaterials(root, sun.csm); + const textures = new Set(); + root.traverse((object) => { + if (!object.isMesh) return; + for (const material of Array.isArray(object.material) ? object.material : [object.material]) { + if (!material) continue; + for (const value of Object.values(material)) { + if (value?.isTexture) textures.add(value); + } + } + }); + for (const texture of textures) renderer.initTexture(texture); + await renderer.compileAsync(root, camera, scene); +} // Fest platzierte Modelle aus three-d-stage (src/world/places.js). Eigene Radien, damit ein Ort nur in // der Szene ist, solange die Chunks unter ihm geladen sind. -const places = new PlaceLayer({ scene, player }); +const places = new PlaceLayer({ scene, player, warmUp: warmUpPlace }); player.setSkipCapsuleCollision(true); let settled = false; let warnedSlowSpawn = false; diff --git a/src/world/places.js b/src/world/places.js index f8c310b..570167d 100644 --- a/src/world/places.js +++ b/src/world/places.js @@ -130,8 +130,8 @@ function loadModel(url) { } export class PlaceLayer { - constructor({ scene, player, places = PLACES, load = loadModel }) { - Object.assign(this, { scene, player, places, load }); + constructor({ scene, player, places = PLACES, load = loadModel, warmUp = null }) { + Object.assign(this, { scene, player, places, load, warmUp }); this.models = new Map(); // id -> { root, solid }; bleibt nach dem ersten Laden erhalten this.pending = new Set(); this.failed = new Set(); @@ -161,9 +161,18 @@ export class PlaceLayer { if (this.models.has(id) || this.pending.has(id) || this.failed.has(id)) return; this.pending.add(id); this.load(place.url) - .then((root) => { + .then(async (root) => { const solid = prepareModel(root, place); if (!solid) console.warn(`Ort ${id} hat keine festen Teile und bekommt keine Kollision.`); + // Vorwärmen (Texturupload, Shader-Übersetzung) noch hier im Vorladen: schlägt es fehl, + // wird der Ort trotzdem eingesetzt, nur ohne den Vorteil (siehe main.js, warmUpPlace). + if (this.warmUp) { + try { + await this.warmUp(root); + } catch (err) { + console.warn(`Ort ${id} konnte nicht vorgewärmt werden:`, err); + } + } this.models.set(id, { root, solid }); // Nur einsetzen, wenn der Spieler noch da ist; sonst beim nächsten Herankommen. if (this.wanted.has(id)) this.#show(id); diff --git a/tests/places.test.js b/tests/places.test.js index 55c9138..98d98f1 100644 --- a/tests/places.test.js +++ b/tests/places.test.js @@ -157,10 +157,10 @@ describe("PlaceLayer", () => { // Spieler distance Meter südlich der Ortsmitte (three: +z ist Süden). const south = (distance) => ({ x: 1489, y: 0, z: -1067.5 + distance }); - function makeLayer(load) { + function makeLayer(load, { warmUp } = {}) { const scene = new Scene(); const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() }; - const layer = new PlaceLayer({ scene, player, places: [place], load }); + const layer = new PlaceLayer({ scene, player, places: [place], load, warmUp }); return { scene, player, layer }; } @@ -259,4 +259,37 @@ describe("PlaceLayer", () => { expect(load).toHaveBeenCalledTimes(1); expect(scene.children).toHaveLength(0); }); + + // Bruch: warmUp läuft erst nach dem Einsetzen oder gar nicht — der einmalige Stall durch + // Texturupload und Shader-Übersetzung (~130 ms, gemessen im Spiel) bliebe dann im ersten Bild, + // statt im Vorladen zu verschwinden. + it("wärmt ein Modell vor dem Einsetzen einmalig vor", async () => { + const model = fakeModel(); + const load = vi.fn(async () => model); + const warmUp = vi.fn(async (root) => { + expect(scene.children).not.toContain(root); + }); + const { scene, player, layer } = makeLayer(load, { warmUp }); + + layer.update(south(100)); + await vi.waitFor(() => expect(scene.children).toContain(model)); + + expect(warmUp).toHaveBeenCalledTimes(1); + expect(warmUp).toHaveBeenCalledWith(model); + }); + + // Bruch: eine fehlschlagende Vorwärmung lässt den Ort für immer draußen, obwohl das Modell + // längst geladen und vorbereitet ist. + it("setzt einen Ort trotz fehlgeschlagener Vorwärmung ein und warnt", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const model = fakeModel(); + const load = vi.fn(async () => model); + const warmUp = vi.fn(async () => { throw new Error("Kontextverlust"); }); + const { scene, layer } = makeLayer(load, { warmUp }); + + layer.update(south(100)); + await vi.waitFor(() => expect(scene.children).toContain(model)); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error)); + }); });