@jgengine/shell 0.25.0 → 0.27.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 (74) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/dist/GamePlayerShell.d.ts +8 -0
  3. package/dist/GamePlayerShell.js +15 -1
  4. package/dist/Shell3dPresentation.js +29 -11
  5. package/dist/devtools/FrameTelemetry.d.ts +12 -0
  6. package/dist/devtools/FrameTelemetry.js +68 -0
  7. package/dist/devtools/agentBridge.js +2 -0
  8. package/dist/devtools/devtoolsReports.d.ts +2 -0
  9. package/dist/devtools/devtoolsReports.js +3 -0
  10. package/dist/devtools/frameSampler.d.ts +127 -0
  11. package/dist/devtools/frameSampler.js +345 -0
  12. package/dist/environment/AuthoredDaylight.d.ts +28 -0
  13. package/dist/environment/AuthoredDaylight.js +42 -0
  14. package/dist/environment/Daylight.d.ts +55 -7
  15. package/dist/environment/Daylight.js +126 -29
  16. package/dist/environment/EnvironmentScene.js +51 -8
  17. package/dist/environment/daylightCycle.d.ts +26 -4
  18. package/dist/environment/daylightCycle.js +36 -5
  19. package/dist/postfx/PostProcessing.d.ts +7 -1
  20. package/dist/postfx/PostProcessing.js +28 -2
  21. package/dist/postfx/autoExposure.d.ts +49 -0
  22. package/dist/postfx/autoExposure.js +189 -0
  23. package/dist/postfx/gradeShader.js +43 -4
  24. package/dist/presentationRecovery3d.js +2 -1
  25. package/dist/render/CascadedShadows.d.ts +3 -1
  26. package/dist/render/CascadedShadows.js +9 -1
  27. package/dist/render/EnvironmentLighting.d.ts +10 -1
  28. package/dist/render/EnvironmentLighting.js +11 -3
  29. package/dist/render/IrradianceVolumeLighting.d.ts +18 -0
  30. package/dist/render/IrradianceVolumeLighting.js +35 -0
  31. package/dist/render/SceneLighting.d.ts +14 -1
  32. package/dist/render/SceneLighting.js +29 -8
  33. package/dist/render/authoredGrade.d.ts +11 -0
  34. package/dist/render/authoredGrade.js +26 -0
  35. package/dist/render/decalGeometry.d.ts +26 -0
  36. package/dist/render/decalGeometry.js +54 -0
  37. package/dist/render/decalTexture.d.ts +13 -0
  38. package/dist/render/decalTexture.js +114 -0
  39. package/dist/render/modelWind.d.ts +28 -0
  40. package/dist/render/modelWind.js +103 -0
  41. package/dist/render/presentationLook.d.ts +15 -0
  42. package/dist/render/presentationLook.js +18 -0
  43. package/dist/render/stableTextureSet.d.ts +9 -0
  44. package/dist/render/stableTextureSet.js +24 -0
  45. package/dist/render/triplanarMaterial.d.ts +52 -0
  46. package/dist/render/triplanarMaterial.js +169 -0
  47. package/dist/render/useModelInstance.d.ts +1 -1
  48. package/dist/render/useModelInstance.js +3 -1
  49. package/dist/scatter/scatterModels.js +2 -0
  50. package/dist/scene/DecalRenderer.d.ts +2 -0
  51. package/dist/scene/DecalRenderer.js +74 -0
  52. package/dist/scene/GrassFieldRenderer.js +3 -2
  53. package/dist/scene/builtinSceneKindRenderers.js +2 -0
  54. package/dist/settings/appliedSettings.js +16 -13
  55. package/dist/terrain/GrassCover.d.ts +21 -0
  56. package/dist/terrain/GrassCover.js +84 -0
  57. package/dist/terrain/grassCoverGeometry.d.ts +40 -0
  58. package/dist/terrain/grassCoverGeometry.js +112 -0
  59. package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
  60. package/dist/terrain/terrainDetailMaterial.js +54 -1
  61. package/dist/terrain/terrainSurfaces.d.ts +6 -0
  62. package/dist/terrain/terrainSurfaces.js +10 -0
  63. package/dist/world/WorldScene.js +2 -1
  64. package/package.json +4 -4
  65. package/skills/jgengine/api.md +59 -6
  66. package/skills/jgengine/capabilities.md +4 -0
  67. package/skills/jgengine-ui/api.md +114 -23
  68. package/skills/jgengine-ui/capabilities.md +14 -0
  69. package/skills/jgengine-ui/reference-primitives.md +4 -2
  70. package/skills/jgengine-world/SKILL.md +7 -1
  71. package/skills/jgengine-world/api.md +222 -8
  72. package/skills/jgengine-world/capabilities.md +42 -2
  73. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
  74. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useLayoutEffect, useRef } from "react";
3
+ import { useFrame } from "@react-three/fiber";
3
4
  import { CascadedShadows, DEFAULT_SHADOW_RADIUS } from "./CascadedShadows.js";
4
5
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
5
6
  /** Default cap for combined dynamic point and spot lights. */
@@ -26,12 +27,12 @@ function resolveDynamicLights(lighting) {
26
27
  function usesCascades(entry) {
27
28
  return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
28
29
  }
29
- function DirectionalShadowLight({ entry, profile }) {
30
+ function DirectionalShadowLight({ entry, profile, modulation }) {
30
31
  const resolved = profile === undefined ? entry : { ...entry, shadowMapSize: entry.shadowMapSize ?? profile.shadowMapSize, cascades: entry.cascades ?? profile.cascades };
31
32
  if (usesCascades(resolved)) {
32
- return _jsx(CascadedShadows, { entry: resolved });
33
+ return _jsx(CascadedShadows, { entry: resolved, modulation: modulation });
33
34
  }
34
- return _jsx(SingleShadowLight, { entry: resolved });
35
+ return _jsx(SingleShadowLight, { entry: resolved, modulation: modulation });
35
36
  }
36
37
  /**
37
38
  * Refresh a directional light's shadow projection after its frustum bounds change.
@@ -42,11 +43,19 @@ function DirectionalShadowLight({ entry, profile }) {
42
43
  export function syncShadowProjection(light) {
43
44
  light.shadow.camera.updateProjectionMatrix();
44
45
  }
45
- function SingleShadowLight({ entry }) {
46
+ function SingleShadowLight({ entry, modulation }) {
46
47
  const lightRef = useRef(null);
47
48
  const size = entry.shadowCameraSize ?? 40;
48
49
  const far = Math.max(200, size * 6);
49
50
  const mapSize = entry.shadowMapSize ?? 1024;
51
+ const baseIntensity = entry.intensity ?? 1.3;
52
+ useFrame(() => {
53
+ const light = lightRef.current;
54
+ if (light === null || modulation === undefined)
55
+ return;
56
+ light.intensity = baseIntensity * modulation.sunFactor;
57
+ light.color.set(modulation.sunColor);
58
+ });
50
59
  useLayoutEffect(() => {
51
60
  if (lightRef.current !== null)
52
61
  syncShadowProjection(lightRef.current);
@@ -60,13 +69,25 @@ function SingleShadowLight({ entry }) {
60
69
  }, [mapSize]);
61
70
  return (_jsx("directionalLight", { ref: lightRef, position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": mapSize, "shadow-mapSize-height": mapSize, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": far, "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02, "shadow-radius": entry.shadowRadius ?? DEFAULT_SHADOW_RADIUS }));
62
71
  }
63
- export function ConfiguredLighting({ lighting, profile }) {
72
+ export function ConfiguredLighting({ lighting, profile, modulation, }) {
64
73
  const dynamic = resolveDynamicLights(lighting);
65
- return (_jsxs(_Fragment, { children: [lighting.ambient !== undefined ? (_jsx("ambientLight", { color: lighting.ambient.color, intensity: lighting.ambient.intensity ?? 0.55 })) : null, lighting.hemisphere !== undefined ? (_jsx("hemisphereLight", { args: [
74
+ const ambientRef = useRef(null);
75
+ const hemisphereRef = useRef(null);
76
+ const baseAmbient = lighting.ambient?.intensity ?? 0.55;
77
+ const baseHemisphere = lighting.hemisphere?.intensity ?? 0.55;
78
+ useFrame(() => {
79
+ if (modulation === undefined)
80
+ return;
81
+ if (ambientRef.current !== null)
82
+ ambientRef.current.intensity = baseAmbient * modulation.ambientFactor;
83
+ if (hemisphereRef.current !== null)
84
+ hemisphereRef.current.intensity = baseHemisphere * modulation.ambientFactor;
85
+ });
86
+ return (_jsxs(_Fragment, { children: [lighting.ambient !== undefined ? (_jsx("ambientLight", { ref: ambientRef, color: lighting.ambient.color, intensity: baseAmbient })) : null, lighting.hemisphere !== undefined ? (_jsx("hemisphereLight", { ref: hemisphereRef, args: [
66
87
  lighting.hemisphere.skyColor ?? "#bfe3ff",
67
88
  lighting.hemisphere.groundColor ?? "#4c6b34",
68
- lighting.hemisphere.intensity ?? 0.55,
69
- ] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry, profile: profile }, index))), dynamic.point.map((entry, index) => (_jsx("pointLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, castShadow: entry.castShadow ?? false }, index))), dynamic.spot.map((entry, index) => (_jsx("spotLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, angle: entry.angle ?? Math.PI / 6, penumbra: entry.penumbra ?? 0, castShadow: entry.castShadow ?? false, "target-position": [...(entry.target ?? [0, 0, 0])] }, index)))] }));
89
+ baseHemisphere,
90
+ ] })) : null, (lighting.directional ?? []).map((entry, index) => (_jsx(DirectionalShadowLight, { entry: entry, profile: profile, ...(index === 0 && modulation !== undefined ? { modulation } : {}) }, index))), dynamic.point.map((entry, index) => (_jsx("pointLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, castShadow: entry.castShadow ?? false }, index))), dynamic.spot.map((entry, index) => (_jsx("spotLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1, color: entry.color, distance: entry.distance ?? 0, decay: entry.decay ?? 2, angle: entry.angle ?? Math.PI / 6, penumbra: entry.penumbra ?? 0, castShadow: entry.castShadow ?? false, "target-position": [...(entry.target ?? [0, 0, 0])] }, index)))] }));
70
91
  }
71
92
  export function BackdropFog({ fog }) {
72
93
  if (fog === undefined)
@@ -0,0 +1,11 @@
1
+ import type { DocumentColorGrade } from "@jgengine/core/editor/environment";
2
+ import type { ResolvedGameLook } from "@jgengine/core/render/lookPreset";
3
+ /**
4
+ * Folds a scene document's authored grade into the resolved look. The document is the authoring
5
+ * source, so its baked LUT and exposure range win over the game's static post config, and everything
6
+ * the document left unauthored stays exactly as the game or look preset set it. With no authored
7
+ * grade the look comes back untouched (same identity), so an ungraded game mounts the pipeline it
8
+ * always mounted.
9
+ * @internal
10
+ */
11
+ export declare function withAuthoredGrade(look: ResolvedGameLook, grade: DocumentColorGrade | undefined): ResolvedGameLook;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Folds a scene document's authored grade into the resolved look. The document is the authoring
3
+ * source, so its baked LUT and exposure range win over the game's static post config, and everything
4
+ * the document left unauthored stays exactly as the game or look preset set it. With no authored
5
+ * grade the look comes back untouched (same identity), so an ungraded game mounts the pipeline it
6
+ * always mounted.
7
+ * @internal
8
+ */
9
+ export function withAuthoredGrade(look, grade) {
10
+ if (grade === undefined)
11
+ return look;
12
+ const post = look.postProcessing ?? { toneMapping: "agx", bloom: false };
13
+ const graded = grade.lut === undefined
14
+ ? post
15
+ : { ...post, grade: { ...(typeof post.grade === "object" ? post.grade : {}), lut: { cube: grade.lut } } };
16
+ if (grade.autoExposure === undefined) {
17
+ return { ...look, postProcessing: graded };
18
+ }
19
+ return {
20
+ ...look,
21
+ postProcessing: {
22
+ ...graded,
23
+ autoExposure: { ...(typeof graded.autoExposure === "object" ? graded.autoExposure : {}), ...grade.autoExposure },
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,26 @@
1
+ import type { TerrainHeightSampler } from "../terrain/terrainMath.js";
2
+ /** Inputs for {@link createDecalDrapeGeometry}. */
3
+ export interface DecalDrapeOptions {
4
+ /** Decal edge length in metres. */
5
+ size: number;
6
+ rotationY: number;
7
+ heightAt: TerrainHeightSampler;
8
+ /** Height above the sampled ground, metres. */
9
+ lift: number;
10
+ /** Grid resolution per axis; defaults to 12 (bounded, decals are small). */
11
+ segments?: number;
12
+ }
13
+ /** Baked vertex/index buffers for one draped ground decal. */
14
+ export interface DecalDrapeGeometry {
15
+ positions: Float32Array;
16
+ normals: Float32Array;
17
+ uvs: Float32Array;
18
+ indices: Uint32Array;
19
+ }
20
+ /**
21
+ * A ground decal's draped quad: an `size`² patch rotated by `rotationY`, every vertex lifted onto
22
+ * the live terrain so a stain follows a curb or a camber instead of floating over it or sinking
23
+ * through it. Bounded to one small grid — decals are metres, not patches.
24
+ * @internal
25
+ */
26
+ export declare function createDecalDrapeGeometry(options: DecalDrapeOptions): DecalDrapeGeometry;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A ground decal's draped quad: an `size`² patch rotated by `rotationY`, every vertex lifted onto
3
+ * the live terrain so a stain follows a curb or a camber instead of floating over it or sinking
4
+ * through it. Bounded to one small grid — decals are metres, not patches.
5
+ * @internal
6
+ */
7
+ export function createDecalDrapeGeometry(options) {
8
+ const segments = Math.max(2, Math.min(24, Math.floor(options.segments ?? 12)));
9
+ const half = options.size / 2;
10
+ const cos = Math.cos(options.rotationY);
11
+ const sin = Math.sin(options.rotationY);
12
+ const cols = segments + 1;
13
+ const positions = new Float32Array(cols * cols * 3);
14
+ const normals = new Float32Array(cols * cols * 3);
15
+ const uvs = new Float32Array(cols * cols * 2);
16
+ const eps = Math.max(0.05, options.size / segments / 2);
17
+ for (let iz = 0; iz < cols; iz += 1) {
18
+ for (let ix = 0; ix < cols; ix += 1) {
19
+ const lx = -half + (ix / segments) * options.size;
20
+ const lz = -half + (iz / segments) * options.size;
21
+ const x = lx * cos + lz * sin;
22
+ const z = -lx * sin + lz * cos;
23
+ const index = iz * cols + ix;
24
+ positions[index * 3] = x;
25
+ positions[index * 3 + 1] = options.heightAt(x, z) + options.lift;
26
+ positions[index * 3 + 2] = z;
27
+ const hx = options.heightAt(x + eps, z) - options.heightAt(x - eps, z);
28
+ const hz = options.heightAt(x, z + eps) - options.heightAt(x, z - eps);
29
+ const length = Math.hypot(hx, 2 * eps, hz) || 1;
30
+ normals[index * 3] = -hx / length;
31
+ normals[index * 3 + 1] = (2 * eps) / length;
32
+ normals[index * 3 + 2] = -hz / length;
33
+ uvs[index * 2] = ix / segments;
34
+ uvs[index * 2 + 1] = 1 - iz / segments;
35
+ }
36
+ }
37
+ const indices = new Uint32Array(segments * segments * 6);
38
+ let cursor = 0;
39
+ for (let iz = 0; iz < segments; iz += 1) {
40
+ for (let ix = 0; ix < segments; ix += 1) {
41
+ const a = iz * cols + ix;
42
+ const b = a + 1;
43
+ const c = a + cols;
44
+ const d = c + 1;
45
+ indices[cursor++] = a;
46
+ indices[cursor++] = c;
47
+ indices[cursor++] = b;
48
+ indices[cursor++] = b;
49
+ indices[cursor++] = c;
50
+ indices[cursor++] = d;
51
+ }
52
+ }
53
+ return { positions, normals, uvs, indices };
54
+ }
@@ -0,0 +1,13 @@
1
+ import type { DecalStyle } from "@jgengine/core/world/decalKind";
2
+ /** Row-major RGBA bytes plus their edge length, ready for a `THREE.DataTexture`. */
3
+ export interface DecalTextureData {
4
+ size: number;
5
+ data: Uint8Array;
6
+ }
7
+ /**
8
+ * The deterministic procedural texture behind one decal style. Same style+seed always bakes the
9
+ * same bytes; the renderer uploads it once as a `DataTexture`, so no scan is pulled and no game
10
+ * ships stain art it cannot inspect. Alpha is the coverage; colour is the stain itself.
11
+ * @internal
12
+ */
13
+ export declare function createDecalTexture(style: DecalStyle, seed: string, size?: number): DecalTextureData;
@@ -0,0 +1,114 @@
1
+ function hash(x, y, seed) {
2
+ let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);
3
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
4
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
5
+ }
6
+ function smooth(t) {
7
+ return t * t * (3 - 2 * t);
8
+ }
9
+ function noise(x, y, seed) {
10
+ const xi = Math.floor(x);
11
+ const yi = Math.floor(y);
12
+ const tx = smooth(x - xi);
13
+ const ty = smooth(y - yi);
14
+ const a = hash(xi, yi, seed);
15
+ const b = hash(xi + 1, yi, seed);
16
+ const c = hash(xi, yi + 1, seed);
17
+ const d = hash(xi + 1, yi + 1, seed);
18
+ return (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
19
+ }
20
+ function fbm(x, y, seed) {
21
+ return noise(x, y, seed) * 0.62 + noise(x * 2.1 + 7.3, y * 2.1 - 3.1, seed + 11) * 0.26 + noise(x * 4.3 - 1.7, y * 4.3 + 5.9, seed + 23) * 0.12;
22
+ }
23
+ function seedOf(text) {
24
+ let h = 2166136261;
25
+ for (let i = 0; i < text.length; i += 1) {
26
+ h ^= text.charCodeAt(i);
27
+ h = Math.imul(h, 16777619);
28
+ }
29
+ return h >>> 0;
30
+ }
31
+ function smoothstep(edge0, edge1, x) {
32
+ const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0 || 1e-6)));
33
+ return t * t * (3 - 2 * t);
34
+ }
35
+ /**
36
+ * The deterministic procedural texture behind one decal style. Same style+seed always bakes the
37
+ * same bytes; the renderer uploads it once as a `DataTexture`, so no scan is pulled and no game
38
+ * ships stain art it cannot inspect. Alpha is the coverage; colour is the stain itself.
39
+ * @internal
40
+ */
41
+ export function createDecalTexture(style, seed, size = 128) {
42
+ const seedNumber = seedOf(seed.length > 0 ? seed : style);
43
+ const data = new Uint8Array(size * size * 4);
44
+ for (let y = 0; y < size; y += 1) {
45
+ for (let x = 0; x < size; x += 1) {
46
+ const u = x / size - 0.5;
47
+ const v = y / size - 0.5;
48
+ const r = Math.hypot(u, v) * 2;
49
+ const n = fbm(u * 5 + 13.1, v * 5 - 7.7, seedNumber);
50
+ let alpha = 0;
51
+ let red = 0;
52
+ let green = 0;
53
+ let blue = 0;
54
+ if (style === "dirt") {
55
+ const grain = fbm(u * 17 + 3.3, v * 17 + 9.1, seedNumber + 5);
56
+ alpha = smoothstep(0.5, 0.62, n) * (1 - smoothstep(0.55, 1, r)) * (0.55 + grain * 0.45);
57
+ const shade = 0.75 + grain * 0.5;
58
+ red = 0.36 * shade;
59
+ green = 0.29 * shade;
60
+ blue = 0.19 * shade;
61
+ }
62
+ else if (style === "blood") {
63
+ const splatter = fbm(u * 7 - 2.2, v * 7 + 4.4, seedNumber);
64
+ const main = smoothstep(0.46, 0.58, splatter) * (1 - smoothstep(0.6, 1, r));
65
+ // Satellite droplets: 26 seeded points, each a soft dark ring.
66
+ let drops = 0;
67
+ for (let i = 0; i < 26; i += 1) {
68
+ const a = hash(i, 3, seedNumber) * Math.PI * 2;
69
+ const d = 0.08 + hash(i, 7, seedNumber) * 0.42;
70
+ const radius = 0.012 + hash(i, 11, seedNumber) * 0.03;
71
+ const dist = Math.hypot(u - Math.cos(a) * d, v - Math.sin(a) * d);
72
+ drops = Math.max(drops, 1 - smoothstep(radius * 0.6, radius, dist));
73
+ }
74
+ alpha = Math.max(main, drops);
75
+ const shade = 0.8 + splatter * 0.35;
76
+ red = 0.46 * shade;
77
+ green = 0.055 * shade;
78
+ blue = 0.045 * shade;
79
+ }
80
+ else if (style === "paint") {
81
+ const body = smoothstep(0.44, 0.55, n) * (1 - smoothstep(0.7, 1, r));
82
+ const streak = smoothstep(0.38, 0.52, fbm(u * 22 + 1.7, v * 5 + 8.3, seedNumber + 3)) * smoothstep(0.2, 0.9, v + 0.5) * 0.5;
83
+ alpha = Math.max(body, streak * (1 - smoothstep(0.5, 0.9, r)));
84
+ const shade = 0.86 + n * 0.22;
85
+ red = 0.84 * shade;
86
+ green = 0.8 * shade;
87
+ blue = 0.72 * shade;
88
+ }
89
+ else {
90
+ // Cracks: six warped radial lines plus two branches, faded toward the rim.
91
+ let line = 0;
92
+ for (let i = 0; i < 8; i += 1) {
93
+ const base = (i / 8) * Math.PI * 2 + hash(i, 1, seedNumber) * 0.7;
94
+ const wobble = (fbm(u * 9 + i * 3.1, v * 9 - i * 2.3, seedNumber + i) - 0.5) * 0.5;
95
+ const angle = Math.atan2(v, u);
96
+ const delta = Math.abs(Math.atan2(Math.sin(angle - base - wobble), Math.cos(angle - base - wobble)));
97
+ const width = 0.022 + hash(i, 5, seedNumber) * 0.02;
98
+ line = Math.max(line, (1 - smoothstep(0, width, delta * Math.max(0.05, r))) * (1 - smoothstep(0.45, 1, r)));
99
+ }
100
+ const grit = fbm(u * 30 + 4.1, v * 30 - 2.8, seedNumber + 9) * 0.35;
101
+ alpha = Math.max(line, grit * smoothstep(0.35, 0.7, n) * (1 - smoothstep(0.6, 1, r)));
102
+ red = 0.13;
103
+ green = 0.12;
104
+ blue = 0.11;
105
+ }
106
+ const index = (y * size + x) * 4;
107
+ data[index] = Math.round(Math.min(1, red) * 255);
108
+ data[index + 1] = Math.round(Math.min(1, green) * 255);
109
+ data[index + 2] = Math.round(Math.min(1, blue) * 255);
110
+ data[index + 3] = Math.round(Math.min(1, alpha) * 255);
111
+ }
112
+ }
113
+ return { size, data };
114
+ }
@@ -0,0 +1,28 @@
1
+ import * as THREE from "three";
2
+ import type { ModelWindConfig } from "@jgengine/core/game/playableGame";
3
+ /** Shared gust clock every foliage material reads; advanced once per frame by `WindClock`. */
4
+ export declare const windClock: {
5
+ value: number;
6
+ };
7
+ /** Shared gust direction (XZ); a model may override it with its own authored direction. */
8
+ export declare const windDirection: THREE.Vector2;
9
+ /** Resolved sway settings baked into one material. */
10
+ export interface ResolvedModelWind {
11
+ strength: number;
12
+ speed: number;
13
+ height: number;
14
+ direction: THREE.Vector2;
15
+ }
16
+ /** Fully-defaulted sway settings for a model. @internal */
17
+ export declare function resolveModelWind(wind: ModelWindConfig | false | undefined, fallbackHeight: number): ResolvedModelWind | null;
18
+ /**
19
+ * Compiles height-ramped wind sway into a standard material's vertex shader. Idempotent per
20
+ * material; the clock and direction are shared module uniforms, so the whole scene advances with
21
+ * one write per frame and no per-material bookkeeping.
22
+ * @capability model-wind sway a foliage model in the shared gust field
23
+ */
24
+ export declare function applyModelWind(material: THREE.MeshStandardMaterial, wind: ResolvedModelWind): THREE.MeshStandardMaterial;
25
+ /** Applies wind to every standard material under a cloned model. @internal */
26
+ export declare function applyModelWindToScene(root: THREE.Object3D, wind: ModelWindConfig | false | undefined, fallbackHeight?: number): void;
27
+ /** Advances the shared gust clock once per frame; mount one per 3D world. */
28
+ export declare function WindClock(): null;
@@ -0,0 +1,103 @@
1
+ import * as THREE from "three";
2
+ import { useFrame } from "@react-three/fiber";
3
+ /** Shared gust clock every foliage material reads; advanced once per frame by `WindClock`. */
4
+ export const windClock = { value: 0 };
5
+ /** Shared gust direction (XZ); a model may override it with its own authored direction. */
6
+ export const windDirection = new THREE.Vector2(1, 0.35).normalize();
7
+ const WIND_KEY = "jgWind";
8
+ /** Fully-defaulted sway settings for a model. @internal */
9
+ export function resolveModelWind(wind, fallbackHeight) {
10
+ if (wind === false || wind === undefined)
11
+ return null;
12
+ const strength = wind.strength ?? 0.35;
13
+ if (!Number.isFinite(strength) || strength < 0)
14
+ throw new Error("Model wind strength must be finite and non-negative");
15
+ const speed = wind.speed ?? 1;
16
+ if (!Number.isFinite(speed) || speed <= 0)
17
+ throw new Error("Model wind speed must be finite and positive");
18
+ const height = wind.height ?? fallbackHeight;
19
+ if (!Number.isFinite(height) || height <= 0)
20
+ throw new Error("Model wind height must be finite and positive");
21
+ const direction = new THREE.Vector2(wind.direction?.[0] ?? windDirection.x, wind.direction?.[1] ?? windDirection.y);
22
+ if (direction.lengthSq() === 0)
23
+ direction.set(1, 0);
24
+ direction.normalize();
25
+ return { strength, speed, height, direction };
26
+ }
27
+ const VERTEX_PARS = /* glsl */ `
28
+ uniform float uJgWindTime;
29
+ uniform vec2 uJgWindDir;
30
+ uniform float uJgWindStrength;
31
+ uniform float uJgWindSpeed;
32
+ uniform float uJgWindHeight;`;
33
+ // Height-ramped sway in object space, phased by the instance's world position so neighbouring
34
+ // plants move on different gusts. Trunks (low vertices) barely move; fronds ride the full travel.
35
+ const VERTEX_SWAY = /* glsl */ `#include <begin_vertex>
36
+ {
37
+ float jgWindT = clamp(position.y / max(uJgWindHeight, 0.001), 0.0, 1.0);
38
+ float jgWindRamp = jgWindT * jgWindT;
39
+ #ifdef USE_INSTANCING
40
+ vec3 jgWindWorld = (instanceMatrix * vec4(transformed, 1.0)).xyz;
41
+ #else
42
+ vec3 jgWindWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;
43
+ #endif
44
+ float jgWindPhase = dot(jgWindWorld.xz, vec2(0.17, 0.13)) + uJgWindTime * uJgWindSpeed;
45
+ float jgWindGust = sin(jgWindPhase) * 0.6 + sin(jgWindPhase * 0.47 + 2.3) * 0.4;
46
+ float jgWindFlutter = sin(uJgWindTime * 6.3 + jgWindWorld.x * 0.9 + jgWindWorld.z * 1.3) * 0.18;
47
+ vec2 jgWindPush = uJgWindDir * (jgWindGust + jgWindFlutter) * uJgWindStrength * jgWindRamp;
48
+ transformed.x += jgWindPush.x;
49
+ transformed.z += jgWindPush.y;
50
+ }`;
51
+ /**
52
+ * Compiles height-ramped wind sway into a standard material's vertex shader. Idempotent per
53
+ * material; the clock and direction are shared module uniforms, so the whole scene advances with
54
+ * one write per frame and no per-material bookkeeping.
55
+ * @capability model-wind sway a foliage model in the shared gust field
56
+ */
57
+ export function applyModelWind(material, wind) {
58
+ if (material.userData[WIND_KEY] !== undefined)
59
+ return material;
60
+ const uniforms = {
61
+ uJgWindTime: windClock,
62
+ uJgWindDir: { value: wind.direction.clone() },
63
+ uJgWindStrength: { value: wind.strength },
64
+ uJgWindSpeed: { value: wind.speed },
65
+ uJgWindHeight: { value: wind.height },
66
+ };
67
+ material.userData[WIND_KEY] = uniforms;
68
+ const previousCompile = material.onBeforeCompile;
69
+ const previousKey = material.customProgramCacheKey;
70
+ material.onBeforeCompile = (shader, renderer) => {
71
+ previousCompile.call(material, shader, renderer);
72
+ Object.assign(shader.uniforms, uniforms);
73
+ shader.vertexShader = shader.vertexShader
74
+ .replace("#include <common>", `#include <common>\n${VERTEX_PARS}`)
75
+ .replace("#include <begin_vertex>", VERTEX_SWAY);
76
+ };
77
+ material.customProgramCacheKey = () => `${previousKey.call(material)}|jg-wind:v1`;
78
+ material.needsUpdate = true;
79
+ return material;
80
+ }
81
+ /** Applies wind to every standard material under a cloned model. @internal */
82
+ export function applyModelWindToScene(root, wind, fallbackHeight = 4) {
83
+ const resolved = resolveModelWind(wind, fallbackHeight);
84
+ if (resolved === null)
85
+ return;
86
+ root.traverse((node) => {
87
+ const mesh = node;
88
+ if (!mesh.isMesh)
89
+ return;
90
+ const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
91
+ for (const material of list) {
92
+ if (material.isMeshStandardMaterial === true)
93
+ applyModelWind(material, resolved);
94
+ }
95
+ });
96
+ }
97
+ /** Advances the shared gust clock once per frame; mount one per 3D world. */
98
+ export function WindClock() {
99
+ useFrame(({ clock }) => {
100
+ windClock.value = clock.getElapsedTime();
101
+ });
102
+ return null;
103
+ }
@@ -0,0 +1,15 @@
1
+ import type { DocumentColorGrade } from "@jgengine/core/editor/environment";
2
+ import { type GameLookInput, type ResolvedGameLook } from "@jgengine/core/render/lookPreset";
3
+ import { type SkyEnvironmentDescriptor } from "@jgengine/core/world/features";
4
+ /** The look the shell renders, resolved once per authored value. @internal */
5
+ export interface PresentationLook {
6
+ look: ResolvedGameLook;
7
+ backdropSky: SkyEnvironmentDescriptor | undefined;
8
+ }
9
+ /**
10
+ * Resolve a game's look preset and backdrop sky, keeping one identity until an authored value
11
+ * changes. The post chain, sky dome and lighting rebuild on identity, so a fresh object per
12
+ * render recompiled every pass each frame.
13
+ * @internal
14
+ */
15
+ export declare function usePresentationLook(input: GameLookInput, authoredGrade: DocumentColorGrade | undefined): PresentationLook;
@@ -0,0 +1,18 @@
1
+ import { useMemo } from "react";
2
+ import { resolveGameLook } from "@jgengine/core/render/lookPreset";
3
+ import { sky as resolveSkyDescriptor } from "@jgengine/core/world/features";
4
+ import { withAuthoredGrade } from "./authoredGrade.js";
5
+ /**
6
+ * Resolve a game's look preset and backdrop sky, keeping one identity until an authored value
7
+ * changes. The post chain, sky dome and lighting rebuild on identity, so a fresh object per
8
+ * render recompiled every pass each frame.
9
+ * @internal
10
+ */
11
+ export function usePresentationLook(input, authoredGrade) {
12
+ const { look, lighting, backdrop, postProcessing, hasWorldSky } = input;
13
+ return useMemo(() => {
14
+ const resolved = withAuthoredGrade(resolveGameLook({ look, lighting, backdrop, postProcessing, hasWorldSky }), authoredGrade);
15
+ const sky = resolved.backdrop?.sky;
16
+ return { look: resolved, backdropSky: sky === undefined ? undefined : resolveSkyDescriptor(sky) };
17
+ }, [look, lighting, backdrop, postProcessing, hasWorldSky, authoredGrade]);
18
+ }
@@ -0,0 +1,9 @@
1
+ /** Whether two texture sets hold the same roles and texture instances. @internal */
2
+ export declare function sameTextureSet(a: unknown, b: unknown): boolean;
3
+ /**
4
+ * Keep one texture-set identity while its roles and texture instances are unchanged. The drei
5
+ * loader hands back a fresh keyed object per render, and materials built from it rebuilt and
6
+ * recompiled their shaders every frame.
7
+ * @internal
8
+ */
9
+ export declare function useStableTextureSet<T extends object>(textures: T): T;
@@ -0,0 +1,24 @@
1
+ import { useRef } from "react";
2
+ /** Whether two texture sets hold the same roles and texture instances. @internal */
3
+ export function sameTextureSet(a, b) {
4
+ if (a === b)
5
+ return true;
6
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
7
+ return false;
8
+ const keys = Object.keys(a);
9
+ if (keys.length !== Object.keys(b).length)
10
+ return false;
11
+ return keys.every((key) => a[key] === b[key]);
12
+ }
13
+ /**
14
+ * Keep one texture-set identity while its roles and texture instances are unchanged. The drei
15
+ * loader hands back a fresh keyed object per render, and materials built from it rebuilt and
16
+ * recompiled their shaders every frame.
17
+ * @internal
18
+ */
19
+ export function useStableTextureSet(textures) {
20
+ const stable = useRef(textures);
21
+ if (!sameTextureSet(stable.current, textures))
22
+ stable.current = textures;
23
+ return stable.current;
24
+ }
@@ -0,0 +1,52 @@
1
+ import type { MaterialAsset } from "@jgengine/core/material/materialAsset";
2
+ import * as THREE from "three";
3
+ /** Inputs for {@link applyTriplanarProjection}. */
4
+ export interface TriplanarProjectionOptions {
5
+ /** Metres one texture tile covers in world space. Must be finite and positive. */
6
+ tileMetres: number;
7
+ /** Blend sharpness between the three projections; 4 keeps corners soft, 8 is decisive. */
8
+ sharpness?: number;
9
+ /** Normal strength applied to each projection's tangent-space perturbation. */
10
+ normalScale?: number;
11
+ }
12
+ /**
13
+ * The GLSL the triplanar path injects, split by chunk. Exposed so a test can prove the patch
14
+ * samples by world position instead of `vMapUv`, and so a reader can audit the projection.
15
+ * @internal
16
+ */
17
+ export declare function triplanarChunks(): {
18
+ vertexPars: string;
19
+ vertexNormal: string;
20
+ vertexPosition: string;
21
+ fragmentPars: string;
22
+ color: string;
23
+ roughness: string;
24
+ normal: string;
25
+ ao: string;
26
+ };
27
+ /**
28
+ * Projects a standard material's maps from world position instead of mesh UVs, so world geometry
29
+ * without a `uv` attribute still renders a textured PBR surface. Instanced meshes project in their
30
+ * instanced world space. Skinned meshes keep the bind-pose normal for the blend weights, which is
31
+ * close enough for the static world surfaces this path exists for.
32
+ */
33
+ export declare function applyTriplanarProjection(material: THREE.MeshStandardMaterial, options: TriplanarProjectionOptions): THREE.MeshStandardMaterial;
34
+ /**
35
+ * Render a serializable material asset — one built by `libraryMaterialAsset`, or authored in the
36
+ * editor — with world-space triplanar projection, so world geometry needs no UVs. Tile size
37
+ * defaults to the asset's own `physicalSize`, which the shared library sets per surface.
38
+ * @capability material-triplanar render a material asset on world geometry without UVs
39
+ */
40
+ export declare function useTriplanarAssetMaterial(asset: MaterialAsset, options?: {
41
+ tileMetres?: number;
42
+ sharpness?: number;
43
+ normalScale?: number;
44
+ }): THREE.MeshStandardMaterial;
45
+ /** Mounts {@link useTriplanarAssetMaterial} on a mesh; the R3F renderer owns disposal. */
46
+ export declare function TriplanarAssetMaterial({ asset, ...options }: {
47
+ asset: MaterialAsset;
48
+ } & {
49
+ tileMetres?: number;
50
+ sharpness?: number;
51
+ normalScale?: number;
52
+ }): import("react").JSX.Element;