@jgengine/shell 0.18.1 → 0.21.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 (261) hide show
  1. package/CHANGELOG.md +504 -7
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +132 -17
  5. package/dist/Shell3dPresentation.d.ts +5 -11
  6. package/dist/Shell3dPresentation.js +73 -11
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +20 -2
  10. package/dist/ShellHudPresentation.js +41 -6
  11. package/dist/audio/AudioComponents.js +6 -2
  12. package/dist/audio/audioEngine.d.ts +24 -0
  13. package/dist/audio/audioEngine.js +231 -83
  14. package/dist/audio/audioWire.d.ts +3 -0
  15. package/dist/audio/audioWire.js +9 -3
  16. package/dist/audio/loopParams.d.ts +39 -0
  17. package/dist/audio/loopParams.js +43 -0
  18. package/dist/audio/synthEngine.d.ts +7 -1
  19. package/dist/audio/synthEngine.js +103 -38
  20. package/dist/camera/GameCameraRig.js +3 -2
  21. package/dist/camera/GameFirstPersonCamera.d.ts +6 -4
  22. package/dist/camera/GameFirstPersonCamera.js +68 -27
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +76 -62
  26. package/dist/camera/chaseHeading.d.ts +9 -0
  27. package/dist/camera/chaseHeading.js +10 -0
  28. package/dist/camera/customMuzzle.d.ts +14 -0
  29. package/dist/camera/customMuzzle.js +68 -0
  30. package/dist/camera/index.d.ts +1 -0
  31. package/dist/camera/index.js +1 -0
  32. package/dist/camera/orbitCameraMath.js +2 -2
  33. package/dist/camera/rigMath.d.ts +82 -4
  34. package/dist/camera/rigMath.js +133 -15
  35. package/dist/camera/rigResolve.d.ts +3 -0
  36. package/dist/camera/rigResolve.js +11 -0
  37. package/dist/combat/ProjectileModels.d.ts +31 -0
  38. package/dist/combat/ProjectileModels.js +70 -0
  39. package/dist/commandSink.d.ts +1 -2
  40. package/dist/commandSink.js +3 -2
  41. package/dist/defineGame.d.ts +19 -3
  42. package/dist/defineGame.js +39 -7
  43. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  44. package/dist/devtools/DevtoolsOverlay.js +15 -167
  45. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  46. package/dist/devtools/DevtoolsRuntime.js +61 -0
  47. package/dist/devtools/agentBridge.d.ts +6 -0
  48. package/dist/devtools/agentBridge.js +16 -2
  49. package/dist/devtools/devtoolsReports.d.ts +68 -0
  50. package/dist/devtools/devtoolsReports.js +100 -0
  51. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  52. package/dist/devtools/latencyInstrumentation.js +11 -0
  53. package/dist/devtools/playtestProbe.d.ts +4 -0
  54. package/dist/devtools/playtestProbe.js +22 -0
  55. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  56. package/dist/diagnostics/FpsCounter.js +32 -0
  57. package/dist/drivers/FrameDriver.js +35 -20
  58. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  59. package/dist/drivers/FrameRateLimiter.js +39 -0
  60. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  61. package/dist/drivers/HudOnlyDriver.js +32 -14
  62. package/dist/environment/DayNightSky.js +5 -5
  63. package/dist/environment/Daylight.d.ts +35 -1
  64. package/dist/environment/Daylight.js +98 -28
  65. package/dist/environment/EnvironmentScene.js +11 -10
  66. package/dist/environment/VolumetricClouds.js +20 -2
  67. package/dist/environment/daylightCycle.d.ts +20 -0
  68. package/dist/environment/daylightCycle.js +59 -7
  69. package/dist/environment/sunShadowMath.d.ts +45 -0
  70. package/dist/environment/sunShadowMath.js +50 -0
  71. package/dist/input/gamepadPoll.d.ts +47 -0
  72. package/dist/input/gamepadPoll.js +142 -0
  73. package/dist/input/gamepadSource.d.ts +11 -1
  74. package/dist/input/gamepadSource.js +117 -71
  75. package/dist/input/mouseLook.js +2 -1
  76. package/dist/input/padHaptics.d.ts +17 -0
  77. package/dist/input/padHaptics.js +32 -0
  78. package/dist/input/pointerLock.d.ts +11 -0
  79. package/dist/input/pointerLock.js +19 -0
  80. package/dist/inputSink.d.ts +6 -6
  81. package/dist/inputSink.js +60 -21
  82. package/dist/lazyShell3dPresentation.d.ts +9 -0
  83. package/dist/lazyShell3dPresentation.js +24 -0
  84. package/dist/materialOverride.d.ts +42 -0
  85. package/dist/materialOverride.js +95 -36
  86. package/dist/multiplayer.d.ts +33 -3
  87. package/dist/multiplayer.js +105 -22
  88. package/dist/pointer/pointerService.d.ts +2 -0
  89. package/dist/pointer/pointerService.js +12 -2
  90. package/dist/postfx/PostProcessing.d.ts +11 -1
  91. package/dist/postfx/PostProcessing.js +14 -42
  92. package/dist/postfx/gradeShader.d.ts +1 -1
  93. package/dist/postfx/gradeShader.js +30 -2
  94. package/dist/postfx/shadowPrepass.d.ts +3 -0
  95. package/dist/postfx/shadowPrepass.js +18 -0
  96. package/dist/postfx/stylizePass.d.ts +23 -0
  97. package/dist/postfx/stylizePass.js +216 -0
  98. package/dist/presentationRecovery.d.ts +75 -0
  99. package/dist/presentationRecovery.js +204 -0
  100. package/dist/presentationRecovery3d.d.ts +4 -0
  101. package/dist/presentationRecovery3d.js +32 -0
  102. package/dist/presentationResolve.d.ts +8 -1
  103. package/dist/presentationResolve.js +5 -2
  104. package/dist/render/CascadedShadows.d.ts +7 -2
  105. package/dist/render/CascadedShadows.js +98 -20
  106. package/dist/render/EnvironmentLighting.js +74 -35
  107. package/dist/render/HairCards.d.ts +7 -0
  108. package/dist/render/HairCards.js +23 -0
  109. package/dist/render/SceneModels.js +52 -78
  110. package/dist/render/SkinnedInstances.d.ts +91 -0
  111. package/dist/render/SkinnedInstances.js +249 -0
  112. package/dist/render/TextureFiltering.d.ts +16 -0
  113. package/dist/render/TextureFiltering.js +64 -0
  114. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  115. package/dist/render/authoredSurfaceMaterial.js +108 -0
  116. package/dist/render/displayColor.d.ts +33 -0
  117. package/dist/render/displayColor.js +138 -0
  118. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  119. package/dist/render/materialAppearanceSignals.js +58 -0
  120. package/dist/render/materialAsset.d.ts +98 -0
  121. package/dist/render/materialAsset.js +454 -0
  122. package/dist/render/measureBounds.js +16 -3
  123. package/dist/render/measureCollisionMesh.js +8 -1
  124. package/dist/render/modelAssets.d.ts +8 -0
  125. package/dist/render/modelAssets.js +43 -0
  126. package/dist/render/modelBindPose.d.ts +5 -0
  127. package/dist/render/modelBindPose.js +35 -0
  128. package/dist/render/modelLoad.d.ts +32 -0
  129. package/dist/render/modelLoad.js +70 -1
  130. package/dist/render/modelNodeVisibility.d.ts +6 -0
  131. package/dist/render/modelNodeVisibility.js +18 -0
  132. package/dist/render/modelRender.d.ts +18 -1
  133. package/dist/render/modelRender.js +81 -3
  134. package/dist/render/projectedOverdraw.d.ts +35 -0
  135. package/dist/render/projectedOverdraw.js +204 -0
  136. package/dist/render/resolveModel.d.ts +12 -2
  137. package/dist/render/resolveModel.js +25 -4
  138. package/dist/render/rigNode.d.ts +26 -0
  139. package/dist/render/rigNode.js +32 -0
  140. package/dist/render/spriteRender.d.ts +4 -0
  141. package/dist/render/spriteRender.js +5 -0
  142. package/dist/render/staticShapeInstances.d.ts +34 -0
  143. package/dist/render/staticShapeInstances.js +78 -0
  144. package/dist/render/useFootIk.d.ts +53 -4
  145. package/dist/render/useFootIk.js +262 -70
  146. package/dist/render/useModelAnimation.d.ts +50 -0
  147. package/dist/render/useModelAnimation.js +245 -99
  148. package/dist/render/useModelInstance.d.ts +25 -0
  149. package/dist/render/useModelInstance.js +43 -0
  150. package/dist/scatter/scatterModels.d.ts +3 -2
  151. package/dist/scatter/scatterModels.js +15 -7
  152. package/dist/scene/AuthoredScene.d.ts +23 -6
  153. package/dist/scene/AuthoredScene.js +46 -9
  154. package/dist/scene/CityRenderer.js +6 -6
  155. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  156. package/dist/settings/QuickControls.js +1 -1
  157. package/dist/settings/SettingsMenu.js +11 -18
  158. package/dist/settings/SettingsRuntime.d.ts +5 -0
  159. package/dist/settings/SettingsRuntime.js +29 -2
  160. package/dist/settings/appliedSettings.d.ts +9 -3
  161. package/dist/settings/appliedSettings.js +6 -5
  162. package/dist/settings/settingsController.d.ts +3 -0
  163. package/dist/settings/settingsController.js +47 -3
  164. package/dist/shellControlSuspension.d.ts +18 -0
  165. package/dist/shellControlSuspension.js +28 -0
  166. package/dist/shellInputPublication.d.ts +28 -0
  167. package/dist/shellInputPublication.js +49 -0
  168. package/dist/shellPointerInput.d.ts +18 -0
  169. package/dist/shellPointerInput.js +49 -0
  170. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  171. package/dist/structures/BuildingKitBatch.js +35 -56
  172. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  173. package/dist/structures/BuildingRenderBudget.js +54 -0
  174. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  175. package/dist/structures/GeneratedBuilding.js +33 -22
  176. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  177. package/dist/structures/buildingKitMaterials.js +70 -0
  178. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  179. package/dist/structures/buildingSpatialBatch.js +45 -0
  180. package/dist/structures/buildingSurface.d.ts +21 -0
  181. package/dist/structures/buildingSurface.js +134 -0
  182. package/dist/terrain/GrassField.d.ts +2 -1
  183. package/dist/terrain/GrassField.js +59 -5
  184. package/dist/terrain/grassGeometry.d.ts +10 -0
  185. package/dist/terrain/grassGeometry.js +55 -0
  186. package/dist/terrain/grassMaterial.js +2 -2
  187. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  188. package/dist/terrain/terrainDetailMaterial.js +7 -7
  189. package/dist/touch/TouchControlsOverlay.js +4 -4
  190. package/dist/useShellMultiplayerSync.d.ts +1 -1
  191. package/dist/useShellMultiplayerSync.js +9 -3
  192. package/dist/vfx/ParticleField.d.ts +23 -20
  193. package/dist/vfx/ParticleField.js +168 -50
  194. package/dist/visibility/CullingProvider.js +2 -62
  195. package/dist/visibility/cullingDriver.d.ts +9 -0
  196. package/dist/visibility/cullingDriver.js +68 -0
  197. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  198. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  199. package/dist/weather/DustField.d.ts +2 -1
  200. package/dist/weather/DustField.js +17 -3
  201. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  202. package/dist/weather/FireSpreadLayer.js +5 -11
  203. package/dist/weather/LightningStrike.d.ts +4 -1
  204. package/dist/weather/LightningStrike.js +13 -17
  205. package/dist/weather/RainField.d.ts +2 -1
  206. package/dist/weather/RainField.js +17 -3
  207. package/dist/weather/RainImpactField.d.ts +16 -0
  208. package/dist/weather/RainImpactField.js +89 -0
  209. package/dist/weather/SnowField.d.ts +2 -1
  210. package/dist/weather/SnowField.js +17 -3
  211. package/dist/weather/WeatherLayer.d.ts +11 -2
  212. package/dist/weather/WeatherLayer.js +30 -6
  213. package/dist/weather/index.d.ts +2 -0
  214. package/dist/weather/index.js +2 -0
  215. package/dist/weather/weatherMath.d.ts +2 -0
  216. package/dist/weather/weatherMath.js +8 -0
  217. package/dist/weather/weatherShelter.d.ts +18 -0
  218. package/dist/weather/weatherShelter.js +75 -0
  219. package/dist/weather/weatherUniforms.d.ts +40 -1
  220. package/dist/weather/weatherUniforms.js +29 -9
  221. package/dist/world/AuthoredFlocks.d.ts +22 -0
  222. package/dist/world/AuthoredFlocks.js +87 -0
  223. package/dist/world/StaticObjectModels.d.ts +8 -0
  224. package/dist/world/StaticObjectModels.js +75 -0
  225. package/dist/world/WorldHud.d.ts +14 -6
  226. package/dist/world/WorldHud.js +25 -12
  227. package/dist/world/WorldParticles.d.ts +10 -23
  228. package/dist/world/WorldParticles.js +210 -62
  229. package/dist/world/WorldScene.d.ts +3 -1
  230. package/dist/world/WorldScene.js +41 -26
  231. package/dist/world/staticObjectBatches.d.ts +35 -0
  232. package/dist/world/staticObjectBatches.js +163 -0
  233. package/dist/world/worldBarSamples.d.ts +37 -33
  234. package/dist/world/worldBarSamples.js +117 -75
  235. package/package.json +4 -4
  236. package/skills/ce-handoff/SKILL.md +2 -2
  237. package/skills/game-design/SKILL.md +2 -2
  238. package/skills/jgengine/SKILL.md +28 -6
  239. package/skills/jgengine/api.md +139 -99
  240. package/skills/jgengine/capabilities.md +28 -0
  241. package/skills/jgengine/recipes/minimal-game.md +10 -2
  242. package/skills/jgengine-ui/SKILL.md +16 -17
  243. package/skills/jgengine-ui/api.md +603 -365
  244. package/skills/jgengine-ui/capabilities.md +131 -1
  245. package/skills/jgengine-ui/reference-primitives.md +179 -0
  246. package/skills/jgengine-ui/reference.md +4 -81
  247. package/skills/jgengine-world/SKILL.md +25 -4
  248. package/skills/jgengine-world/api.md +1045 -655
  249. package/skills/jgengine-world/capabilities.md +246 -9
  250. package/skills/jgengine-world/recipes/character-animation.md +62 -0
  251. package/skills/jgengine-world/recipes/controls.md +48 -0
  252. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  253. package/skills/jgengine-world/recipes/npc-senses.md +55 -0
  254. package/skills/jgengine-world/recipes/vehicle-feel.md +87 -0
  255. package/skills/jgengine-world/reference.md +180 -4
  256. package/skills/jgengine-world/visibility.md +139 -0
  257. package/skills/level-design/SKILL.md +1 -1
  258. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  259. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  260. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  261. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -0,0 +1,45 @@
1
+ import * as THREE from "three";
2
+ /** Partition final geometry bounds without changing instance transforms or material groups. @internal */
3
+ export function partitionBuildingMatrices(geometry, matrices, sourceMatrix, cellSize = 64) {
4
+ if (!Number.isFinite(cellSize) || cellSize <= 0)
5
+ throw new RangeError("Building cell size must be positive and finite");
6
+ if (geometry.boundingBox === null)
7
+ geometry.computeBoundingBox();
8
+ const geometryBounds = geometry.boundingBox;
9
+ if (geometryBounds === null || geometryBounds.isEmpty())
10
+ return [];
11
+ const chunks = new Map();
12
+ const aggregateBounds = new THREE.Box3();
13
+ const finalMatrices = [];
14
+ const bounds = new THREE.Box3();
15
+ const center = new THREE.Vector3();
16
+ for (const matrix of matrices) {
17
+ const finalMatrix = sourceMatrix === undefined ? matrix : new THREE.Matrix4().multiplyMatrices(matrix, sourceMatrix);
18
+ bounds.copy(geometryBounds).applyMatrix4(finalMatrix);
19
+ aggregateBounds.union(bounds);
20
+ finalMatrices.push(finalMatrix);
21
+ bounds.getCenter(center);
22
+ const key = `${Math.floor(center.x / cellSize)}:${Math.floor(center.z / cellSize)}`;
23
+ let chunk = chunks.get(key);
24
+ if (chunk === undefined) {
25
+ chunk = { key, matrices: [], bounds: new THREE.Box3() };
26
+ chunks.set(key, chunk);
27
+ }
28
+ chunk.matrices.push(finalMatrix);
29
+ chunk.bounds.union(bounds);
30
+ }
31
+ if (finalMatrices.length > 0 && aggregateBounds.max.x - aggregateBounds.min.x <= cellSize
32
+ && aggregateBounds.max.z - aggregateBounds.min.z <= cellSize) {
33
+ return [{ key: "compact", matrices: finalMatrices, bounds: aggregateBounds }];
34
+ }
35
+ return [...chunks.values()];
36
+ }
37
+ /** Install matrices and bounds; cleanup releases only the mesh's instance buffers, retaining shared assets. @internal */
38
+ export function applyBuildingChunk(mesh, chunk) {
39
+ chunk.matrices.forEach((matrix, index) => mesh.setMatrixAt(index, matrix));
40
+ mesh.count = chunk.matrices.length;
41
+ mesh.instanceMatrix.needsUpdate = true;
42
+ mesh.boundingBox = chunk.bounds.clone();
43
+ mesh.boundingSphere = chunk.bounds.getBoundingSphere(new THREE.Sphere());
44
+ return () => mesh.dispose();
45
+ }
@@ -0,0 +1,21 @@
1
+ import * as THREE from "three";
2
+ import type { BuildingSurfaceMaps, BuildingSurfaceMaterial } from "@jgengine/core/world/buildings";
3
+ import type { MaterialOverrideTextures } from "../materialOverride.js";
4
+ /** A surface that names at least one map URL. */
5
+ export type TexturedBuildingSurface = BuildingSurfaceMaterial & {
6
+ maps: BuildingSurfaceMaps;
7
+ };
8
+ /** True when the surface names at least one map URL, so the batch needs the textured path. */
9
+ export declare function surfaceHasMaps(surface: BuildingSurfaceMaterial | undefined): surface is TexturedBuildingSurface;
10
+ /** A stable key for bucketing instances that share one surface (same colour, maps, tiling, and response). */
11
+ export declare function surfaceKey(surface: BuildingSurfaceMaterial | undefined): string;
12
+ /**
13
+ * Loads a surface's PBR maps and returns per-caller clones configured to tile `repeat` times per
14
+ * slot, or `undefined` until the first map arrives so the batch draws its flat colour meanwhile.
15
+ * Clones share the decoded image, so two kinds using one map at different repeats cost one
16
+ * download. Maps whose file is missing are skipped rather than failing the whole surface.
17
+ * @internal
18
+ */
19
+ export declare function useBuildingSurfaceTextures(surface: BuildingSurfaceMaterial | undefined): MaterialOverrideTextures | undefined;
20
+ /** Applies a surface's colour, response, and loaded maps onto a standard or physical material in place. @internal */
21
+ export declare function applyBuildingSurface(material: THREE.MeshStandardMaterial, surface: BuildingSurfaceMaterial, textures: MaterialOverrideTextures | undefined): THREE.MeshStandardMaterial;
@@ -0,0 +1,134 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import * as THREE from "three";
3
+ const SURFACE_ROLES = ["color", "normal", "roughness", "ao", "metalness"];
4
+ /** True when the surface names at least one map URL, so the batch needs the textured path. */
5
+ export function surfaceHasMaps(surface) {
6
+ if (surface?.maps === undefined)
7
+ return false;
8
+ return SURFACE_ROLES.some((role) => surface.maps?.[role] !== undefined);
9
+ }
10
+ /** A stable key for bucketing instances that share one surface (same colour, maps, tiling, and response). */
11
+ export function surfaceKey(surface) {
12
+ if (surface === undefined)
13
+ return "";
14
+ const repeat = surface.repeat === undefined ? 1 : surface.repeat;
15
+ const maps = surface.maps === undefined ? "" : SURFACE_ROLES.map((role) => surface.maps?.[role] ?? "").join(",");
16
+ const tiling = typeof repeat === "number" ? String(repeat) : repeat.join("x");
17
+ return `${surface.color ?? ""}|${maps}|${tiling}|${surface.roughness ?? ""}|${surface.metalness ?? ""}`;
18
+ }
19
+ function repeatOf(surface) {
20
+ const repeat = surface.repeat ?? 1;
21
+ return typeof repeat === "number" ? [repeat, repeat] : [repeat[0], repeat[1]];
22
+ }
23
+ const textureLoader = new THREE.TextureLoader();
24
+ const loads = new Map();
25
+ /** One shared decode per URL; a missing file (a pack without `ao.jpg`) resolves to null instead of failing the batch. */
26
+ function loadSurfaceTexture(url) {
27
+ let pending = loads.get(url);
28
+ if (pending === undefined) {
29
+ pending = new Promise((resolve) => {
30
+ textureLoader.load(url, (texture) => resolve(texture), undefined, () => resolve(null));
31
+ });
32
+ loads.set(url, pending);
33
+ }
34
+ return pending;
35
+ }
36
+ /**
37
+ * Loads a surface's PBR maps and returns per-caller clones configured to tile `repeat` times per
38
+ * slot, or `undefined` until the first map arrives so the batch draws its flat colour meanwhile.
39
+ * Clones share the decoded image, so two kinds using one map at different repeats cost one
40
+ * download. Maps whose file is missing are skipped rather than failing the whole surface.
41
+ * @internal
42
+ */
43
+ export function useBuildingSurfaceTextures(surface) {
44
+ const maps = surface?.maps;
45
+ const color = maps?.color;
46
+ const normal = maps?.normal;
47
+ const roughness = maps?.roughness;
48
+ const ao = maps?.ao;
49
+ const metalness = maps?.metalness;
50
+ const urls = useMemo(() => {
51
+ const record = {};
52
+ if (color !== undefined)
53
+ record.color = color;
54
+ if (normal !== undefined)
55
+ record.normal = normal;
56
+ if (roughness !== undefined)
57
+ record.roughness = roughness;
58
+ if (ao !== undefined)
59
+ record.ao = ao;
60
+ if (metalness !== undefined)
61
+ record.metalness = metalness;
62
+ return record;
63
+ }, [color, normal, roughness, ao, metalness]);
64
+ const [loaded, setLoaded] = useState(undefined);
65
+ useEffect(() => {
66
+ const entries = Object.entries(urls);
67
+ if (entries.length === 0) {
68
+ setLoaded(undefined);
69
+ return;
70
+ }
71
+ let cancelled = false;
72
+ Promise.all(entries.map(([role, url]) => loadSurfaceTexture(url).then((texture) => [role, texture]))).then((results) => {
73
+ if (cancelled)
74
+ return;
75
+ const record = {};
76
+ for (const [role, texture] of results)
77
+ if (texture !== null)
78
+ record[role] = texture;
79
+ setLoaded(Object.keys(record).length === 0 ? undefined : record);
80
+ });
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [urls]);
85
+ const [repeatX, repeatY] = surface === undefined ? [1, 1] : repeatOf(surface);
86
+ const textures = useMemo(() => {
87
+ if (loaded === undefined)
88
+ return undefined;
89
+ const out = {};
90
+ for (const role of SURFACE_ROLES) {
91
+ const source = loaded[role];
92
+ if (source === undefined)
93
+ continue;
94
+ const texture = source.clone();
95
+ texture.wrapS = THREE.RepeatWrapping;
96
+ texture.wrapT = THREE.RepeatWrapping;
97
+ texture.repeat.set(repeatX, repeatY);
98
+ texture.colorSpace = role === "color" ? THREE.SRGBColorSpace : THREE.NoColorSpace;
99
+ texture.needsUpdate = true;
100
+ out[role] = texture;
101
+ }
102
+ return out;
103
+ }, [loaded, repeatX, repeatY]);
104
+ useEffect(() => () => {
105
+ if (textures === undefined)
106
+ return;
107
+ for (const role of SURFACE_ROLES)
108
+ textures[role]?.dispose();
109
+ }, [textures]);
110
+ return textures;
111
+ }
112
+ /** Applies a surface's colour, response, and loaded maps onto a standard or physical material in place. @internal */
113
+ export function applyBuildingSurface(material, surface, textures) {
114
+ if (surface.color !== undefined)
115
+ material.color.set(surface.color);
116
+ if (surface.roughness !== undefined)
117
+ material.roughness = surface.roughness;
118
+ if (surface.metalness !== undefined)
119
+ material.metalness = surface.metalness;
120
+ if (textures !== undefined) {
121
+ if (textures.color !== undefined)
122
+ material.map = textures.color;
123
+ if (textures.normal !== undefined)
124
+ material.normalMap = textures.normal;
125
+ if (textures.roughness !== undefined)
126
+ material.roughnessMap = textures.roughness;
127
+ if (textures.ao !== undefined)
128
+ material.aoMap = textures.ao;
129
+ if (textures.metalness !== undefined)
130
+ material.metalnessMap = textures.metalness;
131
+ material.needsUpdate = true;
132
+ }
133
+ return material;
134
+ }
@@ -28,4 +28,5 @@ export interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "c
28
28
  normalLift?: number;
29
29
  roughness?: number;
30
30
  }
31
- export declare function GrassField({ count, density, budget, area, seed, segments, bladeHeight, bladeWidth, bladeBend, tuftBlades, tuftRadius, edgeFeather, exclude, heightAt, colorBase, colorTip, colorGround, colorVariation, wind, distanceFade, normalLift, roughness, castShadow, receiveShadow, frustumCulled, ...meshProps }: GrassFieldProps): import("react").JSX.Element;
31
+ /** Seeded grass tufts with bounded per-camera chunk submission; density and budgets stay in blades. */
32
+ export declare function GrassField({ count, density, budget, area, seed, segments, bladeHeight, bladeWidth, bladeBend, tuftBlades, tuftRadius, edgeFeather, exclude, heightAt, colorBase, colorTip, colorGround, colorVariation, wind, distanceFade, normalLift, roughness, castShadow, receiveShadow, frustumCulled, onBeforeRender, onBeforeShadow, onAfterRender, onAfterShadow, ...meshProps }: GrassFieldProps): import("react").JSX.Element;
@@ -1,14 +1,16 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo } from "react";
4
+ import * as THREE from "three";
4
5
  import { useDisposable } from "../render/useDisposable.js";
5
- import { createGrassBladeGeometry, grassTuftCount, GRASS_TUFT_BLADES, } from "./grassGeometry.js";
6
+ import { createGrassGeometryChunks, grassTuftCount, GRASS_TUFT_BLADES, } from "./grassGeometry.js";
6
7
  import { createGrassMaterial, } from "./grassMaterial.js";
7
8
  import { DEFAULT_GRASS_COUNT, DEFAULT_GRASS_DENSITY, resolveGrassInstanceBudget, } from "./grassBudget.js";
8
9
  export { DEFAULT_GRASS_COUNT, DEFAULT_GRASS_DENSITY, resolveGrassInstanceBudget } from "./grassBudget.js";
9
10
  export { GRASS_TUFT_BLADES } from "./grassGeometry.js";
10
- export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, tuftBlades = GRASS_TUFT_BLADES, tuftRadius, edgeFeather, exclude, heightAt, colorBase, colorTip, colorGround, colorVariation, wind, distanceFade, normalLift, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshProps }) {
11
- const geometry = useDisposable(() => createGrassBladeGeometry({
11
+ /** Seeded grass tufts with bounded per-camera chunk submission; density and budgets stay in blades. */
12
+ export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, tuftBlades = GRASS_TUFT_BLADES, tuftRadius, edgeFeather, exclude, heightAt, colorBase, colorTip, colorGround, colorVariation, wind, distanceFade, normalLift, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, onBeforeRender, onBeforeShadow, onAfterRender, onAfterShadow, ...meshProps }) {
13
+ const chunks = useMemo(() => createGrassGeometryChunks({
12
14
  count,
13
15
  area,
14
16
  seed,
@@ -22,6 +24,7 @@ export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRAS
22
24
  ...(exclude === undefined ? {} : { exclude }),
23
25
  heightAt,
24
26
  }), [area, bladeBend, bladeHeight, bladeWidth, count, edgeFeather, exclude, heightAt, seed, segments, tuftBlades, tuftRadius]);
27
+ useDisposable(() => chunks.map((chunk) => chunk.geometry), [chunks]);
25
28
  const handle = useMemo(() => createGrassMaterial({
26
29
  colorBase,
27
30
  colorTip,
@@ -34,10 +37,61 @@ export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRAS
34
37
  }), [colorBase, colorTip, colorGround, colorVariation, distanceFade, normalLift, roughness, wind]);
35
38
  // Budgets stay in blades (the public unit); the instance buffer carries tufts.
36
39
  const instanceCount = useMemo(() => grassTuftCount(resolveGrassInstanceBudget(count, density, area, budget), tuftBlades), [count, density, area, budget, tuftBlades]);
37
- geometry.instanceCount = Math.min(instanceCount, grassTuftCount(count, tuftBlades));
40
+ const draws = useMemo(() => {
41
+ const counts = chunks.map((chunk) => {
42
+ let low = 0, high = chunk.indices.length;
43
+ while (low < high) {
44
+ const mid = (low + high) >>> 1;
45
+ if (chunk.indices[mid] < instanceCount)
46
+ low = mid + 1;
47
+ else
48
+ high = mid;
49
+ }
50
+ return low;
51
+ });
52
+ const patchBounds = new THREE.Box3(new THREE.Vector3(-0.5, 0, 0), new THREE.Vector3(0.5, 1, 0));
53
+ const windExtent = (Math.abs(handle.uniforms.uWindStrength.value) + Math.abs(handle.uniforms.uWindFlutter.value)) * 1.2;
54
+ for (const chunk of chunks) {
55
+ chunk.bounds.copy(chunk.roots).expandByScalar(chunk.bladeExtent + windExtent);
56
+ chunk.geometry.boundingBox = chunk.bounds;
57
+ patchBounds.union(chunk.bounds);
58
+ }
59
+ const sphere = patchBounds.getBoundingSphere(new THREE.Sphere());
60
+ // All chunks retain the patch's shadow bounds; the factor also covers affine parent shear.
61
+ sphere.radius *= Math.sqrt(3);
62
+ const worldBounds = new THREE.Box3();
63
+ const cameraPosition = new THREE.Vector3();
64
+ const projection = new THREE.Matrix4();
65
+ const frustum = new THREE.Frustum();
66
+ return chunks.map((chunk, index) => {
67
+ chunk.geometry.boundingSphere = sphere;
68
+ chunk.geometry.instanceCount = counts[index];
69
+ const beforeRender = function (renderer, scene, camera, geometry, material, group) {
70
+ let visible = true;
71
+ if (frustumCulled) {
72
+ projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
73
+ frustum.setFromProjectionMatrix(projection, camera.coordinateSystem, camera.reversedDepth);
74
+ visible = frustum.intersectsBox(worldBounds.copy(chunk.bounds).applyMatrix4(this.matrixWorld));
75
+ }
76
+ const fade = handle.uniforms.uDistanceFade.value;
77
+ if (visible && fade.y > fade.x) {
78
+ camera.getWorldPosition(cameraPosition);
79
+ visible = worldBounds.copy(chunk.roots).applyMatrix4(this.matrixWorld).distanceToPoint(cameraPosition) <= fade.y;
80
+ }
81
+ chunk.geometry.instanceCount = visible ? counts[index] : 0;
82
+ onBeforeRender?.call(this, renderer, scene, camera, geometry, material, group);
83
+ };
84
+ const beforeShadow = function (...args) {
85
+ chunk.geometry.instanceCount = counts[index];
86
+ onBeforeShadow?.apply(this, args);
87
+ };
88
+ return { geometry: chunk.geometry, beforeRender, beforeShadow };
89
+ });
90
+ }, [chunks, frustumCulled, handle, instanceCount, onBeforeRender, onBeforeShadow]);
38
91
  useFrame((state) => {
39
92
  handle.uniforms.uTime.value = state.clock.elapsedTime;
40
93
  });
41
94
  useEffect(() => () => handle.material.dispose(), [handle]);
42
- return (_jsx("mesh", { ...meshProps, geometry: geometry, material: handle.material, castShadow: castShadow, receiveShadow: receiveShadow, frustumCulled: frustumCulled }));
95
+ const root = draws[0];
96
+ return (_jsx("mesh", { ...meshProps, geometry: root.geometry, material: handle.material, castShadow: castShadow, receiveShadow: receiveShadow, frustumCulled: frustumCulled, onBeforeRender: root.beforeRender, onBeforeShadow: root.beforeShadow, onAfterRender: onAfterRender, onAfterShadow: onAfterShadow, dispose: null, children: draws.slice(1).map((draw, index) => (_jsx("mesh", { geometry: draw.geometry, material: handle.material, castShadow: castShadow, receiveShadow: receiveShadow, frustumCulled: frustumCulled, onBeforeRender: draw.beforeRender, onBeforeShadow: draw.beforeShadow, onAfterRender: onAfterRender, onAfterShadow: onAfterShadow, dispose: null }, index))) }));
43
97
  }
@@ -60,3 +60,13 @@ export declare function grassTuftCount(bladeCount: number, tuftBlades?: number):
60
60
  * @internal
61
61
  */
62
62
  export declare function createGrassBladeGeometry(options?: GrassBladeGeometryOptions): THREE.InstancedBufferGeometry;
63
+ /** One bounded draw, retaining the original tuft indices for density/budget prefix selection. @internal */
64
+ export interface GrassGeometryChunk {
65
+ geometry: THREE.InstancedBufferGeometry;
66
+ indices: Uint32Array;
67
+ roots: THREE.Box3;
68
+ bounds: THREE.Box3;
69
+ bladeExtent: number;
70
+ }
71
+ /** Partition one existing seeded bake into at most 64 draws without resampling any tuft. @internal */
72
+ export declare function createGrassGeometryChunks(options?: GrassBladeGeometryOptions): GrassGeometryChunk[];
@@ -140,3 +140,58 @@ export function createGrassBladeGeometry(options = {}) {
140
140
  geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), Math.max(size.width, size.depth) * 0.75 + resolved.height[1]);
141
141
  return geometry;
142
142
  }
143
+ /** Partition one existing seeded bake into at most 64 draws without resampling any tuft. @internal */
144
+ export function createGrassGeometryChunks(options = {}) {
145
+ const source = createGrassBladeGeometry(options);
146
+ const size = resolveTerrainSize(options.area ?? 40);
147
+ const columns = Math.max(1, Math.min(8, Math.ceil(size.width / 16)));
148
+ const rows = Math.max(1, Math.min(8, Math.ceil(size.depth / 16)));
149
+ const buckets = Array.from({ length: columns * rows }, () => []);
150
+ const offsets = source.getAttribute("instanceOffset");
151
+ if (source.instanceCount === 0)
152
+ return [{ geometry: source, indices: new Uint32Array(), roots: new THREE.Box3(), bounds: new THREE.Box3(), bladeExtent: 0 }];
153
+ for (let i = 0; i < source.instanceCount; i++) {
154
+ const x = Math.max(0, Math.min(columns - 1, Math.floor(size.width > 0 ? (offsets.getX(i) / size.width + 0.5) * columns : 0)));
155
+ const z = Math.max(0, Math.min(rows - 1, Math.floor(size.depth > 0 ? (offsets.getZ(i) / size.depth + 0.5) * rows : 0)));
156
+ buckets[z * columns + x].push(i);
157
+ }
158
+ const vary = source.getAttribute("bladeVary");
159
+ const bendScale = source.getAttribute("bladeBendScale");
160
+ const bladeOffset = source.getAttribute("bladeOffset");
161
+ let heightScale = 0, widthScale = 0, bendFactor = 0, radius = 0;
162
+ for (let i = 0; i < vary.count; i++) {
163
+ heightScale = Math.max(heightScale, Math.abs(vary.getY(i)));
164
+ widthScale = Math.max(widthScale, Math.abs(vary.getZ(i)));
165
+ bendFactor = Math.max(bendFactor, Math.abs(bendScale.getX(i)));
166
+ radius = Math.max(radius, Math.hypot(bladeOffset.getX(i), bladeOffset.getY(i)));
167
+ }
168
+ const point = new THREE.Vector3();
169
+ const chunks = buckets.filter((indices) => indices.length > 0).map((indices) => {
170
+ const geometry = new THREE.InstancedBufferGeometry();
171
+ geometry.setIndex(source.index);
172
+ for (const [name, attribute] of Object.entries(source.attributes)) {
173
+ if (!(attribute instanceof THREE.InstancedBufferAttribute)) {
174
+ geometry.setAttribute(name, attribute);
175
+ continue;
176
+ }
177
+ const values = new Float32Array(indices.length * attribute.itemSize);
178
+ for (let i = 0; i < indices.length; i++) {
179
+ values.set(attribute.array.subarray(indices[i] * attribute.itemSize, (indices[i] + 1) * attribute.itemSize), i * attribute.itemSize);
180
+ }
181
+ geometry.setAttribute(name, new THREE.InstancedBufferAttribute(values, attribute.itemSize));
182
+ }
183
+ const roots = new THREE.Box3();
184
+ let extent = radius;
185
+ for (const i of indices) {
186
+ roots.expandByPoint(point.set(offsets.getX(i), offsets.getY(i), offsets.getZ(i)));
187
+ const height = Math.abs(source.getAttribute("instanceHeight").getX(i)) * heightScale;
188
+ const bend = Math.abs(source.getAttribute("instanceBend").getX(i)) * bendFactor;
189
+ const width = Math.abs(source.getAttribute("instanceWidth").getX(i)) * widthScale * 1.2;
190
+ extent = Math.max(extent, radius + width + height * (1 + bend * 1.18));
191
+ }
192
+ geometry.instanceCount = indices.length;
193
+ return { geometry, indices: Uint32Array.from(indices), roots, bounds: roots.clone(), bladeExtent: extent };
194
+ });
195
+ source.dispose();
196
+ return chunks;
197
+ }
@@ -111,7 +111,7 @@ void jgComputeGrassBlade() {
111
111
  // triangle blades leave the ground visible between tufts and read as stubble.
112
112
  float taper = (1.0 - 0.3 * t) * (1.0 - smoothstep(0.5, 1.0, t) * 0.95);
113
113
  // Surviving distant tufts widen to hold silhouette coverage as neighbors thin out.
114
- float width = instanceWidth * bladeVary.z * taper * (1.0 + fadeT * 1.4);
114
+ float width = instanceWidth * bladeVary.z * taper * (1.0 + fadeT * 1.4) * keep;
115
115
  float arc = instanceBend * bladeBendScale * t * t;
116
116
  vec3 localPosition = vec3(side * width, heightScale * t - arc * heightScale * 0.18, arc * heightScale);
117
117
  vec3 localNormal = normalize(vec3(0.0, -instanceBend * bladeBendScale * t, 1.0));
@@ -134,7 +134,7 @@ ${wind.layered
134
134
  + sin(gustPhase * 1.7 + crossPhase * 0.4 + 4.3) * 0.13;`
135
135
  : ` float gust = sin(gustPhase) * 0.7 + sin(gustPhase * 0.43 + 2.4) * 0.3;`}
136
136
  float flutter = sin(uTime * 7.5 + instancePhase * 2.7 + bladeVary.w * 6.2832) * uWindFlutter;
137
- vec2 windOffset = uWindDirection * (gust * uWindStrength + flutter) * t * t * (0.4 + 0.6 * bladeVary.y);
137
+ vec2 windOffset = uWindDirection * (gust * uWindStrength + flutter) * t * t * (0.4 + 0.6 * bladeVary.y) * keep;
138
138
  jgGrassPosition = instanceOffset + tuftOffset + yawedPosition + vec3(windOffset.x, 0.0, windOffset.y);
139
139
  // Lifting normals toward straight-up lights the field like a continuous meadow surface —
140
140
  // side-lit individual blade planes read as dark stubble, not turf.
@@ -7,13 +7,13 @@ export interface TerrainDetailMaterialHandle {
7
7
  /**
8
8
  * Loaded PBR textures matching a resolved `ResolvedTerrainDetailMaterial.maps`' roles. The caller
9
9
  * (a React component, via `useTexture`/`useLoader`) owns loading and disposal; `core` never touches
10
- * `THREE.Texture` and this shell function never fetches a URL itself.
10
+ * `THREE.Texture` and this shell function never fetches a URL itself. Omitted AO/roughness skip sampling.
11
11
  */
12
12
  export interface TerrainDetailMaterialTextures {
13
13
  color: THREE.Texture;
14
14
  normal: THREE.Texture;
15
- roughness: THREE.Texture;
16
- ao: THREE.Texture;
15
+ roughness?: THREE.Texture;
16
+ ao?: THREE.Texture;
17
17
  }
18
18
  /**
19
19
  * A `MeshStandardMaterial` whose fragment shader keeps the biome-tinted vertex
@@ -60,8 +60,8 @@ export function createTerrainDetailMaterial(detail, textures) {
60
60
  ? {
61
61
  uMatColor: { value: textures.color },
62
62
  uMatNormal: { value: textures.normal },
63
- uMatRoughness: { value: textures.roughness },
64
- uMatAo: { value: textures.ao },
63
+ ...(textures.roughness !== undefined ? { uMatRoughness: { value: textures.roughness } } : {}),
64
+ ...(textures.ao !== undefined ? { uMatAo: { value: textures.ao } } : {}),
65
65
  uMatRepeat: { value: Math.max(0.01, repeat) },
66
66
  uMatStrength: { value: strength },
67
67
  uMatTint: { value: tint },
@@ -88,8 +88,8 @@ vJgWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`);
88
88
  ? `
89
89
  uniform sampler2D uMatColor;
90
90
  uniform sampler2D uMatNormal;
91
- uniform sampler2D uMatRoughness;
92
- uniform sampler2D uMatAo;
91
+ ${textures.roughness !== undefined ? "uniform sampler2D uMatRoughness;" : ""}
92
+ ${textures.ao !== undefined ? "uniform sampler2D uMatAo;" : ""}
93
93
  uniform float uMatRepeat;
94
94
  uniform float uMatStrength;
95
95
  uniform float uMatTint;`
@@ -106,7 +106,7 @@ float jgMatLum = dot(jgMatColor, vec3(0.2126, 0.7152, 0.0722));
106
106
  jgCol *= mix(1.0, clamp(jgMatLum / 0.35, 0.55, 1.7), uMatStrength * (1.0 - uMatTint));
107
107
  jgCol = mix(jgCol, jgMatColor, uMatStrength * uMatTint);`
108
108
  : "";
109
- const materialRoughnessGlsl = hasTexture
109
+ const materialRoughnessGlsl = hasTexture && textures.roughness !== undefined
110
110
  ? `
111
111
  float jgMatRough = texture2D(uMatRoughness, jgMatUv).g;
112
112
  roughnessFactor = mix(roughnessFactor, jgMatRough, uMatStrength);`
@@ -116,7 +116,7 @@ roughnessFactor = mix(roughnessFactor, jgMatRough, uMatStrength);`
116
116
  vec2 jgMatNormalXy = texture2D(uMatNormal, jgMatUv).rg * 2.0 - 1.0;
117
117
  normal = normalize(normal + vec3(jgMatNormalXy.x, 0.0, jgMatNormalXy.y) * uMatStrength * 0.6);`
118
118
  : "";
119
- const materialAoGlsl = hasTexture
119
+ const materialAoGlsl = hasTexture && textures.ao !== undefined
120
120
  ? `
121
121
  float jgMatAo = texture2D(uMatAo, jgMatUv).r;
122
122
  reflectedLight.indirectDiffuse *= mix(1.0, jgMatAo, uMatStrength);`
@@ -178,6 +178,6 @@ ${materialNormalGlsl}`)
178
178
  .replace("#include <aomap_fragment>", `#include <aomap_fragment>
179
179
  ${materialAoGlsl}`);
180
180
  };
181
- material.customProgramCacheKey = () => `jgengine-terrain-detail-${detail.rockSlopeStart}-${detail.snowHeight}-${hasTexture ? "tex" : "flat"}`;
181
+ material.customProgramCacheKey = () => `jgengine-terrain-detail-${detail.rockSlopeStart}-${detail.snowHeight}-${hasTexture ? "tex" : "flat"}-${hasTexture && textures.roughness !== undefined ? "rough" : "scalar"}-${hasTexture && textures.ao !== undefined ? "ao" : "unoccluded"}`;
182
182
  return { material };
183
183
  }
@@ -548,8 +548,8 @@ function anchorInset(anchor) {
548
548
  }
549
549
  return style;
550
550
  }
551
- function AnchoredSlot({ anchor, children, slotRef, }) {
552
- return (_jsx("div", { className: "pointer-events-none absolute z-40", style: { ...ANCHOR_POSITION[anchor], ...anchorInset(anchor) }, children: _jsx("div", { ref: slotRef, children: children }) }));
551
+ function AnchoredSlot({ anchor, children, slotRef, movement = false, }) {
552
+ return (_jsx("div", { className: "pointer-events-none absolute", style: { zIndex: movement ? 10 : 40, ...ANCHOR_POSITION[anchor], ...anchorInset(anchor) }, children: _jsx("div", { ref: slotRef, children: children }) }));
553
553
  }
554
554
  function bucketByAnchor(buttons) {
555
555
  const map = new Map();
@@ -580,8 +580,8 @@ export function TouchControlsDock({ scheme, sink, style, scale = 1, joystickVari
580
580
  useRegisterLayoutRegion(UTILITY_ZONE, utilityRef, utility.length > 0);
581
581
  const actionsColumn = layout.actions === "left" || layout.actions === "right";
582
582
  const utilityColumn = layout.utility === "left" || layout.utility === "right";
583
- return (_jsxs(ResolvedLayout, { children: [scheme.joystick !== null ? (_jsx(AnchoredSlot, { anchor: layout.movement, slotRef: joystickRef, children: joystickVariant === "floating" ? (_jsx(FloatingJoystick, { joystick: scheme.joystick, sink: sink, tokens: tokens, scale: scale })) : (_jsx(VirtualJoystick, { joystick: scheme.joystick, sink: sink, tokens: tokens, scale: scale })) })) : null, primary.length > 0 ? (_jsx(AnchoredSlot, { anchor: layout.actions, slotRef: actionsRef, children: ARC_ANCHORS.has(layout.actions) ? (_jsx(ThumbArcCluster, { buttons: primary, sink: sink, tokens: tokens, scale: scale })) : (_jsx(StackCluster, { buttons: primary, utility: [], sink: sink, tokens: tokens, column: actionsColumn, scale: scale })) })) : null, utility.length > 0 ? (_jsx(AnchoredSlot, { anchor: layout.utility, slotRef: utilityRef, children: _jsx(StackCluster, { buttons: [], utility: utility, sink: sink, tokens: tokens, column: utilityColumn, scale: scale }) })) : null, [...extras.entries()].map(([anchor, bucket]) => (_jsx(AnchoredSlot, { anchor: anchor, children: _jsx(StackCluster, { buttons: bucket.primary, utility: bucket.utility, sink: sink, tokens: tokens, column: anchor === "left" || anchor === "right", scale: scale }) }, anchor)))] }));
583
+ return (_jsxs(ResolvedLayout, { children: [scheme.joystick !== null ? (_jsx(AnchoredSlot, { anchor: layout.movement, slotRef: joystickRef, movement: true, children: joystickVariant === "floating" ? (_jsx(FloatingJoystick, { joystick: scheme.joystick, sink: sink, tokens: tokens, scale: scale })) : (_jsx(VirtualJoystick, { joystick: scheme.joystick, sink: sink, tokens: tokens, scale: scale })) })) : null, primary.length > 0 ? (_jsx(AnchoredSlot, { anchor: layout.actions, slotRef: actionsRef, children: ARC_ANCHORS.has(layout.actions) ? (_jsx(ThumbArcCluster, { buttons: primary, sink: sink, tokens: tokens, scale: scale })) : (_jsx(StackCluster, { buttons: primary, utility: [], sink: sink, tokens: tokens, column: actionsColumn, scale: scale })) })) : null, utility.length > 0 ? (_jsx(AnchoredSlot, { anchor: layout.utility, slotRef: utilityRef, children: _jsx(StackCluster, { buttons: [], utility: utility, sink: sink, tokens: tokens, column: utilityColumn, scale: scale }) })) : null, [...extras.entries()].map(([anchor, bucket]) => (_jsx(AnchoredSlot, { anchor: anchor, children: _jsx(StackCluster, { buttons: bucket.primary, utility: bucket.utility, sink: sink, tokens: tokens, column: anchor === "left" || anchor === "right", scale: scale }) }, anchor)))] }));
584
584
  }
585
585
  function ResolvedLayout({ children }) {
586
- return _jsx("div", { className: "pointer-events-none absolute inset-0 z-40", children: children });
586
+ return _jsx("div", { className: "pointer-events-none absolute inset-0", children: children });
587
587
  }
@@ -1,6 +1,6 @@
1
1
  import { type Dispatch, type SetStateAction } from "react";
2
2
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
3
- import type { PresencePoseRow } from "@jgengine/core/runtime/transport";
3
+ import { type PresencePoseRow } from "@jgengine/core/runtime/transport";
4
4
  import { type AuthoritativeFrameHandler } from "./worldSync.js";
5
5
  import type { ShellMultiplayer } from "./multiplayer.js";
6
6
  import type { PlayableGame } from "./registry.js";
@@ -1,5 +1,7 @@
1
1
  import { useEffect, useState, useCallback } from "react";
2
2
  import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
3
+ import { bindCommandTransport } from "@jgengine/core/runtime/commandDispatch";
4
+ import { createTransportSessionId } from "@jgengine/core/runtime/transport";
3
5
  import { attachWorldSync } from "./worldSync.js";
4
6
  /** Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. */
5
7
  export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers, authoritativeFrameRef) {
@@ -14,8 +16,12 @@ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef,
14
16
  setFailureReason(null);
15
17
  let disposed = false;
16
18
  const cleanups = [];
19
+ if (isServerAuthoritative(playable.game.multiplayer)) {
20
+ cleanups.push(bindCommandTransport(ctx, { transport: multiplayer.backend.transport, serverId: () => serverIdRef.current }));
21
+ }
22
+ const sessionId = createTransportSessionId();
17
23
  void multiplayer.backend.transport
18
- .joinServer({ gameId: multiplayer.gameId })
24
+ .joinServer({ gameId: multiplayer.gameId, sessionId })
19
25
  .then((joined) => {
20
26
  if (!joined.ok) {
21
27
  if (!disposed) {
@@ -25,7 +31,7 @@ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef,
25
31
  return;
26
32
  }
27
33
  if (disposed) {
28
- void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId });
34
+ void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId, sessionId }).catch(() => undefined);
29
35
  return;
30
36
  }
31
37
  serverIdRef.current = joined.serverId;
@@ -110,7 +116,7 @@ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef,
110
116
  serverIdRef.current = null;
111
117
  setRemotePlayers([]);
112
118
  if (serverId !== null) {
113
- void multiplayer.backend.transport.leaveServer({ serverId }).catch(() => undefined);
119
+ void multiplayer.backend.transport.leaveServer({ serverId, sessionId }).catch(() => undefined);
114
120
  }
115
121
  };
116
122
  }, [ctx, multiplayer, playable, attempt]);
@@ -1,32 +1,35 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { ParticleSystem } from "@jgengine/core/vfx/particles";
3
- /** How particle fragments composite: `additive` for fire/sparks/magic glow, `normal` for smoke/dust. */
3
+ import type { ParticleRenderHint } from "@jgengine/core/vfx/particleDirector";
4
+ /** How particle fragments composite on screen. */
4
5
  export type ParticleBlending = "additive" | "normal";
5
- /** Props for {@link ParticleField}. */
6
+ /** @internal Mutable resource/upload counters read through the shell's supported diagnostics probe. */
7
+ export interface ParticleFieldMetrics {
8
+ fieldsMounted: number;
9
+ fieldsUnmounted: number;
10
+ liveFields: number;
11
+ attributeBytes: number;
12
+ totalUploadMs: number;
13
+ maxUploadMs: number;
14
+ uploadSamples: number;
15
+ }
16
+ /** Props for the existing pooled particle renderer. */
6
17
  export interface ParticleFieldProps {
7
- /** The core simulation to render (from `createParticleSystem`). */
8
18
  system: ParticleSystem;
9
- /**
10
- * Advance the simulation each frame with the render delta. Leave `true` for a
11
- * fire-and-forget effect; set `false` when your game loop already ticks the
12
- * system (so it advances exactly once per fixed step).
13
- */
19
+ /** Disable when an external loop already advances this system. Default true. */
14
20
  advance?: boolean;
15
- /** Compositing mode. Default `additive` (glowy). */
16
21
  blending?: ParticleBlending;
17
- /** Pixel-size multiplier applied to every particle's world radius. Default 300. */
22
+ /** Pixel multiplier for point output. Quad outputs use particle sizes in world units. */
18
23
  scale?: number;
19
- /** Write depth (occludes particles behind geometry). Default `false` so a glow layer never self-occludes. */
20
24
  depthWrite?: boolean;
25
+ /** Point, instanced sprite, velocity streak, connected ribbon, or ground ripple output. */
26
+ render?: ParticleRenderHint;
27
+ /** @internal Optional diagnostics; omitted in production. */
28
+ metrics?: ParticleFieldMetrics;
21
29
  }
22
30
  /**
23
- * Renders a core `ParticleSystem` as a GPU point cloud: one draw call, per-particle
24
- * size / color / alpha via a soft-round shader, and a `drawRange` clamped to the live
25
- * count so dead particles cost nothing. The simulation stays engine-side and
26
- * genre-agnostic; this is purely how it reaches the screen. By default it advances
27
- * the sim with the frame delta — pass `advance={false}` to drive it from your own
28
- * fixed loop instead.
29
- *
30
- * @capability particle-field render a core particle system as a soft-point GPU cloud with per-particle size, color, and alpha
31
+ * Render one pooled simulation through a point cloud or instanced quads. Output
32
+ * selection does not replace spawn, forces, collisions, or deterministic state.
33
+ * @capability particle-field render a core particle pool as soft points, velocity streaks, connected ribbons, flakes, flames, smoke sprites, or ground ripples
31
34
  */
32
- export declare function ParticleField({ system, advance, blending, scale, depthWrite, }: ParticleFieldProps): ReactElement;
35
+ export declare function ParticleField({ system, advance, blending, scale, depthWrite, render, metrics }: ParticleFieldProps): ReactElement;