@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
@@ -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
+ }
@@ -6,6 +6,7 @@ import { MATERIAL_TEXTURE_SEMANTICS, validateMaterialAsset, validateMaterialAssi
6
6
  import { applyMaterialOverrideToMaterial, MATERIAL_TEXTURE_PROPERTIES, requiresPhysicalMaterial } from "../materialOverride.js";
7
7
  import { configureAuthoredSurface } from "./authoredSurfaceMaterial.js";
8
8
  import { ownModelMaterial } from "./modelRender.js";
9
+ import { meshMatchesName } from "./modelMeshMerge.js";
9
10
  import { useDisposable } from "./useDisposable.js";
10
11
  import { detectKtx2Support, materialKtx2Loader } from "./modelLoad.js";
11
12
  import { resolveAssetBaseUrl } from "./assetBase.js";
@@ -200,7 +201,7 @@ function materialAssignmentTargets(root, assets, assignments, options = {}) {
200
201
  const selected = [];
201
202
  root.traverse(node => {
202
203
  const mesh = node;
203
- if (!mesh.isMesh || (assignment.selector.mesh !== undefined && mesh.name !== assignment.selector.mesh))
204
+ if (!mesh.isMesh || (assignment.selector.mesh !== undefined && !meshMatchesName(mesh, assignment.selector.mesh)))
204
205
  return;
205
206
  const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
206
207
  materials.forEach((material, index) => {
@@ -6,6 +6,7 @@ import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.j
6
6
  import { classifyAssetResponse, } from "@jgengine/core/scene/assetDiagnostics";
7
7
  import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
8
8
  import { resolveAssetBaseUrl } from "./assetBase.js";
9
+ import { mergeModelSubMeshes, mergeSkinnedModelParts } from "./modelMeshMerge.js";
9
10
  import { applyModelSurface } from "./modelSurface.js";
10
11
  /** Model containers already covered by the whole-model fallback probe; not "texture" errors. @internal */
11
12
  const MODEL_URL_RE = /\.(glb|gltf)(\?|#|$)/i;
@@ -212,6 +213,8 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
212
213
  super.load(url, (gltf) => {
213
214
  recordModelTriangles(url, countSceneTriangles(gltf.scene));
214
215
  applyModelSurface(gltf.scene);
216
+ mergeSkinnedModelParts(gltf.scene);
217
+ mergeModelSubMeshes(gltf.scene, gltf.animations);
215
218
  onLoad(gltf);
216
219
  }, onProgress, (event) => {
217
220
  if (onError === undefined)
@@ -0,0 +1,49 @@
1
+ import * as THREE from "three";
2
+ /**
3
+ * Turn load-time sub-mesh merging on or off. On (the default) every loaded model whose meshes share a
4
+ * material collapses to one mesh per material, which is what a game with many instances of a
5
+ * multi-mesh prop needs; a game that must keep per-mesh nodes (named material slots, node visibility)
6
+ * turns it off.
7
+ * @internal
8
+ */
9
+ export declare function setModelSubMeshMerge(enabled: boolean): void;
10
+ /** The node names a merged mesh absorbed, in the order they were merged. Empty for an unmerged mesh. @internal */
11
+ export declare function mergedMeshNames(mesh: THREE.Object3D): readonly string[];
12
+ /**
13
+ * Whether a mesh answers to `name`: its own name, or one of the sub-meshes a load-time merge folded
14
+ * into it. Name-based lookups (material assignment selectors) go through this so a merged model keeps
15
+ * resolving the names its source meshes had.
16
+ * @internal
17
+ */
18
+ export declare function meshMatchesName(mesh: THREE.Object3D, name: string): boolean;
19
+ /** What {@link mergeModelSubMeshes} did to one loaded model. @internal */
20
+ export interface ModelMeshMergeReport {
21
+ /** Meshes removed from the graph because their geometry joined a same-material sibling. */
22
+ merged: number;
23
+ /** Meshes left in place. */
24
+ kept: number;
25
+ }
26
+ /**
27
+ * Collapse a freshly loaded model's meshes that share a material into one mesh per material, baking
28
+ * each mesh's transform relative to `root` into the merged vertices. Runs once per load, so every
29
+ * instance cloned from the model inherits the lower draw count.
30
+ *
31
+ * Skinned meshes, instanced meshes, meshes with morph targets or rigged ancestors, meshes with
32
+ * children, hidden meshes, mirrored meshes, and every model carrying an animation clip are left
33
+ * untouched: a clip can move any node in the file, so baking an animated model's transforms would
34
+ * freeze it.
35
+ * @internal
36
+ */
37
+ export declare function mergeModelSubMeshes(root: THREE.Object3D, animations?: readonly THREE.AnimationClip[]): ModelMeshMergeReport;
38
+ /**
39
+ * Collapse a rigged model's skinned parts that share a skeleton and a material into one skinned mesh,
40
+ * so one character is one draw and one bone matrix upload instead of one per body part. Vertices move
41
+ * into bind space (each part's own `bindMatrix` baked in) and skin indices are remapped onto the
42
+ * union of the parts' bones, which leaves the posed result unchanged. Runs once per load, before any
43
+ * clone, so every instance inherits the lower part count.
44
+ *
45
+ * Parts with a different material, a different attribute layout, morph targets, several materials or
46
+ * children stay as they are. A model that only has one part is untouched.
47
+ * @internal
48
+ */
49
+ export declare function mergeSkinnedModelParts(root: THREE.Object3D): ModelMeshMergeReport;
@@ -0,0 +1,269 @@
1
+ import * as THREE from "three";
2
+ import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
3
+ const MERGED_NAMES_KEY = "jgMergedMeshNames";
4
+ let mergeEnabled = true;
5
+ /**
6
+ * Turn load-time sub-mesh merging on or off. On (the default) every loaded model whose meshes share a
7
+ * material collapses to one mesh per material, which is what a game with many instances of a
8
+ * multi-mesh prop needs; a game that must keep per-mesh nodes (named material slots, node visibility)
9
+ * turns it off.
10
+ * @internal
11
+ */
12
+ export function setModelSubMeshMerge(enabled) {
13
+ mergeEnabled = enabled;
14
+ }
15
+ /** The node names a merged mesh absorbed, in the order they were merged. Empty for an unmerged mesh. @internal */
16
+ export function mergedMeshNames(mesh) {
17
+ const names = mesh.userData[MERGED_NAMES_KEY];
18
+ return Array.isArray(names) ? names : [];
19
+ }
20
+ /**
21
+ * Whether a mesh answers to `name`: its own name, or one of the sub-meshes a load-time merge folded
22
+ * into it. Name-based lookups (material assignment selectors) go through this so a merged model keeps
23
+ * resolving the names its source meshes had.
24
+ * @internal
25
+ */
26
+ export function meshMatchesName(mesh, name) {
27
+ return mesh.name === name || mergedMeshNames(mesh).includes(name);
28
+ }
29
+ function countMeshes(root) {
30
+ let count = 0;
31
+ root.traverse((node) => {
32
+ if (node.isMesh === true)
33
+ count += 1;
34
+ });
35
+ return count;
36
+ }
37
+ /** Skinning, instancing and bone parents are all driven at runtime, so their transforms cannot be baked. */
38
+ function hasRiggedAncestor(mesh, root) {
39
+ let node = mesh;
40
+ while (node !== null && node !== root) {
41
+ if (node.isBone === true)
42
+ return true;
43
+ if (node.isSkinnedMesh === true)
44
+ return true;
45
+ node = node.parent;
46
+ }
47
+ return false;
48
+ }
49
+ /** Attribute layout two geometries must agree on before their vertices can share a draw. */
50
+ function attributeKey(geometry) {
51
+ const parts = [geometry.index === null ? "plain" : "indexed"];
52
+ for (const name of Object.keys(geometry.attributes).sort()) {
53
+ const attribute = geometry.attributes[name];
54
+ parts.push(`${name}:${attribute.itemSize}:${attribute.normalized === true ? 1 : 0}`);
55
+ }
56
+ return parts.join(",");
57
+ }
58
+ function isMergeable(mesh, root) {
59
+ if (mesh.isSkinnedMesh === true || mesh.isInstancedMesh === true)
60
+ return false;
61
+ // A node with children carries them: removing it would orphan them, and merging the children instead
62
+ // is handled by their own groups.
63
+ if (mesh.children.length > 0)
64
+ return false;
65
+ if (mesh.visible !== true)
66
+ return false;
67
+ if (Array.isArray(mesh.material) || mesh.material === undefined || mesh.material === null)
68
+ return false;
69
+ const geometry = mesh.geometry;
70
+ if (geometry === undefined || geometry.attributes.position === undefined)
71
+ return false;
72
+ if (Object.keys(geometry.morphAttributes).length > 0)
73
+ return false;
74
+ return !hasRiggedAncestor(mesh, root);
75
+ }
76
+ /**
77
+ * Collapse a freshly loaded model's meshes that share a material into one mesh per material, baking
78
+ * each mesh's transform relative to `root` into the merged vertices. Runs once per load, so every
79
+ * instance cloned from the model inherits the lower draw count.
80
+ *
81
+ * Skinned meshes, instanced meshes, meshes with morph targets or rigged ancestors, meshes with
82
+ * children, hidden meshes, mirrored meshes, and every model carrying an animation clip are left
83
+ * untouched: a clip can move any node in the file, so baking an animated model's transforms would
84
+ * freeze it.
85
+ * @internal
86
+ */
87
+ export function mergeModelSubMeshes(root, animations = []) {
88
+ if (!mergeEnabled || animations.length > 0)
89
+ return { merged: 0, kept: countMeshes(root) };
90
+ root.updateMatrixWorld(true);
91
+ const rootInverse = root.matrixWorld.clone().invert();
92
+ const groups = new Map();
93
+ let kept = 0;
94
+ root.traverse((node) => {
95
+ const mesh = node;
96
+ if (mesh.isMesh !== true)
97
+ return;
98
+ if (!isMergeable(mesh, root)) {
99
+ kept += 1;
100
+ return;
101
+ }
102
+ // A mirrored node renders with flipped winding, which a baked vertex has no way to express.
103
+ const relative = new THREE.Matrix4().multiplyMatrices(rootInverse, mesh.matrixWorld);
104
+ if (relative.determinant() < 0) {
105
+ kept += 1;
106
+ return;
107
+ }
108
+ const material = mesh.material;
109
+ const key = `${material.uuid}|${attributeKey(mesh.geometry)}`;
110
+ const group = groups.get(key);
111
+ if (group === undefined)
112
+ groups.set(key, { material, meshes: [{ mesh, relative }] });
113
+ else
114
+ group.meshes.push({ mesh, relative });
115
+ });
116
+ let merged = 0;
117
+ for (const group of groups.values()) {
118
+ if (group.meshes.length < 2) {
119
+ kept += group.meshes.length;
120
+ continue;
121
+ }
122
+ const parts = group.meshes.map(({ mesh, relative }) => mesh.geometry.clone().applyMatrix4(relative));
123
+ const geometry = mergeGeometries(parts, false);
124
+ for (const part of parts)
125
+ part.dispose();
126
+ if (geometry === null) {
127
+ kept += group.meshes.length;
128
+ continue;
129
+ }
130
+ geometry.computeBoundingBox();
131
+ geometry.computeBoundingSphere();
132
+ const first = group.meshes[0].mesh;
133
+ const mesh = new THREE.Mesh(geometry, group.material);
134
+ mesh.name = first.name;
135
+ mesh.renderOrder = first.renderOrder;
136
+ mesh.frustumCulled = first.frustumCulled;
137
+ mesh.castShadow = first.castShadow;
138
+ mesh.receiveShadow = first.receiveShadow;
139
+ const names = [...new Set(group.meshes.map(({ mesh: source }) => source.name).filter((name) => name.length > 0))];
140
+ if (names.length > 0)
141
+ mesh.userData[MERGED_NAMES_KEY] = names;
142
+ for (const { mesh: source } of group.meshes)
143
+ source.removeFromParent();
144
+ root.add(mesh);
145
+ merged += group.meshes.length - 1;
146
+ kept += 1;
147
+ }
148
+ return { merged, kept };
149
+ }
150
+ function boneSlot(slots, bone, inverse) {
151
+ const found = slots.findIndex((slot) => slot.bone === bone && slot.inverse.equals(inverse));
152
+ if (found >= 0)
153
+ return found;
154
+ slots.push({ bone, inverse: inverse.clone() });
155
+ return slots.length - 1;
156
+ }
157
+ function isMergeableSkinnedPart(mesh) {
158
+ if (mesh.isSkinnedMesh !== true)
159
+ return false;
160
+ if (mesh.children.length > 0 || mesh.visible !== true)
161
+ return false;
162
+ if (Array.isArray(mesh.material) || mesh.material === undefined || mesh.material === null)
163
+ return false;
164
+ const geometry = mesh.geometry;
165
+ if (geometry.attributes.position === undefined)
166
+ return false;
167
+ if (Object.keys(geometry.morphAttributes).length > 0)
168
+ return false;
169
+ return geometry.getAttribute("skinIndex") !== undefined && geometry.getAttribute("skinWeight") !== undefined;
170
+ }
171
+ /**
172
+ * Collapse a rigged model's skinned parts that share a skeleton and a material into one skinned mesh,
173
+ * so one character is one draw and one bone matrix upload instead of one per body part. Vertices move
174
+ * into bind space (each part's own `bindMatrix` baked in) and skin indices are remapped onto the
175
+ * union of the parts' bones, which leaves the posed result unchanged. Runs once per load, before any
176
+ * clone, so every instance inherits the lower part count.
177
+ *
178
+ * Parts with a different material, a different attribute layout, morph targets, several materials or
179
+ * children stay as they are. A model that only has one part is untouched.
180
+ * @internal
181
+ */
182
+ export function mergeSkinnedModelParts(root) {
183
+ if (!mergeEnabled)
184
+ return { merged: 0, kept: countMeshes(root) };
185
+ root.updateMatrixWorld(true);
186
+ const groups = new Map();
187
+ let kept = 0;
188
+ root.traverse((node) => {
189
+ const mesh = node;
190
+ if (mesh.isMesh !== true)
191
+ return;
192
+ if (!isMergeableSkinnedPart(mesh)) {
193
+ if (mesh.isSkinnedMesh === true)
194
+ kept += 1;
195
+ return;
196
+ }
197
+ const skinned = mesh;
198
+ const bones = skinned.skeleton.bones.map((bone) => bone.uuid).join(",");
199
+ const material = skinned.material;
200
+ const key = `${material.uuid}|${attributeKey(skinned.geometry)}|${bones}`;
201
+ const group = groups.get(key);
202
+ if (group === undefined)
203
+ groups.set(key, { material, meshes: [skinned] });
204
+ else
205
+ group.meshes.push(skinned);
206
+ });
207
+ let merged = 0;
208
+ for (const group of groups.values()) {
209
+ if (group.meshes.length < 2) {
210
+ kept += group.meshes.length;
211
+ continue;
212
+ }
213
+ const slots = [];
214
+ const parts = [];
215
+ for (const mesh of group.meshes) {
216
+ const source = mesh.geometry;
217
+ const part = new THREE.BufferGeometry();
218
+ for (const [name, attribute] of Object.entries(source.attributes))
219
+ part.setAttribute(name, attribute.clone());
220
+ if (source.index !== null)
221
+ part.setIndex(source.index.clone());
222
+ part.applyMatrix4(mesh.bindMatrix);
223
+ const skinIndex = source.getAttribute("skinIndex");
224
+ const skinWeight = source.getAttribute("skinWeight");
225
+ const bySkeletonIndex = mesh.skeleton.bones.map((bone, index) => boneSlot(slots, bone, mesh.skeleton.boneInverses[index]));
226
+ const indices = new Float32Array(skinIndex.count * 4);
227
+ const weights = new Float32Array(skinWeight.count * 4);
228
+ for (let vertex = 0; vertex < skinIndex.count; vertex += 1) {
229
+ for (let channel = 0; channel < 4; channel += 1) {
230
+ indices[vertex * 4 + channel] = bySkeletonIndex[skinIndex.getComponent(vertex, channel)] ?? 0;
231
+ weights[vertex * 4 + channel] = skinWeight.getComponent(vertex, channel);
232
+ }
233
+ }
234
+ part.setAttribute("skinIndex", new THREE.Float32BufferAttribute(indices, 4));
235
+ part.setAttribute("skinWeight", new THREE.Float32BufferAttribute(weights, 4));
236
+ parts.push(part);
237
+ }
238
+ const geometry = mergeGeometries(parts, false);
239
+ for (const part of parts)
240
+ part.dispose();
241
+ if (geometry === null) {
242
+ kept += group.meshes.length;
243
+ continue;
244
+ }
245
+ geometry.computeBoundingBox();
246
+ geometry.computeBoundingSphere();
247
+ const first = group.meshes[0];
248
+ const mesh = new THREE.SkinnedMesh(geometry, group.material);
249
+ // Bind mode "attached" keeps bindMatrixInverse equal to the mesh's own world matrix, which is what
250
+ // makes vertices baked out of bind space pose the same wherever the merged node hangs. An explicit
251
+ // identity bind matrix is required: bind() would otherwise recalculate rest inverses from the
252
+ // current pose and skin every part against the wrong rest.
253
+ mesh.bind(new THREE.Skeleton(slots.map((slot) => slot.bone), slots.map((slot) => slot.inverse)), new THREE.Matrix4());
254
+ mesh.name = first.name;
255
+ mesh.renderOrder = first.renderOrder;
256
+ mesh.frustumCulled = first.frustumCulled;
257
+ mesh.castShadow = first.castShadow;
258
+ mesh.receiveShadow = first.receiveShadow;
259
+ const names = [...new Set(group.meshes.map((part) => part.name).filter((name) => name.length > 0))];
260
+ if (names.length > 0)
261
+ mesh.userData[MERGED_NAMES_KEY] = names;
262
+ for (const source of group.meshes)
263
+ source.removeFromParent();
264
+ root.add(mesh);
265
+ merged += group.meshes.length - 1;
266
+ kept += 1;
267
+ }
268
+ return { merged, kept };
269
+ }
@@ -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;