perf: deferred-tier follow-up — spatial hash + Phase 3 GPU (opt-in) — follow-up to #15 - #16
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
monstersNear/rebuildMonsterGrid, wired into projectile collision (superset + exact re-test)__monGridspawnLingerFieldnow a per-framemfieldstick (no setTimeout), grid-integratedapplyFrustumCull()on static zone scenery only (exact bounds, no pop-in)__cullOffscreen__shadowTight__lazyRosterpoolOrRemove/_reusePooledMon__monPoolEvery 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
disposeObjnot 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.Verification
npm run checkgreen (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