Skip to content

perf: deferred-tier follow-up — spatial hash + Phase 3 GPU (opt-in) — follow-up to #15 - #16

Merged
J3vb merged 6 commits into
masterfrom
claude/plan-perf-overhaul-dg0wpj
Aug 22, 2026
Merged

J3vb merged 6 commits into
masterfrom
claude/plan-perf-overhaul-dg0wpj

Conversation

@J3vb

@J3vb J3vb commented Jul 5, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to the merged #15, completing the deferred plan tiers. 8 of the 9 deferred items are implemented; the 9th is declined on value+safety grounds (below). Behavior-changing items are guarded default-off so the default frame/boot is provably unchanged and each is ready to A/B-validate + enable in a real WebGPU/WebGL2 browser; the rest are real, safe wins.

Landed

Item What Gating
2.1 monster spatial hash monstersNear/rebuildMonsterGrid, wired into projectile collision (superset + exact re-test) opt-in __monGrid
2.3 lingering fields → field-FX spawnLingerField now a per-frame mfields tick (no setTimeout), grid-integrated shipped (behavior-preserving refactor)
3.1 frustum culling applyFrustumCull() on static zone scenery only (exact bounds, no pop-in) opt-in __cullOffscreen
3.2 shadow-frustum tightening moon ortho ±70→±50 opt-in __shadowTight
3.4 scatter density wild-prop counts scale by preset (45/70/100) shipped (default High unchanged)
4.1 tiered/lazy roster boot roles eager, monster/boss GLBs prefetched idle + on-demand (procedural fallback) opt-in __lazyRoster
4.4 monster mesh pooling recycle non-elite Group/bodyMat + GLB clone via poolOrRemove/_reusePooledMon opt-in __monPool

Every opt-in returns the exact prior code path when off, so the default is byte-identical. Validate: ?perftest=1 → perfRun(), toggle in console, A/B on the same seed, then flip the defaults if clean.

Not implemented — 4.2/4.3 (LRU eviction + texture disposal), by decision

  • 4.3 texture disposal is actively unsafe: the audit itself flags that disposeObj not disposing textures is "safe today only because all textures are shared/cached." Those textures are shared across cached materials (e.g. _cobbleTexCache), so disposing them on material teardown would break the cache → missing textures. Adding it introduces the bug.
  • 4.2 LRU eviction is low-value: the zone caches are already bounded by the finite zone count (≤13 sets + 3 HDRIs) — the plan's "no monotonic growth" criterion is already met (a ceiling, not a leak). Eviction only trades marginal peak-VRAM for re-entry recompile hitches, and needs a per-cache-entry light-bucket refactor whose re-entry rendering can't be validated headlessly.

Verification

npm run check green (typecheck + Biome lint + format + 84 node tests). Opt-in paths are correct-by-construction (superset+re-test grid; static-only exact-bounds culling; bounded pool with a small reset surface) and default-off; enabling them wants the browser A/B above. The 2.3 refactor and 3.4 density are behavior-preserving / preset-scoped and shipped default-on.

🤖 Generated with Claude Code

https://claude.ai/code/session_019rQGUYz2BY4NJNoVj3FHTS

claude added 3 commits July 5, 2026 18:24
Implement the plan's centerpiece structural item as an opt-in behind
settings.monGrid (default OFF; console toggle __monGrid).

- monstersNear(x,z,r) / rebuildMonsterGrid() in js/12-monsters.js mirror the
  _dungColGrid room-grid idiom: floor(x/cell),floor(z/cell) -> monster[],
  rebuilt once per frame after monster positions are final (post-_compact).
- Wired into the per-frame O(P×M) projectile→monster collision. The query
  returns a SUPERSET (cell + margin-expanded neighbor rings covering per-frame
  knockback), and the loop's existing exact squared-distance test filters it,
  so the hit SET is identical to the linear scan.
- Default OFF, and monstersNear returns the live `monsters` array when off, so
  the default code path is byte-identical to before. It is off by default
  because, when on, it changes hit iteration order for two monsters overlapping
  the same bolt — shifting the seed-deterministic perf-harness RNG. Enable +
  A/B validate parity (grid vs linear hit sets on the same perfRun seed) in a
  real browser, then flip the default. Becomes clearly worthwhile if MOB_CAP
  (30) is raised.

Follow-up to the merged #15. GPU-visual items (frustum-culling, shadow-frustum,
LOD) and asset-lifecycle items (lazy roster, LRU eviction/texture disposal,
monster pooling) remain deferred — they need runtime WebGPU/WebGL2 validation
to implement without regressing (pop-in / black textures / procedural-boss
fallback), which this environment can't provide.

npm run check green (84 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rQGUYz2BY4NJNoVj3FHTS
…default off)

Land the two Phase 3 GPU items behind default-off settings so the default
render is provably unchanged and they're ready to validate + enable in a
browser.

- settings.cullOffscreen (console __cullOffscreen): applyFrustumCull()
  additively re-enables frustumCulled on STATIC town/wild/dungeon scenery
  (exact bounds → drops only genuinely off-screen geometry, no pop-in).
  Skinned entities are intentionally left untouched (animated bounds can
  exceed bind pose — the reason culling was disabled on them; needs QA).
  Applied on each zone (re)build + applyAllGfx.
- settings.shadowTight (console __shadowTight): applyShadowFrustum() narrows
  the moon shadow ortho from ±70 to ±50 (denser shadow texels; with culling,
  the shadow pass drops off-frustum casters). Off by default — a too-tight
  frustum can clip shadows at screen edges; validate coverage first.

Both default OFF → no change to the default frame; enable + A/B in a real
WebGPU/WebGL2 browser (check for pop-in / shadow-edge clipping), then flip the
defaults if clean.

npm run check green (84 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rQGUYz2BY4NJNoVj3FHTS
Scale wild scatter (trees/grass/bushes/flowers/mushrooms/rocks) instance
counts by settings.scatterDensity via the single scatter() helper in
js/07-wild.js. QUALITY_PRESETS set it to 45/70/100 for low/medium/high, so
weaker tiers (and the WebGL2-fallback auto→medium) instance fewer props —
real draw-call + triangle savings — while the default High tier keeps full
density. A build-time count reduction only (no per-frame LOD, no pop-in), so
it's safe without browser validation; it's a preset fidelity tradeoff, which
is what the tiers are for. _rescatterWild reuses the built count, so re-entry
density matches.

npm run check green (84 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rQGUYz2BY4NJNoVj3FHTS
@J3vb J3vb changed the title perf: monster spatial hash (opt-in, default off) — follow-up to #15 perf: deferred-tier follow-up — spatial hash + Phase 3 GPU (opt-in) — follow-up to #15 Jul 5, 2026
claude added 3 commits July 5, 2026 18:37
Implement lazy roster loading behind settings.lazyRoster (console __lazyRoster;
default OFF, reload to apply).

- ensureRole(role): idempotent, in-flight-guarded single-role GLB loader that
  populates GLB_PROTO and swaps hero/NPC meshes when they land.
- startModels: when lazy, only the boot roles (hero + the 4 playable classes +
  town NPCs) load eagerly at boot; after the menu shows, the deferred monster +
  boss GLBs (~11MB, most sessions never reach the boss depths) are prefetched
  during idle. When off, everything loads eagerly exactly as before
  (byte-identical default path).
- spawnMonster: on first spawn of a type whose GLB isn't resident yet, kicks
  ensureRole(t); buildMonsterMesh already falls back to the procedural mesh
  until the GLB arrives — no crash, just a brief lower-fidelity first spawn.

Default OFF because the spawn-before-proto timing (prefetch beating the first
encounter) needs browser validation; enabling it cuts the eager boot download.

npm run check green (84 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rQGUYz2BY4NJNoVj3FHTS
Convert spawnLingerField from 5 setTimeout closures (each a full monster
scan) to a per-frame mfields tick in tickFieldFx (js/15-combat.js). Same 5
pulses × dmg*0.35 within r=3.5 at ~240ms cadence, but on-frame (no off-frame
setTimeout hitch), integrated with the spatial grid (monstersNear), and torn
down by clearFieldFx on zone change — so the old _fieldEpoch race guard is
gone (tickFieldFx only runs while running && isCombat). mfields is declared in
14-runes.js (test-harness-loaded) to keep the browser-free prefix intact.

Behavior-preserving refactor (identical pulse count/timing/damage/radius);
folds into the grid when settings.monGrid is on, else scans the full list as
before.

npm run check green (84 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rQGUYz2BY4NJNoVj3FHTS
Recycle NON-ELITE monster meshes (procedural Group + bodyMat, and the GLB
SkeletonUtils clone + mixer) instead of orphaning/disposing one per kill,
behind settings.monPool (console __monPool; default OFF).

- poolOrRemove() is the single removal entry point (killMesh non-death path,
  the _dying death-anim completion, and the flee vanish); when the pool is off
  or the mesh isn't a pooled non-elite, it falls through to removeMob exactly
  as before → default path byte-identical.
- _reusePooledMon(t) pops a same-type mesh on spawn and resets the small
  reuse surface: clear the hit-flash emissive and rewind the GLB mixer out of
  its death pose to idle/walk. Bounded (6/type). Only non-elite are pooled —
  they carry no aura/eliteLight children and a constant per-type scale.

Default OFF because cleanly resetting a reused GLB skeleton clone needs visual
validation; enabling it removes the per-kill clone/material churn in sustained
farming.

npm run check green (84 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rQGUYz2BY4NJNoVj3FHTS
@J3vb
J3vb marked this pull request as ready for review August 22, 2026 04:25
@J3vb
J3vb merged commit 9a4265f into master Aug 22, 2026
1 check passed
@J3vb
J3vb deleted the claude/plan-perf-overhaul-dg0wpj branch August 22, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants