@jgengine/shell 0.21.0 → 0.23.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 (102) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/dist/GamePlayerShell.js +18 -3
  3. package/dist/GameUiPreview.js +2 -1
  4. package/dist/Shell3dPresentation.js +13 -8
  5. package/dist/ShellChrome.d.ts +1 -1
  6. package/dist/ShellChrome.js +4 -2
  7. package/dist/ShellHudPresentation.js +2 -1
  8. package/dist/ShellNotices.d.ts +26 -0
  9. package/dist/ShellNotices.js +51 -0
  10. package/dist/audio/AudioComponents.d.ts +3 -1
  11. package/dist/audio/AudioComponents.js +5 -2
  12. package/dist/audio/listenerAnchor.d.ts +15 -0
  13. package/dist/audio/listenerAnchor.js +17 -0
  14. package/dist/audio/playableAudio.d.ts +9 -0
  15. package/dist/audio/playableAudio.js +11 -0
  16. package/dist/commandSink.d.ts +1 -1
  17. package/dist/commandSink.js +8 -3
  18. package/dist/defineGame.js +4 -2
  19. package/dist/devtools/DevtoolsOverlay.js +1 -0
  20. package/dist/devtools/agentBridge.d.ts +21 -0
  21. package/dist/devtools/agentBridge.js +95 -3
  22. package/dist/devtools/devtoolsReports.d.ts +32 -0
  23. package/dist/devtools/devtoolsReports.js +26 -0
  24. package/dist/devtools/simStepper.d.ts +21 -0
  25. package/dist/devtools/simStepper.js +37 -0
  26. package/dist/drivers/FrameDriver.js +26 -14
  27. package/dist/drivers/HudOnlyDriver.js +16 -5
  28. package/dist/environment/EnvironmentScene.js +24 -9
  29. package/dist/environment/RoadJunctions.d.ts +3 -1
  30. package/dist/environment/RoadJunctions.js +9 -2
  31. package/dist/environment/RoadRibbons.d.ts +3 -1
  32. package/dist/environment/RoadRibbons.js +16 -3
  33. package/dist/environment/terrainGroundColor.d.ts +5 -4
  34. package/dist/environment/terrainGroundColor.js +20 -9
  35. package/dist/materialOverride.js +4 -2
  36. package/dist/playSurfaceFocus.d.ts +16 -0
  37. package/dist/playSurfaceFocus.js +42 -0
  38. package/dist/postfx/PostProcessing.d.ts +3 -1
  39. package/dist/postfx/PostProcessing.js +14 -6
  40. package/dist/registry.d.ts +5 -1
  41. package/dist/render/CascadedShadows.d.ts +2 -0
  42. package/dist/render/CascadedShadows.js +4 -0
  43. package/dist/render/EntityPreview.js +1 -1
  44. package/dist/render/EnvironmentLighting.d.ts +26 -7
  45. package/dist/render/EnvironmentLighting.js +40 -27
  46. package/dist/render/SceneLighting.js +2 -2
  47. package/dist/render/modelLoad.d.ts +7 -0
  48. package/dist/render/modelLoad.js +13 -0
  49. package/dist/render/modelRender.d.ts +1 -0
  50. package/dist/render/modelRender.js +3 -0
  51. package/dist/render/modelSurface.d.ts +39 -0
  52. package/dist/render/modelSurface.js +206 -0
  53. package/dist/render/useModelInstance.d.ts +1 -1
  54. package/dist/render/useModelInstance.js +2 -2
  55. package/dist/scatter/scatterModels.js +3 -1
  56. package/dist/scene/AuthoredScene.d.ts +11 -3
  57. package/dist/scene/AuthoredScene.js +17 -9
  58. package/dist/scene/CityRenderer.js +86 -24
  59. package/dist/scene/GrassFieldRenderer.js +20 -2
  60. package/dist/scene/cityJunctionSurface.d.ts +7 -0
  61. package/dist/scene/cityJunctionSurface.js +34 -0
  62. package/dist/settings/settingsController.js +1 -1
  63. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  64. package/dist/terrain/CarvedTerrain.js +2 -2
  65. package/dist/terrain/GrassField.d.ts +2 -1
  66. package/dist/terrain/GrassField.js +3 -2
  67. package/dist/terrain/grassGeometry.d.ts +3 -0
  68. package/dist/terrain/grassGeometry.js +2 -1
  69. package/dist/terrain/index.d.ts +5 -0
  70. package/dist/terrain/index.js +5 -0
  71. package/dist/terrain/surfaceRibbonMaterial.d.ts +62 -0
  72. package/dist/terrain/surfaceRibbonMaterial.js +227 -0
  73. package/dist/terrain/terrainDetailMaterial.d.ts +33 -10
  74. package/dist/terrain/terrainDetailMaterial.js +149 -63
  75. package/dist/terrain/terrainMath.d.ts +2 -0
  76. package/dist/terrain/terrainMath.js +2 -0
  77. package/dist/terrain/terrainPaintColor.d.ts +20 -0
  78. package/dist/terrain/terrainPaintColor.js +92 -0
  79. package/dist/terrain/terrainQuality.d.ts +5 -0
  80. package/dist/terrain/terrainQuality.js +7 -0
  81. package/dist/terrain/terrainSurfaceShader.d.ts +16 -0
  82. package/dist/terrain/terrainSurfaceShader.js +266 -0
  83. package/dist/terrain/terrainSurfaces.d.ts +59 -0
  84. package/dist/terrain/terrainSurfaces.js +199 -0
  85. package/dist/world/StaticObjectModels.d.ts +1 -1
  86. package/dist/world/StaticObjectModels.js +1 -1
  87. package/dist/world/WorldScene.js +1 -1
  88. package/dist/world/staticObjectBatches.d.ts +5 -1
  89. package/dist/world/staticObjectBatches.js +9 -5
  90. package/dist/world/worldBarSamples.d.ts +13 -1
  91. package/dist/world/worldBarSamples.js +40 -8
  92. package/package.json +5 -5
  93. package/skills/game-design/SKILL.md +2 -0
  94. package/skills/jgengine/SKILL.md +1 -0
  95. package/skills/jgengine/api.md +18 -18
  96. package/skills/jgengine-ui/SKILL.md +4 -0
  97. package/skills/jgengine-ui/api.md +242 -34
  98. package/skills/jgengine-ui/capabilities.md +56 -0
  99. package/skills/jgengine-ui/reference-primitives.md +4 -4
  100. package/skills/jgengine-world/api.md +28 -15
  101. package/skills/jgengine-world/capabilities.md +4 -0
  102. package/skills/jgengine-world/reference.md +30 -0
@@ -3,6 +3,8 @@ import { useMemo } from "react";
3
3
  import { BufferAttribute, BufferGeometry, Color, DoubleSide } from "three";
4
4
  import { buildJunctionSurface, buildRoadRibbon, dashSegments, GROUND_DECAL_LAYERS, trimPathAtJunctions, } from "@jgengine/core/world/roads";
5
5
  import { offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
6
+ import { resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonSurfaceFrame, useSurfaceRibbonMaterial, } from "../terrain/surfaceRibbonMaterial.js";
7
+ import { useTerrainQuality } from "../terrain/terrainQuality.js";
6
8
  /**
7
9
  * Decal sub-layers as small lifts above each road's authored base `elevation`, spaced by the
8
10
  * single-owner {@link GROUND_DECAL_LAYERS} table so the edge strip and lane paint never z-fight the
@@ -47,7 +49,9 @@ function mergeRibbons(ribbons) {
47
49
  vertexOffset += ribbon.positions.length;
48
50
  indexOffset += ribbon.indices.length;
49
51
  }
50
- return toGeometry(positions, indices);
52
+ const geometry = toGeometry(positions, indices);
53
+ geometry?.setAttribute(RIBBON_FRAME_ATTRIBUTE, new BufferAttribute(ribbonSurfaceFrame(ribbons.map((r) => r.positions)), 3));
54
+ return geometry;
51
55
  }
52
56
  /** Convert a detected {@link RoadJunction} into the structural {@link RoadJunctionInput} the trimmer wants. @internal */
53
57
  export function roadJunctionInput(junction) {
@@ -150,7 +154,9 @@ export function buildAuthoredRoadNetwork(roads, junctions, sampleHeight) {
150
154
  * by {@link buildAuthoredRoadNetwork}): an asphalt ribbon, a curb/edge strip along each border, an
151
155
  * optional dashed centerline, and optional sidewalks. Because the sub-paths already stop at the
152
156
  * junction boundary, every layer naturally ends there — no ribbon ploughs through the crossing and
153
- * the dashed line no longer needs exclusion circles to skip intersections.
157
+ * the dashed line no longer needs exclusion circles to skip intersections. The carriageway, curbs and
158
+ * sidewalks shade with the terrain's procedural surfaces (`road.surface`, default asphalt with tyre
159
+ * wear); `surface: false` keeps them flat.
154
160
  */
155
161
  export function RoadRibbons({ road, field, paths, }) {
156
162
  const asphalt = useMemo(() => mergeRibbons(paths.map((path) => buildRoadRibbon(path, road.width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation }))), [paths, road.width, road.elevation, field]);
@@ -192,7 +198,14 @@ export function RoadRibbons({ road, field, paths, }) {
192
198
  }
193
199
  return mergeRibbons(ribbons);
194
200
  }, [paths, road, field]);
201
+ const quality = useTerrainQuality();
202
+ const detailed = road.surface !== false;
203
+ const style = detailed ? resolvePathSurface("road", road.width, { surface: road.surface ?? "road" }) : null;
204
+ const asphaltMaterial = useSurfaceRibbonMaterial(style === null ? null : { surface: style.surface, wear: style.wear, color: road.color, halfWidth: road.width / 2, quality });
205
+ const curbMaterial = useSurfaceRibbonMaterial(detailed ? { surface: "concrete", color: curbColor, halfWidth: road.width / 2, roughness: 0.9, quality } : null);
206
+ const sidewalkColor = road.sidewalk === false ? "#a7adb8" : road.sidewalk.color;
207
+ const sidewalkMaterial = useSurfaceRibbonMaterial(detailed && sidewalks !== null ? { surface: "pavement", color: sidewalkColor, halfWidth: road.width / 2, roughness: 0.9, scale: 1.6, quality } : null);
195
208
  if (asphalt === null)
196
209
  return null;
197
- return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), curbs !== null ? (_jsx("mesh", { geometry: curbs, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) })) : null, dashes !== null ? (_jsx("mesh", { geometry: dashes, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, sidewalks !== null ? (_jsx("mesh", { geometry: sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) })) : null] }));
210
+ return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, ...(asphaltMaterial === null ? {} : { material: asphaltMaterial }), children: asphaltMaterial === null ? _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) : null }), curbs !== null ? (_jsx("mesh", { geometry: curbs, receiveShadow: true, ...(curbMaterial === null ? {} : { material: curbMaterial }), children: curbMaterial === null ? _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) : null })) : null, dashes !== null ? (_jsx("mesh", { geometry: dashes, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, sidewalks !== null ? (_jsx("mesh", { geometry: sidewalks, receiveShadow: true, ...(sidewalkMaterial === null ? {} : { material: sidewalkMaterial }), children: sidewalkMaterial === null ? _jsx("meshStandardMaterial", { color: sidewalkColor, roughness: 0.9, metalness: 0, side: DoubleSide }) : null })) : null] }));
198
211
  }
@@ -1,9 +1,10 @@
1
1
  import type { TerrainEnvironmentDescriptor } from "@jgengine/core/world/features";
2
2
  import { type TerrainField } from "@jgengine/core/world/terrain";
3
+ import { type TerraformSnapshot } from "@jgengine/core/world/terraform";
3
4
  /**
4
- * Terrain surface color at a world XZ — the palette low/high lerped by the sampled ground height,
5
- * mirroring how `CarvedTerrain` shades its vertices. The seam that lets everything planted on the
6
- * ground (grass roots, soil-patch borders) blend into the exact color it stands on.
5
+ * Terrain base color at a world XZ — authored paint first, otherwise the regional palette lerped
6
+ * by ground height. Grass roots and soil-patch borders share the painted color with ground vertices.
7
+ * Descriptor surface mottling and fragment detail are presentation overlays, not sampled here.
7
8
  * @internal
8
9
  */
9
- export declare function terrainGroundColorSampler(terrain: Omit<TerrainEnvironmentDescriptor, "kind"> | undefined, field: TerrainField): ((x: number, z: number) => string) | undefined;
10
+ export declare function terrainGroundColorSampler(terrain: Omit<TerrainEnvironmentDescriptor, "kind"> | undefined, field: TerrainField, sculpt?: TerraformSnapshot): ((x: number, z: number) => string) | undefined;
@@ -1,22 +1,33 @@
1
1
  import * as THREE from "three";
2
- import { resolveTerrainPalette } from "@jgengine/core/world/terrain";
2
+ import { createTerrainPaletteSampler, resolveTerrainPalette } from "@jgengine/core/world/terrain";
3
+ import { editableTerrainFromSnapshot } from "@jgengine/core/world/terraform";
4
+ import { createTerrainPaintColorSampler } from "../terrain/terrainPaintColor.js";
3
5
  /**
4
- * Terrain surface color at a world XZ — the palette low/high lerped by the sampled ground height,
5
- * mirroring how `CarvedTerrain` shades its vertices. The seam that lets everything planted on the
6
- * ground (grass roots, soil-patch borders) blend into the exact color it stands on.
6
+ * Terrain base color at a world XZ — authored paint first, otherwise the regional palette lerped
7
+ * by ground height. Grass roots and soil-patch borders share the painted color with ground vertices.
8
+ * Descriptor surface mottling and fragment detail are presentation overlays, not sampled here.
7
9
  * @internal
8
10
  */
9
- export function terrainGroundColorSampler(terrain, field) {
10
- if (terrain === undefined)
11
+ export function terrainGroundColorSampler(terrain, field, sculpt) {
12
+ if (terrain === undefined && sculpt === undefined)
11
13
  return undefined;
12
14
  const palette = resolveTerrainPalette(terrain);
15
+ const paletteAt = terrain === undefined ? undefined : createTerrainPaletteSampler(terrain);
16
+ const paintColorAt = sculpt === undefined ? undefined : createTerrainPaintColorSampler(editableTerrainFromSnapshot(sculpt));
13
17
  const low = new THREE.Color(palette.low);
14
18
  const high = new THREE.Color(palette.high);
15
- const base = terrain.baseHeight ?? 0;
16
- const swing = terrain.height * 1.2;
19
+ const base = terrain?.baseHeight ?? 0;
20
+ const swing = (terrain?.height ?? 0) * 1.2;
17
21
  const scratch = new THREE.Color();
18
22
  return (x, z) => {
23
+ const regional = paletteAt?.(x, z);
24
+ if (regional !== undefined) {
25
+ low.set(regional.low);
26
+ high.set(regional.high);
27
+ }
19
28
  const t = swing <= 0 ? 0.5 : THREE.MathUtils.clamp((field.sampleHeight(x, z) - (base - swing)) / (swing * 2), 0, 1);
20
- return `#${scratch.copy(low).lerp(high, t).getHexString()}`;
29
+ scratch.copy(low).lerp(high, t);
30
+ paintColorAt?.(x, z, scratch);
31
+ return `#${scratch.getHexString()}`;
21
32
  };
22
33
  }
@@ -117,7 +117,8 @@ function applyRimLight(target, rim) {
117
117
  if (strength <= 0)
118
118
  return;
119
119
  const previousCompile = target.onBeforeCompile;
120
- const previousKey = target.customProgramCacheKey();
120
+ const previousKey = target.customProgramCacheKey;
121
+ previousKey.call(target);
121
122
  target.onBeforeCompile = (shader, renderer) => {
122
123
  previousCompile.call(target, shader, renderer);
123
124
  shader.uniforms.uJgRimColor = { value: color };
@@ -133,6 +134,7 @@ outgoingLight += uJgRimColor * jgRim * uJgRimStrength;
133
134
  #include <opaque_fragment>`);
134
135
  };
135
136
  // Two materials differing only in rim tuning must not share a compiled program.
136
- target.customProgramCacheKey = () => `${previousKey}|jgengine-rim-${rim.color ?? "#ffffff"}-${strength}-${power}`;
137
+ // Read the chained key lazily: the default surface's key follows the live graphics tier.
138
+ target.customProgramCacheKey = () => `${previousKey.call(target)}|jgengine-rim-${rim.color ?? "#ffffff"}-${strength}-${power}`;
137
139
  target.needsUpdate = true;
138
140
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Focus recovery for the play surface. Gameplay keys are read on the shell's focusable wrapper, so
3
+ * when a focused menu button unmounts (a start screen's "Play", a closed panel's last control) the
4
+ * browser drops focus to `<body>` and WASD silently does nothing until the player clicks the canvas.
5
+ * The one decision: reclaim only when the clicked element is gone from the document and
6
+ * nothing else took focus — a deliberate click elsewhere on the page keeps its focus.
7
+ * @internal
8
+ */
9
+ export declare function shouldReclaimPlayFocus(lost: Element | null, active: Element | null, body: Element | null): boolean;
10
+ /**
11
+ * Watches clicks (pointer, Enter or Space on a button) and, right after the click's update commits, returns focus to `surface()` if the clicked control unmounted and focus fell to the body.
12
+ * Chromium moves focus to `<body>` on removal without a `focusout`, so the click is the hook.
13
+ * Returns the cleanup.
14
+ * @internal
15
+ */
16
+ export declare function installPlayFocusReclaim(doc: Document, surface: () => HTMLElement | null): () => void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Focus recovery for the play surface. Gameplay keys are read on the shell's focusable wrapper, so
3
+ * when a focused menu button unmounts (a start screen's "Play", a closed panel's last control) the
4
+ * browser drops focus to `<body>` and WASD silently does nothing until the player clicks the canvas.
5
+ * The one decision: reclaim only when the clicked element is gone from the document and
6
+ * nothing else took focus — a deliberate click elsewhere on the page keeps its focus.
7
+ * @internal
8
+ */
9
+ export function shouldReclaimPlayFocus(lost, active, body) {
10
+ if (lost === null || lost.isConnected)
11
+ return false;
12
+ return active === null || active === body;
13
+ }
14
+ /**
15
+ * Watches clicks (pointer, Enter or Space on a button) and, right after the click's update commits, returns focus to `surface()` if the clicked control unmounted and focus fell to the body.
16
+ * Chromium moves focus to `<body>` on removal without a `focusout`, so the click is the hook.
17
+ * Returns the cleanup.
18
+ * @internal
19
+ */
20
+ export function installPlayFocusReclaim(doc, surface) {
21
+ let timer;
22
+ const onClick = (event) => {
23
+ const clicked = event.target instanceof Element ? event.target : null;
24
+ if (clicked === null)
25
+ return;
26
+ clearTimeout(timer);
27
+ // React commits a click's update synchronously at the end of the event; a macrotask is enough and,
28
+ // unlike a frame callback, does not stall when the page renders slowly.
29
+ timer = setTimeout(() => {
30
+ const target = surface();
31
+ if (target === null || !target.isConnected)
32
+ return;
33
+ if (shouldReclaimPlayFocus(clicked, doc.activeElement, doc.body))
34
+ target.focus({ preventScroll: true });
35
+ });
36
+ };
37
+ doc.addEventListener("click", onClick, true);
38
+ return () => {
39
+ clearTimeout(timer);
40
+ doc.removeEventListener("click", onClick, true);
41
+ };
42
+ }
@@ -1,5 +1,5 @@
1
1
  import { EffectComposer, type ShaderPass } from "three-stdlib";
2
- import type { PostProcessingConfig } from "@jgengine/core/render/postProcessing";
2
+ import { type AoConfig, type PostProcessingConfig } from "@jgengine/core/render/postProcessing";
3
3
  import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
4
4
  interface BuiltGraph {
5
5
  composer: EffectComposer;
@@ -9,6 +9,8 @@ interface BuiltGraph {
9
9
  export declare function disposeGraph(built: BuiltGraph): void;
10
10
  /** Resize the composer and its passes at the renderer's pixel ratio. @internal */
11
11
  export declare function syncSize(built: BuiltGraph, width: number, height: number, pixelRatio: number): void;
12
+ /** AO the chain runs: authored config, {@link DEFAULT_AO} when unset, null when off or the tier skips it. @internal */
13
+ export declare function resolveAoStage(ao: PostProcessingConfig["ao"], tierAllowsAo: boolean): AoConfig | null;
12
14
  /**
13
15
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
14
16
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
@@ -6,6 +6,7 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
6
6
  import { GTAOPass } from "three/examples/jsm/postprocessing/GTAOPass.js";
7
7
  import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
8
8
  import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
9
+ import { DEFAULT_AO } from "@jgengine/core/render/postProcessing";
9
10
  import { createGradePass } from "./gradeShader.js";
10
11
  import { StylizePass } from "./stylizePass.js";
11
12
  import { renderShadowlessPrepass } from "./shadowPrepass.js";
@@ -48,6 +49,12 @@ export function syncSize(built, width, height, pixelRatio) {
48
49
  built.composer.setPixelRatio(pixelRatio);
49
50
  built.composer.setSize(width, height);
50
51
  }
52
+ /** AO the chain runs: authored config, {@link DEFAULT_AO} when unset, null when off or the tier skips it. @internal */
53
+ export function resolveAoStage(ao, tierAllowsAo) {
54
+ if (!tierAllowsAo || ao === false)
55
+ return null;
56
+ return ao ?? DEFAULT_AO;
57
+ }
51
58
  /**
52
59
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
53
60
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
@@ -76,15 +83,16 @@ export function PostProcessing({ config, quality = "high", stages }) {
76
83
  const composer = new EffectComposer(gl, target);
77
84
  composer.addPass(new RenderPass(scene, camera));
78
85
  const resolvedStages = stages ?? { ao: quality === "high", bloom: true, dof: quality === "high", smaa: true };
79
- if (resolvedStages.ao && config.ao !== undefined && config.ao !== false) {
86
+ const aoStage = resolveAoStage(config.ao, resolvedStages.ao);
87
+ if (aoStage !== null) {
80
88
  const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
81
- ao.blendIntensity = config.ao.blend ?? 1;
89
+ ao.blendIntensity = aoStage.blend ?? 1;
82
90
  ao.updateGtaoMaterial({
83
- radius: config.ao.radius ?? 1.8,
91
+ radius: aoStage.radius ?? 1.8,
84
92
  distanceExponent: 1,
85
- thickness: config.ao.distanceFalloff ?? 3.6,
86
- scale: config.ao.intensity ?? 2.4,
87
- samples: quality === "high" ? 16 : 8,
93
+ thickness: aoStage.distanceFalloff ?? 3.6,
94
+ scale: aoStage.intensity ?? 2.4,
95
+ samples: quality === "ultra" ? 16 : quality === "high" ? 12 : 8,
88
96
  });
89
97
  composer.addPass(ao);
90
98
  }
@@ -2,10 +2,14 @@ import type { ComponentType, ReactNode } from "react";
2
2
  import type { PlayableGame as EnginePlayableGame, WorldOverlayProps } from "@jgengine/core/game/playableGame";
3
3
  import type { SceneEntity } from "@jgengine/core/scene/entityStore";
4
4
  import type { SceneObject } from "@jgengine/core/scene/objectStore";
5
+ import type { HudTheme } from "@jgengine/react/hudTheme";
5
6
  import type { ViewmodelProps } from "./camera/GameFirstPersonCamera.js";
6
7
  export type RenderEntity = (entity: SceneEntity) => ReactNode;
7
8
  export type RenderObject = (object: SceneObject) => ReactNode;
8
9
  /** The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle. */
9
- export type PlayableGame = EnginePlayableGame<ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps>>;
10
+ export type PlayableGame = EnginePlayableGame<ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps>> & {
11
+ /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHudTheme`; `false` opts out of the default theme and the engine's generic HUD control styling. A theme the game sets inside `GameUI` still wins. */
12
+ hudTheme?: HudTheme | false;
13
+ };
10
14
  export type GameRegistry = Record<string, () => Promise<PlayableGame>>;
11
15
  export declare function resolveGameLoader(registry: GameRegistry, gameId: string, fallbackGameId?: string): (() => Promise<PlayableGame>) | undefined;
@@ -1,6 +1,8 @@
1
1
  import * as THREE from "three";
2
2
  import { CSM } from "three/examples/jsm/csm/CSM.js";
3
3
  import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame";
4
+ /** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
5
+ export declare const DEFAULT_SHADOW_RADIUS = 1.25;
4
6
  /**
5
7
  * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
6
8
  * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
@@ -2,6 +2,8 @@ import { useFrame, useThree } from "@react-three/fiber";
2
2
  import { useEffect, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { CSM } from "three/examples/jsm/csm/CSM.js";
5
+ /** @internal Default PCF penumbra in shadow-map texels for configured directional lights. */
6
+ export const DEFAULT_SHADOW_RADIUS = 1.25;
5
7
  function isStandardMaterial(mat) {
6
8
  return mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial;
7
9
  }
@@ -41,6 +43,7 @@ export function CascadedShadows({ entry }) {
41
43
  for (const light of csm.lights) {
42
44
  light.color = new THREE.Color(entry.color ?? "#ffffff");
43
45
  light.castShadow = true;
46
+ light.shadow.radius = entry.shadowRadius ?? DEFAULT_SHADOW_RADIUS;
44
47
  }
45
48
  const unbind = bindCsmMaterials(scene, csm);
46
49
  csmRef.current = csm;
@@ -62,6 +65,7 @@ export function CascadedShadows({ entry }) {
62
65
  entry.shadowCameraSize,
63
66
  entry.shadowMapSize,
64
67
  entry.shadowMaxFar,
68
+ entry.shadowRadius,
65
69
  ]);
66
70
  useFrame(() => {
67
71
  const csm = csmRef.current;
@@ -19,5 +19,5 @@ export function EntityPreview({ children, model, instanceId, mood = "studio", ba
19
19
  const ctx = useOptionalGameContext();
20
20
  const subject = children ??
21
21
  (model !== undefined ? _jsx(IsolatedEntityModel, { model: model, instanceId: instanceId }) : null);
22
- return (_jsx("div", { "data-jg-entity-preview": "", className: className, style: { position: "relative", width: "100%", height: "100%", ...style }, children: _jsx(Canvas, { shadows: true, dpr: dpr, camera: { position: cameraPosition, fov }, gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: _jsx(GameContextBridge, { context: ctx, children: _jsx(StudioStage, { mood: mood, ...(backdrop === undefined ? {} : { backdrop }), turntable: turntable, faceCamera: faceCamera, ...(forward === undefined ? {} : { forward }), environment: environment, children: subject }) }) }) }));
22
+ return (_jsx("div", { "data-jg-entity-preview": "", className: className, style: { position: "relative", width: "100%", height: "100%", ...style }, children: _jsx(Canvas, { shadows: "percentage", dpr: dpr, camera: { position: cameraPosition, fov }, gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: _jsx(GameContextBridge, { context: ctx, children: _jsx(StudioStage, { mood: mood, ...(backdrop === undefined ? {} : { backdrop }), turntable: turntable, faceCamera: faceCamera, ...(forward === undefined ? {} : { forward }), environment: environment, children: subject }) }) }) }));
23
23
  }
@@ -1,29 +1,48 @@
1
+ import * as THREE from "three";
1
2
  import type { EnvironmentSource } from "@jgengine/core/render/environment";
2
3
  /** @internal Props for shell {@link EnvironmentLighting}. */
3
4
  export interface EnvironmentLightingProps {
4
5
  /** Optional authored environment map; absent or `gradient` uses the procedural probe. */
5
6
  source?: EnvironmentSource;
6
- /** IBL intensity. Default 0.35. */
7
+ /** IBL intensity. Default {@link DEFAULT_IBL_INTENSITY}. */
7
8
  intensity?: number;
8
9
  /** Upper-hemisphere tint for the procedural sky probe. Default cool daylight. */
9
10
  skyColor?: string;
10
11
  /** Ground / lower-hemisphere tint. Default soft earth. */
11
12
  groundColor?: string;
13
+ /** Tint the horizon band leans toward, usually the scene's ambient colour so IBL keeps its warmth. Default white. */
14
+ horizonTint?: string;
12
15
  /**
13
- * Optional sun direction (world space, not normalized). When set, a bright disc
14
- * is baked into the probe so metals pick up a real sun glint that tracks daylight.
16
+ * Sun direction (world space, not normalized) baked into the probe as an HDR disc plus a broad
17
+ * lobe, so metals glint and diffuse IBL has a lit side. Default {@link DEFAULT_PROBE_SUN}.
15
18
  */
16
19
  sunDirection?: readonly [number, number, number];
17
20
  /** Sun disc tint. Default warm white. */
18
21
  sunColor?: string;
19
22
  }
20
23
  /**
21
- * Image-based lighting from a procedural sky/ground probe (not three's stock
22
- * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
23
- * an optional sun glint; re-render when sky/sun props change so a daylight cycle
24
+ * Image-based lighting from a procedural sky/horizon/ground probe with an HDR sun (not three's stock
25
+ * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color, a sun glint and a
26
+ * directional sky fill; re-render when sky/sun props change so a daylight cycle
24
27
  * regenerates the environment map with the authored world.
25
28
  *
26
29
  * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
27
30
  * @internal shell-internal default lighting; games never import it.
28
31
  */
29
- export declare function EnvironmentLighting({ source, intensity, skyColor, groundColor, sunDirection, sunColor, }: EnvironmentLightingProps): null;
32
+ export declare function EnvironmentLighting({ source, intensity, skyColor, groundColor, horizonTint, sunDirection, sunColor, }: EnvironmentLightingProps): null;
33
+ /** @internal Default IBL strength for the procedural probe: enough sky fill and sun lobe to model forms. */
34
+ export declare const DEFAULT_IBL_INTENSITY = 0.45;
35
+ /** @internal Fallback probe sun when no directional light is authored: high and off to one side. */
36
+ export declare const DEFAULT_PROBE_SUN: readonly [number, number, number];
37
+ /**
38
+ * @internal Procedural sky probe: zenith → brighter horizon → ground bounce, plus an HDR sun disc and
39
+ * a wide glow lobe. After PMREM the lobe becomes a directional diffuse term, so the IBL alone gives
40
+ * forms a lit and a shaded side instead of a uniform two-tone wash.
41
+ */
42
+ export declare function buildSkyProbeScene(options: {
43
+ skyColor: string;
44
+ groundColor: string;
45
+ horizonTint?: string;
46
+ sunDirection?: readonly [number, number, number];
47
+ sunColor: string;
48
+ }): THREE.Scene;
@@ -5,15 +5,15 @@ import * as THREE from "three";
5
5
  import { EXRLoader } from "three/examples/jsm/loaders/EXRLoader.js";
6
6
  import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
7
7
  /**
8
- * Image-based lighting from a procedural sky/ground probe (not three's stock
9
- * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
10
- * an optional sun glint; re-render when sky/sun props change so a daylight cycle
8
+ * Image-based lighting from a procedural sky/horizon/ground probe with an HDR sun (not three's stock
9
+ * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color, a sun glint and a
10
+ * directional sky fill; re-render when sky/sun props change so a daylight cycle
11
11
  * regenerates the environment map with the authored world.
12
12
  *
13
13
  * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
14
14
  * @internal shell-internal default lighting; games never import it.
15
15
  */
16
- export function EnvironmentLighting({ source, intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
16
+ export function EnvironmentLighting({ source, intensity = DEFAULT_IBL_INTENSITY, skyColor = "#87b5e0", groundColor = "#3d4a38", horizonTint = "#ffffff", sunDirection, sunColor = "#fff2d6", }) {
17
17
  const gl = useThree((state) => state.gl);
18
18
  const scene = useThree((state) => state.scene);
19
19
  useEffect(() => {
@@ -91,7 +91,7 @@ export function EnvironmentLighting({ source, intensity = 0.35, skyColor = "#87b
91
91
  });
92
92
  }
93
93
  else {
94
- const probe = buildSkyProbeScene({ skyColor: effective?.sky ?? skyColor, groundColor: effective?.ground ?? groundColor, sunDirection, sunColor: effective?.sun ?? sunColor });
94
+ const probe = buildSkyProbeScene({ skyColor: effective?.sky ?? skyColor, groundColor: effective?.ground ?? groundColor, horizonTint, sunDirection, sunColor: effective?.sun ?? sunColor });
95
95
  try {
96
96
  apply(pmrem.fromScene(probe, 0.04));
97
97
  }
@@ -105,19 +105,36 @@ export function EnvironmentLighting({ source, intensity = 0.35, skyColor = "#87b
105
105
  throw error;
106
106
  }
107
107
  return release;
108
- }, [gl, scene, source, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
108
+ }, [gl, scene, source, intensity, skyColor, groundColor, horizonTint, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
109
109
  return null;
110
110
  }
111
- function buildSkyProbeScene(options) {
111
+ /** @internal Default IBL strength for the procedural probe: enough sky fill and sun lobe to model forms. */
112
+ export const DEFAULT_IBL_INTENSITY = 0.45;
113
+ /** @internal Fallback probe sun when no directional light is authored: high and off to one side. */
114
+ export const DEFAULT_PROBE_SUN = [-0.45, 0.8, 0.35];
115
+ /**
116
+ * @internal Procedural sky probe: zenith → brighter horizon → ground bounce, plus an HDR sun disc and
117
+ * a wide glow lobe. After PMREM the lobe becomes a directional diffuse term, so the IBL alone gives
118
+ * forms a lit and a shaded side instead of a uniform two-tone wash.
119
+ */
120
+ export function buildSkyProbeScene(options) {
112
121
  const probe = new THREE.Scene();
113
- // Inward-facing sphere: sky above, ground below, so PMREM samples a sky dome not a studio box.
114
- const geo = new THREE.SphereGeometry(50, 32, 16);
122
+ const sun = new THREE.Vector3(...(options.sunDirection ?? DEFAULT_PROBE_SUN));
123
+ if (sun.lengthSq() < 1e-8)
124
+ sun.set(...DEFAULT_PROBE_SUN);
125
+ sun.normalize();
126
+ const sky = new THREE.Color(options.skyColor);
127
+ const horizon = sky.clone().lerp(new THREE.Color(options.horizonTint ?? "#ffffff"), 0.35);
128
+ const geo = new THREE.SphereGeometry(50, 48, 24);
115
129
  const mat = new THREE.ShaderMaterial({
116
130
  side: THREE.BackSide,
117
131
  depthWrite: false,
118
132
  uniforms: {
119
- sky: { value: new THREE.Color(options.skyColor) },
133
+ zenith: { value: sky.clone().multiplyScalar(0.85) },
134
+ horizon: { value: horizon },
120
135
  ground: { value: new THREE.Color(options.groundColor) },
136
+ sunDir: { value: sun },
137
+ sunColor: { value: new THREE.Color(options.sunColor) },
121
138
  },
122
139
  vertexShader: /* glsl */ `
123
140
  varying vec3 vDir;
@@ -127,30 +144,26 @@ function buildSkyProbeScene(options) {
127
144
  }
128
145
  `,
129
146
  fragmentShader: /* glsl */ `
130
- uniform vec3 sky;
147
+ uniform vec3 zenith;
148
+ uniform vec3 horizon;
131
149
  uniform vec3 ground;
150
+ uniform vec3 sunDir;
151
+ uniform vec3 sunColor;
132
152
  varying vec3 vDir;
133
153
  void main() {
134
- float t = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0);
135
- // Soft horizon blend so the equator is not a hard band.
136
- float w = smoothstep(0.0, 1.0, t);
137
- gl_FragColor = vec4(mix(ground, sky, w), 1.0);
154
+ vec3 d = normalize(vDir);
155
+ float up = clamp(d.y, -1.0, 1.0);
156
+ vec3 col = up >= 0.0
157
+ ? mix(horizon, zenith, pow(up, 0.6))
158
+ : mix(horizon * 0.55 + ground * 0.45, ground * 0.7, smoothstep(0.0, 0.5, -up));
159
+ float facing = max(dot(d, sunDir), 0.0);
160
+ // Disc for specular glints; the broad lobe is what survives PMREM into the diffuse term.
161
+ col += sunColor * (pow(facing, 900.0) * 24.0 + pow(facing, 6.0) * 0.6 + pow(facing, 2.0) * 0.1);
162
+ gl_FragColor = vec4(col, 1.0);
138
163
  }
139
164
  `,
140
165
  });
141
166
  probe.add(new THREE.Mesh(geo, mat));
142
- if (options.sunDirection !== undefined) {
143
- const dir = new THREE.Vector3(options.sunDirection[0], options.sunDirection[1], options.sunDirection[2]);
144
- if (dir.lengthSq() > 1e-8) {
145
- dir.normalize();
146
- const sunGeo = new THREE.SphereGeometry(3.5, 16, 12);
147
- const sunMat = new THREE.MeshBasicMaterial({ color: options.sunColor });
148
- const sun = new THREE.Mesh(sunGeo, sunMat);
149
- // Place the disc outside the sky sphere sample so it stays a compact highlight.
150
- sun.position.copy(dir.multiplyScalar(42));
151
- probe.add(sun);
152
- }
153
- }
154
167
  return probe;
155
168
  }
156
169
  function disposeScene(scene) {
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useLayoutEffect, useRef } from "react";
3
- import { CascadedShadows } from "./CascadedShadows.js";
3
+ import { CascadedShadows, DEFAULT_SHADOW_RADIUS } from "./CascadedShadows.js";
4
4
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
5
5
  /** Default cap for combined dynamic point and spot lights. */
6
6
  export const POINT_LIGHT_BUDGET = 8;
@@ -58,7 +58,7 @@ function SingleShadowLight({ entry }) {
58
58
  light.shadow.map.dispose();
59
59
  light.shadow.map = null;
60
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 }));
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, "shadow-radius": entry.shadowRadius ?? DEFAULT_SHADOW_RADIUS }));
62
62
  }
63
63
  export function ConfiguredLighting({ lighting, profile }) {
64
64
  const dynamic = resolveDynamicLights(lighting);
@@ -122,5 +122,12 @@ export declare function materialKtx2Loader(): KTX2Loader;
122
122
  * @capability model-load-idle how long the shared GLB loader has been idle, for capture settle waits
123
123
  */
124
124
  export declare function modelLoadIdleMs(): number;
125
+ /**
126
+ * How many times the shared GLB loader has gone from idle to busy since boot. A capture host
127
+ * compares two readings around a rendered frame to tell whether that frame started new model
128
+ * streaming, which an idle reading alone cannot show.
129
+ * @capability model-load-starts count of model streaming bursts, for capture quiescence checks
130
+ */
131
+ export declare function modelLoadStarts(): number;
125
132
  /** Shared GLTF loader used by shell entity/object model mounts. @internal */
126
133
  export declare const sharedGltfLoader: DiagnosticGLTFLoader;
@@ -6,6 +6,7 @@ import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.j
6
6
  import { classifyAssetResponse, } from "@jgengine/core/scene/assetDiagnostics";
7
7
  import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
8
8
  import { resolveAssetBaseUrl } from "./assetBase.js";
9
+ import { applyModelSurface } from "./modelSurface.js";
9
10
  /** Model containers already covered by the whole-model fallback probe; not "texture" errors. @internal */
10
11
  const MODEL_URL_RE = /\.(glb|gltf)(\?|#|$)/i;
11
12
  /**
@@ -210,6 +211,7 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
210
211
  load(url, onLoad, onProgress, onError) {
211
212
  super.load(url, (gltf) => {
212
213
  recordModelTriangles(url, countSceneTriangles(gltf.scene));
214
+ applyModelSurface(gltf.scene);
213
215
  onLoad(gltf);
214
216
  }, onProgress, (event) => {
215
217
  if (onError === undefined)
@@ -258,9 +260,11 @@ export function materialKtx2Loader() {
258
260
  // a drain timestamp is the whole state.
259
261
  let modelQueueBusy = false;
260
262
  let modelQueueDrainedAt = 0;
263
+ let modelQueueStarts = 0;
261
264
  const now = () => (typeof performance === "undefined" ? Date.now() : performance.now());
262
265
  modelLoadingManager.onStart = () => {
263
266
  modelQueueBusy = true;
267
+ modelQueueStarts += 1;
264
268
  };
265
269
  modelLoadingManager.onLoad = () => {
266
270
  modelQueueBusy = false;
@@ -280,6 +284,15 @@ export function modelLoadIdleMs() {
280
284
  return Number.POSITIVE_INFINITY;
281
285
  return now() - modelQueueDrainedAt;
282
286
  }
287
+ /**
288
+ * How many times the shared GLB loader has gone from idle to busy since boot. A capture host
289
+ * compares two readings around a rendered frame to tell whether that frame started new model
290
+ * streaming, which an idle reading alone cannot show.
291
+ * @capability model-load-starts count of model streaming bursts, for capture quiescence checks
292
+ */
293
+ export function modelLoadStarts() {
294
+ return modelQueueStarts;
295
+ }
283
296
  /** Shared GLTF loader used by shell entity/object model mounts. @internal */
284
297
  export const sharedGltfLoader = new DiagnosticGLTFLoader(modelLoadingManager);
285
298
  sharedGltfLoader.setMeshoptDecoder(MeshoptDecoder);
@@ -19,6 +19,7 @@ export declare function modelPlacementTransform(root: THREE.Object3D, model: Mod
19
19
  export declare function cloneModelScene(source: THREE.Object3D, options?: {
20
20
  cloneMaterials?: boolean;
21
21
  shadows?: ModelShadowMode;
22
+ surfaceDetail?: boolean;
22
23
  }): THREE.Object3D;
23
24
  /**
24
25
  * Release materials and bone textures owned by `cloneModelScene`; shared assets and attached models remain owned by their callers.
@@ -2,6 +2,7 @@ import * as THREE from "three";
2
2
  import { clone as cloneSkinned } from "three/examples/jsm/utils/SkeletonUtils.js";
3
3
  import { measureLocalBounds } from "./measureBounds.js";
4
4
  import { copyModelBindPose } from "./modelBindPose.js";
5
+ import { optOutModelSurface } from "./modelSurface.js";
5
6
  export const PAINT_TEXTURE_SIZE = 512;
6
7
  const modelResources = new WeakMap();
7
8
  /** Register an instance replacement without transferring borrowed geometry or textures. @internal */
@@ -56,6 +57,8 @@ export function cloneModelScene(source, options) {
56
57
  material = source.clone();
57
58
  material.onBeforeCompile = source.onBeforeCompile;
58
59
  material.customProgramCacheKey = source.customProgramCacheKey;
60
+ if (options?.surfaceDetail === false)
61
+ optOutModelSurface(material);
59
62
  materials.set(source, material);
60
63
  }
61
64
  return material;