@volter/threejs-runtime 0.5.65

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 (70) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +12 -0
  3. package/package.json +62 -0
  4. package/src/adapter/body-marks.ts +69 -0
  5. package/src/adapter/constraint.ts +78 -0
  6. package/src/adapter/first-party-navigation-system.ts +48 -0
  7. package/src/adapter/hierarchy-marks.ts +156 -0
  8. package/src/adapter/ingest/scene-capture.ts +865 -0
  9. package/src/adapter/ingest/structural-ids.ts +139 -0
  10. package/src/adapter/ingest/visible-capture-window.ts +302 -0
  11. package/src/adapter/object3d-authoring-subject.ts +50 -0
  12. package/src/adapter/rapier-physics-adapter.ts +226 -0
  13. package/src/adapter/reflection-probe.ts +75 -0
  14. package/src/adapter/renderer-config.ts +116 -0
  15. package/src/adapter/trigger-volume.ts +29 -0
  16. package/src/ai/navigation.ts +627 -0
  17. package/src/animation/animation-clock.ts +479 -0
  18. package/src/animation/runtime-inspection.ts +45 -0
  19. package/src/animation/xstate-animation-binding.ts +655 -0
  20. package/src/animation/xstate-animation-meta.ts +360 -0
  21. package/src/asset-formats/camera.ts +18 -0
  22. package/src/asset-formats/collider.ts +66 -0
  23. package/src/asset-formats/index.ts +45 -0
  24. package/src/asset-formats/light.ts +50 -0
  25. package/src/asset-formats/material.ts +93 -0
  26. package/src/asset-formats/mesh.ts +78 -0
  27. package/src/asset-formats/particles.ts +426 -0
  28. package/src/asset-formats/render-env.ts +318 -0
  29. package/src/asset-formats/tuples.ts +21 -0
  30. package/src/asset-loaders.ts +242 -0
  31. package/src/asset-parse-error.ts +29 -0
  32. package/src/assets.ts +68 -0
  33. package/src/behavior/xstate-inspection.ts +51 -0
  34. package/src/defaults.ts +135 -0
  35. package/src/ecs/object-marks.ts +75 -0
  36. package/src/ecs/scene-index.ts +439 -0
  37. package/src/ecs/scene-query.ts +43 -0
  38. package/src/ecs/user-data.ts +272 -0
  39. package/src/loader.ts +134 -0
  40. package/src/physics/collider-dimensions.ts +121 -0
  41. package/src/physics/collision-system.ts +76 -0
  42. package/src/physics/physics-registry.ts +83 -0
  43. package/src/physics/transform-writer.ts +41 -0
  44. package/src/render/directional-shadow-fit.ts +158 -0
  45. package/src/render/environment-capture.ts +133 -0
  46. package/src/render/ibl-override-material.ts +202 -0
  47. package/src/render/light-camera-factory.ts +103 -0
  48. package/src/render/matcap-texture.ts +92 -0
  49. package/src/render/material-factory.ts +201 -0
  50. package/src/render/mesh-shadow.ts +18 -0
  51. package/src/render/particles-factory.ts +589 -0
  52. package/src/render/render-batch-system.ts +260 -0
  53. package/src/render/render-features.ts +128 -0
  54. package/src/render/render-settings.ts +71 -0
  55. package/src/render/soft-particle-depth.ts +360 -0
  56. package/src/render/spark-renderer-lifecycle.ts +64 -0
  57. package/src/render/structural-signature.ts +231 -0
  58. package/src/render/viewport-shading.ts +163 -0
  59. package/src/render/voxel-surface.ts +358 -0
  60. package/src/setup/setup-particles.ts +32 -0
  61. package/src/setup/setup-physics.ts +67 -0
  62. package/src/setup/setup-renderer.ts +524 -0
  63. package/src/types-n8ao.d.ts +37 -0
  64. package/src/types-realism-effects.d.ts +61 -0
  65. package/vendor/realism-effects/LICENSE.md +21 -0
  66. package/vendor/realism-effects/UPSTREAM.md +19 -0
  67. package/vendor/realism-effects/dist/index.cjs +3447 -0
  68. package/vendor/realism-effects/dist/index.d.ts +59 -0
  69. package/vendor/realism-effects/dist/index.js +3434 -0
  70. package/vendor/realism-effects/package.json +23 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Render settings — the tone-mapping / post-processing / render-feature scope
3
+ * the engine's renderer setup reads (`setup/setup-renderer.ts`).
4
+ *
5
+ * Exactly THREE scopes, and each is here because `setup-renderer.ts` reads
6
+ * every one of its fields to build the postprocessing chain. A setting with
7
+ * no such reader does not belong in this descriptor.
8
+ */
9
+ import { z } from 'zod';
10
+ import { buildRenderingZod } from '../render/render-features';
11
+
12
+ export const ToneMappingDescriptorSchema = z
13
+ .object({
14
+ mode: z
15
+ .enum(['none', 'linear', 'reinhard', 'cineon', 'aces', 'agx'])
16
+ .optional()
17
+ .describe('Tone mapping operator'),
18
+ exposure: z.number().optional().describe('Exposure level'),
19
+ })
20
+ .describe('Tone mapping configuration');
21
+
22
+ export type ToneMappingDescriptor = z.infer<typeof ToneMappingDescriptorSchema>;
23
+
24
+ export const PostProcessingDescriptorSchema = z
25
+ .object({
26
+ // ── Bloom & Vignette ──────────────────────────────────────────────────
27
+ bloom: z
28
+ .object({
29
+ enabled: z.boolean().optional().describe('Enable bloom effect'),
30
+ intensity: z.number().optional().describe('Bloom intensity multiplier'),
31
+ luminanceThreshold: z.number().optional().describe('Minimum luminance to trigger bloom'),
32
+ luminanceSmoothing: z
33
+ .number()
34
+ .optional()
35
+ .describe('Smoothing applied to luminance threshold'),
36
+ })
37
+ .optional()
38
+ .describe('Bloom glow effect'),
39
+ vignette: z
40
+ .object({
41
+ enabled: z.boolean().optional().describe('Enable vignette effect'),
42
+ darkness: z.number().optional().describe('Vignette darkness intensity'),
43
+ offset: z.number().optional().describe('Vignette offset from center'),
44
+ })
45
+ .optional()
46
+ .describe('Screen-edge darkening effect'),
47
+
48
+ // ── Color adjustments ─────────────────────────────────────────────────
49
+ brightnessContrast: z
50
+ .object({
51
+ enabled: z.boolean().optional().describe('Enable brightness/contrast adjustment'),
52
+ brightness: z.number().optional().describe('Brightness adjustment (-1 to 1)'),
53
+ contrast: z.number().optional().describe('Contrast adjustment (-1 to 1)'),
54
+ })
55
+ .optional()
56
+ .describe('Brightness and contrast color adjustment'),
57
+ hueSaturation: z
58
+ .object({
59
+ enabled: z.boolean().optional().describe('Enable hue/saturation adjustment'),
60
+ hue: z.number().optional().describe('Hue rotation in radians'),
61
+ saturation: z.number().optional().describe('Saturation adjustment (-1 to 1)'),
62
+ })
63
+ .optional()
64
+ .describe('Hue rotation and saturation color adjustment'),
65
+ sepia: z
66
+ .object({
67
+ enabled: z.boolean().optional().describe('Enable sepia tone'),
68
+ intensity: z.number().optional().describe('Sepia effect intensity (0 to 1)'),
69
+ })
70
+ .optional()
71
+ .describe('Warm sepia tone for vintage photograph look'),
72
+ colorDepth: z
73
+ .object({
74
+ enabled: z.boolean().optional().describe('Enable color depth reduction'),
75
+ bits: z.number().optional().describe('Number of color bits per channel (1–16)'),
76
+ })
77
+ .optional()
78
+ .describe('Reduce color bit depth for posterized/banded look'),
79
+
80
+ // ── Lens effects ──────────────────────────────────────────────────────
81
+ chromaticAberration: z
82
+ .object({
83
+ enabled: z.boolean().optional().describe('Enable chromatic aberration'),
84
+ offsetX: z.number().optional().describe('Horizontal color fringe offset'),
85
+ offsetY: z.number().optional().describe('Vertical color fringe offset'),
86
+ radialModulation: z.boolean().optional().describe('Increase effect toward screen edges'),
87
+ modulationOffset: z.number().optional().describe('Radial modulation offset from center'),
88
+ })
89
+ .optional()
90
+ .describe('Lens chromatic aberration (color fringing at screen edges)'),
91
+ lensDistortion: z
92
+ .object({
93
+ enabled: z.boolean().optional().describe('Enable lens distortion'),
94
+ distortionX: z.number().optional().describe('Horizontal barrel/pincushion distortion'),
95
+ distortionY: z.number().optional().describe('Vertical barrel/pincushion distortion'),
96
+ focalLengthX: z.number().optional().describe('Horizontal focal length'),
97
+ focalLengthY: z.number().optional().describe('Vertical focal length'),
98
+ skew: z.number().optional().describe('Lens skew angle'),
99
+ })
100
+ .optional()
101
+ .describe('Barrel/pincushion lens distortion'),
102
+ depthOfField: z
103
+ .object({
104
+ enabled: z.boolean().optional().describe('Enable depth of field'),
105
+ worldFocusDistance: z.number().optional().describe('Focus distance in world units'),
106
+ worldFocusRange: z.number().optional().describe('Focus range in world units'),
107
+ bokehScale: z.number().optional().describe('Bokeh blur scale'),
108
+ focalLength: z.number().optional().describe('Camera focal length in mm'),
109
+ })
110
+ .optional()
111
+ .describe('Physically-based depth of field with bokeh blur'),
112
+ tiltShift: z
113
+ .object({
114
+ enabled: z.boolean().optional().describe('Enable tilt-shift effect'),
115
+ offset: z.number().optional().describe('Focus band offset from center'),
116
+ rotation: z.number().optional().describe('Focus band rotation in radians'),
117
+ focusArea: z.number().optional().describe('Focus band width (0–1)'),
118
+ feather: z.number().optional().describe('Blur falloff smoothness'),
119
+ })
120
+ .optional()
121
+ .describe('Tilt-shift miniature-world blur effect'),
122
+
123
+ // ── Film / retro ──────────────────────────────────────────────────────
124
+ noise: z
125
+ .object({
126
+ enabled: z.boolean().optional().describe('Enable film grain noise'),
127
+ premultiply: z
128
+ .boolean()
129
+ .optional()
130
+ .describe('Multiply noise with input color instead of adding'),
131
+ })
132
+ .optional()
133
+ .describe('Random per-pixel noise overlay (film grain)'),
134
+ scanline: z
135
+ .object({
136
+ enabled: z.boolean().optional().describe('Enable scanline overlay'),
137
+ density: z.number().optional().describe('Number of scanlines across screen height'),
138
+ })
139
+ .optional()
140
+ .describe('Horizontal scanline overlay (CRT monitor look)'),
141
+ dotScreen: z
142
+ .object({
143
+ enabled: z.boolean().optional().describe('Enable dot screen halftone'),
144
+ angle: z.number().optional().describe('Pattern angle in radians'),
145
+ scale: z.number().optional().describe('Pattern dot scale'),
146
+ })
147
+ .optional()
148
+ .describe('Halftone dot pattern (newspaper/comic look)'),
149
+ grid: z
150
+ .object({
151
+ enabled: z.boolean().optional().describe('Enable grid overlay'),
152
+ scale: z.number().optional().describe('Grid cell scale'),
153
+ lineWidth: z.number().optional().describe('Grid line width'),
154
+ })
155
+ .optional()
156
+ .describe('Visible grid pattern overlay'),
157
+ pixelation: z
158
+ .object({
159
+ enabled: z.boolean().optional().describe('Enable pixelation'),
160
+ granularity: z.number().optional().describe('Pixel size (higher = more pixelated)'),
161
+ })
162
+ .optional()
163
+ .describe('Reduce resolution for pixel-art / mosaic effect'),
164
+ glitch: z
165
+ .object({
166
+ enabled: z.boolean().optional().describe('Enable digital glitch effect'),
167
+ delayX: z.number().optional().describe('Minimum delay between glitches (seconds)'),
168
+ delayY: z.number().optional().describe('Maximum delay between glitches (seconds)'),
169
+ durationX: z.number().optional().describe('Minimum glitch duration (seconds)'),
170
+ durationY: z.number().optional().describe('Maximum glitch duration (seconds)'),
171
+ strengthX: z.number().optional().describe('Minimum glitch strength'),
172
+ strengthY: z.number().optional().describe('Maximum glitch strength'),
173
+ columns: z.number().optional().describe('Number of glitch columns'),
174
+ ratio: z.number().optional().describe('Glitch ratio (0–1)'),
175
+ })
176
+ .optional()
177
+ .describe('Random digital glitch artifacts (block displacement, color shifting)'),
178
+
179
+ // ── Distortion ────────────────────────────────────────────────────────
180
+ shockWave: z
181
+ .object({
182
+ enabled: z.boolean().optional().describe('Enable shock wave distortion'),
183
+ speed: z.number().optional().describe('Wave propagation speed'),
184
+ maxRadius: z.number().optional().describe('Maximum wave radius'),
185
+ waveSize: z.number().optional().describe('Width of the distortion wave'),
186
+ amplitude: z.number().optional().describe('Distortion strength'),
187
+ positionX: z.number().optional().describe('Wave origin X in world space'),
188
+ positionY: z.number().optional().describe('Wave origin Y in world space'),
189
+ positionZ: z.number().optional().describe('Wave origin Z in world space'),
190
+ })
191
+ .optional()
192
+ .describe('Expanding spherical distortion wave from a point'),
193
+
194
+ // ── Anti-aliasing ─────────────────────────────────────────────────────
195
+ smaa: z
196
+ .object({
197
+ enabled: z.boolean().optional().describe('Enable SMAA anti-aliasing'),
198
+ preset: z
199
+ .enum(['low', 'medium', 'high', 'ultra'])
200
+ .optional()
201
+ .describe('Quality preset (higher = better quality, more cost)'),
202
+ })
203
+ .optional()
204
+ .describe('Subpixel Morphological Anti-Aliasing'),
205
+
206
+ // ── Ambient occlusion ─────────────────────────────────────────────────
207
+ ssao: z
208
+ .object({
209
+ enabled: z.boolean().optional().describe('Enable screen-space ambient occlusion'),
210
+ radius: z.number().optional().describe('Occlusion sampling radius'),
211
+ intensity: z.number().optional().describe('Occlusion intensity multiplier'),
212
+ bias: z.number().optional().describe('Depth bias to reduce self-occlusion artifacts'),
213
+ samples: z.number().optional().describe('Number of occlusion samples per pixel'),
214
+ rings: z.number().optional().describe('Number of spiral sample rings'),
215
+ fade: z.number().optional().describe('Distance at which occlusion fades out'),
216
+ color: z.string().optional().describe('Occlusion tint color as CSS hex'),
217
+ })
218
+ .optional()
219
+ .describe('Screen-space ambient occlusion (darken creases and crevices)'),
220
+ n8ao: z
221
+ .object({
222
+ enabled: z.boolean().optional().describe('Enable N8AO ambient occlusion'),
223
+ aoSamples: z.number().optional().describe('Number of AO samples (default 16)'),
224
+ aoRadius: z.number().optional().describe('AO sampling radius in world units'),
225
+ intensity: z.number().optional().describe('AO intensity multiplier'),
226
+ denoiseSamples: z.number().optional().describe('Number of denoise samples'),
227
+ denoiseRadius: z.number().optional().describe('Denoise blur radius'),
228
+ distanceFalloff: z.number().optional().describe('AO distance falloff'),
229
+ screenSpaceRadius: z
230
+ .boolean()
231
+ .optional()
232
+ .describe('Use screen-space radius instead of world-space'),
233
+ color: z.string().optional().describe('AO tint color as CSS hex'),
234
+ halfRes: z.boolean().optional().describe('Render AO at half resolution for performance'),
235
+ })
236
+ .optional()
237
+ .describe('High-quality ambient occlusion (N8AO — alternative to SSAO)'),
238
+
239
+ // ── Entity-referencing effects ────────────────────────────────────────
240
+ godRays: z
241
+ .object({
242
+ enabled: z.boolean().optional().describe('Enable god rays'),
243
+ sourceEntity: z.string().optional().describe('Entity ID of the light source mesh'),
244
+ density: z.number().optional().describe('Ray density'),
245
+ decay: z.number().optional().describe('Ray intensity decay along distance'),
246
+ weight: z.number().optional().describe('Ray weight / brightness'),
247
+ exposure: z.number().optional().describe('Overall god ray exposure'),
248
+ samples: z.number().optional().describe('Number of ray-march samples'),
249
+ })
250
+ .optional()
251
+ .describe('Volumetric light shafts from a light source entity'),
252
+ outline: z
253
+ .object({
254
+ enabled: z.boolean().optional().describe('Enable outline effect'),
255
+ tags: z.array(z.string()).optional().describe('Entity tags to outline'),
256
+ edgeStrength: z.number().optional().describe('Outline edge thickness / strength'),
257
+ pulseSpeed: z.number().optional().describe('Outline pulse animation speed (0 = no pulse)'),
258
+ visibleEdgeColor: z.string().optional().describe('Color of visible edges as CSS hex'),
259
+ hiddenEdgeColor: z
260
+ .string()
261
+ .optional()
262
+ .describe('Color of hidden/occluded edges as CSS hex'),
263
+ xRay: z.boolean().optional().describe('Show outline through occluding objects'),
264
+ })
265
+ .optional()
266
+ .describe('Highlight entities matching tags with colored outlines'),
267
+
268
+ // ── Screen-space reflections & GI (realism-effects) ───────────────────
269
+ ssr: z
270
+ .object({
271
+ enabled: z.boolean().optional().describe('Enable screen-space reflections'),
272
+ intensity: z.number().optional().describe('Reflection intensity'),
273
+ exponent: z.number().optional().describe('Reflection falloff exponent'),
274
+ distance: z.number().optional().describe('Maximum reflection ray distance'),
275
+ thickness: z.number().optional().describe('Depth thickness for hit testing'),
276
+ maxRoughness: z.number().optional().describe('Maximum roughness for reflections (0–1)'),
277
+ })
278
+ .optional()
279
+ .describe('Screen-space reflections for reflective/glossy surfaces'),
280
+ ssgi: z
281
+ .object({
282
+ enabled: z.boolean().optional().describe('Enable screen-space global illumination'),
283
+ intensity: z.number().optional().describe('Indirect light intensity'),
284
+ distance: z.number().optional().describe('Maximum GI ray distance'),
285
+ thickness: z.number().optional().describe('Depth thickness for hit testing'),
286
+ maxRoughness: z.number().optional().describe('Maximum roughness for GI (0–1)'),
287
+ })
288
+ .optional()
289
+ .describe('Screen-space global illumination (indirect lighting bounce)'),
290
+ motionBlur: z
291
+ .object({
292
+ enabled: z.boolean().optional().describe('Enable motion blur'),
293
+ intensity: z.number().optional().describe('Motion blur intensity'),
294
+ jitter: z.number().optional().describe('Sample jitter amount'),
295
+ samples: z.number().optional().describe('Number of blur samples'),
296
+ })
297
+ .optional()
298
+ .describe('Per-object motion blur from camera and object movement'),
299
+ })
300
+ .describe('Post-processing effects');
301
+
302
+ export type PostProcessingDescriptor = z.infer<typeof PostProcessingDescriptorSchema>;
303
+
304
+ export const RenderEnvironmentSchema = z
305
+ .object({
306
+ toneMapping: ToneMappingDescriptorSchema.optional().describe('Tone mapping configuration'),
307
+ postProcessing: PostProcessingDescriptorSchema.optional().describe(
308
+ 'Post-processing effects (bloom, vignette, SSAO, SSR, ...)',
309
+ ),
310
+ rendering: buildRenderingZod()
311
+ .optional()
312
+ .describe(
313
+ 'Render feature toggles (auto-batching, culling, LOD, shadows, etc.). Omit a field to inherit the engine default. Derived from the render-feature registry.',
314
+ ),
315
+ })
316
+ .describe('Renderer-level settings read by setup/setup-renderer.ts');
317
+
318
+ export type RenderEnvironment = z.infer<typeof RenderEnvironmentSchema>;
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+
3
+ export const Vec3Schema = z
4
+ .tuple([z.number(), z.number(), z.number()])
5
+ .describe('3D vector as [x, y, z]');
6
+ export const QuatSchema = z
7
+ .tuple([z.number(), z.number(), z.number(), z.number()])
8
+ .describe('Quaternion as [x, y, z, w]');
9
+
10
+ export type Vec3 = z.infer<typeof Vec3Schema>;
11
+ export type Quat = z.infer<typeof QuatSchema>;
12
+
13
+ export const TransformSchema = z
14
+ .object({
15
+ position: Vec3Schema.optional().describe('World-space position [x, y, z]'),
16
+ rotation: QuatSchema.optional().describe('Rotation quaternion [x, y, z, w]'),
17
+ scale: Vec3Schema.optional().describe('Scale factor per axis [x, y, z]. Defaults to [1, 1, 1]'),
18
+ })
19
+ .describe('Local transform relative to parent entity');
20
+
21
+ export type Transform = z.infer<typeof TransformSchema>;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Canonical GLTF / texture load + cache layer (A2). (Formerly also hosted the
3
+ * `.animgraph.json` loader — removed by E5; see
4
+ * packages/threejs-runtime/src/animation/xstate-animation-binding.ts.)
5
+ *
6
+ * ONE place that owns the URL-keyed caches and the clone-safety contract, shared
7
+ * by BOTH the engine runtime (scene-loader, create-runtime) AND the editor
8
+ * (entity-factory, scene-sync). Previously this logic was duplicated across six
9
+ * sites with three separate GLTF caches and four GLTFLoader instances, so fixes
10
+ * (DRACO wiring, the `__sharedGeometry` clone-safety tag, skybox handling) did
11
+ * not propagate. Everything funnels through the shared loaders in `../loader`
12
+ * (`gltfLoader` is DRACO-wired — CB2 — and `textureLoader` shares the engine
13
+ * LoadingManager / asset-prefix rewrite).
14
+ *
15
+ * Cache lifetime / P1.8: these caches are intentionally module-level and persist
16
+ * ACROSS hot-reloads / scene switches (they are NOT cleared on every scene load).
17
+ * This is deliberate: (1) it makes repeated Play→Stop→Play cheap, and (2) it
18
+ * underpins the `__sharedGeometry` contract from P0.2 — GLTF clones share the
19
+ * cached source geometry, so clearing mid-session would orphan live clones. The
20
+ * caches are keyed by URL, so loading the SAME asset N times is bounded (one
21
+ * entry per distinct URL, not per load). They are released only on full runtime
22
+ * teardown via {@link clearAssetCaches}.
23
+ */
24
+
25
+ import type { SplatMesh } from '@sparkjsdev/spark';
26
+ import * as THREE from 'three';
27
+ import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
28
+ import { AssetParseError } from './asset-parse-error';
29
+ import { setUserData } from './ecs/user-data';
30
+ import { gltfLoader, resolveUrl, textureLoader } from './loader';
31
+
32
+ const textureCache = new Map<string, THREE.Texture>();
33
+ const gltfCache = new Map<
34
+ string,
35
+ Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }>
36
+ >();
37
+ const splatBytesCache = new Map<string, Promise<Uint8Array>>();
38
+
39
+ /** Load (and cache) a texture via the shared, prefix-aware TextureLoader. */
40
+ export function loadTexture(url: string): THREE.Texture {
41
+ const cached = textureCache.get(url);
42
+ if (cached) return cached;
43
+ const tex = textureLoader.load(url);
44
+ textureCache.set(url, tex);
45
+ return tex;
46
+ }
47
+
48
+ /**
49
+ * Load a GLTF/GLB and return a fresh, disposal-safe clone (+ the source's
50
+ * animation clips). DRACO decoding is handled by the shared `gltfLoader`.
51
+ *
52
+ * Clone-safety contract (P0.2 — load-bearing, tested):
53
+ * `SkeletonUtils.clone` shares geometry AND materials with the cached source.
54
+ * We give each clone its OWN materials (so scene cleanup can dispose them
55
+ * without corrupting the cache) and tag every mesh `userData.__sharedGeometry`
56
+ * so cleanup (engine create-runtime fullCleanup + editor scene-sync
57
+ * disposeObject3D) skips disposing the shared geometry buffers.
58
+ */
59
+ export function loadGLTF(
60
+ url: string,
61
+ ): Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }> {
62
+ let cached = gltfCache.get(url);
63
+ if (!cached) {
64
+ cached = new Promise((resolve, reject) => {
65
+ gltfLoader.load(
66
+ url,
67
+ (gltf) => resolve({ scene: gltf.scene, animations: gltf.animations }),
68
+ undefined,
69
+ (err) => reject(err),
70
+ );
71
+ });
72
+ gltfCache.set(url, cached);
73
+ }
74
+ return cached.then((entry) => {
75
+ const scene = SkeletonUtils.clone(entry.scene) as THREE.Group;
76
+ scene.traverse((child) => {
77
+ if (child instanceof THREE.Mesh) {
78
+ child.material = Array.isArray(child.material)
79
+ ? child.material.map((m) => m.clone())
80
+ : child.material.clone();
81
+ setUserData(child, '__sharedGeometry', true);
82
+ }
83
+ });
84
+ return { scene, animations: entry.animations };
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Load one native Gaussian-splat asset as Spark's own Object3D.
90
+ *
91
+ * The URL cache stores immutable source bytes, not a live SplatMesh: every scene
92
+ * entity needs its own transformable/disposable Object3D, while repeated uses of
93
+ * the same SPZ must not download the multi-megabyte source more than once.
94
+ * Spark stays a lazy chunk so games without splats do not pay its bundle cost.
95
+ */
96
+ export async function loadSplat(url: string, providedBytes?: Uint8Array): Promise<SplatMesh> {
97
+ let bytes = splatBytesCache.get(url);
98
+ if (!bytes && providedBytes) {
99
+ bytes = Promise.resolve(providedBytes);
100
+ splatBytesCache.set(url, bytes);
101
+ }
102
+ if (!bytes) {
103
+ bytes = fetch(resolveUrl(url)).then(async (response) => {
104
+ if (!response.ok) {
105
+ throw new Error(
106
+ `Failed to load Gaussian splat: ${url} (${response.status} ${response.statusText})`,
107
+ );
108
+ }
109
+ return new Uint8Array(await response.arrayBuffer());
110
+ });
111
+ splatBytesCache.set(url, bytes);
112
+ }
113
+
114
+ try {
115
+ const [{ SplatMesh }, fileBytes] = await Promise.all([import('@sparkjsdev/spark'), bytes]);
116
+ const mesh = new SplatMesh({ fileBytes, fileName: url, raycastable: true });
117
+ await mesh.initialized;
118
+ setUserData(mesh, 'gaussianSplat', { src: url, numSplats: mesh.numSplats });
119
+ const bounds = mesh.getBoundingBox();
120
+ if (!bounds.isEmpty()) {
121
+ // Three's generic Box3.setFromObject only understands geometry-bearing
122
+ // Object3Ds. Spark intentionally renders without THREE.BufferGeometry,
123
+ // so give editor/runtime framing a hidden, non-pickable bounds proxy.
124
+ // It is infrastructure, never authored hierarchy or rendered content.
125
+ // Include a framing margin. This proxy is consumed by generic editor
126
+ // focus/selection bounds, while inspectors continue to report Spark's
127
+ // exact Gaussian bounds directly.
128
+ const size = bounds.getSize(new THREE.Vector3()).multiplyScalar(1.5);
129
+ const center = bounds.getCenter(new THREE.Vector3());
130
+ const proxy = new THREE.Mesh(new THREE.BoxGeometry(size.x, size.y, size.z));
131
+ proxy.name = '__vgai_splat_bounds';
132
+ proxy.position.copy(center);
133
+ proxy.visible = false;
134
+ proxy.raycast = () => {};
135
+ setUserData(proxy, 'engineInternal', true);
136
+ mesh.add(proxy);
137
+ }
138
+ return mesh;
139
+ } catch (error) {
140
+ // A failed fetch must be retryable after the source is repaired or the
141
+ // network recovers; successful source bytes remain cached across instances.
142
+ splatBytesCache.delete(url);
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Resolve a single named node inside an already-loaded glTF scene graph (F4,
149
+ * `mesh.node`). Pure and headlessly testable — no fetch, no cache lookups;
150
+ * callers pass the `scene` they already got from {@link loadGLTF}.
151
+ *
152
+ * The returned node is the SAME object (and shares the SAME geometry) as
153
+ * found in `scene` — nothing is cloned or copied out. `scene` is already a
154
+ * fresh `SkeletonUtils.clone()` per {@link loadGLTF}'s clone-safety contract,
155
+ * so mutating/detaching the returned node is safe.
156
+ *
157
+ * Atomic-subtree guard: a `SkinnedMesh` cannot be lifted out of its armature
158
+ * (its skeleton/bind matrices reference sibling bone nodes elsewhere in the
159
+ * hierarchy), so resolving a node that IS a SkinnedMesh, or that CONTAINS one,
160
+ * throws a loud `AssetParseError` instead of silently producing broken skinning.
161
+ */
162
+ export function resolveGltfNode(
163
+ scene: THREE.Object3D,
164
+ nodeName: string,
165
+ src: string,
166
+ ): THREE.Object3D {
167
+ const node = scene.getObjectByName(nodeName);
168
+ if (!node) {
169
+ throw new AssetParseError(
170
+ [
171
+ {
172
+ code: 'custom',
173
+ path: ['mesh', 'node'],
174
+ message: `glTF "${src}" has no node named "${nodeName}" (getObjectByName found nothing)`,
175
+ },
176
+ ],
177
+ src,
178
+ );
179
+ }
180
+
181
+ const isSkinned = (o: THREE.Object3D) => (o as THREE.SkinnedMesh).isSkinnedMesh;
182
+ let containsSkinned = isSkinned(node);
183
+ if (!containsSkinned) {
184
+ node.traverse((child) => {
185
+ if (isSkinned(child)) containsSkinned = true;
186
+ });
187
+ }
188
+ if (containsSkinned) {
189
+ throw new AssetParseError(
190
+ [
191
+ {
192
+ code: 'custom',
193
+ path: ['mesh', 'node'],
194
+ message:
195
+ `glTF "${src}" node "${nodeName}" is (or contains) a SkinnedMesh — a skinned mesh ` +
196
+ 'cannot be lifted out of its armature (atomic-subtree rule). Reference the whole ' +
197
+ 'file instead of a `node`, or bake/export a non-skinned sub-asset.',
198
+ },
199
+ ],
200
+ src,
201
+ );
202
+ }
203
+
204
+ return node;
205
+ }
206
+
207
+ /**
208
+ * Clear the module-level GLTF / texture caches. Call on full runtime teardown
209
+ * (create-runtime fullCleanup / hot-reload) to release cached GPU resources
210
+ * and avoid leaking across editor Play sessions. See the lifetime note at the
211
+ * top of this file for why these are NOT cleared per scene load.
212
+ *
213
+ * NOTE: the IBL/skybox env-map cache lives in scene-loader (it needs a
214
+ * WebGLRenderer for PMREM); scene-loader's `clearAssetCaches` wraps this and
215
+ * also clears that env cache.
216
+ */
217
+ export function clearAssetCaches(): void {
218
+ textureCache.clear();
219
+ gltfCache.clear();
220
+ splatBytesCache.clear();
221
+ }
222
+
223
+ /**
224
+ * Drop the cached entry for ONE asset URL after its bytes changed on disk
225
+ * (editor asset-optimization write-back — W4a). Existing clones keep their
226
+ * shared geometry alive (the cache entry only owns the lookup, not the
227
+ * buffers), and the next `loadGLTF`/`loadTexture` for this URL re-fetches the
228
+ * rewritten file instead of serving stale bytes.
229
+ */
230
+ export function invalidateCachedAsset(url: string): void {
231
+ textureCache.delete(url);
232
+ gltfCache.delete(url);
233
+ }
234
+
235
+ /**
236
+ * Test-only: current sizes of the module-level caches. Used by the P1.8 headless
237
+ * test to assert caches stay bounded (one entry per distinct asset URL) across
238
+ * repeated loads of the same scene, rather than growing per load.
239
+ */
240
+ export function assetCacheSizes(): { textures: number; gltf: number; splat: number } {
241
+ return { textures: textureCache.size, gltf: gltfCache.size, splat: splatBytesCache.size };
242
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The GENERIC fetched-asset parse error.
3
+ *
4
+ * Every authored asset format the engine validates at fetch time throws this
5
+ * when Zod rejects the payload, naming the offending file (T4.6). It lives
6
+ * here — beside `loader.ts`/`assets.ts` — rather than inside a format module so
7
+ * every caller (currently the input-map loader in `input/input-manager.ts`)
8
+ * does not have to reach into an unrelated subsystem for it.
9
+ *
10
+ * The name says ASSET, not scene: an `.inputmap.json` failure is not a scene
11
+ * failure, and the class covers every structured fetched asset.
12
+ */
13
+
14
+ import type { ZodIssue } from 'zod';
15
+
16
+ export class AssetParseError extends Error {
17
+ readonly issues: ZodIssue[];
18
+ /** The offending file's path/URL, when the caller knows it (T4.6). */
19
+ readonly filePath?: string | undefined;
20
+
21
+ constructor(issues: ZodIssue[], filePath?: string) {
22
+ const msg = issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
23
+ const header = filePath ? `Asset validation failed (${filePath}):` : 'Asset validation failed:';
24
+ super(`${header}\n${msg}`);
25
+ this.name = 'AssetParseError';
26
+ this.issues = issues;
27
+ this.filePath = filePath;
28
+ }
29
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared asset cache — URL-keyed, deduplicating loader for GLTF and JSON.
3
+ *
4
+ * Inspired by Unity's Resources.Load / Godot's load():
5
+ * assets.load<T>(url) — async, returns Promise<T>, deduplicates concurrent loads
6
+ * assets.get<T>(url) — sync, returns T, throws if not yet loaded
7
+ *
8
+ * GLTF files (.glb/.gltf) resolve to GLTFResult.
9
+ * JSON files resolve to the parsed object (caller provides type via generic).
10
+ *
11
+ * Cache lives for the app lifetime — no dispose needed.
12
+ */
13
+
14
+ import type { AssetCache, GLTFResult } from '@volter/editor-project/adapter/asset-cache';
15
+ import { gltfLoader, resolveUrl } from './loader';
16
+
17
+ export type { AssetCache, GLTFResult } from '@volter/editor-project/adapter/asset-cache';
18
+
19
+ export function createAssetCache(): AssetCache {
20
+ const cache = new Map<string, Promise<unknown>>();
21
+ const resolved = new Map<string, unknown>();
22
+ // Use the shared, DRACO-wired GLTFLoader (A2 / CB2) — no second loader instance.
23
+ // This cache keeps the parsed source GLTFResult (load/get semantics for gameplay
24
+ // code); it does NOT clone. Callers that need disposal-safe clones use
25
+ // asset-loaders `loadGLTF`, which clones + tags `__sharedGeometry`.
26
+
27
+ function load<T = unknown>(url: string): Promise<T> {
28
+ const existing = cache.get(url);
29
+ if (existing) return existing as Promise<T>;
30
+
31
+ let promise: Promise<unknown>;
32
+
33
+ if (url.endsWith('.glb') || url.endsWith('.gltf')) {
34
+ promise = new Promise<GLTFResult>((resolve, reject) => {
35
+ gltfLoader.load(
36
+ url,
37
+ (gltf) => resolve({ scene: gltf.scene, animations: gltf.animations }),
38
+ undefined,
39
+ reject,
40
+ );
41
+ });
42
+ } else if (url.endsWith('.json')) {
43
+ promise = fetch(resolveUrl(url)).then((r) => {
44
+ if (!r.ok) throw new Error(`Failed to fetch ${url}: ${r.status}`);
45
+ return r.json();
46
+ });
47
+ } else {
48
+ throw new Error(`AssetCache: unsupported file type for "${url}"`);
49
+ }
50
+
51
+ const tracked = promise.then((value) => {
52
+ resolved.set(url, value);
53
+ return value;
54
+ });
55
+
56
+ cache.set(url, tracked);
57
+ return tracked as Promise<T>;
58
+ }
59
+
60
+ function get<T = unknown>(url: string): T {
61
+ if (!resolved.has(url)) {
62
+ throw new Error(`AssetCache: "${url}" not loaded. Call load() first and await it.`);
63
+ }
64
+ return resolved.get(url) as T;
65
+ }
66
+
67
+ return { load, get };
68
+ }