@jgengine/shell 0.10.0 → 0.12.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 (286) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/CombatPresentation.d.ts +5 -0
  5. package/dist/CombatPresentation.js +8 -0
  6. package/dist/GameHost.d.ts +25 -1
  7. package/dist/GameHost.js +60 -4
  8. package/dist/GamePhaseStamp.d.ts +1 -0
  9. package/dist/GamePhaseStamp.js +9 -0
  10. package/dist/GamePlayer.d.ts +5 -0
  11. package/dist/GamePlayer.js +5 -0
  12. package/dist/GamePlayerShell.d.ts +5 -12
  13. package/dist/GamePlayerShell.js +67 -1332
  14. package/dist/GameUiPreview.js +3 -2
  15. package/dist/Shell3dPresentation.d.ts +63 -0
  16. package/dist/Shell3dPresentation.js +302 -0
  17. package/dist/ShellChrome.d.ts +51 -0
  18. package/dist/ShellChrome.js +51 -0
  19. package/dist/ShellHudPresentation.d.ts +41 -0
  20. package/dist/ShellHudPresentation.js +22 -0
  21. package/dist/audio/audioEngine.d.ts +4 -0
  22. package/dist/audio/audioEngine.js +128 -21
  23. package/dist/audio/audioWire.d.ts +40 -0
  24. package/dist/audio/audioWire.js +62 -0
  25. package/dist/audio/loopParams.d.ts +20 -0
  26. package/dist/audio/loopParams.js +28 -0
  27. package/dist/behaviour.d.ts +2 -1
  28. package/dist/behaviour.js +2 -1
  29. package/dist/behaviourAttach.d.ts +1 -0
  30. package/dist/behaviourAttach.js +1 -0
  31. package/dist/behaviourDriver.d.ts +1 -0
  32. package/dist/behaviourDriver.js +1 -0
  33. package/dist/boundActionDispatch.d.ts +13 -0
  34. package/dist/boundActionDispatch.js +37 -0
  35. package/dist/camera/GameCameraRig.d.ts +5 -2
  36. package/dist/camera/GameCameraRig.js +8 -3
  37. package/dist/camera/GameFirstPersonCamera.d.ts +9 -2
  38. package/dist/camera/GameFirstPersonCamera.js +8 -3
  39. package/dist/camera/GameInspectionCamera.d.ts +1 -1
  40. package/dist/camera/GameInspectionCamera.js +2 -2
  41. package/dist/camera/GameOrbitCamera.js +24 -9
  42. package/dist/camera/cameraBlendMath.d.ts +3 -0
  43. package/dist/camera/cameraBlendMath.js +3 -0
  44. package/dist/camera/cameraRigs.d.ts +3 -1
  45. package/dist/camera/cameraRigs.js +21 -3
  46. package/dist/camera/fovPreference.d.ts +6 -1
  47. package/dist/camera/fovPreference.js +6 -1
  48. package/dist/camera/index.d.ts +4 -7
  49. package/dist/camera/index.js +4 -7
  50. package/dist/camera/inspectionCameraMath.d.ts +7 -2
  51. package/dist/camera/inspectionCameraMath.js +7 -2
  52. package/dist/camera/orbitCameraMath.d.ts +36 -6
  53. package/dist/camera/orbitCameraMath.js +56 -6
  54. package/dist/camera/rigMath.d.ts +114 -24
  55. package/dist/camera/rigMath.js +129 -24
  56. package/dist/camera/rigResolve.d.ts +4 -2
  57. package/dist/camera/rigResolve.js +4 -2
  58. package/dist/camera/shakeChannel.d.ts +6 -2
  59. package/dist/camera/shakeChannel.js +6 -2
  60. package/dist/camera/shakeChannelMath.d.ts +1 -0
  61. package/dist/camera/shakeChannelMath.js +1 -0
  62. package/dist/commandSink.d.ts +8 -3
  63. package/dist/commandSink.js +8 -3
  64. package/dist/defineGame.d.ts +15 -1
  65. package/dist/defineGame.js +50 -9
  66. package/dist/devtools/ColPanel.d.ts +1 -0
  67. package/dist/devtools/ColPanel.js +14 -0
  68. package/dist/devtools/DevtoolsOverlay.d.ts +11 -7
  69. package/dist/devtools/DevtoolsOverlay.js +47 -462
  70. package/dist/devtools/KeysPanel.d.ts +4 -0
  71. package/dist/devtools/KeysPanel.js +31 -0
  72. package/dist/devtools/LogsPanel.d.ts +1 -0
  73. package/dist/devtools/LogsPanel.js +12 -0
  74. package/dist/devtools/NetPanel.d.ts +4 -0
  75. package/dist/devtools/NetPanel.js +10 -0
  76. package/dist/devtools/PerfPanel.d.ts +4 -0
  77. package/dist/devtools/PerfPanel.js +53 -0
  78. package/dist/devtools/TunePanel.d.ts +3 -0
  79. package/dist/devtools/TunePanel.js +309 -0
  80. package/dist/devtools/agentBridge.d.ts +41 -0
  81. package/dist/devtools/agentBridge.js +266 -0
  82. package/dist/devtools/collisionDebug.d.ts +6 -0
  83. package/dist/devtools/collisionDebug.js +6 -0
  84. package/dist/devtools/collisionDebugMath.d.ts +8 -2
  85. package/dist/devtools/collisionDebugMath.js +24 -5
  86. package/dist/devtools/devtoolsOverrides.d.ts +6 -0
  87. package/dist/devtools/devtoolsOverrides.js +48 -0
  88. package/dist/devtools/panelAtoms.d.ts +9 -0
  89. package/dist/devtools/panelAtoms.js +10 -0
  90. package/dist/devtools/perfDiagnose.d.ts +2 -0
  91. package/dist/devtools/perfDiagnose.js +43 -0
  92. package/dist/diagnostics/RuntimeDiagnostics.d.ts +36 -0
  93. package/dist/diagnostics/RuntimeDiagnostics.js +80 -0
  94. package/dist/drivers/FrameDriver.d.ts +37 -0
  95. package/dist/drivers/FrameDriver.js +232 -0
  96. package/dist/drivers/HudOnlyDriver.d.ts +16 -0
  97. package/dist/drivers/HudOnlyDriver.js +60 -0
  98. package/dist/environment/Daylight.d.ts +23 -7
  99. package/dist/environment/Daylight.js +106 -13
  100. package/dist/environment/EnvironmentScene.js +44 -9
  101. package/dist/environment/VolumetricClouds.d.ts +17 -0
  102. package/dist/environment/VolumetricClouds.js +202 -0
  103. package/dist/environment/daylightCycle.d.ts +3 -1
  104. package/dist/environment/daylightCycle.js +3 -1
  105. package/dist/environment/groundPadMath.d.ts +3 -0
  106. package/dist/environment/groundPadMath.js +3 -0
  107. package/dist/environment/index.d.ts +1 -0
  108. package/dist/environment/index.js +1 -0
  109. package/dist/environment/skyLightingPolicy.d.ts +2 -0
  110. package/dist/environment/skyLightingPolicy.js +2 -0
  111. package/dist/environment/terrainGroundColor.d.ts +9 -0
  112. package/dist/environment/terrainGroundColor.js +22 -0
  113. package/dist/gameKit.d.ts +27 -0
  114. package/dist/gameKit.js +29 -0
  115. package/dist/hotbarActions.d.ts +26 -0
  116. package/dist/hotbarActions.js +27 -0
  117. package/dist/inputSink.d.ts +16 -4
  118. package/dist/inputSink.js +57 -7
  119. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  120. package/dist/materialOverride.d.ts +10 -0
  121. package/dist/materialOverride.js +15 -3
  122. package/dist/multiplayer.js +8 -2
  123. package/dist/postfx/PostProcessing.d.ts +9 -1
  124. package/dist/postfx/PostProcessing.js +56 -11
  125. package/dist/postfx/gradeShader.js +9 -1
  126. package/dist/presentationResolve.d.ts +35 -0
  127. package/dist/presentationResolve.js +51 -0
  128. package/dist/registry.d.ts +4 -2
  129. package/dist/render/EnvironmentLighting.d.ts +10 -0
  130. package/dist/render/EnvironmentLighting.js +34 -0
  131. package/dist/render/SceneLighting.d.ts +7 -0
  132. package/dist/render/SceneLighting.js +18 -0
  133. package/dist/render/SceneModels.d.ts +14 -0
  134. package/dist/render/SceneModels.js +337 -0
  135. package/dist/render/assetBase.d.ts +17 -0
  136. package/dist/render/assetBase.js +35 -0
  137. package/dist/render/defaultSurface.d.ts +29 -0
  138. package/dist/render/defaultSurface.js +101 -0
  139. package/dist/render/modelLoad.d.ts +24 -0
  140. package/dist/render/modelLoad.js +76 -0
  141. package/dist/render/modelRender.d.ts +11 -0
  142. package/dist/render/modelRender.js +15 -0
  143. package/dist/render/resolveModel.d.ts +54 -3
  144. package/dist/render/resolveModel.js +116 -4
  145. package/dist/render/useEntityRenderCues.d.ts +13 -0
  146. package/dist/render/useEntityRenderCues.js +62 -0
  147. package/dist/scatter/InstancedScatter.d.ts +24 -0
  148. package/dist/scatter/InstancedScatter.js +135 -0
  149. package/dist/scatter/scatterModels.d.ts +25 -0
  150. package/dist/scatter/scatterModels.js +61 -0
  151. package/dist/scatter/scatterProxies.d.ts +6 -0
  152. package/dist/scatter/scatterProxies.js +118 -0
  153. package/dist/scatter.d.ts +1 -0
  154. package/dist/scatter.js +1 -0
  155. package/dist/scene/AuthoredScene.d.ts +84 -0
  156. package/dist/scene/AuthoredScene.js +178 -0
  157. package/dist/scene/CityRenderer.d.ts +2 -0
  158. package/dist/scene/CityRenderer.js +1031 -0
  159. package/dist/scene/GeneratedAssetRenderer.d.ts +14 -0
  160. package/dist/scene/GeneratedAssetRenderer.js +59 -0
  161. package/dist/scene/GrassFieldRenderer.d.ts +2 -0
  162. package/dist/scene/GrassFieldRenderer.js +73 -0
  163. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  164. package/dist/scene/PoleLineRenderer.js +287 -0
  165. package/dist/scene/SoilPatchRenderer.d.ts +2 -0
  166. package/dist/scene/SoilPatchRenderer.js +84 -0
  167. package/dist/scene/StudioStage.d.ts +37 -0
  168. package/dist/scene/StudioStage.js +42 -0
  169. package/dist/scene/WaterRenderer.d.ts +2 -0
  170. package/dist/scene/WaterRenderer.js +61 -0
  171. package/dist/scene/builtinSceneKindRenderers.d.ts +2 -0
  172. package/dist/scene/builtinSceneKindRenderers.js +26 -0
  173. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  174. package/dist/scene/cityWindowMaterial.js +81 -0
  175. package/dist/scene/sceneKindRenderers.d.ts +30 -0
  176. package/dist/scene/sceneKindRenderers.js +17 -0
  177. package/dist/scene.d.ts +1 -0
  178. package/dist/scene.js +1 -0
  179. package/dist/settings/appliedSettings.d.ts +7 -1
  180. package/dist/settings/appliedSettings.js +13 -2
  181. package/dist/settings/settingsController.d.ts +2 -0
  182. package/dist/settings/settingsController.js +22 -1
  183. package/dist/shellConstants.d.ts +4 -0
  184. package/dist/shellConstants.js +4 -0
  185. package/dist/shellMovement.d.ts +8 -0
  186. package/dist/shellMovement.js +11 -0
  187. package/dist/shellPointer.d.ts +20 -0
  188. package/dist/shellPointer.js +28 -0
  189. package/dist/structures/PlacementGhost.d.ts +4 -0
  190. package/dist/structures/PlacementGhost.js +4 -0
  191. package/dist/structures/TransformGizmo.d.ts +53 -0
  192. package/dist/structures/TransformGizmo.js +59 -0
  193. package/dist/structures/index.d.ts +1 -0
  194. package/dist/structures/index.js +1 -0
  195. package/dist/terrain/GrassField.d.ts +11 -3
  196. package/dist/terrain/GrassField.js +16 -6
  197. package/dist/terrain/TerraformBrushCursor.js +3 -0
  198. package/dist/terrain/grassBudget.d.ts +11 -2
  199. package/dist/terrain/grassBudget.js +20 -4
  200. package/dist/terrain/grassGeometry.d.ts +35 -0
  201. package/dist/terrain/grassGeometry.js +85 -20
  202. package/dist/terrain/grassMaterial.d.ts +20 -0
  203. package/dist/terrain/grassMaterial.js +68 -13
  204. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  205. package/dist/terrain/heightfieldGeometry.js +109 -0
  206. package/dist/terrain/index.d.ts +1 -0
  207. package/dist/terrain/index.js +1 -0
  208. package/dist/terrain/random.d.ts +3 -0
  209. package/dist/terrain/random.js +3 -0
  210. package/dist/terrain/soilPatchMaterial.d.ts +23 -0
  211. package/dist/terrain/soilPatchMaterial.js +147 -0
  212. package/dist/terrain/terrainDetailMaterial.d.ts +19 -2
  213. package/dist/terrain/terrainDetailMaterial.js +87 -7
  214. package/dist/terrain/terrainMath.d.ts +8 -1
  215. package/dist/terrain/terrainMath.js +8 -1
  216. package/dist/touch/TouchControlsOverlay.d.ts +9 -5
  217. package/dist/touch/TouchControlsOverlay.js +201 -22
  218. package/dist/useShellMultiplayerSync.d.ts +9 -0
  219. package/dist/useShellMultiplayerSync.js +98 -0
  220. package/dist/visibility/CullingProvider.js +1 -1
  221. package/dist/water/Ocean.d.ts +7 -1
  222. package/dist/water/Ocean.js +16 -3
  223. package/dist/water/OceanConfig.d.ts +11 -0
  224. package/dist/water/OceanConfig.js +8 -0
  225. package/dist/water/OceanMaterial.d.ts +12 -0
  226. package/dist/water/OceanMaterial.js +9 -0
  227. package/dist/water/OceanShader.d.ts +2 -2
  228. package/dist/water/OceanShader.js +78 -10
  229. package/dist/weather/fireSpreadPose.d.ts +1 -0
  230. package/dist/weather/fireSpreadPose.js +1 -0
  231. package/dist/weather/weatherGeometry.d.ts +1 -0
  232. package/dist/weather/weatherGeometry.js +1 -0
  233. package/dist/weather/weatherMath.d.ts +3 -0
  234. package/dist/weather/weatherMath.js +3 -0
  235. package/dist/weather/weatherUniforms.d.ts +3 -0
  236. package/dist/weather/weatherUniforms.js +3 -0
  237. package/dist/world/PlaceScene.d.ts +11 -0
  238. package/dist/world/PlaceScene.js +27 -0
  239. package/dist/world/RetainedVfx.d.ts +10 -0
  240. package/dist/world/RetainedVfx.js +136 -0
  241. package/dist/world/WorldHud.d.ts +49 -2
  242. package/dist/world/WorldHud.js +87 -2
  243. package/dist/world/WorldScene.d.ts +21 -0
  244. package/dist/world/WorldScene.js +186 -0
  245. package/dist/world/WorldVfx.d.ts +9 -0
  246. package/dist/world/WorldVfx.js +184 -0
  247. package/dist/world/entityPose.d.ts +2 -0
  248. package/dist/world/entityPose.js +2 -0
  249. package/dist/world/floatTextStyle.d.ts +1 -0
  250. package/dist/world/floatTextStyle.js +1 -0
  251. package/dist/world/telegraphPulse.d.ts +1 -0
  252. package/dist/world/telegraphPulse.js +1 -0
  253. package/dist/world/worldBarSamples.d.ts +25 -0
  254. package/dist/world/worldBarSamples.js +38 -0
  255. package/dist/worldSky.d.ts +11 -0
  256. package/dist/worldSky.js +21 -0
  257. package/dist/worldSync.d.ts +2 -1
  258. package/dist/worldSync.js +2 -1
  259. package/package.json +6 -6
  260. package/skills/game-design/SKILL.md +28 -0
  261. package/skills/game-design/agents/openai.yaml +4 -0
  262. package/skills/game-design/references/application-playbook.md +170 -0
  263. package/skills/game-design/references/field-guide.md +288 -0
  264. package/skills/jgengine/SKILL.md +54 -0
  265. package/skills/jgengine/api.md +527 -0
  266. package/skills/jgengine/capabilities.md +131 -0
  267. package/skills/jgengine/recipes/minimal-game.md +88 -0
  268. package/skills/jgengine-ui/SKILL.md +41 -0
  269. package/skills/jgengine-ui/api.md +2007 -0
  270. package/skills/jgengine-ui/capabilities.md +211 -0
  271. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  272. package/skills/jgengine-ui/reference.md +513 -0
  273. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  274. package/skills/jgengine-world/SKILL.md +51 -0
  275. package/skills/jgengine-world/api.md +3056 -0
  276. package/skills/jgengine-world/capabilities.md +476 -0
  277. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  278. package/skills/jgengine-world/reference.md +261 -0
  279. package/skills/level-design/SKILL.md +54 -0
  280. package/skills/level-design/agents/openai.yaml +4 -0
  281. package/skills/level-design/references/application-playbook.md +192 -0
  282. package/skills/level-design/references/field-guide.md +337 -0
  283. package/skills/level-design/references/live-editor-loop.md +179 -0
  284. package/dist/cartridge.d.ts +0 -145
  285. package/dist/cartridge.js +0 -246
  286. package/llms.txt +0 -2761
@@ -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, 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,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  const MODE_COLOR = {
3
3
  raise: "#38bdf8",
4
4
  lower: "#fb923c",
5
+ smooth: "#c084fc",
5
6
  flatten: "#a3e635",
7
+ noise: "#facc15",
8
+ ramp: "#2dd4bf",
6
9
  paint: "#e879f9",
7
10
  };
8
11
  export function TerraformBrushCursor({ center, y = 0.05, radius, mode }) {
@@ -1,3 +1,12 @@
1
+ import { type TerrainArea } from "./terrainMath.js";
1
2
  export declare const DEFAULT_GRASS_COUNT = 1500;
2
- export declare const DEFAULT_GRASS_DENSITY = 1;
3
- export declare function resolveGrassInstanceBudget(count: number, density: number, budget?: number): number;
3
+ /** Blades per square meter — matches `@jgengine/core/world/vegetation`'s `VEGETATION_DEFAULTS.density`, so an editor-placed grass volume and a bare `<GrassField>` agree on what "4" means. */
4
+ export declare const DEFAULT_GRASS_DENSITY = 4;
5
+ /**
6
+ * Blade instance count for a patch: `density` (blades/m²) times the patch's
7
+ * `area`, capped at `budget` (defaulting to `count`, the buffer's allocated
8
+ * capacity) so a big field never exceeds the perf ceiling — it just renders
9
+ * sparser than requested and logs a devtools warning when that happens.
10
+ * @internal
11
+ */
12
+ export declare function resolveGrassInstanceBudget(count: number, density: number, area: TerrainArea, budget?: number): number;
@@ -1,6 +1,22 @@
1
+ import { devtools } from "@jgengine/core/devtools/devtools";
2
+ import { resolveTerrainSize } from "./terrainMath.js";
1
3
  export const DEFAULT_GRASS_COUNT = 1500;
2
- export const DEFAULT_GRASS_DENSITY = 1;
3
- export function resolveGrassInstanceBudget(count, density, budget) {
4
- const capped = budget === undefined ? count : Math.min(count, Math.max(0, Math.floor(budget)));
5
- return Math.floor(Math.max(0, Math.min(1, density)) * capped);
4
+ /** Blades per square meter — matches `@jgengine/core/world/vegetation`'s `VEGETATION_DEFAULTS.density`, so an editor-placed grass volume and a bare `<GrassField>` agree on what "4" means. */
5
+ export const DEFAULT_GRASS_DENSITY = 4;
6
+ /**
7
+ * Blade instance count for a patch: `density` (blades/m²) times the patch's
8
+ * `area`, capped at `budget` (defaulting to `count`, the buffer's allocated
9
+ * capacity) so a big field never exceeds the perf ceiling — it just renders
10
+ * sparser than requested and logs a devtools warning when that happens.
11
+ * @internal
12
+ */
13
+ export function resolveGrassInstanceBudget(count, density, area, budget) {
14
+ const size = resolveTerrainSize(area);
15
+ const areaSqMeters = Math.max(0, size.width) * Math.max(0, size.depth);
16
+ const requested = Math.max(0, density) * areaSqMeters;
17
+ const cap = budget === undefined ? count : Math.min(count, Math.max(0, Math.floor(budget)));
18
+ if (requested > cap) {
19
+ devtools.logs.push("warn", `[jgengine:grass] density ${density} blades/m² over ${areaSqMeters.toFixed(0)}m² wants ${Math.floor(requested)} blades — clamped to budget ${cap}`);
20
+ }
21
+ return Math.floor(Math.min(Math.max(0, cap), requested));
6
22
  }
@@ -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,8 +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
  }
49
+ /** @internal */
25
50
  export declare function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number];
51
+ /** @internal */
26
52
  export declare function resolveGrassBladeGeometryOptions(options?: GrassBladeGeometryOptions): ResolvedGrassBladeGeometryOptions;
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
+ */
27
62
  export declare function createGrassBladeGeometry(options?: GrassBladeGeometryOptions): THREE.InstancedBufferGeometry;
@@ -1,11 +1,15 @@
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;
6
+ /** @internal */
4
7
  export function resolveGrassRange(value, fallback) {
5
8
  if (value === undefined)
6
9
  return fallback;
7
10
  return typeof value === "number" ? [value, value] : value;
8
11
  }
12
+ /** @internal */
9
13
  export function resolveGrassBladeGeometryOptions(options = {}) {
10
14
  return {
11
15
  count: Math.max(0, Math.floor(options.count ?? 1500)),
@@ -13,52 +17,113 @@ export function resolveGrassBladeGeometryOptions(options = {}) {
13
17
  seed: options.seed ?? 1,
14
18
  segments: Math.max(1, Math.floor(options.segments ?? 4)),
15
19
  height: resolveGrassRange(options.height, [0.55, 1.1]),
16
- width: resolveGrassRange(options.width, [0.035, 0.075]),
17
- 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 ?? [],
18
26
  heightAt: options.heightAt ?? (() => 0),
19
27
  };
20
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
+ }
21
33
  function randomRange(random, range) {
22
34
  return THREE.MathUtils.lerp(range[0], range[1], random());
23
35
  }
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
+ */
24
43
  export function createGrassBladeGeometry(options = {}) {
25
44
  const resolved = resolveGrassBladeGeometryOptions(options);
26
45
  const size = resolveTerrainSize(resolved.area);
27
46
  const positions = [];
28
47
  const normals = [];
29
48
  const uvs = [];
49
+ const bladeOffsets = [];
50
+ const bladeVary = [];
51
+ const bladeBendScale = [];
30
52
  const indices = [];
31
- for (let segment = 0; segment <= resolved.segments; segment += 1) {
32
- const t = segment / resolved.segments;
33
- positions.push(-0.5, t, 0, 0.5, t, 0);
34
- normals.push(0, 0, 1, 0, 0, 1);
35
- uvs.push(0, t, 1, t);
36
- if (segment < resolved.segments) {
37
- const base = segment * 2;
38
- 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
+ }
39
82
  }
40
83
  }
41
84
  const geometry = new THREE.InstancedBufferGeometry();
42
85
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
43
86
  geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
44
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));
45
91
  geometry.setIndex(indices);
92
+ const tuftCount = grassTuftCount(resolved.count, resolved.tuftBlades);
46
93
  const random = createSeededRandom(resolved.seed);
47
- const offsets = new Float32Array(resolved.count * 3);
48
- const yaw = new Float32Array(resolved.count);
49
- const heights = new Float32Array(resolved.count);
50
- const widths = new Float32Array(resolved.count);
51
- const bend = new Float32Array(resolved.count);
52
- const phase = new Float32Array(resolved.count);
53
- const colorMix = new Float32Array(resolved.count);
54
- 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) {
55
104
  const x = (random() - 0.5) * size.width;
56
105
  const z = (random() - 0.5) * size.depth;
57
106
  offsets[index * 3] = x;
58
107
  offsets[index * 3 + 1] = resolved.heightAt(x, z);
59
108
  offsets[index * 3 + 2] = z;
60
109
  yaw[index] = random() * Math.PI * 2;
61
- 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;
62
127
  widths[index] = randomRange(random, resolved.width);
63
128
  bend[index] = randomRange(random, resolved.bend);
64
129
  phase[index] = random() * Math.PI * 2;
@@ -71,7 +136,7 @@ export function createGrassBladeGeometry(options = {}) {
71
136
  geometry.setAttribute("instanceBend", new THREE.InstancedBufferAttribute(bend, 1));
72
137
  geometry.setAttribute("instancePhase", new THREE.InstancedBufferAttribute(phase, 1));
73
138
  geometry.setAttribute("instanceColorMix", new THREE.InstancedBufferAttribute(colorMix, 1));
74
- geometry.instanceCount = resolved.count;
139
+ geometry.instanceCount = tuftCount;
75
140
  geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), Math.max(size.width, size.depth) * 0.75 + resolved.height[1]);
76
141
  return geometry;
77
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,12 +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>;
48
+ /** @internal */
32
49
  export declare function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>;
50
+ /** @internal */
51
+ export declare function resolveGrassDistanceFade(fade: GrassDistanceFadeOptions | false | undefined): Required<GrassDistanceFadeOptions>;
52
+ /** @internal */
33
53
  export declare function createGrassMaterial(options?: GrassMaterialOptions): GrassMaterialHandle;
@@ -6,10 +6,16 @@ export const DEFAULT_GRASS_WIND = {
6
6
  gustScale: 0.16,
7
7
  flutter: 0.08,
8
8
  };
9
+ /** Default camera-distance fade band: tufts start thinning at 35 m and are gone by 95 m. */
10
+ export const DEFAULT_GRASS_DISTANCE_FADE = {
11
+ start: 35,
12
+ end: 95,
13
+ };
9
14
  function normalizeWindDirection(direction) {
10
15
  const vector = new THREE.Vector2(direction[0], direction[1]);
11
16
  return vector.lengthSq() === 0 ? new THREE.Vector2(1, 0) : vector.normalize();
12
17
  }
18
+ /** @internal */
13
19
  export function resolveGrassWind(wind) {
14
20
  if (wind === false)
15
21
  return { ...DEFAULT_GRASS_WIND, strength: 0, flutter: 0 };
@@ -21,8 +27,24 @@ export function resolveGrassWind(wind) {
21
27
  flutter: wind?.flutter ?? DEFAULT_GRASS_WIND.flutter,
22
28
  };
23
29
  }
30
+ /** @internal */
31
+ export function resolveGrassDistanceFade(fade) {
32
+ // start >= end disables the fade in the shader, so "off" is just an empty band.
33
+ if (fade === false)
34
+ return { start: 0, end: 0 };
35
+ return {
36
+ start: fade?.start ?? DEFAULT_GRASS_DISTANCE_FADE.start,
37
+ end: fade?.end ?? DEFAULT_GRASS_DISTANCE_FADE.end,
38
+ };
39
+ }
40
+ /** @internal */
24
41
  export function createGrassMaterial(options = {}) {
25
42
  const wind = resolveGrassWind(options.wind);
43
+ const fade = resolveGrassDistanceFade(options.distanceFade);
44
+ const colorBase = new THREE.Color(options.colorBase ?? "#2d431f");
45
+ const colorGround = options.colorGround === undefined
46
+ ? colorBase.clone().multiplyScalar(0.82)
47
+ : new THREE.Color(options.colorGround);
26
48
  const uniforms = {
27
49
  uTime: { value: 0 },
28
50
  uWindDirection: { value: normalizeWindDirection(wind.direction) },
@@ -30,9 +52,12 @@ export function createGrassMaterial(options = {}) {
30
52
  uWindSpeed: { value: wind.speed },
31
53
  uWindGustScale: { value: wind.gustScale },
32
54
  uWindFlutter: { value: wind.flutter },
33
- uColorBase: { value: new THREE.Color(options.colorBase ?? "#2d431f") },
55
+ uColorBase: { value: colorBase },
34
56
  uColorTip: { value: new THREE.Color(options.colorTip ?? "#a6cc58") },
57
+ uColorGround: { value: colorGround },
35
58
  uColorVariation: { value: options.colorVariation ?? 0.28 },
59
+ uDistanceFade: { value: new THREE.Vector2(fade.start, fade.end) },
60
+ uNormalLift: { value: THREE.MathUtils.clamp(options.normalLift ?? 0.6, 0, 1) },
36
61
  };
37
62
  const material = new THREE.MeshStandardMaterial({
38
63
  color: "#ffffff",
@@ -52,36 +77,61 @@ attribute float instanceWidth;
52
77
  attribute float instanceBend;
53
78
  attribute float instancePhase;
54
79
  attribute float instanceColorMix;
80
+ attribute vec2 bladeOffset;
81
+ attribute vec4 bladeVary;
82
+ attribute float bladeBendScale;
55
83
  uniform float uTime;
56
84
  uniform vec2 uWindDirection;
57
85
  uniform float uWindStrength;
58
86
  uniform float uWindSpeed;
59
87
  uniform float uWindGustScale;
60
88
  uniform float uWindFlutter;
89
+ uniform vec2 uDistanceFade;
90
+ uniform float uNormalLift;
61
91
  varying float vGrassBladeT;
62
92
  varying float vGrassColorMix;
93
+ varying float vGrassFade;
63
94
  vec3 jgGrassPosition;
64
95
  vec3 jgGrassNormal;
65
96
  void jgComputeGrassBlade() {
66
97
  float t = position.y;
67
98
  float side = position.x;
68
- float taper = (1.0 - t) * (0.72 + 0.28 * (1.0 - t));
69
- float width = instanceWidth * taper;
70
- float arc = instanceBend * t * t;
71
- vec3 localPosition = vec3(side * width, instanceHeight * t - arc * instanceHeight * 0.18, arc * instanceHeight);
72
- vec3 localNormal = normalize(vec3(0.0, -instanceBend * t, 1.0));
73
- float cy = cos(instanceYaw);
74
- float sy = sin(instanceYaw);
99
+ // Camera-distance thinning: each tuft draws a random threshold, so the field dissolves
100
+ // progressively across the fade band instead of ending at a hard curtain.
101
+ float camDist = distance((modelMatrix * vec4(instanceOffset, 1.0)).xyz, cameraPosition);
102
+ float fadeT = uDistanceFade.y > uDistanceFade.x
103
+ ? smoothstep(uDistanceFade.x, uDistanceFade.y, camDist)
104
+ : 0.0;
105
+ float threshold = fract(instanceColorMix * 61.7) * 0.85;
106
+ float keep = 1.0 - smoothstep(threshold, threshold + 0.15, fadeT);
107
+ float heightScale = instanceHeight * bladeVary.y * keep;
108
+ // Paddle taper: hold width through the mid-blade and only pinch near the tip — thin
109
+ // triangle blades leave the ground visible between tufts and read as stubble.
110
+ float taper = (1.0 - 0.3 * t) * (1.0 - smoothstep(0.5, 1.0, t) * 0.95);
111
+ // Surviving distant tufts widen to hold silhouette coverage as neighbors thin out.
112
+ float width = instanceWidth * bladeVary.z * taper * (1.0 + fadeT * 1.4);
113
+ float arc = instanceBend * bladeBendScale * t * t;
114
+ vec3 localPosition = vec3(side * width, heightScale * t - arc * heightScale * 0.18, arc * heightScale);
115
+ vec3 localNormal = normalize(vec3(0.0, -instanceBend * bladeBendScale * t, 1.0));
116
+ float bladeYaw = instanceYaw + bladeVary.x;
117
+ float cy = cos(bladeYaw);
118
+ float sy = sin(bladeYaw);
75
119
  vec3 yawedPosition = vec3(localPosition.x * cy + localPosition.z * sy, localPosition.y, -localPosition.x * sy + localPosition.z * cy);
76
120
  vec3 yawedNormal = normalize(vec3(localNormal.x * cy + localNormal.z * sy, localNormal.y, -localNormal.x * sy + localNormal.z * cy));
121
+ float ct = cos(instanceYaw);
122
+ float st = sin(instanceYaw);
123
+ vec3 tuftOffset = vec3(bladeOffset.x * ct + bladeOffset.y * st, 0.0, -bladeOffset.x * st + bladeOffset.y * ct);
77
124
  float gustPhase = dot(instanceOffset.xz, uWindDirection) * uWindGustScale + uTime * uWindSpeed + instancePhase;
78
125
  float gust = sin(gustPhase) * 0.7 + sin(gustPhase * 0.43 + 2.4) * 0.3;
79
- float flutter = sin(uTime * 7.5 + instancePhase * 2.7) * uWindFlutter;
80
- vec2 windOffset = uWindDirection * (gust * uWindStrength + flutter) * t * t;
81
- jgGrassPosition = instanceOffset + yawedPosition + vec3(windOffset.x, 0.0, windOffset.y);
82
- jgGrassNormal = yawedNormal;
126
+ float flutter = sin(uTime * 7.5 + instancePhase * 2.7 + bladeVary.w * 6.2832) * uWindFlutter;
127
+ vec2 windOffset = uWindDirection * (gust * uWindStrength + flutter) * t * t * (0.4 + 0.6 * bladeVary.y);
128
+ jgGrassPosition = instanceOffset + tuftOffset + yawedPosition + vec3(windOffset.x, 0.0, windOffset.y);
129
+ // Lifting normals toward straight-up lights the field like a continuous meadow surface —
130
+ // side-lit individual blade planes read as dark stubble, not turf.
131
+ jgGrassNormal = normalize(mix(yawedNormal, vec3(0.0, 1.0, 0.0), uNormalLift));
83
132
  vGrassBladeT = t;
84
133
  vGrassColorMix = instanceColorMix;
134
+ vGrassFade = fadeT;
85
135
  }
86
136
  `)
87
137
  .replace("#include <beginnormal_vertex>", "jgComputeGrassBlade();\nvec3 objectNormal = jgGrassNormal;")
@@ -91,15 +141,20 @@ void jgComputeGrassBlade() {
91
141
  #include <common>
92
142
  uniform vec3 uColorBase;
93
143
  uniform vec3 uColorTip;
144
+ uniform vec3 uColorGround;
94
145
  uniform float uColorVariation;
95
146
  varying float vGrassBladeT;
96
147
  varying float vGrassColorMix;
148
+ varying float vGrassFade;
97
149
  `)
98
150
  .replace("#include <color_fragment>", `
99
151
  #include <color_fragment>
100
152
  vec3 grassColor = mix(uColorBase, uColorTip, smoothstep(0.0, 1.0, vGrassBladeT));
101
153
  grassColor *= mix(1.0 - uColorVariation, 1.0 + uColorVariation, vGrassColorMix);
102
- grassColor *= mix(0.58, 1.0, smoothstep(0.05, 0.45, vGrassBladeT));
154
+ // Roots take the ground color so blades grow out of the terrain instead of floating on it,
155
+ // and distant blades converge back toward it so the fade band blends into the mottled ground.
156
+ grassColor = mix(uColorGround, grassColor, smoothstep(0.0, 0.55, vGrassBladeT));
157
+ grassColor = mix(grassColor, uColorGround * 1.04, vGrassFade * 0.6);
103
158
  diffuseColor.rgb = grassColor;
104
159
  `);
105
160
  };
@@ -0,0 +1,34 @@
1
+ import * as THREE from "three";
2
+ /** XZ rectangle in world units — matches the shape of a terraform snapshot's `bounds`. */
3
+ export interface HeightfieldRect {
4
+ minX: number;
5
+ minZ: number;
6
+ maxX: number;
7
+ maxZ: number;
8
+ }
9
+ /** Per-vertex color hook for {@link displaceHeightfieldGeometry}; write the tone into `out`. */
10
+ export type HeightfieldColorFn = (x: number, z: number, height: number, out: THREE.Color) => void;
11
+ /** Options for {@link displaceHeightfieldGeometry}. */
12
+ export interface HeightfieldDisplaceOptions {
13
+ /** World-space rect the plane spans (the plane is centered on it, XZ, +Y up). */
14
+ bounds: HeightfieldRect;
15
+ /**
16
+ * Dirty world-space region to re-sample; only the vertex window covering it (plus a one-vertex
17
+ * normal ring) updates. Omit or pass null for a full rebuild.
18
+ */
19
+ region?: HeightfieldRect | null;
20
+ /** When given, writes a `color` attribute (creating it on demand) via this per-vertex hook. */
21
+ color?: HeightfieldColorFn;
22
+ }
23
+ /**
24
+ * Re-samples a `PlaneGeometry(width, depth, segments, segments).rotateX(-π/2)` heightfield mesh
25
+ * from a `sampleHeight` field in place: vertex Y, optional vertex color, grid central-difference
26
+ * normals, and an analytically maintained bounding sphere. With a dirty `region`, the work is
27
+ * O(region vertices) — heights/colors update inside the covering vertex window, normals inside the
28
+ * window plus a one-vertex ring — never a whole-mesh `computeVertexNormals`/`computeBoundingSphere`
29
+ * pass, which is what keeps per-frame brush stamps inside the editor's frame budget. The bounding
30
+ * sphere derives from the fixed plane extents plus a running height range kept on
31
+ * `geometry.userData`; partial passes only expand it, a full pass resets it exactly.
32
+ * @capability heightfield-mesh-update in-place partial update of a displaced ground plane mesh
33
+ */
34
+ export declare function displaceHeightfieldGeometry(geometry: THREE.BufferGeometry, sampleHeight: (x: number, z: number) => number, options: HeightfieldDisplaceOptions): void;