@jgengine/shell 0.11.0 → 0.13.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 (151) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/CombatPresentation.d.ts +5 -0
  3. package/dist/CombatPresentation.js +8 -0
  4. package/dist/GameHost.d.ts +27 -1
  5. package/dist/GameHost.js +83 -4
  6. package/dist/GamePlayer.d.ts +5 -0
  7. package/dist/GamePlayer.js +5 -0
  8. package/dist/GamePlayerShell.d.ts +5 -0
  9. package/dist/GamePlayerShell.js +63 -359
  10. package/dist/GameUiPreview.js +3 -0
  11. package/dist/Shell3dPresentation.d.ts +63 -0
  12. package/dist/Shell3dPresentation.js +302 -0
  13. package/dist/ShellChrome.d.ts +51 -0
  14. package/dist/ShellChrome.js +51 -0
  15. package/dist/ShellHudPresentation.d.ts +41 -0
  16. package/dist/ShellHudPresentation.js +22 -0
  17. package/dist/audio/audioEngine.d.ts +4 -0
  18. package/dist/audio/audioEngine.js +111 -26
  19. package/dist/audio/audioWire.d.ts +22 -4
  20. package/dist/audio/audioWire.js +46 -3
  21. package/dist/audio/loopParams.d.ts +20 -0
  22. package/dist/audio/loopParams.js +28 -0
  23. package/dist/camera/GameCameraRig.js +6 -1
  24. package/dist/camera/GameOrbitCamera.js +7 -1
  25. package/dist/camera/cameraCollision.d.ts +28 -0
  26. package/dist/camera/cameraCollision.js +34 -0
  27. package/dist/camera/cameraRigs.d.ts +3 -1
  28. package/dist/camera/cameraRigs.js +24 -10
  29. package/dist/camera/index.d.ts +1 -0
  30. package/dist/camera/index.js +1 -0
  31. package/dist/camera/rigMath.d.ts +27 -0
  32. package/dist/camera/rigMath.js +45 -0
  33. package/dist/defineGame.d.ts +15 -1
  34. package/dist/defineGame.js +26 -4
  35. package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
  36. package/dist/devtools/DevtoolsOverlay.js +28 -12
  37. package/dist/devtools/agentBridge.js +4 -0
  38. package/dist/devtools/perfDiagnose.js +1 -1
  39. package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
  40. package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
  41. package/dist/environment/EnvironmentScene.js +17 -3
  42. package/dist/environment/RoadJunctions.d.ts +12 -0
  43. package/dist/environment/RoadJunctions.js +69 -0
  44. package/dist/environment/RoadRibbons.d.ts +8 -2
  45. package/dist/environment/RoadRibbons.js +26 -8
  46. package/dist/environment/terrainGroundColor.d.ts +9 -0
  47. package/dist/environment/terrainGroundColor.js +22 -0
  48. package/dist/gameKit.d.ts +27 -0
  49. package/dist/gameKit.js +29 -0
  50. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  51. package/dist/postfx/PostProcessing.d.ts +9 -1
  52. package/dist/postfx/PostProcessing.js +11 -4
  53. package/dist/presentationResolve.d.ts +39 -0
  54. package/dist/presentationResolve.js +53 -0
  55. package/dist/registry.d.ts +1 -0
  56. package/dist/render/PartMotion.d.ts +18 -0
  57. package/dist/render/PartMotion.js +91 -0
  58. package/dist/render/SceneModels.d.ts +9 -2
  59. package/dist/render/SceneModels.js +31 -180
  60. package/dist/render/assetBase.d.ts +17 -0
  61. package/dist/render/assetBase.js +35 -0
  62. package/dist/render/measureBounds.d.ts +34 -0
  63. package/dist/render/measureBounds.js +127 -0
  64. package/dist/render/modelLoad.js +4 -1
  65. package/dist/render/resolveModel.d.ts +15 -0
  66. package/dist/render/resolveModel.js +43 -2
  67. package/dist/render/useModelAnimation.d.ts +10 -0
  68. package/dist/render/useModelAnimation.js +198 -0
  69. package/dist/scatter/InstancedScatter.js +5 -0
  70. package/dist/scatter/scatterProxies.js +24 -0
  71. package/dist/scene/AuthoredScene.d.ts +3 -1
  72. package/dist/scene/AuthoredScene.js +7 -2
  73. package/dist/scene/CityRenderer.d.ts +2 -0
  74. package/dist/scene/CityRenderer.js +1031 -0
  75. package/dist/scene/GrassFieldRenderer.js +53 -5
  76. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  77. package/dist/scene/PoleLineRenderer.js +287 -0
  78. package/dist/scene/SoilPatchRenderer.js +70 -7
  79. package/dist/scene/WaterRenderer.js +34 -10
  80. package/dist/scene/builtinSceneKindRenderers.js +4 -0
  81. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  82. package/dist/scene/cityWindowMaterial.js +81 -0
  83. package/dist/scene/sceneKindRenderers.d.ts +2 -0
  84. package/dist/settings/appliedSettings.d.ts +2 -1
  85. package/dist/settings/appliedSettings.js +4 -2
  86. package/dist/structures/GeneratedBuilding.d.ts +4 -0
  87. package/dist/structures/GeneratedBuilding.js +17 -7
  88. package/dist/structures/TransformGizmo.d.ts +11 -1
  89. package/dist/structures/TransformGizmo.js +9 -5
  90. package/dist/terrain/GrassField.d.ts +11 -3
  91. package/dist/terrain/GrassField.js +16 -6
  92. package/dist/terrain/grassGeometry.d.ts +33 -1
  93. package/dist/terrain/grassGeometry.js +83 -21
  94. package/dist/terrain/grassMaterial.d.ts +18 -0
  95. package/dist/terrain/grassMaterial.js +66 -13
  96. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  97. package/dist/terrain/heightfieldGeometry.js +109 -0
  98. package/dist/terrain/index.d.ts +1 -0
  99. package/dist/terrain/index.js +1 -0
  100. package/dist/terrain/soilPatchMaterial.d.ts +15 -2
  101. package/dist/terrain/soilPatchMaterial.js +46 -8
  102. package/dist/terrain/terrainDetailMaterial.js +15 -2
  103. package/dist/water/Ocean.d.ts +7 -1
  104. package/dist/water/Ocean.js +16 -3
  105. package/dist/water/OceanConfig.d.ts +9 -0
  106. package/dist/water/OceanConfig.js +6 -0
  107. package/dist/water/OceanMaterial.d.ts +12 -0
  108. package/dist/water/OceanMaterial.js +9 -0
  109. package/dist/water/OceanShader.d.ts +2 -2
  110. package/dist/water/OceanShader.js +78 -10
  111. package/dist/world/PlaceScene.d.ts +11 -0
  112. package/dist/world/PlaceScene.js +27 -0
  113. package/dist/world/WorldHud.d.ts +3 -1
  114. package/dist/world/WorldHud.js +2 -2
  115. package/dist/world/WorldScene.js +32 -4
  116. package/dist/worldSky.d.ts +7 -1
  117. package/dist/worldSky.js +11 -1
  118. package/package.json +4 -4
  119. package/skills/game-design/SKILL.md +28 -0
  120. package/skills/game-design/agents/openai.yaml +4 -0
  121. package/skills/game-design/references/application-playbook.md +170 -0
  122. package/skills/game-design/references/field-guide.md +288 -0
  123. package/skills/jgengine/SKILL.md +8 -4
  124. package/skills/jgengine/api.md +182 -81
  125. package/skills/jgengine/capabilities.md +104 -2
  126. package/skills/jgengine/recipes/minimal-game.md +88 -0
  127. package/skills/jgengine-ui/SKILL.md +17 -10
  128. package/skills/jgengine-ui/api.md +316 -51
  129. package/skills/jgengine-ui/capabilities.md +135 -2
  130. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  131. package/skills/jgengine-ui/reference.md +59 -9
  132. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  133. package/skills/jgengine-world/SKILL.md +13 -0
  134. package/skills/jgengine-world/api.md +453 -21
  135. package/skills/jgengine-world/capabilities.md +112 -2
  136. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  137. package/skills/jgengine-world/reference.md +43 -4
  138. package/skills/level-design/SKILL.md +54 -0
  139. package/skills/level-design/agents/openai.yaml +4 -0
  140. package/skills/level-design/references/application-playbook.md +192 -0
  141. package/skills/level-design/references/field-guide.md +337 -0
  142. package/skills/level-design/references/live-editor-loop.md +179 -0
  143. package/dist/cartridge/runtime.d.ts +0 -15
  144. package/dist/cartridge/runtime.js +0 -556
  145. package/dist/cartridge/spec.d.ts +0 -188
  146. package/dist/cartridge/spec.js +0 -19
  147. package/dist/cartridge/validate.d.ts +0 -2
  148. package/dist/cartridge/validate.js +0 -78
  149. package/dist/cartridge.d.ts +0 -145
  150. package/dist/cartridge.js +0 -246
  151. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,5 +1,5 @@
1
1
  import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff";
2
- import { type SettingsStore } from "@jgengine/core/settings/settingsModel";
2
+ import { type GraphicsQuality, type SettingsStore } from "@jgengine/core/settings/settingsModel";
3
3
  import { type TouchStyle } from "@jgengine/core/input/touchScheme";
4
4
  import type { AudioEngine } from "../audio/audioEngine.js";
5
5
  /** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
@@ -11,6 +11,7 @@ export declare function useGraphicsSettings(store: SettingsStore, shadowsDefault
11
11
  shadows: boolean;
12
12
  dpr: number;
13
13
  uiScale: number;
14
+ quality: GraphicsQuality;
14
15
  };
15
16
  export declare function AudioSettingsBridge({ store, engine, buses, }: {
16
17
  store: SettingsStore;
@@ -16,12 +16,14 @@ export function useSettingsRevision(store) {
16
16
  }
17
17
  export function useGraphicsSettings(store, shadowsDefault) {
18
18
  useSettingsRevision(store);
19
- const quality = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY);
19
+ const rawQuality = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY);
20
+ const quality = GRAPHICS_QUALITY_DPR[rawQuality] !== undefined ? rawQuality : "high";
20
21
  const rawUiScale = store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE);
21
22
  return {
22
23
  shadows: store.get(SETTING_IDS.graphicsShadows, shadowsDefault),
23
- dpr: GRAPHICS_QUALITY_DPR[quality] ?? GRAPHICS_QUALITY_DPR.high,
24
+ dpr: GRAPHICS_QUALITY_DPR[quality],
24
25
  uiScale: Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, rawUiScale)),
26
+ quality,
25
27
  };
26
28
  }
27
29
  export function AudioSettingsBridge({ store, engine, buses, }) {
@@ -43,6 +43,10 @@ export interface GeneratedBuildingProps {
43
43
  export interface InstancedBuildingPlacement {
44
44
  building: GeneratedBuildingData;
45
45
  position?: readonly [number, number, number];
46
+ /** Building yaw (radians) applied to the whole massing about `pivot` — street-aware facing. */
47
+ rotationY?: number;
48
+ /** World XZ the `rotationY` turns about (the building center); defaults to the world origin. */
49
+ pivot?: readonly [number, number];
46
50
  }
47
51
  export interface InstancedBuildingsProps {
48
52
  buildings: readonly InstancedBuildingPlacement[];
@@ -70,25 +70,35 @@ function bucketPartMatrices(buildings, visible) {
70
70
  const buckets = new Map();
71
71
  for (const placement of buildings) {
72
72
  const [ox, oy, oz] = placement.position ?? [0, 0, 0];
73
+ // Building yaw turns the whole massing about its center. Engine Y-rotation convention (matches
74
+ // dummy.rotation.y below): a local vector (x, z) maps to (x·cos + z·sin, −x·sin + z·cos).
75
+ const yaw = placement.rotationY ?? 0;
76
+ const [px0, pz0] = placement.pivot ?? [0, 0];
77
+ const cos = Math.cos(yaw);
78
+ const sin = Math.sin(yaw);
73
79
  for (const part of placement.building.parts) {
74
80
  if (visible !== null && !visible.has(part.kind))
75
81
  continue;
76
82
  const [nx, nz] = normalFor(part.facade);
77
83
  const offset = outwardOffset(part);
78
- const px = ox + part.position[0] + nx * offset;
84
+ // Local offset from the pivot (part position + facade-normal nudge), then rotated by the yaw.
85
+ const lx = part.position[0] - px0 + nx * offset;
86
+ const lz = part.position[2] - pz0 + nz * offset;
87
+ const px = ox + px0 + lx * cos + lz * sin;
79
88
  const py = oy + part.position[1];
80
- const pz = oz + part.position[2] + nz * offset;
89
+ const pz = oz + pz0 - lx * sin + lz * cos;
90
+ const partYaw = part.rotationY + yaw;
81
91
  let bucket = buckets.get(part.kind);
82
92
  if (bucket === undefined) {
83
93
  bucket = [];
84
94
  buckets.set(part.kind, bucket);
85
95
  }
86
96
  if (part.kind === "clothesline") {
87
- const sin = Math.sin(part.rotationY);
88
- const cos = Math.cos(part.rotationY);
97
+ const rowSin = Math.sin(partYaw);
98
+ const rowCos = Math.cos(partYaw);
89
99
  for (const row of CLOTHESLINE_ROW_OFFSETS) {
90
- dummy.position.set(px + row * sin, py, pz + row * cos);
91
- dummy.rotation.set(0, part.rotationY, 0);
100
+ dummy.position.set(px + row * rowSin, py, pz + row * rowCos);
101
+ dummy.rotation.set(0, partYaw, 0);
92
102
  dummy.scale.set(part.scale[0], Math.max(part.scale[1], 0.025), 0.025);
93
103
  dummy.updateMatrix();
94
104
  bucket.push(dummy.matrix.clone());
@@ -96,7 +106,7 @@ function bucketPartMatrices(buildings, visible) {
96
106
  continue;
97
107
  }
98
108
  dummy.position.set(px, py, pz);
99
- dummy.rotation.set(0, part.rotationY, 0);
109
+ dummy.rotation.set(0, partYaw, 0);
100
110
  dummy.scale.set(part.scale[0], part.scale[1], part.scale[2]);
101
111
  dummy.updateMatrix();
102
112
  bucket.push(dummy.matrix.clone());
@@ -2,6 +2,10 @@
2
2
  export type TransformGizmoMode = "translate" | "rotate" | "scale";
3
3
  /** Snap policy: grid quantize, free drag, or ground-height sample on release. */
4
4
  export type TransformGizmoSnap = "grid" | "free" | "ground";
5
+ /** Gizmo handle orientation: world axes, or the target's local (yaw-rotated) axes. */
6
+ export type TransformGizmoSpace = "world" | "local";
7
+ /** Default rotation snap increment (radians) when callers don't configure one. */
8
+ export declare const DEFAULT_ROTATION_SNAP: number;
5
9
  /** Pose reported by {@link TransformGizmo} when the user releases a drag. */
6
10
  export interface TransformGizmoPose {
7
11
  position: {
@@ -27,6 +31,12 @@ export interface TransformGizmoProps {
27
31
  mode?: TransformGizmoMode;
28
32
  snapMode?: TransformGizmoSnap;
29
33
  gridSize?: number;
34
+ /** Handle orientation; `"local"` aligns handles to the target's yaw. */
35
+ space?: TransformGizmoSpace;
36
+ /** Rotation snap increment in radians; `null` rotates freely. */
37
+ rotationSnap?: number | null;
38
+ /** Scale snap increment; `null` (default) scales freely. */
39
+ scaleSnap?: number | null;
30
40
  /** Vertical offset so the gizmo sits above ground markers. */
31
41
  lift?: number;
32
42
  size?: number;
@@ -40,4 +50,4 @@ export interface TransformGizmoProps {
40
50
  * Wraps TransformControls; callers own selection and commit side-effects.
41
51
  * @capability transform-gizmo in-game select/move/rotate/scale gizmo
42
52
  */
43
- export declare const TransformGizmo: import("react").MemoExoticComponent<({ position, rotationY, mode, snapMode, gridSize, lift, size, groundSnap, onDraggingChange, onRelease, }: TransformGizmoProps) => import("react").JSX.Element>;
53
+ export declare const TransformGizmo: import("react").MemoExoticComponent<({ position, rotationY, mode, snapMode, gridSize, space, rotationSnap, scaleSnap, lift, size, groundSnap, onDraggingChange, onRelease, }: TransformGizmoProps) => import("react").JSX.Element>;
@@ -2,12 +2,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { TransformControls } from "@react-three/drei";
3
3
  import { useThree } from "@react-three/fiber";
4
4
  import { memo, useEffect, useRef, useState } from "react";
5
+ /** Default rotation snap increment (radians) when callers don't configure one. */
6
+ export const DEFAULT_ROTATION_SNAP = Math.PI / 12;
5
7
  /**
6
8
  * Runtime selection/move gizmo shared by games and the editor.
7
9
  * Wraps TransformControls; callers own selection and commit side-effects.
8
10
  * @capability transform-gizmo in-game select/move/rotate/scale gizmo
9
11
  */
10
- export const TransformGizmo = memo(function TransformGizmo({ position, rotationY = 0, mode = "translate", snapMode = "free", gridSize = 1, lift = 0, size = 0.85, groundSnap, onDraggingChange, onRelease, }) {
12
+ export const TransformGizmo = memo(function TransformGizmo({ position, rotationY = 0, mode = "translate", snapMode = "free", gridSize = 1, space = "world", rotationSnap = DEFAULT_ROTATION_SNAP, scaleSnap = null, lift = 0, size = 0.85, groundSnap, onDraggingChange, onRelease, }) {
11
13
  const groupRef = useRef(null);
12
14
  const [object, setObject] = useState(null);
13
15
  const draggingRef = useRef(false);
@@ -24,9 +26,11 @@ export const TransformGizmo = memo(function TransformGizmo({ position, rotationY
24
26
  group.rotation.set(0, rotationY, 0);
25
27
  group.scale.set(1, 1, 1);
26
28
  }, [position.x, position.y, position.z, rotationY, lift, anchorKey, mode]);
27
- const snapProps = snapMode === "grid"
28
- ? { translationSnap: gridSize, rotationSnap: Math.PI / 12 }
29
- : { rotationSnap: Math.PI / 12 };
29
+ const snapProps = {
30
+ translationSnap: snapMode === "grid" ? gridSize : null,
31
+ rotationSnap,
32
+ scaleSnap,
33
+ };
30
34
  const handleRelease = () => {
31
35
  draggingRef.current = false;
32
36
  if (controls !== null && "enabled" in controls)
@@ -46,7 +50,7 @@ export const TransformGizmo = memo(function TransformGizmo({ position, rotationY
46
50
  scale: { x: current.scale.x, y: current.scale.y, z: current.scale.z },
47
51
  });
48
52
  };
49
- return (_jsxs(_Fragment, { children: [_jsx("group", { ref: groupRef }), object !== null ? (_jsx(TransformControls, { object: object, mode: mode, size: size, ...snapProps, onMouseDown: () => {
53
+ return (_jsxs(_Fragment, { children: [_jsx("group", { ref: groupRef }), object !== null ? (_jsx(TransformControls, { object: object, mode: mode, size: size, space: space, ...snapProps, onMouseDown: () => {
50
54
  draggingRef.current = true;
51
55
  if (controls !== null && "enabled" in controls)
52
56
  controls.enabled = false;
@@ -1,8 +1,9 @@
1
1
  import { type ThreeElements } from "@react-three/fiber";
2
- import { type GrassBladeGeometryOptions, type GrassRange } from "./grassGeometry.js";
3
- import { type GrassMaterialOptions, type GrassWindOptions } from "./grassMaterial.js";
2
+ import { type GrassBladeGeometryOptions, type GrassExclusion, type GrassRange } from "./grassGeometry.js";
3
+ import { type GrassDistanceFadeOptions, type GrassMaterialOptions, type GrassWindOptions } from "./grassMaterial.js";
4
4
  import type { TerrainArea, TerrainHeightSampler } from "./terrainMath.js";
5
5
  export { DEFAULT_GRASS_COUNT, DEFAULT_GRASS_DENSITY, resolveGrassInstanceBudget } from "./grassBudget.js";
6
+ export { GRASS_TUFT_BLADES } from "./grassGeometry.js";
6
7
  export interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material"> {
7
8
  count?: number;
8
9
  density?: number;
@@ -13,11 +14,18 @@ export interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "c
13
14
  bladeHeight?: GrassRange;
14
15
  bladeWidth?: GrassRange;
15
16
  bladeBend?: GrassRange;
17
+ tuftBlades?: number;
18
+ tuftRadius?: number;
19
+ edgeFeather?: number;
20
+ exclude?: readonly GrassExclusion[];
16
21
  heightAt?: TerrainHeightSampler;
17
22
  colorBase?: GrassMaterialOptions["colorBase"];
18
23
  colorTip?: GrassMaterialOptions["colorTip"];
24
+ colorGround?: GrassMaterialOptions["colorGround"];
19
25
  colorVariation?: number;
20
26
  wind?: GrassWindOptions | false;
27
+ distanceFade?: GrassDistanceFadeOptions | false;
28
+ normalLift?: number;
21
29
  roughness?: number;
22
30
  }
23
- export declare function GrassField({ count, density, budget, area, seed, segments, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow, receiveShadow, frustumCulled, ...meshProps }: GrassFieldProps): import("react").JSX.Element;
31
+ export declare function GrassField({ count, density, budget, area, seed, segments, bladeHeight, bladeWidth, bladeBend, tuftBlades, tuftRadius, edgeFeather, exclude, heightAt, colorBase, colorTip, colorGround, colorVariation, wind, distanceFade, normalLift, roughness, castShadow, receiveShadow, frustumCulled, ...meshProps }: GrassFieldProps): import("react").JSX.Element;
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo } from "react";
4
- import { createGrassBladeGeometry } from "./grassGeometry.js";
5
- import { createGrassMaterial } from "./grassMaterial.js";
4
+ import { createGrassBladeGeometry, grassTuftCount, GRASS_TUFT_BLADES, } from "./grassGeometry.js";
5
+ import { createGrassMaterial, } from "./grassMaterial.js";
6
6
  import { DEFAULT_GRASS_COUNT, DEFAULT_GRASS_DENSITY, resolveGrassInstanceBudget, } from "./grassBudget.js";
7
7
  export { DEFAULT_GRASS_COUNT, DEFAULT_GRASS_DENSITY, resolveGrassInstanceBudget } from "./grassBudget.js";
8
- export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshProps }) {
8
+ export { GRASS_TUFT_BLADES } from "./grassGeometry.js";
9
+ export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, tuftBlades = GRASS_TUFT_BLADES, tuftRadius, edgeFeather, exclude, heightAt, colorBase, colorTip, colorGround, colorVariation, wind, distanceFade, normalLift, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshProps }) {
9
10
  const geometry = useMemo(() => createGrassBladeGeometry({
10
11
  count,
11
12
  area,
@@ -14,16 +15,25 @@ export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRAS
14
15
  height: bladeHeight,
15
16
  width: bladeWidth,
16
17
  bend: bladeBend,
18
+ ...(tuftBlades === undefined ? {} : { tuftBlades }),
19
+ ...(tuftRadius === undefined ? {} : { tuftRadius }),
20
+ ...(edgeFeather === undefined ? {} : { edgeFeather }),
21
+ ...(exclude === undefined ? {} : { exclude }),
17
22
  heightAt,
18
- }), [area, bladeBend, bladeHeight, bladeWidth, count, heightAt, seed, segments]);
23
+ }), [area, bladeBend, bladeHeight, bladeWidth, count, edgeFeather, exclude, heightAt, seed, segments, tuftBlades, tuftRadius]);
19
24
  const handle = useMemo(() => createGrassMaterial({
20
25
  colorBase,
21
26
  colorTip,
27
+ colorGround,
22
28
  colorVariation,
23
29
  wind,
30
+ distanceFade,
31
+ normalLift,
24
32
  roughness,
25
- }), [colorBase, colorTip, colorVariation, roughness, wind]);
26
- geometry.instanceCount = resolveGrassInstanceBudget(count, density, area, budget);
33
+ }), [colorBase, colorTip, colorGround, colorVariation, distanceFade, normalLift, roughness, wind]);
34
+ // Budgets stay in blades (the public unit); the instance buffer carries tufts.
35
+ const instanceCount = useMemo(() => grassTuftCount(resolveGrassInstanceBudget(count, density, area, budget), tuftBlades), [count, density, area, budget, tuftBlades]);
36
+ geometry.instanceCount = Math.min(instanceCount, grassTuftCount(count, tuftBlades));
27
37
  useFrame((state) => {
28
38
  handle.uniforms.uTime.value = state.clock.elapsedTime;
29
39
  });
@@ -2,7 +2,19 @@ import * as THREE from "three";
2
2
  import { type TerrainSeed } from "./random.js";
3
3
  import { type TerrainArea, type TerrainHeightSampler } from "./terrainMath.js";
4
4
  export type GrassRange = number | readonly [min: number, max: number];
5
+ /** Blades baked into each tuft instance — one instance reads as a clump, not a lone blade. */
6
+ export declare const GRASS_TUFT_BLADES = 5;
7
+ /** A rectangle (patch-local XZ) the grass bake keeps clear — soil patches, water, plazas. */
8
+ export interface GrassExclusion {
9
+ /** Rect center in patch-local coordinates. */
10
+ center: readonly [x: number, z: number];
11
+ /** Rect half extents. */
12
+ half: readonly [x: number, z: number];
13
+ /** Ramp width (m) outside the rect over which blades regrow to full height. Default 1.5. */
14
+ feather?: number;
15
+ }
5
16
  export interface GrassBladeGeometryOptions {
17
+ /** Total blade budget for the patch (instances are tufts of `tuftBlades` blades each). */
6
18
  count?: number;
7
19
  area?: TerrainArea;
8
20
  seed?: TerrainSeed;
@@ -10,6 +22,14 @@ export interface GrassBladeGeometryOptions {
10
22
  height?: GrassRange;
11
23
  width?: GrassRange;
12
24
  bend?: GrassRange;
25
+ /** Blades merged into each instanced tuft. Default {@link GRASS_TUFT_BLADES}. */
26
+ tuftBlades?: number;
27
+ /** Tuft spread radius in meters — how far clump blades splay from the tuft root. Default 0.16. */
28
+ tuftRadius?: number;
29
+ /** Meters of density/height feathering at the patch border so the field ends in a soft fringe, not a mowed rectangle. Default 1.5; 0 disables. */
30
+ edgeFeather?: number;
31
+ /** Patch-local rectangles kept clear of blades (soil patches, water) with a soft regrow ramp. */
32
+ exclude?: readonly GrassExclusion[];
13
33
  heightAt?: TerrainHeightSampler;
14
34
  }
15
35
  export interface ResolvedGrassBladeGeometryOptions {
@@ -20,11 +40,23 @@ export interface ResolvedGrassBladeGeometryOptions {
20
40
  height: readonly [min: number, max: number];
21
41
  width: readonly [min: number, max: number];
22
42
  bend: readonly [min: number, max: number];
43
+ tuftBlades: number;
44
+ tuftRadius: number;
45
+ edgeFeather: number;
46
+ exclude: readonly GrassExclusion[];
23
47
  heightAt: TerrainHeightSampler;
24
48
  }
25
49
  /** @internal */
26
50
  export declare function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number];
27
51
  /** @internal */
28
52
  export declare function resolveGrassBladeGeometryOptions(options?: GrassBladeGeometryOptions): ResolvedGrassBladeGeometryOptions;
29
- /** @internal */
53
+ /** Tuft instances needed to carry `bladeCount` blades at `tuftBlades` blades per tuft. @internal */
54
+ export declare function grassTuftCount(bladeCount: number, tuftBlades?: number): number;
55
+ /**
56
+ * Instanced tuft geometry: the template mesh is a clump of `tuftBlades` blades splayed around the
57
+ * tuft root (per-blade yaw/offset/scale baked as vertex attributes), and each instance places one
58
+ * whole clump. Clumps read as turf at a fraction of the instance count single blades need, and the
59
+ * per-instance yaw/scale jitter hides the shared clump layout.
60
+ * @internal
61
+ */
30
62
  export declare function createGrassBladeGeometry(options?: GrassBladeGeometryOptions): THREE.InstancedBufferGeometry;
@@ -1,6 +1,8 @@
1
1
  import * as THREE from "three";
2
2
  import { createSeededRandom } from "./random.js";
3
3
  import { resolveTerrainSize } from "./terrainMath.js";
4
+ /** Blades baked into each tuft instance — one instance reads as a clump, not a lone blade. */
5
+ export const GRASS_TUFT_BLADES = 5;
4
6
  /** @internal */
5
7
  export function resolveGrassRange(value, fallback) {
6
8
  if (value === undefined)
@@ -15,53 +17,113 @@ export function resolveGrassBladeGeometryOptions(options = {}) {
15
17
  seed: options.seed ?? 1,
16
18
  segments: Math.max(1, Math.floor(options.segments ?? 4)),
17
19
  height: resolveGrassRange(options.height, [0.55, 1.1]),
18
- width: resolveGrassRange(options.width, [0.035, 0.075]),
19
- bend: resolveGrassRange(options.bend, [0.08, 0.32]),
20
+ width: resolveGrassRange(options.width, [0.05, 0.1]),
21
+ bend: resolveGrassRange(options.bend, [0.12, 0.42]),
22
+ tuftBlades: Math.max(1, Math.floor(options.tuftBlades ?? GRASS_TUFT_BLADES)),
23
+ tuftRadius: Math.max(0, options.tuftRadius ?? 0.16),
24
+ edgeFeather: Math.max(0, options.edgeFeather ?? 1.5),
25
+ exclude: options.exclude ?? [],
20
26
  heightAt: options.heightAt ?? (() => 0),
21
27
  };
22
28
  }
29
+ /** Tuft instances needed to carry `bladeCount` blades at `tuftBlades` blades per tuft. @internal */
30
+ export function grassTuftCount(bladeCount, tuftBlades = GRASS_TUFT_BLADES) {
31
+ return Math.ceil(Math.max(0, bladeCount) / Math.max(1, tuftBlades));
32
+ }
23
33
  function randomRange(random, range) {
24
34
  return THREE.MathUtils.lerp(range[0], range[1], random());
25
35
  }
26
- /** @internal */
36
+ /**
37
+ * Instanced tuft geometry: the template mesh is a clump of `tuftBlades` blades splayed around the
38
+ * tuft root (per-blade yaw/offset/scale baked as vertex attributes), and each instance places one
39
+ * whole clump. Clumps read as turf at a fraction of the instance count single blades need, and the
40
+ * per-instance yaw/scale jitter hides the shared clump layout.
41
+ * @internal
42
+ */
27
43
  export function createGrassBladeGeometry(options = {}) {
28
44
  const resolved = resolveGrassBladeGeometryOptions(options);
29
45
  const size = resolveTerrainSize(resolved.area);
30
46
  const positions = [];
31
47
  const normals = [];
32
48
  const uvs = [];
49
+ const bladeOffsets = [];
50
+ const bladeVary = [];
51
+ const bladeBendScale = [];
33
52
  const indices = [];
34
- for (let segment = 0; segment <= resolved.segments; segment += 1) {
35
- const t = segment / resolved.segments;
36
- positions.push(-0.5, t, 0, 0.5, t, 0);
37
- normals.push(0, 0, 1, 0, 0, 1);
38
- uvs.push(0, t, 1, t);
39
- if (segment < resolved.segments) {
40
- const base = segment * 2;
41
- indices.push(base, base + 1, base + 3, base, base + 3, base + 2);
53
+ // Blade layout inside the tuft is baked once from the seed, shared by every instance.
54
+ const templateRandom = createSeededRandom(typeof resolved.seed === "number" ? resolved.seed + 7919 : `${resolved.seed}/tuft`);
55
+ const vertsPerBlade = (resolved.segments + 1) * 2;
56
+ for (let blade = 0; blade < resolved.tuftBlades; blade += 1) {
57
+ // First blade anchors the root; the rest ring outward with jittered angle/radius.
58
+ const angle = blade === 0 ? 0 : ((blade - 1) / Math.max(1, resolved.tuftBlades - 1)) * Math.PI * 2 + templateRandom() * 1.4;
59
+ const radius = blade === 0 ? 0 : resolved.tuftRadius * (0.35 + 0.65 * templateRandom());
60
+ const offsetX = Math.cos(angle) * radius;
61
+ const offsetZ = Math.sin(angle) * radius;
62
+ // Bend direction points away from the tuft center (plus jitter) so the clump splays open.
63
+ const yaw = angle + Math.PI / 2 + (templateRandom() - 0.5) * 1.6;
64
+ const heightScale = 0.72 + templateRandom() * 0.45;
65
+ const widthScale = 0.8 + templateRandom() * 0.4;
66
+ const phase = templateRandom();
67
+ const bendScale = 0.7 + templateRandom() * 0.7;
68
+ for (let segment = 0; segment <= resolved.segments; segment += 1) {
69
+ const t = segment / resolved.segments;
70
+ positions.push(-0.5, t, 0, 0.5, t, 0);
71
+ normals.push(0, 0, 1, 0, 0, 1);
72
+ uvs.push(0, t, 1, t);
73
+ for (let corner = 0; corner < 2; corner += 1) {
74
+ bladeOffsets.push(offsetX, offsetZ);
75
+ bladeVary.push(yaw, heightScale, widthScale, phase);
76
+ bladeBendScale.push(bendScale);
77
+ }
78
+ if (segment < resolved.segments) {
79
+ const base = blade * vertsPerBlade + segment * 2;
80
+ indices.push(base, base + 1, base + 3, base, base + 3, base + 2);
81
+ }
42
82
  }
43
83
  }
44
84
  const geometry = new THREE.InstancedBufferGeometry();
45
85
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
46
86
  geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
47
87
  geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
88
+ geometry.setAttribute("bladeOffset", new THREE.Float32BufferAttribute(bladeOffsets, 2));
89
+ geometry.setAttribute("bladeVary", new THREE.Float32BufferAttribute(bladeVary, 4));
90
+ geometry.setAttribute("bladeBendScale", new THREE.Float32BufferAttribute(bladeBendScale, 1));
48
91
  geometry.setIndex(indices);
92
+ const tuftCount = grassTuftCount(resolved.count, resolved.tuftBlades);
49
93
  const random = createSeededRandom(resolved.seed);
50
- const offsets = new Float32Array(resolved.count * 3);
51
- const yaw = new Float32Array(resolved.count);
52
- const heights = new Float32Array(resolved.count);
53
- const widths = new Float32Array(resolved.count);
54
- const bend = new Float32Array(resolved.count);
55
- const phase = new Float32Array(resolved.count);
56
- const colorMix = new Float32Array(resolved.count);
57
- for (let index = 0; index < resolved.count; index += 1) {
94
+ const offsets = new Float32Array(tuftCount * 3);
95
+ const yaw = new Float32Array(tuftCount);
96
+ const heights = new Float32Array(tuftCount);
97
+ const widths = new Float32Array(tuftCount);
98
+ const bend = new Float32Array(tuftCount);
99
+ const phase = new Float32Array(tuftCount);
100
+ const colorMix = new Float32Array(tuftCount);
101
+ const halfW = size.width / 2;
102
+ const halfD = size.depth / 2;
103
+ for (let index = 0; index < tuftCount; index += 1) {
58
104
  const x = (random() - 0.5) * size.width;
59
105
  const z = (random() - 0.5) * size.depth;
60
106
  offsets[index * 3] = x;
61
107
  offsets[index * 3 + 1] = resolved.heightAt(x, z);
62
108
  offsets[index * 3 + 2] = z;
63
109
  yaw[index] = random() * Math.PI * 2;
64
- heights[index] = randomRange(random, resolved.height);
110
+ let heightScale = randomRange(random, resolved.height);
111
+ // Two random draws happen on every path so the seeded stream stays aligned regardless of feather.
112
+ const gate = random();
113
+ const settle = random();
114
+ if (resolved.edgeFeather > 0) {
115
+ // Tufts drop out probabilistically across the feather band (mostly keeping full height),
116
+ // so the border reads as grass growing sparser — not a mowed short-pile strip.
117
+ const edge = Math.min(halfW - Math.abs(x), halfD - Math.abs(z));
118
+ const inside = Math.min(1, Math.max(0, edge / resolved.edgeFeather));
119
+ heightScale *= gate < inside * inside ? 0.7 + 0.3 * settle : 0;
120
+ }
121
+ for (const zone of resolved.exclude) {
122
+ // Signed distance outside the rect: blades vanish inside and regrow across the feather ramp.
123
+ const d = Math.max(Math.abs(x - zone.center[0]) - zone.half[0], Math.abs(z - zone.center[1]) - zone.half[1]);
124
+ heightScale *= Math.min(1, Math.max(0, d / Math.max(0.001, zone.feather ?? 1.5)));
125
+ }
126
+ heights[index] = heightScale;
65
127
  widths[index] = randomRange(random, resolved.width);
66
128
  bend[index] = randomRange(random, resolved.bend);
67
129
  phase[index] = random() * Math.PI * 2;
@@ -74,7 +136,7 @@ export function createGrassBladeGeometry(options = {}) {
74
136
  geometry.setAttribute("instanceBend", new THREE.InstancedBufferAttribute(bend, 1));
75
137
  geometry.setAttribute("instancePhase", new THREE.InstancedBufferAttribute(phase, 1));
76
138
  geometry.setAttribute("instanceColorMix", new THREE.InstancedBufferAttribute(colorMix, 1));
77
- geometry.instanceCount = resolved.count;
139
+ geometry.instanceCount = tuftCount;
78
140
  geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), Math.max(size.width, size.depth) * 0.75 + resolved.height[1]);
79
141
  return geometry;
80
142
  }
@@ -6,11 +6,22 @@ export interface GrassWindOptions {
6
6
  gustScale?: number;
7
7
  flutter?: number;
8
8
  }
9
+ /** Camera-distance fade band: tufts thin out between `start` and `end` meters, so the instance budget spends where the camera lives. */
10
+ export interface GrassDistanceFadeOptions {
11
+ start?: number;
12
+ end?: number;
13
+ }
9
14
  export interface GrassMaterialOptions {
10
15
  colorBase?: THREE.ColorRepresentation;
11
16
  colorTip?: THREE.ColorRepresentation;
17
+ /** Ground color the blade roots blend into (pass the terrain color under the patch so blades grow *out of* the ground instead of floating on it). Defaults to a darkened `colorBase`. */
18
+ colorGround?: THREE.ColorRepresentation;
12
19
  colorVariation?: number;
13
20
  wind?: GrassWindOptions | false;
21
+ /** Distance thinning; `false` disables. Default fades between 35 m and 95 m. */
22
+ distanceFade?: GrassDistanceFadeOptions | false;
23
+ /** 0..1 blend of blade normals toward straight-up — lit like a soft carpet instead of dark individual planes. Default 0.6. */
24
+ normalLift?: number;
14
25
  roughness?: number;
15
26
  }
16
27
  export interface GrassShaderUniforms {
@@ -22,14 +33,21 @@ export interface GrassShaderUniforms {
22
33
  uWindFlutter: THREE.IUniform<number>;
23
34
  uColorBase: THREE.IUniform<THREE.Color>;
24
35
  uColorTip: THREE.IUniform<THREE.Color>;
36
+ uColorGround: THREE.IUniform<THREE.Color>;
25
37
  uColorVariation: THREE.IUniform<number>;
38
+ uDistanceFade: THREE.IUniform<THREE.Vector2>;
39
+ uNormalLift: THREE.IUniform<number>;
26
40
  }
27
41
  export interface GrassMaterialHandle {
28
42
  material: THREE.MeshStandardMaterial;
29
43
  uniforms: GrassShaderUniforms;
30
44
  }
31
45
  export declare const DEFAULT_GRASS_WIND: Required<GrassWindOptions>;
46
+ /** Default camera-distance fade band: tufts start thinning at 35 m and are gone by 95 m. */
47
+ export declare const DEFAULT_GRASS_DISTANCE_FADE: Required<GrassDistanceFadeOptions>;
32
48
  /** @internal */
33
49
  export declare function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>;
34
50
  /** @internal */
51
+ export declare function resolveGrassDistanceFade(fade: GrassDistanceFadeOptions | false | undefined): Required<GrassDistanceFadeOptions>;
52
+ /** @internal */
35
53
  export declare function createGrassMaterial(options?: GrassMaterialOptions): GrassMaterialHandle;