feat(world): Orte beim Vorladen vorwärmen
PlaceLayer nimmt jetzt ein optionales warmUp(root) entgegen und ruft es nach prepareModel, aber vor dem Einsetzen auf. main.js nutzt das, um Texturen hochzuladen und CSM-Shader zu übersetzen, solange der Ort noch nicht in der Szene ist — gemessen kostete das sonst rund 130 ms auf einen Schlag im ersten Bild, in dem der Hof erscheint. Schlägt das Vorwärmen fehl, wird nur gewarnt; der Ort wird trotzdem eingesetzt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
522101c026
commit
1c24fbf71e
3 files changed
+68
-7
No files matched your search
+21
-2
@@ -6,7 +6,7 @@ import { playerController } from "three-player-controller";
|
|||||||
import { ChunkStreamer } from "./world/streaming.js";
|
import { ChunkStreamer } from "./world/streaming.js";
|
||||||
import { createMaterials } from "./world/materials.js";
|
import { createMaterials } from "./world/materials.js";
|
||||||
import { createSky } from "./world/sky.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 { PropLayer } from "./world/props.js";
|
||||||
import { PlaceLayer } from "./world/places.js";
|
import { PlaceLayer } from "./world/places.js";
|
||||||
import { chunkForWorld, threeToWorld } from "./world/coords.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
|
// endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt
|
||||||
// es nur noch eine einmalige Warnung, kein Verhaltenswechsel.
|
// es nur noch eine einmalige Warnung, kein Verhaltenswechsel.
|
||||||
const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });
|
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
|
// 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.
|
// 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);
|
player.setSkipCapsuleCollision(true);
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let warnedSlowSpawn = false;
|
let warnedSlowSpawn = false;
|
||||||
|
|||||||
+12
-3
@@ -130,8 +130,8 @@ function loadModel(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PlaceLayer {
|
export class PlaceLayer {
|
||||||
constructor({ scene, player, places = PLACES, load = loadModel }) {
|
constructor({ scene, player, places = PLACES, load = loadModel, warmUp = null }) {
|
||||||
Object.assign(this, { scene, player, places, load });
|
Object.assign(this, { scene, player, places, load, warmUp });
|
||||||
this.models = new Map(); // id -> { root, solid }; bleibt nach dem ersten Laden erhalten
|
this.models = new Map(); // id -> { root, solid }; bleibt nach dem ersten Laden erhalten
|
||||||
this.pending = new Set();
|
this.pending = new Set();
|
||||||
this.failed = 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;
|
if (this.models.has(id) || this.pending.has(id) || this.failed.has(id)) return;
|
||||||
this.pending.add(id);
|
this.pending.add(id);
|
||||||
this.load(place.url)
|
this.load(place.url)
|
||||||
.then((root) => {
|
.then(async (root) => {
|
||||||
const solid = prepareModel(root, place);
|
const solid = prepareModel(root, place);
|
||||||
if (!solid) console.warn(`Ort ${id} hat keine festen Teile und bekommt keine Kollision.`);
|
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 });
|
this.models.set(id, { root, solid });
|
||||||
// Nur einsetzen, wenn der Spieler noch da ist; sonst beim nächsten Herankommen.
|
// Nur einsetzen, wenn der Spieler noch da ist; sonst beim nächsten Herankommen.
|
||||||
if (this.wanted.has(id)) this.#show(id);
|
if (this.wanted.has(id)) this.#show(id);
|
||||||
|
|||||||
+35
-2
@@ -157,10 +157,10 @@ describe("PlaceLayer", () => {
|
|||||||
// Spieler distance Meter südlich der Ortsmitte (three: +z ist Süden).
|
// Spieler distance Meter südlich der Ortsmitte (three: +z ist Süden).
|
||||||
const south = (distance) => ({ x: 1489, y: 0, z: -1067.5 + distance });
|
const south = (distance) => ({ x: 1489, y: 0, z: -1067.5 + distance });
|
||||||
|
|
||||||
function makeLayer(load) {
|
function makeLayer(load, { warmUp } = {}) {
|
||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() };
|
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 };
|
return { scene, player, layer };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,4 +259,37 @@ describe("PlaceLayer", () => {
|
|||||||
expect(load).toHaveBeenCalledTimes(1);
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
expect(scene.children).toHaveLength(0);
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user