From fe330a2bc83733bfa466624f8961a2f10d78f462 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 26 Jul 2026 14:25:28 +0000 Subject: [PATCH] fix(game): licensed characters rendered white because their materials were disposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quaternius characters rendered as flat white figures. It was never a lighting problem — `loadModel` was destroying their materials at load. That function disposed every material in a GLB whose name was not one of our generated slot names, on the assumption that a model's own materials are always replaced by the slot-binding loop above it. True for the props we generate; catastrophically wrong for a licensed model. All seven Quaternius materials (DarkBrown, Grey, Black, Skin, Swat, Swat_Black, Visor) failed the name test, were disposed, and left every mesh with no material at all — which Babylon renders as flat white. It now disposes only materials nothing references. This cost three wrong diagnoses. It presented as over-exposure, so it was "fixed" by scaling albedo to the scene's budget, clearing emissive, and excluding the meshes from the GlowLayer — none of which can help a mesh that has no material to scale. What settled it was instrumenting the load and printing the actual state: `withMaterial: 0` across all twelve meshes, while the GLB itself had seven materials and every primitive referencing one. Worth remembering as a habit: three guesses cost more than one measurement. Also fixes two things that followed from the earlier revert: - Friendlies and enemies were the same model, because the revert pointed both factions at the generated character. The Directorate now uses fighter_swat and Nightcell fighter_worker, which differ by whole silhouette rather than by a colour swatch. - Bots all stood at the world origin. `addPlayer` initialises movement to (0,0,0) and nothing else placed them, so they piled onto the central hard point. Each is now put on a real spawn pad for its team. And body hits bleed instead of throwing sparks: dark red particles, a dimmer red light, and a flash scaled to 0.35 rather than 1.7 — the previous value painted a ~3.7 m white card over anyone shot at close range, which is what "they blow up white when shot" was. Co-Authored-By: Claude Opus 5 (1M context) --- apps/game/src/assets.ts | 23 ++++++++++++++++++----- apps/game/src/opponents.ts | 29 ++++++++++++++++++++--------- apps/game/src/vfx.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/game/src/assets.ts b/apps/game/src/assets.ts index 246d925..5aff2b0 100644 --- a/apps/game/src/assets.ts +++ b/apps/game/src/assets.ts @@ -239,12 +239,25 @@ async function loadModel( mesh.alwaysSelectAsActiveMesh = false; } - // Materials that came in from the GLB are now unreferenced. + // Drop only materials nothing is actually using. + // + // This used to dispose every material whose name was not one of our + // generated slots, on the assumption that a GLB's own materials are always + // replaced by the loop above. That holds for the props we generate and is + // catastrophically wrong for a licensed model: every Quaternius material + // (DarkBrown, Grey, Black, Skin, Swat, Swat_Black, Visor) failed the name + // test, all seven were destroyed, and the meshes were left with no material + // at all — which Babylon renders as flat white. + // + // That cost three wrong diagnoses. It looked like an exposure problem, so it + // was "fixed" by scaling albedo and clearing emissive, none of which can + // help a mesh that has no material to scale. Checking actual usage is both + // correct and impossible to get wrong for a model we did not author. + const inUse = new Set(container.meshes.map((mesh) => mesh.material).filter(Boolean)); for (const material of [...container.materials]) { - if (!materials.has(material.name)) { - container.materials.splice(container.materials.indexOf(material), 1); - material.dispose(); - } + if (inUse.has(material)) continue; + container.materials.splice(container.materials.indexOf(material), 1); + material.dispose(); } return container; diff --git a/apps/game/src/opponents.ts b/apps/game/src/opponents.ts index df830e1..0dcf68b 100644 --- a/apps/game/src/opponents.ts +++ b/apps/game/src/opponents.ts @@ -21,7 +21,6 @@ import { spawnsForTeam, } from "@nightcell7/multiplayer-sim"; import { placeAnimated, type AssetSet } from "./assets"; -import { brightenCharacter } from "./targets"; /** * Pull a licensed character back into the yard's exposure range. @@ -36,7 +35,8 @@ import { brightenCharacter } from "./targets"; function tameForScene(root: TransformNode, scene: Scene): void { const seen = new Map(); - for (const mesh of root.getChildMeshes() as Mesh[]) { + const meshes = root.getChildMeshes() as Mesh[]; + for (const mesh of meshes) { const source = mesh.material; if (!source) continue; let clone = seen.get(source); @@ -152,8 +152,8 @@ export class Opponents { // The generated character renders correctly, so the bots use it until the // licensed ones are diagnosed. Swapping back is a one-line change: // assets.models.get("fighter_swat") / ("fighter_worker") - const enemyModel = assets.models.get("character"); - const friendlyModel = assets.models.get("character"); + const enemyModel = assets.models.get("fighter_swat") ?? assets.models.get("character"); + const friendlyModel = assets.models.get("fighter_worker") ?? assets.models.get("character"); const character = enemyModel; const carbine = assets.models.get("carbine"); if (!character) throw new Error("no character model loaded"); @@ -196,8 +196,20 @@ export class Opponents { // the same decision on the same tick. this.controllers.push(new BotController(id, 1000 + i * 37)); + // Put them on a real spawn pad. `addPlayer` initialises movement to the + // origin and nothing else places them, so every bot stood on top of the + // central hard point in a single pile — which reads as "they all appear + // where I am" the moment the player walks into the middle. + const player = this.sim.players.get(id); + const spawns = spawnsForTeam(ARDAVAN_YARD, entry.team); + const spawn = spawns[i % Math.max(1, spawns.length)]; + if (player && spawn) { + player.movement.position = { ...spawn.position }; + player.movement.yaw = spawn.yaw; + } + const placed = placeAnimated(entry.model ?? character, id, { - position: new Vector3(0, -50, 0), // parked until the sim spawns them + position: new Vector3(0, -50, 0), // moved to the spawn on the first sync rotationY: 0, }); if (!placed) return; @@ -206,7 +218,6 @@ export class Opponents { // not attached on top of it. void carbine; - brightenCharacter(placed.root); tameForScene(placed.root, scene); this.views.set(id, { @@ -312,7 +323,7 @@ export class Opponents { const view = this.views.get(event.victimId); if (view && !view.dead) { view.dead = true; - this.play(view, "death", false); + this.play(view, "Death", false); this.deadUntil.set(event.victimId, performance.now() + RESPAWN_MS); } continue; @@ -362,7 +373,7 @@ export class Opponents { if (!player.alive) { if (!view.dead) { view.dead = true; - this.play(view, "death", false); + this.play(view, "Death", false); } return; } @@ -384,7 +395,7 @@ export class Opponents { const speed = Math.hypot(player.movement.velocity.x, player.movement.velocity.z); // Clip names come from the licensed pack, whose idle holds the weapon up // — exactly right for a fighter, and something my generated rig lacked. - const wanted = speed > RUN_SPEED ? "run" : speed > IDLE_SPEED ? "walk" : "idle"; + const wanted = speed > RUN_SPEED ? "Run" : speed > IDLE_SPEED ? "Walk" : "Idle_Gun"; if (wanted !== view.current) this.play(view, wanted, true); } diff --git a/apps/game/src/vfx.ts b/apps/game/src/vfx.ts index 4180f04..d61011f 100644 --- a/apps/game/src/vfx.ts +++ b/apps/game/src/vfx.ts @@ -392,6 +392,31 @@ export class WeaponEffects { * they connected. */ private spawnImpact(at: Vector3, direction: Vector3, now: number, heavy = false): void { + // Recolour for the surface being hit. Concrete throws bright orange + // sparks; a body does not, and firing ricochet colours at a person was + // both wrong and unreadable — the player could not tell a hit on a wall + // from a hit on a man. + const impactNow = this.impacts[this.nextImpact % this.impacts.length]; + if (impactNow) { + if (heavy) { + impactNow.sparks.color1 = new Color4(0.62, 0.05, 0.05, 1); + impactNow.sparks.color2 = new Color4(0.34, 0.02, 0.02, 1); + impactNow.sparks.colorDead = new Color4(0.14, 0.01, 0.01, 0); + impactNow.dust.color1 = new Color4(0.42, 0.05, 0.05, 0.55); + impactNow.dust.color2 = new Color4(0.24, 0.03, 0.03, 0.38); + impactNow.dust.colorDead = new Color4(0.1, 0.01, 0.01, 0); + impactNow.light.diffuse = new Color3(0.7, 0.12, 0.1); + } else { + impactNow.sparks.color1 = new Color4(1, 0.86, 0.5, 1); + impactNow.sparks.color2 = new Color4(1, 0.52, 0.16, 1); + impactNow.sparks.colorDead = new Color4(0.5, 0.16, 0.04, 0); + impactNow.dust.color1 = new Color4(0.72, 0.7, 0.66, 0.5); + impactNow.dust.color2 = new Color4(0.5, 0.49, 0.47, 0.35); + impactNow.dust.colorDead = new Color4(0.4, 0.39, 0.38, 0); + impactNow.light.diffuse = new Color3(1, 0.7, 0.35); + } + } + const impact = this.impacts[this.nextImpact % this.impacts.length]; this.nextImpact += 1; if (!impact) return; @@ -415,7 +440,7 @@ export class WeaponEffects { impact.dust.start(); impact.light.position.copyFrom(spawn); - impact.light.intensity = (40 + Math.random() * 18) * (heavy ? 1.6 : 1.0); + impact.light.intensity = (40 + Math.random() * 18) * (heavy ? 0.5 : 1.0); // Grow with distance so a hit is as readable across the yard as it is at // point blank, but clamped so a close impact does not fill the screen.