@jgengine/shell 0.14.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 (114) hide show
  1. package/CHANGELOG.md +483 -1
  2. package/dist/GamePlayerShell.js +25 -5
  3. package/dist/Shell3dPresentation.d.ts +6 -6
  4. package/dist/Shell3dPresentation.js +4 -4
  5. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  6. package/dist/camera/ControllerCameraShake.js +34 -0
  7. package/dist/camera/GameCameraRig.js +10 -5
  8. package/dist/camera/GameOrbitCamera.js +1 -10
  9. package/dist/camera/cameraRigs.js +12 -4
  10. package/dist/camera/index.d.ts +1 -0
  11. package/dist/camera/index.js +1 -0
  12. package/dist/camera/rigMath.d.ts +2 -0
  13. package/dist/camera/rigMath.js +7 -0
  14. package/dist/devtools/CollisionDebugWorld.js +2 -2
  15. package/dist/devtools/DevtoolsOverlay.js +2 -0
  16. package/dist/drivers/FrameDriver.d.ts +4 -1
  17. package/dist/drivers/FrameDriver.js +5 -3
  18. package/dist/environment/DayNightSky.d.ts +33 -0
  19. package/dist/environment/DayNightSky.js +51 -0
  20. package/dist/environment/Daylight.d.ts +3 -1
  21. package/dist/environment/Daylight.js +7 -3
  22. package/dist/environment/EnvironmentScene.js +6 -6
  23. package/dist/environment/RoadJunctions.d.ts +8 -4
  24. package/dist/environment/RoadJunctions.js +16 -15
  25. package/dist/environment/RoadRibbons.d.ts +42 -6
  26. package/dist/environment/RoadRibbons.js +150 -27
  27. package/dist/environment/VolumetricClouds.js +3 -3
  28. package/dist/environment/index.d.ts +1 -0
  29. package/dist/environment/index.js +1 -0
  30. package/dist/gameKit.d.ts +7 -4
  31. package/dist/gameKit.js +8 -5
  32. package/dist/inputSink.js +14 -0
  33. package/dist/materialOverride.js +30 -0
  34. package/dist/postfx/PostProcessing.d.ts +3 -2
  35. package/dist/postfx/PostProcessing.js +11 -3
  36. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  37. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  38. package/dist/presentationResolve.d.ts +3 -2
  39. package/dist/presentationResolve.js +11 -6
  40. package/dist/render/CascadedShadows.d.ts +13 -0
  41. package/dist/render/CascadedShadows.js +96 -0
  42. package/dist/render/EnvironmentLighting.d.ts +23 -7
  43. package/dist/render/EnvironmentLighting.js +69 -10
  44. package/dist/render/PartMotion.js +4 -4
  45. package/dist/render/SceneLighting.js +7 -0
  46. package/dist/render/SceneModels.d.ts +37 -1
  47. package/dist/render/SceneModels.js +40 -11
  48. package/dist/render/modelLoad.d.ts +41 -5
  49. package/dist/render/modelLoad.js +85 -18
  50. package/dist/render/sceneCapture.d.ts +27 -0
  51. package/dist/render/sceneCapture.js +43 -0
  52. package/dist/render/useDisposable.d.ts +16 -0
  53. package/dist/render/useDisposable.js +24 -0
  54. package/dist/render/useModelAnimation.js +47 -4
  55. package/dist/scene/AuthoredScene.js +14 -24
  56. package/dist/scene/CityRenderer.js +103 -36
  57. package/dist/scene/GrassFieldRenderer.js +4 -1
  58. package/dist/scene/PoleLineRenderer.js +7 -16
  59. package/dist/settings/QuickControls.js +5 -3
  60. package/dist/settings/SettingsMenu.js +6 -3
  61. package/dist/settings/appliedSettings.d.ts +3 -1
  62. package/dist/settings/appliedSettings.js +17 -5
  63. package/dist/settings/settingsController.js +11 -5
  64. package/dist/shellMovement.d.ts +15 -1
  65. package/dist/shellMovement.js +15 -1
  66. package/dist/structures/GeneratedBuilding.js +4 -5
  67. package/dist/structures/TransformGizmo.js +6 -5
  68. package/dist/terrain/CarvedTerrain.js +3 -7
  69. package/dist/terrain/GrassField.js +2 -2
  70. package/dist/terrain/ProceduralGround.js +3 -5
  71. package/dist/terrain/grassMaterial.d.ts +8 -2
  72. package/dist/terrain/grassMaterial.js +24 -5
  73. package/dist/terrain/index.d.ts +1 -1
  74. package/dist/terrain/terrainDetailMaterial.js +11 -2
  75. package/dist/terrain/terrainMath.d.ts +38 -1
  76. package/dist/terrain/terrainMath.js +86 -64
  77. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  78. package/dist/touch/TouchControlsOverlay.js +136 -17
  79. package/dist/vfx/ParticleField.d.ts +32 -0
  80. package/dist/vfx/ParticleField.js +91 -0
  81. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  82. package/dist/vfx/WorldFloatingText.js +32 -0
  83. package/dist/water/Ocean.js +3 -6
  84. package/dist/weather/LightningStrike.js +3 -9
  85. package/dist/weather/RainField.js +3 -7
  86. package/dist/weather/SnowField.js +3 -7
  87. package/dist/world/DataObjects.js +3 -6
  88. package/dist/world/GridWorldScene.js +3 -6
  89. package/dist/world/InstancedBodies.js +3 -6
  90. package/dist/world/InstancedJoints.js +2 -2
  91. package/dist/world/SpriteBatch.js +3 -6
  92. package/dist/world/WorldEntityFrames.d.ts +47 -0
  93. package/dist/world/WorldEntityFrames.js +75 -0
  94. package/dist/world/WorldHud.js +7 -5
  95. package/dist/world/WorldPings.d.ts +26 -0
  96. package/dist/world/WorldPings.js +68 -0
  97. package/dist/world/WorldScene.js +9 -4
  98. package/dist/world/pingPulse.d.ts +21 -0
  99. package/dist/world/pingPulse.js +17 -0
  100. package/package.json +5 -5
  101. package/skills/ce-handoff/SKILL.md +149 -0
  102. package/skills/game-design/SKILL.md +2 -0
  103. package/skills/jgengine/SKILL.md +3 -1
  104. package/skills/jgengine/api.md +17 -7
  105. package/skills/jgengine/capabilities.md +12 -0
  106. package/skills/jgengine/recipes/minimal-game.md +7 -4
  107. package/skills/jgengine-ui/SKILL.md +5 -3
  108. package/skills/jgengine-ui/api.md +708 -19
  109. package/skills/jgengine-ui/capabilities.md +362 -1
  110. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  111. package/skills/jgengine-ui/reference.md +7 -4
  112. package/skills/jgengine-world/api.md +378 -11
  113. package/skills/jgengine-world/capabilities.md +123 -2
  114. package/skills/jgengine-world/reference.md +15 -1
@@ -36,7 +36,7 @@ function ShadowCastingSun({ position, intensity, color, }) {
36
36
  return (_jsx("directionalLight", { ref: ref, position: [position[0], position[1], position[2]], intensity: intensity, color: color, castShadow: true, "shadow-mapSize-width": 2048, "shadow-mapSize-height": 2048, "shadow-camera-left": -90, "shadow-camera-right": 90, "shadow-camera-top": 90, "shadow-camera-bottom": -90, "shadow-camera-near": 10, "shadow-camera-far": 520, "shadow-bias": -0.0004, "shadow-normalBias": 0.02 }));
37
37
  }
38
38
  /** @internal */
39
- export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, sunDirection, sunColor = "#fff4d6", sunIntensity = 1, hazeStrength = 0.5, sunGlowStrength = 1, materialRef, } = {}) {
39
+ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, sunDirection, sunColor = "#fff4d6", sunIntensity = 1, hazeStrength = 0.5, sunGlowStrength = 1, cloudiness = 0.35, materialRef, } = {}) {
40
40
  const meshRef = useRef(null);
41
41
  const material = useMemo(() => {
42
42
  return new THREE.ShaderMaterial({
@@ -50,6 +50,7 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
50
50
  uSunIntensity: { value: sunDirection === undefined ? 0 : sunIntensity },
51
51
  uHazeStrength: { value: hazeStrength },
52
52
  uSunGlow: { value: sunGlowStrength },
53
+ uCloudiness: { value: cloudiness },
53
54
  },
54
55
  vertexShader: `
55
56
  varying vec3 vWorldPosition;
@@ -67,6 +68,7 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
67
68
  uniform float uSunIntensity;
68
69
  uniform float uHazeStrength;
69
70
  uniform float uSunGlow;
71
+ uniform float uCloudiness;
70
72
  uniform float offset;
71
73
  uniform float exponent;
72
74
  varying vec3 vWorldPosition;
@@ -86,7 +88,7 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
86
88
  vec2 cuv = vd.xz / (max(vd.y, 0.06) + 0.15) * 1.2;
87
89
  float clouds = smoothstep(0.52, 0.82, sFbm(cuv));
88
90
  float band = smoothstep(0.08, 0.35, dir.y) * smoothstep(1.0, 0.35, dir.y);
89
- col = mix(col, mix(topColor, vec3(1.0), 0.6), clouds * band * 0.35);
91
+ col = mix(col, mix(topColor, vec3(1.0), 0.6), clouds * band * uCloudiness);
90
92
  float sd = max(dot(vd, normalize(uSunDirection)), 0.0);
91
93
  float glow = pow(sd, 8.0) * 0.35 + pow(sd, 128.0) * 2.6;
92
94
  col += uSunColor * glow * uSunIntensity * uSunGlow;
@@ -97,7 +99,7 @@ export function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius
97
99
  depthWrite: false,
98
100
  fog: false,
99
101
  });
100
- }, [topColor, horizonColor, offset, exponent, sunColor, sunDirection, sunIntensity, hazeStrength, sunGlowStrength]);
102
+ }, [topColor, horizonColor, offset, exponent, sunColor, sunDirection, sunIntensity, hazeStrength, sunGlowStrength, cloudiness]);
101
103
  useEffect(() => {
102
104
  if (materialRef !== undefined)
103
105
  materialRef.current = material;
@@ -139,6 +141,8 @@ function skyDomeShape(sky) {
139
141
  ...(sky.radius === undefined ? {} : { radius: sky.radius }),
140
142
  ...(sky.hazeStrength === undefined ? {} : { hazeStrength: sky.hazeStrength }),
141
143
  ...(sky.sunGlowStrength === undefined ? {} : { sunGlowStrength: sky.sunGlowStrength }),
144
+ ...(sky.gradientExponent === undefined ? {} : { exponent: sky.gradientExponent }),
145
+ ...(sky.cloudiness === undefined ? {} : { cloudiness: sky.cloudiness }),
142
146
  };
143
147
  }
144
148
  function StaticSkyDaylight({ sky, lights }) {
@@ -6,11 +6,12 @@ import * as THREE from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { resolveBuildingPalette } from "@jgengine/core/world/buildings";
8
8
  import { resolveStructureBuildings } from "@jgengine/core/world/environmentSummary";
9
- import { findRoadJunctions, junctionExclusions } from "@jgengine/core/world/streets";
9
+ import { findRoadJunctions } from "@jgengine/core/world/streets";
10
10
  import { createTerrainPaletteSampler, resolveEnvironmentField, resolveTerrainDetail, resolveTerrainPalette, } from "@jgengine/core/world/terrain";
11
+ import { useDisposable } from "../render/useDisposable.js";
11
12
  import { GroundPad } from "./GroundPad.js";
12
13
  import { RoadJunctions } from "./RoadJunctions.js";
13
- import { RoadRibbons } from "./RoadRibbons.js";
14
+ import { buildAuthoredRoadNetwork, RoadRibbons } from "./RoadRibbons.js";
14
15
  import { terrainGroundColorSampler } from "./terrainGroundColor.js";
15
16
  import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
16
17
  import { GrassField } from "../terrain/GrassField.js";
@@ -36,8 +37,7 @@ function TexturedTerrainGround({ detail, layout, }) {
36
37
  texture.needsUpdate = true;
37
38
  }
38
39
  }, [textures]);
39
- const material = useMemo(() => createTerrainDetailMaterial(detail, textures).material, [detail, textures]);
40
- useEffect(() => () => material.dispose(), [material]);
40
+ const material = useDisposable(() => createTerrainDetailMaterial(detail, textures).material, [detail, textures]);
41
41
  return (_jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, center: layout.center, roughness: 0.94, surfaceMaterial: material }));
42
42
  }
43
43
  function TerrainGround({ terrain, field, center, }) {
@@ -158,6 +158,6 @@ export function EnvironmentScene({ feature }) {
158
158
  const islands = feature.islands ?? [];
159
159
  const roads = feature.roads ?? [];
160
160
  const junctions = useMemo(() => findRoadJunctions(roads), [roads]);
161
- const exclusions = useMemo(() => junctionExclusions(junctions), [junctions]);
162
- return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field, exclusions: exclusions }, `road-${index}`))), _jsx(RoadJunctions, { junctions: junctions, field: field }), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field, groundColor: vegetationGroundColor(feature.terrain, field, grass) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
161
+ const roadNetwork = useMemo(() => buildAuthoredRoadNetwork(roads, junctions, (x, z) => field.sampleHeight(x, z)), [roads, junctions, field]);
162
+ return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field, paths: roadNetwork.roadPaths[index] ?? [] }, `road-${index}`))), _jsx(RoadJunctions, { surfaces: roadNetwork.junctionSurfaces, junctions: junctions, field: field }), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field, groundColor: vegetationGroundColor(feature.terrain, field, grass) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
163
163
  }
@@ -1,12 +1,16 @@
1
1
  import { type RoadJunction } from "@jgengine/core/world/streets";
2
2
  import type { TerrainField } from "@jgengine/core/world/terrain";
3
+ import type { JunctionSurface } from "./RoadRibbons.js";
3
4
  /**
4
- * Renders welded road intersections: one asphalt disc patch per junction (covering the crossing
5
- * ribbons so they read as one intersection instead of stacking/z-fighting), plus white stop lines
6
- * and crosswalk bars at each approach. Fed the junctions the scene already computed from its roads.
5
+ * Renders welded road intersections: one seam-shared asphalt surface per junction (built by
6
+ * {@link buildAuthoredRoadNetwork}, its boundary vertices welded onto the trimmed ribbon ends so
7
+ * there is no overlap and no floating disc), plus white stop lines and crosswalk bars at each
8
+ * approach. Markings ride the {@link GROUND_DECAL_LAYERS} marking layer above the surface, with
9
+ * `polygonOffset`/`renderOrder` so they win the depth test on sloped ground.
7
10
  * @internal
8
11
  */
9
- export declare function RoadJunctions({ junctions, field, }: {
12
+ export declare function RoadJunctions({ surfaces, junctions, field, }: {
13
+ surfaces: readonly JunctionSurface[];
10
14
  junctions: readonly RoadJunction[];
11
15
  field: TerrainField;
12
16
  }): import("react").JSX.Element | null;
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
4
- import { buildJunctionPatch, buildRoadRibbon } from "@jgengine/core/world/roads";
4
+ import { buildRoadRibbon, GROUND_DECAL_LAYERS } from "@jgengine/core/world/roads";
5
5
  import { junctionMarkings } from "@jgengine/core/world/streets";
6
+ /** Lane paint sits the table's road→marking gap above the welded junction surface (also polygonOffset). */
7
+ const MARKING_LIFT = GROUND_DECAL_LAYERS.marking - GROUND_DECAL_LAYERS.road;
6
8
  function toGeometry(positions, indices) {
7
9
  if (positions.length === 0)
8
10
  return null;
@@ -34,18 +36,17 @@ function mergedBars(bars, width, field, elevation) {
34
36
  return toGeometry(positions, indices);
35
37
  }
36
38
  /**
37
- * Renders welded road intersections: one asphalt disc patch per junction (covering the crossing
38
- * ribbons so they read as one intersection instead of stacking/z-fighting), plus white stop lines
39
- * and crosswalk bars at each approach. Fed the junctions the scene already computed from its roads.
39
+ * Renders welded road intersections: one seam-shared asphalt surface per junction (built by
40
+ * {@link buildAuthoredRoadNetwork}, its boundary vertices welded onto the trimmed ribbon ends so
41
+ * there is no overlap and no floating disc), plus white stop lines and crosswalk bars at each
42
+ * approach. Markings ride the {@link GROUND_DECAL_LAYERS} marking layer above the surface, with
43
+ * `polygonOffset`/`renderOrder` so they win the depth test on sloped ground.
40
44
  * @internal
41
45
  */
42
- export function RoadJunctions({ junctions, field, }) {
43
- const patches = useMemo(() => junctions
44
- .map((junction) => {
45
- const patch = buildJunctionPatch(junction.center, junction.radius, (x, z) => field.sampleHeight(x, z), { elevation: junction.elevation + 0.02 });
46
- return { color: junction.color, geometry: toGeometry(patch.positions, patch.indices) };
47
- })
48
- .filter((entry) => entry.geometry !== null), [junctions, field]);
46
+ export function RoadJunctions({ surfaces, junctions, field, }) {
47
+ const patches = useMemo(() => surfaces
48
+ .map((surface) => ({ color: surface.color, geometry: toGeometry(surface.ribbon.positions, surface.ribbon.indices) }))
49
+ .filter((entry) => entry.geometry !== null), [surfaces]);
49
50
  const markings = useMemo(() => {
50
51
  const stopBars = [];
51
52
  const crossBars = [];
@@ -59,11 +60,11 @@ export function RoadJunctions({ junctions, field, }) {
59
60
  crossBars.push(bar);
60
61
  }
61
62
  return {
62
- stops: mergedBars(stopBars, 0.5, field, elevation + 0.05),
63
- crosswalks: mergedBars(crossBars, 0.55, field, elevation + 0.05),
63
+ stops: mergedBars(stopBars, 0.5, field, elevation + MARKING_LIFT),
64
+ crosswalks: mergedBars(crossBars, 0.55, field, elevation + MARKING_LIFT),
64
65
  };
65
66
  }, [junctions, field]);
66
- if (patches.length === 0)
67
+ if (patches.length === 0 && markings.stops === null && markings.crosswalks === null)
67
68
  return null;
68
- return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx("mesh", { geometry: patch.geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: patch.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null] }));
69
+ return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx("mesh", { geometry: patch.geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: patch.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null] }));
69
70
  }
@@ -1,13 +1,49 @@
1
1
  import type { RoadEnvironmentDescriptor } from "@jgengine/core/world/features";
2
- import { type DashExclusion } from "@jgengine/core/world/roads";
2
+ import { type RoadJunctionInput, type RoadPoint, type RoadRibbon } from "@jgengine/core/world/roads";
3
+ import { type RoadJunction } from "@jgengine/core/world/streets";
3
4
  import type { TerrainField } from "@jgengine/core/world/terrain";
5
+ /** Convert a detected {@link RoadJunction} into the structural {@link RoadJunctionInput} the trimmer wants. @internal */
6
+ export declare function roadJunctionInput(junction: RoadJunction): RoadJunctionInput;
4
7
  /**
5
- * Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
6
- * optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
7
- * — the welded junction patches — so the center line does not paint across intersections).
8
+ * Split every path segment at the junction centers that fall on it, inserting the junction point as
9
+ * a real vertex so {@link trimPathAtJunctions} (which matches junctions to path vertices) can cut the
10
+ * ribbon back there. Authored road crossings from `findRoadJunctions` land mid-segment, not on the
11
+ * hand-authored vertices, so without this the trimmer would never see them.
12
+ * @internal
8
13
  */
9
- export declare function RoadRibbons({ road, field, exclusions, }: {
14
+ export declare function insertJunctionNodes(path: readonly RoadPoint[], junctions: readonly RoadJunctionInput[], tolerance?: number): RoadPoint[];
15
+ /** One welded asphalt junction surface plus the color it should render in. */
16
+ export interface JunctionSurface {
17
+ color: string;
18
+ ribbon: RoadRibbon;
19
+ }
20
+ /** Trimmed ribbons for every road plus the welded surfaces that fill the crossings between them. */
21
+ export interface AuthoredRoadNetwork {
22
+ /** Trimmed centerline sub-paths per input road (parallel to the `roads` array; empty for degenerate roads). */
23
+ roadPaths: RoadPoint[][][];
24
+ /** One welded surface per junction that at least one trimmed ribbon actually ends at. */
25
+ junctionSurfaces: JunctionSurface[];
26
+ }
27
+ /**
28
+ * Trim every authored road back to its junction boundaries and weld one surface into each crossing —
29
+ * the drop-in replacement for the old "full ribbons + floating disc" model. Each road's path gets its
30
+ * junction crossings inserted as vertices, is trimmed by {@link trimPathAtJunctions} (a through-road
31
+ * splits in two), and the terminal cross-section corners feed {@link buildJunctionSurface}. Ribbon
32
+ * corners drape at the road's own authored `elevation`, so each welded corner meets the ribbon end at
33
+ * the exact same height (seam-shared, per {@link GROUND_DECAL_LAYERS}); the surface interior drapes at
34
+ * the junction's representative elevation. Pure geometry, so the shell just meshes the result.
35
+ * @internal
36
+ */
37
+ export declare function buildAuthoredRoadNetwork(roads: readonly RoadEnvironmentDescriptor[], junctions: readonly RoadJunction[], sampleHeight: (x: number, z: number) => number): AuthoredRoadNetwork;
38
+ /**
39
+ * Renders one `road()` descriptor from its trimmed centerline sub-paths (cut back at every junction
40
+ * by {@link buildAuthoredRoadNetwork}): an asphalt ribbon, a curb/edge strip along each border, an
41
+ * optional dashed centerline, and optional sidewalks. Because the sub-paths already stop at the
42
+ * junction boundary, every layer naturally ends there — no ribbon ploughs through the crossing and
43
+ * the dashed line no longer needs exclusion circles to skip intersections.
44
+ */
45
+ export declare function RoadRibbons({ road, field, paths, }: {
10
46
  road: RoadEnvironmentDescriptor;
11
47
  field: TerrainField;
12
- exclusions?: readonly DashExclusion[];
48
+ paths: readonly (readonly RoadPoint[])[];
13
49
  }): import("react").JSX.Element | null;
@@ -1,8 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { BufferAttribute, BufferGeometry, Color, DoubleSide } from "three";
4
- import { buildRoadRibbon, dashSegments } from "@jgengine/core/world/roads";
5
- import { curbPaths, offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
4
+ import { buildJunctionSurface, buildRoadRibbon, dashSegments, GROUND_DECAL_LAYERS, trimPathAtJunctions, } from "@jgengine/core/world/roads";
5
+ import { offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
6
+ /**
7
+ * Decal sub-layers as small lifts above each road's authored base `elevation`, spaced by the
8
+ * single-owner {@link GROUND_DECAL_LAYERS} table so the edge strip and lane paint never z-fight the
9
+ * asphalt they sit on. The asphalt ribbon and the welded junction surface stay on the authored base
10
+ * (they are seam-welded — never stacked); markings lift by the table's road→marking gap and ALSO get
11
+ * `polygonOffset`/`renderOrder` on the material. Curbs and sidewalks are their own surfaces beside
12
+ * the road edge (not table decal layers), so they carry documented offsets rather than a table entry.
13
+ */
14
+ const MARKING_LIFT = GROUND_DECAL_LAYERS.marking - GROUND_DECAL_LAYERS.road;
15
+ const CURB_LIFT = 0.02;
16
+ const SIDEWALK_LIFT = 0.06;
6
17
  /** Darken an asphalt color toward black for a curb/edge strip (deterministic, no alloc churn). */
7
18
  function darken(hex, amount) {
8
19
  const c = new Color(hex);
@@ -18,13 +29,8 @@ function toGeometry(positions, indices) {
18
29
  geometry.computeVertexNormals();
19
30
  return geometry;
20
31
  }
21
- function ribbonGeometry(path, width, field, elevation) {
22
- const ribbon = buildRoadRibbon(path, width, (x, z) => field.sampleHeight(x, z), { elevation });
23
- return toGeometry(ribbon.positions, ribbon.indices);
24
- }
25
- function mergedDashGeometry(road, field, exclusions) {
26
- const width = Math.max(0.25, road.width * 0.035);
27
- const ribbons = dashSegments(road.path, 3.2, 4, exclusions).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
32
+ /** Merge many ribbons into one buffer geometry (draws collapse to a single mesh). */
33
+ function mergeRibbons(ribbons) {
28
34
  const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
29
35
  const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
30
36
  if (vertexCount === 0)
@@ -36,40 +42,157 @@ function mergedDashGeometry(road, field, exclusions) {
36
42
  for (const ribbon of ribbons) {
37
43
  positions.set(ribbon.positions, vertexOffset);
38
44
  const base = vertexOffset / 3;
39
- for (let i = 0; i < ribbon.indices.length; i += 1) {
45
+ for (let i = 0; i < ribbon.indices.length; i += 1)
40
46
  indices[indexOffset + i] = ribbon.indices[i] + base;
41
- }
42
47
  vertexOffset += ribbon.positions.length;
43
48
  indexOffset += ribbon.indices.length;
44
49
  }
45
50
  return toGeometry(positions, indices);
46
51
  }
52
+ /** Convert a detected {@link RoadJunction} into the structural {@link RoadJunctionInput} the trimmer wants. @internal */
53
+ export function roadJunctionInput(junction) {
54
+ return {
55
+ x: junction.center[0],
56
+ z: junction.center[1],
57
+ // Arm direction `[dx, dz]` → angle `atan2(dx, dz)`, matching the generator convention the core uses.
58
+ arms: junction.approaches.map((approach) => ({
59
+ angle: Math.atan2(approach.direction[0], approach.direction[1]),
60
+ width: approach.width,
61
+ })),
62
+ };
63
+ }
64
+ /**
65
+ * Split every path segment at the junction centers that fall on it, inserting the junction point as
66
+ * a real vertex so {@link trimPathAtJunctions} (which matches junctions to path vertices) can cut the
67
+ * ribbon back there. Authored road crossings from `findRoadJunctions` land mid-segment, not on the
68
+ * hand-authored vertices, so without this the trimmer would never see them.
69
+ * @internal
70
+ */
71
+ export function insertJunctionNodes(path, junctions, tolerance = 1e-3) {
72
+ if (path.length < 2)
73
+ return path.map((p) => [p[0], p[1]]);
74
+ const out = [[path[0][0], path[0][1]]];
75
+ for (let i = 0; i < path.length - 1; i += 1) {
76
+ const a = path[i];
77
+ const b = path[i + 1];
78
+ const abx = b[0] - a[0];
79
+ const abz = b[1] - a[1];
80
+ const lengthSq = abx * abx + abz * abz;
81
+ if (lengthSq > 1e-12) {
82
+ const hits = [];
83
+ for (const j of junctions) {
84
+ const t = ((j.x - a[0]) * abx + (j.z - a[1]) * abz) / lengthSq;
85
+ if (t <= 1e-6 || t >= 1 - 1e-6)
86
+ continue; // coincident with an existing vertex — already covered
87
+ const px = a[0] + abx * t;
88
+ const pz = a[1] + abz * t;
89
+ if (Math.hypot(j.x - px, j.z - pz) <= tolerance)
90
+ hits.push({ t, x: j.x, z: j.z });
91
+ }
92
+ hits.sort((u, v) => u.t - v.t);
93
+ for (const hit of hits) {
94
+ const prev = out[out.length - 1];
95
+ if (Math.hypot(prev[0] - hit.x, prev[1] - hit.z) > 1e-6)
96
+ out.push([hit.x, hit.z]);
97
+ }
98
+ }
99
+ out.push([b[0], b[1]]);
100
+ }
101
+ return out;
102
+ }
47
103
  /**
48
- * Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
49
- * optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
50
- * — the welded junction patches — so the center line does not paint across intersections).
104
+ * Trim every authored road back to its junction boundaries and weld one surface into each crossing —
105
+ * the drop-in replacement for the old "full ribbons + floating disc" model. Each road's path gets its
106
+ * junction crossings inserted as vertices, is trimmed by {@link trimPathAtJunctions} (a through-road
107
+ * splits in two), and the terminal cross-section corners feed {@link buildJunctionSurface}. Ribbon
108
+ * corners drape at the road's own authored `elevation`, so each welded corner meets the ribbon end at
109
+ * the exact same height (seam-shared, per {@link GROUND_DECAL_LAYERS}); the surface interior drapes at
110
+ * the junction's representative elevation. Pure geometry, so the shell just meshes the result.
111
+ * @internal
51
112
  */
52
- export function RoadRibbons({ road, field, exclusions = [], }) {
53
- const asphalt = useMemo(() => ribbonGeometry(road.path, road.width, field, road.elevation), [road, field]);
54
- const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field, exclusions) : null), [road, field, exclusions]);
113
+ export function buildAuthoredRoadNetwork(roads, junctions, sampleHeight) {
114
+ const inputs = junctions.map(roadJunctionInput);
115
+ const roadPaths = [];
116
+ const approachesByJunction = new Map();
117
+ for (const road of roads) {
118
+ if (road.path.length < 2 || road.width <= 0) {
119
+ roadPaths.push([]);
120
+ continue;
121
+ }
122
+ const noded = insertJunctionNodes(road.path, inputs);
123
+ const subs = trimPathAtJunctions(noded, road.width, inputs);
124
+ roadPaths.push(subs.map((sub) => sub.path));
125
+ for (const sub of subs) {
126
+ for (const cut of sub.cuts) {
127
+ const approach = {
128
+ center: cut.center,
129
+ left: [cut.left[0], sampleHeight(cut.left[0], cut.left[1]) + road.elevation, cut.left[1]],
130
+ right: [cut.right[0], sampleHeight(cut.right[0], cut.right[1]) + road.elevation, cut.right[1]],
131
+ };
132
+ const list = approachesByJunction.get(cut.junctionIndex);
133
+ if (list)
134
+ list.push(approach);
135
+ else
136
+ approachesByJunction.set(cut.junctionIndex, [approach]);
137
+ }
138
+ }
139
+ }
140
+ const junctionSurfaces = [];
141
+ for (const ji of [...approachesByJunction.keys()].sort((p, q) => p - q)) {
142
+ const junction = junctions[ji];
143
+ const ribbon = buildJunctionSurface({ x: junction.center[0], z: junction.center[1] }, approachesByJunction.get(ji), sampleHeight, { elevation: junction.elevation });
144
+ junctionSurfaces.push({ color: junction.color, ribbon });
145
+ }
146
+ return { roadPaths, junctionSurfaces };
147
+ }
148
+ /**
149
+ * Renders one `road()` descriptor from its trimmed centerline sub-paths (cut back at every junction
150
+ * by {@link buildAuthoredRoadNetwork}): an asphalt ribbon, a curb/edge strip along each border, an
151
+ * optional dashed centerline, and optional sidewalks. Because the sub-paths already stop at the
152
+ * 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.
154
+ */
155
+ export function RoadRibbons({ road, field, paths, }) {
156
+ 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]);
157
+ const dashes = useMemo(() => {
158
+ if (!road.markings)
159
+ return null;
160
+ const width = Math.max(0.25, road.width * 0.035);
161
+ const ribbons = [];
162
+ for (const path of paths) {
163
+ for (const dash of dashSegments(path, 3.2, 4)) {
164
+ ribbons.push(buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + MARKING_LIFT }));
165
+ }
166
+ }
167
+ return mergeRibbons(ribbons);
168
+ }, [paths, road.markings, road.width, road.elevation, field]);
55
169
  const curbColor = useMemo(() => darken(road.color, 0.45), [road.color]);
56
170
  const curbs = useMemo(() => {
57
171
  // A thin strip straddling each asphalt edge, lifted just above the ribbon.
58
172
  const strip = Math.max(0.4, road.width * 0.09);
59
- return curbPaths(road, strip / 2)
60
- .map((path) => ribbonGeometry(path, strip, field, road.elevation + 0.02))
61
- .filter((geometry) => geometry !== null);
62
- }, [road, field]);
173
+ const edge = road.width / 2 - strip / 2;
174
+ const ribbons = [];
175
+ for (const path of paths) {
176
+ for (const off of [edge, -edge]) {
177
+ ribbons.push(buildRoadRibbon(offsetPath(path, off), strip, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + CURB_LIFT }));
178
+ }
179
+ }
180
+ return mergeRibbons(ribbons);
181
+ }, [paths, road.width, road.elevation, field]);
63
182
  const sidewalks = useMemo(() => {
64
183
  const width = sidewalkWidthOf(road);
65
184
  if (width <= 0)
66
- return [];
185
+ return null;
67
186
  const offset = road.width / 2 + width / 2;
68
- return [offsetPath(road.path, offset), offsetPath(road.path, -offset)]
69
- .map((path) => ribbonGeometry(path, width, field, road.elevation + 0.06))
70
- .filter((geometry) => geometry !== null);
71
- }, [road, field]);
187
+ const ribbons = [];
188
+ for (const path of paths) {
189
+ for (const off of [offset, -offset]) {
190
+ ribbons.push(buildRoadRibbon(offsetPath(path, off), width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + SIDEWALK_LIFT }));
191
+ }
192
+ }
193
+ return mergeRibbons(ribbons);
194
+ }, [paths, road, field]);
72
195
  if (asphalt === null)
73
196
  return null;
74
- return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), curbs.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `curb-${index}`))), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
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] }));
75
198
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
- import { useEffect, useMemo, useRef } from "react";
3
+ import { useRef } from "react";
4
4
  import * as THREE from "three";
5
+ import { useDisposable } from "../render/useDisposable.js";
5
6
  /**
6
7
  * Camera-centered proxy dome radius. Must sit inside the default camera far plane
7
8
  * (`CAMERA_FRUSTUM_DEFAULTS.far` = 300) — a bigger sphere is clipped whole and renders nothing. The
@@ -161,7 +162,7 @@ void main() {
161
162
  export function VolumetricClouds({ rules, sunDirection }) {
162
163
  const meshRef = useRef(null);
163
164
  const timeRef = useRef(0);
164
- const material = useMemo(() => {
165
+ const material = useDisposable(() => {
165
166
  return new THREE.ShaderMaterial({
166
167
  uniforms: {
167
168
  uCameraPos: { value: new THREE.Vector3() },
@@ -186,7 +187,6 @@ export function VolumetricClouds({ rules, sunDirection }) {
186
187
  fog: false,
187
188
  });
188
189
  }, [rules, sunDirection]);
189
- useEffect(() => () => material.dispose(), [material]);
190
190
  useFrame((state, delta) => {
191
191
  timeRef.current += delta;
192
192
  const mesh = meshRef.current;
@@ -1,4 +1,5 @@
1
1
  export { Daylight, SkyDaylight, SkyDome, TimeOfDayDaylight, type DaylightProps, type SkyDaylightProps, type SkyDomeProps, type TimeOfDayDaylightProps, } from "./Daylight.js";
2
+ export { DayNightSky, type DayNightSkyProps } from "./DayNightSky.js";
2
3
  export { EnvironmentScene, type EnvironmentSceneProps } from "./EnvironmentScene.js";
3
4
  export { VolumetricClouds, type VolumetricCloudsProps } from "./VolumetricClouds.js";
4
5
  export { daylightStateAt, lerpHexColor, SKY_PRESET_DAY_FRACTION, type DaylightCycleConfig, type DaylightState, } from "./daylightCycle.js";
@@ -1,4 +1,5 @@
1
1
  export { Daylight, SkyDaylight, SkyDome, TimeOfDayDaylight, } from "./Daylight.js";
2
+ export { DayNightSky } from "./DayNightSky.js";
2
3
  export { EnvironmentScene } from "./EnvironmentScene.js";
3
4
  export { VolumetricClouds } from "./VolumetricClouds.js";
4
5
  export { daylightStateAt, lerpHexColor, SKY_PRESET_DAY_FRACTION, } from "./daylightCycle.js";
package/dist/gameKit.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The happy-path game kit — one import surface covering a whole simple game: authoring
3
- * (`defineGame`), mounting (`GameHost`), serializable state (`defineStore`), behavior
4
- * (`defineSystem`), the authored-scene document helpers, HUD primitives, and deterministic
5
- * randomness. Start here; reach for deep package paths only when a game outgrows the kit.
2
+ * **Start here for game code.** The sole happy-path import surface for a whole simple game:
3
+ * authoring (`defineGame`), mounting (`GameHost`), serializable state (`defineStore`), behavior
4
+ * (`defineSystem`), authored-scene helpers, HUD building blocks, and deterministic randomness.
5
+ * Reach for deep package paths only when a game outgrows the kit. Do not start from
6
+ * `@jgengine/core/authoring` — that module is a core-side helper barrel, not the game entrypoint.
6
7
  *
7
8
  * @capability game-kit one-stop happy-path import surface for authoring, mounting, state, systems, authored scenes, and HUD
8
9
  */
@@ -20,7 +21,9 @@ export { authoredSpawnPosition } from "@jgengine/core/world/authoredSpawn";
20
21
  export { collectAuthoredTriggers } from "@jgengine/core/scene/authoredTriggers";
21
22
  export { environmentContentFromDocument } from "@jgengine/core/editor/environment";
22
23
  export { environment } from "@jgengine/core/world/features";
24
+ export { grounded } from "@jgengine/core/world/grounded";
23
25
  export type { GameCameraConfig } from "@jgengine/core/game/playableGame";
26
+ export { DEFAULT_WALK_CODES } from "./shellMovement.js";
24
27
  export { HudCanvas, HudPanel, useHudLayout } from "@jgengine/react/hudLayout";
25
28
  export { StatBar, Hotbar, Clock, Coins } from "@jgengine/react/hud";
26
29
  export { seededRng } from "@jgengine/core/random/rng";
package/dist/gameKit.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The happy-path game kit — one import surface covering a whole simple game: authoring
3
- * (`defineGame`), mounting (`GameHost`), serializable state (`defineStore`), behavior
4
- * (`defineSystem`), the authored-scene document helpers, HUD primitives, and deterministic
5
- * randomness. Start here; reach for deep package paths only when a game outgrows the kit.
2
+ * **Start here for game code.** The sole happy-path import surface for a whole simple game:
3
+ * authoring (`defineGame`), mounting (`GameHost`), serializable state (`defineStore`), behavior
4
+ * (`defineSystem`), authored-scene helpers, HUD building blocks, and deterministic randomness.
5
+ * Reach for deep package paths only when a game outgrows the kit. Do not start from
6
+ * `@jgengine/core/authoring` — that module is a core-side helper barrel, not the game entrypoint.
6
7
  *
7
8
  * @capability game-kit one-stop happy-path import surface for authoring, mounting, state, systems, authored scenes, and HUD
8
9
  */
@@ -20,7 +21,9 @@ export { collectAuthoredTriggers } from "@jgengine/core/scene/authoredTriggers";
20
21
  export { environmentContentFromDocument } from "@jgengine/core/editor/environment";
21
22
  // World + camera.
22
23
  export { environment } from "@jgengine/core/world/features";
23
- // HUD layout + optional building blocks — games own custom chrome (not a stock face).
24
+ export { grounded } from "@jgengine/core/world/grounded";
25
+ export { DEFAULT_WALK_CODES } from "./shellMovement.js";
26
+ // HUD layout + drop-in building blocks — compose these, reskin with HudTheme; games own layout/terminology/art direction.
24
27
  export { HudCanvas, HudPanel, useHudLayout } from "@jgengine/react/hudLayout";
25
28
  export { StatBar, Hotbar, Clock, Coins } from "@jgengine/react/hud";
26
29
  // Deterministic randomness (inject seeds; never Math.random in game logic).
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;