@jgengine/shell 0.27.0 → 0.29.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/GameUiPreview.js +3 -1
  3. package/dist/Shell3dPresentation.js +6 -4
  4. package/dist/ShellChrome.js +3 -1
  5. package/dist/camera/GameCameraRig.js +2 -2
  6. package/dist/camera/GameFirstPersonCamera.js +39 -2
  7. package/dist/camera/cameraCollision.d.ts +66 -1
  8. package/dist/camera/cameraCollision.js +72 -1
  9. package/dist/camera/index.d.ts +1 -1
  10. package/dist/camera/index.js +1 -1
  11. package/dist/camera/rigResolve.d.ts +13 -0
  12. package/dist/camera/rigResolve.js +16 -0
  13. package/dist/environment/EnvironmentScene.js +69 -4
  14. package/dist/postfx/shadowPrepass.d.ts +8 -1
  15. package/dist/postfx/shadowPrepass.js +18 -2
  16. package/dist/postfx/stylizePass.js +2 -1
  17. package/dist/render/CascadedShadows.d.ts +31 -1
  18. package/dist/render/CascadedShadows.js +71 -5
  19. package/dist/render/CharacterCrowd.d.ts +20 -0
  20. package/dist/render/CharacterCrowd.js +176 -0
  21. package/dist/render/SceneLighting.js +27 -3
  22. package/dist/render/SceneWarmupGate.d.ts +20 -0
  23. package/dist/render/SceneWarmupGate.js +48 -0
  24. package/dist/render/characterLod.d.ts +62 -0
  25. package/dist/render/characterLod.js +165 -0
  26. package/dist/render/crowdRegistry.d.ts +48 -0
  27. package/dist/render/crowdRegistry.js +97 -0
  28. package/dist/render/materialAsset.js +2 -1
  29. package/dist/render/modelLoad.js +3 -0
  30. package/dist/render/modelMeshMerge.d.ts +49 -0
  31. package/dist/render/modelMeshMerge.js +269 -0
  32. package/dist/render/shaderWarmup.d.ts +69 -0
  33. package/dist/render/shaderWarmup.js +120 -0
  34. package/dist/render/shadowCasters.d.ts +28 -0
  35. package/dist/render/shadowCasters.js +95 -0
  36. package/dist/render/staticTransforms.d.ts +20 -0
  37. package/dist/render/staticTransforms.js +32 -0
  38. package/dist/render/useFootIk.d.ts +11 -3
  39. package/dist/render/useFootIk.js +35 -7
  40. package/dist/render/useModelAnimation.d.ts +16 -2
  41. package/dist/render/useModelAnimation.js +73 -4
  42. package/dist/render/useModelInstance.d.ts +1 -1
  43. package/dist/render/useModelInstance.js +23 -0
  44. package/dist/render/useRagdoll.d.ts +32 -0
  45. package/dist/render/useRagdoll.js +105 -0
  46. package/dist/settings/SettingsMenu.d.ts +1 -1
  47. package/dist/settings/SettingsMenu.js +10 -79
  48. package/dist/settings/settingsController.js +3 -0
  49. package/dist/world/StaticObjectModels.d.ts +13 -0
  50. package/dist/world/StaticObjectModels.js +28 -3
  51. package/dist/world/WorldParticles.d.ts +2 -1
  52. package/dist/world/WorldParticles.js +29 -10
  53. package/dist/world/WorldScene.js +50 -13
  54. package/dist/world/entityPose.d.ts +17 -0
  55. package/dist/world/entityPose.js +42 -0
  56. package/dist/world/staticObjectBatches.d.ts +33 -5
  57. package/dist/world/staticObjectBatches.js +40 -6
  58. package/package.json +4 -4
  59. package/skills/jgengine/api.md +2 -2
  60. package/skills/jgengine-ui/SKILL.md +1 -0
  61. package/skills/jgengine-ui/api.md +200 -9
  62. package/skills/jgengine-ui/capabilities.md +103 -1
  63. package/skills/jgengine-ui/reference-primitives.md +2 -0
  64. package/skills/jgengine-ui/reference.md +8 -0
  65. package/skills/jgengine-world/SKILL.md +4 -0
  66. package/skills/jgengine-world/api.md +128 -6
  67. package/skills/jgengine-world/capabilities.md +41 -1
  68. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  69. package/skills/jgengine-world/reference.md +1 -1
@@ -1,6 +1,19 @@
1
1
  import { type ReactNode } from "react";
2
+ import * as THREE from "three";
2
3
  import type { SceneObject } from "@jgengine/core/scene/objectStore";
3
4
  import { type StaticObjectBatch } from "./staticObjectBatches.js";
5
+ /**
6
+ * Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
7
+ * total instances behind them, and how many instances survived per-instance culling this frame. A
8
+ * batch whose instances are all outside the view contributes a draw of zero, so `drawn` is the culling
9
+ * evidence, not a claim.
10
+ * @internal
11
+ */
12
+ export declare function useStaticObjectBatchProbe(scene: THREE.Object3D, stats: {
13
+ batches: number;
14
+ instances: number;
15
+ singles: number;
16
+ }): void;
4
17
  /** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
5
18
  export declare function StaticObjectModels({ batch, single }: {
6
19
  batch: StaticObjectBatch;
@@ -1,21 +1,46 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFrame, useLoader } from "@react-three/fiber";
3
- import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useEffect, useMemo, useRef, useState, useCallback } from "react";
4
4
  import * as THREE from "three";
5
+ import { devtools } from "@jgengine/core/devtools/devtools";
5
6
  import { useGameContext } from "@jgengine/react/provider";
6
7
  import { sharedGltfLoader } from "../render/modelLoad.js";
7
8
  import { IsolatedModelPart } from "../render/SceneModels.js";
9
+ import { freezeStaticTransforms } from "../render/staticTransforms.js";
8
10
  import { measureLocalBounds, reportMeasuredBounds } from "../render/measureBounds.js";
9
11
  import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "../render/measureCollisionMesh.js";
10
12
  import { buildScatterModelSources, disposeScatterModelSources } from "../scatter/scatterModels.js";
11
13
  import { useRenderVisibility } from "../visibility/CullingProvider.js";
12
14
  import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
13
- import { canInstanceStaticScene, incompatibleStaticObjectIds, isCompatibleStaticObject, syncStaticObjectSource } from "./staticObjectBatches.js";
15
+ import { canInstanceStaticScene, countStaticBatchDraws, incompatibleStaticObjectIds, isCompatibleStaticObject, STATIC_BATCH_MESH_KEY, syncStaticObjectSource } from "./staticObjectBatches.js";
16
+ /**
17
+ * Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
18
+ * total instances behind them, and how many instances survived per-instance culling this frame. A
19
+ * batch whose instances are all outside the view contributes a draw of zero, so `drawn` is the culling
20
+ * evidence, not a claim.
21
+ * @internal
22
+ */
23
+ export function useStaticObjectBatchProbe(scene, stats) {
24
+ const ref = useRef(stats);
25
+ ref.current = stats;
26
+ useEffect(() => devtools.probes.registerSystem("render/staticBatches", {
27
+ counters: () => ({ ...ref.current, ...countStaticBatchDraws(scene) }),
28
+ state: () => {
29
+ const { draws, drawn } = countStaticBatchDraws(scene);
30
+ return `${ref.current.batches} batches · ${draws} draws · ${drawn}/${ref.current.instances} instances drawn`;
31
+ },
32
+ }), [scene]);
33
+ }
14
34
  function ObjectSource({ source, objects, batch }) {
15
35
  const ctx = useGameContext();
16
36
  const visibility = useRenderVisibility();
17
37
  const ref = useRef(null);
18
38
  const scratch = useMemo(() => new THREE.Object3D(), []);
39
+ const attach = useCallback((mesh) => {
40
+ ref.current = mesh;
41
+ if (mesh !== null)
42
+ freezeStaticTransforms(mesh);
43
+ }, []);
19
44
  useEffect(() => {
20
45
  const mesh = ref.current;
21
46
  return () => { mesh?.dispose(); };
@@ -28,7 +53,7 @@ function ObjectSource({ source, objects, batch }) {
28
53
  isCompatibleStaticObject(object, batch.model, ctx.scene.object.catalog(id)) ? object : null;
29
54
  }, visibility.current, scratch);
30
55
  }, CAMERA_POST_FRAME_PRIORITY + 0.1);
31
- return _jsx("instancedMesh", { ref: ref, args: [source.geometry, source.material, objects.length], castShadow: source.castShadow, receiveShadow: source.receiveShadow, dispose: null });
56
+ 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 });
32
57
  }
33
58
  function LoadedStaticObjects({ batch, single }) {
34
59
  const ctx = useGameContext();
@@ -11,8 +11,9 @@ export declare function transformParticleVector(point: Vec3, origin: Vec3, yaw:
11
11
  /** @internal Cosmetic catch-up follows game time, with bounded work after suspension. */
12
12
  export declare function resolveParticleDelta(previousTime: number, gameTime: number): number;
13
13
  /** Renders the existing director and pooled simulator; named bursts reuse the standing pool. @internal */
14
- export declare function WorldParticles({ quality, particleCap, diagnostics }: {
14
+ export declare function WorldParticles({ quality, particleCap, particleBudget, diagnostics }: {
15
15
  quality: GraphicsQuality;
16
16
  particleCap?: number;
17
+ particleBudget?: number;
17
18
  diagnostics?: boolean;
18
19
  }): ReactElement;
@@ -2,14 +2,16 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { devtools } from "@jgengine/core/devtools/devtools";
5
+ import { DEFAULT_GRAPHICS_PROFILES } from "@jgengine/core/settings/graphicsProfile";
5
6
  import { createParticleSystem } from "@jgengine/core/vfx/particles";
7
+ import { planParticleBudget } from "@jgengine/core/vfx/particleBudget";
6
8
  import { useGameContext } from "@jgengine/react/provider";
7
9
  import { ParticleField } from "../vfx/ParticleField.js";
8
- const QUALITY_POOL_CAP = { low: 128, medium: 256, high: 512, ultra: 1024 };
9
10
  /** The requested visual pool clamped to the graphics tier. */
10
11
  export function resolveParticleBudget(quality, requested, particleCap) {
11
- const rawCap = particleCap ?? QUALITY_POOL_CAP[quality];
12
- const cap = Number.isFinite(rawCap) ? Math.max(1, Math.floor(rawCap)) : QUALITY_POOL_CAP[quality];
12
+ const tier = DEFAULT_GRAPHICS_PROFILES[quality].particleCap;
13
+ const rawCap = particleCap ?? tier;
14
+ const cap = Number.isFinite(rawCap) ? Math.max(1, Math.floor(rawCap)) : tier;
13
15
  return Number.isFinite(requested) ? Math.max(1, Math.floor(Math.min(requested, cap))) : cap;
14
16
  }
15
17
  const MAX_CONCURRENT_BURSTS = 32;
@@ -43,11 +45,12 @@ export function resolveParticleDelta(previousTime, gameTime) {
43
45
  return Number.isFinite(previousTime) && Number.isFinite(gameTime) ? Math.max(0, Math.min(0.1, gameTime - previousTime)) : 0;
44
46
  }
45
47
  /** Renders the existing director and pooled simulator; named bursts reuse the standing pool. @internal */
46
- export function WorldParticles({ quality, particleCap, diagnostics = false }) {
48
+ export function WorldParticles({ quality, particleCap, particleBudget, diagnostics = false }) {
47
49
  const ctx = useGameContext();
48
50
  const emittersRef = useRef(new Map());
49
51
  const burstsRef = useRef([]);
50
52
  const previousTime = useRef(ctx.time.now());
53
+ const budget = useRef({ cap: 0, particles: 0, dropped: [] });
51
54
  const timing = useRef({ poolsCreated: 0, poolsRetired: 0, updateSamples: 0, lastUpdateMs: 0, totalUpdateMs: 0, maxUpdateMs: 0, droppedCatchupSeconds: 0 });
52
55
  const resources = useRef({ fieldsMounted: 0, fieldsUnmounted: 0, liveFields: 0, attributeBytes: 0, totalUploadMs: 0, maxUploadMs: 0, uploadSamples: 0 });
53
56
  const [, setGeneration] = useState(0);
@@ -121,10 +124,23 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
121
124
  const emitters = emittersRef.current;
122
125
  function sync() {
123
126
  let changed = false;
127
+ const cap = resolveParticleBudget(quality, undefined, particleBudget ?? DEFAULT_GRAPHICS_PROFILES[quality].particleBudget);
128
+ const specs = ctx.particles.emitters();
129
+ const queued = ctx.particles.drainBursts();
130
+ const plan = planParticleBudget([
131
+ ...specs.map((spec) => ({ id: spec.id, priority: spec.priority ?? 0, requested: resolveParticleBudget(quality, spec.config.max, particleCap) })),
132
+ ...queued.map((burst) => ({ id: `burst:${burst.seq}`, priority: burst.priority ?? 0, requested: resolveParticleBudget(quality, Math.min(burst.count, burst.config.max ?? burst.count), particleCap) })),
133
+ ], cap);
134
+ const granted = new Map(plan.grants.map((grant) => [grant.id, grant.particles]));
135
+ budget.current = { cap, particles: plan.particles, dropped: plan.dropped };
124
136
  const liveIds = new Set();
125
- for (const spec of ctx.particles.emitters()) {
137
+ for (const spec of specs) {
138
+ const poolMax = granted.get(spec.id) ?? 0;
139
+ if (poolMax <= 0) {
140
+ emitters.delete(spec.id);
141
+ continue;
142
+ }
126
143
  liveIds.add(spec.id);
127
- const poolMax = resolveParticleBudget(quality, spec.config.max, particleCap);
128
144
  const current = emitters.get(spec.id);
129
145
  if (current === undefined || current.poolMax !== poolMax) {
130
146
  const next = { spec, poolMax, group: current?.group ?? null, pose: pose(spec),
@@ -155,14 +171,16 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
155
171
  timing.current.poolsRetired++;
156
172
  changed = true;
157
173
  }
158
- for (const burst of ctx.particles.drainBursts()) {
174
+ for (const burst of queued) {
159
175
  const named = burst.emitterId === undefined ? undefined : emitters.get(burst.emitterId);
160
176
  if (named !== undefined) {
161
177
  syncPose(named);
162
178
  named.system.emit(burst.count);
163
179
  continue;
164
180
  }
165
- const poolMax = resolveParticleBudget(quality, Math.min(burst.count, burst.config.max ?? burst.count), particleCap);
181
+ const poolMax = granted.get(`burst:${burst.seq}`) ?? 0;
182
+ if (poolMax <= 0)
183
+ continue;
166
184
  const spec = { id: `burst:${burst.seq}`, config: burst.config, ...burst.options };
167
185
  const resolved = pose(spec);
168
186
  const system = createParticleSystem({ ...burst.config, rate: 0, max: poolMax, seed: burst.config.seed ?? burst.seq }, { collision: collisionFor(() => spec, resolved), acceleration: accelerationFor(() => spec, resolved) });
@@ -170,7 +188,7 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
170
188
  const base = spec.follow === undefined ? burst.config.position ?? [0, 0, 0] : [0, 0, 0];
171
189
  system.configure({ position: [resolved.position[0] + base[0], resolved.position[1] + base[1], resolved.position[2] + base[2]] });
172
190
  }
173
- system.emit(burst.count);
191
+ system.emit(Math.min(burst.count, poolMax));
174
192
  timing.current.poolsCreated++;
175
193
  burstsRef.current.push({ seq: burst.seq, system, blending: burst.blending ?? "additive", render: spec.render, collisionEffect: spec.collisionEffect,
176
194
  position: spec.space === "local" ? resolved.position : [0, 0, 0], rotationY: spec.space === "local" ? resolved.yaw : 0 });
@@ -186,7 +204,7 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
186
204
  sync();
187
205
  const off = ctx.particles.subscribe(sync);
188
206
  return off;
189
- }, [ctx, quality, particleCap]);
207
+ }, [ctx, quality, particleCap, particleBudget]);
190
208
  useEffect(() => {
191
209
  if (!diagnostics)
192
210
  return;
@@ -204,6 +222,7 @@ export function WorldParticles({ quality, particleCap, diagnostics = false }) {
204
222
  read(burst.system);
205
223
  const sample = timing.current, fields = resources.current;
206
224
  return { scope: "WorldParticles", emitters: emittersRef.current.size, bursts: burstsRef.current.length, count, capacity, renderBufferBytes,
225
+ cap: budget.current.cap, budgetParticles: budget.current.particles, dropped: budget.current.dropped.length, droppedIds: budget.current.dropped,
207
226
  poolsCreated: sample.poolsCreated, poolsRetired: sample.poolsRetired,
208
227
  updateSamples: sample.updateSamples, lastUpdateMs: sample.lastUpdateMs, maxUpdateMs: sample.maxUpdateMs,
209
228
  avgUpdateMs: sample.updateSamples === 0 ? 0 : sample.totalUpdateMs / sample.updateSamples,
@@ -1,14 +1,15 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { modelWithAuthoredMaterials } from "@jgengine/core/editor/materialAuthoring";
3
3
  import { modelWithAuthoredNodeVisibility } from "@jgengine/core/editor/modelNodeVisibility";
4
- import { useFrame } from "@react-three/fiber";
4
+ import { useFrame, useThree } from "@react-three/fiber";
5
5
  import { useEffect, useMemo, useRef } from "react";
6
6
  import * as THREE from "three";
7
7
  import { createEmptyEditorDocument } from "@jgengine/core/editor/index";
8
8
  import { resolveAuthoredObjects } from "@jgengine/core/world/authoredObjects";
9
9
  import { useLiveEditorDocument } from "../scene/AuthoredScene.js";
10
- import { StaticObjectModels } from "./StaticObjectModels.js";
10
+ import { StaticObjectModels, useStaticObjectBatchProbe } from "./StaticObjectModels.js";
11
11
  import { groupStaticObjects } from "./staticObjectBatches.js";
12
+ import { groundSpeed } from "@jgengine/core/scene/entityStore";
12
13
  import { objectVisualScale } from "@jgengine/core/scene/objectStore";
13
14
  import { WORLD_ITEM_ENTITY_NAME } from "@jgengine/core/game/worldItem";
14
15
  import { beginFallbackSeam, endFallbackPass, reportFallbackSeam, } from "@jgengine/core/devtools/fallbackSeams";
@@ -20,15 +21,18 @@ import { useDisposable } from "../render/useDisposable.js";
20
21
  import { WindClock } from "../render/modelWind.js";
21
22
  import { MeasuredBoundsGroup } from "../render/measureBounds.js";
22
23
  import { EntitySprite, IsolatedEntityModel } from "../render/SceneModels.js";
24
+ import { CharacterCrowd } from "../render/CharacterCrowd.js";
25
+ import { CharacterCrowdRegistry, crowdEligible, crowdKindOf, crowdKindReady, } from "../render/crowdRegistry.js";
26
+ import { characterLodTier, clearCharacterLodTier, setCharacterLodTier, } from "../render/characterLod.js";
23
27
  import { resolveEntityModel, resolveObjectModel } from "../render/resolveModel.js";
24
28
  import { useRenderVisibility } from "../visibility/CullingProvider.js";
25
- import { writeEntityPose, writeRenderPose } from "./entityPose.js";
29
+ import { writePoseIfChanged, writeRenderPoseIfChanged, emptyPoseMemo } from "./entityPose.js";
26
30
  import { createSnapshotBuffer } from "@jgengine/core/runtime/snapshotBuffer";
27
31
  import { POINTER_ENTITY_KEY, POINTER_OBJECT_KEY } from "../pointer/pointerService.js";
28
32
  const EMPTY_DOCUMENT = createEmptyEditorDocument();
29
33
  const GROUND_SIZE = 160;
30
34
  const GROUND_SEGMENTS = 80;
31
- function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, selected, onSelect, }) {
35
+ function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, selected, crowd, onSelect, }) {
32
36
  const groupRef = useRef(null);
33
37
  const ctx = useGameContext();
34
38
  const visibleRef = useRenderVisibility();
@@ -36,18 +40,40 @@ function EntityMarker({ entity, custom, model, sprite, isLocal, targeted, select
36
40
  const role = entity.role;
37
41
  const name = entity.name;
38
42
  const color = isLocal ? "#4ade80" : role === "npc" ? colorFromId(name) : "#9ca3af";
43
+ const tierRef = useRef(undefined);
44
+ const crowdModel = custom === undefined && !isLocal && crowdEligible(model) && crowd !== undefined ? model : undefined;
45
+ const crowdKind = crowdModel === undefined ? undefined : crowdKindOf(crowdModel);
39
46
  const scratchRef = useRef([0, 0, 0, 0]);
40
- useFrame(() => {
47
+ const memoRef = useRef(emptyPoseMemo());
48
+ useFrame((state) => {
41
49
  const group = groupRef.current;
42
50
  if (group === null)
43
51
  return;
44
52
  const live = ctx.scene.entity.get(entityId);
45
53
  if (live === null)
46
54
  return;
47
- writeRenderPose(group, ctx, entityId, live, scratchRef.current);
48
- group.visible = live.hidden !== true && visibleRef.current(entityId);
55
+ writeRenderPoseIfChanged(group, ctx, entityId, live, scratchRef.current, memoRef.current);
56
+ let tier;
57
+ if (crowdModel !== undefined && crowd !== undefined && crowdKind !== undefined && crowdKindReady(crowdKind)) {
58
+ const [x, y, z] = live.position;
59
+ const distance = Math.sqrt((state.camera.position.x - x) ** 2 + (state.camera.position.y - y) ** 2 + (state.camera.position.z - z) ** 2);
60
+ tier = characterLodTier(distance, crowdModel.lod === false ? undefined : crowdModel.lod, tierRef.current);
61
+ setCharacterLodTier(entityId, tier);
62
+ if (tier === "crowd") {
63
+ crowd.set(entityId, crowdKind, { x, y, z, rotationY: live.rotationY ?? 0, speed: groundSpeed(live) });
64
+ }
65
+ else {
66
+ crowd.delete(entityId);
67
+ }
68
+ }
69
+ tierRef.current = tier;
70
+ group.visible = live.hidden !== true && visibleRef.current(entityId) && tier !== "crowd";
49
71
  });
50
- return (_jsxs("group", { ref: groupRef, userData: { [POINTER_ENTITY_KEY]: entityId }, onPointerDown: (event) => {
72
+ useEffect(() => () => {
73
+ crowd?.delete(entityId);
74
+ clearCharacterLodTier(entityId);
75
+ }, [crowd, entityId]);
76
+ return (_jsxs("group", { ref: groupRef, matrixAutoUpdate: false, userData: { [POINTER_ENTITY_KEY]: entityId }, onPointerDown: (event) => {
51
77
  event.stopPropagation();
52
78
  if (isLocal)
53
79
  return;
@@ -61,6 +87,7 @@ function ObjectMarker({ object, custom, model, style, }) {
61
87
  const ctx = useGameContext();
62
88
  const visibleRef = useRenderVisibility();
63
89
  const instanceId = object.instanceId;
90
+ const memoRef = useRef(emptyPoseMemo());
64
91
  const [scaleX, scaleY, scaleZ] = objectVisualScale(object.visual);
65
92
  const color = object.visual?.color ?? style?.color ?? colorFromId(object.catalogId);
66
93
  const opacity = object.visual?.opacity ?? style?.opacity ?? 1;
@@ -71,10 +98,10 @@ function ObjectMarker({ object, custom, model, style, }) {
71
98
  const live = ctx.scene.object.get(instanceId);
72
99
  if (live === null)
73
100
  return;
74
- writeEntityPose(group, live);
101
+ writePoseIfChanged(group, live, memoRef.current);
75
102
  group.visible = visibleRef.current(instanceId);
76
103
  });
77
- return (_jsx("group", { ref: groupRef, userData: { [POINTER_OBJECT_KEY]: instanceId }, children: custom !== undefined && custom !== null ? (_jsx(MeasuredBoundsGroup, { target: "object", measureKey: object.catalogId, children: custom })) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: instanceId, measure: { target: "object", key: object.catalogId } })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], castShadow: true, receiveShadow: true, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx(DefaultSurface, { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
104
+ return (_jsx("group", { ref: groupRef, matrixAutoUpdate: false, userData: { [POINTER_OBJECT_KEY]: instanceId }, children: custom !== undefined && custom !== null ? (_jsx(MeasuredBoundsGroup, { target: "object", measureKey: object.catalogId, children: custom })) : model !== undefined ? (_jsx(IsolatedEntityModel, { model: model, instanceId: instanceId, measure: { target: "object", key: object.catalogId } })) : style?.hidden === true ? null : (_jsxs("mesh", { "position-y": 0.5 * scaleY, scale: [scaleX, scaleY, scaleZ], castShadow: true, receiveShadow: true, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx(DefaultSurface, { color: color, transparent: opacity < 1, opacity: opacity })] })) }));
78
105
  }
79
106
  function GroundPlane() {
80
107
  const geometry = useMemo(() => {
@@ -141,6 +168,7 @@ function WorldEnvironment({ environment: Environment }) {
141
168
  }
142
169
  function WorldActors({ entitySprites, entityModels, objectModels, objectStyles, assets, renderEntity, renderObject, selectedIds, hideLocalActor, editorLayers, }) {
143
170
  const ctx = useGameContext();
171
+ const scene = useThree((state) => state.scene);
144
172
  const entityIds = useSceneEntityIds();
145
173
  const objectIds = useSceneObjectIds();
146
174
  const document = useLiveEditorDocument(editorLayers ?? EMPTY_DOCUMENT, editorLayers !== undefined);
@@ -151,6 +179,7 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
151
179
  const player = usePlayer();
152
180
  const targetId = useTarget(player.userId);
153
181
  const controlledId = useGameStore((c) => c.player.possession.active(player.userId));
182
+ const crowdRegistry = useMemo(() => new CharacterCrowdRegistry(), []);
154
183
  const handleSelect = (entity) => {
155
184
  const relation = ctx.scene.entity.canReceive(entity.id, "damage") === null ? "hostile" : "friendly";
156
185
  ctx.scene.entity.setTarget(controlledId, relation === "hostile" || entity.role === "npc" ? entity.id : null);
@@ -176,9 +205,15 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
176
205
  }
177
206
  return [{ object, model, custom, style, authored: authoredIds.has(instanceId), catalog: ctx.scene.object.catalog(instanceId) }];
178
207
  });
179
- const grouped = groupStaticObjects(candidates.map((candidate) => ({ ...candidate, custom: candidate.custom !== undefined && candidate.custom !== null })), undefined, batchCache.current);
208
+ const grouped = groupStaticObjects(candidates.map((candidate) => ({ ...candidate, custom: candidate.custom !== undefined && candidate.custom !== null })), batchCache.current);
180
209
  batchCache.current = new Map(grouped.batches.map((batch) => [batch.key, batch]));
210
+ useStaticObjectBatchProbe(scene, {
211
+ batches: grouped.batches.length,
212
+ instances: grouped.batches.reduce((total, batch) => total + batch.objects.length, 0),
213
+ singles: grouped.singles.length,
214
+ });
181
215
  const candidatesById = new Map(candidates.map((candidate) => [candidate.object.instanceId, candidate]));
216
+ const crowdModels = new Map();
182
217
  const renderSingle = (object) => {
183
218
  object = ctx.scene.object.get(object.instanceId) ?? object;
184
219
  const candidate = candidatesById.get(object.instanceId);
@@ -199,8 +234,10 @@ function WorldActors({ entitySprites, entityModels, objectModels, objectStyles,
199
234
  if ((custom === undefined || custom === null) && model === undefined && sprite === undefined) {
200
235
  reportFallbackSeam("entity", "omittedMapping");
201
236
  }
202
- return (_jsx(EntityMarker, { entity: entity, custom: custom, model: model, sprite: sprite, isLocal: entityId === controlledId, targeted: entityId === targetId, selected: selectedIds.has(entityId), onSelect: handleSelect }, entityId));
203
- }), grouped.singles.map((candidate) => renderSingle(candidate.object)), grouped.batches.map((batch) => _jsx(StaticObjectModels, { batch: batch, single: renderSingle }, batch.key))] }));
237
+ if (crowdEligible(model))
238
+ crowdModels.set(crowdKindOf(model), model);
239
+ return (_jsx(EntityMarker, { entity: entity, custom: custom, model: model, sprite: sprite, isLocal: entityId === controlledId, targeted: entityId === targetId, selected: selectedIds.has(entityId), crowd: crowdRegistry, onSelect: handleSelect }, entityId));
240
+ }), crowdModels.size > 0 ? _jsx(CharacterCrowd, { models: [...crowdModels.values()], registry: crowdRegistry }) : null, grouped.singles.map((candidate) => renderSingle(candidate.object)), grouped.batches.map((batch) => _jsx(StaticObjectModels, { batch: batch, single: renderSingle }, batch.key))] }));
204
241
  }
205
242
  export function WorldView({ entitySprites, entityModels, objectModels, objectStyles, environment, assets, renderEntity, renderObject, selectedIds, hideLocalActor, editorLayers, }) {
206
243
  return (_jsxs(_Fragment, { children: [_jsx(WindClock, {}), _jsx(WorldEnvironment, { environment: environment }), _jsx(WorldActors, { entitySprites: entitySprites, entityModels: entityModels, objectModels: objectModels, objectStyles: objectStyles, assets: assets, renderEntity: renderEntity, renderObject: renderObject, selectedIds: selectedIds, hideLocalActor: hideLocalActor, editorLayers: editorLayers })] }));
@@ -14,6 +14,23 @@ export interface PoseSource {
14
14
  }
15
15
  /** @internal */
16
16
  export declare function writeEntityPose(target: PoseWritable, source: PoseSource): void;
17
+ /** The pose last written to one marker group, reused across frames. @internal */
18
+ export type PoseMemo = [number, number, number, number];
19
+ /** A marker group that owns its own matrix instead of three recomposing it every frame. @internal */
20
+ export interface MatrixPoseTarget extends PoseWritable {
21
+ updateMatrix(): void;
22
+ }
23
+ /** A memo that never matches, so the first write to a marker group always lands. @internal */
24
+ export declare function emptyPoseMemo(): PoseMemo;
25
+ /**
26
+ * Write a marker pose and recompose only when it changed. The marker group runs with
27
+ * `matrixAutoUpdate=false`, so an idle actor or a parked prop leaves its whole subtree out of the
28
+ * per-frame world-matrix pass instead of forcing every child to recompose (#2038).
29
+ * @internal
30
+ */
31
+ export declare function writePoseIfChanged(target: MatrixPoseTarget, source: PoseSource, memo: PoseMemo): boolean;
32
+ /** {@link writePoseIfChanged} for the interpolated pose in `ctx.sim`, falling back to `live`. @internal */
33
+ export declare function writeRenderPoseIfChanged(target: MatrixPoseTarget, ctx: Pick<GameContext, "sim">, entityId: string, live: PoseSource, scratch: RenderPose, memo: PoseMemo): boolean;
17
34
  /** Write the interpolated render pose from `ctx.sim` (falls back to `live` when no history exists). @internal */
18
35
  export declare function writeRenderPose(target: PoseWritable, ctx: Pick<GameContext, "sim">, entityId: string, live: PoseSource, scratch: RenderPose): void;
19
36
  /** @internal */
@@ -3,6 +3,48 @@ export function writeEntityPose(target, source) {
3
3
  target.position.set(source.position[0], source.position[1], source.position[2]);
4
4
  target.rotation.y = source.rotationY;
5
5
  }
6
+ /** A memo that never matches, so the first write to a marker group always lands. @internal */
7
+ export function emptyPoseMemo() {
8
+ return [Number.NaN, Number.NaN, Number.NaN, Number.NaN];
9
+ }
10
+ /**
11
+ * Write a marker pose and recompose only when it changed. The marker group runs with
12
+ * `matrixAutoUpdate=false`, so an idle actor or a parked prop leaves its whole subtree out of the
13
+ * per-frame world-matrix pass instead of forcing every child to recompose (#2038).
14
+ * @internal
15
+ */
16
+ export function writePoseIfChanged(target, source, memo) {
17
+ const [x, y, z] = source.position;
18
+ if (memo[0] === x && memo[1] === y && memo[2] === z && memo[3] === source.rotationY)
19
+ return false;
20
+ memo[0] = x;
21
+ memo[1] = y;
22
+ memo[2] = z;
23
+ memo[3] = source.rotationY;
24
+ target.position.set(x, y, z);
25
+ target.rotation.y = source.rotationY;
26
+ target.updateMatrix();
27
+ return true;
28
+ }
29
+ /** {@link writePoseIfChanged} for the interpolated pose in `ctx.sim`, falling back to `live`. @internal */
30
+ export function writeRenderPoseIfChanged(target, ctx, entityId, live, scratch, memo) {
31
+ if (!ctx.sim.renderPose(entityId, scratch)) {
32
+ scratch[0] = live.position[0];
33
+ scratch[1] = live.position[1];
34
+ scratch[2] = live.position[2];
35
+ scratch[3] = live.rotationY;
36
+ }
37
+ if (memo[0] === scratch[0] && memo[1] === scratch[1] && memo[2] === scratch[2] && memo[3] === scratch[3])
38
+ return false;
39
+ memo[0] = scratch[0];
40
+ memo[1] = scratch[1];
41
+ memo[2] = scratch[2];
42
+ memo[3] = scratch[3];
43
+ target.position.set(scratch[0], scratch[1], scratch[2]);
44
+ target.rotation.y = scratch[3];
45
+ target.updateMatrix();
46
+ return true;
47
+ }
6
48
  /** Write the interpolated render pose from `ctx.sim` (falls back to `live` when no history exists). @internal */
7
49
  export function writeRenderPose(target, ctx, entityId, live, scratch) {
8
50
  if (ctx.sim.renderPose(entityId, scratch)) {
@@ -24,15 +24,43 @@ export declare function canBatchStaticObject(candidate: StaticObjectCandidate):
24
24
  export declare function isCompatibleStaticObject(object: SceneObject, model: ModelConfig | undefined, catalog: GameContextObjectEntry | null): boolean;
25
25
  /** Stable exclusion snapshot; only compatibility transitions allocate. @internal */
26
26
  export declare function incompatibleStaticObjectIds(batch: StaticObjectBatch, get: (id: string) => SceneObject | null, catalog: (id: string) => GameContextObjectEntry | null, previous: readonly string[], fallbackObjects?: Map<string, SceneObject>): readonly string[];
27
- /** Batch cell edge in metres: one instanced draw per model per cell, culled as a unit. @internal */
28
- export declare const STATIC_BATCH_CHUNK = 64;
29
27
  /** Smallest same-model group worth an instanced draw; below this each prop draws on its own. @internal */
30
- export declare const STATIC_BATCH_MIN = 3;
31
- /** Groups compatible authored or `scenery` props, at least {@link STATIC_BATCH_MIN} per spatial cell. @internal */
32
- export declare function groupStaticObjects(candidates: readonly StaticObjectCandidate[], chunkSize?: number, previous?: ReadonlyMap<string, StaticObjectBatch>): {
28
+ export declare const STATIC_BATCH_MIN = 2;
29
+ /**
30
+ * Batch cell edge in metres. One InstancedMesh has one bounding sphere, and three frustum-tests it in
31
+ * the shadow pass too, so a batch that spans the whole city submits every camera-visible instance to
32
+ * every shadow cascade. A cell keeps that test useful without fragmenting one model per prop.
33
+ * @internal
34
+ */
35
+ export declare const STATIC_BATCH_CELL = 128;
36
+ /**
37
+ * Groups compatible authored or `scenery` props by model inside {@link STATIC_BATCH_CELL} metres, at
38
+ * least {@link STATIC_BATCH_MIN} of a model. Culling stays per instance: `syncStaticObjectSource`
39
+ * compacts each batch's drawn range against the visibility predicate every frame, and the cell keeps
40
+ * the batch's one bounding sphere small enough that three's own frustum test still rejects it in the
41
+ * shadow pass. Props spread thinner than the minimum in a cell fall back to individual draws.
42
+ * @internal
43
+ */
44
+ export declare function groupStaticObjects(candidates: readonly StaticObjectCandidate[], previous?: ReadonlyMap<string, StaticObjectBatch>, cell?: number): {
33
45
  batches: StaticObjectBatch[];
34
46
  singles: StaticObjectCandidate[];
35
47
  };
48
+ /** Marks the instanced draws a static object batch owns so the batching probe can count them. @internal */
49
+ export declare const STATIC_BATCH_MESH_KEY = "jgStaticObjectBatch";
50
+ /** What one frame's static object batching produced. @internal */
51
+ export interface StaticBatchCounts {
52
+ /** Instanced draws the loaded models actually submitted. */
53
+ draws: number;
54
+ /** Distinct instances inside those draws after the visibility predicate compacted them. */
55
+ drawn: number;
56
+ }
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.
61
+ * @internal
62
+ */
63
+ export declare function countStaticBatchDraws(scene: THREE.Object3D): StaticBatchCounts;
36
64
  /** Rejects loaded scenes whose behavior cannot be represented by static instanced draws. @internal */
37
65
  export declare function canInstanceStaticScene(scene: THREE.Object3D, clips: readonly THREE.AnimationClip[]): boolean;
38
66
  /** Compact visible placements and update picking identities without allocating per instance. @internal */
@@ -49,12 +49,24 @@ export function incompatibleStaticObjectIds(batch, get, catalog, previous, fallb
49
49
  return live !== null && !compatible(live) ? [live.instanceId] : [];
50
50
  });
51
51
  }
52
- /** Batch cell edge in metres: one instanced draw per model per cell, culled as a unit. @internal */
53
- export const STATIC_BATCH_CHUNK = 64;
54
52
  /** Smallest same-model group worth an instanced draw; below this each prop draws on its own. @internal */
55
- export const STATIC_BATCH_MIN = 3;
56
- /** Groups compatible authored or `scenery` props, at least {@link STATIC_BATCH_MIN} per spatial cell. @internal */
57
- export function groupStaticObjects(candidates, chunkSize = STATIC_BATCH_CHUNK, previous) {
53
+ export const STATIC_BATCH_MIN = 2;
54
+ /**
55
+ * Batch cell edge in metres. One InstancedMesh has one bounding sphere, and three frustum-tests it in
56
+ * the shadow pass too, so a batch that spans the whole city submits every camera-visible instance to
57
+ * every shadow cascade. A cell keeps that test useful without fragmenting one model per prop.
58
+ * @internal
59
+ */
60
+ export const STATIC_BATCH_CELL = 128;
61
+ /**
62
+ * Groups compatible authored or `scenery` props by model inside {@link STATIC_BATCH_CELL} metres, at
63
+ * least {@link STATIC_BATCH_MIN} of a model. Culling stays per instance: `syncStaticObjectSource`
64
+ * compacts each batch's drawn range against the visibility predicate every frame, and the cell keeps
65
+ * the batch's one bounding sphere small enough that three's own frustum test still rejects it in the
66
+ * shadow pass. Props spread thinner than the minimum in a cell fall back to individual draws.
67
+ * @internal
68
+ */
69
+ export function groupStaticObjects(candidates, previous, cell = STATIC_BATCH_CELL) {
58
70
  const groups = new Map();
59
71
  const singles = [];
60
72
  for (const candidate of candidates) {
@@ -63,7 +75,7 @@ export function groupStaticObjects(candidates, chunkSize = STATIC_BATCH_CHUNK, p
63
75
  singles.push(candidate);
64
76
  continue;
65
77
  }
66
- const key = JSON.stringify([object.catalogId, Math.floor(object.position[0] / chunkSize), Math.floor(object.position[2] / chunkSize), model]);
78
+ const key = JSON.stringify([object.catalogId, Math.floor(object.position[0] / cell), Math.floor(object.position[2] / cell), model]);
67
79
  let group = groups.get(key);
68
80
  if (group === undefined) {
69
81
  group = { batch: { key, model, objects: [] }, candidates: [] };
@@ -84,6 +96,28 @@ export function groupStaticObjects(candidates, chunkSize = STATIC_BATCH_CHUNK, p
84
96
  }
85
97
  return { batches, singles };
86
98
  }
99
+ /** Marks the instanced draws a static object batch owns so the batching probe can count them. @internal */
100
+ export const STATIC_BATCH_MESH_KEY = "jgStaticObjectBatch";
101
+ /**
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.
105
+ * @internal
106
+ */
107
+ export function countStaticBatchDraws(scene) {
108
+ let draws = 0;
109
+ const drawn = new Set();
110
+ scene.traverse((node) => {
111
+ if (node.userData[STATIC_BATCH_MESH_KEY] !== true)
112
+ return;
113
+ draws += 1;
114
+ const ids = node.userData[POINTER_OBJECT_INSTANCES_KEY];
115
+ if (Array.isArray(ids))
116
+ for (const id of ids)
117
+ drawn.add(id);
118
+ });
119
+ return { draws, drawn: drawn.size };
120
+ }
87
121
  /** Rejects loaded scenes whose behavior cannot be represented by static instanced draws. @internal */
88
122
  export function canInstanceStaticScene(scene, clips) {
89
123
  if (clips.length > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.27.0",
3
+ "version": "0.29.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",
@@ -35,9 +35,9 @@
35
35
  "test": "bun test src"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.27.0",
39
- "@jgengine/react": "^0.27.0",
40
- "@jgengine/ws": "^0.27.0"
38
+ "@jgengine/core": "^0.29.0",
39
+ "@jgengine/react": "^0.29.0",
40
+ "@jgengine/ws": "^0.29.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@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.27.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.29.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.27.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.29.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -29,6 +29,7 @@ Existing React games keep their entity store and use the focused
29
29
 
30
30
  - **Every game owns its UI end-to-end.** Custom composition, skin, placement, terminology, motion, and one main menu. The website/runner is a bare loader. Composing and reskinning the shipped blocks is correct use; a genre HUD kit, theme preset, or unarranged default stack dropped in as the game's identity is not.
31
31
  - **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md#main-menu).
32
+ - **Menus come from the shipped blocks, navigated by `useMenuNav`.** `TitleMenu`/`MenuScreen` (`@jgengine/react`) give the animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` the slot list, `CreditsPanel` the credits screen, `LoadingScreen` the boot screen with rotating tips. `useMenuNav` (`@jgengine/react/menuNav`) is the one keyboard + gamepad focus model for any menu: roving focus, confirm/back, disabled rows skipped, no focus trap. Games keep wording, layout, art and terminology.
32
33
  - **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact.
33
34
  - **Route the front-end with `useMenuRouter`, and generate the credits.** `useMenuRouter(root)` is the screen stack behind title → save select → settings → credits: `open`/`back`/`replace`/`reset`, `path` for depth, and Escape-to-back bound by default (`escapeToBack: false` when the game owns Escape). `menuStackReducer` is the same transition as a pure function. For credits, `creditsForSourceIds(ids)` (`@jgengine/assets/credits`) turns the pack ids a game declares to `buildCatalog` into licensed, attributed sections, `mergeCredits` folds them into hand-authored lines, and `CreditsScreen` renders the result HudTheme-skinned.
34
35
  - **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the whole `--jg-*` vocabulary — bar fills and troughs, `HudFrame variation="themed"`, painted slots, the minimap ring, plus surfaces, edges, text, status colors, fonts, and rarity — so one theme spread on any HUD ancestor reskins the `@jgengine/react` primitives and the `registry/jgengine` blocks together. Author your own with `deriveHudTheme({ accent, surface })` or `hudTheme({ ... })`; `HUD_THEME_PRESETS` / `resolveHudTheme("<preset>")` are demo/scaffold starting points, never a game's identity. A component that needs a token no theme emits fails `check-hud-tokens` — add it to `HudTheme` rather than inventing a second vocabulary. Caller `style`/`renderItem` overrides still win.