@fps-games/editor 0.1.8-beta.2 → 0.1.8-beta.4

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 (41) hide show
  1. package/dist/agent-bridge/capabilities/shadows.js +20 -1
  2. package/dist/agent-bridge/capabilities/shadows.js.map +1 -1
  3. package/dist/agent-bridge/shared.d.ts +7 -0
  4. package/dist/agent-bridge/shared.d.ts.map +1 -1
  5. package/dist/agent-bridge/shared.js.map +1 -1
  6. package/dist/build-info.json +16 -16
  7. package/node_modules/@fps-games/babylon-renderer/dist/shadows/planar-shadow-shaders.d.ts.map +1 -1
  8. package/node_modules/@fps-games/babylon-renderer/dist/shadows/planar-shadow-shaders.js +41 -3
  9. package/node_modules/@fps-games/babylon-renderer/dist/shadows/planar-shadow-shaders.js.map +1 -1
  10. package/node_modules/@fps-games/babylon-renderer/dist/shadows/planar-shadow-system.d.ts.map +1 -1
  11. package/node_modules/@fps-games/babylon-renderer/dist/shadows/planar-shadow-system.js +507 -90
  12. package/node_modules/@fps-games/babylon-renderer/dist/shadows/planar-shadow-system.js.map +1 -1
  13. package/node_modules/@fps-games/babylon-renderer/dist/shadows/types.d.ts +14 -0
  14. package/node_modules/@fps-games/babylon-renderer/dist/shadows/types.d.ts.map +1 -1
  15. package/node_modules/@fps-games/babylon-renderer/dist/shadows/types.js.map +1 -1
  16. package/node_modules/@fps-games/babylon-renderer/package.json +1 -1
  17. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts.map +1 -1
  18. package/node_modules/@fps-games/editor-babylon/dist/projection.js +39 -0
  19. package/node_modules/@fps-games/editor-babylon/dist/projection.js.map +1 -1
  20. package/node_modules/@fps-games/editor-babylon/dist/shadow-preview-controller.d.ts.map +1 -1
  21. package/node_modules/@fps-games/editor-babylon/dist/shadow-preview-controller.js +98 -13
  22. package/node_modules/@fps-games/editor-babylon/dist/shadow-preview-controller.js.map +1 -1
  23. package/node_modules/@fps-games/editor-babylon/package.json +5 -5
  24. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts.map +1 -1
  25. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js +17 -0
  26. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js.map +1 -1
  27. package/node_modules/@fps-games/editor-browser/package.json +3 -3
  28. package/node_modules/@fps-games/editor-core/package.json +2 -2
  29. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  30. package/node_modules/@fps-games/editor-playable-sdk/dist/inspector/document-sections.d.ts.map +1 -1
  31. package/node_modules/@fps-games/editor-playable-sdk/dist/inspector/document-sections.js +33 -27
  32. package/node_modules/@fps-games/editor-playable-sdk/dist/inspector/document-sections.js.map +1 -1
  33. package/node_modules/@fps-games/editor-playable-sdk/dist/serialized-property/inspector.js +24 -6
  34. package/node_modules/@fps-games/editor-playable-sdk/dist/serialized-property/inspector.js.map +1 -1
  35. package/node_modules/@fps-games/editor-playable-sdk/package.json +3 -3
  36. package/node_modules/@fps-games/editor-protocol/dist/agent-bridge.d.ts +14 -0
  37. package/node_modules/@fps-games/editor-protocol/dist/agent-bridge.d.ts.map +1 -1
  38. package/node_modules/@fps-games/editor-protocol/dist/agent-bridge.js +32 -0
  39. package/node_modules/@fps-games/editor-protocol/dist/agent-bridge.js.map +1 -1
  40. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  41. package/package.json +8 -8
@@ -30,12 +30,16 @@ class BabylonPlanarShadowSystem {
30
30
  this.casters = new Map();
31
31
  this.receivers = new Map();
32
32
  this.syncObservers = new Map();
33
+ this.instancedBatches = new Map();
34
+ this.instancedEntriesByRoot = new Map();
33
35
  this.renderObserver = null;
36
+ this.instancedBatchSyncObserver = null;
34
37
  this.meshAddedObserver = null;
35
38
  this.initialized = false;
36
39
  this.casterPatterns = [];
37
40
  this.resolvedPlaneNormal = Vector3.Up();
38
41
  this.resolvedPlaneHeight = 0;
42
+ this.patternNameCache = null;
39
43
  this.options = resolvePlanarShadowOptions(options);
40
44
  this.casterPatterns = [...this.options.casters.includePatterns];
41
45
  }
@@ -48,6 +52,7 @@ class BabylonPlanarShadowSystem {
48
52
  this.baseMaterial = this.createShadowMaterial();
49
53
  this.materials.add(this.baseMaterial);
50
54
  this.renderObserver = this.scene.onBeforeRenderObservable.add(() => this.updateUniforms());
55
+ this.instancedBatchSyncObserver = this.scene.onBeforeRenderObservable.add(() => this.syncInstancedBatches());
51
56
  this.initialized = true;
52
57
  this.refresh();
53
58
  }
@@ -55,7 +60,7 @@ class BabylonPlanarShadowSystem {
55
60
  const root = this.findCasterRoot(mesh);
56
61
  if (!this.initialized || this.casters.has(root))
57
62
  return false;
58
- if (!this.isValidShadowSource(mesh))
63
+ if (this.getInvalidShadowSourceReason(mesh))
59
64
  return false;
60
65
  const shadow = this.createShadowForNode(root);
61
66
  if (!shadow)
@@ -133,12 +138,21 @@ class BabylonPlanarShadowSystem {
133
138
  refresh() {
134
139
  if (!this.initialized)
135
140
  return;
136
- this.pruneDisposedCasters();
137
- this.refreshReceivers();
138
- for (const mesh of this.scene.meshes)
139
- this.tryAutoAddCaster(mesh);
140
- if (this.options.casters.autoDetectAll || this.casterPatterns.length > 0 || this.options.receivers.patterns.length > 0) {
141
- this.ensureMeshObserver();
141
+ const previousPatternNameCache = this.patternNameCache;
142
+ this.patternNameCache = new WeakMap();
143
+ try {
144
+ this.pruneDisposedCasters();
145
+ this.refreshReceivers();
146
+ if (this.options.casters.autoDetectAll || this.casterPatterns.length > 0) {
147
+ for (const mesh of this.scene.meshes)
148
+ this.tryAutoAddCaster(mesh);
149
+ }
150
+ if (this.options.casters.autoDetectAll || this.casterPatterns.length > 0 || this.options.receivers.patterns.length > 0) {
151
+ this.ensureMeshObserver();
152
+ }
153
+ }
154
+ finally {
155
+ this.patternNameCache = previousPatternNameCache;
142
156
  }
143
157
  }
144
158
  setOptions(options) {
@@ -179,6 +193,12 @@ class BabylonPlanarShadowSystem {
179
193
  projectedMeshCount: info.projectedMeshCount,
180
194
  convexHullMeshCount: info.convexHullMeshCount,
181
195
  alphaCutoutMeshCount: info.alphaCutoutMeshCount,
196
+ instancedCasterCount: info.instancedCasterCount,
197
+ batchMeshCount: info.batchMeshCount,
198
+ instancedBatchIds: [...info.instancedBatchIds],
199
+ instancedBatchNames: [...info.instancedBatchNames],
200
+ independentProjectedMeshCount: info.independentProjectedMeshCount,
201
+ visibleInstanceCount: info.visibleInstanceCount,
182
202
  alphaCutoutChannels: [...info.alphaCutoutChannels],
183
203
  alphaCutoutUvSets: [...info.alphaCutoutUvSets],
184
204
  alphaCutoutLevels: [...info.alphaCutoutLevels],
@@ -188,6 +208,7 @@ class BabylonPlanarShadowSystem {
188
208
  z: weights.z,
189
209
  })),
190
210
  fallbackReasons: [...info.fallbackReasons],
211
+ instancingFallbackReasons: [...info.instancingFallbackReasons],
191
212
  }));
192
213
  }
193
214
  dispose() {
@@ -195,6 +216,10 @@ class BabylonPlanarShadowSystem {
195
216
  this.scene.onBeforeRenderObservable.remove(this.renderObserver);
196
217
  this.renderObserver = null;
197
218
  }
219
+ if (this.instancedBatchSyncObserver) {
220
+ this.scene.onBeforeRenderObservable.remove(this.instancedBatchSyncObserver);
221
+ this.instancedBatchSyncObserver = null;
222
+ }
198
223
  if (this.meshAddedObserver) {
199
224
  this.scene.onNewMeshAddedObservable.remove(this.meshAddedObserver);
200
225
  this.meshAddedObserver = null;
@@ -205,6 +230,8 @@ class BabylonPlanarShadowSystem {
205
230
  }
206
231
  for (const mesh of [...this.casters.keys()])
207
232
  this.disposeCaster(mesh);
233
+ for (const batch of [...this.instancedBatches.values()])
234
+ this.disposeInstancedBatch(batch);
208
235
  for (const mesh of [...this.receivers.keys()])
209
236
  this.removeReceiver(mesh);
210
237
  for (const material of this.materials)
@@ -425,18 +452,58 @@ class BabylonPlanarShadowSystem {
425
452
  this.scene.onBeforeRenderObservable.remove(observer);
426
453
  this.syncObservers.delete(root);
427
454
  }
428
- for (const shadowMesh of info.shadow.getChildMeshes(false)) {
429
- if (shadowMesh instanceof Mesh
430
- && shadowMesh.material
431
- && shadowMesh.material !== this.baseMaterial
432
- && shadowMesh.material !== this.flatMaterial) {
433
- this.materials.delete(shadowMesh.material);
434
- shadowMesh.material.dispose();
455
+ this.removeInstancedEntriesForRoot(root);
456
+ if (info.independentProjectedMeshCount > 0 || info.convexHullMeshCount > 0) {
457
+ for (const shadowMesh of info.shadow.getChildMeshes(false)) {
458
+ if (shadowMesh instanceof Mesh
459
+ && shadowMesh.material
460
+ && shadowMesh.material !== this.baseMaterial
461
+ && shadowMesh.material !== this.flatMaterial) {
462
+ this.materials.delete(shadowMesh.material);
463
+ shadowMesh.material.dispose();
464
+ }
435
465
  }
466
+ info.shadow.dispose();
436
467
  }
437
- info.shadow.dispose();
438
468
  this.casters.delete(root);
439
469
  }
470
+ removeInstancedEntriesForRoot(root) {
471
+ const entries = this.instancedEntriesByRoot.get(root);
472
+ if (!entries || entries.length === 0)
473
+ return;
474
+ const entriesByBatch = new Map();
475
+ for (const entry of entries) {
476
+ const batchEntries = entriesByBatch.get(entry.batchKey) ?? new Set();
477
+ batchEntries.add(entry);
478
+ entriesByBatch.set(entry.batchKey, batchEntries);
479
+ }
480
+ for (const [batchKey, batchEntries] of entriesByBatch) {
481
+ const batch = this.instancedBatches.get(batchKey);
482
+ if (!batch)
483
+ continue;
484
+ batch.entries = batch.entries.filter(candidate => !batchEntries.has(candidate));
485
+ batch.entries.forEach((candidate, index) => {
486
+ candidate.instanceIndex = index;
487
+ });
488
+ if (batch.entries.length === 0) {
489
+ this.disposeInstancedBatch(batch);
490
+ }
491
+ else {
492
+ this.markInstancedBatchBuffersDirty(batch);
493
+ }
494
+ }
495
+ this.instancedEntriesByRoot.delete(root);
496
+ }
497
+ disposeInstancedBatch(batch) {
498
+ this.instancedBatches.delete(batch.key);
499
+ batch.shadow.thinInstanceSetBuffer('shadowVisibility', null);
500
+ batch.shadow.thinInstanceSetBuffer('matrix', null);
501
+ if (batch.material !== this.baseMaterial) {
502
+ this.materials.delete(batch.material);
503
+ batch.material.dispose();
504
+ }
505
+ batch.shadow.dispose();
506
+ }
440
507
  rebuildCasters() {
441
508
  const roots = [...this.casters.keys()].filter((root) => !isDisposedNode(root));
442
509
  for (const root of roots)
@@ -447,10 +514,10 @@ class BabylonPlanarShadowSystem {
447
514
  tryAutoAddCaster(mesh) {
448
515
  if (!this.initialized || this.casters.has(mesh))
449
516
  return;
450
- if (!this.isValidShadowSource(mesh))
451
- return;
452
517
  if (!this.options.casters.autoDetectAll && !this.matchesCasterPattern(mesh))
453
518
  return;
519
+ if (!this.isValidShadowSource(mesh))
520
+ return;
454
521
  const root = this.findCasterRoot(mesh);
455
522
  if (this.isAlreadyCoveredByCaster(root))
456
523
  return;
@@ -475,10 +542,16 @@ class BabylonPlanarShadowSystem {
475
542
  const validMeshes = collectNodeMeshes(root).filter((mesh) => this.isValidShadowSource(mesh));
476
543
  if (validMeshes.length === 0)
477
544
  return null;
478
- const shadowRoot = new TransformNode(`${root.name}_planarShadowRoot`, this.scene);
545
+ let shadowRoot = null;
546
+ const ensureShadowRoot = () => {
547
+ shadowRoot ?? (shadowRoot = new TransformNode(`${root.name}_planarShadowRoot`, this.scene));
548
+ return shadowRoot;
549
+ };
479
550
  let hasSkeleton = false;
480
551
  const pairs = [];
552
+ const instancedEntries = [];
481
553
  const fallbackReasons = [];
554
+ const instancingFallbackReasons = [];
482
555
  let sharedSkeleton = null;
483
556
  let sharedMaterial = null;
484
557
  for (const mesh of validMeshes) {
@@ -486,8 +559,20 @@ class BabylonPlanarShadowSystem {
486
559
  continue;
487
560
  const geometrySource = mesh instanceof InstancedMesh ? mesh.sourceMesh : mesh;
488
561
  const strategy = this.options.projection.strategy;
562
+ const instancingEligibility = this.resolveInstancingEligibility(mesh);
563
+ if (instancingEligibility.eligible) {
564
+ const instanced = this.addInstancedShadowMesh(root, mesh, instancingEligibility);
565
+ if (instanced) {
566
+ instancedEntries.push(instanced);
567
+ continue;
568
+ }
569
+ instancingFallbackReasons.push(`${mesh.name}: instanced projected batch unavailable`);
570
+ }
571
+ else {
572
+ instancingFallbackReasons.push(`${mesh.name}: ${instancingEligibility.reason}`);
573
+ }
489
574
  if (strategy !== 'convex-hull') {
490
- const alphaCutout = resolvePlanarShadowAlphaCutout(geometrySource);
575
+ const alphaCutout = instancingEligibility.alphaCutout;
491
576
  const material = this.resolveShadowMaterial(geometrySource, sharedSkeleton, sharedMaterial, alphaCutout);
492
577
  if (geometrySource.skeleton) {
493
578
  hasSkeleton = true;
@@ -498,7 +583,7 @@ class BabylonPlanarShadowSystem {
498
583
  const shadowMesh = material ? this.createProjectedShadowMesh(mesh, material) : null;
499
584
  if (shadowMesh) {
500
585
  const alphaCutoutLayers = alphaCutout?.layers.filter((layer) => !!layer) ?? [];
501
- shadowMesh.parent = shadowRoot;
586
+ shadowMesh.parent = ensureShadowRoot();
502
587
  pairs.push({
503
588
  mode: 'projected-mesh',
504
589
  source: mesh,
@@ -517,7 +602,7 @@ class BabylonPlanarShadowSystem {
517
602
  }
518
603
  const shadowMesh = this.createHullShadowMesh(mesh);
519
604
  if (shadowMesh) {
520
- shadowMesh.parent = shadowRoot;
605
+ shadowMesh.parent = ensureShadowRoot();
521
606
  pairs.push({
522
607
  mode: 'flat-hull',
523
608
  source: mesh,
@@ -533,46 +618,279 @@ class BabylonPlanarShadowSystem {
533
618
  fallbackReasons.push(`${mesh.name}: convex hull geometry unavailable`);
534
619
  }
535
620
  }
536
- if (pairs.length === 0) {
537
- shadowRoot.dispose();
621
+ if (pairs.length === 0 && instancedEntries.length === 0) {
622
+ this.removeInstancedEntriesForRoot(root);
538
623
  return null;
539
624
  }
540
- const sync = () => {
541
- if (shadowRoot.isDisposed())
542
- return;
543
- for (const pair of pairs) {
544
- if (pair.source.isDisposed() || pair.shadow.isDisposed())
545
- continue;
546
- if (pair.mode === 'flat-hull') {
547
- this.updateHullShadowMesh(pair.source, pair.shadow);
548
- }
549
- else {
550
- pair.shadow.setEnabled(pair.source.isEnabled());
551
- pair.shadow.isVisible = pair.source.isVisible;
552
- pair.source.computeWorldMatrix(true).decompose(pair.shadow.scaling, pair.shadow.rotationQuaternion, pair.shadow.position);
625
+ if (pairs.length > 0 && shadowRoot) {
626
+ const sync = () => {
627
+ if (!shadowRoot || shadowRoot.isDisposed())
628
+ return;
629
+ for (const pair of pairs) {
630
+ if (pair.source.isDisposed() || pair.shadow.isDisposed())
631
+ continue;
632
+ if (pair.mode === 'flat-hull') {
633
+ this.updateHullShadowMesh(pair.source, pair.shadow);
634
+ }
635
+ else {
636
+ pair.shadow.setEnabled(pair.source.isEnabled());
637
+ pair.shadow.isVisible = pair.source.isVisible;
638
+ pair.source.computeWorldMatrix(true).decompose(pair.shadow.scaling, pair.shadow.rotationQuaternion, pair.shadow.position);
639
+ }
553
640
  }
554
- }
555
- };
556
- sync();
557
- const observer = this.scene.onBeforeRenderObservable.add(sync);
558
- this.syncObservers.set(root, observer);
641
+ };
642
+ sync();
643
+ const observer = this.scene.onBeforeRenderObservable.add(sync);
644
+ this.syncObservers.set(root, observer);
645
+ }
646
+ const instancedBatchKeys = uniqueStrings(instancedEntries.map((entry) => entry.batchKey));
647
+ const instancedBatchNames = uniqueStrings(instancedBatchKeys.map((key) => this.instancedBatches.get(key)?.shadow.name ?? ''));
648
+ const instancedAlphaCutoutLayers = instancedEntries.flatMap((entry) => {
649
+ const batch = this.instancedBatches.get(entry.batchKey);
650
+ return batch?.alphaCutout?.layers.filter((layer) => !!layer) ?? [];
651
+ });
652
+ const instancedAlphaCutoutMeshCount = instancedEntries.filter((entry) => {
653
+ const batch = this.instancedBatches.get(entry.batchKey);
654
+ return !!batch?.alphaCutout;
655
+ }).length;
656
+ const independentProjectedMeshCount = pairs.filter((pair) => pair.mode === 'projected-mesh').length;
657
+ const visibleInstanceCount = instancedEntries.filter((entry) => readPlanarShadowEffectiveVisibility(entry.source)).length;
559
658
  return {
560
659
  source: root,
561
- shadow: shadowRoot,
562
- sourceMeshNames: uniqueStrings(pairs.map((pair) => pair.source.name)),
563
- sourceMeshUniqueIds: uniqueNumbers(pairs.map((pair) => pair.source.uniqueId)),
660
+ shadow: shadowRoot ?? this.instancedBatches.get(instancedEntries[0]?.batchKey)?.shadow ?? root,
661
+ sourceMeshNames: uniqueStrings([
662
+ ...pairs.map((pair) => pair.source.name),
663
+ ...instancedEntries.map((entry) => entry.source.name),
664
+ ]),
665
+ sourceMeshUniqueIds: uniqueNumbers([
666
+ ...pairs.map((pair) => pair.source.uniqueId),
667
+ ...instancedEntries.map((entry) => entry.source.uniqueId),
668
+ ]),
564
669
  hasSkeleton,
565
- strategy: summarizePlanarShadowStrategy(pairs),
566
- projectedMeshCount: pairs.filter((pair) => pair.mode === 'projected-mesh').length,
670
+ strategy: summarizePlanarShadowStrategy(pairs, instancedEntries.length),
671
+ projectedMeshCount: independentProjectedMeshCount + instancedBatchKeys.length,
567
672
  convexHullMeshCount: pairs.filter((pair) => pair.mode === 'flat-hull').length,
568
- alphaCutoutMeshCount: pairs.filter((pair) => pair.alphaCutout).length,
569
- alphaCutoutChannels: pairs.flatMap((pair) => pair.alphaCutoutChannels),
570
- alphaCutoutUvSets: pairs.flatMap((pair) => pair.alphaCutoutUvSets),
571
- alphaCutoutLevels: pairs.flatMap((pair) => pair.alphaCutoutLevels),
572
- alphaCutoutLuminanceWeights: pairs.flatMap((pair) => pair.alphaCutoutLuminanceWeights),
673
+ alphaCutoutMeshCount: pairs.filter((pair) => pair.alphaCutout).length + instancedAlphaCutoutMeshCount,
674
+ instancedCasterCount: instancedEntries.length,
675
+ batchMeshCount: instancedBatchKeys.length,
676
+ instancedBatchIds: instancedBatchKeys,
677
+ instancedBatchNames,
678
+ independentProjectedMeshCount,
679
+ visibleInstanceCount,
680
+ alphaCutoutChannels: [
681
+ ...pairs.flatMap((pair) => pair.alphaCutoutChannels),
682
+ ...instancedAlphaCutoutLayers.map((layer) => layer.channel),
683
+ ],
684
+ alphaCutoutUvSets: [
685
+ ...pairs.flatMap((pair) => pair.alphaCutoutUvSets),
686
+ ...instancedAlphaCutoutLayers.map((layer) => layer.coordinatesIndex),
687
+ ],
688
+ alphaCutoutLevels: [
689
+ ...pairs.flatMap((pair) => pair.alphaCutoutLevels),
690
+ ...instancedAlphaCutoutLayers.map((layer) => layer.level),
691
+ ],
692
+ alphaCutoutLuminanceWeights: [
693
+ ...pairs.flatMap((pair) => pair.alphaCutoutLuminanceWeights),
694
+ ...instancedAlphaCutoutLayers.map((layer) => layer.luminanceWeights),
695
+ ],
573
696
  fallbackReasons,
697
+ instancingFallbackReasons,
574
698
  };
575
699
  }
700
+ resolveInstancingEligibility(source) {
701
+ const geometrySource = source instanceof InstancedMesh ? source.sourceMesh : source;
702
+ const alphaCutout = resolvePlanarShadowAlphaCutout(geometrySource);
703
+ if (this.options.projection.strategy !== 'auto') {
704
+ return {
705
+ eligible: false,
706
+ reason: `projection strategy ${this.options.projection.strategy} bypasses instanced backend`,
707
+ alphaCutout,
708
+ };
709
+ }
710
+ if (this.requiresPerShadowMaterial()) {
711
+ return {
712
+ eligible: false,
713
+ reason: 'footprintScale requires per-caster projected material',
714
+ alphaCutout,
715
+ };
716
+ }
717
+ if (!geometrySource.geometry) {
718
+ return {
719
+ eligible: false,
720
+ reason: 'geometry unavailable',
721
+ alphaCutout,
722
+ };
723
+ }
724
+ if (geometrySource.skeleton) {
725
+ return {
726
+ eligible: false,
727
+ reason: 'skinned caster requires independent projected mesh',
728
+ alphaCutout,
729
+ };
730
+ }
731
+ if (geometrySource.morphTargetManager) {
732
+ return {
733
+ eligible: false,
734
+ reason: 'morph target caster requires independent projected mesh',
735
+ alphaCutout,
736
+ };
737
+ }
738
+ if (geometrySource.hasThinInstances) {
739
+ return {
740
+ eligible: false,
741
+ reason: 'source mesh thin instances require independent projected mesh',
742
+ alphaCutout,
743
+ };
744
+ }
745
+ const alphaSignature = createPlanarShadowInstancedAlphaCutoutSignature(alphaCutout);
746
+ if (!alphaSignature.signature) {
747
+ return {
748
+ eligible: false,
749
+ reason: alphaSignature.reason,
750
+ alphaCutout,
751
+ };
752
+ }
753
+ const geometryId = readPlanarShadowGeometryIdentity(geometrySource);
754
+ return {
755
+ eligible: true,
756
+ batchKey: [
757
+ 'projected',
758
+ `geometry:${geometryId}`,
759
+ `alpha:${alphaSignature.signature}`,
760
+ `shadowGroup:${this.options.stencil.shadowRenderingGroup}`,
761
+ `stencil:${this.options.stencil.enabled ? 1 : 0}`,
762
+ ].join('|'),
763
+ geometrySource,
764
+ alphaCutout,
765
+ alphaCutoutSignature: alphaSignature.signature,
766
+ };
767
+ }
768
+ addInstancedShadowMesh(root, source, eligibility) {
769
+ const batch = this.getOrCreateInstancedBatch(eligibility);
770
+ if (!batch)
771
+ return null;
772
+ const entry = {
773
+ root,
774
+ source,
775
+ geometrySource: eligibility.geometrySource,
776
+ instanceIndex: batch.entries.length,
777
+ batchKey: eligibility.batchKey,
778
+ };
779
+ batch.entries.push(entry);
780
+ const entries = this.instancedEntriesByRoot.get(root) ?? [];
781
+ entries.push(entry);
782
+ this.instancedEntriesByRoot.set(root, entries);
783
+ this.markInstancedBatchBuffersDirty(batch);
784
+ return entry;
785
+ }
786
+ getOrCreateInstancedBatch(eligibility) {
787
+ const existing = this.instancedBatches.get(eligibility.batchKey);
788
+ if (existing)
789
+ return existing;
790
+ const material = this.resolveShadowMaterial(eligibility.geometrySource, null, null, eligibility.alphaCutout);
791
+ if (!material)
792
+ return null;
793
+ const shadow = this.createInstancedProjectedShadowMesh(eligibility.geometrySource, material);
794
+ if (!shadow) {
795
+ if (material !== this.baseMaterial) {
796
+ this.materials.delete(material);
797
+ material.dispose();
798
+ }
799
+ return null;
800
+ }
801
+ const batch = {
802
+ key: eligibility.batchKey,
803
+ shadow,
804
+ material,
805
+ geometrySource: eligibility.geometrySource,
806
+ alphaCutout: eligibility.alphaCutout,
807
+ entries: [],
808
+ matrixData: new Float32Array(0),
809
+ visibilityData: new Float32Array(0),
810
+ buffersDirty: true,
811
+ };
812
+ this.instancedBatches.set(batch.key, batch);
813
+ return batch;
814
+ }
815
+ createInstancedProjectedShadowMesh(geometrySource, material) {
816
+ const shadowMesh = geometrySource.clone(`${geometrySource.name}_planarShadowBatch`, null, true, false);
817
+ if (!shadowMesh)
818
+ return null;
819
+ shadowMesh.parent = null;
820
+ shadowMesh.position.setAll(0);
821
+ shadowMesh.rotation.setAll(0);
822
+ shadowMesh.scaling.setAll(1);
823
+ shadowMesh.rotationQuaternion = null;
824
+ shadowMesh.material = material;
825
+ shadowMesh.isPickable = false;
826
+ shadowMesh.receiveShadows = false;
827
+ shadowMesh.renderingGroupId = this.options.stencil.shadowRenderingGroup;
828
+ shadowMesh.alphaIndex = PLANAR_SHADOW_ALPHA_INDEX;
829
+ shadowMesh.renderOutline = false;
830
+ shadowMesh.renderOverlay = false;
831
+ shadowMesh.alwaysSelectAsActiveMesh = true;
832
+ shadowMesh.metadata = {
833
+ ...(shadowMesh.metadata && typeof shadowMesh.metadata === 'object' ? shadowMesh.metadata : {}),
834
+ disablePlanarShadow: true,
835
+ planarShadowInternal: true,
836
+ planarShadowInstancedBatch: true,
837
+ };
838
+ shadowMesh.thinInstanceSetBuffer('matrix', new Float32Array(0), 16, false);
839
+ shadowMesh.thinInstanceSetBuffer('shadowVisibility', new Float32Array(0), 1, false);
840
+ return shadowMesh;
841
+ }
842
+ rebuildInstancedBatchBuffers(batch) {
843
+ const instanceCount = batch.entries.length;
844
+ batch.matrixData = new Float32Array(instanceCount * 16);
845
+ batch.visibilityData = new Float32Array(instanceCount);
846
+ this.writeInstancedBatchData(batch);
847
+ batch.shadow.thinInstanceSetBuffer('matrix', batch.matrixData, 16, false);
848
+ batch.shadow.thinInstanceSetBuffer('shadowVisibility', batch.visibilityData, 1, false);
849
+ batch.shadow.thinInstanceCount = instanceCount;
850
+ batch.buffersDirty = false;
851
+ batch.shadow.isVisible = instanceCount > 0;
852
+ }
853
+ syncInstancedBatches() {
854
+ for (const batch of this.instancedBatches.values()) {
855
+ if (this.shouldRebuildInstancedBatchBuffers(batch)) {
856
+ this.rebuildInstancedBatchBuffers(batch);
857
+ }
858
+ else {
859
+ this.writeInstancedBatchData(batch);
860
+ batch.shadow.thinInstanceBufferUpdated('matrix');
861
+ batch.shadow.thinInstanceBufferUpdated('shadowVisibility');
862
+ }
863
+ batch.shadow.isVisible = batch.entries.length > 0 && batch.visibilityData.some(value => value >= 0.5);
864
+ }
865
+ }
866
+ markInstancedBatchBuffersDirty(batch) {
867
+ batch.buffersDirty = true;
868
+ batch.shadow.thinInstanceCount = batch.entries.length;
869
+ }
870
+ shouldRebuildInstancedBatchBuffers(batch) {
871
+ return batch.buffersDirty
872
+ || batch.matrixData.length !== batch.entries.length * 16
873
+ || batch.visibilityData.length !== batch.entries.length;
874
+ }
875
+ writeInstancedBatchData(batch) {
876
+ batch.entries.forEach((entry, index) => {
877
+ entry.instanceIndex = index;
878
+ if (entry.source.isDisposed()) {
879
+ Matrix.Identity().copyToArray(batch.matrixData, index * 16);
880
+ batch.visibilityData[index] = 0;
881
+ return;
882
+ }
883
+ entry.source.computeWorldMatrix(true);
884
+ entry.source.getWorldMatrix().copyToArray(batch.matrixData, index * 16);
885
+ batch.visibilityData[index] = readPlanarShadowEffectiveVisibility(entry.source) ? 1 : 0;
886
+ });
887
+ for (const [root, info] of this.casters) {
888
+ const entries = this.instancedEntriesByRoot.get(root);
889
+ if (!entries?.some((entry) => entry.batchKey === batch.key))
890
+ continue;
891
+ info.visibleInstanceCount = entries.filter((entry) => readPlanarShadowEffectiveVisibility(entry.source)).length;
892
+ }
893
+ }
576
894
  resolveSkeletonShadowMaterial(geometrySource, sharedSkeleton, sharedMaterial) {
577
895
  if (geometrySource.skeleton === sharedSkeleton && sharedMaterial)
578
896
  return sharedMaterial;
@@ -664,29 +982,36 @@ class BabylonPlanarShadowSystem {
664
982
  shadowMesh.isVisible = true;
665
983
  }
666
984
  isValidShadowSource(mesh) {
985
+ return this.getInvalidShadowSourceReason(mesh) === null;
986
+ }
987
+ getInvalidShadowSourceReason(mesh) {
988
+ if (isDisposedNode(mesh))
989
+ return 'source disposed';
667
990
  if (!(mesh instanceof Mesh) && !(mesh instanceof InstancedMesh))
668
- return false;
991
+ return 'source is not a mesh';
669
992
  if (mesh instanceof Mesh && !mesh.geometry)
670
- return false;
993
+ return 'geometry unavailable';
671
994
  if (mesh instanceof InstancedMesh && !mesh.sourceMesh.geometry)
672
- return false;
673
- if (!mesh.isVisible || !mesh.isEnabled())
674
- return false;
995
+ return 'instance source geometry unavailable';
675
996
  if (hasDisablePlanarShadowMetadata(mesh))
676
- return false;
677
- if (this.matchesExcludePattern(mesh) || this.hasExcludedAncestor(mesh))
678
- return false;
997
+ return 'planar shadow disabled by metadata';
998
+ if (this.matchesExcludePattern(mesh))
999
+ return 'source matches exclude pattern';
1000
+ if (this.hasExcludedAncestor(mesh))
1001
+ return 'ancestor matches exclude or disabled pattern';
679
1002
  if (this.matchesReceiverPattern(mesh))
680
- return false;
1003
+ return 'source matches receiver pattern';
681
1004
  mesh.computeWorldMatrix(true);
682
1005
  const size = mesh.getBoundingInfo()?.boundingBox.extendSizeWorld;
683
1006
  if (!size)
684
- return true;
1007
+ return null;
685
1008
  const volume = size.x * size.y * size.z;
686
1009
  if (volume >= this.options.casters.minVolume)
687
- return true;
1010
+ return null;
688
1011
  const maxProjectedArea = Math.max(size.x * size.y, size.x * size.z, size.y * size.z);
689
- return maxProjectedArea >= this.options.casters.minVolume;
1012
+ return maxProjectedArea >= this.options.casters.minVolume
1013
+ ? null
1014
+ : `bounds below minVolume volume=${volume} maxProjectedArea=${maxProjectedArea}`;
690
1015
  }
691
1016
  findCasterRoot(mesh) {
692
1017
  let topmost = mesh;
@@ -706,7 +1031,7 @@ class BabylonPlanarShadowSystem {
706
1031
  return topmost;
707
1032
  }
708
1033
  matchesCasterPattern(mesh) {
709
- return this.casterPatterns.some((pattern) => nodeOrAncestorNameIncludes(mesh, pattern));
1034
+ return this.casterPatterns.some((pattern) => this.nodeOrAncestorNameIncludes(mesh, pattern));
710
1035
  }
711
1036
  matchesReceiver(mesh) {
712
1037
  return this.options.stencil.enabled
@@ -717,7 +1042,7 @@ class BabylonPlanarShadowSystem {
717
1042
  && this.matchesReceiverPattern(mesh);
718
1043
  }
719
1044
  matchesReceiverPattern(mesh) {
720
- return this.options.receivers.patterns.some((pattern) => nodeOrAncestorNameIncludes(mesh, pattern));
1045
+ return this.options.receivers.patterns.some((pattern) => this.nodeOrAncestorNameIncludes(mesh, pattern));
721
1046
  }
722
1047
  matchesExcludePattern(node) {
723
1048
  const name = node.name?.toLowerCase() ?? '';
@@ -742,12 +1067,44 @@ class BabylonPlanarShadowSystem {
742
1067
  return hasDisabledPlanarShadowReceiver(mesh, node => this.matchesRootBoundaryPattern(node));
743
1068
  }
744
1069
  isAlreadyCoveredByCaster(node) {
745
- for (const caster of this.casters.keys()) {
746
- if (caster === node || isDescendantOf(node, caster) || isDescendantOf(caster, node))
1070
+ if (this.casters.has(node))
1071
+ return true;
1072
+ let current = node.parent;
1073
+ while (current) {
1074
+ if (current instanceof AbstractMesh && this.casters.has(current))
1075
+ return true;
1076
+ current = current.parent;
1077
+ }
1078
+ for (const descendant of node.getChildMeshes(false)) {
1079
+ if (this.casters.has(descendant))
747
1080
  return true;
748
1081
  }
749
1082
  return false;
750
1083
  }
1084
+ nodeOrAncestorNameIncludes(mesh, pattern) {
1085
+ const normalized = pattern.toLowerCase();
1086
+ if (!normalized)
1087
+ return false;
1088
+ return this.readNodeAndAncestorNames(mesh).some(name => name.includes(normalized));
1089
+ }
1090
+ readNodeAndAncestorNames(mesh) {
1091
+ const cache = this.patternNameCache;
1092
+ const cached = cache?.get(mesh);
1093
+ if (cached)
1094
+ return cached;
1095
+ const names = [];
1096
+ let current = mesh;
1097
+ const seen = new Set();
1098
+ while (current && typeof current === 'object' && !seen.has(current)) {
1099
+ seen.add(current);
1100
+ const name = current.name;
1101
+ if (typeof name === 'string')
1102
+ names.push(name.toLowerCase());
1103
+ current = current.parent;
1104
+ }
1105
+ cache?.set(mesh, names);
1106
+ return names;
1107
+ }
751
1108
  }
752
1109
  function buildPlanarShadowHullGeometry(source, options) {
753
1110
  const geometrySource = source instanceof InstancedMesh ? source.sourceMesh : source;
@@ -817,6 +1174,87 @@ function bindPlanarShadowAlphaCutoutLayer(material, suffix, layer) {
817
1174
  material.setMatrix(`u_shadowAlphaMatrix${suffix}`, layer.texture.getTextureMatrix?.() ?? Matrix.Identity());
818
1175
  material.setTexture(`shadowAlphaSampler${suffix}`, layer.texture);
819
1176
  }
1177
+ function createPlanarShadowInstancedAlphaCutoutSignature(alphaCutout) {
1178
+ if (!alphaCutout)
1179
+ return { signature: 'opaque', reason: '' };
1180
+ const layers = alphaCutout.layers.filter((layer) => !!layer);
1181
+ if (layers.length !== 1) {
1182
+ return {
1183
+ signature: null,
1184
+ reason: 'multi-layer alpha cutout requires independent projected mesh',
1185
+ };
1186
+ }
1187
+ const [layer] = layers;
1188
+ const textureIdentity = readPlanarShadowTextureIdentity(layer.texture);
1189
+ if (!textureIdentity) {
1190
+ return {
1191
+ signature: null,
1192
+ reason: 'alpha cutout texture identity unavailable',
1193
+ };
1194
+ }
1195
+ return {
1196
+ signature: [
1197
+ 'cutout',
1198
+ `texture:${textureIdentity}`,
1199
+ `channel:${layer.channel}`,
1200
+ `uv:${layer.coordinatesIndex}`,
1201
+ `level:${formatPlanarShadowSignatureNumber(layer.level)}`,
1202
+ `cutoff:${formatPlanarShadowSignatureNumber(alphaCutout.cutoff)}`,
1203
+ `weights:${formatPlanarShadowSignatureNumber(layer.luminanceWeights.x)},${formatPlanarShadowSignatureNumber(layer.luminanceWeights.y)},${formatPlanarShadowSignatureNumber(layer.luminanceWeights.z)}`,
1204
+ `matrix:${serializePlanarShadowTextureMatrix(layer.texture)}`,
1205
+ ].join('|'),
1206
+ reason: '',
1207
+ };
1208
+ }
1209
+ function readPlanarShadowGeometryIdentity(mesh) {
1210
+ const geometry = mesh.geometry;
1211
+ const geometryUniqueId = geometry?.uniqueId;
1212
+ if (typeof geometryUniqueId === 'number' && Number.isFinite(geometryUniqueId))
1213
+ return `id:${geometryUniqueId}`;
1214
+ if (typeof geometryUniqueId === 'string' && geometryUniqueId.length > 0)
1215
+ return `id:${geometryUniqueId}`;
1216
+ return `mesh:${mesh.uniqueId}`;
1217
+ }
1218
+ function readPlanarShadowTextureIdentity(texture) {
1219
+ const record = texture;
1220
+ if (typeof record.uniqueId === 'number' && Number.isFinite(record.uniqueId))
1221
+ return `id:${record.uniqueId}`;
1222
+ if (typeof record.uniqueId === 'string' && record.uniqueId.length > 0)
1223
+ return `id:${record.uniqueId}`;
1224
+ if (typeof record.uid === 'string' && record.uid.length > 0)
1225
+ return `uid:${record.uid}`;
1226
+ if (typeof record.url === 'string' && record.url.length > 0)
1227
+ return `url:${record.url}`;
1228
+ if (typeof record.name === 'string' && record.name.length > 0)
1229
+ return `name:${record.name}`;
1230
+ return null;
1231
+ }
1232
+ function serializePlanarShadowTextureMatrix(texture) {
1233
+ const matrix = texture.getTextureMatrix?.() ?? Matrix.Identity();
1234
+ return [...matrix.m].map(formatPlanarShadowSignatureNumber).join(',');
1235
+ }
1236
+ function formatPlanarShadowSignatureNumber(value) {
1237
+ if (!Number.isFinite(value))
1238
+ return '0';
1239
+ return String(Math.round(value * 1000000) / 1000000);
1240
+ }
1241
+ function readPlanarShadowEffectiveVisibility(node) {
1242
+ let current = node;
1243
+ while (current) {
1244
+ if (isDisposedNode(current))
1245
+ return false;
1246
+ if (current.isEnabled?.() === false)
1247
+ return false;
1248
+ if (current.isVisible === false)
1249
+ return false;
1250
+ if (typeof current.visibility === 'number' && current.visibility <= 0)
1251
+ return false;
1252
+ current = current.parent && typeof current.parent === 'object'
1253
+ ? current.parent
1254
+ : null;
1255
+ }
1256
+ return true;
1257
+ }
820
1258
  function buildPlaneBasis(normal) {
821
1259
  const seed = Math.abs(Vector3.Dot(normal, Vector3.Up())) > 0.95
822
1260
  ? Vector3.Right()
@@ -898,8 +1336,8 @@ function resolvePlanarShadowOptions(override, base = DEFAULT_PLANAR_SHADOW_OPTIO
898
1336
  debug: override.debug ?? base.debug,
899
1337
  };
900
1338
  }
901
- function summarizePlanarShadowStrategy(pairs) {
902
- const projectedCount = pairs.filter((pair) => pair.mode === 'projected-mesh').length;
1339
+ function summarizePlanarShadowStrategy(pairs, instancedProjectedCount = 0) {
1340
+ const projectedCount = pairs.filter((pair) => pair.mode === 'projected-mesh').length + instancedProjectedCount;
903
1341
  const hullCount = pairs.filter((pair) => pair.mode === 'flat-hull').length;
904
1342
  if (projectedCount > 0 && hullCount === 0)
905
1343
  return 'projected-mesh';
@@ -934,27 +1372,6 @@ function collectNodeMeshes(root) {
934
1372
  meshes.unshift(root);
935
1373
  return meshes;
936
1374
  }
937
- function nodeOrAncestorNameIncludes(mesh, pattern) {
938
- const normalized = pattern.toLowerCase();
939
- if (mesh.name.toLowerCase().includes(normalized))
940
- return true;
941
- let current = mesh.parent;
942
- while (current) {
943
- if (current.name?.toLowerCase().includes(normalized))
944
- return true;
945
- current = current.parent;
946
- }
947
- return false;
948
- }
949
- function isDescendantOf(node, ancestor) {
950
- let current = node.parent;
951
- while (current) {
952
- if (current === ancestor)
953
- return true;
954
- current = current.parent;
955
- }
956
- return false;
957
- }
958
1375
  function hasDisablePlanarShadowMetadata(node) {
959
1376
  const metadata = node.metadata;
960
1377
  return !!metadata