@jgengine/shell 0.19.0 → 0.21.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 (203) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +113 -15
  5. package/dist/Shell3dPresentation.d.ts +2 -2
  6. package/dist/Shell3dPresentation.js +49 -7
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +11 -1
  10. package/dist/ShellHudPresentation.js +20 -6
  11. package/dist/audio/audioEngine.d.ts +16 -0
  12. package/dist/audio/audioEngine.js +178 -89
  13. package/dist/audio/audioWire.js +2 -2
  14. package/dist/audio/synthEngine.d.ts +7 -1
  15. package/dist/audio/synthEngine.js +103 -38
  16. package/dist/camera/GameCameraRig.js +2 -1
  17. package/dist/camera/GameFirstPersonCamera.d.ts +2 -2
  18. package/dist/camera/GameFirstPersonCamera.js +17 -13
  19. package/dist/camera/cameraRigs.js +8 -2
  20. package/dist/camera/chaseHeading.d.ts +9 -0
  21. package/dist/camera/chaseHeading.js +10 -0
  22. package/dist/camera/customMuzzle.d.ts +14 -0
  23. package/dist/camera/customMuzzle.js +68 -0
  24. package/dist/camera/index.d.ts +1 -0
  25. package/dist/camera/index.js +1 -0
  26. package/dist/camera/rigResolve.d.ts +3 -0
  27. package/dist/camera/rigResolve.js +11 -0
  28. package/dist/combat/ProjectileModels.d.ts +31 -0
  29. package/dist/combat/ProjectileModels.js +70 -0
  30. package/dist/commandSink.d.ts +1 -2
  31. package/dist/commandSink.js +3 -2
  32. package/dist/defineGame.d.ts +12 -5
  33. package/dist/defineGame.js +24 -9
  34. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  35. package/dist/devtools/DevtoolsOverlay.js +14 -168
  36. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  37. package/dist/devtools/DevtoolsRuntime.js +61 -0
  38. package/dist/devtools/agentBridge.js +1 -1
  39. package/dist/devtools/devtoolsReports.d.ts +68 -0
  40. package/dist/devtools/devtoolsReports.js +100 -0
  41. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  42. package/dist/devtools/latencyInstrumentation.js +11 -0
  43. package/dist/devtools/playtestProbe.d.ts +4 -0
  44. package/dist/devtools/playtestProbe.js +22 -0
  45. package/dist/drivers/FrameDriver.js +25 -21
  46. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  47. package/dist/drivers/HudOnlyDriver.js +32 -14
  48. package/dist/environment/EnvironmentScene.js +11 -10
  49. package/dist/inputSink.d.ts +6 -6
  50. package/dist/inputSink.js +60 -21
  51. package/dist/lazyShell3dPresentation.d.ts +9 -0
  52. package/dist/lazyShell3dPresentation.js +24 -0
  53. package/dist/materialOverride.d.ts +42 -0
  54. package/dist/materialOverride.js +95 -36
  55. package/dist/multiplayer.d.ts +33 -3
  56. package/dist/multiplayer.js +105 -22
  57. package/dist/pointer/pointerService.d.ts +2 -0
  58. package/dist/pointer/pointerService.js +12 -2
  59. package/dist/postfx/PostProcessing.d.ts +10 -0
  60. package/dist/postfx/PostProcessing.js +9 -41
  61. package/dist/postfx/gradeShader.d.ts +1 -1
  62. package/dist/postfx/gradeShader.js +30 -2
  63. package/dist/postfx/shadowPrepass.d.ts +3 -0
  64. package/dist/postfx/shadowPrepass.js +18 -0
  65. package/dist/presentationRecovery.d.ts +75 -0
  66. package/dist/presentationRecovery.js +204 -0
  67. package/dist/presentationRecovery3d.d.ts +4 -0
  68. package/dist/presentationRecovery3d.js +32 -0
  69. package/dist/presentationResolve.d.ts +8 -1
  70. package/dist/presentationResolve.js +5 -2
  71. package/dist/render/CascadedShadows.d.ts +7 -2
  72. package/dist/render/CascadedShadows.js +98 -20
  73. package/dist/render/EnvironmentLighting.js +74 -35
  74. package/dist/render/HairCards.d.ts +7 -0
  75. package/dist/render/HairCards.js +23 -0
  76. package/dist/render/SceneModels.js +51 -77
  77. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  78. package/dist/render/authoredSurfaceMaterial.js +108 -0
  79. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  80. package/dist/render/materialAppearanceSignals.js +58 -0
  81. package/dist/render/materialAsset.d.ts +98 -0
  82. package/dist/render/materialAsset.js +454 -0
  83. package/dist/render/measureBounds.js +16 -3
  84. package/dist/render/measureCollisionMesh.js +8 -1
  85. package/dist/render/modelAssets.d.ts +8 -0
  86. package/dist/render/modelAssets.js +43 -0
  87. package/dist/render/modelBindPose.d.ts +5 -0
  88. package/dist/render/modelBindPose.js +35 -0
  89. package/dist/render/modelLoad.d.ts +3 -0
  90. package/dist/render/modelLoad.js +9 -0
  91. package/dist/render/modelNodeVisibility.d.ts +6 -0
  92. package/dist/render/modelNodeVisibility.js +18 -0
  93. package/dist/render/modelRender.d.ts +18 -1
  94. package/dist/render/modelRender.js +81 -3
  95. package/dist/render/projectedOverdraw.d.ts +35 -0
  96. package/dist/render/projectedOverdraw.js +204 -0
  97. package/dist/render/resolveModel.d.ts +12 -2
  98. package/dist/render/resolveModel.js +21 -4
  99. package/dist/render/rigNode.d.ts +26 -0
  100. package/dist/render/rigNode.js +32 -0
  101. package/dist/render/spriteRender.d.ts +4 -0
  102. package/dist/render/spriteRender.js +5 -0
  103. package/dist/render/staticShapeInstances.d.ts +34 -0
  104. package/dist/render/staticShapeInstances.js +78 -0
  105. package/dist/render/useFootIk.js +76 -11
  106. package/dist/render/useModelAnimation.d.ts +28 -2
  107. package/dist/render/useModelAnimation.js +194 -69
  108. package/dist/render/useModelInstance.d.ts +25 -0
  109. package/dist/render/useModelInstance.js +43 -0
  110. package/dist/scatter/scatterModels.d.ts +3 -2
  111. package/dist/scatter/scatterModels.js +15 -7
  112. package/dist/scene/AuthoredScene.d.ts +14 -6
  113. package/dist/scene/AuthoredScene.js +30 -9
  114. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  115. package/dist/settings/SettingsMenu.js +11 -18
  116. package/dist/settings/SettingsRuntime.d.ts +5 -0
  117. package/dist/settings/SettingsRuntime.js +29 -2
  118. package/dist/shellControlSuspension.d.ts +18 -0
  119. package/dist/shellControlSuspension.js +28 -0
  120. package/dist/shellInputPublication.d.ts +28 -0
  121. package/dist/shellInputPublication.js +49 -0
  122. package/dist/shellPointerInput.d.ts +18 -0
  123. package/dist/shellPointerInput.js +49 -0
  124. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  125. package/dist/structures/BuildingKitBatch.js +34 -59
  126. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  127. package/dist/structures/BuildingRenderBudget.js +54 -0
  128. package/dist/structures/GeneratedBuilding.js +13 -8
  129. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  130. package/dist/structures/buildingKitMaterials.js +70 -0
  131. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  132. package/dist/structures/buildingSpatialBatch.js +45 -0
  133. package/dist/terrain/GrassField.d.ts +2 -1
  134. package/dist/terrain/GrassField.js +59 -5
  135. package/dist/terrain/grassGeometry.d.ts +10 -0
  136. package/dist/terrain/grassGeometry.js +55 -0
  137. package/dist/terrain/grassMaterial.js +2 -2
  138. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  139. package/dist/terrain/terrainDetailMaterial.js +7 -7
  140. package/dist/touch/TouchControlsOverlay.js +4 -4
  141. package/dist/useShellMultiplayerSync.js +4 -0
  142. package/dist/vfx/ParticleField.d.ts +23 -20
  143. package/dist/vfx/ParticleField.js +168 -50
  144. package/dist/visibility/CullingProvider.js +2 -62
  145. package/dist/visibility/cullingDriver.d.ts +9 -0
  146. package/dist/visibility/cullingDriver.js +68 -0
  147. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  148. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  149. package/dist/weather/DustField.d.ts +2 -1
  150. package/dist/weather/DustField.js +17 -3
  151. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  152. package/dist/weather/FireSpreadLayer.js +5 -11
  153. package/dist/weather/LightningStrike.d.ts +4 -1
  154. package/dist/weather/LightningStrike.js +13 -17
  155. package/dist/weather/RainField.d.ts +2 -1
  156. package/dist/weather/RainField.js +17 -3
  157. package/dist/weather/RainImpactField.d.ts +16 -0
  158. package/dist/weather/RainImpactField.js +89 -0
  159. package/dist/weather/SnowField.d.ts +2 -1
  160. package/dist/weather/SnowField.js +17 -3
  161. package/dist/weather/WeatherLayer.d.ts +11 -2
  162. package/dist/weather/WeatherLayer.js +30 -6
  163. package/dist/weather/index.d.ts +2 -0
  164. package/dist/weather/index.js +2 -0
  165. package/dist/weather/weatherMath.d.ts +2 -0
  166. package/dist/weather/weatherMath.js +8 -0
  167. package/dist/weather/weatherShelter.d.ts +18 -0
  168. package/dist/weather/weatherShelter.js +75 -0
  169. package/dist/weather/weatherUniforms.d.ts +40 -1
  170. package/dist/weather/weatherUniforms.js +29 -9
  171. package/dist/world/AuthoredFlocks.d.ts +22 -0
  172. package/dist/world/AuthoredFlocks.js +87 -0
  173. package/dist/world/StaticObjectModels.d.ts +8 -0
  174. package/dist/world/StaticObjectModels.js +75 -0
  175. package/dist/world/WorldHud.d.ts +14 -6
  176. package/dist/world/WorldHud.js +25 -12
  177. package/dist/world/WorldParticles.d.ts +10 -23
  178. package/dist/world/WorldParticles.js +209 -61
  179. package/dist/world/WorldScene.d.ts +3 -1
  180. package/dist/world/WorldScene.js +41 -26
  181. package/dist/world/staticObjectBatches.d.ts +35 -0
  182. package/dist/world/staticObjectBatches.js +163 -0
  183. package/dist/world/worldBarSamples.d.ts +37 -33
  184. package/dist/world/worldBarSamples.js +117 -75
  185. package/package.json +4 -4
  186. package/skills/ce-handoff/SKILL.md +1 -1
  187. package/skills/jgengine/SKILL.md +22 -2
  188. package/skills/jgengine/api.md +28 -17
  189. package/skills/jgengine/recipes/minimal-game.md +7 -2
  190. package/skills/jgengine-ui/SKILL.md +3 -1
  191. package/skills/jgengine-ui/api.md +204 -67
  192. package/skills/jgengine-ui/capabilities.md +115 -1
  193. package/skills/jgengine-ui/reference-primitives.md +93 -3
  194. package/skills/jgengine-ui/reference.md +2 -0
  195. package/skills/jgengine-world/SKILL.md +12 -3
  196. package/skills/jgengine-world/api.md +200 -57
  197. package/skills/jgengine-world/capabilities.md +116 -3
  198. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  199. package/skills/jgengine-world/recipes/controls.md +4 -0
  200. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  201. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  202. package/skills/jgengine-world/reference.md +152 -5
  203. package/skills/jgengine-world/visibility.md +28 -1
@@ -0,0 +1,454 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useLoader, useThree } from "@react-three/fiber";
3
+ import { useMemo } from "react";
4
+ import * as THREE from "three";
5
+ import { MATERIAL_TEXTURE_SEMANTICS, validateMaterialAsset, validateMaterialAssignments } from "@jgengine/core/material/materialAsset";
6
+ import { applyMaterialOverrideToMaterial, MATERIAL_TEXTURE_PROPERTIES, requiresPhysicalMaterial } from "../materialOverride.js";
7
+ import { configureAuthoredSurface } from "./authoredSurfaceMaterial.js";
8
+ import { ownModelMaterial } from "./modelRender.js";
9
+ import { useDisposable } from "./useDisposable.js";
10
+ import { detectKtx2Support, materialKtx2Loader } from "./modelLoad.js";
11
+ import { resolveAssetBaseUrl } from "./assetBase.js";
12
+ /** Shares the model transcoder and loader cache while loading native authored images normally. @internal */
13
+ export class MaterialTextureLoader extends THREE.Loader {
14
+ load(url, onLoad, onProgress, onError) {
15
+ const resolved = resolveAssetBaseUrl(url);
16
+ if (/\.ktx2(?:[?#]|$)/i.test(url))
17
+ return materialKtx2Loader().load(resolved, texture => onLoad?.(texture), onProgress, onError);
18
+ new THREE.TextureLoader(this.manager).load(resolved, onLoad, onProgress, onError);
19
+ }
20
+ }
21
+ const wrapping = { repeat: THREE.RepeatWrapping, clamp: THREE.ClampToEdgeWrapping, mirror: THREE.MirroredRepeatWrapping };
22
+ const filters = { nearest: THREE.NearestFilter, linear: THREE.LinearFilter, "nearest-mipmap-nearest": THREE.NearestMipmapNearestFilter, "nearest-mipmap-linear": THREE.NearestMipmapLinearFilter, "linear-mipmap-nearest": THREE.LinearMipmapNearestFilter, "linear-mipmap-linear": THREE.LinearMipmapLinearFilter };
23
+ /**
24
+ * Validate and configure an owned texture view while retaining the loader's immutable source.
25
+ * @capability material-texture-metadata configure owned map views with semantic color space, packed channels, UV transforms and sampling
26
+ */
27
+ export function configureMaterialTexture(role, metadata, source) {
28
+ const expected = MATERIAL_TEXTURE_SEMANTICS[role].channel;
29
+ if (metadata.channel !== undefined && metadata.channel !== expected && !(role === "color" && metadata.channel === "rgb"))
30
+ throw new Error(`Texture ${role} requires ${expected} channels in Three.js; repack ${metadata.url}`);
31
+ if (metadata.compression !== undefined && (metadata.compression === "ktx2" || metadata.compression === "basisu") && !source.isCompressedTexture)
32
+ throw new Error(`Compressed texture ${metadata.url} requires the KTX2 loader; embed it in glTF or use an uncompressed authored map`);
33
+ const color = ["color", "emissive", "sheenColor", "specularColor"].includes(role);
34
+ if (metadata.colorSpace !== (color ? "srgb" : "linear"))
35
+ throw new Error(`Texture ${role} requires ${color ? "srgb" : "linear"} color space`);
36
+ const texture = source.clone();
37
+ texture.colorSpace = color ? THREE.SRGBColorSpace : THREE.NoColorSpace;
38
+ texture.channel = metadata.uvSet ?? 0;
39
+ if (metadata.transform !== undefined) {
40
+ texture.offset.set(...metadata.transform.offset ?? [0, 0]);
41
+ texture.repeat.set(...metadata.transform.scale ?? [1, 1]);
42
+ texture.rotation = metadata.transform.rotation ?? 0;
43
+ }
44
+ if (metadata.sampler?.wrapS !== undefined)
45
+ texture.wrapS = wrapping[metadata.sampler.wrapS];
46
+ if (metadata.sampler?.wrapT !== undefined)
47
+ texture.wrapT = wrapping[metadata.sampler.wrapT];
48
+ if (metadata.sampler?.minFilter !== undefined)
49
+ texture.minFilter = filters[metadata.sampler.minFilter];
50
+ if (metadata.sampler?.magFilter !== undefined)
51
+ texture.magFilter = filters[metadata.sampler.magFilter];
52
+ texture.anisotropy = metadata.sampler?.anisotropy ?? 1;
53
+ texture.needsUpdate = true;
54
+ return texture;
55
+ }
56
+ /**
57
+ * Physical defaults adapt a declared construction; explicit authored parameters remain authoritative.
58
+ * @capability material-assets resolve fabric and hair construction into sparse physical appearance parameters
59
+ */
60
+ export function materialAssetSurface(asset, overrides = {}) {
61
+ const errors = validateMaterialAsset(asset).filter(diagnostic => diagnostic.severity === "error");
62
+ if (errors.length > 0)
63
+ throw new Error(`Material ${asset.id}: ${errors.map(error => `${error.path}: ${error.message}`).join("; ")}`);
64
+ const fabric = asset.fabric;
65
+ const hair = asset.hair;
66
+ return {
67
+ ...(fabric?.construction === "fuzzy" ? { sheen: 1, sheenColor: asset.surface.color ?? "#ffffff", sheenRoughness: 0.8 } : {}),
68
+ ...(fabric?.construction === "woven" ? { anisotropy: 0.65, anisotropyRotation: fabric.weaveDirection ?? 0 } : {}),
69
+ ...(hair !== undefined ? { anisotropy: 0.8, anisotropyRotation: hair.strandDirection ?? Math.PI / 2, ...(hair.geometry === "cards" ? { alphaMode: "mask", alphaCutoff: 0.5, doubleSided: true } : {}) } : {}),
70
+ ...asset.surface,
71
+ ...overrides,
72
+ };
73
+ }
74
+ const materialAssetBases = new WeakMap();
75
+ function applyNormalConventions(material, asset) {
76
+ if (asset.textures?.normal?.normalConvention === "directx")
77
+ material.normalScale.y *= -1;
78
+ if (asset.textures?.clearcoatNormal?.normalConvention === "directx")
79
+ material.clearcoatNormalScale.y *= -1;
80
+ }
81
+ function applyFabric(material, asset) {
82
+ const fabric = asset.fabric;
83
+ if (fabric?.construction !== "woven")
84
+ return;
85
+ const direction = fabric.weaveDirection ?? 0;
86
+ const scale = fabric.threadScale ?? 80;
87
+ const strength = fabric.normalStrength ?? 0.12;
88
+ const variation = fabric.variation ?? 0.05;
89
+ if (![direction, scale, strength, variation].every(Number.isFinite) || scale <= 0)
90
+ throw new Error(`Fabric ${asset.id} requires finite weave settings and positive threadScale`);
91
+ const previousCompile = material.onBeforeCompile;
92
+ const previousKey = material.customProgramCacheKey();
93
+ material.onBeforeCompile = (shader, renderer) => {
94
+ previousCompile.call(material, shader, renderer);
95
+ shader.uniforms.uJgFabric = { value: new THREE.Vector4(scale, strength, variation, direction) };
96
+ shader.vertexShader = shader.vertexShader.replace("#include <common>", "#include <common>\nvarying vec2 vJgFabricUv;").replace("#include <uv_vertex>", "#include <uv_vertex>\nvJgFabricUv = uv;");
97
+ shader.fragmentShader = shader.fragmentShader.replace("#include <common>", "#include <common>\nvarying vec2 vJgFabricUv;\nuniform vec4 uJgFabric;")
98
+ .replace("#include <roughnessmap_fragment>", `#include <roughnessmap_fragment>
99
+ float jgCos = cos(uJgFabric.w), jgSin = sin(uJgFabric.w);
100
+ mat2 jgRotation = mat2(jgCos, -jgSin, jgSin, jgCos);
101
+ vec2 jgThreads = jgRotation * vJgFabricUv * uJgFabric.x * 6.28318530718;
102
+ vec2 jgThreadFilter = 1.0 - smoothstep(vec2(1.0), vec2(3.14159265359), fwidth(jgThreads));
103
+ roughnessFactor = clamp(roughnessFactor + sin(jgThreads.x) * sin(jgThreads.y) * jgThreadFilter.x * jgThreadFilter.y * uJgFabric.z, 0.04, 1.0);`)
104
+ .replace("#include <normal_fragment_maps>", `#include <normal_fragment_maps>
105
+ vec3 jgDx = dFdx(-vViewPosition), jgDy = dFdy(-vViewPosition);
106
+ vec2 jgUvDx = dFdx(vJgFabricUv), jgUvDy = dFdy(vJgFabricUv);
107
+ vec3 jgT = jgDx * jgUvDy.y - jgDy * jgUvDx.y;
108
+ vec3 jgB = -jgDx * jgUvDy.x + jgDy * jgUvDx.x;
109
+ float jgFrameLength = max(max(length(jgT), length(jgB)), 0.000001);
110
+ vec2 jgSlope = transpose(jgRotation) * (cos(jgThreads) * jgThreadFilter * uJgFabric.y);
111
+ normal = normalize(normal + (jgT * jgSlope.x + jgB * jgSlope.y) / jgFrameLength);`);
112
+ };
113
+ material.customProgramCacheKey = () => `${previousKey}|jg-fabric:v1`;
114
+ material.needsUpdate = true;
115
+ }
116
+ function applyHair(material, asset) {
117
+ const hair = asset.hair;
118
+ if (hair?.geometry !== "cards")
119
+ return;
120
+ const strength = hair.backlightStrength ?? 0.25;
121
+ if (strength === 0)
122
+ return;
123
+ const tint = new THREE.Color(hair.backlightColor ?? asset.surface.color ?? "#ffffff");
124
+ material.userData.jgHairBacklightStrength = strength;
125
+ const previousCompile = material.onBeforeCompile;
126
+ const previousKey = material.customProgramCacheKey();
127
+ material.onBeforeCompile = function (shader, renderer) {
128
+ previousCompile.call(this, shader, renderer);
129
+ const uniform = { value: strength * (this.userData.jgHairLightExposure ?? 1) };
130
+ this.userData.jgHairBacklightUniform = uniform;
131
+ shader.uniforms.uJgHairBacklight = uniform;
132
+ shader.uniforms.uJgHairTint = { value: tint };
133
+ const directLighting = /void\s+RE_Direct_Physical\s*\([^)]*\)\s*\{/;
134
+ const chunk = THREE.ShaderChunk.lights_physical_pars_fragment;
135
+ if (!directLighting.test(chunk))
136
+ throw new Error(`Three.js r${THREE.REVISION} physical shader lacks RE_Direct_Physical for the hair-card backlight adapter`);
137
+ shader.fragmentShader = shader.fragmentShader.replace("#include <common>", "#include <common>\nuniform float uJgHairBacklight;\nuniform vec3 uJgHairTint;")
138
+ .replace("#include <lights_physical_pars_fragment>", chunk.replace(directLighting, declaration => `${declaration}
139
+ float jgHairBack = saturate(-dot(geometryNormal, directLight.direction));
140
+ float jgHairForward = pow(saturate(dot(-directLight.direction, geometryViewDir)), 4.0);
141
+ reflectedLight.directDiffuse += directLight.color * uJgHairTint * (jgHairBack * mix(0.25, 1.0, jgHairForward) * uJgHairBacklight * RECIPROCAL_PI);`));
142
+ };
143
+ material.customProgramCacheKey = () => `${previousKey}|jg-hair-cards:v2`;
144
+ material.needsUpdate = true;
145
+ }
146
+ /**
147
+ * Apply one reusable material's parameters, map views and construction to an imported PBR slot.
148
+ * @capability material-assets clone and configure an imported PBR slot while retaining its omitted physical features and maps
149
+ */
150
+ export function applyMaterialAsset(material, asset, textures, overrides) {
151
+ const surface = materialAssetSurfaceForSlot(material, asset, overrides);
152
+ const source = materialAssetBases.get(material) ?? material;
153
+ const target = applyMaterialOverrideToMaterial(source, surface, true, textures);
154
+ try {
155
+ materialAssetBases.set(target, source);
156
+ applyNormalConventions(target, asset);
157
+ applyFabric(target, asset);
158
+ applyHair(target, asset);
159
+ target.userData = { ...target.userData, jgMaterialAsset: asset.id };
160
+ return target;
161
+ }
162
+ catch (error) {
163
+ try {
164
+ target.dispose();
165
+ }
166
+ finally {
167
+ throw error;
168
+ }
169
+ }
170
+ }
171
+ function materialAssetSurfaceForSlot(material, asset, overrides, baseAlphaMode) {
172
+ if (!material.isMeshStandardMaterial)
173
+ throw new Error(`Material ${asset.id} requires an imported standard or physical PBR slot`);
174
+ const source = materialAssetBases.get(material) ?? material;
175
+ const surface = materialAssetSurface(asset, overrides);
176
+ const transparent = baseAlphaMode === undefined ? source.transparent : baseAlphaMode === "blend";
177
+ if ((surface.transmission ?? 0) > 0 && surface.alphaMode === undefined && transparent)
178
+ throw new Error(`Material ${asset.id} transmission requires an explicit opaque or mask alphaMode on the imported blended slot`);
179
+ return surface;
180
+ }
181
+ /**
182
+ * Check selectors, PBR slots, UVs and construction prerequisites before allocating material or texture views.
183
+ * Optional role restrictions apply only to referenced assets; imported maps remain untouched.
184
+ * @capability material-slot-selection preflight native model assignments without allocating or changing imported resources
185
+ */
186
+ export function validateMaterialAssignmentTargets(root, assets, assignments, options = {}) {
187
+ materialAssignmentTargets(root, assets, assignments, options);
188
+ }
189
+ function materialAssignmentTargets(root, assets, assignments, options = {}) {
190
+ if (options.disallowedTextureRoles?.length) {
191
+ const errors = validateMaterialAssignments(assignments, assets, options).filter(diagnostic => diagnostic.code === "unsupported-texture-role");
192
+ if (errors.length > 0)
193
+ throw new Error(errors.map(error => error.message).join("; "));
194
+ }
195
+ const byId = new Map(assets.map(asset => [asset.id, asset]));
196
+ const matches = assignments.map(assignment => {
197
+ const asset = byId.get(assignment.materialId);
198
+ if (asset === undefined)
199
+ throw new Error(`Unknown material asset ${assignment.materialId}`);
200
+ const selected = [];
201
+ root.traverse(node => {
202
+ const mesh = node;
203
+ if (!mesh.isMesh || (assignment.selector.mesh !== undefined && mesh.name !== assignment.selector.mesh))
204
+ return;
205
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
206
+ materials.forEach((material, index) => {
207
+ if (assignment.selector.slot !== undefined && material.name !== assignment.selector.slot)
208
+ return;
209
+ if (assignment.selector.slotIndex !== undefined && index !== assignment.selector.slotIndex)
210
+ return;
211
+ if (!material.isMeshStandardMaterial)
212
+ throw new Error(`Slot ${mesh.name}/${index} is not a supported PBR material`);
213
+ if ((asset.fabric?.construction === "woven" || asset.hair !== undefined || (materialAssetSurface(asset, assignment.overrides).anisotropy ?? 0) > 0) && (mesh.geometry.getAttribute("uv") === undefined || mesh.geometry.getAttribute("normal") === undefined))
214
+ throw new Error(`Directional material ${asset.id} requires mesh UVs and normals`);
215
+ for (const [role, metadata] of Object.entries(asset.textures ?? {})) {
216
+ const uv = metadata.uvSet ?? 0;
217
+ if (mesh.geometry.getAttribute(uv === 0 ? "uv" : `uv${uv}`) === undefined)
218
+ throw new Error(`Texture ${role} on ${mesh.name} requires UV set ${uv}`);
219
+ }
220
+ selected.push({ mesh, index });
221
+ });
222
+ });
223
+ if (selected.length === 0)
224
+ throw new Error(`Material selector ${JSON.stringify(assignment.selector)} matched no slots`);
225
+ return { assignment, asset, selected };
226
+ });
227
+ const final = new Map();
228
+ for (const { assignment, asset, selected } of matches)
229
+ for (const { mesh, index } of selected) {
230
+ let slots = final.get(mesh);
231
+ if (slots === undefined) {
232
+ slots = new Map();
233
+ final.set(mesh, slots);
234
+ }
235
+ slots.set(index, { assignment, asset });
236
+ }
237
+ for (const [mesh, slots] of final)
238
+ for (const [index, { assignment, asset }] of slots) {
239
+ const source = Array.isArray(mesh.material) ? mesh.material[index] : mesh.material;
240
+ materialAssetSurfaceForSlot(source, asset, assignment.overrides, options.baseAlphaMode);
241
+ }
242
+ return final;
243
+ }
244
+ /**
245
+ * Apply intersecting mesh/slot selectors without changing omitted imported slots. Returns caller-owned replacements; model clones register them only after success.
246
+ * @capability material-assets assign reusable physical assets to independent imported model slots
247
+ */
248
+ export function applyMaterialAssignments(root, assets, assignments, textures = new Map()) {
249
+ const final = materialAssignmentTargets(root, assets, assignments);
250
+ const owned = [];
251
+ const originals = [];
252
+ try {
253
+ for (const [mesh, slots] of final)
254
+ for (const [index, { assignment, asset }] of slots) {
255
+ const source = Array.isArray(mesh.material) ? mesh.material[index] : mesh.material;
256
+ const target = applyMaterialAsset(source, asset, textures.get(asset.id), assignment.overrides);
257
+ originals.push({ mesh, index, source });
258
+ owned.push(target);
259
+ if (Array.isArray(mesh.material))
260
+ mesh.material[index] = target;
261
+ else
262
+ mesh.material = target;
263
+ }
264
+ for (const material of owned)
265
+ ownModelMaterial(root, material);
266
+ return owned;
267
+ }
268
+ catch (error) {
269
+ for (const { mesh, index, source } of originals) {
270
+ if (Array.isArray(mesh.material))
271
+ mesh.material[index] = source;
272
+ else
273
+ mesh.material = source;
274
+ }
275
+ for (const material of owned) {
276
+ try {
277
+ material.dispose();
278
+ }
279
+ catch { /* Preserve the construction error while draining every owned target. */ }
280
+ }
281
+ throw error;
282
+ }
283
+ }
284
+ /**
285
+ * Load map views, own their cleanup and build the same physical shader used by model assignments.
286
+ * @capability material-assets own a serializable material asset's textures and runtime physical shader for a custom renderer
287
+ */
288
+ export function useMaterialAssetMaterial(asset, options = {}) {
289
+ const key = JSON.stringify([asset, options]);
290
+ const entries = useMemo(() => Object.entries(asset.textures ?? {}), [key]);
291
+ const renderer = useThree(state => state.gl);
292
+ if (entries.some(([, metadata]) => /\.ktx2(?:[?#]|$)/i.test(metadata.url)))
293
+ detectKtx2Support(renderer);
294
+ const loaded = useLoader(MaterialTextureLoader, entries.map(([, metadata]) => metadata.url));
295
+ const resources = useDisposable(() => {
296
+ const textures = {};
297
+ let material;
298
+ try {
299
+ entries.forEach(([role, metadata], index) => { textures[role] = configureMaterialTexture(role, metadata, loaded[index]); });
300
+ const surface = materialAssetSurface(asset, options.overrides);
301
+ material = requiresPhysicalMaterial(surface, textures) ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial();
302
+ configureAuthoredSurface(material, { ...surface, anisotropy: undefined, surfaceAnisotropy: surface.anisotropy, repeatMetres: options.repeatMetres }, textures, options.shape ?? "box");
303
+ applyNormalConventions(material, asset);
304
+ applyFabric(material, asset);
305
+ applyHair(material, asset);
306
+ material.userData.jgMaterialAsset = asset.id;
307
+ return [material, ...Object.values(textures)];
308
+ }
309
+ catch (error) {
310
+ material?.dispose();
311
+ Object.values(textures).forEach(texture => texture.dispose());
312
+ throw error;
313
+ }
314
+ }, [key, loaded]);
315
+ return resources[0];
316
+ }
317
+ /**
318
+ * Mount a serializable material asset on an authored primitive, sharing model-slot rendering and owned resource cleanup.
319
+ * @capability material-assets render authored physical surfaces, woven detail and approximate hair-card appearance
320
+ */
321
+ export function MaterialAssetSurface({ asset, ...options }) {
322
+ const material = useMaterialAssetMaterial(asset, options);
323
+ return _jsx("primitive", { object: material, attach: "material" });
324
+ }
325
+ /**
326
+ * Estimate texture allocation from loaded dimensions/mips and report pass/coverage risks. Compressed maps count actual mip bytes; uncompressed bytes bound texture views that may share uploads. This does not measure pixel overdraw.
327
+ * @capability material-resource-metrics inspect loaded texture byte bounds and physical, coverage and transmission participation
328
+ */
329
+ export function materialResourceMetrics(materials) {
330
+ const unique = new Set(materials);
331
+ const textures = new Set();
332
+ let textureBytes = 0, unknownTextureSizes = 0, blendedMaterials = 0, maskedMaterials = 0, transmissionMaterials = 0, doubleSidedMaterials = 0, physicalMaterials = 0;
333
+ for (const material of unique) {
334
+ if (material.transparent)
335
+ blendedMaterials++;
336
+ if (material.alphaTest > 0)
337
+ maskedMaterials++;
338
+ if (material.side === THREE.DoubleSide)
339
+ doubleSidedMaterials++;
340
+ if (material.isMeshPhysicalMaterial)
341
+ physicalMaterials++;
342
+ if (material.transmission > 0)
343
+ transmissionMaterials++;
344
+ for (const property of Object.values(MATERIAL_TEXTURE_PROPERTIES)) {
345
+ const texture = material[property];
346
+ if (texture instanceof THREE.Texture)
347
+ textures.add(texture);
348
+ }
349
+ }
350
+ for (const texture of textures) {
351
+ if (texture.isCompressedTexture) {
352
+ textureBytes += texture.mipmaps.reduce((sum, mip) => sum + mip.data.byteLength, 0);
353
+ continue;
354
+ }
355
+ const image = texture.image;
356
+ if (image?.width === undefined || image.height === undefined) {
357
+ unknownTextureSizes++;
358
+ continue;
359
+ }
360
+ const components = texture.format === THREE.RedFormat ? 1 : texture.format === THREE.RGFormat ? 2 : 4;
361
+ const bytes = texture.type === THREE.FloatType ? 4 : texture.type === THREE.HalfFloatType || texture.type === THREE.UnsignedShortType ? 2 : 1;
362
+ let width = image.width, height = image.height;
363
+ do {
364
+ textureBytes += width * height * components * bytes;
365
+ if (!texture.generateMipmaps || (width === 1 && height === 1))
366
+ break;
367
+ width = Math.max(1, Math.floor(width / 2));
368
+ height = Math.max(1, Math.floor(height / 2));
369
+ } while (true);
370
+ }
371
+ return { materials: unique.size, textures: textures.size, textureBytes, unknownTextureSizes, blendedMaterials, maskedMaterials, transmissionMaterials, doubleSidedMaterials, physicalMaterials };
372
+ }
373
+ /**
374
+ * Inspect imported names, local slot indices and directional/map prerequisites without mutating the source.
375
+ * @capability material-slots discover exact imported mesh and material selectors with UV and tangent prerequisites
376
+ */
377
+ export function inspectModelMaterialSlots(root) {
378
+ const result = [];
379
+ root.traverse(node => {
380
+ const mesh = node;
381
+ if (!mesh.isMesh)
382
+ return;
383
+ const uvSets = [0, 1, 2, 3].filter(index => mesh.geometry.getAttribute(index === 0 ? "uv" : `uv${index}`) !== undefined);
384
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
385
+ materials.forEach((material, slotIndex) => result.push({ mesh: mesh.name, slot: material.name, slotIndex, materialType: material.type, uvSets, tangents: mesh.geometry.getAttribute("tangent") !== undefined, physical: material.isMeshPhysicalMaterial === true }));
386
+ });
387
+ return result;
388
+ }
389
+ /** Loaded, owned map views for one model configuration. Loader sources and imported maps remain borrowed. @internal */
390
+ export function useModelMaterialTextures(model) {
391
+ const ids = new Set(model.materialAssignments?.map(assignment => assignment.materialId));
392
+ const referenced = (model.materialAssets ?? []).filter(asset => ids.has(asset.id));
393
+ const key = JSON.stringify([model.material?.maps, referenced]);
394
+ const entries = useMemo(() => {
395
+ const result = [];
396
+ const maps = model.material?.maps ?? {};
397
+ for (const [role, url] of Object.entries(maps))
398
+ if (url !== undefined)
399
+ result.push({ role: role, metadata: { url, colorSpace: MATERIAL_TEXTURE_SEMANTICS[role].colorSpace } });
400
+ for (const asset of referenced)
401
+ for (const [role, metadata] of Object.entries(asset.textures ?? {}))
402
+ result.push({ assetId: asset.id, role: role, metadata: metadata });
403
+ return result;
404
+ }, [key]);
405
+ const renderer = useThree(state => state.gl);
406
+ if (entries.some(entry => /\.ktx2(?:[?#]|$)/i.test(entry.metadata.url)))
407
+ detectKtx2Support(renderer);
408
+ const loaded = useLoader(MaterialTextureLoader, entries.map(entry => entry.metadata.url));
409
+ const views = useDisposable(() => {
410
+ const owned = [];
411
+ try {
412
+ entries.forEach((entry, index) => {
413
+ const view = configureMaterialTexture(entry.role, entry.metadata, loaded[index]);
414
+ view.flipY = false;
415
+ owned.push(view);
416
+ });
417
+ return owned;
418
+ }
419
+ catch (error) {
420
+ owned.forEach(texture => texture.dispose());
421
+ throw error;
422
+ }
423
+ }, [entries, loaded]);
424
+ return useMemo(() => {
425
+ const global = {};
426
+ const assets = new Map();
427
+ entries.forEach((entry, index) => {
428
+ if (entry.assetId === undefined)
429
+ global[entry.role] = views[index];
430
+ else {
431
+ let textures = assets.get(entry.assetId);
432
+ if (textures === undefined) {
433
+ textures = {};
434
+ assets.set(entry.assetId, textures);
435
+ }
436
+ textures[entry.role] = views[index];
437
+ }
438
+ });
439
+ return { global, assets };
440
+ }, [entries, views]);
441
+ }
442
+ /**
443
+ * Gate the existing thin-sheet hair-card scatter by sampled local light exposure, without changing scene exposure.
444
+ * @capability material-appearance-signals apply sampled local exposure to the hair-card backlight approximation without global lighting edits
445
+ */
446
+ export function setHairCardLightExposure(material, exposure) {
447
+ if (!Number.isFinite(exposure))
448
+ throw new Error("Hair light exposure must be finite");
449
+ const value = Math.max(0, Math.min(1, exposure));
450
+ material.userData.jgHairLightExposure = value;
451
+ const uniform = material.userData.jgHairBacklightUniform;
452
+ if (uniform !== undefined)
453
+ uniform.value = material.userData.jgHairBacklightStrength * value;
454
+ }
@@ -4,18 +4,21 @@ import { useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { useGameContext } from "@jgengine/react/provider";
6
6
  import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
7
+ import { modelBindPosePositions } from "./modelBindPose.js";
7
8
  /** Subtrees flagged with this userData key are excluded from bounds measurement — debug gizmos,
8
9
  * selection quads, effect billboards that should not inflate the hitbox. Sprites and invisible
9
10
  * subtrees are always excluded. */
10
11
  export const MEASURE_EXCLUDE_KEY = "jgMeasureExclude";
11
12
  const IDENTITY = new THREE.Matrix4();
12
13
  const tmpBox = new THREE.Box3();
14
+ const tmpVertex = new THREE.Vector3();
13
15
  /**
14
16
  * Measure the meshes under `root` in `root`'s own frame (root's transform is the measuring frame,
15
17
  * children compose their local matrices below it). Returns `null` when nothing measurable is
16
18
  * mounted. Skinned meshes measure at bind pose — the standard engine approximation.
17
19
  */
18
20
  export function measureLocalBounds(root) {
21
+ root.updateMatrixWorld(true);
19
22
  const box = new THREE.Box3();
20
23
  box.makeEmpty();
21
24
  const meshCount = collectBounds(root, null, box);
@@ -46,9 +49,19 @@ function collectBounds(object, parentMatrix, into) {
46
49
  const mesh = object;
47
50
  if (mesh.isMesh === true && mesh.geometry !== undefined) {
48
51
  const geometry = mesh.geometry;
49
- if (geometry.boundingBox === null)
50
- geometry.computeBoundingBox();
51
- const bounds = geometry.boundingBox;
52
+ let bounds;
53
+ if (mesh.isSkinnedMesh === true) {
54
+ tmpBox.makeEmpty();
55
+ const positions = modelBindPosePositions(mesh);
56
+ for (let index = 0; index < positions.length; index += 3)
57
+ tmpBox.expandByPoint(tmpVertex.fromArray(positions, index));
58
+ bounds = tmpBox;
59
+ }
60
+ else {
61
+ if (geometry.boundingBox === null)
62
+ geometry.computeBoundingBox();
63
+ bounds = geometry.boundingBox;
64
+ }
52
65
  if (bounds !== null && !bounds.isEmpty()) {
53
66
  tmpBox.copy(bounds);
54
67
  if (matrix !== null)
@@ -1,5 +1,6 @@
1
1
  import * as THREE from "three";
2
2
  import { MEASURE_EXCLUDE_KEY } from "./measureBounds.js";
3
+ import { modelBindPosePositions } from "./modelBindPose.js";
3
4
  /** Above this the model is too dense to double as its own hitbox — keep the fitted box instead of
4
5
  * paying BVH build + per-ray traversal on a hero-detail mesh. Game-ready low-poly assets sit far under it. */
5
6
  const MAX_COLLISION_TRIANGLES = 60_000;
@@ -29,8 +30,13 @@ function collectTriangles(object, parentMatrix, sink) {
29
30
  if (positionAttribute !== undefined && positionAttribute.itemSize === 3 && positionAttribute.count >= 3) {
30
31
  const base = sink.positions.length / 3;
31
32
  const applied = matrix ?? IDENTITY;
33
+ const bindPositions = mesh.isSkinnedMesh === true ? modelBindPosePositions(mesh) : null;
32
34
  for (let v = 0; v < positionAttribute.count; v += 1) {
33
- tmpVec.fromBufferAttribute(positionAttribute, v).applyMatrix4(applied);
35
+ if (bindPositions === null)
36
+ tmpVec.fromBufferAttribute(positionAttribute, v);
37
+ else
38
+ tmpVec.fromArray(bindPositions, v * 3);
39
+ tmpVec.applyMatrix4(applied);
34
40
  sink.positions.push(tmpVec.x, tmpVec.y, tmpVec.z);
35
41
  }
36
42
  const index = geometry.getIndex();
@@ -66,6 +72,7 @@ function collectTriangles(object, parentMatrix, sink) {
66
72
  * triangle budget — callers keep their box fallback.
67
73
  */
68
74
  export function measureLocalCollisionTriangles(root, transform) {
75
+ root.updateMatrixWorld(true);
69
76
  const sink = { positions: [], indices: [], meshCount: 0, triangleCount: 0, overflow: false };
70
77
  collectTriangles(root, null, sink);
71
78
  if (sink.overflow || sink.meshCount === 0 || sink.triangleCount === 0)
@@ -0,0 +1,8 @@
1
+ import type { ModelConfig, ModelMaterialMaps } from "@jgengine/core/game/playableGame";
2
+ /** Stable loader keys shared by preloading and material application. @internal */
3
+ export declare function modelMapEntries(maps: ModelMaterialMaps): Record<string, string>;
4
+ /** Start composed model dependencies together instead of discovering them after each parent commits. @internal */
5
+ export declare function modelAssetRequests(model: ModelConfig): {
6
+ models: string[];
7
+ textureGroups: string[][];
8
+ };
@@ -0,0 +1,43 @@
1
+ import { MATERIAL_TEXTURE_SEMANTICS } from "@jgengine/core/material/materialAsset";
2
+ const legacyMapRoles = ["color", "normal", "roughness", "ao", "metalness", "emissive", "height"];
3
+ const modelMapRoles = [...legacyMapRoles, ...Object.keys(MATERIAL_TEXTURE_SEMANTICS).filter(role => !legacyMapRoles.includes(role))];
4
+ /** Stable loader keys shared by preloading and material application. @internal */
5
+ export function modelMapEntries(maps) {
6
+ const entries = {};
7
+ for (const key of modelMapRoles) {
8
+ if (maps[key] !== undefined)
9
+ entries[key] = maps[key];
10
+ }
11
+ return entries;
12
+ }
13
+ /** Start composed model dependencies together instead of discovering them after each parent commits. @internal */
14
+ export function modelAssetRequests(model) {
15
+ const models = new Set();
16
+ const textureGroups = new Map();
17
+ const visited = new Set();
18
+ const visit = (next) => {
19
+ if (visited.has(next))
20
+ return;
21
+ visited.add(next);
22
+ models.add(next.url);
23
+ if (next.material?.maps !== undefined) {
24
+ const urls = Object.values(modelMapEntries(next.material.maps));
25
+ if (urls.length > 0)
26
+ textureGroups.set(JSON.stringify(urls), urls);
27
+ }
28
+ const referencedIds = new Set(next.materialAssignments?.map(assignment => assignment.materialId));
29
+ for (const asset of next.materialAssets ?? []) {
30
+ if (!referencedIds.has(asset.id))
31
+ continue;
32
+ const urls = Object.values(asset.textures ?? {}).map(metadata => metadata.url);
33
+ if (urls.length > 0)
34
+ textureGroups.set(JSON.stringify(urls), urls);
35
+ }
36
+ for (const child of [...(next.parts ?? []), ...(next.attachments ?? [])]) {
37
+ if (typeof child.model !== "string")
38
+ visit(child.model);
39
+ }
40
+ };
41
+ visit(model);
42
+ return { models: [...models], textureGroups: [...textureGroups.values()] };
43
+ }
@@ -0,0 +1,5 @@
1
+ import * as THREE from "three";
2
+ /** Skin-applied vertices captured before animation, shared by a loaded model's clones. @internal */
3
+ export declare function modelBindPosePositions(mesh: THREE.SkinnedMesh): Float32Array;
4
+ /** Retain the imported pose for normalization and colliders without sampling a live animation. @internal */
5
+ export declare function copyModelBindPose(source: THREE.Object3D, clone: THREE.Object3D): void;
@@ -0,0 +1,35 @@
1
+ import * as THREE from "three";
2
+ const positionsByMesh = new WeakMap();
3
+ const vertex = new THREE.Vector3();
4
+ /** Skin-applied vertices captured before animation, shared by a loaded model's clones. @internal */
5
+ export function modelBindPosePositions(mesh) {
6
+ let positions = positionsByMesh.get(mesh);
7
+ if (positions !== undefined)
8
+ return positions;
9
+ const attribute = mesh.geometry.getAttribute("position");
10
+ const skinned = mesh.geometry.getAttribute("skinIndex") !== undefined && mesh.geometry.getAttribute("skinWeight") !== undefined;
11
+ positions = new Float32Array(attribute.count * 3);
12
+ for (let index = 0; index < attribute.count; index++) {
13
+ if (skinned)
14
+ mesh.getVertexPosition(index, vertex);
15
+ else
16
+ vertex.fromBufferAttribute(attribute, index);
17
+ vertex.toArray(positions, index * 3);
18
+ }
19
+ positionsByMesh.set(mesh, positions);
20
+ return positions;
21
+ }
22
+ /** Retain the imported pose for normalization and colliders without sampling a live animation. @internal */
23
+ export function copyModelBindPose(source, clone) {
24
+ source.updateMatrixWorld(true);
25
+ const positions = [];
26
+ source.traverse((node) => {
27
+ if (node.isSkinnedMesh === true)
28
+ positions.push(modelBindPosePositions(node));
29
+ });
30
+ let index = 0;
31
+ clone.traverse((node) => {
32
+ if (node.isSkinnedMesh === true)
33
+ positionsByMesh.set(node, positions[index++]);
34
+ });
35
+ }
@@ -1,5 +1,6 @@
1
1
  import * as THREE from "three";
2
2
  import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
3
+ import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
3
4
  import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
4
5
  /**
5
6
  * The shared GLB {@link THREE.LoadingManager}'s `onError` sink. It fires for EVERY item the manager
@@ -111,6 +112,8 @@ export interface ModelLoaderConfig {
111
112
  export declare function configureModelLoaders(options?: ModelLoaderConfig): Readonly<Required<ModelLoaderConfig>>;
112
113
  /** Detects GPU support for the configured KTX2 transcoder after a renderer exists. */
113
114
  export declare function detectKtx2Support(renderer: THREE.WebGLRenderer): void;
115
+ /** Shared transcoder for authored material maps; GPU support is detected by the canvas. @internal */
116
+ export declare function materialKtx2Loader(): KTX2Loader;
114
117
  /**
115
118
  * How long the shared GLB loader has been idle, in ms — `0` while any model is still
116
119
  * in flight. A capture host reads this to wait for streaming to finish instead of