@jgengine/shell 0.26.0 → 0.28.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 (91) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/GamePlayerShell.d.ts +8 -0
  3. package/dist/GamePlayerShell.js +15 -1
  4. package/dist/GameUiPreview.js +3 -1
  5. package/dist/Shell3dPresentation.js +6 -4
  6. package/dist/ShellChrome.js +3 -1
  7. package/dist/camera/GameCameraRig.js +2 -2
  8. package/dist/camera/GameFirstPersonCamera.js +39 -2
  9. package/dist/camera/cameraCollision.d.ts +66 -1
  10. package/dist/camera/cameraCollision.js +72 -1
  11. package/dist/camera/index.d.ts +1 -1
  12. package/dist/camera/index.js +1 -1
  13. package/dist/camera/rigResolve.d.ts +13 -0
  14. package/dist/camera/rigResolve.js +16 -0
  15. package/dist/environment/EnvironmentScene.js +118 -10
  16. package/dist/postfx/shadowPrepass.d.ts +8 -1
  17. package/dist/postfx/shadowPrepass.js +18 -2
  18. package/dist/postfx/stylizePass.js +2 -1
  19. package/dist/render/CascadedShadows.d.ts +31 -1
  20. package/dist/render/CascadedShadows.js +71 -5
  21. package/dist/render/CharacterCrowd.d.ts +20 -0
  22. package/dist/render/CharacterCrowd.js +176 -0
  23. package/dist/render/SceneLighting.js +27 -3
  24. package/dist/render/SceneWarmupGate.d.ts +20 -0
  25. package/dist/render/SceneWarmupGate.js +48 -0
  26. package/dist/render/characterLod.d.ts +62 -0
  27. package/dist/render/characterLod.js +165 -0
  28. package/dist/render/crowdRegistry.d.ts +48 -0
  29. package/dist/render/crowdRegistry.js +97 -0
  30. package/dist/render/decalGeometry.d.ts +26 -0
  31. package/dist/render/decalGeometry.js +54 -0
  32. package/dist/render/decalTexture.d.ts +13 -0
  33. package/dist/render/decalTexture.js +114 -0
  34. package/dist/render/materialAsset.js +2 -1
  35. package/dist/render/modelLoad.js +3 -0
  36. package/dist/render/modelMeshMerge.d.ts +49 -0
  37. package/dist/render/modelMeshMerge.js +269 -0
  38. package/dist/render/modelWind.d.ts +28 -0
  39. package/dist/render/modelWind.js +103 -0
  40. package/dist/render/shaderWarmup.d.ts +69 -0
  41. package/dist/render/shaderWarmup.js +120 -0
  42. package/dist/render/shadowCasters.d.ts +28 -0
  43. package/dist/render/shadowCasters.js +95 -0
  44. package/dist/render/staticTransforms.d.ts +20 -0
  45. package/dist/render/staticTransforms.js +32 -0
  46. package/dist/render/useFootIk.d.ts +11 -3
  47. package/dist/render/useFootIk.js +35 -7
  48. package/dist/render/useModelAnimation.d.ts +16 -2
  49. package/dist/render/useModelAnimation.js +73 -4
  50. package/dist/render/useModelInstance.d.ts +1 -1
  51. package/dist/render/useModelInstance.js +26 -1
  52. package/dist/render/useRagdoll.d.ts +32 -0
  53. package/dist/render/useRagdoll.js +105 -0
  54. package/dist/scatter/scatterModels.js +2 -0
  55. package/dist/scene/DecalRenderer.d.ts +2 -0
  56. package/dist/scene/DecalRenderer.js +74 -0
  57. package/dist/scene/GrassFieldRenderer.js +3 -2
  58. package/dist/scene/builtinSceneKindRenderers.js +2 -0
  59. package/dist/settings/SettingsMenu.d.ts +1 -1
  60. package/dist/settings/SettingsMenu.js +10 -79
  61. package/dist/settings/settingsController.js +3 -0
  62. package/dist/terrain/GrassCover.d.ts +21 -0
  63. package/dist/terrain/GrassCover.js +84 -0
  64. package/dist/terrain/grassCoverGeometry.d.ts +40 -0
  65. package/dist/terrain/grassCoverGeometry.js +112 -0
  66. package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
  67. package/dist/terrain/terrainDetailMaterial.js +54 -1
  68. package/dist/terrain/terrainSurfaces.d.ts +6 -0
  69. package/dist/terrain/terrainSurfaces.js +10 -0
  70. package/dist/world/StaticObjectModels.d.ts +13 -0
  71. package/dist/world/StaticObjectModels.js +28 -3
  72. package/dist/world/WorldParticles.d.ts +2 -1
  73. package/dist/world/WorldParticles.js +29 -10
  74. package/dist/world/WorldScene.js +52 -14
  75. package/dist/world/entityPose.d.ts +17 -0
  76. package/dist/world/entityPose.js +42 -0
  77. package/dist/world/staticObjectBatches.d.ts +33 -5
  78. package/dist/world/staticObjectBatches.js +40 -6
  79. package/package.json +4 -4
  80. package/skills/jgengine/api.md +2 -2
  81. package/skills/jgengine-ui/SKILL.md +1 -0
  82. package/skills/jgengine-ui/api.md +231 -9
  83. package/skills/jgengine-ui/capabilities.md +107 -1
  84. package/skills/jgengine-ui/reference-primitives.md +2 -0
  85. package/skills/jgengine-ui/reference.md +8 -0
  86. package/skills/jgengine-world/SKILL.md +8 -0
  87. package/skills/jgengine-world/api.md +243 -11
  88. package/skills/jgengine-world/capabilities.md +61 -1
  89. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  90. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  91. package/skills/jgengine-world/reference.md +1 -1
@@ -2,6 +2,7 @@ import { actionLabel, bindingLabel } from "@jgengine/core/input/actionBindings";
2
2
  import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
3
3
  import { ANISOTROPY_OPTIONS, applyGraphicsQuality, GRAPHICS_POST_STAGE_LABELS, GRAPHICS_POST_STAGES, graphicsPostStageSettingId, readGraphicsSettings, RENDER_SCALE_MAX, RENDER_SCALE_MIN, RENDER_SCALE_STEP, } from "@jgengine/core/settings/graphicsSettings";
4
4
  import { FRAME_RATE_LIMIT_DISPLAY, FRAME_RATE_LIMIT_OPTIONS } from "@jgengine/core/settings/frameRateLimit";
5
+ import { accessibilitySettingRows } from "@jgengine/core/settings/accessibilitySettings";
5
6
  import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANT_OPTIONS, TOUCH_STYLE_OPTIONS, } from "@jgengine/core/input/touchScheme";
6
7
  import { TOUCH_STYLE_AUTO, useSettingsRevision } from "./appliedSettings.js";
7
8
  import { useSettingsStore, } from "@jgengine/react/settings";
@@ -11,6 +12,7 @@ const DEFAULT_CATEGORY_LABELS = {
11
12
  graphics: "Graphics",
12
13
  gameplay: "Gameplay",
13
14
  controls: "Controls",
15
+ accessibility: "Accessibility",
14
16
  };
15
17
  const percent = (value) => `${Math.round(value * 100)}%`;
16
18
  const TOUCH_STYLE_SELECT_OPTIONS = [
@@ -195,6 +197,7 @@ export function useSettingsCategories(config) {
195
197
  graphics: graphicsRows,
196
198
  gameplay: gameplayRows,
197
199
  controls: controlsRows,
200
+ accessibility: accessibilitySettingRows(store),
198
201
  };
199
202
  const builtInKeybinds = { controls: keybinds };
200
203
  const declaredLabels = new Map(config.categories.map((c) => [c.id, c.label]));
@@ -0,0 +1,21 @@
1
+ import * as THREE from "three";
2
+ import type { TerrainHeightSampler } from "./terrainMath.js";
3
+ /** Props for {@link GrassCover}: one authored patch's footprint, colours and drape samplers. */
4
+ export interface GrassCoverProps {
5
+ area: readonly [number, number];
6
+ seed: number;
7
+ colorBase: THREE.ColorRepresentation;
8
+ colorTip: THREE.ColorRepresentation;
9
+ strength: number;
10
+ edgeFeather: number;
11
+ heightAt: TerrainHeightSampler;
12
+ coverageAt: TerrainHeightSampler;
13
+ segments?: number;
14
+ position?: readonly [number, number, number];
15
+ }
16
+ /**
17
+ * The turf surface under one authored `grass_field` patch: a ground-draped, edge-feathered mat that
18
+ * turns a bare pavement footprint into grass the blade field stands in. Bounded to one draw and a
19
+ * {@link GRASS_COVER_MAX_SEGMENTS}² grid, so a large park still costs a single mesh.
20
+ */
21
+ export declare function GrassCover({ area, seed, colorBase, colorTip, strength, edgeFeather, heightAt, coverageAt, segments, position, }: GrassCoverProps): import("react").JSX.Element | null;
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as THREE from "three";
3
+ import { useDisposable } from "../render/useDisposable.js";
4
+ import { createGrassCoverGeometry, GRASS_COVER_MAX_SEGMENTS } from "./grassCoverGeometry.js";
5
+ let grainTexture = null;
6
+ /**
7
+ * Tileable thatch grain for the cover mat (luminance around 0.9), generated once from a hash — no
8
+ * asset pull, deterministic, and cheap enough that a 96² mat still reads as grass up close.
9
+ */
10
+ function coverGrain() {
11
+ if (grainTexture !== null)
12
+ return grainTexture;
13
+ const size = 64;
14
+ const data = new Uint8Array(size * size * 4);
15
+ const hash = (x, y) => {
16
+ let h = Math.imul(x + 1, 374761393) ^ Math.imul(y + 1, 668265263) ^ 0x9e3779b9;
17
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
18
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
19
+ };
20
+ for (let y = 0; y < size; y += 1) {
21
+ for (let x = 0; x < size; x += 1) {
22
+ const fine = (hash(x, y) + hash((x + 32) % size, (y + 17) % size)) * 0.5;
23
+ const clump = (hash((x >> 2) * 3, (y >> 2) * 5) + hash(((x >> 2) + 7) % 16, ((y >> 2) + 3) % 16)) * 0.5;
24
+ const value = Math.max(180, Math.min(255, Math.round(232 + (fine - 0.5) * 46 + (clump - 0.5) * 30)));
25
+ const i = (y * size + x) * 4;
26
+ data[i] = value;
27
+ data[i + 1] = value;
28
+ data[i + 2] = value;
29
+ data[i + 3] = 255;
30
+ }
31
+ }
32
+ grainTexture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat, THREE.UnsignedByteType);
33
+ grainTexture.wrapS = THREE.RepeatWrapping;
34
+ grainTexture.wrapT = THREE.RepeatWrapping;
35
+ grainTexture.magFilter = THREE.LinearFilter;
36
+ grainTexture.minFilter = THREE.LinearMipmapLinearFilter;
37
+ grainTexture.generateMipmaps = true;
38
+ grainTexture.needsUpdate = true;
39
+ return grainTexture;
40
+ }
41
+ /**
42
+ * The turf surface under one authored `grass_field` patch: a ground-draped, edge-feathered mat that
43
+ * turns a bare pavement footprint into grass the blade field stands in. Bounded to one draw and a
44
+ * {@link GRASS_COVER_MAX_SEGMENTS}² grid, so a large park still costs a single mesh.
45
+ */
46
+ export function GrassCover({ area, seed, colorBase, colorTip, strength, edgeFeather, heightAt, coverageAt, segments, position, }) {
47
+ const geometry = useDisposable(() => {
48
+ const baked = createGrassCoverGeometry({
49
+ area,
50
+ seed,
51
+ colorBase,
52
+ colorTip,
53
+ strength,
54
+ edgeFeather,
55
+ heightAt,
56
+ coverageAt,
57
+ ...(segments === undefined ? {} : { segments }),
58
+ });
59
+ const next = new THREE.BufferGeometry();
60
+ if (baked.positions.length === 0)
61
+ return next;
62
+ next.setAttribute("position", new THREE.BufferAttribute(baked.positions, 3));
63
+ next.setAttribute("normal", new THREE.BufferAttribute(baked.normals, 3));
64
+ next.setAttribute("color", new THREE.BufferAttribute(baked.colors, 4));
65
+ next.setAttribute("uv", new THREE.BufferAttribute(baked.uvs, 2));
66
+ next.setIndex(new THREE.BufferAttribute(baked.indices, 1));
67
+ next.computeBoundingSphere();
68
+ return next;
69
+ }, [area, seed, colorBase, colorTip, strength, edgeFeather, heightAt, coverageAt, segments]);
70
+ const material = useDisposable(() => new THREE.MeshStandardMaterial({
71
+ map: coverGrain(),
72
+ vertexColors: true,
73
+ transparent: true,
74
+ depthWrite: false,
75
+ roughness: 0.96,
76
+ metalness: 0,
77
+ polygonOffset: true,
78
+ polygonOffsetFactor: -1,
79
+ polygonOffsetUnits: -1,
80
+ }), []);
81
+ if (strength <= 0)
82
+ return null;
83
+ return (_jsx("mesh", { geometry: geometry, material: material, receiveShadow: true, frustumCulled: true, ...(position === undefined ? {} : { position: [position[0], position[1], position[2]] }) }));
84
+ }
@@ -0,0 +1,40 @@
1
+ import * as THREE from "three";
2
+ import { type TerrainArea, type TerrainHeightSampler } from "./terrainMath.js";
3
+ /** World metres one cover-texture tile spans; the mat's own grain is sampled in world space. */
4
+ export declare const GRASS_COVER_TILE_METRES = 3;
5
+ /** Largest grid a cover mat bakes to, per axis — a 66 m park stays one draw at ~1 m cells. */
6
+ export declare const GRASS_COVER_MAX_SEGMENTS = 96;
7
+ /** Inputs for {@link createGrassCoverGeometry}. */
8
+ export interface GrassCoverGeometryOptions {
9
+ area: TerrainArea;
10
+ seed: number;
11
+ colorBase: THREE.ColorRepresentation;
12
+ colorTip: THREE.ColorRepresentation;
13
+ /** Ground-cover mat strength 0..1 (the authored `groundCover`); 0 produces no geometry. */
14
+ strength: number;
15
+ /** Meters of soft fringe at the patch border, matching the blade field's own feather. */
16
+ edgeFeather: number;
17
+ heightAt: TerrainHeightSampler;
18
+ coverageAt: TerrainHeightSampler;
19
+ /** Lift above the ground in meters so the mat never z-fights the terrain. Default 0.035. */
20
+ lift?: number;
21
+ /** Grid segments per axis; defaults to one per 2 m, clamped to {@link GRASS_COVER_MAX_SEGMENTS}. */
22
+ segments?: number;
23
+ }
24
+ /** Bake result for {@link createGrassCoverGeometry}; empty arrays when the mat is switched off. */
25
+ export interface GrassCoverGeometry {
26
+ positions: Float32Array;
27
+ normals: Float32Array;
28
+ colors: Float32Array;
29
+ uvs: Float32Array;
30
+ indices: Uint32Array;
31
+ size: readonly [number, number];
32
+ }
33
+ /**
34
+ * Drapes an authored `grass_field` volume's footprint with a feathered turf mat: vertices follow the
35
+ * runtime ground, the patch edge fades out over `edgeFeather`, and authored clearance masks punch
36
+ * the cover back out where a path or footing crosses it. This is the surface under the blades, so a
37
+ * park reads as grass instead of bare pavement with stubble on it.
38
+ * @internal
39
+ */
40
+ export declare function createGrassCoverGeometry(options: GrassCoverGeometryOptions): GrassCoverGeometry;
@@ -0,0 +1,112 @@
1
+ import * as THREE from "three";
2
+ import { resolveTerrainSize } from "./terrainMath.js";
3
+ /** World metres one cover-texture tile spans; the mat's own grain is sampled in world space. */
4
+ export const GRASS_COVER_TILE_METRES = 3;
5
+ /** Largest grid a cover mat bakes to, per axis — a 66 m park stays one draw at ~1 m cells. */
6
+ export const GRASS_COVER_MAX_SEGMENTS = 96;
7
+ function hash2(x, z, seed) {
8
+ let h = Math.imul(x | 0, 374761393) ^ Math.imul(z | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);
9
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
10
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
11
+ }
12
+ function smooth(t) {
13
+ return t * t * (3 - 2 * t);
14
+ }
15
+ /** Bilinear value noise in world space; deterministic per seed, no allocation on the hot path. */
16
+ function valueNoise(x, z, seed) {
17
+ const xi = Math.floor(x);
18
+ const zi = Math.floor(z);
19
+ const tx = smooth(x - xi);
20
+ const tz = smooth(z - zi);
21
+ const a = hash2(xi, zi, seed);
22
+ const b = hash2(xi + 1, zi, seed);
23
+ const c = hash2(xi, zi + 1, seed);
24
+ const d = hash2(xi + 1, zi + 1, seed);
25
+ return (a + (b - a) * tx) * (1 - tz) + (c + (d - c) * tx) * tz;
26
+ }
27
+ /**
28
+ * Drapes an authored `grass_field` volume's footprint with a feathered turf mat: vertices follow the
29
+ * runtime ground, the patch edge fades out over `edgeFeather`, and authored clearance masks punch
30
+ * the cover back out where a path or footing crosses it. This is the surface under the blades, so a
31
+ * park reads as grass instead of bare pavement with stubble on it.
32
+ * @internal
33
+ */
34
+ export function createGrassCoverGeometry(options) {
35
+ const size = resolveTerrainSize(options.area);
36
+ const segments = Math.max(1, Math.min(GRASS_COVER_MAX_SEGMENTS, Math.floor(options.segments ?? Math.min(GRASS_COVER_MAX_SEGMENTS, Math.max(2, Math.round(Math.max(size.width, size.depth) / 2))))));
37
+ if (options.strength <= 0 || size.width <= 0 || size.depth <= 0) {
38
+ return { positions: new Float32Array(), normals: new Float32Array(), colors: new Float32Array(), uvs: new Float32Array(), indices: new Uint32Array(), size: [size.width, size.depth] };
39
+ }
40
+ const lift = options.lift ?? 0.035;
41
+ const halfW = size.width / 2;
42
+ const halfD = size.depth / 2;
43
+ const base = new THREE.Color(options.colorBase);
44
+ const tip = new THREE.Color(options.colorTip);
45
+ const root = base.clone().multiplyScalar(0.55);
46
+ const sheen = base.clone().multiplyScalar(0.98);
47
+ const cols = segments + 1;
48
+ const count = cols * cols;
49
+ const positions = new Float32Array(count * 3);
50
+ const normals = new Float32Array(count * 3);
51
+ const colors = new Float32Array(count * 4);
52
+ const uvs = new Float32Array(count * 2);
53
+ const eps = Math.max(0.25, Math.min(size.width, size.depth) / segments / 2);
54
+ for (let iz = 0; iz < cols; iz += 1) {
55
+ const z = -halfD + (iz / segments) * size.depth;
56
+ for (let ix = 0; ix < cols; ix += 1) {
57
+ const x = -halfW + (ix / segments) * size.width;
58
+ const index = iz * cols + ix;
59
+ positions[index * 3] = x;
60
+ positions[index * 3 + 1] = options.heightAt(x, z) + lift;
61
+ positions[index * 3 + 2] = z;
62
+ const hL = options.heightAt(x - eps, z);
63
+ const hR = options.heightAt(x + eps, z);
64
+ const hD = options.heightAt(x, z - eps);
65
+ const hU = options.heightAt(x, z + eps);
66
+ const nx = hL - hR;
67
+ const nz = hD - hU;
68
+ const ny = 2 * eps;
69
+ const length = Math.hypot(nx, ny, nz) || 1;
70
+ normals[index * 3] = nx / length;
71
+ normals[index * 3 + 1] = ny / length;
72
+ normals[index * 3 + 2] = nz / length;
73
+ const edge = Math.min(halfW - Math.abs(x), halfD - Math.abs(z));
74
+ const feather = Math.max(0.001, options.edgeFeather);
75
+ const edgeFade = smooth(Math.min(1, Math.max(0, edge / feather)));
76
+ const coverage = Math.max(0, Math.min(1, options.coverageAt(x, z)));
77
+ const grain = valueNoise(x / 1.7, z / 1.7, options.seed);
78
+ const patch = valueNoise(x / 11 + 31.7, z / 11 - 9.2, options.seed + 17);
79
+ const alpha = options.strength * edgeFade * coverage * (0.82 + 0.18 * grain);
80
+ // Macro patches lift the mat toward the blade tip hue and back to a rooted shade, so a park
81
+ // reads as turf with light and dry pockets instead of one flat green sheet.
82
+ const light = smooth(Math.min(1, Math.max(0, (patch - 0.25) / 0.6)));
83
+ const r = root.r + (sheen.r - root.r) * light;
84
+ const g = root.g + (sheen.g - root.g) * light;
85
+ const b = root.b + (sheen.b - root.b) * light;
86
+ const tipMix = grain * 0.3;
87
+ colors[index * 4] = r + (tip.r - r) * tipMix;
88
+ colors[index * 4 + 1] = g + (tip.g - g) * tipMix;
89
+ colors[index * 4 + 2] = b + (tip.b - b) * tipMix;
90
+ colors[index * 4 + 3] = alpha;
91
+ uvs[index * 2] = x / GRASS_COVER_TILE_METRES;
92
+ uvs[index * 2 + 1] = z / GRASS_COVER_TILE_METRES;
93
+ }
94
+ }
95
+ const indices = new Uint32Array(segments * segments * 6);
96
+ let cursor = 0;
97
+ for (let iz = 0; iz < segments; iz += 1) {
98
+ for (let ix = 0; ix < segments; ix += 1) {
99
+ const a = iz * cols + ix;
100
+ const b = a + 1;
101
+ const c = a + cols;
102
+ const d = c + 1;
103
+ indices[cursor++] = a;
104
+ indices[cursor++] = c;
105
+ indices[cursor++] = b;
106
+ indices[cursor++] = b;
107
+ indices[cursor++] = c;
108
+ indices[cursor++] = d;
109
+ }
110
+ }
111
+ return { positions, normals, colors, uvs, indices, size: [size.width, size.depth] };
112
+ }
@@ -34,9 +34,21 @@ export interface TerrainDetailMaterialOptions {
34
34
  classMask?: number;
35
35
  /** Initial editor relief-contour strength. Default 0. */
36
36
  contour?: number;
37
+ /**
38
+ * Per-painted-slot scanned color maps (slot 1 first), each with its own world tile metres. Layers
39
+ * without a map (or beyond {@link MAX_TERRAIN_LAYER_MAPS}) keep the procedural surface response.
40
+ */
41
+ layerMaps?: readonly (TerrainLayerMap | undefined)[];
37
42
  }
38
43
  /** Compile every surface response, for an editor viewport whose paint can change to any surface. */
39
44
  export declare const ALL_TERRAIN_SURFACE_CLASSES = 511;
45
+ /** One painted layer's scanned color map plus the world metres one tile covers. */
46
+ export interface TerrainLayerMap {
47
+ color: THREE.Texture;
48
+ tileMetres: number;
49
+ }
50
+ /** At most this many painted layers sample their own scanned map; extra layers keep procedural shading. */
51
+ export declare const MAX_TERRAIN_LAYER_MAPS = 4;
40
52
  /**
41
53
  * A `MeshStandardMaterial` that renders terrain as ground rather than flat colour. Each painted
42
54
  * layer draws its surface type's procedural response (grass clumps and blades, dirt clods and
@@ -1,10 +1,38 @@
1
1
  import * as THREE from "three";
2
2
  import { NOISE_GLSL, PAINT_PARS_GLSL, SURFACE_BUMP_NORMAL_GLSL, SURFACE_FUNCTIONS_GLSL } from "./terrainSurfaceShader.js";
3
+ let placeholder;
4
+ function placeholderTexture() {
5
+ if (placeholder !== undefined)
6
+ return placeholder;
7
+ placeholder = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1, THREE.RGBAFormat);
8
+ placeholder.needsUpdate = true;
9
+ return placeholder;
10
+ }
3
11
  const ROCK_CLASS_BIT = 1 << 4;
4
12
  /** Compile every surface response, for an editor viewport whose paint can change to any surface. */
5
13
  export const ALL_TERRAIN_SURFACE_CLASSES = 0x1ff;
14
+ /** At most this many painted layers sample their own scanned map; extra layers keep procedural shading. */
15
+ export const MAX_TERRAIN_LAYER_MAPS = 4;
6
16
  // Slot 0 is the unpainted base, 1–8 the paint layers, 9 slope rock.
7
17
  const SLOT_GLSL = /* glsl */ `
18
+ #ifdef JG_TERRAIN_LAYER_MAPS
19
+ uniform sampler2D uLayerMap1;
20
+ uniform sampler2D uLayerMap2;
21
+ uniform sampler2D uLayerMap3;
22
+ uniform sampler2D uLayerMap4;
23
+ uniform float uLayerTile1;
24
+ uniform float uLayerTile2;
25
+ uniform float uLayerTile3;
26
+ uniform float uLayerTile4;
27
+ vec3 jgLayerMap(int slot, vec3 wp, out bool has) {
28
+ has = false;
29
+ if (slot == 1) { if (uLayerTile1 > 0.0) { has = true; return texture2D(uLayerMap1, wp.xz / uLayerTile1).rgb; } }
30
+ else if (slot == 2) { if (uLayerTile2 > 0.0) { has = true; return texture2D(uLayerMap2, wp.xz / uLayerTile2).rgb; } }
31
+ else if (slot == 3) { if (uLayerTile3 > 0.0) { has = true; return texture2D(uLayerMap3, wp.xz / uLayerTile3).rgb; } }
32
+ else if (slot == 4) { if (uLayerTile4 > 0.0) { has = true; return texture2D(uLayerMap4, wp.xz / uLayerTile4).rgb; } }
33
+ return vec3(0.0);
34
+ }
35
+ #endif
8
36
  JgSurf jgSlot(int slot, vec3 baseTone, vec3 wp){
9
37
  vec3 jgFlatC = baseTone;
10
38
  JgSurf s;
@@ -22,6 +50,15 @@ JgSurf jgSlot(int slot, vec3 baseTone, vec3 wp){
22
50
  else {
23
51
  s = jgSurface(0, baseTone, roughness, 1.0, wp);
24
52
  }
53
+ #ifdef JG_TERRAIN_LAYER_MAPS
54
+ if (slot > 0 && slot <= 4) {
55
+ bool jgHasMap;
56
+ vec3 jgMapped = jgLayerMap(slot, wp, jgHasMap);
57
+ // The layer owns its own scan: grain, colour and all, tiled at its authored metres, and the
58
+ // mask weights decide how much of it shows against the neighbour layer.
59
+ if (jgHasMap) s.albedo = mix(s.albedo, jgMapped, 0.92);
60
+ }
61
+ #endif
25
62
  s.albedo = mix(jgFlatC, s.albedo, uSurface);
26
63
  s.height *= uSurface;
27
64
  s.bump *= uSurface;
@@ -48,6 +85,20 @@ export function createTerrainDetailMaterial(detail, textures, options = {}) {
48
85
  const strength = detail.material?.strength ?? 1;
49
86
  const tint = detail.material?.tint ?? 1;
50
87
  const paint = options.paint;
88
+ const layerMaps = (options.layerMaps ?? []).slice(0, MAX_TERRAIN_LAYER_MAPS);
89
+ const hasLayerMaps = layerMaps.some((entry) => entry !== undefined);
90
+ const layerMapUniforms = hasLayerMaps
91
+ ? {
92
+ uLayerMap1: { value: layerMaps[0]?.color ?? placeholderTexture() },
93
+ uLayerMap2: { value: layerMaps[1]?.color ?? placeholderTexture() },
94
+ uLayerMap3: { value: layerMaps[2]?.color ?? placeholderTexture() },
95
+ uLayerMap4: { value: layerMaps[3]?.color ?? placeholderTexture() },
96
+ uLayerTile1: { value: layerMaps[0]?.tileMetres ?? 0 },
97
+ uLayerTile2: { value: layerMaps[1]?.tileMetres ?? 0 },
98
+ uLayerTile3: { value: layerMaps[2]?.tileMetres ?? 0 },
99
+ uLayerTile4: { value: layerMaps[3]?.tileMetres ?? 0 },
100
+ }
101
+ : {};
51
102
  const low = options.quality === "low";
52
103
  const contour = { value: options.contour ?? 0 };
53
104
  // Rock is always compiled for slope rock; each extra class costs shader time even where unused on
@@ -68,6 +119,7 @@ export function createTerrainDetailMaterial(detail, textures, options = {}) {
68
119
  uSurface: { value: detail.surfaceStrength },
69
120
  uContour: contour,
70
121
  ...(paint === undefined ? {} : paint.uniforms),
122
+ ...layerMapUniforms,
71
123
  ...(hasTexture
72
124
  ? {
73
125
  uMatColor: { value: textures.color },
@@ -88,6 +140,7 @@ export function createTerrainDetailMaterial(detail, textures, options = {}) {
88
140
  });
89
141
  material.defines = {
90
142
  ...(paint === undefined ? {} : { JG_TERRAIN_PAINT: "" }),
143
+ ...(hasLayerMaps ? { JG_TERRAIN_LAYER_MAPS: "" } : {}),
91
144
  ...(low ? { JG_SURFACE_LOW: "" } : {}),
92
145
  JG_SURFACE_CLASSES: String(classMask),
93
146
  };
@@ -264,6 +317,6 @@ ${materialNormalGlsl}`)
264
317
  .replace("#include <aomap_fragment>", `#include <aomap_fragment>
265
318
  ${materialAoGlsl}`);
266
319
  };
267
- material.customProgramCacheKey = () => `jgengine-terrain-detail-${hasTexture ? "tex" : "flat"}-${hasTexture && textures.roughness !== undefined ? "rough" : "scalar"}-${hasTexture && textures.ao !== undefined ? "ao" : "unoccluded"}-${paint === undefined ? "bare" : "paint"}-${low ? "low" : "full"}-${classMask}`;
320
+ material.customProgramCacheKey = () => `jgengine-terrain-detail-${hasTexture ? "tex" : "flat"}-${hasTexture && textures.roughness !== undefined ? "rough" : "scalar"}-${hasTexture && textures.ao !== undefined ? "ao" : "unoccluded"}-${paint === undefined ? "bare" : "paint"}-${hasLayerMaps ? "layers" : "procedural"}-${low ? "low" : "full"}-${classMask}`;
268
321
  return { material, contour };
269
322
  }
@@ -21,6 +21,10 @@ export interface TerrainPaintSlot {
21
21
  roughness: number;
22
22
  /** Pattern frequency multiplier from the layer's authored `tiling`. */
23
23
  scale: number;
24
+ /** World metres per texture repeat for this layer's scanned color map; 0 when the layer has no map. */
25
+ tileMetres: number;
26
+ /** Resolved color-map URL the slot's scanned texture comes from, if any. */
27
+ materialUrl?: string;
24
28
  }
25
29
  /** Per-cell paint coverage packed for the shader: two RGBA8 planes holding up to eight layer weights. */
26
30
  export interface TerrainPaintWeights {
@@ -50,6 +54,8 @@ export interface TerrainPaintTextures {
50
54
  readonly classMask: number;
51
55
  /** Re-upload coverage and layer slots; reallocates only when the cell grid size changes. */
52
56
  update(weights: TerrainPaintWeights): void;
57
+ /** The slot table the current coverage was packed from (layer materials, tile metres, classes). */
58
+ readonly slots: readonly TerrainPaintSlot[];
53
59
  dispose(): void;
54
60
  }
55
61
  /**
@@ -53,6 +53,8 @@ function slotFor(surface, layer) {
53
53
  classIndex: CLASS_INDEX[surfaceClass],
54
54
  roughness: layer?.roughness ?? CLASS_ROUGHNESS[surfaceClass],
55
55
  scale: tiling === undefined || tiling <= 0 ? 1 : Math.min(2, Math.max(0.5, CLASS_TILING[surfaceClass] / tiling)),
56
+ tileMetres: layer?.materialUrl === undefined ? 0 : tiling !== undefined && tiling > 0 ? tiling : 6,
57
+ ...(layer?.materialUrl === undefined ? {} : { materialUrl: layer.materialUrl }),
56
58
  };
57
59
  }
58
60
  /**
@@ -145,6 +147,7 @@ export function createTerrainPaintTextures(weights) {
145
147
  const classes = new Float32Array(MAX_TERRAIN_PAINT_LAYERS);
146
148
  const roughness = new Float32Array(MAX_TERRAIN_PAINT_LAYERS);
147
149
  const scales = new Float32Array(MAX_TERRAIN_PAINT_LAYERS);
150
+ const tiles = new Float32Array(MAX_TERRAIN_PAINT_LAYERS);
148
151
  const uniforms = {
149
152
  uPaintA: { value: coverageTexture(weights.planes[0], weights.cols, weights.rows) },
150
153
  uPaintB: { value: coverageTexture(weights.planes[1], weights.cols, weights.rows) },
@@ -156,9 +159,12 @@ export function createTerrainPaintTextures(weights) {
156
159
  uLayerClass: { value: classes },
157
160
  uLayerRough: { value: roughness },
158
161
  uLayerScale: { value: scales },
162
+ uLayerTile: { value: tiles },
159
163
  };
160
164
  let classMask = 0;
165
+ let slots = weights.slots;
161
166
  const update = (next) => {
167
+ slots = next.slots;
162
168
  classMask = next.slots.reduce((mask, slot) => mask | (1 << slot.classIndex), 0);
163
169
  for (const [key, plane] of [["uPaintA", next.planes[0]], ["uPaintB", next.planes[1]]]) {
164
170
  const current = uniforms[key].value;
@@ -182,6 +188,7 @@ export function createTerrainPaintTextures(weights) {
182
188
  classes[i] = slot?.classIndex ?? 0;
183
189
  roughness[i] = slot?.roughness ?? 0.9;
184
190
  scales[i] = slot?.scale ?? 1;
191
+ tiles[i] = slot?.tileMetres ?? 0;
185
192
  }
186
193
  };
187
194
  update(weights);
@@ -190,6 +197,9 @@ export function createTerrainPaintTextures(weights) {
190
197
  get classMask() {
191
198
  return classMask;
192
199
  },
200
+ get slots() {
201
+ return slots;
202
+ },
193
203
  update,
194
204
  dispose: () => {
195
205
  uniforms.uPaintA.value.dispose();
@@ -1,6 +1,19 @@
1
1
  import { type ReactNode } from "react";
2
+ import * as THREE from "three";
2
3
  import type { SceneObject } from "@jgengine/core/scene/objectStore";
3
4
  import { type StaticObjectBatch } from "./staticObjectBatches.js";
5
+ /**
6
+ * Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
7
+ * total instances behind them, and how many instances survived per-instance culling this frame. A
8
+ * batch whose instances are all outside the view contributes a draw of zero, so `drawn` is the culling
9
+ * evidence, not a claim.
10
+ * @internal
11
+ */
12
+ export declare function useStaticObjectBatchProbe(scene: THREE.Object3D, stats: {
13
+ batches: number;
14
+ instances: number;
15
+ singles: number;
16
+ }): void;
4
17
  /** Render compatible authored or `scenery` props as shared model draws, preserving unsupported models. @internal */
5
18
  export declare function StaticObjectModels({ batch, single }: {
6
19
  batch: StaticObjectBatch;
@@ -1,21 +1,46 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFrame, useLoader } from "@react-three/fiber";
3
- import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useEffect, useMemo, useRef, useState, useCallback } from "react";
4
4
  import * as THREE from "three";
5
+ import { devtools } from "@jgengine/core/devtools/devtools";
5
6
  import { useGameContext } from "@jgengine/react/provider";
6
7
  import { sharedGltfLoader } from "../render/modelLoad.js";
7
8
  import { IsolatedModelPart } from "../render/SceneModels.js";
9
+ import { freezeStaticTransforms } from "../render/staticTransforms.js";
8
10
  import { measureLocalBounds, reportMeasuredBounds } from "../render/measureBounds.js";
9
11
  import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "../render/measureCollisionMesh.js";
10
12
  import { buildScatterModelSources, disposeScatterModelSources } from "../scatter/scatterModels.js";
11
13
  import { useRenderVisibility } from "../visibility/CullingProvider.js";
12
14
  import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
13
- import { canInstanceStaticScene, incompatibleStaticObjectIds, isCompatibleStaticObject, syncStaticObjectSource } from "./staticObjectBatches.js";
15
+ import { canInstanceStaticScene, countStaticBatchDraws, incompatibleStaticObjectIds, isCompatibleStaticObject, STATIC_BATCH_MESH_KEY, syncStaticObjectSource } from "./staticObjectBatches.js";
16
+ /**
17
+ * Report what static object batching is doing to `debug_snapshot().systems`: the grouped batches, the
18
+ * total instances behind them, and how many instances survived per-instance culling this frame. A
19
+ * batch whose instances are all outside the view contributes a draw of zero, so `drawn` is the culling
20
+ * evidence, not a claim.
21
+ * @internal
22
+ */
23
+ export function useStaticObjectBatchProbe(scene, stats) {
24
+ const ref = useRef(stats);
25
+ ref.current = stats;
26
+ useEffect(() => devtools.probes.registerSystem("render/staticBatches", {
27
+ counters: () => ({ ...ref.current, ...countStaticBatchDraws(scene) }),
28
+ state: () => {
29
+ const { draws, drawn } = countStaticBatchDraws(scene);
30
+ return `${ref.current.batches} batches · ${draws} draws · ${drawn}/${ref.current.instances} instances drawn`;
31
+ },
32
+ }), [scene]);
33
+ }
14
34
  function ObjectSource({ source, objects, batch }) {
15
35
  const ctx = useGameContext();
16
36
  const visibility = useRenderVisibility();
17
37
  const ref = useRef(null);
18
38
  const scratch = useMemo(() => new THREE.Object3D(), []);
39
+ const attach = useCallback((mesh) => {
40
+ ref.current = mesh;
41
+ if (mesh !== null)
42
+ freezeStaticTransforms(mesh);
43
+ }, []);
19
44
  useEffect(() => {
20
45
  const mesh = ref.current;
21
46
  return () => { mesh?.dispose(); };
@@ -28,7 +53,7 @@ function ObjectSource({ source, objects, batch }) {
28
53
  isCompatibleStaticObject(object, batch.model, ctx.scene.object.catalog(id)) ? object : null;
29
54
  }, visibility.current, scratch);
30
55
  }, CAMERA_POST_FRAME_PRIORITY + 0.1);
31
- return _jsx("instancedMesh", { ref: ref, args: [source.geometry, source.material, objects.length], castShadow: source.castShadow, receiveShadow: source.receiveShadow, dispose: null });
56
+ return _jsx("instancedMesh", { ref: attach, args: [source.geometry, source.material, objects.length], userData: { [STATIC_BATCH_MESH_KEY]: true }, castShadow: source.castShadow, receiveShadow: source.receiveShadow, dispose: null });
32
57
  }
33
58
  function LoadedStaticObjects({ batch, single }) {
34
59
  const ctx = useGameContext();
@@ -11,8 +11,9 @@ export declare function transformParticleVector(point: Vec3, origin: Vec3, yaw:
11
11
  /** @internal Cosmetic catch-up follows game time, with bounded work after suspension. */
12
12
  export declare function resolveParticleDelta(previousTime: number, gameTime: number): number;
13
13
  /** Renders the existing director and pooled simulator; named bursts reuse the standing pool. @internal */
14
- export declare function WorldParticles({ quality, particleCap, diagnostics }: {
14
+ export declare function WorldParticles({ quality, particleCap, particleBudget, diagnostics }: {
15
15
  quality: GraphicsQuality;
16
16
  particleCap?: number;
17
+ particleBudget?: number;
17
18
  diagnostics?: boolean;
18
19
  }): ReactElement;