@jgengine/shell 0.38.0 → 0.38.2

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 CHANGED
@@ -26,6 +26,53 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.38.2
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.38.2`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.2`; `@jgengine/github` is `0.25.2`.
34
+
35
+ ### Added
36
+
37
+ - `requestSceneCapture` and `requestSceneThumbnail` (`@jgengine/shell/render/sceneCapture`) ask the live scene for a frame or a save-card thumbnail. A request waits for `SceneCaptureBinding` to mount, waits for the canvas to paint, retries a blank read and resolves with the image or a stated failure (`no-binding`, `no-frame`, `undecodable`), so a game stops polling for the binding and waiting frames before a save screenshot.
38
+ - `registerSceneCapture` (`@jgengine/shell/render/sceneCapture`) lets a host that owns its own canvas hand the capture path a reader.
39
+ - `createDownedState` (`@jgengine/core/combat/downed`) exposes `restore(next)`, the counterpart to `snapshot()`, so a saved bleed-out resumes with the same remaining seconds, revive progress, phase and banner timer instead of being rebuilt around a guessed countdown.
40
+
41
+ ### Changed
42
+
43
+ - Pin Games to the 0.38.1 adoption
44
+ - `GameMenu`'s `style` and `detailStyle` accept `--jg-*` custom properties (`GameMenuStyle`), so a game passes `{ "--jg-menu-surface-radius": "12px" }` without a cast.
45
+ - `GameMenu` sets `pointer-events: auto` on its own root while its painted backdrop stays `none`, so a menu inside a HUD wrapper that passes clicks through to the scene no longer has dead buttons.
46
+ - `check-stateful-ratchet` skips a factory carrying `// stateless: <reason>`, the named case for a catalog whose only closure state is an index of its own config. `createBuildSocketCatalog` declares it instead of being baselined.
47
+
48
+ ### Fixed
49
+
50
+ - `bun run shoot` refuses a scene shot whose page has no 3D canvas, and scores rendered geometry inside the canvas region before writing the file. `bun run shoot <game> --view <name>` defaults to `--mode ui`, which renders the game's HUD preview over a CSS backdrop and creates no canvas; the whole-frame flat test was skipped without a region and the backdrop's full-screen paint withheld the viewport verdict, so a HUD-only frame was written with exit 0.
51
+ - `countStaticBatchDraws` (`@jgengine/shell/world/staticObjectBatches`) counts the draws the static batches submit, so a variant level with no instance in a cell and a cell the camera culled are no longer reported as draws. `submitInstanceCount` (`@jgengine/shell/render/submittedDraws`) is the one place the batch, scatter, proxy and authored-primitive paths set an instance count and its visibility, and three issues a draw only when both say there is something to draw.
52
+ - `createCastRunner.state()` carries the cast's `moveTolerance`, `moveDeadzone` and `movedTotal`, and `restore(next)` resumes it. A cast saved mid-fight used to come back with a fresh movement budget, so the caster could walk further than the ability allowed.
53
+ - `createDecayMeterSet.serialize()` / `restore(next)` carry each meter's value, base rate and `setRateModifier` multiplier. A save used to drop a tuned rate, so a cold biome's faster warmth drain reset to the config rate on load.
54
+ - `createAreaEffectField.restore(state)`, `createSelectionBookmarks.restore(snapshot)` and `createPlotPermissions.restore(snapshot)` are the state-in counterparts to their existing `serialize()` / `snapshot()`; restoring into a live field releases the members the saved state does not carry.
55
+ - `check-stateful-ratchet` names the round-trip gap separately and caps it: a `snapshot()` with no `restore()`/`reset()` fails as `round-trip` debt, `--write` refuses to raise that ceiling, and a group paid below its ceiling must lower it. `createDownedState` was baselined when the ratchet shipped and never revisited; the restores in this release take the baseline from 113 entries to 93 and the `round-trip` ceiling from 8 to 2.
56
+
57
+ ## 0.38.1
58
+
59
+ ### Migrate
60
+
61
+ - **Bump lockstep SDK packages to `^0.38.1`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.35.1`; `@jgengine/github` is `0.25.1`.
62
+ - Nothing to migrate. `variantLevels()` was renamed to `ModelVariantConfig.shadows` before any release shipped it, so no published version ever exported it and no consumer can be holding one. A level that wants to stop paying the near level's shadow cost writes `{ url, shadows: "none" }`; leaving `shadows` out keeps the model's own answer.
63
+
64
+ ### Added
65
+
66
+ - `ModelVariantConfig.shadows` (`@jgengine/core/world/modelLod`): a model variant level declares its own shadow participation as a plain field, `"inherit" | "cast" | "receive" | "both" | "none"`. `"inherit"`, the default, keeps the model's own `shadows`, so every existing model draws as before; a far level set to `"none"` leaves the shadow pass while still drawing its mesh.
67
+ - `ModelShadowMode` (`@jgengine/core/world/modelLod`) names the four-value shadow vocabulary, and `ModelConfig.shadows` uses it.
68
+ - `variantLevelModel` (`@jgengine/shell/render/singleVariantLod`) is the one place that resolves what a level draws and how it shadows; the single prop, batched prop and scatter paths all ask it, so a level's asset and its shadow answer cannot disagree.
69
+
70
+ ### Fixed
71
+
72
+ - `SaveSlotsPane` cards are one box each: equal tracks, `grid-auto-rows: 1fr`, a card edge that reads over a bright scene, and a name that ellipsises instead of widening the card. A save with a long name and one with none produce the same card box.
73
+ - `GameMenu` puts the primary verbs on their own scrim, so their labels stay legible over whatever the live scene is showing instead of relying on a text shadow.
74
+ - The theme emits three menu tokens the slot card and the verb surface read: `--jg-menu-card-height` (`clamp(244px, 27vh, 300px)`), `--jg-menu-card-thumb` (`clamp(88px, 14vh, 160px)`) and `--jg-menu-surface-radius` (`8px`). A game overrides them in its own `HudTheme` instead of patching the menu components.
75
+
29
76
  ## 0.38.0
30
77
 
31
78
  ### Migrate
@@ -17,7 +17,7 @@ import { useFootIk } from "./useFootIk.js";
17
17
  import { resolveRigNode } from "./rigNode.js";
18
18
  import { PartMotionRig } from "./PartMotion.js";
19
19
  import { syncSpriteFrame } from "./spriteRender.js";
20
- import { singleVariantLevel, singleVariantModel } from "./singleVariantLod.js";
20
+ import { singleVariantLevel, variantLevelModel } from "./singleVariantLod.js";
21
21
  import { applyMaterialOverride } from "../materialOverride.js";
22
22
  import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeModelScene, ownModelMaterial, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
23
23
  export function EntitySprite({ sprite }) {
@@ -123,7 +123,7 @@ export function SingleVariantModel({ model, instanceId, measure, }) {
123
123
  if (next !== level)
124
124
  setLevel(next);
125
125
  });
126
- const drawn = useMemo(() => singleVariantModel(model, level), [model, level]);
126
+ const drawn = useMemo(() => variantLevelModel(model, level), [model, level]);
127
127
  // The near level owns the measured bounds and collision mesh, like the batched path: a decimated
128
128
  // asset must not redefine what the catalog entry collides as.
129
129
  return _jsx(EntityModel, { model: drawn, instanceId: instanceId, measure: level === 0 ? measure : undefined });
@@ -0,0 +1,69 @@
1
+ import type * as THREE from "three";
2
+ import type { SceneObject } from "@jgengine/core/scene/objectStore";
3
+ import type { ModelVariantLodConfig, ModelVariantSelector } from "@jgengine/core/world/modelLod";
4
+ import type { SystemProbe } from "@jgengine/core/devtools/types";
5
+ /**
6
+ * Metres of camera travel a path tolerates before it re-runs its level pass when it cannot tell how
7
+ * close an instance sits to a switch edge. The scatter path passes this as its margin; a path that
8
+ * knows the switch distances passes their real margin instead.
9
+ * @internal
10
+ */
11
+ export declare const LEVEL_PASS_MIN_CAMERA_MOVE = 0.5;
12
+ /** Count one frame a level pass was offered. @internal */
13
+ export declare function noteLevelPassFrame(): void;
14
+ /** Count one level pass that ran or was skipped. @internal */
15
+ export declare function noteLevelPass(ran: boolean): void;
16
+ /** Clear the level pass counters. For tests and between capture runs. @internal */
17
+ export declare function resetLevelPassStats(): void;
18
+ /**
19
+ * The level-pass system probe: how many per-instance variant level passes ran, how many the
20
+ * camera-unchanged skip removed, and the per-frame average. `debug_snapshot().systems` names the CPU
21
+ * cost a still or distant camera no longer pays instead of leaving it to a profile.
22
+ * @internal
23
+ */
24
+ export declare function levelPassProbe(): SystemProbe;
25
+ /**
26
+ * True when the camera has moved far enough that some instance's level could differ from the last
27
+ * pass. Moving the camera by `d` changes every instance distance by at most `d`, so a pass whose
28
+ * smallest margin to a switch edge was `margin` cannot change until the camera moves that far. A
29
+ * margin of `0` (an instance sitting on its edge) always runs.
30
+ * @internal
31
+ */
32
+ export declare function levelPassNeeded(last: THREE.Vector3, camera: THREE.Vector3, margin: number): boolean;
33
+ /** Holds the last pass's camera position and its distance to the nearest switch edge. @internal */
34
+ export interface LevelPassGate {
35
+ /** True when this frame's pass could change an answer, or when no pass has run yet. */
36
+ needsPass(camera: THREE.Vector3): boolean;
37
+ /** Record a pass that ran: its camera position and the smallest margin it measured. */
38
+ record(camera: THREE.Vector3, margin: number): void;
39
+ /** Forget the last pass, so the next frame runs one. */
40
+ invalidate(): void;
41
+ }
42
+ /** A skip gate for one batch's level pass. @internal */
43
+ export declare function createLevelPassGate(): LevelPassGate;
44
+ /**
45
+ * A model's switch edges as the level pass measures against them: one finite boundary per level
46
+ * below the last, and the hysteresis an instance must clear before it crosses one.
47
+ * @internal
48
+ */
49
+ export interface VariantSwitch {
50
+ readonly boundaries: readonly number[];
51
+ readonly hysteresis: number;
52
+ }
53
+ /** Read a model's variant config as {@link VariantSwitch}. @internal */
54
+ export declare function variantSwitchOf(variants: ModelVariantLodConfig): VariantSwitch;
55
+ /**
56
+ * Metres from one instance's distance to the nearest distance at which its level would change: the
57
+ * level's own boundary plus the hysteresis going out, the level below's boundary minus it coming
58
+ * back. Zero means the instance sits on its edge.
59
+ * @internal
60
+ */
61
+ export declare function levelEdgeMargin(distance: number, level: number, variantSwitch: VariantSwitch): number;
62
+ /**
63
+ * One batch's per-instance level pass: asks the shared {@link ModelVariantSelector} for every live
64
+ * instance's level at its camera distance, writes the answers into `into`, and returns the smallest
65
+ * margin any instance had to its switch edge so the caller can skip the next pass that cannot change
66
+ * an answer.
67
+ * @internal
68
+ */
69
+ export declare function assignVariantLevels(selector: ModelVariantSelector, variantSwitch: VariantSwitch, objects: readonly SceneObject[], live: (id: string) => SceneObject | null, camera: THREE.Vector3, into: Map<string, number>, probe: THREE.Vector3): number;
@@ -0,0 +1,129 @@
1
+ import { DEFAULT_VARIANT_HYSTERESIS } from "@jgengine/core/world/modelLod";
2
+ /**
3
+ * Metres of camera travel a path tolerates before it re-runs its level pass when it cannot tell how
4
+ * close an instance sits to a switch edge. The scatter path passes this as its margin; a path that
5
+ * knows the switch distances passes their real margin instead.
6
+ * @internal
7
+ */
8
+ export const LEVEL_PASS_MIN_CAMERA_MOVE = 0.5;
9
+ let passes = 0;
10
+ let skips = 0;
11
+ let frames = 0;
12
+ /** Count one frame a level pass was offered. @internal */
13
+ export function noteLevelPassFrame() {
14
+ frames += 1;
15
+ }
16
+ /** Count one level pass that ran or was skipped. @internal */
17
+ export function noteLevelPass(ran) {
18
+ if (ran)
19
+ passes += 1;
20
+ else
21
+ skips += 1;
22
+ }
23
+ /** Clear the level pass counters. For tests and between capture runs. @internal */
24
+ export function resetLevelPassStats() {
25
+ passes = 0;
26
+ skips = 0;
27
+ frames = 0;
28
+ }
29
+ function readLevelPass() {
30
+ const perFrame = frames === 0 ? 0 : Math.round((passes / frames) * 100) / 100;
31
+ return {
32
+ counters: { levelPasses: passes, levelPassSkips: skips, levelPassFrames: frames, levelPassesPerFrame: perFrame },
33
+ line: `${passes} level passes / ${frames} frames (${perFrame}/frame) � ${skips} skipped`,
34
+ };
35
+ }
36
+ /**
37
+ * The level-pass system probe: how many per-instance variant level passes ran, how many the
38
+ * camera-unchanged skip removed, and the per-frame average. `debug_snapshot().systems` names the CPU
39
+ * cost a still or distant camera no longer pays instead of leaving it to a profile.
40
+ * @internal
41
+ */
42
+ export function levelPassProbe() {
43
+ return {
44
+ counters: () => readLevelPass().counters,
45
+ state: () => readLevelPass().line,
46
+ };
47
+ }
48
+ /**
49
+ * True when the camera has moved far enough that some instance's level could differ from the last
50
+ * pass. Moving the camera by `d` changes every instance distance by at most `d`, so a pass whose
51
+ * smallest margin to a switch edge was `margin` cannot change until the camera moves that far. A
52
+ * margin of `0` (an instance sitting on its edge) always runs.
53
+ * @internal
54
+ */
55
+ export function levelPassNeeded(last, camera, margin) {
56
+ return camera.distanceToSquared(last) >= margin * margin;
57
+ }
58
+ /** A skip gate for one batch's level pass. @internal */
59
+ export function createLevelPassGate() {
60
+ let x = 0;
61
+ let y = 0;
62
+ let z = 0;
63
+ let margin = 0;
64
+ let recorded = false;
65
+ return {
66
+ needsPass(camera) {
67
+ if (!recorded)
68
+ return true;
69
+ const dx = camera.x - x;
70
+ const dy = camera.y - y;
71
+ const dz = camera.z - z;
72
+ return dx * dx + dy * dy + dz * dz >= margin * margin;
73
+ },
74
+ record(camera, next) {
75
+ x = camera.x;
76
+ y = camera.y;
77
+ z = camera.z;
78
+ margin = next;
79
+ recorded = true;
80
+ },
81
+ invalidate() {
82
+ recorded = false;
83
+ },
84
+ };
85
+ }
86
+ /** Read a model's variant config as {@link VariantSwitch}. @internal */
87
+ export function variantSwitchOf(variants) {
88
+ const boundaries = [];
89
+ for (let index = 0; index < variants.levels.length - 1; index += 1) {
90
+ boundaries.push(variants.levels[index].maxDistance);
91
+ }
92
+ return { boundaries, hysteresis: Math.max(0, variants.hysteresis ?? DEFAULT_VARIANT_HYSTERESIS) };
93
+ }
94
+ /**
95
+ * Metres from one instance's distance to the nearest distance at which its level would change: the
96
+ * level's own boundary plus the hysteresis going out, the level below's boundary minus it coming
97
+ * back. Zero means the instance sits on its edge.
98
+ * @internal
99
+ */
100
+ export function levelEdgeMargin(distance, level, variantSwitch) {
101
+ const { boundaries, hysteresis } = variantSwitch;
102
+ let margin = Number.POSITIVE_INFINITY;
103
+ if (level < boundaries.length)
104
+ margin = Math.abs(distance - (boundaries[level] + hysteresis));
105
+ if (level > 0)
106
+ margin = Math.min(margin, Math.abs(distance - (boundaries[level - 1] - hysteresis)));
107
+ return margin;
108
+ }
109
+ /**
110
+ * One batch's per-instance level pass: asks the shared {@link ModelVariantSelector} for every live
111
+ * instance's level at its camera distance, writes the answers into `into`, and returns the smallest
112
+ * margin any instance had to its switch edge so the caller can skip the next pass that cannot change
113
+ * an answer.
114
+ * @internal
115
+ */
116
+ export function assignVariantLevels(selector, variantSwitch, objects, live, camera, into, probe) {
117
+ let margin = Number.POSITIVE_INFINITY;
118
+ for (const object of objects) {
119
+ const current = live(object.instanceId) ?? object;
120
+ probe.set(current.position[0] ?? 0, current.position[1] ?? 0, current.position[2] ?? 0);
121
+ const distance = camera.distanceTo(probe);
122
+ const level = selector.level(object.instanceId, distance);
123
+ into.set(object.instanceId, level);
124
+ const edge = levelEdgeMargin(distance, level, variantSwitch);
125
+ if (edge < margin)
126
+ margin = edge;
127
+ }
128
+ return margin;
129
+ }
@@ -1,3 +1,5 @@
1
+ export { registerSceneCapture, requestSceneCapture, requestSceneThumbnail, } from "./sceneCaptureRequest.js";
2
+ export type { SceneCapture, SceneCaptureFailure, SceneCaptureRequestOptions, SceneCaptureResult, SceneThumbnailResult, } from "./sceneCaptureRequest.js";
1
3
  /** The bit of a WebGL renderer scene capture needs — its backing `<canvas>`. */
2
4
  export interface CaptureRenderer {
3
5
  domElement: {
@@ -16,12 +18,15 @@ export declare function captureCanvas(gl: CaptureRenderer): string | null;
16
18
  export declare function downloadImage(dataUrl: string, filename?: string): void;
17
19
  /**
18
20
  * In-`<Canvas>` binder that hands the scene-capture function out to HUD code
19
- * living outside the reconciler (a photo-mode Capture button). Mount it inside
20
- * the game's `WorldOverlay`; call `bind` receives a `() => string | null` that
21
- * grabs the current frame. Renders nothing.
21
+ * living outside the reconciler (a photo-mode Capture button, a save slot's
22
+ * thumbnail). Mount it inside the game's `WorldOverlay`; the reader is
23
+ * registered for `requestSceneCapture`/`requestSceneThumbnail`, which wait for
24
+ * this mount and the first painted frame. `bind` is for code that wants the
25
+ * function directly and can be left out when a game only requests captures.
26
+ * Renders nothing.
22
27
  *
23
- * @capability scene-capture-binding expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode)
28
+ * @capability scene-capture-binding expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode, save thumbnails)
24
29
  */
25
- export declare function SceneCaptureBinding({ bind }: {
26
- bind: (capture: () => string | null) => void;
30
+ export declare function SceneCaptureBinding({ bind }?: {
31
+ bind?: (capture: () => string | null) => void;
27
32
  }): null;
@@ -1,5 +1,7 @@
1
1
  import { useThree } from "@react-three/fiber";
2
2
  import { useEffect } from "react";
3
+ import { registerSceneCapture } from "./sceneCaptureRequest.js";
4
+ export { registerSceneCapture, requestSceneCapture, requestSceneThumbnail, } from "./sceneCaptureRequest.js";
3
5
  /**
4
6
  * Read the current frame to a PNG data URL. Requires the R3F `<Canvas>` to have
5
7
  * been created with `gl={{ preserveDrawingBuffer: true }}` (the shell's game
@@ -27,17 +29,25 @@ export function downloadImage(dataUrl, filename = "screenshot.png") {
27
29
  }
28
30
  /**
29
31
  * In-`<Canvas>` binder that hands the scene-capture function out to HUD code
30
- * living outside the reconciler (a photo-mode Capture button). Mount it inside
31
- * the game's `WorldOverlay`; call `bind` receives a `() => string | null` that
32
- * grabs the current frame. Renders nothing.
32
+ * living outside the reconciler (a photo-mode Capture button, a save slot's
33
+ * thumbnail). Mount it inside the game's `WorldOverlay`; the reader is
34
+ * registered for `requestSceneCapture`/`requestSceneThumbnail`, which wait for
35
+ * this mount and the first painted frame. `bind` is for code that wants the
36
+ * function directly and can be left out when a game only requests captures.
37
+ * Renders nothing.
33
38
  *
34
- * @capability scene-capture-binding expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode)
39
+ * @capability scene-capture-binding expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode, save thumbnails)
35
40
  */
36
- export function SceneCaptureBinding({ bind }) {
41
+ export function SceneCaptureBinding({ bind } = {}) {
37
42
  const gl = useThree((state) => state.gl);
38
43
  useEffect(() => {
39
- bind(() => captureCanvas(gl));
40
- return () => bind(() => null);
44
+ const capture = () => captureCanvas(gl);
45
+ const unbind = registerSceneCapture(capture);
46
+ bind?.(capture);
47
+ return () => {
48
+ unbind();
49
+ bind?.(() => null);
50
+ };
41
51
  }, [gl, bind]);
42
52
  return null;
43
53
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Asking the live scene for a frame without owning the canvas. A game mounts the
3
+ * {@link import("./sceneCapture.js").SceneCaptureBinding} once and then requests a thumbnail when it
4
+ * saves. The request survives the two ways a hand-written capture goes blank — the binding mounting
5
+ * after the save screen rendered, and reading the canvas before its first painted frame — so no game
6
+ * polls for the binding or waits frames of its own.
7
+ */
8
+ import { type SaveThumbnailOptions } from "@jgengine/react/saveThumbnail";
9
+ /** Reads the current scene frame as an image data URL, or null when the frame cannot be read. */
10
+ export type SceneCapture = () => string | null;
11
+ /** Why a capture request produced no image. */
12
+ export type SceneCaptureFailure = "no-binding" | "no-frame" | "undecodable";
13
+ /** A captured frame, or the stated reason there is none. */
14
+ export type SceneCaptureResult = {
15
+ ok: true;
16
+ capture: string;
17
+ } | {
18
+ ok: false;
19
+ reason: SceneCaptureFailure;
20
+ };
21
+ /** A save-card thumbnail, or the stated reason there is none. */
22
+ export type SceneThumbnailResult = {
23
+ ok: true;
24
+ thumbnail: string;
25
+ } | {
26
+ ok: false;
27
+ reason: SceneCaptureFailure;
28
+ };
29
+ /** How a capture request waits and retries. */
30
+ export interface SceneCaptureRequestOptions {
31
+ /**
32
+ * How long to wait for a capture binding to mount, in milliseconds. Default 5000. A game that
33
+ * never mounts the binding gets `"no-binding"` instead of a request that never settles.
34
+ */
35
+ bindingTimeoutMs?: number;
36
+ /**
37
+ * Painted frames to wait after the binding appears before reading, so the read lands after the
38
+ * canvas has drawn at least once. Default 2, which is one paint.
39
+ */
40
+ settleFrames?: number;
41
+ /** Reads attempted on later frames before reporting `"no-frame"`. Default 3. */
42
+ attempts?: number;
43
+ }
44
+ /**
45
+ * Hand the capture path a reader for the live frame, or `null` to retire it. The returned function
46
+ * unbinds only that reader, so a canvas that remounts cannot be unbound by its predecessor.
47
+ */
48
+ export declare function registerSceneCapture(capture: SceneCapture | null): () => void;
49
+ /**
50
+ * Request the current scene frame. Resolves once a binding exists and the canvas has painted, or
51
+ * with the reason no frame could be read; it never polls and never rejects.
52
+ *
53
+ * @capability request-scene-capture request the live scene frame, waiting for a late binding and the first painted frame
54
+ */
55
+ export declare function requestSceneCapture(options?: SceneCaptureRequestOptions): Promise<SceneCaptureResult>;
56
+ /**
57
+ * Request a save-card thumbnail from the live scene: the shared capture request, then
58
+ * `shrinkCapture`. Pass what the slot card stores; omit everything for a 320x180 JPEG.
59
+ *
60
+ * @capability request-scene-thumbnail request a save-card thumbnail from the live scene, or a stated failure
61
+ */
62
+ export declare function requestSceneThumbnail(options?: SceneCaptureRequestOptions & SaveThumbnailOptions): Promise<SceneThumbnailResult>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Asking the live scene for a frame without owning the canvas. A game mounts the
3
+ * {@link import("./sceneCapture.js").SceneCaptureBinding} once and then requests a thumbnail when it
4
+ * saves. The request survives the two ways a hand-written capture goes blank — the binding mounting
5
+ * after the save screen rendered, and reading the canvas before its first painted frame — so no game
6
+ * polls for the binding or waits frames of its own.
7
+ */
8
+ import { shrinkCapture } from "@jgengine/react/saveThumbnail";
9
+ /** The registered reader, and the requests waiting for one to appear. */
10
+ let bound = null;
11
+ const waiting = new Set();
12
+ /**
13
+ * Hand the capture path a reader for the live frame, or `null` to retire it. The returned function
14
+ * unbinds only that reader, so a canvas that remounts cannot be unbound by its predecessor.
15
+ */
16
+ export function registerSceneCapture(capture) {
17
+ bound = capture;
18
+ if (capture !== null) {
19
+ for (const resolve of [...waiting]) {
20
+ waiting.delete(resolve);
21
+ resolve(capture);
22
+ }
23
+ }
24
+ return () => {
25
+ if (bound === capture)
26
+ bound = null;
27
+ };
28
+ }
29
+ function settleFrames(count) {
30
+ if (typeof requestAnimationFrame !== "function")
31
+ return Promise.resolve();
32
+ return new Promise((resolve) => {
33
+ let remaining = count;
34
+ const step = () => {
35
+ remaining -= 1;
36
+ if (remaining <= 0)
37
+ resolve();
38
+ else
39
+ requestAnimationFrame(step);
40
+ };
41
+ requestAnimationFrame(step);
42
+ });
43
+ }
44
+ function waitForBinding(timeoutMs) {
45
+ if (bound !== null)
46
+ return Promise.resolve(bound);
47
+ return new Promise((resolve) => {
48
+ let settled = false;
49
+ let timer;
50
+ const settle = (capture) => {
51
+ if (settled)
52
+ return;
53
+ settled = true;
54
+ waiting.delete(onReady);
55
+ if (timer !== undefined)
56
+ clearTimeout(timer);
57
+ resolve(capture);
58
+ };
59
+ const onReady = (capture) => settle(capture);
60
+ waiting.add(onReady);
61
+ timer = setTimeout(() => settle(null), timeoutMs);
62
+ });
63
+ }
64
+ function read(capture) {
65
+ try {
66
+ const image = capture();
67
+ return typeof image === "string" && image.startsWith("data:image") ? image : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /**
74
+ * Request the current scene frame. Resolves once a binding exists and the canvas has painted, or
75
+ * with the reason no frame could be read; it never polls and never rejects.
76
+ *
77
+ * @capability request-scene-capture request the live scene frame, waiting for a late binding and the first painted frame
78
+ */
79
+ export async function requestSceneCapture(options = {}) {
80
+ const capture = await waitForBinding(options.bindingTimeoutMs ?? 5000);
81
+ if (capture === null)
82
+ return { ok: false, reason: "no-binding" };
83
+ await settleFrames(options.settleFrames ?? 2);
84
+ const attempts = options.attempts ?? 3;
85
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
86
+ const image = read(capture);
87
+ if (image !== null)
88
+ return { ok: true, capture: image };
89
+ if (attempt < attempts - 1)
90
+ await settleFrames(1);
91
+ }
92
+ return { ok: false, reason: "no-frame" };
93
+ }
94
+ /**
95
+ * Request a save-card thumbnail from the live scene: the shared capture request, then
96
+ * `shrinkCapture`. Pass what the slot card stores; omit everything for a 320x180 JPEG.
97
+ *
98
+ * @capability request-scene-thumbnail request a save-card thumbnail from the live scene, or a stated failure
99
+ */
100
+ export async function requestSceneThumbnail(options = {}) {
101
+ const source = await requestSceneCapture(options);
102
+ if (!source.ok)
103
+ return source;
104
+ let thumbnail = null;
105
+ try {
106
+ thumbnail = await shrinkCapture(source.capture, options);
107
+ }
108
+ catch {
109
+ thumbnail = null;
110
+ }
111
+ return thumbnail === null ? { ok: false, reason: "undecodable" } : { ok: true, thumbnail };
112
+ }
@@ -2,12 +2,19 @@ import type * as THREE from "three";
2
2
  import type { ModelConfig } from "@jgengine/core/game/playableGame";
3
3
  import type { ModelVariantSelector } from "@jgengine/core/world/modelLod";
4
4
  /**
5
- * The model an isolated prop draws at one variant level: that level's asset and no nested variants, so
6
- * a prop rendered on its own switches assets the way a batched instance does. Out-of-range levels clamp
7
- * to the nearest one, and a model without variants is returned untouched.
5
+ * Shadow participation the model draws with at one level: the level's own choice when it made one,
6
+ * otherwise the model's `shadows` untouched.
8
7
  * @internal
9
8
  */
10
- export declare function singleVariantModel(model: ModelConfig, level: number): ModelConfig;
9
+ export declare function variantLevelShadows(model: ModelConfig, level: number): ModelConfig["shadows"];
10
+ /**
11
+ * The model any path draws at one variant level: that level's asset, its shadow participation, and no
12
+ * nested variants, so a prop rendered on its own, a batched instance and a scatter cell all switch
13
+ * the same way. Out-of-range levels clamp to the nearest one, and a model without variants is
14
+ * returned untouched.
15
+ * @internal
16
+ */
17
+ export declare function variantLevelModel(model: ModelConfig, level: number): ModelConfig;
11
18
  /**
12
19
  * Metres from the camera's world position to a placement, written through the caller's scratch vectors
13
20
  * so a frame allocates nothing.
@@ -1,15 +1,36 @@
1
+ function levelOf(model, level) {
2
+ const levels = model.variants?.levels;
3
+ if (levels === undefined)
4
+ return undefined;
5
+ return levels[Math.min(levels.length - 1, Math.max(0, level))];
6
+ }
1
7
  /**
2
- * The model an isolated prop draws at one variant level: that level's asset and no nested variants, so
3
- * a prop rendered on its own switches assets the way a batched instance does. Out-of-range levels clamp
4
- * to the nearest one, and a model without variants is returned untouched.
8
+ * Shadow participation the model draws with at one level: the level's own choice when it made one,
9
+ * otherwise the model's `shadows` untouched.
5
10
  * @internal
6
11
  */
7
- export function singleVariantModel(model, level) {
8
- const levels = model.variants?.levels;
9
- if (levels === undefined)
12
+ export function variantLevelShadows(model, level) {
13
+ const chosen = levelOf(model, level)?.shadows;
14
+ return chosen === undefined || chosen === "inherit" ? model.shadows : chosen;
15
+ }
16
+ /**
17
+ * The model any path draws at one variant level: that level's asset, its shadow participation, and no
18
+ * nested variants, so a prop rendered on its own, a batched instance and a scatter cell all switch
19
+ * the same way. Out-of-range levels clamp to the nearest one, and a model without variants is
20
+ * returned untouched.
21
+ * @internal
22
+ */
23
+ export function variantLevelModel(model, level) {
24
+ const chosen = levelOf(model, level);
25
+ if (chosen === undefined)
10
26
  return model;
11
- const chosen = levels[Math.min(levels.length - 1, Math.max(0, level))];
12
- return { ...model, url: chosen.url, variants: undefined };
27
+ const shadows = variantLevelShadows(model, level);
28
+ return {
29
+ ...model,
30
+ url: chosen.url,
31
+ variants: undefined,
32
+ ...(shadows === undefined ? {} : { shadows }),
33
+ };
13
34
  }
14
35
  /**
15
36
  * Metres from the camera's world position to a placement, written through the caller's scratch vectors
@@ -3,6 +3,7 @@ import { Suspense, useLayoutEffect, useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { AuthoredSurfaceMaterial, authoredSurfaceKey } from "./authoredSurfaceMaterial.js";
5
5
  import { ModelFallbackBoundary } from "./SceneModels.js";
6
+ import { submitInstanceCount } from "./submittedDraws.js";
6
7
  import { useDisposable } from "./useDisposable.js";
7
8
  /** Plan compatible material/shape/shadow groups without changing authored transforms. @internal */
8
9
  export function groupStaticShapeInstances(instances) {
@@ -36,7 +37,7 @@ export function writeStaticShapeInstances(mesh, instances) {
36
37
  transform.updateMatrix();
37
38
  mesh.setMatrixAt(index, transform.matrix);
38
39
  });
39
- mesh.count = instances.length;
40
+ submitInstanceCount(mesh, instances.length);
40
41
  if (mesh.geometry.boundingBox === null)
41
42
  mesh.geometry.computeBoundingBox();
42
43
  const heightSurface = instances[0]?.surface;
@@ -0,0 +1,13 @@
1
+ import type * as THREE from "three";
2
+ /**
3
+ * What one instanced draw submits this frame. Three skips a draw whose instance count is zero and
4
+ * skips an invisible node, so the count and the visibility are one answer: every path that claims a
5
+ * subset of a batch's placements — a variant level, a species, a proxy, an authored primitive group —
6
+ * states it here, and one that holds nothing submits nothing.
7
+ * @internal
8
+ */
9
+ export declare function submitInstanceCount(mesh: THREE.InstancedMesh, count: number): void;
10
+ /** Whether three issues a draw for this node this frame: instanced, visible, and holding an instance. @internal */
11
+ export declare function submitsDraw(node: THREE.Object3D): boolean;
12
+ /** Instanced draws a subtree submits this frame, optionally only the nodes an owner tagged. @internal */
13
+ export declare function countSubmittedDraws(root: THREE.Object3D, tag?: string): number;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What one instanced draw submits this frame. Three skips a draw whose instance count is zero and
3
+ * skips an invisible node, so the count and the visibility are one answer: every path that claims a
4
+ * subset of a batch's placements — a variant level, a species, a proxy, an authored primitive group —
5
+ * states it here, and one that holds nothing submits nothing.
6
+ * @internal
7
+ */
8
+ export function submitInstanceCount(mesh, count) {
9
+ mesh.count = count;
10
+ mesh.visible = count > 0;
11
+ }
12
+ /** Whether three issues a draw for this node this frame: instanced, visible, and holding an instance. @internal */
13
+ export function submitsDraw(node) {
14
+ const mesh = node;
15
+ return mesh.isInstancedMesh === true && node.visible && mesh.count > 0;
16
+ }
17
+ /** Instanced draws a subtree submits this frame, optionally only the nodes an owner tagged. @internal */
18
+ export function countSubmittedDraws(root, tag) {
19
+ let draws = 0;
20
+ root.traverse((node) => {
21
+ if (tag !== undefined && node.userData[tag] !== true)
22
+ return;
23
+ if (submitsDraw(node))
24
+ draws += 1;
25
+ });
26
+ return draws;
27
+ }
@@ -8,6 +8,9 @@ import { chunkScatterInstances } from "@jgengine/core/world/scatterRegion";
8
8
  import { beginFallbackSeam, reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
9
9
  import { createModelVariantSelector } from "@jgengine/core/world/modelLod";
10
10
  import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
11
+ import { submitInstanceCount } from "../render/submittedDraws.js";
12
+ import { LEVEL_PASS_MIN_CAMERA_MOVE, levelPassNeeded } from "../render/levelPass.js";
13
+ import { variantLevelModel } from "../render/singleVariantLod.js";
11
14
  import { buildScatterProxy } from "./scatterProxies.js";
12
15
  import { buildScatterModelSources, disposeScatterModelSources } from "./scatterModels.js";
13
16
  import { writeScatterVariantLevel } from "./scatterVariants.js";
@@ -53,7 +56,7 @@ function ScatterBatchMesh({ batch, geometry }) {
53
56
  return;
54
57
  for (let i = 0; i < batch.matrices.length; i += 1)
55
58
  mesh.setMatrixAt(i, batch.matrices[i]);
56
- mesh.count = batch.matrices.length;
59
+ submitInstanceCount(mesh, batch.matrices.length);
57
60
  mesh.instanceMatrix.needsUpdate = true;
58
61
  mesh.computeBoundingSphere();
59
62
  }, [batch]);
@@ -71,7 +74,7 @@ function ScatterModelSourceInstances({ source, matrices }) {
71
74
  composed.multiplyMatrices(matrices[i], source.localMatrix);
72
75
  mesh.setMatrixAt(i, composed);
73
76
  }
74
- mesh.count = matrices.length;
77
+ submitInstanceCount(mesh, matrices.length);
75
78
  mesh.instanceMatrix.needsUpdate = true;
76
79
  mesh.computeBoundingSphere();
77
80
  }, [matrices, source]);
@@ -94,8 +97,7 @@ function ScatterVariantSourceInstances({ source, batch, level, selector }) {
94
97
  if (mesh === null)
95
98
  return;
96
99
  const count = writeScatterVariantLevel(level, selector, from, batch.instances, batch.matrices, source.localMatrix, slots);
97
- mesh.count = count;
98
- mesh.visible = count > 0;
100
+ submitInstanceCount(mesh, count);
99
101
  mesh.instanceMatrix.needsUpdate = true;
100
102
  if (count > 0)
101
103
  mesh.computeBoundingSphere();
@@ -107,7 +109,7 @@ function ScatterVariantSourceInstances({ source, batch, level, selector }) {
107
109
  }, [camera, writeLevel, cameraPosition]);
108
110
  useFrame((state) => {
109
111
  state.camera.getWorldPosition(cameraPosition);
110
- if (cameraPosition.distanceToSquared(lastCamera.current) < 0.25)
112
+ if (!levelPassNeeded(lastCamera.current, cameraPosition, LEVEL_PASS_MIN_CAMERA_MOVE))
111
113
  return;
112
114
  lastCamera.current.copy(cameraPosition);
113
115
  writeLevel(cameraPosition);
@@ -120,9 +122,8 @@ function ScatterVariantLevel({ model, url, level, selector, batches }) {
120
122
  loader.setMeshoptDecoder(MeshoptDecoder);
121
123
  });
122
124
  const { sources, root } = useMemo(() => {
123
- const levelModel = { ...model, url, variants: undefined };
124
- return buildScatterModelSources(gltf.scene, levelModel);
125
- }, [gltf, model, url]);
125
+ return buildScatterModelSources(gltf.scene, variantLevelModel(model, level));
126
+ }, [gltf, model, level]);
126
127
  useEffect(() => () => disposeScatterModelSources(root), [root]);
127
128
  return (_jsx(_Fragment, { children: sources.map((source, sourceIndex) => batches.map((batch) => (_jsx(ScatterVariantSourceInstances, { source: source, batch: batch, level: level, selector: selector }, `${batch.key}:${sourceIndex}`)))) }));
128
129
  }
@@ -1,7 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import * as THREE from "three";
3
3
  import type { SceneObject } from "@jgengine/core/scene/objectStore";
4
- import { type ModelVariantSelector } from "@jgengine/core/world/modelLod";
5
4
  import { type StaticObjectBatch } from "./staticObjectBatches.js";
6
5
  /**
7
6
  * Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
@@ -16,10 +15,10 @@ export declare function useStaticObjectBatchProbe(scene: THREE.Object3D, stats:
16
15
  singles: number;
17
16
  }): void;
18
17
  /** Members of one level drawn through the individual renderer, for a level whose asset cannot be instanced. @internal */
19
- export declare function LevelSingles({ objects, level, selector, single }: {
18
+ export declare function LevelSingles({ objects, level, assignment, single }: {
20
19
  objects: readonly SceneObject[];
21
20
  level: number;
22
- selector: ModelVariantSelector;
21
+ assignment: ReadonlyMap<string, number>;
23
22
  single: (object: SceneObject) => ReactNode;
24
23
  }): import("react").JSX.Element;
25
24
  /**
@@ -7,6 +7,8 @@ import { devtools } from "@jgengine/core/devtools/devtools";
7
7
  import { useGameContext } from "@jgengine/react/provider";
8
8
  import { sharedGltfLoader } from "../render/modelLoad.js";
9
9
  import { IsolatedModelPart } from "../render/SceneModels.js";
10
+ import { variantLevelModel } from "../render/singleVariantLod.js";
11
+ import { assignVariantLevels, createLevelPassGate, levelPassProbe, noteLevelPass, noteLevelPassFrame, variantSwitchOf } from "../render/levelPass.js";
10
12
  import { freezeStaticTransforms } from "../render/staticTransforms.js";
11
13
  import { measureLocalBounds, reportMeasuredBounds } from "../render/measureBounds.js";
12
14
  import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "../render/measureCollisionMesh.js";
@@ -31,6 +33,7 @@ export function useStaticObjectBatchProbe(scene, stats) {
31
33
  return `${ref.current.batches} batches · ${draws} draws · ${drawn}/${ref.current.instances} instances drawn`;
32
34
  },
33
35
  }), [scene]);
36
+ useEffect(() => devtools.probes.registerSystem("render/levelPass", levelPassProbe()), []);
34
37
  }
35
38
  /** Live members of one batch after the per-frame compatibility gate removed any that changed at runtime. */
36
39
  function useBatchMembers(batch) {
@@ -45,13 +48,11 @@ function useBatchMembers(batch) {
45
48
  const objects = useMemo(() => batch.objects.filter((object) => !excluded.includes(object.instanceId)), [batch, excluded]);
46
49
  return { objects, excluded };
47
50
  }
48
- function ObjectSource({ source, objects, batch, selector, level }) {
51
+ function ObjectSource({ source, objects, batch, assignment, level }) {
49
52
  const ctx = useGameContext();
50
53
  const visibility = useRenderVisibility();
51
54
  const ref = useRef(null);
52
55
  const scratch = useMemo(() => new THREE.Object3D(), []);
53
- const cameraPosition = useRef(new THREE.Vector3());
54
- const probe = useMemo(() => new THREE.Vector3(), []);
55
56
  const attach = useCallback((mesh) => {
56
57
  ref.current = mesh;
57
58
  if (mesh !== null)
@@ -61,21 +62,18 @@ function ObjectSource({ source, objects, batch, selector, level }) {
61
62
  const mesh = ref.current;
62
63
  return () => { mesh?.dispose(); };
63
64
  }, [objects.length, source]);
64
- useFrame((state) => {
65
+ useFrame(() => {
65
66
  if (ref.current === null)
66
67
  return;
67
- let accept;
68
- if (selector !== undefined && level !== undefined) {
69
- const camera = cameraPosition.current;
70
- state.camera.getWorldPosition(camera);
71
- accept = (live) => selector.level(live.instanceId, camera.distanceTo(probe.set(live.position[0], live.position[1], live.position[2]))) === level;
72
- }
68
+ const accept = assignment !== undefined && level !== undefined
69
+ ? (live) => assignment.get(live.instanceId) === level
70
+ : undefined;
73
71
  syncStaticObjectSource(ref.current, source, objects, (id) => {
74
72
  const object = ctx.scene.object.get(id);
75
73
  return object !== null && object.catalogId === batch.objects[0].catalogId &&
76
74
  isCompatibleStaticObject(object, batch.model, ctx.scene.object.catalog(id)) ? object : null;
77
75
  }, visibility.current, scratch, accept);
78
- }, CAMERA_POST_FRAME_PRIORITY + 0.1);
76
+ }, CAMERA_POST_FRAME_PRIORITY + 0.2);
79
77
  return _jsx("instancedMesh", { ref: attach, args: [source.geometry, source.material, objects.length], userData: { [STATIC_BATCH_MESH_KEY]: true }, castShadow: source.castShadow, receiveShadow: source.receiveShadow, dispose: null });
80
78
  }
81
79
  function LoadedStaticObjects({ batch, single }) {
@@ -111,7 +109,7 @@ function LoadedStaticObjects({ batch, single }) {
111
109
  return _jsxs(_Fragment, { children: [harvested.sources.map((source, index) => objects.length === 0 ? null : _jsx(ObjectSource, { source: source, objects: objects, batch: batch }, index)), excluded.map((id) => { const live = ctx.scene.object.get(id); return live === null ? null : single(live); })] });
112
110
  }
113
111
  /** One detail level's GLB, harvested into instanced draws that only claim the instances the selector put on this level. */
114
- function VariantLevel({ url, model, batch, objects, level, selector, measure, single }) {
112
+ function VariantLevel({ url, model, batch, objects, level, assignment, measure, single }) {
115
113
  const ctx = useGameContext();
116
114
  const gltf = useLoader(sharedGltfLoader, url);
117
115
  const supported = useMemo(() => canInstanceStaticScene(gltf.scene, gltf.animations), [gltf]);
@@ -139,28 +137,24 @@ function VariantLevel({ url, model, batch, objects, level, selector, measure, si
139
137
  reportMeasuredCollisionMesh(ctx, "object", key, triangles);
140
138
  }, [ctx, harvested, batch, measure]);
141
139
  if (harvested === null)
142
- return _jsx(LevelSingles, { objects: objects, level: level, selector: selector, single: single });
143
- return _jsx(_Fragment, { children: harvested.sources.map((source, index) => _jsx(ObjectSource, { source: source, objects: objects, batch: batch, selector: selector, level: level }, index)) });
140
+ return _jsx(LevelSingles, { objects: objects, level: level, assignment: assignment, single: single });
141
+ return _jsx(_Fragment, { children: harvested.sources.map((source, index) => _jsx(ObjectSource, { source: source, objects: objects, batch: batch, assignment: assignment, level: level }, index)) });
144
142
  }
145
143
  /** Members of one level drawn through the individual renderer, for a level whose asset cannot be instanced. @internal */
146
- export function LevelSingles({ objects, level, selector, single }) {
144
+ export function LevelSingles({ objects, level, assignment, single }) {
147
145
  const ctx = useGameContext();
148
146
  const visibility = useRenderVisibility();
149
- const camera = useMemo(() => new THREE.Vector3(), []);
150
- const probe = useMemo(() => new THREE.Vector3(), []);
151
147
  const [members, setMembers] = useState(objects);
152
- useFrame((state) => {
153
- state.camera.getWorldPosition(camera);
148
+ useFrame(() => {
154
149
  const next = objects.flatMap((object) => {
155
150
  const live = ctx.scene.object.get(object.instanceId);
156
151
  if (live === null || !visibility.current(live.instanceId))
157
152
  return [];
158
- probe.set(live.position[0], live.position[1], live.position[2]);
159
- return selector.level(live.instanceId, camera.distanceTo(probe)) === level ? [live] : [];
153
+ return assignment.get(live.instanceId) === level ? [live] : [];
160
154
  });
161
155
  if (next.length !== members.length || next.some((member, index) => member !== members[index]))
162
156
  setMembers(next);
163
- });
157
+ }, CAMERA_POST_FRAME_PRIORITY + 0.2);
164
158
  return _jsx(_Fragment, { children: members.map(single) });
165
159
  }
166
160
  /**
@@ -174,7 +168,34 @@ export function VariantStaticObjects({ batch, single }) {
174
168
  const { objects, excluded } = useBatchMembers(batch);
175
169
  const levels = batch.model.variants.levels;
176
170
  const selector = useMemo(() => createModelVariantSelector(batch.model.variants), [batch.model]);
177
- return _jsxs(_Fragment, { children: [levels.map((level, index) => (_jsx(VariantLevel, { url: level.url, model: { ...batch.model, url: level.url, variants: undefined }, batch: batch, objects: objects, level: index, selector: selector, measure: index === 0, single: single }, `${level.url}:${index}`))), excluded.map((id) => { const live = ctx.scene.object.get(id); return live === null ? null : single(live); })] });
171
+ const variantSwitch = useMemo(() => variantSwitchOf(batch.model.variants), [batch.model]);
172
+ const gate = useMemo(() => createLevelPassGate(), []);
173
+ const assignment = useMemo(() => new Map(), []);
174
+ const camera = useMemo(() => new THREE.Vector3(), []);
175
+ const probe = useMemo(() => new THREE.Vector3(), []);
176
+ const members = useRef(objects);
177
+ const revision = useRef(-1);
178
+ const activeSelector = useRef(selector);
179
+ useFrame((state) => {
180
+ noteLevelPassFrame();
181
+ state.camera.getWorldPosition(camera);
182
+ const membership = members.current !== objects;
183
+ const model = activeSelector.current !== selector;
184
+ const moved = revision.current !== ctx.scene.object.version();
185
+ if (!membership && !model && !moved && !gate.needsPass(camera)) {
186
+ noteLevelPass(false);
187
+ return;
188
+ }
189
+ if (membership || model)
190
+ assignment.clear();
191
+ const margin = assignVariantLevels(selector, variantSwitch, objects, ctx.scene.object.get, camera, assignment, probe);
192
+ gate.record(camera, margin);
193
+ members.current = objects;
194
+ activeSelector.current = selector;
195
+ revision.current = ctx.scene.object.version();
196
+ noteLevelPass(true);
197
+ }, CAMERA_POST_FRAME_PRIORITY + 0.1);
198
+ return _jsxs(_Fragment, { children: [levels.map((level, index) => (_jsx(VariantLevel, { url: level.url, model: variantLevelModel(batch.model, index), batch: batch, objects: objects, level: index, assignment: assignment, measure: index === 0, single: single }, `${level.url}:${index}`))), excluded.map((id) => { const live = ctx.scene.object.get(id); return live === null ? null : single(live); })] });
178
199
  }
179
200
  /** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
180
201
  export function StaticObjectModels({ batch, single }) {
@@ -55,9 +55,11 @@ export interface StaticBatchCounts {
55
55
  drawn: number;
56
56
  }
57
57
  /**
58
- * Sum the tagged draws in the live scene. `drawn` counts each instance once even though one instance
59
- * appears in every material source's draw, so it is the number of instances the renderer was asked to
60
- * draw this frame — a world-wide batch whose instances leave the view drops it without unmounting.
58
+ * Sum the tagged draws the batches actually submit. `drawn` counts each instance once even though one
59
+ * instance appears in every material source's draw, so it is the number of instances the renderer was
60
+ * asked to draw this frame — a world-wide batch whose instances leave the view drops it without
61
+ * unmounting. A variant level with no instance in a cell, or a cell the camera culled, submits nothing
62
+ * and is not a draw.
61
63
  * @internal
62
64
  */
63
65
  export declare function countStaticBatchDraws(scene: THREE.Object3D): StaticBatchCounts;
@@ -1,5 +1,6 @@
1
1
  import * as THREE from "three";
2
2
  import { POINTER_OBJECT_INSTANCES_KEY } from "../pointer/pointerService.js";
3
+ import { submitInstanceCount, submitsDraw } from "../render/submittedDraws.js";
3
4
  /** The same compatibility gate is used when live placements change after grouping. @internal */
4
5
  export function canBatchStaticObject(candidate) {
5
6
  const { object, model, catalog } = candidate;
@@ -99,16 +100,18 @@ export function groupStaticObjects(candidates, previous, cell = STATIC_BATCH_CEL
99
100
  /** Marks the instanced draws a static object batch owns so the batching probe can count them. @internal */
100
101
  export const STATIC_BATCH_MESH_KEY = "jgStaticObjectBatch";
101
102
  /**
102
- * Sum the tagged draws in the live scene. `drawn` counts each instance once even though one instance
103
- * appears in every material source's draw, so it is the number of instances the renderer was asked to
104
- * draw this frame — a world-wide batch whose instances leave the view drops it without unmounting.
103
+ * Sum the tagged draws the batches actually submit. `drawn` counts each instance once even though one
104
+ * instance appears in every material source's draw, so it is the number of instances the renderer was
105
+ * asked to draw this frame — a world-wide batch whose instances leave the view drops it without
106
+ * unmounting. A variant level with no instance in a cell, or a cell the camera culled, submits nothing
107
+ * and is not a draw.
105
108
  * @internal
106
109
  */
107
110
  export function countStaticBatchDraws(scene) {
108
111
  let draws = 0;
109
112
  const drawn = new Set();
110
113
  scene.traverse((node) => {
111
- if (node.userData[STATIC_BATCH_MESH_KEY] !== true)
114
+ if (node.userData[STATIC_BATCH_MESH_KEY] !== true || !submitsDraw(node))
112
115
  return;
113
116
  draws += 1;
114
117
  const ids = node.userData[POINTER_OBJECT_INSTANCES_KEY];
@@ -192,8 +195,7 @@ export function syncStaticObjectSource(mesh, source, objects, get, visible, scra
192
195
  ids.length = count;
193
196
  poses.length = count;
194
197
  changed ||= mesh.count !== count;
195
- mesh.count = count;
196
- mesh.visible = count > 0;
198
+ submitInstanceCount(mesh, count);
197
199
  if (changed) {
198
200
  mesh.instanceMatrix.needsUpdate = true;
199
201
  mesh.computeBoundingSphere();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -39,9 +39,9 @@
39
39
  "test": "bun test src"
40
40
  },
41
41
  "dependencies": {
42
- "@jgengine/core": "^0.38.0",
43
- "@jgengine/react": "^0.38.0",
44
- "@jgengine/ws": "^0.38.0"
42
+ "@jgengine/core": "^0.38.2",
43
+ "@jgengine/react": "^0.38.2",
44
+ "@jgengine/ws": "^0.38.2"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@react-three/drei": "^10.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.38.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.38.2" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.38.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.38.2" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -795,6 +795,7 @@
795
795
  - `GameMenuSoundConfig` (interface): interface GameMenuSoundConfig — How {@link createGameMenuSound} is tuned.
796
796
  - `GameMenuSoundKind` (type): type GameMenuSoundKind = "move" | "confirm" | "back" — The short, quiet click a menu makes when the selection moves or is confirmed. Generated with an oscillator instead of shipped as an audio file, so a game gets menu feedback without authoring a sound and the whole thing unmounts with the screen.
797
797
  - `GameMenuSoundState` (interface): interface GameMenuSoundState — The sound player's readable state: on/off, gain, and whether audio is actually running.
798
+ - `GameMenuStyle` (type): type GameMenuStyle = CSSProperties & Record<`--${string}`, string | number> — Inline style for a menu surface that also accepts `--jg-*` theme tokens, so a game sets a token with `{ "--jg-menu-radius": "12px" }` instead of casting the object to `CSSProperties`.
798
799
  - `GameMenuVariant` (type): type GameMenuVariant = "title" | "pause" — How a {@link GameMenu} is used. `pause` is the same system at a smaller scale.
799
800
  - `GamePauseMenu` (function): function GamePauseMenu(props: Omit<GameMenuProps, "variant"> & { onResume?: () => void }): ReactNode — A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a game composes one menu system rather than a second one. Escape (or B/Start) resumes.
800
801
  - `GamePreviewComponent` (type): type GamePreviewComponent = ComponentType<GamePreviewProps> — ⚠ undocumented
@@ -1483,6 +1484,7 @@
1483
1484
  - `GameMenuItem` (interface): interface GameMenuItem — A game's front-end menu: a full-bleed surface over a living background, a logo, primary verbs on the left that animate as the selection moves, and a detail pane on the right that changes with the highlighted verb. It is the block a main menu, a pause menu and their sub-screens are built from, so a game composes one menu system instead of a stack of modal panels.
1484
1485
  - `GameMenuLogo` (interface): interface GameMenuLogo — The logo slot. Give it `image` for art, or `text` for a styled wordmark.
1485
1486
  - `GameMenuProps` (interface): interface GameMenuProps — Props for {@link GameMenu}.
1487
+ - `GameMenuStyle` (type): type GameMenuStyle = CSSProperties & Record<`--${string}`, string | number> — Inline style for a menu surface that also accepts `--jg-*` theme tokens, so a game sets a token with `{ "--jg-menu-radius": "12px" }` instead of casting the object to `CSSProperties`.
1486
1488
  - `GameMenuVariant` (type): type GameMenuVariant = "title" | "pause" — How a {@link GameMenu} is used. `pause` is the same system at a smaller scale.
1487
1489
  - `GamePauseMenu` (function): function GamePauseMenu(props: Omit<GameMenuProps, "variant"> & { onResume?: () => void }): ReactNode — A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a game composes one menu system rather than a second one. Escape (or B/Start) resumes.
1488
1490
  - `useViewportScrollLock` (function): function useViewportScrollLock(active: boolean): void — Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous inline styles are restored when the last one releases.
@@ -2115,6 +2117,10 @@
2115
2117
  - `TalentTree` (function): function TalentTree<TStat extends string = string>({ view: viewProp, nodes, tree, onLearn, icon, label, branchLabel, title = "Talents", variation = "themed", nodeSize = 54, columnGap = 30, tierGap = 46, showPoints = true, className, style, }: TalentTreeProps<TStat>): ReactNode — Drop-in talent / skill-tree widget over the existing progression model (`@jgengine/core/game/talents` + its `talentTreeView` selector). The game passes its node definitions and a live `createTalentTree` instance; the widget lays nodes out by branch column and prerequisite-depth tier, draws SVG prerequisite edges, styles each node learned/available/locked/maxed, shows an icon + rank badge, and calls `onLearn(nodeId)` when the player clicks a node that is currently allocatable. All layout, edge-drawing, and eligibility come from the model/selector — the widget never re-derives topology. Unskinned and HudTheme-token driven; node ids/branches are opaque game data the game supplies icons and labels for.
2116
2118
  - `TalentTreeProps` (interface): interface TalentTreeProps<TStat extends string = string> — Props for {@link TalentTree}.
2117
2119
 
2120
+ ## @jgengine/react/testFixtures
2121
+
2122
+ - `MENU_THEME_STYLE` (const): const MENU_THEME_STYLE: GameMenuStyle — Menu theme tokens passed through the `style` prop with no cast. This file is type-checked and kept out of the build, so the compiler proves the prop accepts `--jg-*` custom properties on every typecheck while nothing ships from here.
2123
+
2118
2124
  ## @jgengine/react/tileLayerPreview
2119
2125
 
2120
2126
  - `TileLayerPreview` (function): function TileLayerPreview({ className }: { className?: string }): React.JSX.Element — Deterministic canvas-backed tile-layer fixture for textured tile presentation evidence.
@@ -3078,9 +3084,28 @@
3078
3084
  ## @jgengine/shell/render/sceneCapture
3079
3085
 
3080
3086
  - `CaptureRenderer` (interface): interface CaptureRenderer — The bit of a WebGL renderer scene capture needs — its backing `<canvas>`.
3081
- - `SceneCaptureBinding` (function): function SceneCaptureBinding({ bind }: { bind: (capture: () => string | null) => void }): null — In-`<Canvas>` binder that hands the scene-capture function out to HUD code living outside the reconciler (a photo-mode Capture button). Mount it inside the game's `WorldOverlay`; call `bind` receives a `() => string | null` that grabs the current frame. Renders nothing.
3087
+ - `SceneCapture` (type): type SceneCapture = () => string | null — Reads the current scene frame as an image data URL, or null when the frame cannot be read.
3088
+ - `SceneCaptureBinding` (function): function SceneCaptureBinding({ bind }: { bind?: (capture: () => string | null) => void } = {}): null — In-`<Canvas>` binder that hands the scene-capture function out to HUD code living outside the reconciler (a photo-mode Capture button, a save slot's thumbnail). Mount it inside the game's `WorldOverlay`; the reader is registered for `requestSceneCapture`/`requestSceneThumbnail`, which wait for this mount and the first painted frame. `bind` is for code that wants the function directly and can be left out when a game only requests captures. Renders nothing.
3089
+ - `SceneCaptureFailure` (type): type SceneCaptureFailure = "no-binding" | "no-frame" | "undecodable" — Why a capture request produced no image.
3090
+ - `SceneCaptureRequestOptions` (interface): interface SceneCaptureRequestOptions — How a capture request waits and retries.
3091
+ - `SceneCaptureResult` (type): type SceneCaptureResult = | { ok: true; capture: string } | { ok: false; reason: SceneCaptureFailure } — A captured frame, or the stated reason there is none.
3092
+ - `SceneThumbnailResult` (type): type SceneThumbnailResult = | { ok: true; thumbnail: string } | { ok: false; reason: SceneCaptureFailure } — A save-card thumbnail, or the stated reason there is none.
3082
3093
  - `captureCanvas` (function): function captureCanvas(gl: CaptureRenderer): string | null — Read the current frame to a PNG data URL. Requires the R3F `<Canvas>` to have been created with `gl={{ preserveDrawingBuffer: true }}` (the shell's game canvas already is); returns null if the backing canvas can't be read.
3083
3094
  - `downloadImage` (function): function downloadImage(dataUrl: string, filename = "screenshot.png"): void — Trigger a browser download of an image data URL (the photo-mode "save" action).
3095
+ - `registerSceneCapture` (function): function registerSceneCapture(capture: SceneCapture | null): () => void — Hand the capture path a reader for the live frame, or `null` to retire it. The returned function unbinds only that reader, so a canvas that remounts cannot be unbound by its predecessor.
3096
+ - `requestSceneCapture` (function): function requestSceneCapture(options: SceneCaptureRequestOptions = {}): Promise<SceneCaptureResult> — Request the current scene frame. Resolves once a binding exists and the canvas has painted, or with the reason no frame could be read; it never polls and never rejects.
3097
+ - `requestSceneThumbnail` (function): function requestSceneThumbnail(options: SceneCaptureRequestOptions & SaveThumbnailOptions = {}): Promise<SceneThumbnailResult> — Request a save-card thumbnail from the live scene: the shared capture request, then `shrinkCapture`. Pass what the slot card stores; omit everything for a 320x180 JPEG.
3098
+
3099
+ ## @jgengine/shell/render/sceneCaptureRequest
3100
+
3101
+ - `SceneCapture` (type): type SceneCapture = () => string | null — Reads the current scene frame as an image data URL, or null when the frame cannot be read.
3102
+ - `SceneCaptureFailure` (type): type SceneCaptureFailure = "no-binding" | "no-frame" | "undecodable" — Why a capture request produced no image.
3103
+ - `SceneCaptureRequestOptions` (interface): interface SceneCaptureRequestOptions — How a capture request waits and retries.
3104
+ - `SceneCaptureResult` (type): type SceneCaptureResult = | { ok: true; capture: string } | { ok: false; reason: SceneCaptureFailure } — A captured frame, or the stated reason there is none.
3105
+ - `SceneThumbnailResult` (type): type SceneThumbnailResult = | { ok: true; thumbnail: string } | { ok: false; reason: SceneCaptureFailure } — A save-card thumbnail, or the stated reason there is none.
3106
+ - `registerSceneCapture` (function): function registerSceneCapture(capture: SceneCapture | null): () => void — Hand the capture path a reader for the live frame, or `null` to retire it. The returned function unbinds only that reader, so a canvas that remounts cannot be unbound by its predecessor.
3107
+ - `requestSceneCapture` (function): function requestSceneCapture(options: SceneCaptureRequestOptions = {}): Promise<SceneCaptureResult> — Request the current scene frame. Resolves once a binding exists and the canvas has painted, or with the reason no frame could be read; it never polls and never rejects.
3108
+ - `requestSceneThumbnail` (function): function requestSceneThumbnail(options: SceneCaptureRequestOptions & SaveThumbnailOptions = {}): Promise<SceneThumbnailResult> — Request a save-card thumbnail from the live scene: the shared capture request, then `shrinkCapture`. Pass what the slot card stores; omit everything for a 320x180 JPEG.
3084
3109
 
3085
3110
  ## @jgengine/shell/render/shaderWarmup
3086
3111
 
@@ -805,6 +805,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
805
805
 
806
806
  - `RainImpactField` (function) · `import { RainImpactField } from "@jgengine/shell/weather"`
807
807
 
808
+ ## request-scene-capture — request the live scene frame, waiting for a late binding and the first painted frame
809
+
810
+ - `requestSceneCapture` (function) · `import { requestSceneCapture } from "@jgengine/shell/render/sceneCapture"`
811
+
812
+ ## request-scene-thumbnail — request a save-card thumbnail from the live scene, or a stated failure
813
+
814
+ - `requestSceneThumbnail` (function) · `import { requestSceneThumbnail } from "@jgengine/shell/render/sceneCapture"`
815
+
808
816
  ## resolve-game-look — expand a look preset into concrete lighting/backdrop/post knobs
809
817
 
810
818
  - `resolveGameLook` (function) · `import { resolveGameLook } from "@jgengine/core/ui"`
@@ -821,7 +829,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
821
829
 
822
830
  - `SaveSlotsPane` (function) · `import { SaveSlotsPane } from "@jgengine/react"`
823
831
 
824
- ## scene-capture-binding — expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode)
832
+ ## scene-capture-binding — expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode, save thumbnails)
825
833
 
826
834
  - `SceneCaptureBinding` (function) · `import { SceneCaptureBinding } from "@jgengine/shell/render/sceneCapture"`
827
835
 
@@ -2655,7 +2655,7 @@
2655
2655
  - `ModelAssetRef` (interface): interface ModelAssetRef { url: string; anchor?: "center" | "origin"; dims?: ModelDims; collisionMesh?: CollisionMeshData; space?: AssetSpace; clips?: readonly string[] } — ⚠ undocumented · used by `createHostedWorldSessionAsync` (@jgengine/core/runtime/hostedWorldSession): Build a hosted session from an asynchronous persistence backend.
2656
2656
  - `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space.
2657
2657
  - `ModelNode` (interface): interface ModelNode — Generic named-socket reader for loaded 3D models. Walks a node tree (any object with `.name`, `.position`, and `.children` — structurally satisfied by `THREE.Object3D`) and collects the local offsets of nodes whose name marks an attachment point. Genre-agnostic: wire anchors on a pylon, muzzle/hand mounts on a character, hardpoints on a ship, seat/decal slots on furniture — anything an artist tags with an empty in the GLB. Pure data (no three.js import), so it lives in core.
2658
- - `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB and how far it draws.
2658
+ - `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB, how far it draws and how it shadows.
2659
2659
  - `ModelVariantLodConfig` (interface): interface ModelVariantLodConfig — Ordered mesh detail levels for one model, switched per instance by camera distance. The first level is the full mesh the model's own `url` names; every later level is a cheaper asset. A tree line uses this so near trees keep their full mesh while far ones draw a decimated copy of the same species.
2660
2660
  - `ModelVariantSelector` (interface): interface ModelVariantSelector — Per-instance distance switch over a model's detail levels: one answer per id, held across a boundary until the instance clears it.
2661
2661
  - `ModelVariantSelectorState` (interface): interface ModelVariantSelectorState — Plain JSON state of a {@link ModelVariantSelector}: each id's current level index.
@@ -4041,7 +4041,8 @@
4041
4041
  ## @jgengine/core/world/modelLod
4042
4042
 
4043
4043
  - `DEFAULT_VARIANT_HYSTERESIS` (const): const DEFAULT_VARIANT_HYSTERESIS: 6 — Metres an instance must travel past a switch boundary before it changes level unless the model declares its own.
4044
- - `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB and how far it draws.
4044
+ - `ModelShadowMode` (type): type ModelShadowMode = "cast" | "receive" | "both" | "none" — How a model, or one of its variant levels, takes part in the shadow pass.
4045
+ - `ModelVariantConfig` (interface): interface ModelVariantConfig — One mesh level of a {@link ModelVariantLodConfig}: a full or decimated GLB, how far it draws and how it shadows.
4045
4046
  - `ModelVariantLodConfig` (interface): interface ModelVariantLodConfig — Ordered mesh detail levels for one model, switched per instance by camera distance. The first level is the full mesh the model's own `url` names; every later level is a cheaper asset. A tree line uses this so near trees keep their full mesh while far ones draw a decimated copy of the same species.
4046
4047
  - `ModelVariantSelector` (interface): interface ModelVariantSelector — Per-instance distance switch over a model's detail levels: one answer per id, held across a boundary until the instance clears it.
4047
4048
  - `ModelVariantSelectorState` (interface): interface ModelVariantSelectorState — Plain JSON state of a {@link ModelVariantSelector}: each id's current level index.