feat(playset): scene3d surface + GameBlocks port + native wgpu core — rally renders#85
feat(playset): scene3d surface + GameBlocks port + native wgpu core — rally renders#85doodlewind wants to merge 12 commits into
Conversation
…, foundation modules - scene3d: closed write-only presentation vocabulary (ops.ts), retained guest mirror with batched pose flush (client.ts), renderless sim host with canonical serialization for goldens (sim.ts), <Viewport3D> - playset/math: clean-room three@0.161-compatible math subset - modules/math: WorldBasis, Vector3Utils, Scalar/Random/TimeUtils ports (Clock defaults to the virtual clock, never Date.now) - modules/physics: deterministic CollisionWorld (capsule resolve, raycasts, walkable tops) — the injected-Rapier replacement seam - loop.ts: fixed 1/60 game loop, hz-invariant via ticksPerFrame catch-up Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
actor-motion (18), behavior (6), camera (5), gameplay (8), math (5), user-interface (8), world (24) — kebab-case files, PascalCase exports, verbatim semantics with per-file deviation notes and MIT attribution. Reengineered seams (documented in headers): - KinematicBatchResolver + DynamicCarBatchResolver run on the deterministic CollisionWorld (Rapier replaced; dynamic car is a kinematic raycast-vehicle approximation pending the native block) - AimResolver picks via CollisionWorld.raycast + Camera3D.rayFromNdc - visual factories/effects build SceneNode trees; JetFlame shader -> additive cones; tire marks/tracers -> beam pools; shadows -> blob-shadow.ts decals - DOM/Canvas2D HUD -> Solid components (radar/minimap as View trees) 243 new tests (344 total in playset/), all green; repo test script now runs playset/test/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- demos/rally: checkpoint-racing demo composing track environment, arcade car + AI rival (waypoint driver), kinematic resolver, chase camera, Viewport3D + HUD (lap/standings/minimap) - playset/test/rally-sim.test.ts: host-sim goldens — byte-identical framebuffer + scene3d serialization across boots, 30/60 Hz exact subsampling relation, graceful no-s3 degradation - summary.md (77-entry catalog), README.md, SKILL.md with the Three.js -> playset porting mapping table Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kdrop Core emits sceneQuad(xy, wh, handle) at a node's world AABB before its background when the host has bound a scene3d scene (host-written prop, never app code). All DrawList consumers skip it gracefully: wasm raster and PSP GE draw nothing (graceful absence), pocket-ui-wgpu skips (the 3D-capable host composites from the same DrawList). No golden drift — the prop is only ever set by hosts with a scene3d core. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…top host New pocket3d/crates/pocket-scene3d: retained store implementing the scene3d contract (sim.ts semantics: id lifecycle, silent dead-handle ops, pool replace+clamp, heightfield tessellation with smooth normals), rquickjs mount as globalThis.s3, and a wgpu renderer — per-scene pass into the sceneQuad rect: gradient sky, per-vertex sun+hemisphere lit meshes with linear fog (fixed-function honest), back-to-front transparent/additive, camera-facing sprite pools + view-aligned beams. pocket-ui-wgpu grows scene_quads() (DrawList walker) + UiSurface set_prop passthrough. uihost mounts s3 before eval, bridges bindViewport -> PROP.scene3d, composites scenes under the ui layer (LoadOp::Load), adds --hold for scripted screenshot input. rally verified visually at 480x272 (terrain/fog/cars/gates/HUD); hero-main regression unchanged; 12 crate tests + full bun run test green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he PSP host pocket-scene3d store goes no_std+alloc behind a feature gate (desktop keeps mount+wgpu under default 'std'; BTreeMap swap, fmath shim) so both hosts share one semantics. native/ gains scene3d.rs (globalThis.s3 over the QuickJS C API, bindViewport -> PROP.scene3d) and ge3d.rs (SCENE_QUAD compositor: rect-scoped viewport/scissor, depth-only clear, gouraud sky strip, GE hardware sun + hemisphere-average ambient, sceGuFog, additive pools, 2D-clean exit state). Always-on perf probe logs avg_js_us every 300 frames for the 333MHz hardware spike (bun run hw rally -r). PPSSPP-verified: rally f10/f240 at RMSE 5.4%/5.3% vs the desktop wgpu references; all 23 PSP ui goldens byte-exact; tape:check 180 frames; desktop uihost screenshot byte-identical after the store rework. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three ports following the rally pipeline (fixed-step sim, seeded PRNG, Viewport3D + HUD, sim-host goldens, uihost screenshot verification): - runner: 3-lane endless runner (jump/slide, coins/boost, difficulty ramp, snow sprite pool) — clean-room glue, numbers from the original - dogfight: the flight+combat+UI showcase — 17 playset modules as-is, FlightHud + HeadingRelativeRadar in action, wave AI, missile lock, terrain crash referee - snake: 16x16 grid arena vs A*/flood-fill AI rival, cadence ramp 16 new sim tests (byte-identical two-boot traces, 30/60 Hz subsampling, no-s3 degradation, gameplay probes); all bundles in the test chain. Module adaptations: WeaponEffectsSystem gains tracerWidth/particleSize options; FlightHud gets font classes + 480x272 layout fixes (unclassed Text renders nothing on native hosts — systemic gap noted for core). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PSPLINK boots the console at 222/111 MHz; the host never set the retail clock, so every hardware run paid a 1.5x CPU / 1.5x bus tax. run() now pins 333/333/166 and the perf line prints a cpu_mhz readback. The always-on probe also appends to host0:/PocketJS-perf.txt (PSPLINK's tty only reaches a shell connected at module start and drops on every reconnect — the file lands in the served target dir on the host), and when the app publishes cumulative counters on globalThis.__jsPerf it emits a [pocketjs jsperf] split line. s3 gains __hwNow (µs clock, debug-only, not part of the Scene3dOps contract) to feed those counters. Measured on device (rally): QuickJS ≈ 1.7µs per interpreter op at 333 MHz (soft-float f64) ≈ 8k-op frame budget; GE waits 29µs — the 3D compositor is never the bottleneck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly HUD throttle
Measured on real PSP hardware (333 MHz, QuickJS -O2), rally per frame:
step 490ms -> 268ms CollisionWorld planar grid broadphase; gathers
sort by handle so resolution keeps exact
insertion-order semantics (goldens byte-equal)
flush 128ms -> 46ms Scene3D.markStatic(root?): settled scenery is
skipped by the per-frame pose differ
hud 69ms -> 12ms rally refreshes the HUD on the virtual 0.1s
grid (sim steps, not render frames — keeps the
30/60Hz subsampling theorem for HUD pixels)
total 689ms -> 328ms
All 365 tests + PPSSPP e2e stay byte-exact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardware perf spike — answered on a real PSP (2026-07-20)The standing question ("is QuickJS per-frame 3D sim fast enough at 333 MHz?") now has a measured answer: no, by ~20×, and it's interpreter physics, not a fixable bug. Two root fixes found on the way
Calibrated interpreter cost (on device, 333 MHz, -O2)
Frame budget 16,667µs ≈ ~8k interpreter ops. Rally's step runs ~250k. rally, per frame (steady state, 300-frame windows)
GE wait: 29µs — the scene3d GE compositor is never the bottleneck. Rust tick+draw+enqueue ≈ 18ms. snake (unoptimized): 54ms steady. hero (ui-only) was always 60 fps. Consequence for wave 2+60 fps sims on PSP need the native hot-path blocks already on the backlog (Rust behind the same batch-resolver seams) or sims sized under ~8k ops/frame. Vita/desktop are unaffected (hard-float). All 365 tests + PPSSPP e2e stay byte-exact. 🤖 Generated with Claude Code |
…real PSP
[[quickjs-psp-perf-wall]] measured the ceiling: QuickJS on this CPU costs
~1.7µs per interpreter op, so a 60 Hz frame affords ~8k ops and the TS rally
step needed ~160k. This lands the hot GameBlocks blocks as native f32 Rust and
then chases what that exposed.
pocket3d/crates/pocket-playset math / collision / terrain / vehicle /
resolver / behavior / world, no_std+alloc for PSP and std for desktop —
one crate, two hosts, same semantics (the pocket-scene3d layout).
playset/sim/ops.ts the `ps` contract, mounted by
native/src/playset.rs (QuickJS C API) and pocket-playset/src/mount.rs
(rquickjs). The sim OWNS scene3d node handles and writes poses straight
into the shared store, so the per-frame pose batch never becomes JS: the
guest's whole turn is one `step` call and one 15-float `readHud`.
The TS modules REMAIN the reference implementation. A host without `ps` runs
them unchanged — the same graceful absence `s3` follows — and every byte-exact
golden still pins that path. f32 vs f64 makes the two trajectory-equivalent,
not bit-equivalent, so parity is asserted as bounded divergence instead
(tests/parity.rs, generated by playset/test/gen-parity-fixture.ts): over 600
frames the native car stays within 0.05u of the reference racing line, and the
bounds are mutation-tested (a steer-sign flip misses by 300x).
MEASURED per frame on a real PSP (333 MHz, release), rally:
689 ms -> 13.1 ms average, 91.5% of frames inside the 16.67 ms budget
sim step 268 ms -> 1.7 ms the port itself
flush 128 ms -> 0.6 ms Scene3D.markStatic + a dynamic-only walk list;
the native path hands the car nodes over to
the sim, so the guest walk empties completely
HUD 69 ms -> 2.0 ms one signal per field instead of one object
signal whose fresh identity re-ran everything
ge::render 17.6 ms -> 4.3 ms frustum culling (707 draws -> ~65, 23.6k tris
-> ~6.4k), retained scratch buffers, and a
terrain split into 6x6 cullable patches
Two findings worth keeping:
- Physics must sample the DRAWN terrain. The procedural samplers hash through
`fract(sin(...))`, which is a different function in f32 than in f64 (1.94 vs
2.59 at the same point), so a native core re-deriving heights puts the car on
a surface that is not on screen. `createTerrainMesh` now hands its
tessellated grid to `ps.terrainHeightfield` — physics and pixels read the
same data, and a bilinear lookup is cheaper than the procedural path.
- CameraRigCfg offsets are from_basis-encoded; a raw Vec3 puts the chase
camera in front of the car.
Remaining: ~8.5% of frames miss the budget, all of them minimap marker
refreshes. Rebuilding and diffing a twelve-key style object per marker costs
27 ms on this interpreter — far more than its op count explains, and not GC
(raising the collector threshold changed nothing). Markers now refresh at 5 Hz
to halve the hitch rate; making the update itself cheap is the follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rally on a real PSP: 689 ms → 13.1 ms per frameFollow-up to the perf spike. The native sim cores landed, and then chased everything they exposed. Commit 0e70e61. Per frame, measured on hardware (333 MHz, release)
91.5% of frames land inside the 16.67 ms budget. What did it
The TS path is untouchedThe playset modules remain the reference implementation; a host without Two findings worth stealing
Known remainderThe last 8.5% of over-budget frames are all minimap marker refreshes: rebuilding and diffing a twelve-key style object per marker costs 27 ms on this interpreter, far more than its op count explains. It is not GC — raising the collector threshold changed nothing, measured and reverted. Markers are gated to 5 Hz to halve the hitch rate; making the update itself cheap is the follow-up. 365 TS tests + 77 Rust tests + PPSSPP e2e all green. 🤖 Generated with Claude Code |
# Conflicts: # native/src/lib.rs # native/src/main.rs # package.json # pocket3d/crates/pocket-ui-wgpu/src/render.rs # tsconfig.json
Merged
|
| file | resolution |
|---|---|
native/src/lib.rs |
union — scene3d + playset alongside main's stats/svc/vid |
native/src/main.rs |
union of the pocketjs_psp::{...} import |
package.json |
main's test chain is a superset of the shared base; kept it and appended the playset segment (parity --check + the four demo builds + playset/test/) |
pocket3d/.../render.rs |
took main's DPI-scaled xy/wh closures; the module-level decode_xy/decode_wh stay unscaled because scene_quads reports logical ui px and its caller owns the scaling |
tsconfig.json |
dropped the retired @pocketjs/aot/* paths (main removed aot/), kept @pocketjs/playset/* and added @pocketjs/playset/sim |
Also had to rebuild host-web/pocketjs.wasm — main added core ops (ui_set_active) and the checked-in binary was stale, which failed the sim suites until regenerated.
Verification after the merge: 654 TS tests across the full merged chain, 77 Rust tests (incl. the rally trajectory-parity fixture, still up to date), cargo check --workspace clean, and the PSP EBOOT builds against main's new pins.
⚠️ The QuickJS -O2 fix is no longer in the build
main repinned the PSP toolchain from local paths to git revs:
libquickjs-sys = { git = "https://github.com/pocket-stack/quickjs-rs.git", rev = "0fc946fb..." }I read that rev's build.rs — it still has .opt_level(0). So the interpreter is back to the unoptimized build the perf spike found had been shipping since a 2021 bring-up experiment. That was worth roughly a 2x on JS time, which is a large share of how rally got to 13.1 ms/frame.
Nothing in this PR can fix that on its own. It needs, in order:
- perf(libquickjs-sys): compile QuickJS at -O2 (was -O0 since 2021 bring-up) quickjs-rs#6 (
-O2+ the GC bindings) merged — it is still a draft I opened; native/Cargo.tomlrepinned to the resulting rev.
I did not repin to my unmerged branch's sha: it happens to be fetchable through the fork network, but pinning a shipped build to an unmerged branch is a landmine, and merging a PR in another repo is not mine to decide.
I could not re-measure on hardware — the PSPLINK link wedged partway through (pspsh stopped responding; it needs a physical reset). The regression above is established from the pinned source, not inferred from a measurement.
🤖 Generated with Claude Code
What
Playset — Three.js mini-games (GameBlocks-class) running on Pocket, end to end: framework, native rendering on desktop wgpu AND PSP GE, and the first four games.
Same bundle on the PSP host under PPSSPP: RMSE 5.4% vs the desktop frame. Wave-1 ports: runner · dogfight · snake
Layers
scene3dsurface (playset/scene3d/) — closed write-only 3D presentation vocabulary (the 3D sibling ofui): batchedwritePoses, primitives/heightfield/mesh, vertex-lit fixed-function-honest materials, sun/hemisphere/fog/sky, sprite & beam pools,bindViewportper the<Video>precedent. TS client +<Viewport3D>+ renderless sim host for goldens.playset/modules/— all 74 GameBlocks modules (MIT, attributed) + 3 natives (deterministicCollisionWorldreplacing Rapier, blob-shadow, color-utils). Copy-into-project source perSKILL.md(with the Three.js → playset mapping table).PROP.scene3d+DRAW_OP.sceneQuadthrough spec/core (all four DrawList consumers; graceful absence, zero golden drift). Desktop:pocket3d/crates/pocket-scene3d(retained store + rquickjss3mount + wgpu vertex-lit renderer), composited inuihost. PSP: the store goes no_std behind a feature gate (one semantics, two hosts — the pocket3d-bsp pattern) withnative/src/{scene3d,ge3d}.rs: QuickJS C-API mount, GE hardware sun + hemisphere-average ambient,sceGuFog, depth-only clear, additive pools, 2D-clean exit. Always-on perf probe (avg_js_usper 300 frames) answers the 333 MHz QuickJS question on hardware:bun run hw rally -r.demos/rally(original composition) + wave-1 portsdemos/runner,demos/dogfight(17 modules as-is; FlightHud + radar in action),demos/snake(A*/flood-fill AI rival). Each: fixed-step deterministic sim, two-boot byte-identical goldens, exact 30↔60 Hz subsampling, no-s3 degradation, uihost screenshots in-repo.Validation
bun run testbuilds all five game bundles);tsc --noEmitclean; 12pocket-scene3dcargo tests; no_std build clean; clippy cleantape:check180 frames, rally renders at RMSE 5.4%/5.3% vs desktop refshero-mainscreenshot: pixel-identical (2D path inert)Follow-ups
GameInput, flight-pursuit behavior module, per-target hit radii on projectiles, default font slot for unclassedText(systemic), point-light verb,frameBoundscamera solver, BoardEnvironment rim option, exp fogpocket portfunnel skillbun run hw rally -ron the real PSP (PSPLINK console prints the probe)🤖 Generated with Claude Code