Skip to content

feat(playset): scene3d surface + GameBlocks port + native wgpu core — rally renders#85

Draft
doodlewind wants to merge 12 commits into
mainfrom
feat/playset
Draft

feat(playset): scene3d surface + GameBlocks port + native wgpu core — rally renders#85
doodlewind wants to merge 12 commits into
mainfrom
feat/playset

Conversation

@doodlewind

@doodlewind doodlewind commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

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.

rally desktop

Same bundle on the PSP host under PPSSPP: RMSE 5.4% vs the desktop frame. Wave-1 ports: runner · dogfight · snake

Layers

  1. scene3d surface (playset/scene3d/) — closed write-only 3D presentation vocabulary (the 3D sibling of ui): batched writePoses, primitives/heightfield/mesh, vertex-lit fixed-function-honest materials, sun/hemisphere/fog/sky, sprite & beam pools, bindViewport per the <Video> precedent. TS client + <Viewport3D> + renderless sim host for goldens.
  2. playset/modules/ — all 74 GameBlocks modules (MIT, attributed) + 3 natives (deterministic CollisionWorld replacing Rapier, blob-shadow, color-utils). Copy-into-project source per SKILL.md (with the Three.js → playset mapping table).
  3. Native coresPROP.scene3d + DRAW_OP.sceneQuad through spec/core (all four DrawList consumers; graceful absence, zero golden drift). Desktop: pocket3d/crates/pocket-scene3d (retained store + rquickjs s3 mount + wgpu vertex-lit renderer), composited in uihost. PSP: the store goes no_std behind a feature gate (one semantics, two hosts — the pocket3d-bsp pattern) with native/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_us per 300 frames) answers the 333 MHz QuickJS question on hardware: bun run hw rally -r.
  4. Gamesdemos/rally (original composition) + wave-1 ports demos/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

  • 365 bun tests / 5,879 assertions green (bun run test builds all five game bundles); tsc --noEmit clean; 12 pocket-scene3d cargo tests; no_std build clean; clippy clean
  • PSP: all 23 ui e2e goldens byte-exact under PPSSPP, tape:check 180 frames, rally renders at RMSE 5.4%/5.3% vs desktop refs
  • Desktop hero-main screenshot: pixel-identical (2D path inert)

Follow-ups

  • Wave-2 ports (shooters/defense ×6), wave-3 (vehicles/water/marble/arm), voxel-survival last (needs a voxel-world module)
  • Playset gap backlog from wave-1: instanced-geometry verb, pooled-entity helper, edge-triggered input in GameInput, flight-pursuit behavior module, per-target hit radii on projectiles, default font slot for unclassed Text (systemic), point-light verb, frameBounds camera solver, BoardEnvironment rim option, exp fog
  • Rust hot-path blocks behind the batch seams; mid-DrawList compositing (v1: scene under all ui); ramp wedge collision; textured materials; pocket port funnel skill
  • Hardware perf spike: bun run hw rally -r on the real PSP (PSPLINK console prints the probe)

🤖 Generated with Claude Code

doodlewind and others added 6 commits July 11, 2026 07:41
…, 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>
@doodlewind doodlewind changed the title feat(playset): scene3d surface + full GameBlocks module port + rally E2E feat(playset): scene3d surface + GameBlocks port + native wgpu core — rally renders Jul 11, 2026
doodlewind and others added 4 commits July 11, 2026 11:31
…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>
@doodlewind

Copy link
Copy Markdown
Collaborator Author

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

  • The host never set the clock — PSPLINK boots at 222/111 MHz. run() now pins 333/333/166 (cpu_mhz= readback in the perf line).
  • QuickJS was compiled -O0 since 2021libquickjs-sys/build.rs carried .opt_level(0) from the original bring-up experiment. Fixed to -O2 (quickjs-rs branch perf/interpreter-o2).

Calibrated interpreter cost (on device, 333 MHz, -O2)

microbench unit cost
float mul+add loop iteration 8.3µs (~1.7µs/op ≈ 550 cycles — soft-float f64)
property read ~2.5µs/op
Vector3.copy().multiplyScalar().add() 110µs/chain

Frame budget 16,667µs ≈ ~8k interpreter ops. Rally's step runs ~250k.

rally, per frame (steady state, 300-frame windows)

segment before after fix
step 490ms 268ms CollisionWorld broadphase grid (insertion-order-exact)
flush 128ms 46ms Scene3D.markStatic() scenery skip
HUD 69ms 12ms 10 Hz refresh on the virtual 0.1s grid
total JS 689ms 328ms

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>
@doodlewind

Copy link
Copy Markdown
Collaborator Author

rally on a real PSP: 689 ms → 13.1 ms per frame

Follow-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)

before after
sim step 268 ms 1.7 ms
scene.flush() 128 ms 0.6 ms
HUD 69 ms 2.0 ms
ge::render 17.6 ms 4.3 ms
frame total 689 ms 13.1 ms avg

91.5% of frames land inside the 16.67 ms budget.

What did it

  • pocket-playset — the hot GameBlocks blocks as native f32 Rust, one crate for both hosts. 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 and one 15-float readHud. The port alone took the step from 268 ms to 1.7 ms — after that, everything costing time was outside the sim.
  • Frustum culling in ge3d — 707 draws → ~65, 23,585 → ~6,400 triangles. rally was vertex-bound: 23.6k tris ≈ 28 ms of GE at the ~2.5M lit-verts/s the hardware sustains, which matched the ~30 ms list time I'd measured. Fill was never the constraint.
  • Terrain tiling — one heightfield was 4,608 triangles that no cull can reject. 6×6 overlapping patches; the overlap is load-bearing (normals are averaged per mesh, so touching tiles give boundary vertices a half-neighbourhood and a visible seam).
  • HUD — one signal per field. A single object signal re-ran every consumer because a fresh object is never ===.

The TS path is untouched

The playset modules remain the reference implementation; a host without ps runs them exactly as before, and every byte-exact golden still pins that path. f32 vs f64 makes the two trajectory-equivalent, not bit-equivalent, so parity is a bounded-divergence test instead: over 600 frames the native car stays within 0.05 u of the reference racing line, and the bounds are mutation-tested (a steer-sign flip misses by 300×).

Two findings worth stealing

  • Physics has to sample the terrain that is actually drawn. The procedural samplers hash through fract(sin(...)), which is a genuinely different function in f32 than f64 — 1.94 vs 2.59 at the same point. A native core re-deriving heights puts the car on a surface that is not on screen. The mesh now hands its tessellated grid to the sim.
  • avg_work_us excludes the GE wait. The real critical path is (js+tick+draw) + gu_wait + render, and the display is vblank-quantised, so one frame over budget costs a whole 16.7 ms. The probe now counts over-budget frames per 300 — that's the honest 60fps metric, not the mean.

Known remainder

The 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
@doodlewind

Copy link
Copy Markdown
Collaborator Author

Merged main (39 commits) — conflicts resolved, plus one thing that needs a decision

Branch is MERGEABLE / CLEAN again. Five conflicts, all resolved as unions or in favour of main's newer semantics:

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:

  1. 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;
  2. native/Cargo.toml repinned 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

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.

1 participant