@jgengine/shell 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +265 -2
- package/dist/GamePlayerShell.js +27 -4
- package/dist/JoinGate.d.ts +17 -0
- package/dist/JoinGate.js +14 -0
- package/dist/Shell3dPresentation.d.ts +9 -8
- package/dist/Shell3dPresentation.js +57 -8
- package/dist/ShellChrome.d.ts +2 -0
- package/dist/ShellChrome.js +6 -0
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/TerritoryOverlay.d.ts +21 -0
- package/dist/TerritoryOverlay.js +9 -0
- package/dist/audio/AudioComponents.js +13 -2
- package/dist/audio/audioEngine.d.ts +15 -1
- package/dist/audio/audioEngine.js +132 -11
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +7 -1
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/camera/GameCameraRig.js +1 -1
- package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
- package/dist/camera/GameFirstPersonCamera.js +52 -15
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +77 -62
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/defineGame.d.ts +9 -0
- package/dist/defineGame.js +38 -6
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/KeysPanel.js +3 -3
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +15 -1
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.d.ts +5 -1
- package/dist/drivers/FrameDriver.js +74 -26
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +10 -5
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadMerge.d.ts +10 -0
- package/dist/input/gamepadMerge.js +12 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +22 -0
- package/dist/input/gamepadSource.js +153 -0
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/materialOverride.d.ts +4 -1
- package/dist/materialOverride.js +10 -1
- package/dist/postfx/PostProcessing.d.ts +11 -8
- package/dist/postfx/PostProcessing.js +38 -16
- package/dist/postfx/gradeShader.js +13 -0
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/CascadedShadows.d.ts +11 -0
- package/dist/render/CascadedShadows.js +19 -8
- package/dist/render/EnvironmentLighting.d.ts +4 -1
- package/dist/render/EnvironmentLighting.js +45 -9
- package/dist/render/SceneLighting.d.ts +16 -2
- package/dist/render/SceneLighting.js +56 -6
- package/dist/render/SceneModels.js +45 -9
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/modelLoad.d.ts +39 -0
- package/dist/render/modelLoad.js +89 -1
- package/dist/render/modelRender.d.ts +3 -0
- package/dist/render/modelRender.js +6 -2
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +58 -0
- package/dist/render/useFootIk.js +216 -0
- package/dist/render/useModelAnimation.d.ts +26 -0
- package/dist/render/useModelAnimation.js +184 -107
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +666 -241
- package/dist/scene/cityWindowMaterial.d.ts +8 -6
- package/dist/scene/cityWindowMaterial.js +80 -21
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +10 -2
- package/dist/settings/appliedSettings.js +9 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/structures/BuildingKitBatch.js +26 -22
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +21 -15
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/terrain/terrainDetailMaterial.js +7 -3
- package/dist/useShellMultiplayerSync.d.ts +9 -2
- package/dist/useShellMultiplayerSync.js +30 -8
- package/dist/visibility/CullingProvider.d.ts +2 -1
- package/dist/visibility/CullingProvider.js +7 -2
- package/dist/world/GridWorldScene.js +4 -0
- package/dist/world/TileLayerRenderer.d.ts +9 -0
- package/dist/world/TileLayerRenderer.js +64 -0
- package/dist/world/WorldParticles.d.ts +31 -0
- package/dist/world/WorldParticles.js +128 -0
- package/dist/world/WorldScene.d.ts +2 -1
- package/dist/world/WorldScene.js +46 -4
- package/dist/world/entityPose.d.ts +4 -0
- package/dist/world/entityPose.js +9 -0
- package/dist/world/tileLayerInstances.d.ts +11 -0
- package/dist/world/tileLayerInstances.js +28 -0
- package/dist/worldSync.d.ts +5 -1
- package/dist/worldSync.js +25 -4
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +20 -15
- package/skills/jgengine-ui/api.md +538 -326
- package/skills/jgengine-ui/capabilities.md +37 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -78
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
- package/skills/level-design/SKILL.md +1 -1
- package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Recipe — vehicle feel (feel target → metrics → physical knobs)
|
|
2
|
+
|
|
3
|
+
**What this wires:** a ground vehicle that feels the way the brief says, from sim to camera, sound and rumble. There is no default car: every vehicle is a set of physical numbers the game picks for its own feel, checked against metrics it writes down first.
|
|
4
|
+
|
|
5
|
+
## The seams
|
|
6
|
+
|
|
7
|
+
- **Sim.** `createVehicleDynamics(tuning, { surfaceFriction?, clampMove?, groundHeight? })` from `@jgengine/core/physics/vehicleDynamics`. Yaw comes from tire forces, so balance, slides and recovery are outcomes of the numbers, not special cases. It has `snapshot()`/`restore()` for prediction and replay, `retune()` for upgrades and damage, and per-tick modifiers (`driveScale`, `gripScale`, `steerScale`, `brakeScale`, `thrust` for boost).
|
|
8
|
+
- **Terrain.** Add a `suspension` block (spring and damper rates, travel, ride height, anti-roll) and pass `groundHeight` for ramps, hills and jumps. Each wheel then samples the ground, the body heaves, pitches and rolls on real springs, slopes pull the car downhill, and the step reports `airborne`, `landingSpeed` and per-corner `wheelLoads`. Without the block the car stays on a flat plane.
|
|
9
|
+
- **Input.** Put `createAxisShaper` (`@jgengine/core/input/axisShaper`) between `ctx.input.axis(...)` and the sim. Keys get `riseRate`/`returnRate`, so a tap steers a little and a hold builds up; sticks get `deadzone`/`curve` with no lag. Pass `{ analog: analogAxes(bindings, ctx.input.analog()) }` each frame so each axis picks the right profile.
|
|
10
|
+
- **Pad feel.** Set the stick deadzone, curve and trigger deadzone once in `defineGame({ gamepad: { deadzone, curve, triggerDeadzone } })`. The shell applies it before any analog value reaches `ctx.input`, so per-axis shaping in `createAxisShaper` only adds ramps and speed scaling on top.
|
|
11
|
+
- **Jumps and air.** With `suspension`, a `jump` block (`speed`, `count` for double jumps, `window`) enables `car.jump()`, and an `air` block (`pitchAccel`/`yawAccel`/`rollAccel`, `damping`, `maxRate`) rotates the body in flight. Air input comes from `modifiers.air`; without it, throttle−brake pitches and steer yaws, which suits Rocket League-style play. Pass `{ pitch: 0, yaw: axis.steer, roll: 0 }` if W should keep driving instead of nosing down. `applyAngularImpulse` composes dodges and flips. The body stays near level, so full flips and wall-driving wait for the rigid-body backend.
|
|
12
|
+
- **Boats.** `createBoatDynamics` (`@jgengine/core/physics/boatDynamics`) takes the same axis input and returns a step `tickDrivableVehicle` and `measureHandling` accept. Its knobs are mass, length, beam, prop thrust and prop speed, `planingSpeed`, `keel` and `steering` (`rudder` with an area, or `outboard`). A tug and a speedboat differ only in those numbers.
|
|
13
|
+
- **Collision.** In a `PhysicsBackend` world (Rapier or `PhysicsWorld`), `createVehicleBackendLink` gives the car a kinematic chassis that shoves props, and a `clampMove` that stops it at walls. The sim keeps the handling, so the feel numbers above still hold.
|
|
14
|
+
- **Two wheels.** A `lean` block (`maxLean`, `leanRate`, optional `countersteer`) turns the same sim into a motorcycle or bicycle.
|
|
15
|
+
- Steer asks for a lean, and the bar steers the balanced turn that lean needs.
|
|
16
|
+
- The bike tips the other way briefly as the lean starts, which is countersteer.
|
|
17
|
+
- The step reports the balance lean `atan(lateral g)` as `lean`, with `bodyRoll = -lean`. The requested lean is capped at what grip can hold.
|
|
18
|
+
- `wheelie`/`stoppie` flags come from axle loads. `assists.antiWheelie` and `assists.antiStoppie` cap drive and braking just under the pitch-over limit, and on springs they also back off as the axle goes light. A sprung bike needs them to stay playable.
|
|
19
|
+
- `measureLean` reports steady lean, time to lean and counter-lean.
|
|
20
|
+
- **Pose.** `tickDrivableVehicle(car, dt, ctx.input.axis(bindings, ranges), { groundHeight })` returns a `setPose` patch. Pitch and roll come from load transfer, or from the springs when `suspension` is set.
|
|
21
|
+
- **Camera.** `camera: { rig: "chase", chase: { fov, lead, bank, velocityYaw, yawResponse } }`. `velocityYaw` shows the car's side in a slide, and `fov` widens with speed.
|
|
22
|
+
- Speed comes from the entity's sim `velocity`, so publish poses with `setPose({ ..., dt })` (`tickDrivableVehicle` does). `fov.response` eases the FOV.
|
|
23
|
+
- The boom holds its length at any speed. `distanceBySpeed: { extra }` pulls it back on purpose; `ctx.camera.kickFov(deg)` adds a decaying punch for landings and hits.
|
|
24
|
+
- `pitchFollow` tilts the boom with `rotationX` on ramps and slopes. `lookBackAction` names an input action that looks behind while held.
|
|
25
|
+
- The boom stops short of objects, terrain and walls through `ctx.scene.raycast`; `collision: false` turns that off.
|
|
26
|
+
- Switch views at runtime with `ctx.camera.setChaseTuning({ ...ctx.camera.chaseTuning(), view: nextChaseView(view) })` (`@jgengine/core/runtime/cameraDirector`).
|
|
27
|
+
- **Sound.** Call `ctx.game.audio.loop(id, sound)` once, then `setLoop` every tick:
|
|
28
|
+
- Engine: `rate` from `step.rpm`, `gain` from `step.engineLoad`.
|
|
29
|
+
- Tires: `gain` from how far `max(step.frontSaturation, step.rearSaturation)` exceeds ~0.85.
|
|
30
|
+
- Real engine samples: `createEngineLayers` (`@jgengine/core/audio/engineLayers`) takes N loops keyed by the rpm they were recorded at, crossfades the two around the current rpm, and picks on-load or off-load sets by `engineLoad`. Call `update(dt, { rpm, load })` then `play(ctx.game.audio, { at, velocity, gain, lowpass })` each tick.
|
|
31
|
+
- `setLoop` also takes `lowpass`/`highpass` cutoffs in Hz (muffle off-throttle) and `velocity`; a sound with `doppler: 1` pitches by listener and emitter velocity.
|
|
32
|
+
- **One place for all of it.** `createFeedbackMixer` (`@jgengine/core/vfx/feedbackMixer`) declares these mappings as routes: `{ signal, target, curve, attack, release }`, with `max` or `sum` combining and threshold `events` for one-shots like a landing thud. Update it with the step's telemetry each tick and apply its targets to `setLoop`, the chase camera and `haptics`, instead of hand-writing the glue.
|
|
33
|
+
- **Rumble.** Set continuous channels each tick on `ctx.input.haptics(userId)`: `set("road", { strong: rearSaturation, weak: frontSaturation })`, an `engine` hum from load and rpm, and `pulse("impact", { strong, weak, ms }, 1)` on a landing so it ducks the rest. The shell mixes them by priority onto that player's pad. `ctx.input.rumble` stays for a single fire-and-forget effect.
|
|
34
|
+
- **Sound.** Play a one-shot on `step.landingSpeed` (thud, suspension clunk).
|
|
35
|
+
- **Proof.** For jumps and air control, `measureAir` reports apex height and time, double-jump height, and air rotation rates.
|
|
36
|
+
- **Proof.** `measureHandling(() => createVehicleDynamics(tuning), options?)` from `@jgengine/core/physics/handlingProbe` returns deterministic numbers. Assert them in a test. For a sprung car, `measureRide` adds settle time, brake dive, roll per g and landing bounces.
|
|
37
|
+
|
|
38
|
+
## Workflow
|
|
39
|
+
|
|
40
|
+
1. **Write the feel as numbers before tuning.** Turn the brief into target ranges on the `HandlingReport` fields. Some examples:
|
|
41
|
+
- "Snappy" is `turnIn < 0.2`.
|
|
42
|
+
- "Planted" is `maxLateralG` close to the tire μ with `stepPeakSideslipDeg < 8`.
|
|
43
|
+
- "Drifty but safe" is `handbrakePeakSideslipDeg` 40–70 with `spun === false`.
|
|
44
|
+
- "Keyboard-friendly" is `spun` and `powerSteerSpun` both `false`, including at `cornerSpeed: 40`.
|
|
45
|
+
- "Neutral balance" is `measureCourse(...).understeerGradient` near 0–0.5 deg/g; "tail-happy" is below 0.
|
|
46
|
+
- "Agile" is a high `measureCourse(...).slalomSpeed`; "forgiving" is a short `handbrakeRecoverySeconds`.
|
|
47
|
+
2. **Put those ranges in a test first**, next to the tuning.
|
|
48
|
+
3. **Start from the real vehicle's physical numbers**: mass, wheelbase, weight split, CoM height, driven axle, torque and gears. Then move one knob at a time using the table below, rerunning the test after each change.
|
|
49
|
+
4. **Drive it.**
|
|
50
|
+
- In the engine repo: `bun run drive <game> --key KeyW:4000 --probe a --key KeyW+KeyD:1500 --probe b --record <name>`. Chords hold throttle and steer together.
|
|
51
|
+
- `--record-fps 20` keeps game time at 1:1, because each recorded frame advances at most one 50 ms step.
|
|
52
|
+
- Tests prove the numbers; the drive proves the camera and sound.
|
|
53
|
+
|
|
54
|
+
## Symptom → knob
|
|
55
|
+
|
|
56
|
+
| Symptom | Knob (direction) |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `understeerGradient` too high | front tire `peakSlipAngle` ↓ or rear ↑ (tire stiffness split is what moves it at moderate g) |
|
|
59
|
+
| Pushes wide, won't turn in | `rollStiffnessFront` ↓, rear `peakGrip` ↓ relative to front, `yawInertiaIndex` ↓, `steering.rate` ↑ |
|
|
60
|
+
| Snaps into oversteer | `rollStiffnessFront` ↑, rear `peakGrip` ↑, `slideGrip` ↑ (gentler breakaway), `assists.stability` ↑ |
|
|
61
|
+
| Spins holding throttle and steer | `assists.tractionControl` ↑ (budgets for cornering grip), `driveFront` ↑, peak torque ↓ |
|
|
62
|
+
| Slides feel uncatchable | `steering.selfAlign` ↑ (caster catches the slide), `slideGrip` ↑, `assists.maxSideslip` ↑ with `stability` ↑ |
|
|
63
|
+
| Too twitchy at speed | `steering.highSpeedAngle` ↓, `steering.rate` ↓, `yawInertiaIndex` ↑, shaper steer `digital.riseRate` ↓ |
|
|
64
|
+
| Keyboard steering is all-or-nothing | shaper steer `digital.riseRate` 2–4/s, `returnRate` 5–8/s |
|
|
65
|
+
| Stick feels twitchy on centre | shaper steer `analog.curve` 1.3–2, `analog.deadzone` 0.05–0.12 |
|
|
66
|
+
| Sluggish, boat-like | `yawInertiaIndex` ↓, `peakSlipAngle` ↓ (stiffer tire), `comHeight` ↓ |
|
|
67
|
+
| Handbrake does nothing / spins every time | `handbrakeGrip` ↓ / ↑, with `selfAlign` to set how it recovers |
|
|
68
|
+
| No top speed ceiling / wrong ceiling | `aero.dragArea`, power (`maxPower` or torque × gearing); `speedLimit` only for a hard governor |
|
|
69
|
+
| Boost or supersonic above the cap | `modifiers.thrust` (bypasses tires and the governor) |
|
|
70
|
+
| Floaty, wallowy body | `suspension.springRate` ↑, `damperRate` ↑ |
|
|
71
|
+
| Harsh, skips over bumps | `springRate` ↓, `travel` ↑ |
|
|
72
|
+
| Landings bounce back into the air | `suspension.reboundRate` ↑ (defaults to 1.5 × `damperRate`), `travel` ↑ |
|
|
73
|
+
| Leans too much / feels flat | `suspension.antiRoll` ↑ / ↓; `rollStiffnessFront` still sets which axle takes it |
|
|
74
|
+
| Jump too floaty / too short | `jump.speed` (apex ≈ speed² / 2g); `jump.count: 2` plus `window` for a double jump |
|
|
75
|
+
| Air rotation too twitchy / sluggish | `air.*Accel` for how fast it builds, `air.maxRate` for the ceiling, `air.damping` for how fast it stops |
|
|
76
|
+
| Bike wheelies on launch / flips over the bars braking | `assists.antiWheelie` / `assists.antiStoppie` ↑ (1 keeps both wheels down; 0.6 lets it lift briefly) |
|
|
77
|
+
| Bike tips in lazily / nervously | `lean.leanRate` ↑ / ↓ (2 tourer … 5 sport); `lean.countersteer` sets the wrong-way tip at the start |
|
|
78
|
+
| Rolls over in corners | `comHeight` ↓ or `trackWidth` ↑: it tips over at about `trackWidth / (2 · comHeight)` g |
|
|
79
|
+
|
|
80
|
+
## Traps
|
|
81
|
+
|
|
82
|
+
- Hand-editing `yawRate` or heading to "fix" a turn defeats the model. Change a physical knob instead.
|
|
83
|
+
- `speedLimit` fades drive out and never clamps velocity. A car pinned at its cap can still turn, and thrust can carry it past the cap.
|
|
84
|
+
- A slide that looks right on a gamepad can spin on a keyboard. Assert `powerSteerSpun` and `spun` with the default `stepSteer: 1`, which is a held key.
|
|
85
|
+
- For multiplayer, send inputs, not poses. Predict locally with the same tuning, and on a correction call `restore(serverState)` and replay the buffered inputs.
|
|
@@ -45,7 +45,7 @@ editor/preset-written scene data (`environmentContentFromDocument`), not a world
|
|
|
45
45
|
|
|
46
46
|
`terrain()`'s `material` (a named palette — `"grass" | "sand" | "snow" | "rock" | "ash"`, resolved via `resolveTerrainPalette`/`TERRAIN_MATERIAL_PALETTES` in `world/terrain`) sets the default low/high/waterline colors; `colors: { low?, high?, waterline? }` overrides any of them field-by-field, and `segments` tunes the render mesh's subdivision. `biomeBands: BiomeBand[]` (`{ z, fade?, material?, colors? }`) cross-fades the **ground palette along the world's z axis** — ordered zones (vale → marsh → peaks) that blend across a `fade`-wide window (default 64) centered on each boundary; it's the linear counterpart to the radial `materialRegions` (which paint on top; each region is a `TerrainCircleRegion` (default), `TerrainPolylineRegion` (roads/rivers), or `TerrainRectRegion` (rotatable districts), sharing a `TerrainRegionStyle`). Pure/testable via `createTerrainPaletteSampler`/`createBiomeBandSampler` in `world/terrain`; the shell renders it through the same per-vertex `paletteAt` seam as regions, no extra wiring. (Per-zone **fog/sky** cross-fade isn't wired yet — three.js fog is scene-global — so bands drive ground color today.) `flatten: TerrainFlattenMask[]` (`{ center, radius, height?, falloff? }`) carves explicit flat circles into the noise field independent of pads — building foundations, spawn circles, roads — blending back to the noise height over `falloff` (default `radius * 0.5`). `detail: TerrainDetailConfig` (`{ rockColor?, sandColor?, snowColor?, rockSlopeStart?, snowHeight?, waterLevel?, detailScale?, macroScale?, roughness?, strength? }`) swaps the flat vertex-colour ground for a procedural noise shader (`@jgengine/shell`'s `createTerrainDetailMaterial`, defaults via `resolveTerrainDetail`): it keeps the biome-tinted base and blends noise-broken **rock by slope**, **sand by waterline**, and **snow by height** over it — textured-reading terrain with no image assets, full PBR (lit/shadowed/fogged), composing with the post-processing chain. Omit for the flat look. `detail.material: { maps, repeat?, strength? }` (`TerrainDetailMaterialConfig`) is the real-texture seam: `maps` is `buildMaterialCatalog({ basePath }).resolve("material/grass")!.maps` from `@jgengine/assets` (or any URLs with the same `{ color, normal, roughness, ao, displacement }` shape) — `resolveTerrainDetail` fills `repeat` (world units per tile, default 4) and `strength` (blend 0..1, default 1), and the shell's `createTerrainDetailMaterial(detail, textures)` tiles the loaded color/roughness/ao/normal by world position and blends them over the same procedural rock/sand/snow result, never replacing it. The shell resolves the map URLs to `THREE.Texture`s itself (`useTexture`/`useLoader`, e.g. in the game's `environment` component) — `core` only ever sees the URL strings. Detail noise is coordinate-stable at any world position — the fragment hash is integer/position-based, so `detailScale` keeps full fine detail far from origin (a playfield at `|x|≈550` reads the same as one near origin; the old sin-hash flattened past `worldPos/scale ≳ 100`). `sky({ preset?, timeOfDay?, horizonColor?, zenithColor?, sunIntensity?, ambientIntensity?, radius?, hazeStrength?, sunGlowStrength?, fog? })` — `preset: "day" | "dusk" | "night"` (default `"day"`) is the static look; `timeOfDay: true` instead drives sun position, sky colors, and fog from the world clock's `calendar().dayFraction` every frame (`@jgengine/shell`'s `TimeOfDayDaylight` mounts this automatically for an `environment()` world with `sky` set — no per-game render wiring). The sky dome renders a bright HDR sun disc + warm glow (aligned to the sun light, so it blooms through a `postProcessing` chain), a mid-sky procedural cloud band, and a horizon haze — no HDRI assets; richer than a flat gradient with zero config. The dome **follows the camera every frame** (XZ), so it never leaves the viewer — but its `radius` (default 260) still has to clear your `fog.far` and any far scenery; a playfield sited far from the origin (or with distant landmarks) sets `radius` up to a few thousand so nothing pokes through the shell. `hazeStrength` (default 0.5) sizes the dusty horizon band and `sunGlowStrength` (default 1) the bloom around the sun disc — leave both unset for the stock look, raise/lower to dial a dusk/dust-storm horizon (the-robots uses `radius: 2600, hazeStrength: 0.62, sunGlowStrength: 0.6`). No game needs its own sky-dome mesh; configure `sky()` instead. **Gotcha:** `sunIntensity` / `ambientIntensity` are honored only on the **day** keyframe (`daylightCycle` builds dusk/night/dawn from fixed constants). Raising intensities under `preset: "dusk"` or `"night"` does nothing — use `preset: "day"` plus warm `horizonColor`/`zenithColor` when the first screenshot must be bright and readable (see `jgengine`'s first-shot art recipe).
|
|
47
47
|
|
|
48
|
-
**
|
|
48
|
+
**Look presets.** Unset `look` is `"neutral"`; `defineGame({ look: "cinematic" | "photoreal" | "comic" | ... })` gives a 3D game a real sky + shadow-casting sun/hemisphere rig + a tuned post stack. The preset **defers to a world that declares its own `sky`** — an `environment({ sky })` world keeps exactly the dome/time-of-day/lighting it configured; the preset only supplies a sky (and the post stack) to worlds that have none. So `sky()` tuning below still owns the look for scenes that set it. Full rig + resolver: `jgengine-ui` → "Rendering".
|
|
49
49
|
|
|
50
50
|
**Flatten-mask composition semantics** (`withFlattenMasks` — easy to fight if guessed): masks apply **sequentially and the last matching mask wins outright**; there is no mask-to-mask blending. Inside `radius` the height becomes that mask's `target` exactly; in the `radius..radius+falloff` annulus it lerps from `target` back to the **raw noise height**, never to another mask's output. `target` defaults to the raw noise height at the mask's own center (`height` overrides). Consequences to know before composing roads or multi-site layouts: (1) a chain of default-height circles stair-steps, because each snaps to its own center's noise — give road masks explicit `height` values ramped between the endpoint site targets; (2) a later mask's falloff annulus punches raw-noise bumps through an earlier flat area — order masks so the surface that must win comes last, keep road `falloff` small (≤ radius), and skip road circles inside an already-flat site radius; (3) raw noise rarely herds — to guarantee canyon walls, emit masks with `height` *above* the surroundings flanking the corridor. A working road-ramp recipe (overlapping circles every ~12 units, height ramped between site targets, ramp clamped flat inside each site radius, wall masks alongside) lives in `Games/the-robots/src/game/world/level.ts`.
|
|
51
51
|
|
|
@@ -109,6 +109,15 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F
|
|
|
109
109
|
| `createPlotPermissions({ plotId, ownerId, guildId? })` + `createContributionPool(goal)` | Per-plot/guild edit authority (`canEdit`/`canView`, `grant`/`revoke` `BuildRole`, guild inheritance) for co-op building, plus a pooled-resource contribution model (`contribute` caps at the goal, reports overflow, `isComplete`, per-contributor totals). |
|
|
110
110
|
| `createFootprintGrid({ cellSize? })` (`world/footprintGrid`) | Multi-cell footprint occupancy/reservation on a shared build grid — `cellsFor(origin, footprint, quarterTurns?)` derives the cells, `reserve(id, kind, cells)`/`release(id)`/`isFree`/`occupantAt`/`kindAt` hold the persistent claim `placementController`'s ghost preview doesn't. `footprintObstacles(grid)` bridges live reservations into `world/placement`'s `PlacementRules.obstacles` unchanged — no separate obstacle bookkeeping. `boundaryNeighbors(grid, cells)`/`hasValidAdjacency(grid, cells, accepts, requireConnection?)` validate connective-piece adjacency (roads/pipes/belts): every touching occupied neighbor must satisfy `accepts(kind)`, and `requireConnection` demands at least one (a road segment with nothing to connect to is invalid). |
|
|
111
111
|
|
|
112
|
+
### World solids: collision for generated and studio geometry
|
|
113
|
+
|
|
114
|
+
`ctx.world.solids` (`world/worldSolids` `WorldSolids`) is the static collision for world geometry that is not a scene object. It holds oriented boxes (`WorldSolid { center, halfExtents, rotationY? }`) in named layers. The player walk resolver and walking NPCs read it through `solidObstaclesNear`/`resolveWalkerStep`, `syncWorldColliders` mirrors it into a physics backend as static bodies, and `populateNavGridFromSolids` blocks it on a nav grid. Scene objects stay in `ctx.scene.object`; do not re-author generated buildings as placed objects to make them solid.
|
|
115
|
+
|
|
116
|
+
- `environment({ structures: building(...) })` fills the `environment:structures` layer at context creation. `building({ solid: false })` keeps a set render-only.
|
|
117
|
+
- Studio kinds declare collision with a `solids(resolved, object, params, context)` hook on `registerSceneKind`. `city` implements it for wall and roof pieces, with a `solid buildings` toggle in the inspector (`meta.solid`). `AuthoredScene` calls `syncAuthoredSolids(ctx.world.solids, document, sampleHeight)`, one `authored:<objectId>` layer per object; a headless host calls it itself.
|
|
118
|
+
- For code-built geometry, derive solids from data and `set` a layer: `buildingSolids(buildings, groundHeight)`, `wallSolids(wallSegments(points, closed), { height, thickness })`.
|
|
119
|
+
- A yawed solid reaches walkers as AABB strips along its long axis (`obstacleFromSolid`); physics backends get the exact oriented box.
|
|
120
|
+
|
|
112
121
|
### Physics world (optional, headless)
|
|
113
122
|
|
|
114
123
|
`physics/physicsWorld` `PhysicsWorld` is a standalone fixed-capacity rigid-body sim (SoA buffers, spatial-hash broadphase, sleeping) — **not** the `defineGame` `physics: { gravity, jumpVelocity }` field, which the built-in walk controller reads directly every frame (see "Controller kinematics" above; both values are real and honored, not dead config). Reach for `PhysicsWorld` when a game needs many colliding dynamic bodies (piles, debris, stress scenes): `new PhysicsWorld({ capacity, bounds, … })`, `addBody({ position, mass?, ...shape })`, then `step(dt)` per tick → `PhysicsStats`. Core owns the sim; `@jgengine/shell/world/InstancedBodies` renders its bodies. Most games never need it — the character controller covers ordinary movement. The broadphase grid (`nx*ny*nz` cells from `bounds`/`cellSize`) throws at construction if it would exceed a sane cell cap — shrink `bounds` or raise `cellSize` (same guard on `physics/spatialGrid`'s `SpatialGrid`).
|
|
@@ -134,9 +143,17 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F
|
|
|
134
143
|
**Group movement (`movement/formation` + `movement/avoidance`).** Three separable seams keep "where the group stands" apart from "how each member gets there" so a game can pair them with any route source. `placeFormation(destination, facing, count, generator)` maps a `FormationSlotGenerator`'s local `[right, forward]` offsets into world XZ slots around a target — formation shape is **caller data**, not an engine enum; `lineFormation`/`boxFormation`/`wedgeFormation`/`circleFormation` are sample generators and a custom one needs no engine edit. `assignFormationSlots(members, slots, { previous?, stickiness? })` is deterministic greedy nearest matching (`assignment[member] = slot`, `-1` when short on slots); `stickiness` biases a member toward its previous slot to cut per-frame reshuffle. `facingYaw(from, to)` orients a formation along its travel heading. Then `resolveLocalAvoidance(agents, { iterations?, strength?, padding?, weights? })` pushes overlapping circular agents apart in place — a bounded, deterministic Jacobi relaxation over an internal 3×3 hash grid (never an O(n²) whole-world scan), honoring per-agent radii and inverse-mass `weights` (`0` = pinned anchor/obstacle). Route planning (nav path, flow field, direct seek) stays the caller's; this owns slot geometry, matching, and separation only. Squads, parties, convoys, and non-formation crowds all compose the same three calls.
|
|
135
144
|
### Vehicles, mounts, crash damage & racing
|
|
136
145
|
Five primitives layer a driving/racing game over the physics sim and `world/water`. All are **data-first** (spec the chassis/wheels/grip curve, damage thresholds, and checkpoint layout as catalog data) and pure `@jgengine/core`; renderers live in the game/shell. Each `update(dt, …)` runs **before** the shared `world.step(dt)`.
|
|
146
|
+
- **Input buffer — `input/inputBuffer`.** `createInputBuffer({ windowMs })` remembers presses with caller-supplied ms: `press`/`release`, `consume(action, nowMs, windowMs?)` (spends a recent press once), `coyote(groundedAtMs, nowMs, windowMs)`, `hold`, `doubleTap`, `retune`, `snapshot`/`restore`. The walk controller keeps one per player for `movement.feel.jumpBufferMs` and `coyoteMs` (kinematic walk only; the capsule and voxel controllers jump on the press frame), and `snapshotPlayerMovement` carries it.
|
|
137
147
|
- **Analog input — `input/axisInput`.** `AxisInput { throttle, brake, steer, handbrake }` is a continuous channel, **distinct from the digital action bindings**. `new AxisChannel({ bindings, smoothing })` ramps held keys into pedal-like analog values (`sample(dt, isDown)`), or `setAnalog(axis, value)` drives it straight from a gamepad axis. `DRIVE_AXIS_BINDINGS` is a ready WASD/arrow map. `sampleAxisBindings` is the instantaneous, unsmoothed read the channel ramps toward each frame — exposed headlessly as `ctx.input.axis(bindings, ranges?)`, bound to **action names** rather than raw key codes (#533.7).
|
|
138
148
|
- **`physics/vehicleBody`.** `createVehicleBody(world, config)` is an arcade car: a chassis box body with per-wheel suspension held by G3's `springJoint` against the sampled `groundHeight`, drive/brake along the heading, and a `GripCurve` (`sampleGripCurve`) that bleeds lateral velocity for cornering — and, under `handbrake`, drift. `update(dt, axisInput)` then `world.step`. Because the chassis is a real body it still collides, which feeds crash damage. Rocket League, Trackmania, Wreckfest.
|
|
139
|
-
- **`
|
|
149
|
+
- **`movement/playerMovement` rollback.** `snapshotPlayerMovement(ctx, userId)` copies one player's walk state (heading, facing, velocities, jump latch, capsule controller) as plain JSON; `restorePlayerMovement(ctx, userId, snapshot)` puts it back so the next `stepPlayerMovement` replays from there. Restore the entity pose from the entity store alongside it. This is what lets walking characters predict and roll back like `vehicleDynamics`.
|
|
150
|
+
- **`movement/movementProbe`.** `measureMovement({ movement, physics, walkSpeed }, { sprint? })` drives the walk integrator `stepPlayerMovement` uses through a standing start, a release, a reversal, a standing jump and a strafed jump, and returns deterministic feel numbers: `topSpeed`, `timeToTopSpeed`, `stopDistance`/`stopTime`, `turnAroundTime`, `jumpHeight`, `tapJumpHeight` (jump released after `tapSeconds`), `apexTime`, `airTime`, `airControlReach`. The dev runner's `walk-floaty` and `walk-weighty` are two characters built only from these fields. Pass the same `movement.feel` and `physics` fields the game gives `defineGame`, and pin the feel in a test.
|
|
151
|
+
- **`physics/kinematicVehicle`.** `createKinematicVehicle(tuning, options?)` is the no-`PhysicsWorld` sibling of `vehicleBody` — same steer-yaw/accel/grip-curve arcade feel, driven purely by `tick(dt, axis, modifiers?)` returning `{ position, heading, forwardSpeed, lateralSpeed, slip, surface }`. `surfaceFriction`/`dragAt` hooks (grip-by-position, drag zones) and `clampMove` (walls/arena bounds) keep game flavor out of the sim; `scaleVelocity`/`resetTo` cover boost pads and respawns, and `snapshot`/`restore` cover rollback. Use it for karts, top-down cars and bumper cars, where heading following steer is the point. Its `chassis` and `steering` blocks are deprecated: when handling feel matters, use `physics/vehicleDynamics`.
|
|
152
|
+
- **`physics/vehicleDynamics`.** `createVehicleDynamics(tuning, options?)` is the force-based ground vehicle: a two-axle model with per-axle Pacejka-style tires, load transfer, a friction ellipse, a gearbox or direct drive, aero, and ABS/TC/stability/caster assists. An optional `suspension` block with a `groundHeight` option adds four sprung corners (heave, pitch, roll, anti-roll bars, slope gravity, airborne and landing) for ramps, hills and jumps. Yaw comes from tire forces, so understeer, oversteer, lift-off tuck, power slides and handbrake turns follow from physical knobs in SI units. `tick(dt, axis, modifiers?)` returns pose plus telemetry (sideslip, slip angles, per-axle saturation, loads, rpm, `engineLoad`) for camera, sound and rumble; `snapshot`/`restore`/`retune` for prediction, replay and upgrades. Prefer it over `kinematicVehicle` whenever handling feel matters; tune it with [recipes/vehicle-feel.md](recipes/vehicle-feel.md).
|
|
153
|
+
- **`physics/boatDynamics`.** `createBoatDynamics(tuning, { waterHeight?, current? })` is the powered hull on the same step contract as `vehicleDynamics`: throttle drives the prop, brake reverses it, steer swings a `rudder` (lift from flow, so no turning at rest without prop wash) or an `outboard` (swings its thrust, turns at rest). Drag humps at hull speed; `planingSpeed` lets the hull climb over it and sheds keel grip so it turns more freely. The step carries `planing` and `engineLoad` for wake, spray and engine sound; `snapshot`/`restore`/`retune` as usual, and `measureHandling` reads it directly.
|
|
154
|
+
- **`physics/aircraftDynamics`.** `createRigidAircraft(tuning, { wind?, groundHeight?, gravityField?, airDensity? })` is the force-and-torque aircraft: a quaternion rigid body with an inertia tensor (kg·m²), pushed by lifting `surfaces` (area, lift slope, stall angle, post-stall lift, drag polar `cd0` + `k·CL²`, placed at a body-frame point), an `engine` with spool, fuselage `dragArea` and optional `gear`. Controls deflect surfaces through rate-limited actuators (`control: { pitch, roll, yaw }` is AoA change per rad, signed from the lever arm), so loops, rolls, stalls and weathervaning come from where the surfaces sit. The step reports attitude (`heading`/`pitch`/`bank`, quaternion), body rates, `airspeed`, `angleOfAttack`, `sideslip`, `gLoad`, `stalled`; `snapshot`/`restore`/`retune` and per-tick `modifiers` as usual. A `rotor` block makes it a helicopter: collective thrust with rotor spool, cyclic disc tilt, drag torque that yaws the body unless a `tail` rotor cancels it, translational lift and ground effect from momentum theory, with `step.rotor` telemetry. A `motor` block makes it a rocket: a thrust curve over burn time, propellant that leaves at a mass flow so mass and inertia fall through the burn, a gimballed nozzle, and staging by `retune` to the next stage. An `assists` block drives the same actuators: per-axis SAS with attitude and heading hold, auto-level, AoA and g limiters, helicopter hover hold, and a `policy` callback that has the last word. `measureFlight(create, options?)` in `physics/handlingProbe` returns `rollRateDeg`, `sustainedTurnRateDeg`, `stallSpeed`, `climbRate`, `throttleResponse` and `hoverDriftMeters` for tests. Tune it with [recipes/flight-feel.md](recipes/flight-feel.md).
|
|
155
|
+
- **`physics/vehicleBackendLink`.** `createVehicleBackendLink(backend, { halfExtents, blockMask? })` puts a vehicle sim into a `PhysicsBackend` world while the sim keeps owning handling. Pass `link.clampMove` as the sim's `clampMove`, so walls and parked cars stop it and it slides along them. Call `link.sync(step)` after each tick so a kinematic chassis follows the car and shoves dynamic props; put props on a layer outside `blockMask` so they get shoved instead of stopping the car. `lastHit()` reports the wall, normal and blocked distance for crash damage and sound.
|
|
156
|
+
- **`physics/handlingProbe`.** `measureHandling(create, options?)` runs any vehicle sim (`VehicleDynamics` or `KinematicVehicle`) through launch, braking, a steer ramp, a step steer, a lift-off, a handbrake pull and full-throttle-full-steer, and returns deterministic feel metrics (`zeroTo100`, `maxLateralG`, `turnIn`, `stepPeakSideslipDeg`, `spun`, `powerSteerSpun`, …) for tests. `measureCourse(create, { wheelbase })` drives a steer ramp, a skidpad and a slalom with simple deterministic drivers and returns `understeerGradient` (deg/g), `skidpadG` and `slalomSpeed`, and `measureHandling` also reports `handbrakeRecoverySeconds`. A `lean` block makes the sim a motorcycle: steer asks for a lean, the bar steers the balanced turn with a countersteer tip-in, and the step reports `lean` (balance lean) plus `wheelie`/`stoppie`; `measureLean` gives steady lean, time to lean and counter-lean. `measureAir(create)` reports jump apex height and time, double-jump height and air rotation rates for a car with `jump`/`air` blocks. `measureRide(create)` does the same for a sprung car (`suspension` block): `settleSeconds`, `heaveOvershoot`, `brakeDiveDeg`, `rollGradient` (deg/g), `landingBounces`.
|
|
140
157
|
- **`physics/drivableVehicle`** (#533.1). `tickDrivableVehicle(vehicle, dt, axis, { groundHeight?, modifiers? })` is the missing wire from an `AxisInput` sample through a `KinematicVehicle` to a `setPose`-ready entity patch — the throttle/steer/handbrake → sim → `entities.setPose(id, drive.pose)` loop every drivable-vehicle game hand-rolled, one call instead of a hand-copied per-game `stepCar`. `groundHeight` snaps the resulting `y` to terrain each tick; omit it to keep the sim's flat `y`. Returns `{ pose, step }` — `step` is the raw `KinematicVehicleStep` for HUD speed/slip reads.
|
|
141
158
|
- **`physics/buoyancy`.** `createBuoyantBody(world, { body, water, … })` floats a body on a CPU `WaterSurface` (Archimedes per hull point + water drag) so it settles at the waterline and rides the Gerstner waves; pass an `AxisInput` to `update(dt, time, input?)` and it drives as a boat (thrust + yaw + keel). Sea of Thieves, BOTW rafts.
|
|
142
159
|
- **`scene/mount`.** `createMountController()` transfers control to a driven entity: `register({ id, kit, seats })`, `mount(riderId, mountId, seatId?)`, `dismount`. Read `cameraTarget(riderId)` to point the follow camera and `driveTarget(riderId)` to route that rider's `AxisInput` at the mount — the control seat drives, passenger seats ride (multi-seat shared vehicles), and an un-mounted rider drives themselves. `driver(mountId)`/`occupants(mountId)`/`kitOf`. Palworld mounts, V Rising horse, a crewed ship.
|
|
@@ -273,3 +290,15 @@ Path progress adapter (`nav/pathFollow`): `PathProgress` is `{ kind: "normalized
|
|
|
273
290
|
Compose, don't replace: scale `MobBrainConfig.aggroRadius` by `perceptionScale`, route `acquireTarget` scoring through `pickScored`, gate `advancePursuit` target swaps behind the reaction gate.
|
|
274
291
|
|
|
275
292
|
**Drive** — `ai/driver` is the vehicle consumer of the profile: `driveStep(state, dt, pose, goal, profile, tuning, rng, obstacleAhead?)` turns a pose + goal into the shared `AxisInput` (`throttle`/`brake`/`steer`) for `tickDrivableVehicle`. The profile decides how well the car drives: the steering target is a delay-line resampled every `reactionSeconds` (easy chasers cut to where the target *was*), corners shed speed instead of orbiting at full throttle, obstacle distance is perceived late by `speed × reactionSeconds`, the wheel wobbles by `executionJitter` (resampled on an interval, so it reads as a loose wheel), and a car grinding a wall backs out with counter-steer after a tier-scaled delay (brake past standstill is the sim's reverse). `createDriverState` is one small serializable object; the caller owns the sim, collision, and all spatial queries. For streets and circuits, `pathTargetAhead(path, x, z, lookahead, loop?)` walks a road/route polyline to the pure-pursuit point ahead — feed it back in as the goal each tick and the same brain follows authored roads; give sharper tiers a longer lookahead.
|
|
293
|
+
|
|
294
|
+
### Snapshot and restore on movement and visibility handles
|
|
295
|
+
|
|
296
|
+
`movement/glideModel` `createGlideModel`, `world/lod` `createLodScheduler`, `visibility/simulationCulling` `createSimulationCuller` and `visibility/spatialIndex` `createSpatialIndex` hand back a plain JSON `snapshot()` and take it back with `restore(next)`; `movement/grappleSwing` `createGrappleSwing` pairs its existing `state()` with `restore(state)`. A restored spatial index keeps cell and id order, so queries return ids in the same order.
|
|
297
|
+
|
|
298
|
+
### Snapshot and restore on AI, faction and sensor handles
|
|
299
|
+
|
|
300
|
+
These handles hand back a plain JSON `snapshot()` that later ticks do not mutate, and take it back with `restore(next)`, so a host, save file or replay can rewind them bit-exactly: `ai/threat` `createThreatTable`, `ai/groupAssist` `createAssistNetwork` (membership only; `restore(next, tableOf)` rebinds each member's separately restored threat table), `faction/reputation` `createReputationLedger`, `sensor/concealment` `createConcealmentSensor`, `sensor/freezeMonitor` `createFreezeMonitor` and `sensor/recordingBuffer` `createRecordingBuffer` (frame `data` is shared, not cloned).
|
|
301
|
+
|
|
302
|
+
### Snapshot and restore on building handles
|
|
303
|
+
|
|
304
|
+
`world/footprintGrid` `createFootprintGrid` (reservations in claim order; restore rebuilds occupancy), `world/walls` `createWallDrawTool` (points and closed flag) and `world/terraform` `createTerraformBrush` (radius and strength; the terrain keeps its own `snapshot()`) hand back a plain JSON `snapshot()` and take it back with `restore(next)`. `world/interiors` `createInteriors` holds config only, so it has nothing to save.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Visibility & Streaming
|
|
2
|
+
|
|
3
|
+
Automatic camera-frustum culling, distance culling, spatial partitioning, and asset streaming
|
|
4
|
+
for every game — no per-game setup. A normal object with a position and a version counter is
|
|
5
|
+
culled automatically; you only touch this system to *override* the defaults.
|
|
6
|
+
|
|
7
|
+
This package (`@jgengine/core/visibility/*`) is headless and zero-dependency. The shell wires it
|
|
8
|
+
into the R3F render path (`packages/shell/src/visibility/CullingProvider.tsx`), which reads the
|
|
9
|
+
live camera each frame and toggles `group.visible` so off-screen objects are never submitted to
|
|
10
|
+
the renderer.
|
|
11
|
+
|
|
12
|
+
## What happens automatically
|
|
13
|
+
|
|
14
|
+
- **Frustum culling** — objects fully outside the active camera's view are not rendered. Uses
|
|
15
|
+
object bounds (sphere + AABB), not origins. Works for 3D perspective and 2D orthographic cameras.
|
|
16
|
+
- **Distance culling** — objects past a max render distance are dropped (square-root-free). The
|
|
17
|
+
default max is `Infinity`, so nothing is culled by distance until a game opts in.
|
|
18
|
+
- **Spatial partitioning** — a uniform 3D hash (`SpatialIndex`); the renderer queries potentially
|
|
19
|
+
visible objects near the frustum instead of scanning the whole scene. Moving objects rewrite only
|
|
20
|
+
the cells that changed; static objects are inserted once.
|
|
21
|
+
- **Bounds caching** — bounds recompute only when an object's `version` advances (transform,
|
|
22
|
+
geometry, or override change), reusing the cached object so steady-state frames allocate nothing.
|
|
23
|
+
|
|
24
|
+
Every game improves immediately after upgrading, because defaults favor visual stability: a preload
|
|
25
|
+
margin larger than the view, hysteresis so objects don't flicker at the boundary, and a large
|
|
26
|
+
default render distance.
|
|
27
|
+
|
|
28
|
+
## Defaults
|
|
29
|
+
|
|
30
|
+
| Setting | Default | Why |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `preloadMargin` (culling) | 24 world units | Preload/consider region beyond the visible frustum |
|
|
33
|
+
| `hysteresis` | 2 world units | Re-test band for already-visible objects (no flicker) |
|
|
34
|
+
| `defaultMaxRenderDistance` | `Infinity` | Never distance-cull unless a game opts in |
|
|
35
|
+
| `occlusionCulling` | `false` | Off until proven reliable for the renderer |
|
|
36
|
+
| streaming `preloadMargin` | 32 | Assets load ahead of visibility |
|
|
37
|
+
| `unloadGraceSeconds` | 10 | Grace period before an idle asset is unloaded |
|
|
38
|
+
| `maxLoadsPerFrame` / `maxUnloadsPerFrame` | 4 / 2 | Per-frame budgets to avoid spikes |
|
|
39
|
+
| `keepResidentBytes` | 64 KiB | Small shared assets stay resident |
|
|
40
|
+
|
|
41
|
+
## How to opt out / override
|
|
42
|
+
|
|
43
|
+
Set `PlayableGame.visibility`:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
visibility: {
|
|
47
|
+
enabled: false, // disable render culling entirely
|
|
48
|
+
culling: { defaultMaxRenderDistance: 200 },
|
|
49
|
+
scene: { preloadMargin: 60 }, // scene-wide override
|
|
50
|
+
entities: { boss: { alwaysVisible: true } }, // by entity kind
|
|
51
|
+
objects: { beacon: { neverUnload: true } }, // by object catalog id
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Override precedence (earlier wins): **per-object → per-layer → per-scene → global**. Available
|
|
56
|
+
controls: `alwaysVisible`, `neverUnload`, `minRenderDistance`, `maxRenderDistance`, `preloadMargin`,
|
|
57
|
+
`cullingDisabled`, `streamingDisabled`, `classification` (`static`/`dynamic`), `bounds` (custom),
|
|
58
|
+
`customVisibility` (callback), `pinned`.
|
|
59
|
+
|
|
60
|
+
## Override object bounds
|
|
61
|
+
|
|
62
|
+
Pass a `bounds` override — `sphere`, `aabb`, `rect` (2D), or `point`, each with an optional `offset`:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
entities: { tower: { bounds: { kind: "aabb", half: [2, 8, 2], offset: [0, 8, 0] } } }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Pin assets
|
|
69
|
+
|
|
70
|
+
An asset is never auto-unloaded when it is `pinned`, retained (shared by an active object), or
|
|
71
|
+
required by the current scene. `AssetStreamingSystem.pin(id)` / `retain(id)` from game code; or set
|
|
72
|
+
`neverUnload` / `pinned` on the object override so its assets stay resident.
|
|
73
|
+
|
|
74
|
+
## Multiple cameras
|
|
75
|
+
|
|
76
|
+
The `VisibilitySystem` unions visibility across every active `CameraVisibilityContext` — split-screen,
|
|
77
|
+
render-to-texture, editor, and minimap cameras all keep an object renderable if any of them needs it.
|
|
78
|
+
A camera can set `influencesStreaming: false` to stay out of asset preloading (e.g. a minimap that
|
|
79
|
+
only needs positions), or `cullingDisabled: true` to render everything.
|
|
80
|
+
|
|
81
|
+
## Render culling vs asset streaming vs simulation culling
|
|
82
|
+
|
|
83
|
+
- **Render culling** decides what is *drawn*. It never destroys entities or disables simulation.
|
|
84
|
+
- **Asset streaming** decides what is *loaded*. An object may be loaded but not rendered, or inside
|
|
85
|
+
the preload region but outside the render region.
|
|
86
|
+
- **Simulation culling** (`createSimulationCuller`, opt-in, off by default) decides whether a
|
|
87
|
+
low-priority off-screen entity *updates* this tick. It never throttles a protected entity
|
|
88
|
+
(physics/network/audio/scripted/active). Gameplay correctness must not depend on visibility.
|
|
89
|
+
|
|
90
|
+
## Diagnostics
|
|
91
|
+
|
|
92
|
+
`VisibilitySystem.stats()` reports total objects, considered/rejected counts (frustum, distance,
|
|
93
|
+
occlusion), draw calls avoided, asset queue/loading/loaded/unloaded counts, streamed bytes, and
|
|
94
|
+
spatial-query / culling timings. `debugSnapshot()` returns camera frustum corners, object bounds,
|
|
95
|
+
spatial partitions, culled ids, and streaming state for a debug overlay to draw.
|
|
96
|
+
|
|
97
|
+
## Architecture
|
|
98
|
+
|
|
99
|
+
`BoundsCache` · `SpatialIndex` · `frustum` (view volumes) · `distance` · `OcclusionTester` ·
|
|
100
|
+
`CullingSettings`/`StreamingSettings` (+ layered `resolveOverrides`) · `CameraVisibilityContext` ·
|
|
101
|
+
`VisibilitySystem` (orchestrator + debug snapshot) · `AssetStreamingSystem` · `SimulationCuller`.
|
|
102
|
+
|
|
103
|
+
## Known limitations
|
|
104
|
+
|
|
105
|
+
- **Occlusion culling** ships as an interface plus a conservative bounding-volume tester, disabled
|
|
106
|
+
by default. It is not a hierarchical-Z implementation; enable only where large blockers exist.
|
|
107
|
+
- **Instanced batches** in the shell (`world/Instanced*`, `SpriteBatch`) set `frustumCulled={false}`
|
|
108
|
+
and are not yet per-instance culled by this system — a documented future extension.
|
|
109
|
+
- **Asset-streaming shell wiring**: the engine `AssetStreamingSystem` is complete and tested, but the
|
|
110
|
+
shell's R3F loaders still load on mount; wiring streaming into R3F suspension is the next seam.
|
|
111
|
+
- The spatial index partitions on all three axes; extremely oversized objects fall back to an
|
|
112
|
+
always-considered bucket by design.
|
|
@@ -11,7 +11,7 @@ Own the spatial realization of game design: player metrics, topology, routes, la
|
|
|
11
11
|
|
|
12
12
|
All authorable world content belongs in `editor.scene.json`, changed through the editor GUI or RPC/CLI. Never hand-edit the document or keep geometry, paths, spawns, zones, objectives, terrain, foliage, encounters, or coordinate arrays in code. File an editor `[FEATURE]` issue before any fallback.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
For a greenfield build, read [references/application-playbook.md](references/application-playbook.md) §1 and produce its metric sheet, topology graph, beat sheet, and encounter cards before art; §3–5 hold the templates. Audits may read only the playbook's audit sections. Read [references/live-editor-loop.md](references/live-editor-loop.md) when a separate editor agent or live editor session applies the changes, or when repairing an existing scene from captures. Read [references/field-guide.md](references/field-guide.md) for metric families, topology, pacing, genre lenses, accessibility, and research.
|
|
15
15
|
|
|
16
16
|
## Choose the mode
|
|
17
17
|
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
# Visual scorecard
|
|
2
|
-
|
|
3
|
-
This repeatable 0–3 rubric makes "looks shipped" consistent across sessions and usable by a fresh-eyes reviewer. Apply it to milestone shots, not every tweak, and place the scored table beside PR screenshots.
|
|
4
|
-
|
|
5
|
-
Structure and thresholds adapted (concept only, reworded for this stack — not copied verbatim) from `majidmanzarpour/threejs-game-skills`' `visual-scorecard.md` (MIT); see `CREDITS.md`.
|
|
6
|
-
|
|
7
|
-
## The 0–3 scale
|
|
8
|
-
|
|
9
|
-
Score each category independently against what's actually in the shot, not what the code intends.
|
|
10
|
-
|
|
11
|
-
| Score | Meaning |
|
|
12
|
-
| --- | --- |
|
|
13
|
-
| **0** | Engine defaults or debug output. Untextured primitives, default grey material, flat ambient light, no sky/fog treatment, no authored placement. |
|
|
14
|
-
| **1** | Basic styled prototype. Readable and on-genre, but generic — solid-color materials, one light, sparse or untuned `AuthoredScene` content, no distinct silhouettes. |
|
|
15
|
-
| **2** | Premium stylized. Authored silhouettes on hero/obstacles, a coherent material system (not one grey PBR everywhere), tuned `environment()` lighting/sky/post, foliage and props placed through the editor read as a real place, not a demo. |
|
|
16
|
-
| **3** | Showcase art direction. Everything in 2, plus: no repeated silhouette anywhere in frame, deliberate foreground/midground/background composition, purposeful VFX and motion, a HUD that could not be mistaken for another game. Reads like a store page, not a build. |
|
|
17
|
-
|
|
18
|
-
## The 10 categories
|
|
19
|
-
|
|
20
|
-
Score all ten from one `--mode play` shot plus deterministic world/document evidence from `jgengine-verify`. Data presence is a prerequisite, not a substitute for visual judgment.
|
|
21
|
-
|
|
22
|
-
1. **Art direction** — one coherent palette and mood across ground, props, sky, and UI; not three unrelated hue choices stapled together.
|
|
23
|
-
2. **Hero/player** — an authored silhouette and material set the player is meant to be, not a default primitive or a capsule with an emissive glow standing in for a character.
|
|
24
|
-
3. **Obstacles/enemies** — distinct types read apart at a glance; not one mesh reused at different scales/colors.
|
|
25
|
-
4. **Rewards/interactables** — visually distinct from hazards and terrain at a glance, not the same proxy geometry as everything else.
|
|
26
|
-
5. **World/environment** — foreground, midground, and background read as separate layers (props/landmarks near camera, terrain shaping mid-distance, sky/fog treatment at the horizon); not one flat plane running to a bare horizon.
|
|
27
|
-
6. **Materials/textures** — terrain and props carry texture and color variation (`environment({ sculpt, materialRegions })`, catalog GLBs via `jgengine-assets`), not flat untextured solids.
|
|
28
|
-
7. **Lighting/render** — a tuned daylight/sun+hemisphere rig and post stack (per `look: "cinematic"`, #773), not the bare untouched three.js default light.
|
|
29
|
-
8. **VFX/motion** — particle/motion feedback tied to actual gameplay events (hits, pickups, weather), not absent, and not one looping default effect standing in everywhere.
|
|
30
|
-
9. **UI/HUD** — a game-specific `HudCanvas`/`HudPanel` composition with unique art direction; fails automatically if it could pass for a generic admin dashboard **or** for another game shipping stock engine widgets/theme presets unskinned.
|
|
31
|
-
10. **Performance evidence** — the look holds at the scale the game actually runs (`InstancedScatter` density, many entities), not only a hand-posed hero shot with three objects in frame.
|
|
32
|
-
|
|
33
|
-
## Measured-evidence row
|
|
34
|
-
|
|
35
|
-
Pixel metrics from `bun run shoot <id> --mode play --inspect` (or `bun run inspect-shot.ts <shot.png>` on an existing PNG — [#788](https://github.com/Noisemaker111/jgengine/issues/788), shipped) are the objective inputs to categories 5–7 — they don't replace the eye, they catch what a rushed glance misses. Output lands at `shots/<name>.metrics.json`:
|
|
36
|
-
|
|
37
|
-
| Metric | Threshold | Flags |
|
|
38
|
-
| --- | --- | --- |
|
|
39
|
-
| `colorEntropyBits` | `< 3.0` | sparse palette |
|
|
40
|
-
| `dominantColorShare` | `> 0.6` | sparse / one material dominates the frame |
|
|
41
|
-
| `edgeDensity` | `< 0.04` | primitive-dominant, too few silhouettes |
|
|
42
|
-
| `luminance.contrast` | `< 60` | fog/post compressing out real geometry |
|
|
43
|
-
|
|
44
|
-
Any metric over threshold caps the affected category at **1** regardless of how the shot reads by eye — the number is a floor, not a veto the eye can talk it out of. `--strict` hard-fails on a crossed threshold for CI; the scorecard itself always records this row from the JSON, never "not measured."
|
|
45
|
-
|
|
46
|
-
## Pass thresholds
|
|
47
|
-
|
|
48
|
-
- **Premium**: every category ≥ 2, average ≥ 2.3.
|
|
49
|
-
- **Showcase**: at least 6 categories at 3, no category < 2, average ≥ 2.7.
|
|
50
|
-
|
|
51
|
-
Below "premium" on a claimed milestone is not shippable; keep working the art stack or narrow the claim.
|
|
52
|
-
|
|
53
|
-
## Automatic failures
|
|
54
|
-
|
|
55
|
-
Any one of these fails the shot regardless of the averaged score:
|
|
56
|
-
|
|
57
|
-
- **Primitive-dominant shots** — untextured boxes/spheres/cylinders make up the visible mass of the frame.
|
|
58
|
-
- **Default primitive + glow standing in for a hero** — an emissive-glow capsule/sphere is not a character, even at full brightness.
|
|
59
|
-
- **One repeated silhouette everywhere** — `InstancedScatter`'s stylized proxy species (or any single catalog model) used with no palette variety across the whole visible world.
|
|
60
|
-
- **Fog/bloom hiding missing geometry** — atmosphere used to obscure an empty or unfinished scene instead of to add depth to a populated one.
|
|
61
|
-
- **No active-play shot** — only editor/menu/poster shots submitted. `--mode play` (or `drive` past the menu) evidence is mandatory; a scene that only looks good in the editor camera hasn't proven anything about how the game actually reads.
|
|
62
|
-
|
|
63
|
-
## Fresh-eyes review and the lower-score rule
|
|
64
|
-
|
|
65
|
-
Hand a reviewer **only** the screenshots, this rubric, and the measured-evidence JSON — no PR description, no "here's what I was going for." A reviewer who knows the intent scores the intent; a fresh reviewer scores the pixels, which is the only thing a player ever sees.
|
|
66
|
-
|
|
67
|
-
- The author scores first (self-score).
|
|
68
|
-
- A fresh-eyes pass (a different session, or the same session after clearing framing context) scores independently.
|
|
69
|
-
- Where the two disagree, **the PR carries the lower score**, never the average and never the higher one. Optimistic self-scoring is the failure mode this guards against — take the lower number and keep working the art stack instead of arguing the rubric.
|
|
70
|
-
|
|
71
|
-
## Using this in a PR
|
|
72
|
-
|
|
73
|
-
Any PR that changes what a player sees pastes the ten-category table (score + one-line reason per category below 2), the measured-evidence row, and the reconciled score next to embedded screenshots. Report that verdict through `jgengine-verify`'s evidence format.
|