@jgengine/shell 0.13.0 → 0.15.0-next.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 (124) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/CombatPresentation.js +2 -1
  3. package/dist/GamePlayerShell.js +25 -5
  4. package/dist/Shell3dPresentation.d.ts +6 -6
  5. package/dist/Shell3dPresentation.js +4 -4
  6. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  7. package/dist/camera/ControllerCameraShake.js +34 -0
  8. package/dist/camera/GameCameraRig.js +10 -5
  9. package/dist/camera/GameOrbitCamera.js +1 -10
  10. package/dist/camera/cameraRigs.js +16 -4
  11. package/dist/camera/index.d.ts +1 -0
  12. package/dist/camera/index.js +1 -0
  13. package/dist/camera/rigMath.d.ts +2 -0
  14. package/dist/camera/rigMath.js +7 -0
  15. package/dist/devtools/CollisionDebugWorld.js +15 -1
  16. package/dist/devtools/DevtoolsOverlay.js +2 -0
  17. package/dist/devtools/collisionDebugMath.d.ts +8 -0
  18. package/dist/devtools/collisionDebugMath.js +14 -5
  19. package/dist/drivers/FrameDriver.d.ts +4 -1
  20. package/dist/drivers/FrameDriver.js +5 -3
  21. package/dist/environment/DayNightSky.d.ts +33 -0
  22. package/dist/environment/DayNightSky.js +51 -0
  23. package/dist/environment/Daylight.d.ts +3 -1
  24. package/dist/environment/Daylight.js +7 -3
  25. package/dist/environment/EnvironmentScene.js +6 -6
  26. package/dist/environment/RoadJunctions.d.ts +8 -4
  27. package/dist/environment/RoadJunctions.js +16 -15
  28. package/dist/environment/RoadRibbons.d.ts +42 -6
  29. package/dist/environment/RoadRibbons.js +150 -27
  30. package/dist/environment/VolumetricClouds.js +3 -3
  31. package/dist/environment/index.d.ts +1 -0
  32. package/dist/environment/index.js +1 -0
  33. package/dist/gameKit.d.ts +7 -4
  34. package/dist/gameKit.js +8 -5
  35. package/dist/inputSink.js +14 -0
  36. package/dist/materialOverride.js +30 -0
  37. package/dist/postfx/PostProcessing.d.ts +3 -2
  38. package/dist/postfx/PostProcessing.js +38 -5
  39. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  40. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  41. package/dist/postfx/postfxOverlay.d.ts +24 -0
  42. package/dist/postfx/postfxOverlay.js +35 -0
  43. package/dist/presentationResolve.d.ts +3 -2
  44. package/dist/presentationResolve.js +11 -6
  45. package/dist/render/CascadedShadows.d.ts +13 -0
  46. package/dist/render/CascadedShadows.js +96 -0
  47. package/dist/render/EntityPreview.d.ts +53 -0
  48. package/dist/render/EntityPreview.js +23 -0
  49. package/dist/render/EnvironmentLighting.d.ts +23 -7
  50. package/dist/render/EnvironmentLighting.js +69 -10
  51. package/dist/render/PartMotion.js +4 -4
  52. package/dist/render/SceneLighting.js +7 -0
  53. package/dist/render/SceneModels.d.ts +37 -1
  54. package/dist/render/SceneModels.js +52 -10
  55. package/dist/render/measureBounds.js +4 -0
  56. package/dist/render/measureCollisionMesh.d.ts +26 -0
  57. package/dist/render/measureCollisionMesh.js +108 -0
  58. package/dist/render/modelLoad.d.ts +41 -5
  59. package/dist/render/modelLoad.js +85 -18
  60. package/dist/render/sceneCapture.d.ts +27 -0
  61. package/dist/render/sceneCapture.js +43 -0
  62. package/dist/render/useDisposable.d.ts +16 -0
  63. package/dist/render/useDisposable.js +24 -0
  64. package/dist/render/useModelAnimation.js +47 -4
  65. package/dist/scene/AuthoredScene.js +14 -24
  66. package/dist/scene/CityRenderer.js +103 -36
  67. package/dist/scene/GrassFieldRenderer.js +4 -1
  68. package/dist/scene/PoleLineRenderer.js +7 -16
  69. package/dist/settings/QuickControls.js +5 -3
  70. package/dist/settings/SettingsMenu.js +6 -3
  71. package/dist/settings/appliedSettings.d.ts +3 -1
  72. package/dist/settings/appliedSettings.js +17 -5
  73. package/dist/settings/settingsController.js +11 -5
  74. package/dist/shellMovement.d.ts +15 -1
  75. package/dist/shellMovement.js +15 -1
  76. package/dist/structures/GeneratedBuilding.js +4 -5
  77. package/dist/structures/TransformGizmo.js +6 -5
  78. package/dist/terrain/CarvedTerrain.js +3 -7
  79. package/dist/terrain/GrassField.js +2 -2
  80. package/dist/terrain/ProceduralGround.js +3 -5
  81. package/dist/terrain/grassMaterial.d.ts +8 -2
  82. package/dist/terrain/grassMaterial.js +24 -5
  83. package/dist/terrain/index.d.ts +1 -1
  84. package/dist/terrain/terrainDetailMaterial.js +11 -2
  85. package/dist/terrain/terrainMath.d.ts +38 -1
  86. package/dist/terrain/terrainMath.js +86 -64
  87. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  88. package/dist/touch/TouchControlsOverlay.js +136 -17
  89. package/dist/vfx/ParticleField.d.ts +32 -0
  90. package/dist/vfx/ParticleField.js +91 -0
  91. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  92. package/dist/vfx/WorldFloatingText.js +32 -0
  93. package/dist/water/Ocean.js +3 -6
  94. package/dist/weather/LightningStrike.js +3 -9
  95. package/dist/weather/RainField.js +3 -7
  96. package/dist/weather/SnowField.js +3 -7
  97. package/dist/world/DataObjects.js +3 -6
  98. package/dist/world/GridWorldScene.js +3 -6
  99. package/dist/world/InstancedBodies.js +3 -6
  100. package/dist/world/InstancedJoints.js +2 -2
  101. package/dist/world/SpriteBatch.js +3 -6
  102. package/dist/world/WorldEntityFrames.d.ts +47 -0
  103. package/dist/world/WorldEntityFrames.js +75 -0
  104. package/dist/world/WorldHud.js +7 -5
  105. package/dist/world/WorldPings.d.ts +26 -0
  106. package/dist/world/WorldPings.js +68 -0
  107. package/dist/world/WorldScene.js +9 -4
  108. package/dist/world/pingPulse.d.ts +21 -0
  109. package/dist/world/pingPulse.js +17 -0
  110. package/package.json +5 -5
  111. package/skills/ce-handoff/SKILL.md +149 -0
  112. package/skills/game-design/SKILL.md +2 -0
  113. package/skills/jgengine/SKILL.md +7 -3
  114. package/skills/jgengine/api.md +17 -7
  115. package/skills/jgengine/capabilities.md +12 -0
  116. package/skills/jgengine/recipes/minimal-game.md +7 -4
  117. package/skills/jgengine-ui/SKILL.md +8 -3
  118. package/skills/jgengine-ui/api.md +732 -23
  119. package/skills/jgengine-ui/capabilities.md +371 -2
  120. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  121. package/skills/jgengine-ui/reference.md +7 -4
  122. package/skills/jgengine-world/api.md +408 -12
  123. package/skills/jgengine-world/capabilities.md +127 -2
  124. package/skills/jgengine-world/reference.md +15 -1
package/dist/inputSink.js CHANGED
@@ -77,6 +77,20 @@ export function inputFramesEqual(a, b) {
77
77
  for (let i = 0; i < a.held.length; i++)
78
78
  if (a.held[i] !== b.held[i])
79
79
  return false;
80
+ const aa = a.analog ?? null;
81
+ const ba = b.analog ?? null;
82
+ if (aa === null || ba === null) {
83
+ if (aa !== ba)
84
+ return false;
85
+ }
86
+ else {
87
+ const keys = Object.keys(aa);
88
+ if (keys.length !== Object.keys(ba).length)
89
+ return false;
90
+ for (const key of keys)
91
+ if (aa[key] !== ba[key])
92
+ return false;
93
+ }
80
94
  const pa = a.pointer;
81
95
  const pb = b.pointer;
82
96
  if (pa === null || pb === null)
@@ -36,5 +36,35 @@ function overrideOne(material, override, clone, textures) {
36
36
  target.aoMap = textures.ao;
37
37
  if (textures !== undefined)
38
38
  target.needsUpdate = true;
39
+ if (override.rim !== undefined)
40
+ applyRimLight(target, override.rim);
39
41
  return target;
40
42
  }
43
+ /**
44
+ * Adds a fresnel rim term to a standard material's outgoing light. Patched through
45
+ * `onBeforeCompile` rather than as a second pass so the rim shadows, fogs, and tonemaps with the
46
+ * body it belongs to instead of floating in front of it.
47
+ */
48
+ function applyRimLight(target, rim) {
49
+ const color = new THREE.Color(rim.color ?? "#ffffff");
50
+ const strength = rim.strength ?? 0.6;
51
+ const power = rim.power ?? 2.5;
52
+ if (strength <= 0)
53
+ return;
54
+ target.onBeforeCompile = (shader) => {
55
+ shader.uniforms.uJgRimColor = { value: color };
56
+ shader.uniforms.uJgRimStrength = { value: strength };
57
+ shader.uniforms.uJgRimPower = { value: power };
58
+ shader.fragmentShader = shader.fragmentShader
59
+ .replace("#include <common>", `#include <common>
60
+ uniform vec3 uJgRimColor;
61
+ uniform float uJgRimStrength;
62
+ uniform float uJgRimPower;`)
63
+ .replace("#include <opaque_fragment>", `float jgRim = pow(1.0 - clamp(dot(normalize(normal), normalize(vViewPosition)), 0.0, 1.0), uJgRimPower);
64
+ outgoingLight += uJgRimColor * jgRim * uJgRimStrength;
65
+ #include <opaque_fragment>`);
66
+ };
67
+ // Two materials differing only in rim tuning must not share a compiled program.
68
+ target.customProgramCacheKey = () => `jgengine-rim-${rim.color ?? "#ffffff"}-${strength}-${power}`;
69
+ target.needsUpdate = true;
70
+ }
@@ -3,14 +3,15 @@ import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
3
3
  /**
4
4
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
5
5
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
6
- * post chain: RenderPass → GTAO → UnrealBloom → OutputPass → Grade. Rendered only
6
+ * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
7
7
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
8
8
  *
9
9
  * `quality` (the player's graphics-quality setting) gates the passes whose cost
10
10
  * scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
11
11
  * the whole scene for depth/normals and runs a multi-sample full-screen pass,
12
12
  * and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
13
- * tone mapping, and grade stay on every tier.
13
+ * SMAA, tone mapping, and grade stay on every tier (SMAA is the cheap edge fix
14
+ * for alpha-tested foliage that MSAA samples alone leave crawling).
14
15
  */
15
16
  export declare function PostProcessing({ config, quality }: {
16
17
  config: PostProcessingConfig;
@@ -5,7 +5,33 @@ import { EffectComposer, RenderPass, UnrealBloomPass } from "three-stdlib";
5
5
  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
+ import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js";
8
9
  import { createGradePass } from "./gradeShader.js";
10
+ import { hidePostfxOverlays, restorePostfxOverlays } from "./postfxOverlay.js";
11
+ /**
12
+ * GTAO renders the scene with an `overrideMaterial` normal/depth prepass, which stamps
13
+ * transparent overlay quads (additive VFX sprites, telegraphs, debug gizmos) into the AO
14
+ * buffers as opaque squares — the AO composite then multiplies them toward black. Hide
15
+ * marked overlay subtrees for the whole pass: the beauty buffer it composites over was
16
+ * already rendered with them in.
17
+ */
18
+ class OverlayAwareGTAOPass extends GTAOPass {
19
+ hiddenOverlays = [];
20
+ render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
21
+ hidePostfxOverlays(this.scene, this.hiddenOverlays);
22
+ super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
23
+ restorePostfxOverlays(this.hiddenOverlays);
24
+ }
25
+ }
26
+ /** DOF's depth prepass has the same `overrideMaterial` overlay-stamping problem as GTAO. */
27
+ class OverlayAwareBokehPass extends BokehPass {
28
+ hiddenOverlays = [];
29
+ render(renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
30
+ hidePostfxOverlays(this.scene, this.hiddenOverlays);
31
+ super.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive);
32
+ restorePostfxOverlays(this.hiddenOverlays);
33
+ }
34
+ }
9
35
  const TONE_MAPPING = {
10
36
  aces: THREE.ACESFilmicToneMapping,
11
37
  agx: THREE.AgXToneMapping,
@@ -34,14 +60,15 @@ function syncSize(built, width, height, pixelRatio) {
34
60
  /**
35
61
  * Mounts an `EffectComposer` inside the shell Canvas and takes over rendering
36
62
  * (priority-1 `useFrame`, which disables R3F auto-render) to run the configured
37
- * post chain: RenderPass → GTAO → UnrealBloom → OutputPass → Grade. Rendered only
63
+ * post chain: RenderPass → GTAO → UnrealBloom → SMAA → OutputPass → Grade. Rendered only
38
64
  * when `PlayableGame.postProcessing` is set, so games without it draw unchanged.
39
65
  *
40
66
  * `quality` (the player's graphics-quality setting) gates the passes whose cost
41
67
  * scales with scene geometry or resolution beyond the dpr cap: GTAO re-renders
42
68
  * the whole scene for depth/normals and runs a multi-sample full-screen pass,
43
69
  * and Bokeh DOF renders scene depth again — both run on "high" only. Bloom,
44
- * tone mapping, and grade stay on every tier.
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).
45
72
  */
46
73
  export function PostProcessing({ config, quality = "high" }) {
47
74
  const gl = useThree((s) => s.gl);
@@ -52,15 +79,17 @@ export function PostProcessing({ config, quality = "high" }) {
52
79
  useEffect(() => {
53
80
  const width = Math.max(1, size.width);
54
81
  const height = Math.max(1, size.height);
82
+ const aa = config.aa === undefined ? "smaa" : config.aa;
83
+ const msaaSamples = aa === false ? 0 : 2;
55
84
  const target = new THREE.WebGLRenderTarget(width, height, {
56
85
  type: THREE.HalfFloatType,
57
- samples: 2,
86
+ samples: msaaSamples,
58
87
  });
59
88
  const composer = new EffectComposer(gl, target);
60
89
  composer.addPass(new RenderPass(scene, camera));
61
90
  const heavyPasses = quality === "high";
62
91
  if (heavyPasses && config.ao !== undefined && config.ao !== false) {
63
- const ao = new GTAOPass(scene, camera, width, height);
92
+ const ao = new OverlayAwareGTAOPass(scene, camera, width, height);
64
93
  ao.blendIntensity = config.ao.blend ?? 1;
65
94
  ao.updateGtaoMaterial({
66
95
  radius: config.ao.radius ?? 1.8,
@@ -78,13 +107,17 @@ export function PostProcessing({ config, quality = "high" }) {
78
107
  let dof = null;
79
108
  if (heavyPasses && config.dof !== undefined && config.dof !== false) {
80
109
  const d = config.dof;
81
- dof = new BokehPass(scene, camera, {
110
+ dof = new OverlayAwareBokehPass(scene, camera, {
82
111
  focus: d.focus ?? 18,
83
112
  aperture: d.aperture ?? 0.00025,
84
113
  maxblur: d.maxBlur ?? 0.01,
85
114
  });
86
115
  composer.addPass(dof);
87
116
  }
117
+ // SMAA must run before OutputPass (linear-sRGB). Default on for post chains.
118
+ if (aa === "smaa") {
119
+ composer.addPass(new SMAAPass());
120
+ }
88
121
  composer.addPass(new OutputPass());
89
122
  let grade = null;
90
123
  if (config.grade !== false) {
@@ -0,0 +1,32 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { ScreenEffectsController } from "@jgengine/core/vfx/screenEffects";
3
+ /** Reskin tokens for {@link ScreenEffectsOverlay}. */
4
+ export interface ScreenEffectsOverlayProps {
5
+ /** The model to render. */
6
+ controller: ScreenEffectsController;
7
+ /**
8
+ * CSS blend mode for the tint layers. Default `"normal"` (straight alpha
9
+ * composite). `"screen"` or `"multiply"` read as more filmic color grades.
10
+ */
11
+ blendMode?: CSSProperties["mixBlendMode"];
12
+ /** Extra `z-index` for the overlay root. Default `40`. */
13
+ zIndex?: number;
14
+ /**
15
+ * Drive `advance()` on an animation frame. Default `true`. Turn off if the game
16
+ * already advances the controller from its own tick.
17
+ */
18
+ animate?: boolean;
19
+ }
20
+ /**
21
+ * A DOM overlay that renders a {@link ScreenEffectsController} as full-screen
22
+ * color-grade layers over the game canvas — damage vignettes, heal flashes, a
23
+ * sustained low-health pulse — through the shell's postfx overlay layer, so a game
24
+ * gets screen feedback without hand-rolling shader or overlay timing. It subscribes
25
+ * to the model, drives `advance()` on an animation frame (unless the game ticks it),
26
+ * and paints one absolutely-positioned, pointer-transparent layer per active effect
27
+ * at its eased opacity. Presentation only: all timing and state live in the core
28
+ * model, and `kind` is never interpreted here.
29
+ *
30
+ * @capability screen-effects-overlay shell DOM overlay that renders a core screen-effects controller as full-screen flash / edge-vignette / low-health-pulse color-grade layers
31
+ */
32
+ export declare function ScreenEffectsOverlay({ controller, blendMode, zIndex, animate, }: ScreenEffectsOverlayProps): ReactNode;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef } from "react";
3
+ /**
4
+ * Build the CSS `background` for one effect at its current eased opacity. A
5
+ * `"full"` effect tints the whole frame; a `"vignette"` effect tints only the
6
+ * edges via a radial gradient that stays transparent in the center. Genre-blind —
7
+ * it reads `color`/`shape`/`intensity` off the model and nothing else.
8
+ */
9
+ function effectBackground(effect) {
10
+ if (effect.shape === "vignette") {
11
+ // Transparent core → the effect color at the corners, scaled by opacity.
12
+ return `radial-gradient(ellipse at center, transparent 40%, ${effect.color} 140%)`;
13
+ }
14
+ return effect.color;
15
+ }
16
+ /** Per-effect fill layer opacity, so `color` can stay a plain CSS color string. */
17
+ function effectOpacity(effect) {
18
+ const a = effect.intensity;
19
+ return a < 0 ? 0 : a > 1 ? 1 : a;
20
+ }
21
+ /**
22
+ * A DOM overlay that renders a {@link ScreenEffectsController} as full-screen
23
+ * color-grade layers over the game canvas — damage vignettes, heal flashes, a
24
+ * sustained low-health pulse — through the shell's postfx overlay layer, so a game
25
+ * gets screen feedback without hand-rolling shader or overlay timing. It subscribes
26
+ * to the model, drives `advance()` on an animation frame (unless the game ticks it),
27
+ * and paints one absolutely-positioned, pointer-transparent layer per active effect
28
+ * at its eased opacity. Presentation only: all timing and state live in the core
29
+ * model, and `kind` is never interpreted here.
30
+ *
31
+ * @capability screen-effects-overlay shell DOM overlay that renders a core screen-effects controller as full-screen flash / edge-vignette / low-health-pulse color-grade layers
32
+ */
33
+ export function ScreenEffectsOverlay({ controller, blendMode = "normal", zIndex = 40, animate = true, }) {
34
+ const [, bump] = useReducer((n) => n + 1, 0);
35
+ const rafRef = useRef(0);
36
+ useEffect(() => controller.subscribe(bump), [controller]);
37
+ useEffect(() => {
38
+ if (!animate)
39
+ return;
40
+ let running = true;
41
+ const loop = () => {
42
+ if (!running)
43
+ return;
44
+ controller.advance();
45
+ rafRef.current = requestAnimationFrame(loop);
46
+ };
47
+ rafRef.current = requestAnimationFrame(loop);
48
+ return () => {
49
+ running = false;
50
+ cancelAnimationFrame(rafRef.current);
51
+ };
52
+ }, [controller, animate]);
53
+ const composite = controller.composite();
54
+ if (composite.length === 0)
55
+ return null;
56
+ const root = {
57
+ position: "absolute",
58
+ inset: 0,
59
+ pointerEvents: "none",
60
+ overflow: "hidden",
61
+ zIndex,
62
+ };
63
+ return (_jsx("div", { style: root, "aria-hidden": "true", "data-jg-screen-effects": "", children: composite.map((effect) => (_jsx("div", { style: {
64
+ position: "absolute",
65
+ inset: 0,
66
+ background: effectBackground(effect),
67
+ opacity: effectOpacity(effect),
68
+ mixBlendMode: blendMode,
69
+ } }, effect.id))) }));
70
+ }
@@ -0,0 +1,24 @@
1
+ import type * as THREE from "three";
2
+ /**
3
+ * Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect —
4
+ * additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses
5
+ * (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the
6
+ * scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps
7
+ * in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own
8
+ * combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
9
+ */
10
+ export declare const POSTFX_OVERLAY_USERDATA: {
11
+ readonly jgPostfxOverlay: true;
12
+ };
13
+ /** True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}. */
14
+ export declare function isPostfxOverlay(object: {
15
+ userData?: Record<string, unknown>;
16
+ }): boolean;
17
+ /**
18
+ * Hide every currently visible marked object under `scene`, recording what was hidden into
19
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
20
+ * undo exactly that set.
21
+ */
22
+ export declare function hidePostfxOverlays(scene: THREE.Object3D, out: THREE.Object3D[]): void;
23
+ /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
24
+ export declare function restorePostfxOverlays(hidden: THREE.Object3D[]): void;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Spread onto a group's `userData` to mark its subtree as a screen/world overlay effect —
3
+ * additive glows, telegraphs, tracers, debug gizmos — that post-processing scene prepasses
4
+ * (GTAO's normal/depth render, DOF's depth render) must skip. Those prepasses render the
5
+ * scene with `overrideMaterial`, which turns transparent overlay quads into opaque stamps
6
+ * in the AO/DOF buffers (visible as black squares over hit sparks). The shell marks its own
7
+ * combat VFX and collision-debug mounts; games mark custom overlay effects the same way.
8
+ */
9
+ export const POSTFX_OVERLAY_USERDATA = {
10
+ jgPostfxOverlay: true,
11
+ };
12
+ /** True when the object opted out of postfx scene prepasses via {@link POSTFX_OVERLAY_USERDATA}. */
13
+ export function isPostfxOverlay(object) {
14
+ return object.userData?.jgPostfxOverlay === true;
15
+ }
16
+ /**
17
+ * Hide every currently visible marked object under `scene`, recording what was hidden into
18
+ * `out` (cleared first, no allocation on the hot path) so {@link restorePostfxOverlays} can
19
+ * undo exactly that set.
20
+ */
21
+ export function hidePostfxOverlays(scene, out) {
22
+ out.length = 0;
23
+ scene.traverse((object) => {
24
+ if (object.visible && isPostfxOverlay(object)) {
25
+ object.visible = false;
26
+ out.push(object);
27
+ }
28
+ });
29
+ }
30
+ /** Restore visibility for the objects hidden by {@link hidePostfxOverlays} and clear the list. */
31
+ export function restorePostfxOverlays(hidden) {
32
+ for (const object of hidden)
33
+ object.visible = true;
34
+ hidden.length = 0;
35
+ }
@@ -31,9 +31,10 @@ export interface ResolvedPresentationEffects {
31
31
  }
32
32
  /**
33
33
  * Resolve `presentationEffects` for the 3D shell.
34
- * - `undefined` / `true` → all channels on (historical default).
34
+ * - `undefined` / `true` → default channels (telegraphs, vfx, floatText, shake on; tracers off).
35
35
  * - `false` → none.
36
- * - object → per-channel; missing keys default to on so games can disable one channel.
36
+ * - object → per-channel; missing keys default on, EXCEPT `tracers`, which is opt-in and only
37
+ * enabled by an explicit `tracers: true`.
37
38
  * @internal
38
39
  */
39
40
  export declare function resolvePresentationEffects(config: boolean | PresentationEffectsConfig | undefined): ResolvedPresentationEffects;
@@ -17,11 +17,15 @@ export function resolveWorldOverlayBars(config) {
17
17
  ...(config.showHealth === undefined ? {} : { showHealth: config.showHealth }),
18
18
  };
19
19
  }
20
- const ALL_ON = {
20
+ // Every channel defaults on EXCEPT tracers. `fireProjectile` is a generic seam (bullets,
21
+ // bolts, grenades, launchers), so a muzzle→impact tracer line is only ever right for a subset
22
+ // of shots — it must never appear in a game that never asked for it. Tracers are therefore
23
+ // opt-in: a game turns them on with `presentationEffects: { tracers: true }`.
24
+ const DEFAULTS = {
21
25
  telegraphs: true,
22
26
  vfx: true,
23
27
  floatText: true,
24
- tracers: true,
28
+ tracers: false,
25
29
  shake: true,
26
30
  };
27
31
  const ALL_OFF = {
@@ -33,21 +37,22 @@ const ALL_OFF = {
33
37
  };
34
38
  /**
35
39
  * Resolve `presentationEffects` for the 3D shell.
36
- * - `undefined` / `true` → all channels on (historical default).
40
+ * - `undefined` / `true` → default channels (telegraphs, vfx, floatText, shake on; tracers off).
37
41
  * - `false` → none.
38
- * - object → per-channel; missing keys default to on so games can disable one channel.
42
+ * - object → per-channel; missing keys default on, EXCEPT `tracers`, which is opt-in and only
43
+ * enabled by an explicit `tracers: true`.
39
44
  * @internal
40
45
  */
41
46
  export function resolvePresentationEffects(config) {
42
47
  if (config === false)
43
48
  return ALL_OFF;
44
49
  if (config === undefined || config === true)
45
- return ALL_ON;
50
+ return { ...DEFAULTS };
46
51
  return {
47
52
  telegraphs: config.telegraphs !== false,
48
53
  vfx: config.vfx !== false,
49
54
  floatText: config.floatText !== false,
50
- tracers: config.tracers !== false,
55
+ tracers: config.tracers === true,
51
56
  shake: config.shake !== false,
52
57
  };
53
58
  }
@@ -0,0 +1,13 @@
1
+ import type { DirectionalLightingConfig } from "@jgengine/core/game/playableGame";
2
+ /**
3
+ * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
4
+ * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
5
+ *
6
+ * Mount when `entry.cascades > 1` and `castShadow`. Replaces the single R3F directional
7
+ * shadow light for that entry.
8
+ *
9
+ * @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
10
+ */
11
+ export declare function CascadedShadows({ entry }: {
12
+ entry: DirectionalLightingConfig;
13
+ }): null;
@@ -0,0 +1,96 @@
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
+ import { useEffect, useRef } from "react";
3
+ import * as THREE from "three";
4
+ import { CSM } from "three/examples/jsm/csm/CSM.js";
5
+ function isStandardMaterial(mat) {
6
+ return mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial;
7
+ }
8
+ /**
9
+ * Cascaded shadow maps for one directional light — outdoor scenes keep shadows past a
10
+ * single 40-unit ortho frustum. Uses three.js `CSM` (shader injection on standard materials).
11
+ *
12
+ * Mount when `entry.cascades > 1` and `castShadow`. Replaces the single R3F directional
13
+ * shadow light for that entry.
14
+ *
15
+ * @internal shell SceneLighting helper — games set `cascades` on directional lighting config.
16
+ */
17
+ export function CascadedShadows({ entry }) {
18
+ const camera = useThree((s) => s.camera);
19
+ const scene = useThree((s) => s.scene);
20
+ const csmRef = useRef(null);
21
+ const patchedRef = useRef(new WeakSet());
22
+ const frameRef = useRef(0);
23
+ useEffect(() => {
24
+ const cascades = Math.max(2, Math.min(4, Math.floor(entry.cascades ?? 3)));
25
+ const lightDirection = new THREE.Vector3(-entry.position[0], -entry.position[1], -entry.position[2]);
26
+ if (lightDirection.lengthSq() < 1e-8)
27
+ lightDirection.set(-1, -1, -1);
28
+ lightDirection.normalize();
29
+ const csm = new CSM({
30
+ camera,
31
+ parent: scene,
32
+ cascades,
33
+ maxFar: entry.shadowMaxFar ?? 200,
34
+ mode: "practical",
35
+ shadowMapSize: entry.shadowMapSize ?? 1024,
36
+ shadowBias: entry.shadowBias ?? -0.0004,
37
+ lightDirection,
38
+ lightIntensity: entry.intensity ?? 1.3,
39
+ lightNear: 0.5,
40
+ lightFar: Math.max(200, (entry.shadowCameraSize ?? 40) * 6),
41
+ lightMargin: entry.shadowCameraSize ?? 40,
42
+ });
43
+ for (const light of csm.lights) {
44
+ light.color = new THREE.Color(entry.color ?? "#ffffff");
45
+ light.castShadow = true;
46
+ }
47
+ patchedRef.current = new WeakSet();
48
+ patchSceneMaterials(scene, csm, patchedRef.current);
49
+ csmRef.current = csm;
50
+ frameRef.current = 0;
51
+ return () => {
52
+ csm.dispose();
53
+ csmRef.current = null;
54
+ };
55
+ }, [
56
+ camera,
57
+ scene,
58
+ entry.cascades,
59
+ entry.color,
60
+ entry.intensity,
61
+ entry.position[0],
62
+ entry.position[1],
63
+ entry.position[2],
64
+ entry.shadowBias,
65
+ entry.shadowCameraSize,
66
+ entry.shadowMapSize,
67
+ entry.shadowMaxFar,
68
+ ]);
69
+ useFrame(() => {
70
+ const csm = csmRef.current;
71
+ if (csm === null)
72
+ return;
73
+ 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
+ }
79
+ csm.update();
80
+ });
81
+ return null;
82
+ }
83
+ function patchSceneMaterials(scene, csm, patched) {
84
+ scene.traverse((obj) => {
85
+ const mesh = obj;
86
+ if (!mesh.isMesh)
87
+ return;
88
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
89
+ for (const mat of materials) {
90
+ if (!isStandardMaterial(mat) || patched.has(mat))
91
+ continue;
92
+ csm.setupMaterial(mat);
93
+ patched.add(mat);
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,53 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
3
+ import { type StudioMood } from "../scene/StudioStage.js";
4
+ /** Props for {@link EntityPreview}. */
5
+ export interface EntityPreviewProps {
6
+ /**
7
+ * The rendered entity — the game's *own* `renderEntity` content (e.g. its procedural body component),
8
+ * which may call `useEntityRenderCues(id)` / `useGameContext()` internally to mirror the live game.
9
+ * Takes precedence over `model`. Omit both to render an empty lit stage.
10
+ */
11
+ children?: ReactNode;
12
+ /**
13
+ * Convenience for engine-native models: render this {@link ModelConfig} via `IsolatedEntityModel`.
14
+ * Pair with `instanceId` to bind it to a live entity so the preview walks / flinches / topples in
15
+ * sync with the world. Ignored when `children` is supplied.
16
+ */
17
+ model?: ModelConfig;
18
+ /** Live entity id driving motion cues for `model` (also lets a custom child resolve the same entity). */
19
+ instanceId?: string;
20
+ /** Lighting mood for the stage. Default `"studio"`. */
21
+ mood?: StudioMood;
22
+ /** Override the backdrop color (hex). Default: the mood's backdrop. */
23
+ backdrop?: string;
24
+ /** Turntable spin (rad/s). Default 0 — a still portrait that mirrors the live entity's own motion. */
25
+ turntable?: number;
26
+ /** Yaw the subject to face the camera each frame instead of spinning. Overrides `turntable`. */
27
+ faceCamera?: boolean;
28
+ /** The subject's declared forward axis for `faceCamera`. Default `[0, 0, 1]`. */
29
+ forward?: readonly [number, number, number];
30
+ /** Draw the backdrop + floor (true) or keep a transparent stage as a pure lighting rig (false). Default true. */
31
+ environment?: boolean;
32
+ /** Camera position framing the subject. Default `[0, 1, 3]`. */
33
+ cameraPosition?: [number, number, number];
34
+ /** Camera field of view. Default 35. */
35
+ fov?: number;
36
+ /** Device pixel ratio clamp. Default `[1, 2]`. */
37
+ dpr?: number | [number, number];
38
+ className?: string;
39
+ style?: CSSProperties;
40
+ }
41
+ /**
42
+ * Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout
43
+ * viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the
44
+ * {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so
45
+ * the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a
46
+ * {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly
47
+ * GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native
48
+ * `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks,
49
+ * flinches, and topples in lockstep with the world instead of a canned spin.
50
+ *
51
+ * @capability entity-preview live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
52
+ */
53
+ export declare function EntityPreview({ children, model, instanceId, mood, backdrop, turntable, faceCamera, forward, environment, cameraPosition, fov, dpr, className, style, }: EntityPreviewProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Canvas } from "@react-three/fiber";
3
+ import { GameContextBridge, useOptionalGameContext } from "@jgengine/react/provider";
4
+ import { StudioStage } from "../scene/StudioStage.js";
5
+ import { IsolatedEntityModel } from "./SceneModels.js";
6
+ /**
7
+ * Drop-in live 3D preview of a single entity — the character-screen portrait, unit inspector, or loadout
8
+ * viewer. It owns all the plumbing a game would otherwise re-derive by hand: a nested `<Canvas>`, the
9
+ * {@link GameContextBridge} that carries the running `GameContext` across the R3F reconciler boundary (so
10
+ * the subject's `useEntityRenderCues` / `useGameContext` hooks resolve and it mirrors the live game), a
11
+ * {@link StudioStage} 3-point lighting rig with optional turntable / face-camera, and a capture-friendly
12
+ * GL surface. The game keeps ownership of its `renderEntity`: pass it as `children`, or hand a native
13
+ * `model` (+ `instanceId`) for an engine-composed body. Bound to a live `instanceId`, the portrait walks,
14
+ * flinches, and topples in lockstep with the world instead of a canned spin.
15
+ *
16
+ * @capability entity-preview live 3D entity portrait — nested Canvas + context bridge + studio stage, driven by the game's own renderEntity
17
+ */
18
+ export function EntityPreview({ children, model, instanceId, mood = "studio", backdrop, turntable = 0, faceCamera = false, forward, environment = true, cameraPosition = [0, 1, 3], fov = 35, dpr = [1, 2], className, style, }) {
19
+ const ctx = useOptionalGameContext();
20
+ const subject = children ??
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 }) }) }) }));
23
+ }
@@ -1,10 +1,26 @@
1
+ /** @internal Props for shell {@link EnvironmentLighting}. */
2
+ export interface EnvironmentLightingProps {
3
+ /** IBL intensity. Default 0.35. */
4
+ intensity?: number;
5
+ /** Upper-hemisphere tint for the procedural sky probe. Default cool daylight. */
6
+ skyColor?: string;
7
+ /** Ground / lower-hemisphere tint. Default soft earth. */
8
+ groundColor?: string;
9
+ /**
10
+ * Optional sun direction (world space, not normalized). When set, a bright disc
11
+ * is baked into the probe so metals pick up a real sun glint that tracks daylight.
12
+ */
13
+ sunDirection?: readonly [number, number, number];
14
+ /** Sun disc tint. Default warm white. */
15
+ sunColor?: string;
16
+ }
1
17
  /**
2
- * Sets a neutral, network-free image-based-lighting environment on the scene so every
3
- * `MeshStandardMaterial` — models and un-modeled primitive fallbacks alike — picks up soft
4
- * reflections and fill instead of rendering dead-flat. A game's own sky/lighting still layers on
5
- * top; this only supplies the reflective ambient the shell never had. Opt out with `look: "flat"`.
18
+ * Image-based lighting from a procedural sky/ground probe (not three's stock
19
+ * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
20
+ * an optional sun glint; re-render when sky/sun props change so a daylight cycle
21
+ * regenerates the environment map with the authored world.
22
+ *
23
+ * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
6
24
  * @internal shell-internal default lighting; games never import it.
7
25
  */
8
- export declare function EnvironmentLighting({ intensity }: {
9
- intensity?: number;
10
- }): null;
26
+ export declare function EnvironmentLighting({ intensity, skyColor, groundColor, sunDirection, sunColor, }: EnvironmentLightingProps): null;