@jgengine/shell 0.36.0 → 0.37.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 +84 -0
- package/dist/GamePlayerShell.js +9 -0
- package/dist/devtools/DevtoolsOverlay.js +34 -0
- package/dist/render/CascadedShadows.d.ts +28 -9
- package/dist/render/CascadedShadows.js +67 -20
- package/dist/render/shadowTiers.d.ts +0 -10
- package/dist/render/shadowTiers.js +0 -17
- package/dist/render/triangleAttribution.d.ts +40 -0
- package/dist/render/triangleAttribution.js +88 -0
- package/dist/scatter/InstancedScatter.d.ts +12 -0
- package/dist/scatter/InstancedScatter.js +83 -11
- package/dist/scatter/scatterVariants.d.ts +30 -0
- package/dist/scatter/scatterVariants.js +36 -0
- package/dist/world/StaticObjectModels.d.ts +18 -0
- package/dist/world/StaticObjectModels.js +101 -17
- package/dist/world/staticObjectBatches.d.ts +2 -2
- package/dist/world/staticObjectBatches.js +3 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/api.md +15 -0
- package/skills/jgengine-ui/capabilities.md +4 -0
- package/skills/jgengine-world/api.md +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,90 @@ 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.37.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.37.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.34.0`; `@jgengine/github` is `0.24.0`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `bun run check:script-help` walks every `package.json` script that points into `scripts/`, runs each with `--help`, and fails when one errors, hangs over its own timeout, prints nothing, or when a script names a file the repo does not have. Script files that predate `--help` are listed in `scripts/script-help-baseline.json` and reported, so a new one cannot arrive undocumented.
|
|
38
|
+
- `land`, `lane:close`, `lane:audit`, `ci-status` and `verify:fast` answer `--help` with their purpose, the flags they actually accept, one example and their exit codes, through one shared `scripts/script-help.ts`. No install, network or browser needed.
|
|
39
|
+
- `RunLoop.present(count)` and `RunLoop.choose(id)` (`@jgengine/core/run/runLoop`) draw several next-run changes for a choice screen and adopt the pick, so a game that offers three relics does not compose `game/runDraft` beside the loop. `present` honours the same `after` gates and weights as the single draw and never repeats the change just played while alternatives remain; `choose` opens the next run around the pick and refuses an id the loop's table does not carry.
|
|
40
|
+
- `installRunProbe` (`@jgengine/core/run/runLoop`) publishes `window.__jgengineRun` with the run in progress, what it carries, what it banked or lost, its best bank and the change it offers, so a capture or devtools read proves the loop is live instead of only imported.
|
|
41
|
+
- `bun run find <pattern> [--in <dir>] [--kind ts,tsx] [--files]` answers a repository-wide search in about a second: ripgrep reads the tree once, honours `.gitignore` and never enters `node_modules`, `.git`, `.claude/worktrees` or build output. `--files` lists names by `--glob`, which is what a tree listing was for. Eight of the ten hard timeouts in three hours of lane transcripts were a recursive directory listing over this checkout (160 s, 3.0M files) against a 120 s limit, while every other call's p90 was 10.5 s.
|
|
42
|
+
- `bun run check-recursive-walk` fails a `scripts/**` file or a lane document (`AGENTS.md`, `README.md`, every skill under `.claude/skills`) that ships a recursive directory listing, a `find` walk from a root, or `git ls-files` piped into per-file work; a required one carries `// walk-ok: <reason>`. It runs in CI through the scripts test shard.
|
|
43
|
+
- `ModelConfig.variants` (`levels: [{ url, maxDistance? }]`, `hysteresis?`) declares ordered mesh detail levels for a model. The instanced prop path switches level per instance by distance to the camera, so near and far placements of one species keep their own mesh inside one batch cell. A model without `variants` draws exactly as before.
|
|
44
|
+
- `createModelVariantSelector` (`@jgengine/core/world/modelLod`) returns the level index for an id at a distance, carrying hysteresis across each boundary (`DEFAULT_VARIANT_HYSTERESIS`, 6 m). `lodBandIndex` is now exported from `@jgengine/core/world/lod` and both the LOD scheduler and the selector read distances through it, so simulation throttling and mesh detail cannot disagree.
|
|
45
|
+
- `syncStaticObjectSource` takes an optional `accept` predicate so one batch can split its instances between two instanced draws.
|
|
46
|
+
- `InstancedScatter` now honours `ModelConfig.variants`: each species-chunk batch asks the same `createModelVariantSelector` the static prop path uses for every placement's level, and each level draws its own instanced meshes claiming only its placements. A chunk that holds near and far palms of one species draws both levels from the one batch; a model without `variants` draws exactly as before.
|
|
47
|
+
- `writeScatterVariantLevel` (`@jgengine/shell` internal, `scatter/scatterVariants`) is the per-frame pass: it writes one level's composed matrices into a reserved slot array and returns `count`, allocating nothing per frame, and takes an optional `keep` predicate so a tier-distance or occlusion cull composes with level selection in one loop.
|
|
48
|
+
- `terrain({ cellSize })` states ground density as world units per quad instead of a segment count, and derives the per-axis grid from `bounds` — the same `cellSize` on a bigger world spends more cells rather than stretching one count across it. `segments` still works and also takes `[x, z]` for a non-square grid. Setting both fails naming `cellSize`; a cell size over the 1,000,000-vertex ceiling fails with the cell/vertex numbers and the smallest `cellSize` that fits.
|
|
49
|
+
- `shoot`, `drive` and `perf` take `--quality <low|medium|high|ultra>` and pin that graphics tier on the game page (`?quality=<tier>`). Each run reads the tier back from the shell (`data-jg-quality`) and fails when it is missing or different, so a capture or perf record always names the tier it measured. Every capture summary prints `tier <tier>`; `perf` echoes `perf: tier <tier>` and records it in `quality`.
|
|
50
|
+
- The old `--quality draft|full` iteration mode is now the `--draft` switch (low tier, half viewport, `-draft` file names), on `shoot`, `drive` and `perf`; `perf:gate` takes `--draft` too. An unknown `--quality` value fails naming all four tiers.
|
|
51
|
+
- `attributeTriangles` (`@jgengine/shell/render/triangleAttribution`) sums a scene's triangles by model and geometry with instance counts, and the devtools render probe publishes it as `probes["render/triangles"]` and the `render/triangles` system probe, so `debug_snapshot` names the object behind a heavy frame instead of only counting it. Harbor Heat read 1,021,671 triangles from 840 objects: the 640 m terrain at 300 segments (180,000), the `TwistedTree_2` foliage species (137,010 over 15 trees) and 93 instances of a 1,280-triangle NPC model.
|
|
52
|
+
- `ShotSpec` and `resolveShotSpec` (`@jgengine/core/combat/shotSpec`) describe how a shot travels and what it does on contact as plain data: an instant trace or a travelling body with speed, gravity, drag, fuse, lifetime, radius and homing, plus contact damage, falloff, penetration, splash, ricochet and statuses. Every field but `delivery.kind` is optional with a documented default, so a plain gun is three lines and a grenade, mortar, arrow or hitscan explosive is the same shape with different numbers. `shotArc`, `shotFalloffAt` and `shotHitOutcome` are the one place trajectory, falloff and penetration are solved; `shotSpecFromStats` and `shotSpecFromWeapon` adapt existing item stats and weapon definitions once, and a splash radius or damage number never decides that a shot travels.
|
|
53
|
+
- `ProjectileShotInput.spec` lets a caller state the spec directly; the projectile and weapon systems read the resolved spec at spawn, fuse, hitscan settle and damage application instead of classifying a shot.
|
|
54
|
+
- A trace that carries splash now resolves its area damage at the contact point on the same tick, which is the #2272 hitscan-explosive configuration.
|
|
55
|
+
- `bun run check:path-portability` fails a test that asserts one platform's path separator: a `\\` literal under `toContain`/`toEqual`/`toBe`/`includes`/`endsWith`, a bare `toContain("/")`, a POSIX root handed to `join`/`resolve`, or a literal compared with an OS path result. It reports the file, the line and the `join(...)` that replaces it. A genuinely single-platform assertion is named in `scripts/path-portability-allow.json` with a reason, printed on every run. This is the class behind #2301 (`toContain("proofs\\flows")`) and the `agent-worktree.test.ts` assertions that only pass on one OS.
|
|
56
|
+
|
|
57
|
+
Verification:
|
|
58
|
+
- `bun test scripts/check-path-portability.test.ts` - pass, including the pre-fix `proofs\\flows` line failing through the gate and the fixed one passing
|
|
59
|
+
- `bun run check:path-portability` - ok, 3 allowlisted with reasons
|
|
60
|
+
- `bun test scripts/agent-worktree.test.ts scripts/shot-metrics.test.ts scripts/lane-audit.test.ts` - pass
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Merge pull request #2309 from Noisemaker111/agent/lane-path-portability
|
|
65
|
+
- `bun run land` hands the PR over instead of waiting for it: it pushes, opens or updates the PR, runs `--verify`, arms auto-merge and exits in seconds, printing the PR URL and `bun run ci:status --pr <n>`. A trivial change spent 5m09s inside `land`'s own poll loop, and one night's lanes spent 449 minutes watching CI. `--watch` still lands the old way and says it is deprecated.
|
|
66
|
+
- `bun run land:queue` is the only process that waits for CI. It lands ready PRs into `beta` in readiness order (auto-merge armed, else the `land:queue` label), rebases a branch whose base moved in a throwaway worktree so a moved base costs one CI run instead of two, waits once per PR and prints one line per PR with its wait time and run id. A lane hands off by running `bun run land`; it never rebases, labels or waits.
|
|
67
|
+
- `bun run lane:close` ends a lane in one command. It stages the worktree's tracked and new files (ignored ones such as `.scratch/` stay out), takes the commit message from the branch's own `changes/` fragment, writes that fragment from the commit summary when the diff has none, pushes, opens or updates the PR with the fragment as the body, arms auto-merge, and prints the PR URL and `bun run ci:status --pr <n>`. One lane's last 20 calls were 13 of commit, push, rebase and PR choreography; this replaces them with one. `bun run land --commit` does the same for a lane that skips `lane:close`, and a clean tree lands as it did before.
|
|
68
|
+
- `bun run bg:run -- <cmd>` starts a long command and returns its id at once; `bun run bg:await <id>` blocks on that task's exit file as an event, then prints its whole output and its exit code. It replaces waiting out a `.out` file with guessed intervals — one lane spent 10 minutes of `Start-Sleep` calls watching a capture it had already started. A runner that dies is answered rather than waited on forever.
|
|
69
|
+
- `--commit` prints every path it will commit before committing, and refuses the ones a lane did not author: a scratch artifact, a path git ignores, a lockfile staged without its `package.json` manifest, and a new `changes/` fragment whose name is not part of the branch's name. `--allow <path>` keeps one refused file on purpose. The first replay committed a test-written `changes/agent-lane-x.md` and this checkout's shared-install `bun.lock` drift; both are refused now, with the file list printed either way.
|
|
70
|
+
- A capture at a non-default tier writes its tier into the file name (`-low`, `-medium`, `-ultra`), so an ultra shot cannot overwrite the high one it was compared against.
|
|
71
|
+
|
|
72
|
+
Root cause: `--quality` carried the iteration preset, not the graphics tier, so a culling lane that asked for ultra got the game's default `high` on both sides of its comparison and could not prove a tier claim.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- `verify:fast`, `land` and `lane:close` refuse a checkout with no `node_modules` up front: they print the missing marker, the exact fix (`bun install` in this checkout, or `bun run agent:bootstrap`) and exit 78, instead of a `command not found` or a module error from a test file. A genuine failure still exits on its own code.
|
|
77
|
+
- `lane:audit --cwd` finds the lane whose own tool calls name that worktree, and prints `attributed ses_… (N references to …)`. Ambiguous ties name their candidates and pick the worst-waiting one; no candidate at all exits non-zero naming the path it searched, so `--fail-on-waiting` has a lane to judge instead of an empty table.
|
|
78
|
+
- `land` builds the PR body only from `changes/` fragments its own `git diff origin/beta...HEAD` adds, prints a line for every fragment it ignores, and falls back to the branch's commit subjects.
|
|
79
|
+
|
|
80
|
+
Root cause: a subagent session stores the directory of the agent that spawned it, so a lane was invisible to its own audit; `changes/` is shared by every lane on the branch, so "newest fragment" was free to be a stranger's note.
|
|
81
|
+
|
|
82
|
+
Before: `lane:audit --cwd …\lane-wait-audit` exited 1 with "no child sessions", and a body built from a stray `changes/` fragment claimed work the diff did not contain.
|
|
83
|
+
After: `lane:audit: attributed ses_e94f3aeedffe2CCMsTlrzgvez5 (136 references to …\lane-wait-audit)`, and `land: ignoring changes/scratch-leftover.md: this branch's diff against origin/beta does not add it`.
|
|
84
|
+
|
|
85
|
+
Verification:
|
|
86
|
+
- `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass
|
|
87
|
+
- `bun run verify:fast` — pass
|
|
88
|
+
- `bun run lane:audit --cwd <the real lane worktree> --hours 6` — attributed that lane by 136 references
|
|
89
|
+
- `lane:audit` classifies a call by what it runs, not by the text inside its arguments: `rg`, `grep`, `Select-String`, `findstr`, `Get-Content`, `cat`, `echo`, `Write-Output` and `git` (any subcommand) never count as a sleep or a CI watch, and quoted arguments are stripped before matching. A lane that greps for `Start-Sleep` or writes a PR body about `gh run watch` no longer records waiting it never did.
|
|
90
|
+
- Catches the waits it used to miss: `Start-Sleep` under `powershell -Command`/`pwsh -c`/`bash -lc`, `cmd /c "timeout /t"`, `Thread.sleep`, `Atomics.wait`, `Bun.sleep`, and `gh pr checks` inside a `for`/`while` loop.
|
|
91
|
+
|
|
92
|
+
Over the last 40 hours (209 lanes): sleep 1066.6 -> 1068.7 minutes, CI watch 421.8 -> 421.3 minutes. 15 calls stopped counting as waits, one real `Start-Sleep -Seconds 150` started counting.
|
|
93
|
+
|
|
94
|
+
Verification:
|
|
95
|
+
- `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts scripts/land.test.ts` — pass
|
|
96
|
+
- `bun run lane:audit --json --hours 40` before and after, compared per command
|
|
97
|
+
- `lane:close` reads `calls` as the count it is, takes waits from a new `waits` list in `lane:audit --json`, and refuses an unfamiliar audit shape with `the audit output shape changed: <field> is <type>, expected <type> (keys seen: …)` instead of a TypeError. Exit codes unchanged: a sleep or CI watch is 2, `--waive` records the reason, no percentage thresholds.
|
|
98
|
+
|
|
99
|
+
Root cause: `lane:audit --json` had always reported `calls` as a number, while `lane:close` held it as a list of call objects; nothing read the real document until lane attribution started returning a lane, so the gate that judges a finishing lane crashed on the first lane that had one.
|
|
100
|
+
|
|
101
|
+
Before: `lane:close` landed the PR, then died with `source.filter is not a function` at `waitingGate`.
|
|
102
|
+
After: the same captured output is judged, the waits are listed, and the lane gets exit 2 with a report skeleton.
|
|
103
|
+
|
|
104
|
+
Verification:
|
|
105
|
+
- `bun test scripts/lane-audit.test.ts scripts/lane-close.test.ts` — pass, including the captured-output fixture
|
|
106
|
+
- `bun run verify:fast` — pass
|
|
107
|
+
- `shoot --url ... --look` now sends the aim to the page it captures, so a lane can measure a branch on a dev runner it started itself. The read-back stays: a page that reports no applied aim still fails the capture instead of returning a plausible wrong view, and a page that applies `--param look=` itself can report it on `data-jg-look` or `window.__jgLook`. `--site --look` is refused at the command line, naming the `--url` form that works.
|
|
108
|
+
- `check:no-blocking-wait` judges code that waits, not prose that describes waiting. In `packages/<pkg>/src/meta` (the changelog mirror and its neighbours) single- and double-quoted text and comments are masked before matching, so a release note reading "one lane spent 10 minutes of `Start-Sleep`" no longer fails the gate; a command in a template literal still fails, and every other `scripts/**` path keeps the strict rule where a quoted command is a real finding. The 0.37.0 release cut failed `types` on `packages/core/src/meta/changelog.ts:46,55,56` with exactly that false positive.
|
|
109
|
+
- `@jgengine/rapier` now accumulates `step(dt)` into its fixed substeps instead of stepping once per call with `fixedDt` as the timestep. A 1 s move covered 0.5 s of simulated time at 30 fps and 2.4 s at 144 fps; it now covers 1 s at either rate, matching `PhysicsWorld` and the `PhysicsBackend` contract.
|
|
110
|
+
- `PhysicsWorld` bounds-floor friction is Coulomb (`friction × g × dt`) like contact friction, not a 60 Hz multiplicative brake: a crate sliding at 4 m/s travelled 0.165 m on the world floor and 0.970 m on a floor collider, and now travels 0.967 m on both.
|
|
111
|
+
- Player and character-controller gravity integrate trapezoidally, so a jump reaches `v²/2g` at any frame rate: the default jump peaked at 0.933 m at 30 fps and 1.026 m at 144 fps, and now at 1.050 m for both.
|
|
112
|
+
|
|
29
113
|
## 0.36.0
|
|
30
114
|
|
|
31
115
|
### Migrate
|
package/dist/GamePlayerShell.js
CHANGED
|
@@ -242,6 +242,15 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
|
|
|
242
242
|
delete document.documentElement.dataset.jgPresentation;
|
|
243
243
|
};
|
|
244
244
|
}, [playable]);
|
|
245
|
+
// The tier the shell actually resolved, so a capture can prove which one it measured.
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (typeof document === "undefined")
|
|
248
|
+
return;
|
|
249
|
+
document.documentElement.dataset.jgQuality = graphics.quality;
|
|
250
|
+
return () => {
|
|
251
|
+
delete document.documentElement.dataset.jgQuality;
|
|
252
|
+
};
|
|
253
|
+
}, [graphics.quality]);
|
|
245
254
|
useEffect(() => {
|
|
246
255
|
if (ctx === null || typeof document === "undefined")
|
|
247
256
|
return;
|
|
@@ -3,6 +3,7 @@ import { useFrame, useThree } from "@react-three/fiber";
|
|
|
3
3
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { devtools, } from "@jgengine/core/devtools/devtools";
|
|
5
5
|
import { heavyModels } from "../render/modelLoad.js";
|
|
6
|
+
import { triangleProbe } from "../render/triangleAttribution.js";
|
|
6
7
|
import { buildLeanReport } from "./devtoolsReports.js";
|
|
7
8
|
import { PerfPanel } from "./PerfPanel.js";
|
|
8
9
|
import { LogsPanel } from "./LogsPanel.js";
|
|
@@ -19,8 +20,41 @@ const RENDER_PROBE_FRAME_PRIORITY = -10;
|
|
|
19
20
|
export function DevtoolsRendererProbe() {
|
|
20
21
|
useEffect(() => devtools.probes.register("heavyModels", () => heavyModels()), []);
|
|
21
22
|
const gl = useThree((state) => state.gl);
|
|
23
|
+
const scene = useThree((state) => state.scene);
|
|
22
24
|
const lastSampleAt = useRef(0);
|
|
23
25
|
useEffect(() => devtools.probes.register("shaderPrograms", () => gl.info.programs?.length ?? 0), [gl]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// One walk serves the counters, the state line and the probe: a snapshot reads the same frame.
|
|
28
|
+
const probe = triangleProbe(scene);
|
|
29
|
+
let cachedAt = 0;
|
|
30
|
+
let cached = probe.table();
|
|
31
|
+
const read = () => {
|
|
32
|
+
const now = performance.now();
|
|
33
|
+
if (now - cachedAt > 500) {
|
|
34
|
+
cachedAt = now;
|
|
35
|
+
cached = probe.table();
|
|
36
|
+
}
|
|
37
|
+
return cached;
|
|
38
|
+
};
|
|
39
|
+
const disposeProbe = devtools.probes.register("render/triangles", () => read());
|
|
40
|
+
const disposeSystem = devtools.probes.registerSystem("render/triangles", {
|
|
41
|
+
counters: () => {
|
|
42
|
+
const { triangles, casters, lines } = read();
|
|
43
|
+
return { triangles, casters, lines: lines.length };
|
|
44
|
+
},
|
|
45
|
+
state: () => {
|
|
46
|
+
const { triangles, casters, lines } = read();
|
|
47
|
+
const top = lines[0];
|
|
48
|
+
return top === undefined
|
|
49
|
+
? `${triangles} triangles`
|
|
50
|
+
: `${triangles} triangles · casters ${casters} · top ${top.label} ${top.triangles}`;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return () => {
|
|
54
|
+
disposeProbe();
|
|
55
|
+
disposeSystem();
|
|
56
|
+
};
|
|
57
|
+
}, [scene]);
|
|
24
58
|
useEffect(() => {
|
|
25
59
|
// three resets gl.info after every render() call, so with a multi-pass
|
|
26
60
|
// composer the counters only ever hold the last fullscreen pass. Own the
|
|
@@ -21,21 +21,40 @@ export declare const SHADOW_DISTANCE_BY_MAP_SIZE: Readonly<Record<number, number
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function resolveShadowDistance(profile?: GraphicsProfile, authored?: number): number;
|
|
23
23
|
/**
|
|
24
|
-
* Frames between shadow-map refreshes for the cascade at `index`, nearest first.
|
|
25
|
-
*
|
|
26
|
-
* slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
|
|
24
|
+
* Frames between shadow-map refreshes for the cascade at `index`, nearest first.
|
|
25
|
+
* @deprecated Cascades refresh on a texel step; see {@link refreshCascadesOnTexelStep}.
|
|
27
26
|
* @internal
|
|
28
27
|
*/
|
|
29
28
|
export declare function cascadeShadowCadence(index: number, cascades: number): number;
|
|
29
|
+
/** Depth-axis steps worth of a texel the snapped light may drift before it re-anchors. */
|
|
30
|
+
export declare const CASCADE_DEPTH_STEP_TEXELS = 8;
|
|
31
|
+
/** World size of one texel on a cascade's own shadow map. @internal */
|
|
32
|
+
export declare function cascadeTexelSize(light: THREE.DirectionalLight): number;
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
* The cascade centre rounded to whole texels of its own shadow map, in the light's orientation.
|
|
35
|
+
* Lateral texels are what the eye sees move; the depth axis is coarser because it only shifts the
|
|
36
|
+
* ortho depth window. @internal
|
|
37
|
+
*/
|
|
38
|
+
export declare function snapCascadeCenter(center: THREE.Vector3, lightDirection: THREE.Vector3, texelSize: number, depthTexels?: number): THREE.Vector3;
|
|
39
|
+
/** Per-frame cascade refresh bookkeeping: the centre each map was last rendered with. */
|
|
40
|
+
export interface CascadeRefreshState {
|
|
41
|
+
centres: THREE.Vector3[];
|
|
42
|
+
/** Depth passes requested since the state was created. */
|
|
43
|
+
refreshes: number;
|
|
44
|
+
/** Leading cascades that refresh every frame whatever the snap says: a moving caster must not freeze. */
|
|
45
|
+
floor: number;
|
|
46
|
+
}
|
|
47
|
+
/** @internal */
|
|
48
|
+
export declare function createCascadeRefreshState(floor?: number): CascadeRefreshState;
|
|
49
|
+
/**
|
|
50
|
+
* Pin each cascade's light to its texel grid and request a depth pass where that grid step moved
|
|
51
|
+
* the light, plus every frame for the `floor` nearest cascades. `floor: 1` keeps the cascade holding
|
|
52
|
+
* the player live so a walking actor's shadow follows it while the camera stands still; the far
|
|
53
|
+
* cascades skip until the camera crosses a texel. An unmoved light keeps `autoUpdate` off, so the
|
|
54
|
+
* map and the sampling matrix always stay the pair they were rendered as.
|
|
36
55
|
* @internal
|
|
37
56
|
*/
|
|
38
|
-
export declare function
|
|
57
|
+
export declare function refreshCascadesOnTexelStep(lights: readonly THREE.DirectionalLight[], lightDirection: THREE.Vector3, state: CascadeRefreshState): number;
|
|
39
58
|
/**
|
|
40
59
|
* Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
|
|
41
60
|
* single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
|
|
@@ -2,7 +2,7 @@ import { useFrame, useThree } from "@react-three/fiber";
|
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
import * as THREE from "three";
|
|
4
4
|
import { CSM } from "three/examples/jsm/csm/CSM.js";
|
|
5
|
-
import { resolveCascadeCount, resolveShadowMapSize
|
|
5
|
+
import { resolveCascadeCount, resolveShadowMapSize } from "./shadowTiers.js";
|
|
6
6
|
/** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
|
|
7
7
|
export const DEFAULT_SHADOW_RADIUS = 1.25;
|
|
8
8
|
/** @internal Reach in metres used when neither the game nor a graphics profile sets one. */
|
|
@@ -33,9 +33,8 @@ export function resolveShadowDistance(profile, authored) {
|
|
|
33
33
|
return Math.min(profile.drawDistance, reach);
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Frames between shadow-map refreshes for the cascade at `index`, nearest first.
|
|
37
|
-
*
|
|
38
|
-
* slow camera barely crosses, so they pay the depth pass a half or a quarter as often.
|
|
36
|
+
* Frames between shadow-map refreshes for the cascade at `index`, nearest first.
|
|
37
|
+
* @deprecated Cascades refresh on a texel step; see {@link refreshCascadesOnTexelStep}.
|
|
39
38
|
* @internal
|
|
40
39
|
*/
|
|
41
40
|
export function cascadeShadowCadence(index, cascades) {
|
|
@@ -45,24 +44,70 @@ export function cascadeShadowCadence(index, cascades) {
|
|
|
45
44
|
return index === 0 ? 1 : 2;
|
|
46
45
|
return 2 ** (index - (cascades - 3));
|
|
47
46
|
}
|
|
47
|
+
/** Depth-axis steps worth of a texel the snapped light may drift before it re-anchors. */
|
|
48
|
+
export const CASCADE_DEPTH_STEP_TEXELS = 8;
|
|
49
|
+
const UP = new THREE.Vector3(0, 1, 0);
|
|
50
|
+
const orientMatrix = new THREE.Matrix4();
|
|
51
|
+
const inverseOrient = new THREE.Matrix4();
|
|
52
|
+
const localCentre = new THREE.Vector3();
|
|
53
|
+
/** World size of one texel on a cascade's own shadow map. @internal */
|
|
54
|
+
export function cascadeTexelSize(light) {
|
|
55
|
+
const shadowCamera = light.shadow.camera;
|
|
56
|
+
const span = Math.max(shadowCamera.right - shadowCamera.left, shadowCamera.top - shadowCamera.bottom);
|
|
57
|
+
return span / Math.max(1, light.shadow.mapSize.x);
|
|
58
|
+
}
|
|
48
59
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
* The cascade centre rounded to whole texels of its own shadow map, in the light's orientation.
|
|
61
|
+
* Lateral texels are what the eye sees move; the depth axis is coarser because it only shifts the
|
|
62
|
+
* ortho depth window. @internal
|
|
63
|
+
*/
|
|
64
|
+
export function snapCascadeCenter(center, lightDirection, texelSize, depthTexels = CASCADE_DEPTH_STEP_TEXELS) {
|
|
65
|
+
const texel = texelSize > 0 ? texelSize : 1;
|
|
66
|
+
orientMatrix.lookAt(new THREE.Vector3(), lightDirection, UP);
|
|
67
|
+
inverseOrient.copy(orientMatrix).invert();
|
|
68
|
+
localCentre.copy(center).applyMatrix4(inverseOrient);
|
|
69
|
+
localCentre.x = Math.round(localCentre.x / texel) * texel;
|
|
70
|
+
localCentre.y = Math.round(localCentre.y / texel) * texel;
|
|
71
|
+
const depth = texel * Math.max(1, Math.floor(depthTexels));
|
|
72
|
+
localCentre.z = Math.round(localCentre.z / depth) * depth;
|
|
73
|
+
return localCentre.applyMatrix4(orientMatrix);
|
|
74
|
+
}
|
|
75
|
+
/** @internal */
|
|
76
|
+
export function createCascadeRefreshState(floor = 1) {
|
|
77
|
+
return { centres: [], refreshes: 0, floor };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Pin each cascade's light to its texel grid and request a depth pass where that grid step moved
|
|
81
|
+
* the light, plus every frame for the `floor` nearest cascades. `floor: 1` keeps the cascade holding
|
|
82
|
+
* the player live so a walking actor's shadow follows it while the camera stands still; the far
|
|
83
|
+
* cascades skip until the camera crosses a texel. An unmoved light keeps `autoUpdate` off, so the
|
|
84
|
+
* map and the sampling matrix always stay the pair they were rendered as.
|
|
54
85
|
* @internal
|
|
55
86
|
*/
|
|
56
|
-
export function
|
|
57
|
-
|
|
87
|
+
export function refreshCascadesOnTexelStep(lights, lightDirection, state) {
|
|
88
|
+
let refreshed = 0;
|
|
58
89
|
for (let index = 0; index < lights.length; index += 1) {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
const light = lights[index];
|
|
91
|
+
const snapped = snapCascadeCenter(light.position, lightDirection, cascadeTexelSize(light));
|
|
92
|
+
const known = state.centres[index];
|
|
93
|
+
const moved = known === undefined || known.distanceToSquared(snapped) > 1e-12;
|
|
94
|
+
const refresh = moved || index < state.floor;
|
|
95
|
+
light.position.copy(snapped);
|
|
96
|
+
light.target.position.copy(snapped).add(lightDirection);
|
|
97
|
+
light.shadow.autoUpdate = refresh;
|
|
98
|
+
light.shadow.needsUpdate = false;
|
|
99
|
+
if (moved) {
|
|
100
|
+
if (known === undefined)
|
|
101
|
+
state.centres[index] = snapped.clone();
|
|
102
|
+
else
|
|
103
|
+
known.copy(snapped);
|
|
104
|
+
}
|
|
105
|
+
if (refresh) {
|
|
106
|
+
refreshed += 1;
|
|
107
|
+
state.refreshes += 1;
|
|
108
|
+
}
|
|
65
109
|
}
|
|
110
|
+
return refreshed;
|
|
66
111
|
}
|
|
67
112
|
function isStandardMaterial(mat) {
|
|
68
113
|
return mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial;
|
|
@@ -80,12 +125,12 @@ export function CascadedShadows({ entry, profile, modulation, }) {
|
|
|
80
125
|
const camera = useThree((s) => s.camera);
|
|
81
126
|
const scene = useThree((s) => s.scene);
|
|
82
127
|
const csmRef = useRef(null);
|
|
83
|
-
const
|
|
128
|
+
const lightDirectionRef = useRef(new THREE.Vector3());
|
|
129
|
+
const refreshRef = useRef(createCascadeRefreshState());
|
|
84
130
|
const baseIntensity = entry.intensity ?? 1.3;
|
|
85
131
|
const maxFar = resolveShadowDistance(profile, entry.shadowMaxFar);
|
|
86
132
|
const shadowMapSize = resolveShadowMapSize(profile, entry.shadowMapSize);
|
|
87
133
|
const cascades = Math.max(2, Math.min(4, Math.floor(resolveCascadeCount(profile, entry.cascades))));
|
|
88
|
-
const refreshScale = shadowRefreshScale(shadowMapSize);
|
|
89
134
|
useEffect(() => {
|
|
90
135
|
const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
|
|
91
136
|
if (lightDirection.lengthSq() < 1e-8)
|
|
@@ -112,6 +157,8 @@ export function CascadedShadows({ entry, profile, modulation, }) {
|
|
|
112
157
|
}
|
|
113
158
|
const unbind = bindCsmMaterials(scene, csm);
|
|
114
159
|
csmRef.current = csm;
|
|
160
|
+
lightDirectionRef.current.copy(lightDirection);
|
|
161
|
+
refreshRef.current = createCascadeRefreshState();
|
|
115
162
|
return () => {
|
|
116
163
|
unbind();
|
|
117
164
|
releaseCascadedShadows(csm);
|
|
@@ -145,7 +192,7 @@ export function CascadedShadows({ entry, profile, modulation, }) {
|
|
|
145
192
|
}
|
|
146
193
|
csm.camera = camera;
|
|
147
194
|
csm.update();
|
|
148
|
-
|
|
195
|
+
refreshCascadesOnTexelStep(csm.lights, lightDirectionRef.current, refreshRef.current);
|
|
149
196
|
});
|
|
150
197
|
return null;
|
|
151
198
|
}
|
|
@@ -15,16 +15,6 @@ export declare function resolveShadowMapSize(profile?: GraphicsProfile, authored
|
|
|
15
15
|
* @internal
|
|
16
16
|
*/
|
|
17
17
|
export declare function resolveCascadeCount(profile?: GraphicsProfile, authored?: number): number;
|
|
18
|
-
/**
|
|
19
|
-
* Multiplier on the far cascades' refresh interval, by shadow map size. A tier that halves its map
|
|
20
|
-
* also accepts a far cascade lagging a frame or two longer: at 1024 and below the depth pass is
|
|
21
|
-
* cheap per cascade but there are several of them, and a half-res silhouette moving is harder to
|
|
22
|
-
* see than a full-res one. `high` and `ultra` keep the cadence they had.
|
|
23
|
-
* @internal
|
|
24
|
-
*/
|
|
25
|
-
export declare const SHADOW_REFRESH_SCALE_BY_MAP_SIZE: Readonly<Record<number, number>>;
|
|
26
|
-
/** Refresh-interval multiplier for a shadow map of `mapSize`. @internal */
|
|
27
|
-
export declare function shadowRefreshScale(mapSize: number): number;
|
|
28
18
|
/**
|
|
29
19
|
* Map size at which a caster's silhouette is still a few texels wide, the reference
|
|
30
20
|
* {@link MIN_SHADOW_CASTER_RADIUS} was measured at. A smaller map resolves less, so the smallest
|
|
@@ -20,23 +20,6 @@ export function resolveCascadeCount(profile, authored) {
|
|
|
20
20
|
const wanted = authored ?? profile?.cascades ?? DEFAULT_CASCADES;
|
|
21
21
|
return profile === undefined ? wanted : Math.min(wanted, profile.cascades);
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Multiplier on the far cascades' refresh interval, by shadow map size. A tier that halves its map
|
|
25
|
-
* also accepts a far cascade lagging a frame or two longer: at 1024 and below the depth pass is
|
|
26
|
-
* cheap per cascade but there are several of them, and a half-res silhouette moving is harder to
|
|
27
|
-
* see than a full-res one. `high` and `ultra` keep the cadence they had.
|
|
28
|
-
* @internal
|
|
29
|
-
*/
|
|
30
|
-
export const SHADOW_REFRESH_SCALE_BY_MAP_SIZE = {
|
|
31
|
-
512: 2,
|
|
32
|
-
1024: 2,
|
|
33
|
-
2048: 1,
|
|
34
|
-
4096: 1,
|
|
35
|
-
};
|
|
36
|
-
/** Refresh-interval multiplier for a shadow map of `mapSize`. @internal */
|
|
37
|
-
export function shadowRefreshScale(mapSize) {
|
|
38
|
-
return SHADOW_REFRESH_SCALE_BY_MAP_SIZE[mapSize] ?? 1;
|
|
39
|
-
}
|
|
40
23
|
/**
|
|
41
24
|
* Map size at which a caster's silhouette is still a few texels wide, the reference
|
|
42
25
|
* {@link MIN_SHADOW_CASTER_RADIUS} was measured at. A smaller map resolves less, so the smallest
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
/** One line of the triangle table: the draws sharing one geometry inside one named model. */
|
|
3
|
+
export interface TriangleLine {
|
|
4
|
+
/** Model ancestor names plus the mesh name, or the material name when nothing is named. */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Triangles one instance submits. */
|
|
7
|
+
perInstance: number;
|
|
8
|
+
/** Instances drawn from this geometry. */
|
|
9
|
+
instances: number;
|
|
10
|
+
/** Draw calls this line costs. */
|
|
11
|
+
draws: number;
|
|
12
|
+
/** `perInstance * instances` - the triangles this line puts in the frame. */
|
|
13
|
+
triangles: number;
|
|
14
|
+
}
|
|
15
|
+
/** Where one scene's triangles are, biggest line first. */
|
|
16
|
+
export interface TriangleAttribution {
|
|
17
|
+
/** Every triangle the scene graph would submit for one camera pass, instances included. */
|
|
18
|
+
triangles: number;
|
|
19
|
+
/** How many of those belong to meshes that cast shadows, so the shadow pass pays them again. */
|
|
20
|
+
casters: number;
|
|
21
|
+
lines: readonly TriangleLine[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Sum a scene's triangles by model and geometry, which is the attribution a triangle budget needs:
|
|
25
|
+
* a total says a frame is heavy, a table says which authored object made it heavy. Walks the graph
|
|
26
|
+
* once; call it on demand, never per frame.
|
|
27
|
+
* @capability triangle-attribution per-model triangle table with instance counts, for perf probes
|
|
28
|
+
*/
|
|
29
|
+
export declare function attributeTriangles(root: THREE.Object3D, limit?: number): TriangleAttribution;
|
|
30
|
+
/**
|
|
31
|
+
* The `render/triangles` probe for one scene: the table, its two headline numbers, and the one-line
|
|
32
|
+
* summary a report prints. Reads the graph on every call, so wrap it in a short cache when a
|
|
33
|
+
* snapshot reads counters and state together.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export declare function triangleProbe(root: THREE.Object3D): {
|
|
37
|
+
table(): TriangleAttribution;
|
|
38
|
+
counters(): Record<string, number>;
|
|
39
|
+
state(): string;
|
|
40
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
function triangleCount(mesh) {
|
|
3
|
+
if (mesh.isPoints === true)
|
|
4
|
+
return 0;
|
|
5
|
+
if (mesh.isLine === true)
|
|
6
|
+
return 0;
|
|
7
|
+
const geometry = mesh.geometry;
|
|
8
|
+
if (geometry === undefined || geometry === null)
|
|
9
|
+
return 0;
|
|
10
|
+
const index = geometry.getIndex();
|
|
11
|
+
const position = geometry.getAttribute("position");
|
|
12
|
+
const vertices = index !== null ? index.count : position?.count ?? 0;
|
|
13
|
+
return Math.round(vertices / 3);
|
|
14
|
+
}
|
|
15
|
+
const MAX_LABEL_DEPTH = 3;
|
|
16
|
+
function labelOf(mesh) {
|
|
17
|
+
const names = [];
|
|
18
|
+
let node = mesh;
|
|
19
|
+
for (let depth = 0; node !== null && node !== undefined && depth < MAX_LABEL_DEPTH + 1; depth += 1) {
|
|
20
|
+
if (typeof node.name === "string" && node.name.length > 0)
|
|
21
|
+
names.push(node.name);
|
|
22
|
+
node = node.parent;
|
|
23
|
+
}
|
|
24
|
+
if (names.length > 0)
|
|
25
|
+
return names.reverse().join("/");
|
|
26
|
+
const material = mesh.material;
|
|
27
|
+
const first = Array.isArray(material) ? material[0] : material;
|
|
28
|
+
return first?.name || first?.type || mesh.type;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Sum a scene's triangles by model and geometry, which is the attribution a triangle budget needs:
|
|
32
|
+
* a total says a frame is heavy, a table says which authored object made it heavy. Walks the graph
|
|
33
|
+
* once; call it on demand, never per frame.
|
|
34
|
+
* @capability triangle-attribution per-model triangle table with instance counts, for perf probes
|
|
35
|
+
*/
|
|
36
|
+
export function attributeTriangles(root, limit = 12) {
|
|
37
|
+
const lines = new Map();
|
|
38
|
+
let triangles = 0;
|
|
39
|
+
let casters = 0;
|
|
40
|
+
root.traverse((node) => {
|
|
41
|
+
const mesh = node;
|
|
42
|
+
const per = triangleCount(mesh);
|
|
43
|
+
if (per === 0)
|
|
44
|
+
return;
|
|
45
|
+
const instances = mesh.isInstancedMesh === true
|
|
46
|
+
? Math.max(0, mesh.count)
|
|
47
|
+
: 1;
|
|
48
|
+
const total = per * instances;
|
|
49
|
+
triangles += total;
|
|
50
|
+
if (mesh.castShadow === true)
|
|
51
|
+
casters += total;
|
|
52
|
+
const label = labelOf(mesh);
|
|
53
|
+
const key = `${label}#${mesh.geometry.uuid}`;
|
|
54
|
+
const line = lines.get(key);
|
|
55
|
+
if (line === undefined)
|
|
56
|
+
lines.set(key, { label, perInstance: per, instances, draws: 1, triangles: total });
|
|
57
|
+
else {
|
|
58
|
+
line.instances += instances;
|
|
59
|
+
line.draws += 1;
|
|
60
|
+
line.triangles += total;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const sorted = [...lines.values()].sort((a, b) => b.triangles - a.triangles);
|
|
64
|
+
return { triangles, casters, lines: limit > 0 ? sorted.slice(0, limit) : sorted };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The `render/triangles` probe for one scene: the table, its two headline numbers, and the one-line
|
|
68
|
+
* summary a report prints. Reads the graph on every call, so wrap it in a short cache when a
|
|
69
|
+
* snapshot reads counters and state together.
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
export function triangleProbe(root) {
|
|
73
|
+
const table = () => attributeTriangles(root);
|
|
74
|
+
return {
|
|
75
|
+
table,
|
|
76
|
+
counters: () => {
|
|
77
|
+
const { triangles, casters, lines } = table();
|
|
78
|
+
return { triangles, casters, lines: lines.length };
|
|
79
|
+
},
|
|
80
|
+
state: () => {
|
|
81
|
+
const { triangles, casters, lines } = table();
|
|
82
|
+
const top = lines[0];
|
|
83
|
+
return top === undefined
|
|
84
|
+
? `${triangles} triangles`
|
|
85
|
+
: `${triangles} triangles · casters ${casters} · top ${top.label} ${top.triangles}`;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
1
2
|
import type { ModelConfig } from "@jgengine/core/game/playableGame";
|
|
2
3
|
import { type ScatterInstance } from "@jgengine/core/world/scatterRegion";
|
|
4
|
+
/** One instanced draw unit - a single species within a single spatial chunk. @internal */
|
|
5
|
+
export interface ScatterBatch {
|
|
6
|
+
key: string;
|
|
7
|
+
item: string;
|
|
8
|
+
/** Per-instance transforms, parallel to `instances`. */
|
|
9
|
+
matrices: THREE.Matrix4[];
|
|
10
|
+
/** The placements themselves, so the level pass can read a stable id and a world position. */
|
|
11
|
+
instances: ScatterInstance[];
|
|
12
|
+
}
|
|
13
|
+
/** Groups resolved instances by spatial chunk then species so each batch is one cullable draw. @internal */
|
|
14
|
+
export declare function scatterBatchInstances(instances: readonly ScatterInstance[], chunkSize: number): ScatterBatch[];
|
|
3
15
|
/** Props for {@link InstancedScatter}: the resolved instances plus chunking/instance-cap knobs. */
|
|
4
16
|
export interface InstancedScatterProps {
|
|
5
17
|
instances: readonly ScatterInstance[];
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Suspense, useEffect, useMemo, useRef } from "react";
|
|
3
|
-
import { useLoader } from "@react-three/fiber";
|
|
2
|
+
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { useFrame, useLoader, useThree } from "@react-three/fiber";
|
|
4
4
|
import * as THREE from "three";
|
|
5
5
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
6
6
|
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
|
7
7
|
import { chunkScatterInstances } from "@jgengine/core/world/scatterRegion";
|
|
8
8
|
import { beginFallbackSeam, reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
|
|
9
|
+
import { createModelVariantSelector } from "@jgengine/core/world/modelLod";
|
|
10
|
+
import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
|
|
9
11
|
import { buildScatterProxy } from "./scatterProxies.js";
|
|
10
12
|
import { buildScatterModelSources, disposeScatterModelSources } from "./scatterModels.js";
|
|
13
|
+
import { writeScatterVariantLevel } from "./scatterVariants.js";
|
|
11
14
|
/** Chunk edge (m): dense fields split into this-sized buckets, each frustum-culled independently. */
|
|
12
15
|
const DEFAULT_CHUNK_SIZE = 24;
|
|
13
16
|
const DEFAULT_MAX_INSTANCES = 20000;
|
|
14
|
-
/** Groups resolved instances by spatial chunk then species so each batch is one cullable draw. */
|
|
15
|
-
function
|
|
17
|
+
/** Groups resolved instances by spatial chunk then species so each batch is one cullable draw. @internal */
|
|
18
|
+
export function scatterBatchInstances(instances, chunkSize) {
|
|
16
19
|
const batches = [];
|
|
17
20
|
const matrix = new THREE.Matrix4();
|
|
18
21
|
const position = new THREE.Vector3();
|
|
@@ -29,12 +32,15 @@ function batchInstances(instances, chunkSize) {
|
|
|
29
32
|
matrix.compose(position, quaternion, scale);
|
|
30
33
|
const bucket = byItem.get(instance.item);
|
|
31
34
|
if (bucket === undefined)
|
|
32
|
-
byItem.set(instance.item, [matrix.clone()]);
|
|
33
|
-
else
|
|
34
|
-
bucket.push(matrix.clone());
|
|
35
|
+
byItem.set(instance.item, { matrices: [matrix.clone()], instances: [instance] });
|
|
36
|
+
else {
|
|
37
|
+
bucket.matrices.push(matrix.clone());
|
|
38
|
+
bucket.instances.push(instance);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const [item, bucket] of byItem) {
|
|
42
|
+
batches.push({ key: `${chunk.key}:${item}`, item, matrices: bucket.matrices, instances: bucket.instances });
|
|
35
43
|
}
|
|
36
|
-
for (const [item, matrices] of byItem)
|
|
37
|
-
batches.push({ key: `${chunk.key}:${item}`, item, matrices });
|
|
38
44
|
}
|
|
39
45
|
return batches;
|
|
40
46
|
}
|
|
@@ -71,8 +77,68 @@ function ScatterModelSourceInstances({ source, matrices }) {
|
|
|
71
77
|
}, [matrices, source]);
|
|
72
78
|
return _jsx("instancedMesh", { ref: meshRef, args: [source.geometry, source.material, matrices.length], castShadow: true, receiveShadow: true });
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* One detail level's draw source, instanced across every chunk of one species. Each frame it asks the
|
|
82
|
+
* shared variant selector for each placement's level and draws only the ones it owns, composing into
|
|
83
|
+
* a reserved matrix slot so the far level's instances cost a second instanced draw, not per-frame
|
|
84
|
+
* allocation. A cell that holds near and far placements feeds both levels from the same batch.
|
|
85
|
+
*/
|
|
86
|
+
function ScatterVariantSourceInstances({ source, batch, level, selector }) {
|
|
87
|
+
const meshRef = useRef(null);
|
|
88
|
+
const camera = useThree((state) => state.camera);
|
|
89
|
+
const slots = useMemo(() => batch.matrices.map(() => new THREE.Matrix4()), [batch]);
|
|
90
|
+
const cameraPosition = useMemo(() => new THREE.Vector3(), []);
|
|
91
|
+
const lastCamera = useRef(new THREE.Vector3(Number.POSITIVE_INFINITY, 0, 0));
|
|
92
|
+
const writeLevel = useCallback((from) => {
|
|
93
|
+
const mesh = meshRef.current;
|
|
94
|
+
if (mesh === null)
|
|
95
|
+
return;
|
|
96
|
+
const count = writeScatterVariantLevel(level, selector, from, batch.instances, batch.matrices, source.localMatrix, slots);
|
|
97
|
+
mesh.count = count;
|
|
98
|
+
mesh.visible = count > 0;
|
|
99
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
100
|
+
if (count > 0)
|
|
101
|
+
mesh.computeBoundingSphere();
|
|
102
|
+
}, [level, selector, batch, source, slots]);
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
camera.getWorldPosition(cameraPosition);
|
|
105
|
+
lastCamera.current.copy(cameraPosition);
|
|
106
|
+
writeLevel(cameraPosition);
|
|
107
|
+
}, [camera, writeLevel, cameraPosition]);
|
|
108
|
+
useFrame((state) => {
|
|
109
|
+
state.camera.getWorldPosition(cameraPosition);
|
|
110
|
+
if (cameraPosition.distanceToSquared(lastCamera.current) < 0.25)
|
|
111
|
+
return;
|
|
112
|
+
lastCamera.current.copy(cameraPosition);
|
|
113
|
+
writeLevel(cameraPosition);
|
|
114
|
+
}, CAMERA_POST_FRAME_PRIORITY + 0.1);
|
|
115
|
+
return (_jsx("instancedMesh", { ref: meshRef, args: [source.geometry, source.material, batch.matrices.length], castShadow: source.castShadow ?? true, receiveShadow: source.receiveShadow ?? true }));
|
|
116
|
+
}
|
|
117
|
+
/** Renders one level of a variant model: the level's own GLB harvested into instanced draws. */
|
|
118
|
+
function ScatterVariantLevel({ model, url, level, selector, batches }) {
|
|
119
|
+
const gltf = useLoader(GLTFLoader, url, (loader) => {
|
|
120
|
+
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
121
|
+
});
|
|
122
|
+
const { sources, root } = useMemo(() => {
|
|
123
|
+
const levelModel = { ...model, url, variants: undefined };
|
|
124
|
+
return buildScatterModelSources(gltf.scene, levelModel);
|
|
125
|
+
}, [gltf, model, url]);
|
|
126
|
+
useEffect(() => () => disposeScatterModelSources(root), [root]);
|
|
127
|
+
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
|
+
/**
|
|
130
|
+
* A species whose model declares detail levels: one GLB per level, each level's instanced draws
|
|
131
|
+
* claiming only the placements the one shared selector put on it. The selector lives here so a
|
|
132
|
+
* placement keeps its hysteresis across every level component instead of each level deciding alone.
|
|
133
|
+
*/
|
|
134
|
+
function ScatterVariantSpecies({ model, batches }) {
|
|
135
|
+
const variants = model.variants;
|
|
136
|
+
const selector = useMemo(() => createModelVariantSelector(variants), [variants]);
|
|
137
|
+
useEffect(() => () => selector.clear(), [selector]);
|
|
138
|
+
return (_jsx(_Fragment, { children: variants.levels.map((level, index) => (_jsx(Suspense, { fallback: null, children: _jsx(ScatterVariantLevel, { model: model, url: level.url, level: index, selector: selector, batches: batches }) }, `${level.url}:${index}`))) }));
|
|
139
|
+
}
|
|
74
140
|
/** Loads one species' real catalog GLB once, harvests its instanceable draw sources, then GPU-instances them across every chunk that species appears in. */
|
|
75
|
-
function
|
|
141
|
+
function ScatterSingleSpecies({ model, batches }) {
|
|
76
142
|
const gltf = useLoader(GLTFLoader, model.url, (loader) => {
|
|
77
143
|
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
78
144
|
});
|
|
@@ -80,6 +146,12 @@ function ScatterModelSpecies({ model, batches }) {
|
|
|
80
146
|
useEffect(() => () => disposeScatterModelSources(root), [root]);
|
|
81
147
|
return (_jsx(_Fragment, { children: sources.map((source, sourceIndex) => batches.map((batch) => (_jsx(ScatterModelSourceInstances, { source: source, matrices: batch.matrices }, `${batch.key}:${sourceIndex}`)))) }));
|
|
82
148
|
}
|
|
149
|
+
/** One species draw: the level switch for a model with `variants`, the single GLB otherwise. */
|
|
150
|
+
function ScatterModelSpecies({ model, batches }) {
|
|
151
|
+
return model.variants === undefined
|
|
152
|
+
? _jsx(ScatterSingleSpecies, { model: model, batches: batches })
|
|
153
|
+
: _jsx(ScatterVariantSpecies, { model: model, batches: batches });
|
|
154
|
+
}
|
|
83
155
|
/**
|
|
84
156
|
* Renders resolved {@link ScatterInstance}s grouped into per-chunk, per-species GPU-instanced draws
|
|
85
157
|
* that frustum-cull independently. Species with no `resolveItem` mapping (or a `null` result) render
|
|
@@ -89,7 +161,7 @@ function ScatterModelSpecies({ model, batches }) {
|
|
|
89
161
|
* `resolveScatter` — never one node per placement.
|
|
90
162
|
*/
|
|
91
163
|
export function InstancedScatter({ instances, chunkSize = DEFAULT_CHUNK_SIZE, maxInstances = DEFAULT_MAX_INSTANCES, resolveItem, }) {
|
|
92
|
-
const batches = useMemo(() =>
|
|
164
|
+
const batches = useMemo(() => scatterBatchInstances(instances.length > maxInstances ? instances.slice(0, maxInstances) : instances, chunkSize), [instances, chunkSize, maxInstances]);
|
|
93
165
|
const { proxyBatches, modelSpecies } = useMemo(() => {
|
|
94
166
|
// Dev diagnostic (no-op unless armed): re-zero the scatter seam before re-tallying which species
|
|
95
167
|
// fall to a stylized proxy (no resolveItem mapping / a null result). Reported once per species.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type * as THREE from "three";
|
|
2
|
+
import type { ModelVariantSelector } from "@jgengine/core/world/modelLod";
|
|
3
|
+
/** A scatter placement as the level pass reads it: a stable id and a world position. */
|
|
4
|
+
export interface ScatterVariantMember {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly x: number;
|
|
7
|
+
readonly y: number;
|
|
8
|
+
readonly z: number;
|
|
9
|
+
}
|
|
10
|
+
/** A camera position the level pass measures from. */
|
|
11
|
+
export interface ScatterVariantCamera {
|
|
12
|
+
readonly x: number;
|
|
13
|
+
readonly y: number;
|
|
14
|
+
readonly z: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Writes the matrices of the members the shared variant selector put on `level` into `out`, in source
|
|
18
|
+
* order, and returns how many were written. `out` is the level mesh's reserved capacity (one matrix
|
|
19
|
+
* per member), so a frame writes matrices and `count` without allocating.
|
|
20
|
+
*
|
|
21
|
+
* Every member is asked for its level through the same {@link ModelVariantSelector} the static prop
|
|
22
|
+
* path uses, so hysteresis and the `lodBandIndex` boundary are identical, and a chunk cell holding
|
|
23
|
+
* near and far members of one species splits between this level's draw and the other levels'.
|
|
24
|
+
*
|
|
25
|
+
* `keep` lets an orthogonal instance cull (tier distance, occlusion) drop a member without changing
|
|
26
|
+
* the level answer, so culling and level selection compose in one pass over the batch.
|
|
27
|
+
*/
|
|
28
|
+
export declare function writeScatterVariantLevel(level: number, selector: ModelVariantSelector, camera: ScatterVariantCamera, members: readonly ScatterVariantMember[], transforms: readonly THREE.Matrix4[], localMatrix: THREE.Matrix4, out: THREE.Matrix4[], keep?: (index: number) => boolean): number;
|
|
29
|
+
/** Triangle total for per-level instance counts, so a fixture reports real geometry, not instance counts. */
|
|
30
|
+
export declare function scatterVariantTriangles(counts: readonly number[], triangles: readonly number[]): number;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the matrices of the members the shared variant selector put on `level` into `out`, in source
|
|
3
|
+
* order, and returns how many were written. `out` is the level mesh's reserved capacity (one matrix
|
|
4
|
+
* per member), so a frame writes matrices and `count` without allocating.
|
|
5
|
+
*
|
|
6
|
+
* Every member is asked for its level through the same {@link ModelVariantSelector} the static prop
|
|
7
|
+
* path uses, so hysteresis and the `lodBandIndex` boundary are identical, and a chunk cell holding
|
|
8
|
+
* near and far members of one species splits between this level's draw and the other levels'.
|
|
9
|
+
*
|
|
10
|
+
* `keep` lets an orthogonal instance cull (tier distance, occlusion) drop a member without changing
|
|
11
|
+
* the level answer, so culling and level selection compose in one pass over the batch.
|
|
12
|
+
*/
|
|
13
|
+
export function writeScatterVariantLevel(level, selector, camera, members, transforms, localMatrix, out, keep) {
|
|
14
|
+
let count = 0;
|
|
15
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
16
|
+
const member = members[index];
|
|
17
|
+
const dx = member.x - camera.x;
|
|
18
|
+
const dy = member.y - camera.y;
|
|
19
|
+
const dz = member.z - camera.z;
|
|
20
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
21
|
+
if (selector.level(member.id, distance) !== level)
|
|
22
|
+
continue;
|
|
23
|
+
if (keep !== undefined && !keep(index))
|
|
24
|
+
continue;
|
|
25
|
+
out[count].multiplyMatrices(transforms[index], localMatrix);
|
|
26
|
+
count += 1;
|
|
27
|
+
}
|
|
28
|
+
return count;
|
|
29
|
+
}
|
|
30
|
+
/** Triangle total for per-level instance counts, so a fixture reports real geometry, not instance counts. */
|
|
31
|
+
export function scatterVariantTriangles(counts, triangles) {
|
|
32
|
+
let total = 0;
|
|
33
|
+
for (let index = 0; index < counts.length; index += 1)
|
|
34
|
+
total += (counts[index] ?? 0) * (triangles[index] ?? 0);
|
|
35
|
+
return total;
|
|
36
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
4
5
|
import { type StaticObjectBatch } from "./staticObjectBatches.js";
|
|
5
6
|
/**
|
|
6
7
|
* Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
|
|
@@ -14,6 +15,23 @@ export declare function useStaticObjectBatchProbe(scene: THREE.Object3D, stats:
|
|
|
14
15
|
instances: number;
|
|
15
16
|
singles: number;
|
|
16
17
|
}): void;
|
|
18
|
+
/** 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 }: {
|
|
20
|
+
objects: readonly SceneObject[];
|
|
21
|
+
level: number;
|
|
22
|
+
selector: ModelVariantSelector;
|
|
23
|
+
single: (object: SceneObject) => ReactNode;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
/**
|
|
26
|
+
* A batch whose model declares detail levels: every level draws its own instanced meshes and claims
|
|
27
|
+
* only the instances the shared distance switch put on it, so near and far trees of one species keep
|
|
28
|
+
* their own mesh inside one batch cell.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export declare function VariantStaticObjects({ batch, single }: {
|
|
32
|
+
batch: StaticObjectBatch;
|
|
33
|
+
single: (object: SceneObject) => ReactNode;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
17
35
|
/** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
|
|
18
36
|
export declare function StaticObjectModels({ batch, single }: {
|
|
19
37
|
batch: StaticObjectBatch;
|
|
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useFrame, useLoader } from "@react-three/fiber";
|
|
3
3
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
4
4
|
import * as THREE from "three";
|
|
5
|
+
import { createModelVariantSelector } from "@jgengine/core/world/modelLod";
|
|
5
6
|
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
6
7
|
import { useGameContext } from "@jgengine/react/provider";
|
|
7
8
|
import { sharedGltfLoader } from "../render/modelLoad.js";
|
|
@@ -31,11 +32,26 @@ export function useStaticObjectBatchProbe(scene, stats) {
|
|
|
31
32
|
},
|
|
32
33
|
}), [scene]);
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
/** Live members of one batch after the per-frame compatibility gate removed any that changed at runtime. */
|
|
36
|
+
function useBatchMembers(batch) {
|
|
37
|
+
const ctx = useGameContext();
|
|
38
|
+
const [excluded, setExcluded] = useState([]);
|
|
39
|
+
const fallbackObjects = useRef(new Map());
|
|
40
|
+
useFrame(() => {
|
|
41
|
+
const next = incompatibleStaticObjectIds(batch, ctx.scene.object.get, ctx.scene.object.catalog, excluded, fallbackObjects.current);
|
|
42
|
+
if (next !== excluded)
|
|
43
|
+
setExcluded(next);
|
|
44
|
+
});
|
|
45
|
+
const objects = useMemo(() => batch.objects.filter((object) => !excluded.includes(object.instanceId)), [batch, excluded]);
|
|
46
|
+
return { objects, excluded };
|
|
47
|
+
}
|
|
48
|
+
function ObjectSource({ source, objects, batch, selector, level }) {
|
|
35
49
|
const ctx = useGameContext();
|
|
36
50
|
const visibility = useRenderVisibility();
|
|
37
51
|
const ref = useRef(null);
|
|
38
52
|
const scratch = useMemo(() => new THREE.Object3D(), []);
|
|
53
|
+
const cameraPosition = useRef(new THREE.Vector3());
|
|
54
|
+
const probe = useMemo(() => new THREE.Vector3(), []);
|
|
39
55
|
const attach = useCallback((mesh) => {
|
|
40
56
|
ref.current = mesh;
|
|
41
57
|
if (mesh !== null)
|
|
@@ -45,26 +61,26 @@ function ObjectSource({ source, objects, batch }) {
|
|
|
45
61
|
const mesh = ref.current;
|
|
46
62
|
return () => { mesh?.dispose(); };
|
|
47
63
|
}, [objects.length, source]);
|
|
48
|
-
useFrame(() => {
|
|
49
|
-
if (ref.current
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
useFrame((state) => {
|
|
65
|
+
if (ref.current === null)
|
|
66
|
+
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
|
+
}
|
|
73
|
+
syncStaticObjectSource(ref.current, source, objects, (id) => {
|
|
74
|
+
const object = ctx.scene.object.get(id);
|
|
75
|
+
return object !== null && object.catalogId === batch.objects[0].catalogId &&
|
|
76
|
+
isCompatibleStaticObject(object, batch.model, ctx.scene.object.catalog(id)) ? object : null;
|
|
77
|
+
}, visibility.current, scratch, accept);
|
|
55
78
|
}, CAMERA_POST_FRAME_PRIORITY + 0.1);
|
|
56
79
|
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 });
|
|
57
80
|
}
|
|
58
81
|
function LoadedStaticObjects({ batch, single }) {
|
|
59
82
|
const ctx = useGameContext();
|
|
60
|
-
const
|
|
61
|
-
const fallbackObjects = useRef(new Map());
|
|
62
|
-
useFrame(() => {
|
|
63
|
-
const next = incompatibleStaticObjectIds(batch, ctx.scene.object.get, ctx.scene.object.catalog, excluded, fallbackObjects.current);
|
|
64
|
-
if (next !== excluded)
|
|
65
|
-
setExcluded(next);
|
|
66
|
-
});
|
|
67
|
-
const objects = useMemo(() => batch.objects.filter((object) => !excluded.includes(object.instanceId)), [batch, excluded]);
|
|
83
|
+
const { objects, excluded } = useBatchMembers(batch);
|
|
68
84
|
const gltf = useLoader(sharedGltfLoader, batch.model.url);
|
|
69
85
|
const supported = useMemo(() => canInstanceStaticScene(gltf.scene, gltf.animations), [gltf]);
|
|
70
86
|
const harvested = useMemo(() => supported ? buildScatterModelSources(gltf.scene, batch.model) : null, [supported, gltf, batch.model]);
|
|
@@ -94,7 +110,75 @@ function LoadedStaticObjects({ batch, single }) {
|
|
|
94
110
|
return _jsx(_Fragment, { children: batch.objects.map(single) });
|
|
95
111
|
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); })] });
|
|
96
112
|
}
|
|
113
|
+
/** 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 }) {
|
|
115
|
+
const ctx = useGameContext();
|
|
116
|
+
const gltf = useLoader(sharedGltfLoader, url);
|
|
117
|
+
const supported = useMemo(() => canInstanceStaticScene(gltf.scene, gltf.animations), [gltf]);
|
|
118
|
+
const harvested = useMemo(() => supported ? buildScatterModelSources(gltf.scene, model) : null, [supported, gltf, model]);
|
|
119
|
+
useEffect(() => () => { if (harvested !== null)
|
|
120
|
+
disposeScatterModelSources(harvested.root); }, [harvested]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (harvested === null || !measure)
|
|
123
|
+
return;
|
|
124
|
+
const root = new THREE.Group();
|
|
125
|
+
for (const source of harvested.sources) {
|
|
126
|
+
const mesh = new THREE.Mesh(source.geometry, source.material);
|
|
127
|
+
mesh.matrixAutoUpdate = false;
|
|
128
|
+
mesh.matrix.copy(source.localMatrix);
|
|
129
|
+
root.add(mesh);
|
|
130
|
+
}
|
|
131
|
+
const key = batch.objects[0].catalogId;
|
|
132
|
+
if (batch.model.dims?.maxY === undefined) {
|
|
133
|
+
const bounds = measureLocalBounds(root);
|
|
134
|
+
if (bounds !== null)
|
|
135
|
+
reportMeasuredBounds(ctx, "object", key, bounds);
|
|
136
|
+
}
|
|
137
|
+
const triangles = measureLocalCollisionTriangles(root);
|
|
138
|
+
if (triangles !== null)
|
|
139
|
+
reportMeasuredCollisionMesh(ctx, "object", key, triangles);
|
|
140
|
+
}, [ctx, harvested, batch, measure]);
|
|
141
|
+
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)) });
|
|
144
|
+
}
|
|
145
|
+
/** 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 }) {
|
|
147
|
+
const ctx = useGameContext();
|
|
148
|
+
const visibility = useRenderVisibility();
|
|
149
|
+
const camera = useMemo(() => new THREE.Vector3(), []);
|
|
150
|
+
const probe = useMemo(() => new THREE.Vector3(), []);
|
|
151
|
+
const [members, setMembers] = useState(objects);
|
|
152
|
+
useFrame((state) => {
|
|
153
|
+
state.camera.getWorldPosition(camera);
|
|
154
|
+
const next = objects.flatMap((object) => {
|
|
155
|
+
const live = ctx.scene.object.get(object.instanceId);
|
|
156
|
+
if (live === null || !visibility.current(live.instanceId))
|
|
157
|
+
return [];
|
|
158
|
+
probe.set(live.position[0], live.position[1], live.position[2]);
|
|
159
|
+
return selector.level(live.instanceId, camera.distanceTo(probe)) === level ? [live] : [];
|
|
160
|
+
});
|
|
161
|
+
if (next.length !== members.length || next.some((member, index) => member !== members[index]))
|
|
162
|
+
setMembers(next);
|
|
163
|
+
});
|
|
164
|
+
return _jsx(_Fragment, { children: members.map(single) });
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A batch whose model declares detail levels: every level draws its own instanced meshes and claims
|
|
168
|
+
* only the instances the shared distance switch put on it, so near and far trees of one species keep
|
|
169
|
+
* their own mesh inside one batch cell.
|
|
170
|
+
* @internal
|
|
171
|
+
*/
|
|
172
|
+
export function VariantStaticObjects({ batch, single }) {
|
|
173
|
+
const ctx = useGameContext();
|
|
174
|
+
const { objects, excluded } = useBatchMembers(batch);
|
|
175
|
+
const levels = batch.model.variants.levels;
|
|
176
|
+
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); })] });
|
|
178
|
+
}
|
|
97
179
|
/** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
|
|
98
180
|
export function StaticObjectModels({ batch, single }) {
|
|
99
|
-
return _jsx(IsolatedModelPart, { model: batch.model, seam: "object", children:
|
|
181
|
+
return (_jsx(IsolatedModelPart, { model: batch.model, seam: "object", children: batch.model.variants === undefined
|
|
182
|
+
? _jsx(LoadedStaticObjects, { batch: batch, single: single })
|
|
183
|
+
: _jsx(VariantStaticObjects, { batch: batch, single: single }) }));
|
|
100
184
|
}
|
|
@@ -63,5 +63,5 @@ export interface StaticBatchCounts {
|
|
|
63
63
|
export declare function countStaticBatchDraws(scene: THREE.Object3D): StaticBatchCounts;
|
|
64
64
|
/** Rejects loaded scenes whose behavior cannot be represented by static instanced draws. @internal */
|
|
65
65
|
export declare function canInstanceStaticScene(scene: THREE.Object3D, clips: readonly THREE.AnimationClip[]): boolean;
|
|
66
|
-
/** Compact visible placements and update picking identities without allocating per instance. @internal */
|
|
67
|
-
export declare function syncStaticObjectSource(mesh: THREE.InstancedMesh, source: ScatterModelSource, objects: readonly SceneObject[], get: (id: string) => SceneObject | null, visible: (id: string) => boolean, scratch: THREE.Object3D): void;
|
|
66
|
+
/** Compact visible placements and update picking identities without allocating per instance. `accept` narrows the range to one detail level of a variant model. @internal */
|
|
67
|
+
export declare function syncStaticObjectSource(mesh: THREE.InstancedMesh, source: ScatterModelSource, objects: readonly SceneObject[], get: (id: string) => SceneObject | null, visible: (id: string) => boolean, scratch: THREE.Object3D, accept?: (live: SceneObject) => boolean): void;
|
|
@@ -155,8 +155,8 @@ export function canInstanceStaticScene(scene, clips) {
|
|
|
155
155
|
});
|
|
156
156
|
return supported;
|
|
157
157
|
}
|
|
158
|
-
/** Compact visible placements and update picking identities without allocating per instance. @internal */
|
|
159
|
-
export function syncStaticObjectSource(mesh, source, objects, get, visible, scratch) {
|
|
158
|
+
/** Compact visible placements and update picking identities without allocating per instance. `accept` narrows the range to one detail level of a variant model. @internal */
|
|
159
|
+
export function syncStaticObjectSource(mesh, source, objects, get, visible, scratch, accept) {
|
|
160
160
|
const ids = (mesh.userData[POINTER_OBJECT_INSTANCES_KEY] ??= []);
|
|
161
161
|
const poses = (mesh.userData["jgStaticObjectPoses"] ??= []);
|
|
162
162
|
if (mesh.userData["jgStaticObjectSource"] !== source) {
|
|
@@ -168,7 +168,7 @@ export function syncStaticObjectSource(mesh, source, objects, get, visible, scra
|
|
|
168
168
|
const composed = scratch.matrixWorld;
|
|
169
169
|
for (const object of objects) {
|
|
170
170
|
const live = get(object.instanceId);
|
|
171
|
-
if (live === null || !visible(live.instanceId))
|
|
171
|
+
if (live === null || !visible(live.instanceId) || (accept !== undefined && !accept(live)))
|
|
172
172
|
continue;
|
|
173
173
|
if (poses[count] === live) {
|
|
174
174
|
ids[count++] = live.instanceId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
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.
|
|
43
|
-
"@jgengine/react": "^0.
|
|
44
|
-
"@jgengine/ws": "^0.
|
|
42
|
+
"@jgengine/core": "^0.37.0",
|
|
43
|
+
"@jgengine/react": "^0.37.0",
|
|
44
|
+
"@jgengine/ws": "^0.37.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@react-three/drei": "^10.0.0",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -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.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.37.0" — 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.
|
|
288
|
+
- `VERSION` (const): const VERSION: "0.37.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -2829,6 +2829,8 @@
|
|
|
2829
2829
|
|
|
2830
2830
|
## @jgengine/shell/render/CascadedShadows
|
|
2831
2831
|
|
|
2832
|
+
- `CASCADE_DEPTH_STEP_TEXELS` (const): const CASCADE_DEPTH_STEP_TEXELS: 8 — Depth-axis steps worth of a texel the snapped light may drift before it re-anchors.
|
|
2833
|
+
- `CascadeRefreshState` (interface): interface CascadeRefreshState — Per-frame cascade refresh bookkeeping: the centre each map was last rendered with.
|
|
2832
2834
|
- `CsmMaterialSetup` (interface): interface CsmMaterialSetup — Structural slice of CSM so the patcher is testable without a renderer.
|
|
2833
2835
|
- `patchSceneMaterials` (function): function patchSceneMaterials(scene: THREE.Scene, csm: CsmMaterialSetup, patched: WeakSet<THREE.Material>): void — Run CSM's `setupMaterial` over every unpatched standard material in the scene, chaining rather than clobbering a material's own `onBeforeCompile`.
|
|
2834
2836
|
|
|
@@ -3034,6 +3036,12 @@
|
|
|
3034
3036
|
|
|
3035
3037
|
- `drawProceduralTileLayer` (function): function drawProceduralTileLayer(canvas: CanvasFixture): TilemapWorldConfig — Draw the deterministic tile atlas used by textured tile-layer captures and tests.
|
|
3036
3038
|
|
|
3039
|
+
## @jgengine/shell/render/triangleAttribution
|
|
3040
|
+
|
|
3041
|
+
- `TriangleAttribution` (interface): interface TriangleAttribution — Where one scene's triangles are, biggest line first.
|
|
3042
|
+
- `TriangleLine` (interface): interface TriangleLine — One line of the triangle table: the draws sharing one geometry inside one named model.
|
|
3043
|
+
- `attributeTriangles` (function): function attributeTriangles(root: THREE.Object3D, limit = 12): TriangleAttribution — Sum a scene's triangles by model and geometry, which is the attribution a triangle budget needs: a total says a frame is heavy, a table says which authored object made it heavy. Walks the graph once; call it on demand, never per frame.
|
|
3044
|
+
|
|
3037
3045
|
## @jgengine/shell/render/triplanarMaterial
|
|
3038
3046
|
|
|
3039
3047
|
- `TriplanarAssetMaterial` (function): function TriplanarAssetMaterial({ asset, ...options }: { asset: MaterialAsset } & { tileMetres?: number; sharpness?: number; normalScale?: number }): React.JSX.Element — Mounts {@link useTriplanarAssetMaterial} on a mesh; the R3F renderer owns disposal.
|
|
@@ -3101,6 +3109,13 @@
|
|
|
3101
3109
|
|
|
3102
3110
|
- `ScatterModelSource` (interface): interface ScatterModelSource — One instanceable draw source harvested from a resolved GLB — geometry stays shared with the loader cache, `localMatrix` bakes the model's own transform (from its scene root) plus scale/anchor normalization.
|
|
3103
3111
|
|
|
3112
|
+
## @jgengine/shell/scatter/scatterVariants
|
|
3113
|
+
|
|
3114
|
+
- `ScatterVariantCamera` (interface): interface ScatterVariantCamera — A camera position the level pass measures from.
|
|
3115
|
+
- `ScatterVariantMember` (interface): interface ScatterVariantMember — A scatter placement as the level pass reads it: a stable id and a world position.
|
|
3116
|
+
- `scatterVariantTriangles` (function): function scatterVariantTriangles(counts: readonly number[], triangles: readonly number[]): number — Triangle total for per-level instance counts, so a fixture reports real geometry, not instance counts.
|
|
3117
|
+
- `writeScatterVariantLevel` (function): function writeScatterVariantLevel(level: number, selector: ModelVariantSelector, camera: ScatterVariantCamera, members: readonly ScatterVariantMember[], transforms: readonly THREE.Matrix4[], localMatrix: THREE.Matrix4, out: THREE.Matrix4[], keep?: (index: number) => boolean): number — Writes the matrices of the members the shared variant selector put on `level` into `out`, in source order, and returns how many were written. `out` is the level mesh's reserved capacity (one matrix per member), so a frame writes matrices and `count` without allocating.
|
|
3118
|
+
|
|
3104
3119
|
## @jgengine/shell/scene
|
|
3105
3120
|
|
|
3106
3121
|
- `AuthoredObjects` (function): function AuthoredObjects({ document, field, verticalOffset = 0, onExisting = "keep", synchronize = false, excludeKinds, }: AuthoredObjectsProps): null — Places every editor marker with a catalog id into `ctx.scene.object`, grounded on `field`. WorldScene renders them through the game's `objectModels` seam — no bespoke place loop. Pure headless twin: `resolveAuthoredObjects` / `placeAuthoredObjects`.
|
|
@@ -919,6 +919,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
919
919
|
|
|
920
920
|
- `TransformGizmo` (const) · `import { TransformGizmo } from "@jgengine/shell/structures"`
|
|
921
921
|
|
|
922
|
+
## triangle-attribution — per-model triangle table with instance counts, for perf probes
|
|
923
|
+
|
|
924
|
+
- `attributeTriangles` (function) · `import { attributeTriangles } from "@jgengine/shell/render/triangleAttribution"`
|
|
925
|
+
|
|
922
926
|
## tween-number — rolling number readout with an up/down delta flash
|
|
923
927
|
|
|
924
928
|
- `TweenNumber` (function) · `import { TweenNumber } from "@jgengine/react/tweenNumber"`
|
|
@@ -2501,6 +2501,7 @@
|
|
|
2501
2501
|
- `DEFAULT_POLICE_TIERS` (const): const DEFAULT_POLICE_TIERS: readonly PoliceTier[] — The starting tier table: patrol until heat arrives, then investigate, pursue and finally cordon.
|
|
2502
2502
|
- `DEFAULT_POLICE_TUNING` (const): const DEFAULT_POLICE_TUNING: PoliceTuning — The starting policy: linear decay, a six-second escape and a search area that grows at 2 m/s.
|
|
2503
2503
|
- `DEFAULT_REPUTATION_TIERS` (const): const DEFAULT_REPUTATION_TIERS: readonly ReputationTier[] — ⚠ undocumented
|
|
2504
|
+
- `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.
|
|
2504
2505
|
- `DamageDirectionOptions` (interface): interface DamageDirectionOptions — Options for {@link createDamageDirectionTracker}.
|
|
2505
2506
|
- `DamageDirectionSnapshot` (interface): interface DamageDirectionSnapshot — Serializable state for save/restore — the clock reading plus every live indicator.
|
|
2506
2507
|
- `DamageDirectionTracker` (interface): interface DamageDirectionTracker — A serializable, allocation-aware brain that tracks where recent damage came from. Each registered hit becomes a directional indicator that fades over a fixed duration on the injected clock; {@link DamageDirectionTracker.active} reports the live ones with their eased current strength.
|
|
@@ -2654,6 +2655,10 @@
|
|
|
2654
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.
|
|
2655
2656
|
- `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space.
|
|
2656
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.
|
|
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
|
+
- `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
|
+
- `ModelVariantSelectorState` (interface): interface ModelVariantSelectorState — Plain JSON state of a {@link ModelVariantSelector}: each id's current level index.
|
|
2657
2662
|
- `MountController` (class): class MountController — Mount / rideable control-transfer (issue #83). Registers rideables (each with one or more seats — a control seat drives, the rest ride) and tracks who is on what. It owns no camera or physics: game code reads `cameraTarget(riderId)` to point the follow camera at the mount, and `driveTarget(riderId)` to route that rider's {@link import("../physics/vehicleBody").AxisInput}-driven input at the mount's movement kit — the same seam a horse, a truck, or a shared multi-seat ship all plug into.
|
|
2658
2663
|
- `MoveOrderPayload` (interface): interface MoveOrderPayload — Payload for a move order: go to a point and complete on arrival.
|
|
2659
2664
|
- `MovementPose` (type): type MovementPose = "standing" | "crouch" | "prone" | "running" — ⚠ undocumented
|
|
@@ -3109,6 +3114,7 @@
|
|
|
3109
3114
|
- `createLodScheduler` (function): function createLodScheduler(config: LodSchedulerConfig): LodScheduler — ⚠ undocumented
|
|
3110
3115
|
- `createMarkerSet` (function): function createMarkerSet<TMeta = unknown>(now: () => number = Date.now): MarkerSet<TMeta> — ⚠ undocumented
|
|
3111
3116
|
- `createMarkerSource` (function): function createMarkerSource<TEntity, TMarker extends MarkerView = MarkerView>(options: MarkerSourceOptions<TEntity, TMarker>): MarkerSource<TMarker> — Adapt a caller-owned array/store to an observable marker source. Projection is cached between source changes, so React reads do not copy the collection per frame. The caller retains ownership of entities, updates, persistence, and subscription scheduling.
|
|
3117
|
+
- `createModelVariantSelector` (function): function createModelVariantSelector(config: ModelVariantLodConfig): ModelVariantSelector — Builds the per-instance distance switch for a model's variant levels.
|
|
3112
3118
|
- `createMountController` (function): function createMountController(): MountController — ⚠ undocumented
|
|
3113
3119
|
- `createNavCrowd` (function): function createNavCrowd(options: NavCrowdOptions & { nav: NavRuntime }): NavCrowd — Compose {@link createNavRuntime} and {@link createRvoCrowd} into a moving crowd: plan on the mesh, follow the smoothed corridor, avoid each other. Deterministic — agents advance in insertion order and the avoidance solve breaks ties by slot, not chance.
|
|
3114
3120
|
- `createNavGrid` (function): function createNavGrid(config: NavGridConfig): NavGrid — ⚠ undocumented
|
|
@@ -3213,6 +3219,7 @@
|
|
|
3213
3219
|
- `leaveVehicle` (function): function leaveVehicle(ctx: GameContext, seats: VehicleSeats, riderId: string, options: LeaveVehicleOptions = {}): ExitVehicleResult | { ok: false; reason: "unknown_rider" | "unknown_vehicle" } — Apply dismount placement, restore rider movement/visibility and the configured camera rig.
|
|
3214
3220
|
- `lineFormation` (function): function lineFormation(options: LineFormationOptions): FormationSlotGenerator — A single rank abreast, centered on the destination and facing forward — a skirmish line or a chorus row. Slots run left→right along the group's right axis.
|
|
3215
3221
|
- `listTriggerActions` (function): function listTriggerActions(target?: TriggerSourceKind): TriggerActionDefinition[] — Every registered action, optionally filtered by target collection.
|
|
3222
|
+
- `lodBandIndex` (function): function lodBandIndex(bands: readonly { maxDistance: number }[], distance: number): number — Index of the band `distance` falls in, `-1` past the last band. Bands must already be sorted near to far. The scheduler and `createModelVariantSelector` both read distances through this one function, so simulation throttling and mesh detail cannot disagree about what band an id is in.
|
|
3216
3223
|
- `mapLayerColor` (function): function mapLayerColor(tone: MapLayerTone | undefined): string — ⚠ undocumented
|
|
3217
3224
|
- `markerCatalogId` (function): function markerCatalogId(marker: AuthoredObjectMarkerLike): string | null — Catalog id for a marker: first-class `catalogId` field, else `meta.catalogId` migration alias. Returns null when the marker is not an authored catalog prop (spawn, mob, generator, …).
|
|
3218
3225
|
- `markerKindStyle` (function): function markerKindStyle(kind: string | undefined, styles: Record<string, MarkerKindStyle> = DEFAULT_MARKER_KINDS): MarkerKindStyle — ⚠ undocumented
|
|
@@ -3942,6 +3949,7 @@
|
|
|
3942
3949
|
- `LodSchedulerConfig` (interface): interface LodSchedulerConfig { bands: readonly LodBand[]; beyondInterval?: number | null; stagger?: boolean } — ⚠ undocumented
|
|
3943
3950
|
- `LodSchedulerState` (interface): interface LodSchedulerState — Plain JSON state of a {@link LodScheduler}: each id's accumulated seconds.
|
|
3944
3951
|
- `createLodScheduler` (function): function createLodScheduler(config: LodSchedulerConfig): LodScheduler — ⚠ undocumented
|
|
3952
|
+
- `lodBandIndex` (function): function lodBandIndex(bands: readonly { maxDistance: number }[], distance: number): number — Index of the band `distance` falls in, `-1` past the last band. Bands must already be sorted near to far. The scheduler and `createModelVariantSelector` both read distances through this one function, so simulation throttling and mesh detail cannot disagree about what band an id is in.
|
|
3945
3953
|
|
|
3946
3954
|
## @jgengine/core/world/mapAnnotations
|
|
3947
3955
|
|
|
@@ -4030,6 +4038,15 @@
|
|
|
4030
4038
|
- `TrackAxis` (interface): interface TrackAxis — A world-space segment defining a linear "track" minimap — points are projected onto the `from`→`to` axis.
|
|
4031
4039
|
- `trackFraction` (function): function trackFraction(point: WorldXZ | readonly [number, number, number], axis: TrackAxis): number — Project a world point onto the `from`→`to` segment of `axis` and return the clamped 0..1 fraction of how far along the track it lies (0 at `from`, 1 at `to`). The linear analogue of `projectToMinimap` — feeds a horizontal corridor/route progress rail. Accepts XZ (`[x, z]`) or XYZ (`[x, y, z]`) points (index 0 = x, index 2 when length 3 else index 1 = z). A zero-length axis returns 0.
|
|
4032
4040
|
|
|
4041
|
+
## @jgengine/core/world/modelLod
|
|
4042
|
+
|
|
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.
|
|
4045
|
+
- `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
|
+
- `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
|
+
- `ModelVariantSelectorState` (interface): interface ModelVariantSelectorState — Plain JSON state of a {@link ModelVariantSelector}: each id's current level index.
|
|
4048
|
+
- `createModelVariantSelector` (function): function createModelVariantSelector(config: ModelVariantLodConfig): ModelVariantSelector — Builds the per-instance distance switch for a model's variant levels.
|
|
4049
|
+
|
|
4033
4050
|
## @jgengine/core/world/pathInstances
|
|
4034
4051
|
|
|
4035
4052
|
- `PathInstance` (interface): interface PathInstance — One placed transform along a path: grounded position, facing yaw, and its ordinal + arc distance.
|