openvibers/breakroomsimulator Commits

Performance pass + graphics settings + inventory/crafting cross links

OP OpenVibers committed 30 Jul 2026 · cbb5d7f52c · 6 files, +139 −24

Perf (defaults got faster, and knobs for the rest):
- Shadow map now refreshes every OTHER frame (renderer.shadowMap.autoUpdate
  off) and defaults to 1024px — the shadow pass was re-rendering the whole
  scene every frame at 2048.
- Stable light count: headlights are a fixed pool of 3 spotlights assigned
  to driven cars instead of lazily-created per car — creating a light mid-
  game recompiled EVERY shader (the enter-car hitch).
- Static-matrix freeze: all world meshes that never move get
  matrixAutoUpdate=false (movers — cars, gates, knockables, fire, pong
  balls, C4 discs, chess — are whitelisted), cutting per-frame matrix
  recomposition for thousands of objects.
- Physics catch-up substeps 3 -> 2 (slow frames no longer triple the
  physics cost — the death-spiral pattern).

Graphics settings (in the Locker panel):
- Shadows Off/Low(1024)/High(2048), render scale 50-100%, draw distance
  Near(170m)/Far(300m) with matched fog, extra-lights toggle (interior
  fluorescents + street lamps + fire = 14 point lights), FPS counter with
  draw-call readout. Measured on the software renderer: 38.5 -> 25 ms/frame
  going from defaults to everything-low.

UI: inventory has a '🔨 Crafting →' button and the crafting menu has
'🎒 Inventory →', swapping panels in place.

Co-Authored-By: Claude Fable 5 <[email protected]>

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 6430921..6f6544e 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -169,3 +169,8 @@ body.touch #hp-wrap { bottom: calc(174px + env(safe-area-inset-bottom)); }
.craft-row .cn small { display: block; color: #8fa0b3; }
.craft-row button { background: rgba(55,224,111,.15); border: 1px solid #37e06f; color: #b8f5cf; border-radius: 8px; padding: 6px 14px; cursor: pointer; font-family: inherit; }
.craft-row button:disabled { background: rgba(255,255,255,.04); border-color: rgba(255,255,255,.15); color: #5d6b7a; cursor: default; }
++
++/* ---------- graphics/perf UI ---------- */
++#fps-meter { position: absolute; top: 12px; left: 12px; z-index: 25; font: 700 13px ui-monospace, monospace; color: #37e06f; background: rgba(10,14,20,.55); padding: 4px 8px; border-radius: 8px; pointer-events: none; }
++.xlink { background: rgba(53,224,255,.12); border: 1px solid rgba(53,224,255,.5); color: #9fe8ff; border-radius: 8px; padding: 4px 10px; cursor: pointer; font-family: inherit; font-size: .8rem; margin-left: 10px; }
++.xlink:hover { background: rgba(53,224,255,.22); }
ddiff --git a/public/js/lighting.js b/public/js/lighting.js
iindex a51bb70..233f9e9 100644
---- a/public/js/lighting.js
++++ b/public/js/lighting.js
@@@ -33,7 +33,10 @@ export function initLighting(scene, renderer, shadows = true) {
const sun = new THREE.DirectionalLight(0xffeed8, 1.35);
const moon = new THREE.DirectionalLight(0x8fa8d8, 0);
sun.castShadow = true;
-- sun.shadow.mapSize.set(2048, 2048);
++ // shadow pass = the whole scene rendered again; refresh it every OTHER
++ // frame — the sun moves slowly enough that nobody can tell
++ renderer.shadowMap.autoUpdate = false;
++ sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -48; sun.shadow.camera.right = 48;
sun.shadow.camera.top = 48; sun.shadow.camera.bottom = -48;
sun.shadow.camera.near = 1; sun.shadow.camera.far = 240;
@@@ -43,6 +46,7 @@ export function initLighting(scene, renderer, shadows = true) {
// interior fluorescents — the lights stay on all night (Bug44: the facility
// went pitch black after dark). World coords: cafeteria, security, lockers,
// service hallway.
++ const interiorLights = [];
for (const [x, y, z, i2, dist] of [
[67, 4.6, 55, .75, 30], [67, 4.6, 83, .75, 30], [83, 4.6, 55, .75, 30], [83, 4.6, 83, .75, 30],
[77, 4.4, -5, .65, 26], [75, 4.2, 24, .65, 26],
@@@ -51,6 +55,7 @@ export function initLighting(scene, renderer, shadows = true) {
const pl = new THREE.PointLight(0xf6f4ea, i2, dist, 1.2);
pl.position.set(x, y, z);
scene.add(pl);
++ interiorLights.push(pl);
}
enableShadows(scene); // everything built so far
@@@ -92,7 +97,10 @@ export function initLighting(scene, renderer, shadows = true) {
];
let indoorK = 0;
++ let frameN = 0;
function update(dt, px, pz) {
++ frameN++;
++ if (renderer.shadowMap.enabled && frameN % 2 === 0) renderer.shadowMap.needsUpdate = true;
const inside = INDOORS.some(([x0, x1, z0, z1]) => px >= x0 && px <= x1 && pz >= z0 && pz <= z1) ? 1 : 0;
indoorK += (inside - indoorK) * Math.min(1, (dt || .016) * 4);
const t = timeOfDay();
@@@ -133,12 +141,27 @@ export function initLighting(scene, renderer, shadows = true) {
return t;
}
-- function setShadows(on) {
-- renderer.shadowMap.enabled = on;
++ function refreshMaterials() {
scene.traverse(o => {
if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => { m.needsUpdate = true; });
});
}
++ function setShadowQuality(q) { // 'off' | 'low' | 'high'
++ renderer.shadowMap.enabled = q !== 'off';
++ const size = q === 'high' ? 2048 : 1024;
++ if (sun.shadow.mapSize.x !== size) {
++ sun.shadow.mapSize.set(size, size);
++ if (sun.shadow.map) { sun.shadow.map.dispose(); sun.shadow.map = null; }
++ }
++ renderer.shadowMap.needsUpdate = true;
++ refreshMaterials();
++ }
++ function setExtraLights(on) { // interior fluorescents + street lamps + fire
++ for (const pl of interiorLights) pl.visible = on;
++ for (const nl of W.nightLights || []) nl.light.visible = on;
++ for (const fl of W.extraLights || []) fl.visible = on;
++ refreshMaterials();
++ }
-- return { update, setShadows, timeOfDay };
++ return { update, setShadowQuality, setExtraLights, timeOfDay };
}
ddiff --git a/public/js/main.js b/public/js/main.js
iindex 274eb0f..3ed97c3 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -94,6 +94,28 @@ function start(token, user) {
JSON.parse(localStorage.getItem('brs-cfg') || '{}').shadows !== false);
let editor = null;
++ // ---- static-matrix freeze: the world is thousands of meshes that never
++ // move; recomposing their matrices every frame is pure CPU waste. Freeze
++ // everything except the known movers. ----
++ {
++ const dyn = new Set();
++ const markDyn = (o) => o && o.traverse(x => dyn.add(x));
++ W.cars?.forEach(c => markDyn(c.group));
++ W.gates?.forEach(g => { markDyn(g.pl); markDyn(g.pr); });
++ W.knockables?.forEach(k => markDyn(k.obj));
++ W.dynamic.fireGroups?.forEach(markDyn);
++ for (const id of ['a', 'b']) {
++ markDyn(W.anchors[`pong-${id}`]?.ball);
++ W.anchors[`c4-${id}`]?.discs?.forEach(markDyn);
++ }
++ markDyn(W.anchors.chess?.group);
++ scene.traverse(o => {
++ if (dyn.has(o) || o.isLight || o.isCamera) return;
++ o.matrixAutoUpdate = false;
++ o.updateMatrix();
++ });
++ }
++
// ---------- drivable cars (rigid-body vehicles — they roll and flip) ----------
const carState = { driving: null }; // driving = W.cars entry
let carSendT = 0;
@@@ -171,8 +193,9 @@ function start(token, user) {
let fp = false; // first-person mode
// ---------- persistent game settings ----------
const cfg = Object.assign(
-- { wheel: 'zoom', sens: 1.0, fov: 70, invertY: false, shadows: true },
++ { wheel: 'zoom', sens: 1.0, fov: 70, invertY: false, shadows: true, shadowQ: null, rscale: 1, drawDist: 'far', xlights: true, fps: false },
JSON.parse(localStorage.getItem('brs-cfg') || '{}'));
++ cfg.shadowQ ||= cfg.shadows === false ? 'off' : 'low'; // migrate the old checkbox
const saveCfg = () => localStorage.setItem('brs-cfg', JSON.stringify(cfg));
let myAvatar = makeAvatar(me.name, me.vest, me.guest, me.ap);
enableShadows(myAvatar.group);
@@@ -222,6 +245,13 @@ function start(token, user) {
torch.position.set(.25, -.2, .1);
torch.target.position.set(0, -.4, -12);
camera.add(torch, torch.target);
++ // headlight pool (created up-front — stable light count, no shader hitches)
++ const hlPool = [];
++ for (let i = 0; i < 3; i++) {
++ const sp = new THREE.SpotLight(0xfff2cf, 0, 36, .52, .5, 1.1);
++ scene.add(sp, sp.target);
++ hlPool.push(sp);
++ }
let vmSwing = 0;
// per-item viewmodel pose so props read correctly in first person (Bug10)
const VM_POSE = {
@@@ -924,6 +954,9 @@ function start(token, user) {
uiFocus('craft', craftOpen);
if (craftOpen) renderCraft();
}
++ // inventory ⇄ crafting cross links
++ $('btn-inv-craft').onclick = () => { invApi.toggle(false); toggleCraft(true); };
++ $('btn-craft-inv').onclick = () => { toggleCraft(false); invApi.toggle(true); };
// ---------- base building: walls & floors with hp and upgrades ----------
const buildMap = new Map(); // id -> {b, mesh, col, body, inter}
@@@ -1282,14 +1315,53 @@ function start(token, user) {
$('set-sens').value = cfg.sens;
$('set-fov').value = cfg.fov;
$('set-inverty').checked = cfg.invertY;
-- $('set-shadows').checked = cfg.shadows !== false;
++ for (const [id, v] of [['set-sh-off', 'off'], ['set-sh-low', 'low'], ['set-sh-high', 'high']])
++ $(id).classList.toggle('sel', cfg.shadowQ === v);
++ $('set-rscale').value = cfg.rscale;
++ $('set-dd-near').classList.toggle('sel', cfg.drawDist === 'near');
++ $('set-dd-far').classList.toggle('sel', cfg.drawDist !== 'near');
++ $('set-xlights').checked = cfg.xlights !== false;
++ $('set-fps').checked = !!cfg.fps;
};
++ // ---- graphics application ----
++ const baseDpr = Math.min(devicePixelRatio, 2);
++ function applyGraphics() {
++ daylight.setShadowQuality(cfg.shadowQ);
++ renderer.setPixelRatio(baseDpr * cfg.rscale);
++ renderer.setSize(innerWidth, innerHeight);
++ const far = cfg.drawDist === 'near' ? 170 : 300;
++ camera.far = far;
++ camera.updateProjectionMatrix();
++ scene.fog.far = Math.min(260, far - 15);
++ scene.fog.near = cfg.drawDist === 'near' ? 60 : 90;
++ daylight.setExtraLights(cfg.xlights !== false);
++ $('fps-meter').classList.toggle('hidden', !cfg.fps);
++ }
++ for (const [id, v] of [['set-sh-off', 'off'], ['set-sh-low', 'low'], ['set-sh-high', 'high']])
++ $(id).onclick = () => { cfg.shadowQ = v; cfg.shadows = v !== 'off'; saveCfg(); applyGraphics(); refreshCfgUI(); };
++ $('set-rscale').oninput = (e) => { cfg.rscale = +e.target.value; saveCfg(); applyGraphics(); };
++ $('set-dd-near').onclick = () => { cfg.drawDist = 'near'; saveCfg(); applyGraphics(); refreshCfgUI(); };
++ $('set-dd-far').onclick = () => { cfg.drawDist = 'far'; saveCfg(); applyGraphics(); refreshCfgUI(); };
++ $('set-xlights').onchange = (e) => { cfg.xlights = e.target.checked; saveCfg(); applyGraphics(); };
++ $('set-fps').onchange = (e) => { cfg.fps = e.target.checked; saveCfg(); applyGraphics(); };
++ applyGraphics();
++ // fps counter (1s window)
++ let fpsN = 0, fpsT = performance.now();
++ function tickFps() {
++ if (!cfg.fps) return;
++ fpsN++;
++ const now2 = performance.now();
++ if (now2 - fpsT > 1000) {
++ $('fps-meter').textContent = `${Math.round(fpsN * 1000 / (now2 - fpsT))} fps · ${renderer.info.render.calls} draws`;
++ fpsN = 0;
++ fpsT = now2;
++ }
++ }
$('set-wheel-zoom').onclick = () => { cfg.wheel = 'zoom'; saveCfg(); refreshCfgUI(); };
$('set-wheel-hotbar').onclick = () => { cfg.wheel = 'hotbar'; saveCfg(); refreshCfgUI(); };
$('set-sens').oninput = (e) => { cfg.sens = +e.target.value; saveCfg(); };
$('set-fov').oninput = (e) => { cfg.fov = +e.target.value; camera.fov = cfg.fov; camera.updateProjectionMatrix(); saveCfg(); };
$('set-inverty').onchange = (e) => { cfg.invertY = e.target.checked; saveCfg(); };
-- $('set-shadows').onchange = (e) => { cfg.shadows = e.target.checked; daylight.setShadows(cfg.shadows); saveCfg(); };
camera.fov = cfg.fov; camera.updateProjectionMatrix();
refreshCfgUI();
$('btn-close-settings').onclick = () => { $('settings').classList.add('hidden'); uiFocus('settings', false); };
@@@ -1763,21 +1835,22 @@ function start(token, user) {
ghosts[bkind].rotation.y = gp.ry;
}
-- // headlights on driven cars, brighter after dark
-- for (const e2 of phys.cars.values()) {
-- const car = e2.car;
-- if (car.driver != null) {
-- if (!e2.hlLight) {
-- const sp = new THREE.SpotLight(0xfff2cf, 0, 36, .52, .5, 1.1);
-- sp.position.set(0, .85, 2.0);
-- const tgt = new THREE.Object3D();
-- tgt.position.set(0, .25, 15);
-- car.group.add(sp, tgt);
-- sp.target = tgt;
-- e2.hlLight = sp;
++ // headlights: a fixed POOL of 3 spotlights shared by driven cars — the
++ // light count never changes, so shaders never recompile mid-game (the
++ // old per-car lazy lights caused a full recompile hitch on every enter)
++ {
++ let hi = 0;
++ for (const e2 of phys.cars.values()) {
++ if (e2.car.driver == null || hi >= hlPool.length) continue;
++ const hl2 = hlPool[hi++];
++ if (hl2.parent !== e2.car.group) {
++ e2.car.group.add(hl2, hl2.target);
++ hl2.position.set(0, .85, 2.0);
++ hl2.target.position.set(0, .25, 15);
}
-- e2.hlLight.intensity = .25 + (1 - (W.dayFactor ?? 1)) * 3.4;
-- } else if (e2.hlLight) e2.hlLight.intensity = 0;
++ hl2.intensity = .25 + (1 - (W.dayFactor ?? 1)) * 3.4;
++ }
++ for (; hi < hlPool.length; hi++) hlPool[hi].intensity = 0;
}
// flashlight follows your view
torch.intensity = my.held === 'flashlight' ? .5 + (1 - (W.dayFactor ?? 1)) * 3.6 : 0;
@@@ -1793,6 +1866,7 @@ function start(token, user) {
if (sendTimer > .1) { sendTimer = 0; sendPos(); }
renderer.render(scene, camera);
++ tickFps();
}
frame();
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex 3dd33ed..1ce815b 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -357,7 +357,7 @@ export function initPhysics(scene) {
}
}
if (playerPos) playerPush(playerPos.x, playerPos.y, playerPos.z, dt);
-- world.step(1 / 60, dt, 3);
++ world.step(1 / 60, dt, 2);
// cars: visual group, walking collider, and E-prompt follow the body
for (const e of cars.values()) {
const b = e.body, car = e.car;
ddiff --git a/public/js/world.js b/public/js/world.js
iindex 1beb4d4..3b0a0c0 100644
---- a/public/js/world.js
++++ b/public/js/world.js
@@@ -2830,6 +2830,8 @@ function streets(scene) {
const fireLight = new THREE.PointLight(0xff7722, 1.8, 11, 2);
fireLight.position.set(0, 1.4, 0);
fireG.add(fireLight);
++ (W.extraLights ||= []).push(fireLight);
++ (W.dynamic.fireGroups ||= []).push(fireG); // stays matrix-dynamic for the flame anim
// rising smoke puffs
const smokeTex = TX.ct(64, 64, (g, w, h) => {
const gr = g.createRadialGradient(w / 2, h / 2, 4, w / 2, h / 2, 30);
ddiff --git a/public/play.html b/public/play.html
iindex b82341b..0e1f210 100644
---- a/public/play.html
++++ b/public/play.html
@@@ -67,9 +67,11 @@
<div id="dmg-flash"></div>
<div id="hitmark"></div>
<div id="headshot-tag">HEADSHOT</div>
++ <div id="fps-meter" class="hidden"></div>
<!-- crafting (C) -->
<div id="craft-menu" class="hidden">
++ <button id="btn-craft-inv" class="xlink" style="float:right">🎒 Inventory →</button>
<h3>🔨 Crafting</h3>
<div class="have" id="craft-have"></div>
<div id="craft-list"></div>
@@@ -101,7 +103,7 @@
<!-- ============ INVENTORY ============ -->
<div id="inventory" class="hidden">
<div id="inv-panel">
-- <div id="inv-head">🎒 INVENTORY <span>drag to move · 1-6 select · F use/swing · Tab close</span></div>
++ <div id="inv-head">🎒 INVENTORY <span>drag to move · 1-6 select · F use/swing · Tab close</span><button id="btn-inv-craft" class="xlink">🔨 Crafting →</button></div>
<div id="inv-grid"></div>
<div id="inv-hot-label">HOTBAR</div>
<div id="inv-hotbar"></div>
@@@ -134,7 +136,16 @@
<div class="set-row"><span>Sensitivity</span><input id="set-sens" type="range" min="0.4" max="2.2" step="0.1"></div>
<div class="set-row"><span>FOV</span><input id="set-fov" type="range" min="60" max="100" step="1"></div>
<div class="set-row"><span>Invert Y</span><input id="set-inverty" type="checkbox"></div>
-- <div class="set-row"><span>Shadows</span><input id="set-shadows" type="checkbox"></div>
++ <p style="margin-top:6px">Graphics</p>
++ <div class="set-row"><span>Shadows</span>
++ <div class="seg"><button id="set-sh-off" class="segbtn">Off</button><button id="set-sh-low" class="segbtn">Low</button><button id="set-sh-high" class="segbtn">High</button></div>
++ </div>
++ <div class="set-row"><span>Render scale</span><input id="set-rscale" type="range" min="0.5" max="1" step="0.05"></div>
++ <div class="set-row"><span>Draw distance</span>
++ <div class="seg"><button id="set-dd-near" class="segbtn">Near</button><button id="set-dd-far" class="segbtn">Far</button></div>
++ </div>
++ <div class="set-row"><span>Extra lights</span><input id="set-xlights" type="checkbox"></div>
++ <div class="set-row"><span>FPS counter</span><input id="set-fps" type="checkbox"></div>
<button id="btn-close-settings" class="btn-primary">Back to break</button>
<button id="btn-logout" class="btn-ghost">Badge out (log out)</button>
</div>