@jgengine/shell 0.17.0 → 0.18.1

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 (86) hide show
  1. package/CHANGELOG.md +130 -2
  2. package/dist/GamePlayerShell.js +10 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +8 -1
  6. package/dist/Shell3dPresentation.js +34 -5
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/TerritoryOverlay.d.ts +21 -0
  10. package/dist/TerritoryOverlay.js +9 -0
  11. package/dist/audio/AudioComponents.js +8 -1
  12. package/dist/audio/audioEngine.d.ts +7 -1
  13. package/dist/audio/audioEngine.js +67 -5
  14. package/dist/camera/cameraRigs.js +7 -0
  15. package/dist/defineGame.js +21 -6
  16. package/dist/devtools/KeysPanel.js +3 -3
  17. package/dist/drivers/FrameDriver.d.ts +5 -1
  18. package/dist/drivers/FrameDriver.js +61 -24
  19. package/dist/drivers/HudOnlyDriver.js +9 -4
  20. package/dist/environment/EnvironmentScene.js +1 -1
  21. package/dist/input/gamepadMerge.d.ts +10 -0
  22. package/dist/input/gamepadMerge.js +12 -0
  23. package/dist/input/gamepadSource.d.ts +12 -0
  24. package/dist/input/gamepadSource.js +107 -0
  25. package/dist/materialOverride.d.ts +4 -1
  26. package/dist/materialOverride.js +10 -1
  27. package/dist/postfx/PostProcessing.d.ts +10 -7
  28. package/dist/postfx/PostProcessing.js +33 -15
  29. package/dist/postfx/gradeShader.js +13 -0
  30. package/dist/render/CascadedShadows.d.ts +11 -0
  31. package/dist/render/CascadedShadows.js +19 -8
  32. package/dist/render/EnvironmentLighting.d.ts +4 -1
  33. package/dist/render/EnvironmentLighting.js +45 -9
  34. package/dist/render/SceneLighting.d.ts +16 -2
  35. package/dist/render/SceneLighting.js +56 -6
  36. package/dist/render/SceneModels.js +45 -9
  37. package/dist/render/modelLoad.d.ts +10 -0
  38. package/dist/render/modelLoad.js +28 -0
  39. package/dist/render/modelRender.d.ts +3 -0
  40. package/dist/render/modelRender.js +6 -2
  41. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +15 -0
  42. package/dist/render/testFixtures/proceduralSpriteAtlas.js +30 -0
  43. package/dist/render/testFixtures/proceduralTileLayer.d.ts +13 -0
  44. package/dist/render/testFixtures/proceduralTileLayer.js +30 -0
  45. package/dist/render/useFootIk.d.ts +9 -0
  46. package/dist/render/useFootIk.js +89 -0
  47. package/dist/render/useModelAnimation.d.ts +2 -0
  48. package/dist/render/useModelAnimation.js +163 -107
  49. package/dist/scene/CityRenderer.js +661 -236
  50. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  51. package/dist/scene/cityWindowMaterial.js +80 -21
  52. package/dist/settings/appliedSettings.d.ts +3 -1
  53. package/dist/settings/appliedSettings.js +5 -2
  54. package/dist/terrain/terrainDetailMaterial.js +7 -3
  55. package/dist/useShellMultiplayerSync.d.ts +8 -1
  56. package/dist/useShellMultiplayerSync.js +25 -5
  57. package/dist/visibility/CullingProvider.d.ts +2 -1
  58. package/dist/visibility/CullingProvider.js +7 -2
  59. package/dist/world/GridWorldScene.js +4 -0
  60. package/dist/world/TileLayerRenderer.d.ts +9 -0
  61. package/dist/world/TileLayerRenderer.js +64 -0
  62. package/dist/world/WorldParticles.d.ts +31 -0
  63. package/dist/world/WorldParticles.js +128 -0
  64. package/dist/world/WorldScene.d.ts +2 -1
  65. package/dist/world/WorldScene.js +46 -4
  66. package/dist/world/entityPose.d.ts +4 -0
  67. package/dist/world/entityPose.js +9 -0
  68. package/dist/world/tileLayerInstances.d.ts +11 -0
  69. package/dist/world/tileLayerInstances.js +28 -0
  70. package/dist/worldSync.d.ts +5 -1
  71. package/dist/worldSync.js +25 -4
  72. package/package.json +4 -4
  73. package/skills/ce-handoff/SKILL.md +1 -1
  74. package/skills/game-design/SKILL.md +11 -1
  75. package/skills/jgengine/SKILL.md +2 -0
  76. package/skills/jgengine/api.md +98 -22
  77. package/skills/jgengine/capabilities.md +28 -0
  78. package/skills/jgengine-ui/SKILL.md +8 -0
  79. package/skills/jgengine-ui/api.md +127 -16
  80. package/skills/jgengine-ui/capabilities.md +21 -0
  81. package/skills/jgengine-ui/reference.md +3 -0
  82. package/skills/jgengine-world/SKILL.md +6 -0
  83. package/skills/jgengine-world/api.md +312 -12
  84. package/skills/jgengine-world/capabilities.md +102 -0
  85. package/skills/level-design/SKILL.md +1 -1
  86. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -63,14 +63,11 @@ function syncSize(built, width, height, pixelRatio) {
63
63
  * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
64
64
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
65
65
  *
66
- * `quality` (the player's graphics-quality setting) gates the passes whose cost
67
- * scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
68
- * the whole scene for depth/normals and runs a multi-sample full-screen pass,
69
- * and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
70
- * SMAA, tone mapping, and grade stay on every tier (SMAA is the cheap edge fix
71
- * for alpha-tested foliage that MSAA samples alone leave crawling).
66
+ * `stages` (resolved from the player's graphics profile) controls expensive passes
67
+ * independently. Medium keeps AO and DOF with reduced AO sampling; low can disable
68
+ * them while retaining cheaper bloom, SMAA, tone mapping, and grade stages.
72
69
  */
73
- export function PostProcessing({ config, quality = "high" }) {
70
+ export function PostProcessing({ config, quality = "high", stages }) {
74
71
  const gl = useThree((s) => s.gl);
75
72
  const scene = useThree((s) => s.scene);
76
73
  const camera = useThree((s) => s.camera);
@@ -87,8 +84,8 @@ export function PostProcessing({ config, quality = "high" }) {
87
84
  });
88
85
  const composer = new EffectComposer(gl, target);
89
86
  composer.addPass(new RenderPass(scene, camera));
90
- const heavyPasses = quality === "high";
91
- if (heavyPasses && config.ao !== undefined && config.ao !== false) {
87
+ const resolvedStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
88
+ if (resolvedStages.ao && config.ao !== undefined && config.ao !== false) {
92
89
  const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
93
90
  ao.blendIntensity = config.ao.blend ?? 1;
94
91
  ao.updateGtaoMaterial({
@@ -96,26 +93,26 @@ export function PostProcessing({ config, quality = "high" }) {
96
93
  distanceExponent: 1,
97
94
  thickness: config.ao.distanceFalloff ?? 3.6,
98
95
  scale: config.ao.intensity ?? 2.4,
99
- samples: 16,
96
+ samples: quality === "high" ? 16 : 8,
100
97
  });
101
98
  composer.addPass(ao);
102
99
  }
103
- if (config.bloom !== false) {
100
+ if (resolvedStages.bloom && config.bloom !== false) {
104
101
  const b = config.bloom ?? {};
105
102
  composer.addPass(new UnrealBloomPass(new THREE.Vector2(width, height), b.strength ?? 0.32, b.radius ?? 0.55, b.threshold ?? 0.85));
106
103
  }
107
104
  let dof = null;
108
- if (heavyPasses && config.dof !== undefined && config.dof !== false) {
105
+ if (resolvedStages.dof && config.dof !== undefined && config.dof !== false) {
109
106
  const d = config.dof;
110
107
  dof = new OverlayAwareBokehPass(scene, camera, {
111
108
  focus: d.focus ?? 18,
112
- aperture: d.aperture ?? 0.00025,
109
+ aperture: d.aperture ?? (quality === "high" ? 0.00025 : 0.00012),
113
110
  maxblur: d.maxBlur ?? 0.01,
114
111
  });
115
112
  composer.addPass(dof);
116
113
  }
117
114
  // SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
118
- if (aa === "smaa") {
115
+ if (resolvedStages.smaa && aa === "smaa") {
119
116
  composer.addPass(new SMAAPass());
120
117
  }
121
118
  composer.addPass(new OutputPass());
@@ -131,7 +128,28 @@ export function PostProcessing({ config, quality = "high" }) {
131
128
  disposeGraph(graph);
132
129
  builtRef.current = null;
133
130
  };
134
- }, [gl, scene, camera, config, quality]);
131
+ }, [gl, scene, camera, config, quality, stages]);
132
+ useEffect(() => {
133
+ const lut = config.grade !== undefined && config.grade !== false ? config.grade.lut : undefined;
134
+ const grade = builtRef.current?.grade;
135
+ if (lut === undefined || grade === null || grade === undefined)
136
+ return;
137
+ let cancelled = false;
138
+ const loader = new THREE.TextureLoader();
139
+ loader.loadAsync(lut.url).then((texture) => {
140
+ if (cancelled) {
141
+ texture.dispose();
142
+ return;
143
+ }
144
+ texture.colorSpace = THREE.SRGBColorSpace;
145
+ texture.minFilter = THREE.NearestFilter;
146
+ texture.magFilter = THREE.NearestFilter;
147
+ texture.generateMipmaps = false;
148
+ grade.uniforms.uLut.value = texture;
149
+ grade.uniforms.uLutSize.value = lut.size ?? 32;
150
+ }).catch(() => undefined);
151
+ return () => { cancelled = true; };
152
+ }, [config.grade]);
135
153
  useEffect(() => {
136
154
  const prevTone = gl.toneMapping;
137
155
  const prevExposure = gl.toneMappingExposure;
@@ -17,12 +17,22 @@ const fragmentShader = /* glsl */ `
17
17
  uniform float uGrain;
18
18
  uniform float uChroma;
19
19
  uniform float uTime;
20
+ uniform sampler2D uLut;
21
+ uniform float uLutSize;
20
22
  varying vec2 vUv;
21
23
 
22
24
  float hash(vec2 p) {
23
25
  return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
24
26
  }
25
27
 
28
+ vec3 sampleLut(vec3 color) {
29
+ float size = max(uLutSize, 2.0);
30
+ vec3 c = clamp(color, 0.0, 1.0) * (size - 1.0);
31
+ float blue = floor(c.b);
32
+ vec2 uv = vec2((blue * size + c.r + 0.5) / (size * size), (c.g + 0.5) / size);
33
+ return texture2D(uLut, uv).rgb;
34
+ }
35
+
26
36
  void main() {
27
37
  vec2 dir = vUv - 0.5;
28
38
  // Chromatic aberration: split RGB along the radial direction, growing toward the edges.
@@ -35,6 +45,7 @@ const fragmentShader = /* glsl */ `
35
45
  c = pow(max(c, 0.0), vec3(uGamma));
36
46
  float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
37
47
  c = mix(vec3(luma), c, uSaturation);
48
+ if (uLutSize > 1.0) c = sampleLut(c);
38
49
  vec2 d = vUv - 0.5;
39
50
  float vig = 1.0 - uVignette * smoothstep(0.6, 0.95, dot(d, d) * 2.2);
40
51
  c *= vig;
@@ -65,6 +76,8 @@ export function createGradePass(config = {}) {
65
76
  uGrain: { value: config.grain ?? DEFAULT_GRAIN },
66
77
  uChroma: { value: config.chromaticAberration ?? DEFAULT_CHROMA },
67
78
  uTime: { value: 0 },
79
+ uLut: { value: null },
80
+ uLutSize: { value: 0 },
68
81
  },
69
82
  vertexShader,
70
83
  fragmentShader,
@@ -1,3 +1,4 @@
1
+ import * as THREE from "three";
1
2
  import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame";
2
3
  /**
3
4
  * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
@@ -11,3 +12,13 @@ import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame
11
12
  export declare function CascadedShadows({ entry }: {
12
13
  entry: DirectionalLightingConfig;
13
14
  }): null;
15
+ /** Structural slice of CSM so the patcher is testable without a renderer. */
16
+ export interface CsmMaterialSetup {
17
+ setupMaterial(material: THREE.Material): void;
18
+ }
19
+ /**
20
+ * Run CSM's `setupMaterial` over every unpatched standard material in the scene,
21
+ * chaining rather than clobbering a material's own `onBeforeCompile`. Called every
22
+ * frame (WeakSet-guarded) so streamed meshes never render lit by all cascades at once.
23
+ */
24
+ export declare function patchSceneMaterials(scene: THREE.Scene, csm: CsmMaterialSetup, patched: WeakSet<THREE.Material>): void;
@@ -19,7 +19,6 @@ export function CascadedShadows({ entry }) {
19
19
  const scene = useThree((s) => s.scene);
20
20
  const csmRef = useRef(null);
21
21
  const patchedRef = useRef(new WeakSet());
22
- const frameRef = useRef(0);
23
22
  useEffect(() => {
24
23
  const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
25
24
  const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
@@ -47,7 +46,6 @@ export function CascadedShadows({ entry }) {
47
46
  patchedRef.current = new WeakSet();
48
47
  patchSceneMaterials(scene, csm, patchedRef.current);
49
48
  csmRef.current = csm;
50
- frameRef.current = 0;
51
49
  return () => {
52
50
  csm.dispose();
53
51
  csmRef.current = null;
@@ -71,16 +69,19 @@ export function CascadedShadows({ entry }) {
71
69
  if (csm === null)
72
70
  return;
73
71
  csm.camera = camera;
74
- frameRef.current += 1;
75
- // Async model mounts need setupMaterial — re-scan every 30 frames after the first.
76
- if (frameRef.current === 1 || frameRef.current % 30 === 0) {
77
- patchSceneMaterials(scene, csm, patchedRef.current);
78
- }
72
+ // Every frame: a mesh that renders before setupMaterial receives all cascade
73
+ // lights at once (a cascades× sun) — the WeakSet keeps the traverse cheap.
74
+ patchSceneMaterials(scene, csm, patchedRef.current);
79
75
  csm.update();
80
76
  });
81
77
  return null;
82
78
  }
83
- function patchSceneMaterials(scene, csm, patched) {
79
+ /**
80
+ * Run CSM's `setupMaterial` over every unpatched standard material in the scene,
81
+ * chaining rather than clobbering a material's own `onBeforeCompile`. Called every
82
+ * frame (WeakSet-guarded) so streamed meshes never render lit by all cascades at once.
83
+ */
84
+ export function patchSceneMaterials(scene, csm, patched) {
84
85
  scene.traverse((obj) => {
85
86
  const mesh = obj;
86
87
  if (!mesh.isMesh)
@@ -89,7 +90,17 @@ function patchSceneMaterials(scene, csm, patched) {
89
90
  for (const mat of materials) {
90
91
  if (!isStandardMaterial(mat) || patched.has(mat))
91
92
  continue;
93
+ const prior = mat.onBeforeCompile;
92
94
  csm.setupMaterial(mat);
95
+ const csmHook = mat.onBeforeCompile;
96
+ if (prior !== THREE.Material.prototype.onBeforeCompile && prior !== csmHook) {
97
+ // setupMaterial assigns onBeforeCompile, which would drop a material's own
98
+ // shader surgery (rim light, detail maps) — run both, the material's first.
99
+ mat.onBeforeCompile = function (shader, renderer) {
100
+ prior.call(this, shader, renderer);
101
+ csmHook.call(this, shader, renderer);
102
+ };
103
+ }
93
104
  patched.add(mat);
94
105
  }
95
106
  });
@@ -1,5 +1,8 @@
1
+ import type { EnvironmentSource } from "@jgengine/core/render/environment";
1
2
  /** @internal Props for shell {@link EnvironmentLighting}. */
2
3
  export interface EnvironmentLightingProps {
4
+ /** Optional authored environment map; absent or `gradient` uses the procedural probe. */
5
+ source?: EnvironmentSource;
3
6
  /** IBL intensity. Default 0.35. */
4
7
  intensity?: number;
5
8
  /** Upper-hemisphere tint for the procedural sky probe. Default cool daylight. */
@@ -23,4 +26,4 @@ export interface EnvironmentLightingProps {
23
26
  * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
24
27
  * @internal shell-internal default lighting; games never import it.
25
28
  */
26
- export declare function EnvironmentLighting({ intensity, skyColor, groundColor, sunDirection, sunColor, }: EnvironmentLightingProps): null;
29
+ export declare function EnvironmentLighting({ source, intensity, skyColor, groundColor, sunDirection, sunColor, }: EnvironmentLightingProps): null;
@@ -1,6 +1,8 @@
1
1
  import { useThree } from "@react-three/fiber";
2
2
  import { useEffect } from "react";
3
3
  import * as THREE from "three";
4
+ import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js";
5
+ import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
4
6
  /**
5
7
  * Image-based lighting from a procedural sky/ground probe (not three's stock
6
8
  * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
@@ -10,27 +12,61 @@ import * as THREE from "three";
10
12
  * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
11
13
  * @internal shell-internal default lighting; games never import it.
12
14
  */
13
- export function EnvironmentLighting({ intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
15
+ export function EnvironmentLighting({ source, intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
14
16
  const gl = useThree((state) => state.gl);
15
17
  const scene = useThree((state) => state.scene);
16
18
  useEffect(() => {
17
19
  const pmrem = new THREE.PMREMGenerator(gl);
18
- const probe = buildSkyProbeScene({ skyColor, groundColor, sunDirection, sunColor });
19
- const target = pmrem.fromScene(probe, 0.04);
20
+ let target;
21
+ let loadedTexture;
22
+ let cancelled = false;
23
+ const effective = source?.kind === "gradient" ? source : undefined;
24
+ const apply = (next, rotation) => {
25
+ if (cancelled) {
26
+ next.dispose();
27
+ return;
28
+ }
29
+ target = next;
30
+ scene.environment = next.texture;
31
+ if (rotation !== undefined)
32
+ scene.environmentRotation = new THREE.Euler(0, rotation, 0);
33
+ };
20
34
  const previous = scene.environment;
21
- scene.environment = target.texture;
35
+ const previousRotation = scene.environmentRotation;
36
+ if (source?.kind === "hdri") {
37
+ const Loader = /\.exr(?:$|\?)/i.test(source.url) ? EXRLoader : RGBELoader;
38
+ new Loader().loadAsync(source.url).then((texture) => {
39
+ loadedTexture = texture;
40
+ texture.mapping = THREE.EquirectangularReflectionMapping;
41
+ apply(pmrem.fromEquirectangular(texture), source.rotation);
42
+ }).catch(() => undefined);
43
+ }
44
+ else if (source?.kind === "cube") {
45
+ new THREE.CubeTextureLoader().loadAsync(source.urls).then((texture) => {
46
+ loadedTexture = texture;
47
+ apply(pmrem.fromCubemap(texture));
48
+ }).catch(() => undefined);
49
+ }
50
+ else {
51
+ const probe = buildSkyProbeScene({ skyColor: effective?.sky ?? skyColor, groundColor: effective?.ground ?? groundColor, sunDirection, sunColor: effective?.sun ?? sunColor });
52
+ apply(pmrem.fromScene(probe, 0.04));
53
+ disposeScene(probe);
54
+ }
22
55
  const sceneWithIntensity = scene;
23
56
  const previousIntensity = sceneWithIntensity.environmentIntensity;
24
- sceneWithIntensity.environmentIntensity = intensity;
25
- disposeScene(probe);
26
- pmrem.dispose();
57
+ sceneWithIntensity.environmentIntensity = source?.intensity ?? intensity;
27
58
  return () => {
28
- target.dispose();
59
+ cancelled = true;
60
+ pmrem.dispose();
61
+ target?.dispose();
62
+ loadedTexture?.dispose();
29
63
  scene.environment = previous;
64
+ if (previousRotation !== undefined)
65
+ scene.environmentRotation = previousRotation;
30
66
  if (previousIntensity !== undefined)
31
67
  sceneWithIntensity.environmentIntensity = previousIntensity;
32
68
  };
33
- }, [gl, scene, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
69
+ }, [gl, scene, source, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
34
70
  return null;
35
71
  }
36
72
  function buildSkyProbeScene(options) {
@@ -1,6 +1,20 @@
1
- import type { BackdropConfig, LightingConfig } from "@jgengine/core/game/playableGame";
2
- export declare function ConfiguredLighting({ lighting }: {
1
+ import type { DirectionalLight } from "three";
2
+ import type { BackdropConfig, LightingConfig, PointLightingConfig } from "@jgengine/core/game/playableGame";
3
+ import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
4
+ /** Default cap for combined dynamic point and spot lights. */
5
+ export declare const POINT_LIGHT_BUDGET = 8;
6
+ /** @internal Clamp point lights for the runtime budget. */
7
+ export declare function resolvePointLightBudget(entries: readonly PointLightingConfig[] | undefined): readonly PointLightingConfig[];
8
+ /**
9
+ * Refresh a directional light's shadow projection after its frustum bounds change.
10
+ * R3F pierced `shadow-camera-*` props write the bounds but never call
11
+ * `updateProjectionMatrix()`, so depth renders with the stale default ~10×10 box
12
+ * and `shadowCameraSize` is silently inert.
13
+ */
14
+ export declare function syncShadowProjection(light: DirectionalLight): void;
15
+ export declare function ConfiguredLighting({ lighting, profile }: {
3
16
  lighting: LightingConfig;
17
+ profile?: GraphicsProfile;
4
18
  }): import("react").JSX.Element;
5
19
  export declare function BackdropFog({ fog }: {
6
20
  fog: BackdropConfig["fog"];
@@ -1,22 +1,72 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useLayoutEffect, useRef } from "react";
2
3
  import { CascadedShadows } from "./CascadedShadows.js";
3
4
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
5
+ /** Default cap for combined dynamic point and spot lights. */
6
+ export const POINT_LIGHT_BUDGET = 8;
7
+ /** @internal Clamp point lights for the runtime budget. */
8
+ export function resolvePointLightBudget(entries) {
9
+ if (entries === undefined || entries.length <= POINT_LIGHT_BUDGET)
10
+ return entries ?? [];
11
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
12
+ console.warn(`[jgengine:lighting] ${entries.length} point lights requested — clamped to budget ${POINT_LIGHT_BUDGET}`);
13
+ }
14
+ return entries.slice(0, POINT_LIGHT_BUDGET);
15
+ }
16
+ function resolveDynamicLights(lighting) {
17
+ const point = lighting.point ?? [];
18
+ const spot = lighting.spot ?? [];
19
+ const budget = Math.max(0, Math.floor(lighting.maxDynamicLights ?? POINT_LIGHT_BUDGET));
20
+ if (point.length + spot.length <= budget)
21
+ return { point, spot };
22
+ if (typeof console !== "undefined" && typeof console.warn === "function")
23
+ console.warn(`[jgengine:lighting] ${point.length + spot.length} dynamic lights requested — clamped to budget ${budget}`);
24
+ return { point: point.slice(0, budget), spot: spot.slice(0, Math.max(0, budget - point.length)) };
25
+ }
4
26
  function usesCascades(entry) {
5
27
  return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
6
28
  }
7
- function DirectionalShadowLight({ entry }) {
8
- if (usesCascades(entry)) {
9
- return _jsx(CascadedShadows, { entry: entry });
29
+ function DirectionalShadowLight({ entry, profile }) {
30
+ const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades };
31
+ if (usesCascades(resolved)) {
32
+ return _jsx(CascadedShadows, { entry: resolved });
10
33
  }
34
+ return _jsx(SingleShadowLight, { entry: resolved });
35
+ }
36
+ /**
37
+ * Refresh a directional light's shadow projection after its frustum bounds change.
38
+ * R3F pierced `shadow-camera-*` props write the bounds but never call
39
+ * `updateProjectionMatrix()`, so depth renders with the stale default ~10×10 box
40
+ * and `shadowCameraSize` is silently inert.
41
+ */
42
+ export function syncShadowProjection(light) {
43
+ light.shadow.camera.updateProjectionMatrix();
44
+ }
45
+ function SingleShadowLight({ entry }) {
46
+ const lightRef = useRef(null);
11
47
  const size = entry.shadowCameraSize ?? 40;
12
- return (_jsx("directionalLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": entry.shadowMapSize ?? 1024, "shadow-mapSize-height": entry.shadowMapSize ?? 1024, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": Math.max(200, size * 6), "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02 }));
48
+ const far = Math.max(200, size * 6);
49
+ const mapSize = entry.shadowMapSize ?? 1024;
50
+ useLayoutEffect(() => {
51
+ if (lightRef.current !== null)
52
+ syncShadowProjection(lightRef.current);
53
+ }, [size, far]);
54
+ useLayoutEffect(() => {
55
+ const light = lightRef.current;
56
+ if (light === null || light.shadow.map === null)
57
+ return;
58
+ light.shadow.map.dispose();
59
+ light.shadow.map = null;
60
+ }, [mapSize]);
61
+ return (_jsx("directionalLight", { ref: lightRef, position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": mapSize, "shadow-mapSize-height": mapSize, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": far, "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02 }));
13
62
  }
14
- export function ConfiguredLighting({ lighting }) {
63
+ export function ConfiguredLighting({ lighting, profile }) {
64
+ const dynamic = resolveDynamicLights(lighting);
15
65
  return (_jsxs(_Fragment, { children: [lighting.ambient !== undefined ? (_jsx("ambientLight", { color: lighting.ambient.color, intensity: lighting.ambient.intensity ?? 0.55 })) : null, lighting.hemisphere !== undefined ? (_jsx("hemisphereLight", { args: [
16
66
  lighting.hemisphere.skyColor ?? "#bfe3ff",
17
67
  lighting.hemisphere.groundColor ?? "#4c6b34",
18
68
  lighting.hemisphere.intensity ?? 0.55,
19
- ] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry }, index)))] }));
69
+ ] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry, profile: profile }, index))), dynamic.point.map((entry, index) => (_jsx("pointLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, castShadow: entry.castShadow ?? false }, index))), dynamic.spot.map((entry, index) => (_jsx("spotLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, angle: entry.angle ?? Math.PI / 6, penumbra: entry.penumbra ?? 0, castShadow: entry.castShadow ?? false, "target-position": [...(entry.target ?? [0, 0, 0])] }, index)))] }));
20
70
  }
21
71
  export function BackdropFog({ fog }) {
22
72
  if (fog === undefined)
@@ -3,23 +3,51 @@ import { useFrame, useLoader } from "@react-three/fiber";
3
3
  import { useTexture } from "@react-three/drei";
4
4
  import { Component, Suspense, useEffect, useMemo, useRef } from "react";
5
5
  import * as THREE from "three";
6
+ import { createSpriteClipPlayer } from "@jgengine/core/render/sprite2d";
6
7
  import { reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
7
8
  import { useOptionalGameContext } from "@jgengine/react/provider";
8
9
  import { sharedGltfLoader } from "./modelLoad.js";
9
10
  import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
10
11
  import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
11
12
  import { useModelAnimation } from "./useModelAnimation.js";
13
+ import { useFootIk } from "./useFootIk.js";
12
14
  import { PartMotionRig } from "./PartMotion.js";
13
15
  import { applyMaterialOverride } from "../materialOverride.js";
14
16
  import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeClonedMaterials, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
15
17
  export function EntitySprite({ sprite }) {
16
- const texture = useLoader(THREE.TextureLoader, sprite.url);
18
+ const texture = useLoader(THREE.TextureLoader, sprite.clip?.atlas.image ?? sprite.url);
19
+ const map = useMemo(() => texture.clone(), [texture]);
20
+ const player = useMemo(() => (sprite.clip === undefined ? null : createSpriteClipPlayer(sprite.clip.atlas)), [sprite.clip]);
21
+ const animation = sprite.clip?.animation;
17
22
  useEffect(() => {
18
- texture.colorSpace = THREE.SRGBColorSpace;
19
- texture.anisotropy = 4;
20
- texture.needsUpdate = true;
21
- }, [texture]);
22
- return (_jsx("sprite", { "position-y": sprite.y, scale: [sprite.width, sprite.height, 1], children: _jsx("spriteMaterial", { map: texture, transparent: true, alphaTest: 0.08, depthWrite: false }) }));
23
+ map.colorSpace = THREE.SRGBColorSpace;
24
+ map.anisotropy = 4;
25
+ map.needsUpdate = true;
26
+ return () => map.dispose();
27
+ }, [map]);
28
+ useEffect(() => {
29
+ if (player !== null && animation !== undefined)
30
+ player.play(animation);
31
+ }, [player, animation]);
32
+ useFrame((_state, delta) => {
33
+ if (player === null || sprite.clip === undefined)
34
+ return;
35
+ player.advance(delta);
36
+ const frame = player.frame();
37
+ if (frame === undefined)
38
+ return;
39
+ const [atlasWidth, atlasHeight] = sprite.clip.atlas.size;
40
+ map.repeat.set(frame.w / atlasWidth, frame.h / atlasHeight);
41
+ map.offset.set(frame.x / atlasWidth, 1 - (frame.y + frame.h) / atlasHeight);
42
+ map.needsUpdate = true;
43
+ });
44
+ useEffect(() => {
45
+ if (player === null) {
46
+ map.repeat.set(1, 1);
47
+ map.offset.set(0, 0);
48
+ }
49
+ }, [map, player]);
50
+ return (_jsx("sprite", { "position-y": sprite.y, scale: [sprite.width, sprite.height, 1], children: _jsx("spriteMaterial", { map: map, transparent: true, alphaTest: 0.08, depthWrite: false }) }));
23
51
  }
24
52
  /**
25
53
  * Catches a render throw from a model subtree and swaps in `fallback` — but never silently: the
@@ -102,8 +130,14 @@ function ModelMaterialMapsApplier({ scene, maps }) {
102
130
  record.roughness = maps.roughness;
103
131
  if (maps.ao !== undefined)
104
132
  record.ao = maps.ao;
133
+ if (maps.metalness !== undefined)
134
+ record.metalness = maps.metalness;
135
+ if (maps.emissive !== undefined)
136
+ record.emissive = maps.emissive;
137
+ if (maps.height !== undefined)
138
+ record.height = maps.height;
105
139
  return record;
106
- }, [maps.color, maps.normal, maps.roughness, maps.ao]);
140
+ }, [maps.color, maps.normal, maps.roughness, maps.ao, maps.metalness, maps.emissive, maps.height]);
107
141
  const textures = useTexture(entries);
108
142
  useEffect(() => {
109
143
  if (textures.color !== undefined)
@@ -121,12 +155,13 @@ export function EntityModel({ model, instanceId, measure, }) {
121
155
  const material = model.material;
122
156
  const baseY = model.y ?? 0;
123
157
  const dims = model.dims;
158
+ const shadows = model.shadows;
124
159
  const scene = useMemo(() => {
125
- const cloned = cloneModelScene(gltf.scene);
160
+ const cloned = cloneModelScene(gltf.scene, { shadows });
126
161
  if (material !== undefined)
127
162
  applyMaterialOverride(cloned, material, { clone: false });
128
163
  return cloned;
129
- }, [gltf, material]);
164
+ }, [gltf, material, shadows]);
130
165
  const measured = useMemo(() => {
131
166
  if (model.targetHeight === undefined)
132
167
  return null;
@@ -183,6 +218,7 @@ export function EntityModel({ model, instanceId, measure, }) {
183
218
  reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
184
219
  }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
185
220
  useModelAnimation(scene, gltf.animations, model.animation, instanceId);
221
+ useFootIk(scene, model.ik, ctx, instanceId);
186
222
  const paintCanvasRef = useRef(null);
187
223
  const paintDrawnCountRef = useRef(0);
188
224
  const paintVersionRef = useRef(-1);
@@ -1,3 +1,4 @@
1
+ import * as THREE from "three";
1
2
  import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
2
3
  import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
3
4
  /**
@@ -72,6 +73,15 @@ export declare function clearModelLoadFallbacks(): void;
72
73
  export declare class DiagnosticGLTFLoader extends GLTFLoader {
73
74
  load(url: string, onLoad: (gltf: GLTF) => void, onProgress?: (event: ProgressEvent) => void, onError?: (event: unknown) => void): void;
74
75
  }
76
+ /** CDN-backed decoder configuration for compressed model and texture assets. */
77
+ export interface ModelLoaderConfig {
78
+ dracoDecoderPath?: string;
79
+ ktx2TranscoderPath?: string;
80
+ }
81
+ /** Configures shared Draco and KTX2 loaders. Repeated calls with the same paths are no-ops. */
82
+ export declare function configureModelLoaders(options?: ModelLoaderConfig): Readonly<Required<ModelLoaderConfig>>;
83
+ /** Detects GPU support for the configured KTX2 transcoder after a renderer exists. */
84
+ export declare function detectKtx2Support(renderer: THREE.WebGLRenderer): void;
75
85
  /**
76
86
  * How long the shared GLB loader has been idle, in ms — `0` while any model is still
77
87
  * in flight. A capture host reads this to wait for streaming to finish instead of
@@ -1,5 +1,7 @@
1
1
  import * as THREE from "three";
2
2
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
3
+ import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
4
+ import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
3
5
  import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
4
6
  import { classifyAssetResponse, } from "@jgengine/core/scene/assetDiagnostics";
5
7
  import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
@@ -157,6 +159,31 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
157
159
  });
158
160
  }
159
161
  }
162
+ const DEFAULT_DRACO_DECODER_PATH = "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/";
163
+ const DEFAULT_KTX2_TRANSCODER_PATH = "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/basis/";
164
+ let configuredModelLoaders;
165
+ let ktx2Loader;
166
+ /** Configures shared Draco and KTX2 loaders. Repeated calls with the same paths are no-ops. */
167
+ export function configureModelLoaders(options = {}) {
168
+ const next = {
169
+ dracoDecoderPath: options.dracoDecoderPath ?? DEFAULT_DRACO_DECODER_PATH,
170
+ ktx2TranscoderPath: options.ktx2TranscoderPath ?? DEFAULT_KTX2_TRANSCODER_PATH,
171
+ };
172
+ if (configuredModelLoaders?.dracoDecoderPath === next.dracoDecoderPath && configuredModelLoaders.ktx2TranscoderPath === next.ktx2TranscoderPath) {
173
+ return configuredModelLoaders;
174
+ }
175
+ const draco = new DRACOLoader().setDecoderPath(next.dracoDecoderPath);
176
+ ktx2Loader = new KTX2Loader().setTranscoderPath(next.ktx2TranscoderPath);
177
+ sharedGltfLoader.setDRACOLoader(draco);
178
+ sharedGltfLoader.setKTX2Loader(ktx2Loader);
179
+ configuredModelLoaders = next;
180
+ return configuredModelLoaders;
181
+ }
182
+ /** Detects GPU support for the configured KTX2 transcoder after a renderer exists. */
183
+ export function detectKtx2Support(renderer) {
184
+ configureModelLoaders();
185
+ ktx2Loader?.detectSupport(renderer);
186
+ }
160
187
  // LoadingManager reports queue transitions, not per-item events: `onStart` fires when the
161
188
  // queue goes busy and `onLoad` when it drains (errors included, via itemEnd), so a flag plus
162
189
  // a drain timestamp is the whole state.
@@ -187,3 +214,4 @@ export function modelLoadIdleMs() {
187
214
  /** Shared GLTF loader used by shell entity/object model mounts. @internal */
188
215
  export const sharedGltfLoader = new DiagnosticGLTFLoader(modelLoadingManager);
189
216
  sharedGltfLoader.setMeshoptDecoder(MeshoptDecoder);
217
+ configureModelLoaders();
@@ -1,9 +1,12 @@
1
1
  import * as THREE from "three";
2
2
  import type { PaintStroke } from "@jgengine/core/scene/paintLayer";
3
3
  export declare const PAINT_TEXTURE_SIZE = 512;
4
+ /** Shadow participation applied to every mesh of a cloned model; mirrors `ModelConfig.shadows`. */
5
+ export type ModelShadowMode = "cast" | "receive" | "both" | "none";
4
6
  /** @internal */
5
7
  export declare function cloneModelScene(source: THREE.Object3D, options?: {
6
8
  cloneMaterials?: boolean;
9
+ shadows?: ModelShadowMode;
7
10
  }): THREE.Object3D;
8
11
  /** @internal */
9
12
  export declare function disposeClonedMaterials(root: THREE.Object3D): void;
@@ -4,12 +4,16 @@ export const PAINT_TEXTURE_SIZE = 512;
4
4
  /** @internal */
5
5
  export function cloneModelScene(source, options) {
6
6
  const clone = cloneSkinned(source);
7
- if (options?.cloneMaterials === false)
8
- return clone;
7
+ const shadows = options?.shadows ?? "both";
8
+ const cloneMaterials = options?.cloneMaterials !== false;
9
9
  clone.traverse((node) => {
10
10
  const mesh = node;
11
11
  if (!mesh.isMesh)
12
12
  return;
13
+ mesh.castShadow = shadows === "cast" || shadows === "both";
14
+ mesh.receiveShadow = shadows === "receive" || shadows === "both";
15
+ if (!cloneMaterials)
16
+ return;
13
17
  mesh.material = Array.isArray(mesh.material)
14
18
  ? mesh.material.map((material) => material.clone())
15
19
  : mesh.material.clone();
@@ -0,0 +1,15 @@
1
+ import type { SpriteAtlas } from "@jgengine/core/assets/spriteAtlas";
2
+ type DrawingContext = Pick<CanvasRenderingContext2D, "clearRect" | "fillRect" | "beginPath" | "arc" | "fill"> & {
3
+ fillStyle: string | CanvasGradient | CanvasPattern;
4
+ };
5
+ type CanvasFixture = {
6
+ width: number;
7
+ height: number;
8
+ getContext(contextId: "2d"): DrawingContext | null;
9
+ toDataURL(): string;
10
+ };
11
+ /** Draw the deterministic two-frame atlas used by shell sprite-clip captures and tests.
12
+ * @internal
13
+ */
14
+ export declare function drawProceduralSpriteAtlas(canvas: CanvasFixture): SpriteAtlas;
15
+ export {};