@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.
- package/CHANGELOG.md +174 -0
- package/dist/GamePlayerShell.d.ts +8 -0
- package/dist/GamePlayerShell.js +15 -1
- package/dist/GameUiPreview.js +3 -1
- package/dist/Shell3dPresentation.js +6 -4
- package/dist/ShellChrome.js +3 -1
- package/dist/camera/GameCameraRig.js +2 -2
- package/dist/camera/GameFirstPersonCamera.js +39 -2
- package/dist/camera/cameraCollision.d.ts +66 -1
- package/dist/camera/cameraCollision.js +72 -1
- package/dist/camera/index.d.ts +1 -1
- package/dist/camera/index.js +1 -1
- package/dist/camera/rigResolve.d.ts +13 -0
- package/dist/camera/rigResolve.js +16 -0
- package/dist/environment/EnvironmentScene.js +118 -10
- package/dist/postfx/shadowPrepass.d.ts +8 -1
- package/dist/postfx/shadowPrepass.js +18 -2
- package/dist/postfx/stylizePass.js +2 -1
- package/dist/render/CascadedShadows.d.ts +31 -1
- package/dist/render/CascadedShadows.js +71 -5
- package/dist/render/CharacterCrowd.d.ts +20 -0
- package/dist/render/CharacterCrowd.js +176 -0
- package/dist/render/SceneLighting.js +27 -3
- package/dist/render/SceneWarmupGate.d.ts +20 -0
- package/dist/render/SceneWarmupGate.js +48 -0
- package/dist/render/characterLod.d.ts +62 -0
- package/dist/render/characterLod.js +165 -0
- package/dist/render/crowdRegistry.d.ts +48 -0
- package/dist/render/crowdRegistry.js +97 -0
- package/dist/render/decalGeometry.d.ts +26 -0
- package/dist/render/decalGeometry.js +54 -0
- package/dist/render/decalTexture.d.ts +13 -0
- package/dist/render/decalTexture.js +114 -0
- package/dist/render/materialAsset.js +2 -1
- package/dist/render/modelLoad.js +3 -0
- package/dist/render/modelMeshMerge.d.ts +49 -0
- package/dist/render/modelMeshMerge.js +269 -0
- package/dist/render/modelWind.d.ts +28 -0
- package/dist/render/modelWind.js +103 -0
- package/dist/render/shaderWarmup.d.ts +69 -0
- package/dist/render/shaderWarmup.js +120 -0
- package/dist/render/shadowCasters.d.ts +28 -0
- package/dist/render/shadowCasters.js +95 -0
- package/dist/render/staticTransforms.d.ts +20 -0
- package/dist/render/staticTransforms.js +32 -0
- package/dist/render/useFootIk.d.ts +11 -3
- package/dist/render/useFootIk.js +35 -7
- package/dist/render/useModelAnimation.d.ts +16 -2
- package/dist/render/useModelAnimation.js +73 -4
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +26 -1
- package/dist/render/useRagdoll.d.ts +32 -0
- package/dist/render/useRagdoll.js +105 -0
- package/dist/scatter/scatterModels.js +2 -0
- package/dist/scene/DecalRenderer.d.ts +2 -0
- package/dist/scene/DecalRenderer.js +74 -0
- package/dist/scene/GrassFieldRenderer.js +3 -2
- package/dist/scene/builtinSceneKindRenderers.js +2 -0
- package/dist/settings/SettingsMenu.d.ts +1 -1
- package/dist/settings/SettingsMenu.js +10 -79
- package/dist/settings/settingsController.js +3 -0
- package/dist/terrain/GrassCover.d.ts +21 -0
- package/dist/terrain/GrassCover.js +84 -0
- package/dist/terrain/grassCoverGeometry.d.ts +40 -0
- package/dist/terrain/grassCoverGeometry.js +112 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
- package/dist/terrain/terrainDetailMaterial.js +54 -1
- package/dist/terrain/terrainSurfaces.d.ts +6 -0
- package/dist/terrain/terrainSurfaces.js +10 -0
- package/dist/world/StaticObjectModels.d.ts +13 -0
- package/dist/world/StaticObjectModels.js +28 -3
- package/dist/world/WorldParticles.d.ts +2 -1
- package/dist/world/WorldParticles.js +29 -10
- package/dist/world/WorldScene.js +52 -14
- package/dist/world/entityPose.d.ts +17 -0
- package/dist/world/entityPose.js +42 -0
- package/dist/world/staticObjectBatches.d.ts +33 -5
- package/dist/world/staticObjectBatches.js +40 -6
- package/package.json +4 -4
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-ui/SKILL.md +1 -0
- package/skills/jgengine-ui/api.md +231 -9
- package/skills/jgengine-ui/capabilities.md +107 -1
- package/skills/jgengine-ui/reference-primitives.md +2 -0
- package/skills/jgengine-ui/reference.md +8 -0
- package/skills/jgengine-world/SKILL.md +8 -0
- package/skills/jgengine-world/api.md +243 -11
- package/skills/jgengine-world/capabilities.md +61 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
- 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
|
|
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) => {
|
package/dist/render/modelLoad.js
CHANGED
|
@@ -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;
|