@d5techs/3dgs-lib 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/3dgs-lib.cjs CHANGED
@@ -55,7 +55,7 @@ function computeBoundingBox$1(positions) {
55
55
  };
56
56
  }
57
57
  const min = [positions[0], positions[1], positions[2]];
58
- const max = [positions[0], positions[1], positions[2]];
58
+ const max2 = [positions[0], positions[1], positions[2]];
59
59
  for (let i = 3; i < positions.length; i += 3) {
60
60
  const x = positions[i];
61
61
  const y = positions[i + 1];
@@ -63,20 +63,20 @@ function computeBoundingBox$1(positions) {
63
63
  min[0] = Math.min(min[0], x);
64
64
  min[1] = Math.min(min[1], y);
65
65
  min[2] = Math.min(min[2], z);
66
- max[0] = Math.max(max[0], x);
67
- max[1] = Math.max(max[1], y);
68
- max[2] = Math.max(max[2], z);
66
+ max2[0] = Math.max(max2[0], x);
67
+ max2[1] = Math.max(max2[1], y);
68
+ max2[2] = Math.max(max2[2], z);
69
69
  }
70
70
  const center = [
71
- (min[0] + max[0]) / 2,
72
- (min[1] + max[1]) / 2,
73
- (min[2] + max[2]) / 2
71
+ (min[0] + max2[0]) / 2,
72
+ (min[1] + max2[1]) / 2,
73
+ (min[2] + max2[2]) / 2
74
74
  ];
75
- const dx = max[0] - min[0];
76
- const dy = max[1] - min[1];
77
- const dz = max[2] - min[2];
75
+ const dx = max2[0] - min[0];
76
+ const dy = max2[1] - min[1];
77
+ const dz = max2[2] - min[2];
78
78
  const radius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
79
- return { min, max, center, radius };
79
+ return { min, max: max2, center, radius };
80
80
  }
81
81
  function mergeBoundingBoxes(boxes) {
82
82
  if (boxes.length === 0) return null;
@@ -102,17 +102,17 @@ function mergeBoundingBoxes(boxes) {
102
102
  const radius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
103
103
  return { min: combinedMin, max: combinedMax, center, radius };
104
104
  }
105
- function createBoundingBoxFromMinMax(min, max) {
105
+ function createBoundingBoxFromMinMax(min, max2) {
106
106
  const center = [
107
- (min[0] + max[0]) / 2,
108
- (min[1] + max[1]) / 2,
109
- (min[2] + max[2]) / 2
107
+ (min[0] + max2[0]) / 2,
108
+ (min[1] + max2[1]) / 2,
109
+ (min[2] + max2[2]) / 2
110
110
  ];
111
- const dx = max[0] - min[0];
112
- const dy = max[1] - min[1];
113
- const dz = max[2] - min[2];
111
+ const dx = max2[0] - min[0];
112
+ const dy = max2[1] - min[1];
113
+ const dz = max2[2] - min[2];
114
114
  const radius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
115
- return { min, max, center, radius };
115
+ return { min, max: max2, center, radius };
116
116
  }
117
117
  function transformBoundingBox(bbox, modelMatrix) {
118
118
  const corners = [
@@ -440,10 +440,8 @@ class Camera {
440
440
  __publicField(this, "fov", Math.PI / 4);
441
441
  // 45度
442
442
  __publicField(this, "aspect", 1);
443
- __publicField(this, "near", 0.1);
444
- // 增大近平面以提高深度精度 (参考实现使用 0.1)
445
- __publicField(this, "far", 1e3);
446
- // 减小远平面以提高深度精度
443
+ __publicField(this, "near", 0.01);
444
+ __publicField(this, "far", 9e3);
447
445
  // 矩阵
448
446
  __publicField(this, "viewMatrix", new Float32Array(16));
449
447
  __publicField(this, "projectionMatrix", new Float32Array(16));
@@ -552,9 +550,7 @@ class OrbitControls {
552
550
  // 球坐标参数
553
551
  __publicField(this, "distance", 5);
554
552
  __publicField(this, "theta", 0);
555
- // 水平角 (绕Y轴)
556
553
  __publicField(this, "phi", Math.PI / 4);
557
- // 垂直角 (从Y轴向下)
558
554
  // 限制
559
555
  __publicField(this, "minDistance", 1e-3);
560
556
  __publicField(this, "maxDistance", Infinity);
@@ -567,10 +563,20 @@ class OrbitControls {
567
563
  // 移动端触摸灵敏度
568
564
  __publicField(this, "touchZoomSpeed", 0.01);
569
565
  __publicField(this, "touchPanSpeed", 3e-3);
566
+ // 阻尼(值越大越灵敏,越小越丝滑;0.12 约 5 帧消化 50% 增量)
567
+ __publicField(this, "enableDamping", true);
568
+ __publicField(this, "dampingFactor", 0.12);
570
569
  // 状态
571
570
  __publicField(this, "isDragging", false);
572
571
  __publicField(this, "lastX", 0);
573
572
  __publicField(this, "lastY", 0);
573
+ // 阻尼速度
574
+ __publicField(this, "velocityTheta", 0);
575
+ __publicField(this, "velocityPhi", 0);
576
+ __publicField(this, "velocityDistance", 0);
577
+ __publicField(this, "velocityPanX", 0);
578
+ __publicField(this, "velocityPanY", 0);
579
+ __publicField(this, "velocityPanZ", 0);
574
580
  // 触摸手势状态
575
581
  __publicField(this, "touchMode", "none");
576
582
  __publicField(this, "lastTouchDistance", 0);
@@ -597,11 +603,8 @@ class OrbitControls {
597
603
  this.boundOnTouchEnd = this.onTouchEnd.bind(this);
598
604
  this.boundOnContextMenu = (e) => e.preventDefault();
599
605
  this.setupEventListeners();
600
- this.update();
606
+ this.applySpherical();
601
607
  }
602
- /**
603
- * 设置事件监听
604
- */
605
608
  setupEventListeners() {
606
609
  this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
607
610
  this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
@@ -619,9 +622,6 @@ class OrbitControls {
619
622
  this.canvas.addEventListener("touchend", this.boundOnTouchEnd);
620
623
  this.canvas.addEventListener("contextmenu", this.boundOnContextMenu);
621
624
  }
622
- /**
623
- * 移除事件监听
624
- */
625
625
  removeEventListeners() {
626
626
  this.canvas.removeEventListener("mousedown", this.boundOnMouseDown);
627
627
  this.canvas.removeEventListener("mousemove", this.boundOnMouseMove);
@@ -633,12 +633,37 @@ class OrbitControls {
633
633
  this.canvas.removeEventListener("touchend", this.boundOnTouchEnd);
634
634
  this.canvas.removeEventListener("contextmenu", this.boundOnContextMenu);
635
635
  }
636
- /**
637
- * 销毁控制器
638
- */
639
636
  destroy() {
640
637
  this.removeEventListeners();
641
638
  }
639
+ /**
640
+ * 从视图矩阵提取相机的 right 和 up 向量,用于屏幕空间平移
641
+ */
642
+ getCameraAxes() {
643
+ const m = this.camera.viewMatrix;
644
+ return {
645
+ right: [m[0], m[4], m[8]],
646
+ up: [m[1], m[5], m[9]]
647
+ };
648
+ }
649
+ /**
650
+ * 屏幕空间平移:将屏幕 delta 映射到相机 right/up 方向
651
+ */
652
+ panByScreenDelta(deltaX, deltaY) {
653
+ const { right, up } = this.getCameraAxes();
654
+ const scale = this.panSpeed * this.distance;
655
+ const dx = -deltaX * scale;
656
+ const dy = deltaY * scale;
657
+ if (this.enableDamping) {
658
+ this.velocityPanX += dx * right[0] + dy * up[0];
659
+ this.velocityPanY += dx * right[1] + dy * up[1];
660
+ this.velocityPanZ += dx * right[2] + dy * up[2];
661
+ } else {
662
+ this.camera.target[0] += dx * right[0] + dy * up[0];
663
+ this.camera.target[1] += dx * right[1] + dy * up[1];
664
+ this.camera.target[2] += dx * right[2] + dy * up[2];
665
+ }
666
+ }
642
667
  onMouseDown(e) {
643
668
  if (!this.enabled) return;
644
669
  this.isDragging = true;
@@ -652,19 +677,20 @@ class OrbitControls {
652
677
  this.lastX = e.clientX;
653
678
  this.lastY = e.clientY;
654
679
  if (e.buttons === 1) {
655
- this.theta -= deltaX * this.rotateSpeed;
656
- this.phi -= deltaY * this.rotateSpeed;
657
- this.phi = Math.max(this.minPhi, Math.min(this.maxPhi, this.phi));
658
- } else if (e.buttons === 2) {
659
- const panX = -deltaX * this.panSpeed * this.distance;
660
- const panY = deltaY * this.panSpeed * this.distance;
661
- const sinTheta = Math.sin(this.theta);
662
- const cosTheta = Math.cos(this.theta);
663
- this.camera.target[0] += panX * cosTheta;
664
- this.camera.target[2] += panX * sinTheta;
665
- this.camera.target[1] += panY;
680
+ if (this.enableDamping) {
681
+ this.velocityTheta += -deltaX * this.rotateSpeed;
682
+ this.velocityPhi += -deltaY * this.rotateSpeed;
683
+ } else {
684
+ this.theta -= deltaX * this.rotateSpeed;
685
+ this.phi -= deltaY * this.rotateSpeed;
686
+ this.phi = Math.max(this.minPhi, Math.min(this.maxPhi, this.phi));
687
+ }
688
+ } else if (e.buttons === 2 || e.buttons === 4) {
689
+ this.panByScreenDelta(deltaX, deltaY);
690
+ }
691
+ if (!this.enableDamping) {
692
+ this.applySpherical();
666
693
  }
667
- this.update();
668
694
  }
669
695
  onMouseUp() {
670
696
  this.isDragging = false;
@@ -672,12 +698,17 @@ class OrbitControls {
672
698
  onWheel(e) {
673
699
  e.preventDefault();
674
700
  if (!this.enabled) return;
675
- this.distance += e.deltaY * this.zoomSpeed * this.distance;
676
- this.distance = Math.max(
677
- this.minDistance,
678
- Math.min(this.maxDistance, this.distance)
679
- );
680
- this.update();
701
+ const zoomDelta = e.deltaY * this.zoomSpeed;
702
+ if (this.enableDamping) {
703
+ this.velocityDistance += zoomDelta;
704
+ } else {
705
+ this.distance *= Math.exp(zoomDelta);
706
+ this.distance = Math.max(
707
+ this.minDistance,
708
+ Math.min(this.maxDistance, this.distance)
709
+ );
710
+ this.applySpherical();
711
+ }
681
712
  }
682
713
  onTouchStart(e) {
683
714
  e.preventDefault();
@@ -702,33 +733,51 @@ class OrbitControls {
702
733
  const deltaY = e.touches[0].clientY - this.lastY;
703
734
  this.lastX = e.touches[0].clientX;
704
735
  this.lastY = e.touches[0].clientY;
705
- this.theta -= deltaX * this.rotateSpeed;
706
- this.phi -= deltaY * this.rotateSpeed;
707
- this.phi = Math.max(this.minPhi, Math.min(this.maxPhi, this.phi));
708
- this.update();
736
+ if (this.enableDamping) {
737
+ this.velocityTheta += -deltaX * this.rotateSpeed;
738
+ this.velocityPhi += -deltaY * this.rotateSpeed;
739
+ } else {
740
+ this.theta -= deltaX * this.rotateSpeed;
741
+ this.phi -= deltaY * this.rotateSpeed;
742
+ this.phi = Math.max(this.minPhi, Math.min(this.maxPhi, this.phi));
743
+ this.applySpherical();
744
+ }
709
745
  } else if (e.touches.length === 2) {
710
746
  const currentDistance = this.getTouchDistance(e.touches);
711
747
  const currentCenter = this.getTouchCenter(e.touches);
712
748
  if (this.lastTouchDistance > 0) {
713
- const scale = this.lastTouchDistance / currentDistance;
714
- this.distance *= Math.pow(scale, this.touchZoomSpeed * 100);
715
- this.distance = Math.max(
716
- this.minDistance,
717
- Math.min(this.maxDistance, this.distance)
718
- );
749
+ const ratio = currentDistance / this.lastTouchDistance;
750
+ const zoomDelta = -Math.log(ratio) / (this.touchZoomSpeed * 100);
751
+ if (this.enableDamping) {
752
+ this.velocityDistance += zoomDelta;
753
+ } else {
754
+ this.distance *= Math.exp(zoomDelta);
755
+ this.distance = Math.max(
756
+ this.minDistance,
757
+ Math.min(this.maxDistance, this.distance)
758
+ );
759
+ }
719
760
  }
720
761
  const deltaX = currentCenter.x - this.lastTouchCenter.x;
721
762
  const deltaY = currentCenter.y - this.lastTouchCenter.y;
722
- const panX = -deltaX * this.touchPanSpeed * this.distance;
723
- const panY = deltaY * this.touchPanSpeed * this.distance;
724
- const sinTheta = Math.sin(this.theta);
725
- const cosTheta = Math.cos(this.theta);
726
- this.camera.target[0] += panX * cosTheta;
727
- this.camera.target[2] += panX * sinTheta;
728
- this.camera.target[1] += panY;
763
+ const { right, up } = this.getCameraAxes();
764
+ const scale = this.touchPanSpeed * this.distance;
765
+ const dx = -deltaX * scale;
766
+ const dy = deltaY * scale;
767
+ if (this.enableDamping) {
768
+ this.velocityPanX += dx * right[0] + dy * up[0];
769
+ this.velocityPanY += dx * right[1] + dy * up[1];
770
+ this.velocityPanZ += dx * right[2] + dy * up[2];
771
+ } else {
772
+ this.camera.target[0] += dx * right[0] + dy * up[0];
773
+ this.camera.target[1] += dx * right[1] + dy * up[1];
774
+ this.camera.target[2] += dx * right[2] + dy * up[2];
775
+ }
729
776
  this.lastTouchDistance = currentDistance;
730
777
  this.lastTouchCenter = currentCenter;
731
- this.update();
778
+ if (!this.enableDamping) {
779
+ this.applySpherical();
780
+ }
732
781
  }
733
782
  }
734
783
  onTouchEnd(e) {
@@ -742,17 +791,11 @@ class OrbitControls {
742
791
  this.lastY = e.touches[0].clientY;
743
792
  }
744
793
  }
745
- /**
746
- * 计算双指之间的距离
747
- */
748
794
  getTouchDistance(touches) {
749
795
  const dx = touches[0].clientX - touches[1].clientX;
750
796
  const dy = touches[0].clientY - touches[1].clientY;
751
797
  return Math.sqrt(dx * dx + dy * dy);
752
798
  }
753
- /**
754
- * 计算双指的中心点
755
- */
756
799
  getTouchCenter(touches) {
757
800
  return {
758
801
  x: (touches[0].clientX + touches[1].clientX) / 2,
@@ -760,9 +803,9 @@ class OrbitControls {
760
803
  };
761
804
  }
762
805
  /**
763
- * 根据球坐标更新相机位置
806
+ * 将球坐标写入相机位置(内部方法,不处理阻尼)
764
807
  */
765
- update() {
808
+ applySpherical() {
766
809
  const sinPhi = Math.sin(this.phi);
767
810
  const cosPhi = Math.cos(this.phi);
768
811
  const sinTheta = Math.sin(this.theta);
@@ -773,12 +816,42 @@ class OrbitControls {
773
816
  this.camera.updateMatrix();
774
817
  }
775
818
  /**
776
- * 切换到标准视图
777
- * @param axis 轴 'X' | 'Y' | 'Z'
778
- * @param positive 是否正向
779
- * @param animate 是否动画过渡
819
+ * 每帧调用:应用阻尼衰减并更新相机
820
+ * 在渲染循环中调用此方法以获得平滑惯性效果
780
821
  */
822
+ update() {
823
+ if (this.enableDamping) {
824
+ const EPS = 1e-6;
825
+ const f = this.dampingFactor;
826
+ this.theta += this.velocityTheta * f;
827
+ this.phi += this.velocityPhi * f;
828
+ this.phi = Math.max(this.minPhi, Math.min(this.maxPhi, this.phi));
829
+ this.distance *= Math.exp(this.velocityDistance * f);
830
+ this.distance = Math.max(
831
+ this.minDistance,
832
+ Math.min(this.maxDistance, this.distance)
833
+ );
834
+ this.camera.target[0] += this.velocityPanX * f;
835
+ this.camera.target[1] += this.velocityPanY * f;
836
+ this.camera.target[2] += this.velocityPanZ * f;
837
+ const decay = 1 - f;
838
+ this.velocityTheta *= decay;
839
+ this.velocityPhi *= decay;
840
+ this.velocityDistance *= decay;
841
+ this.velocityPanX *= decay;
842
+ this.velocityPanY *= decay;
843
+ this.velocityPanZ *= decay;
844
+ if (Math.abs(this.velocityTheta) < EPS) this.velocityTheta = 0;
845
+ if (Math.abs(this.velocityPhi) < EPS) this.velocityPhi = 0;
846
+ if (Math.abs(this.velocityDistance) < EPS) this.velocityDistance = 0;
847
+ if (Math.abs(this.velocityPanX) < EPS) this.velocityPanX = 0;
848
+ if (Math.abs(this.velocityPanY) < EPS) this.velocityPanY = 0;
849
+ if (Math.abs(this.velocityPanZ) < EPS) this.velocityPanZ = 0;
850
+ }
851
+ this.applySpherical();
852
+ }
781
853
  setViewAxis(axis, positive, animate = true) {
854
+ this.clearVelocity();
782
855
  let targetTheta = this.theta;
783
856
  let targetPhi = this.phi;
784
857
  switch (axis) {
@@ -799,12 +872,9 @@ class OrbitControls {
799
872
  } else {
800
873
  this.theta = targetTheta;
801
874
  this.phi = targetPhi;
802
- this.update();
875
+ this.applySpherical();
803
876
  }
804
877
  }
805
- /**
806
- * 动画过渡到目标视图
807
- */
808
878
  animateToView(targetTheta, targetPhi) {
809
879
  const startTheta = this.theta;
810
880
  const startPhi = this.phi;
@@ -819,28 +889,20 @@ class OrbitControls {
819
889
  const eased = 1 - Math.pow(1 - progress, 3);
820
890
  this.theta = startTheta + deltaTheta * eased;
821
891
  this.phi = startPhi + (targetPhi - startPhi) * eased;
822
- this.update();
892
+ this.applySpherical();
823
893
  if (progress < 1) {
824
894
  requestAnimationFrame(animate);
825
895
  }
826
896
  };
827
897
  requestAnimationFrame(animate);
828
898
  }
829
- /**
830
- * 设置相机目标点(控制器旋转中心)
831
- * @param x X 坐标
832
- * @param y Y 坐标
833
- * @param z Z 坐标
834
- */
835
899
  setTarget(x, y, z) {
900
+ this.clearVelocity();
836
901
  this.camera.target[0] = x;
837
902
  this.camera.target[1] = y;
838
903
  this.camera.target[2] = z;
839
- this.update();
904
+ this.applySpherical();
840
905
  }
841
- /**
842
- * 获取当前目标点
843
- */
844
906
  getTarget() {
845
907
  return [
846
908
  this.camera.target[0],
@@ -848,13 +910,8 @@ class OrbitControls {
848
910
  this.camera.target[2]
849
911
  ];
850
912
  }
851
- /**
852
- * 根据模型参数自动调整相机位置和参数
853
- * @param center 模型中心点
854
- * @param radius 模型包围球半径
855
- * @param animate 是否使用动画过渡
856
- */
857
913
  frameModel(center, radius, animate = true) {
914
+ this.clearVelocity();
858
915
  const fovRad = this.camera.fov;
859
916
  const halfFov = fovRad / 2;
860
917
  const marginFactor = 1.5;
@@ -867,12 +924,9 @@ class OrbitControls {
867
924
  this.camera.target[1] = center[1];
868
925
  this.camera.target[2] = center[2];
869
926
  this.distance = clampedDistance;
870
- this.update();
927
+ this.applySpherical();
871
928
  }
872
929
  }
873
- /**
874
- * 动画过渡到目标帧(包含目标点和距离)
875
- */
876
930
  animateToFrame(targetCenter, targetDistance) {
877
931
  const startTarget = [
878
932
  this.camera.target[0],
@@ -890,13 +944,21 @@ class OrbitControls {
890
944
  this.camera.target[1] = startTarget[1] + (targetCenter[1] - startTarget[1]) * eased;
891
945
  this.camera.target[2] = startTarget[2] + (targetCenter[2] - startTarget[2]) * eased;
892
946
  this.distance = startDistance + (targetDistance - startDistance) * eased;
893
- this.update();
947
+ this.applySpherical();
894
948
  if (progress < 1) {
895
949
  requestAnimationFrame(animate);
896
950
  }
897
951
  };
898
952
  requestAnimationFrame(animate);
899
953
  }
954
+ clearVelocity() {
955
+ this.velocityTheta = 0;
956
+ this.velocityPhi = 0;
957
+ this.velocityDistance = 0;
958
+ this.velocityPanX = 0;
959
+ this.velocityPanY = 0;
960
+ this.velocityPanZ = 0;
961
+ }
900
962
  }
901
963
  const gizmoShaderCode = (
902
964
  /* wgsl */
@@ -1559,11 +1621,11 @@ class BoundingBoxRenderer {
1559
1621
  * 生成顶点数据
1560
1622
  */
1561
1623
  generateVertices(box) {
1562
- const { min, max } = box;
1624
+ const { min, max: max2 } = box;
1563
1625
  const [r, g, b] = this.lineColor;
1564
- const dx = max[0] - min[0];
1565
- const dy = max[1] - min[1];
1566
- const dz = max[2] - min[2];
1626
+ const dx = max2[0] - min[0];
1627
+ const dy = max2[1] - min[1];
1628
+ const dz = max2[2] - min[2];
1567
1629
  const lx = dx * this.cornerRatio;
1568
1630
  const ly = dy * this.cornerRatio;
1569
1631
  const lz = dz * this.cornerRatio;
@@ -1575,27 +1637,27 @@ class BoundingBoxRenderer {
1575
1637
  addLine(min[0], min[1], min[2], min[0] + lx, min[1], min[2]);
1576
1638
  addLine(min[0], min[1], min[2], min[0], min[1] + ly, min[2]);
1577
1639
  addLine(min[0], min[1], min[2], min[0], min[1], min[2] + lz);
1578
- addLine(max[0], min[1], min[2], max[0] - lx, min[1], min[2]);
1579
- addLine(max[0], min[1], min[2], max[0], min[1] + ly, min[2]);
1580
- addLine(max[0], min[1], min[2], max[0], min[1], min[2] + lz);
1581
- addLine(min[0], max[1], min[2], min[0] + lx, max[1], min[2]);
1582
- addLine(min[0], max[1], min[2], min[0], max[1] - ly, min[2]);
1583
- addLine(min[0], max[1], min[2], min[0], max[1], min[2] + lz);
1584
- addLine(max[0], max[1], min[2], max[0] - lx, max[1], min[2]);
1585
- addLine(max[0], max[1], min[2], max[0], max[1] - ly, min[2]);
1586
- addLine(max[0], max[1], min[2], max[0], max[1], min[2] + lz);
1587
- addLine(min[0], min[1], max[2], min[0] + lx, min[1], max[2]);
1588
- addLine(min[0], min[1], max[2], min[0], min[1] + ly, max[2]);
1589
- addLine(min[0], min[1], max[2], min[0], min[1], max[2] - lz);
1590
- addLine(max[0], min[1], max[2], max[0] - lx, min[1], max[2]);
1591
- addLine(max[0], min[1], max[2], max[0], min[1] + ly, max[2]);
1592
- addLine(max[0], min[1], max[2], max[0], min[1], max[2] - lz);
1593
- addLine(min[0], max[1], max[2], min[0] + lx, max[1], max[2]);
1594
- addLine(min[0], max[1], max[2], min[0], max[1] - ly, max[2]);
1595
- addLine(min[0], max[1], max[2], min[0], max[1], max[2] - lz);
1596
- addLine(max[0], max[1], max[2], max[0] - lx, max[1], max[2]);
1597
- addLine(max[0], max[1], max[2], max[0], max[1] - ly, max[2]);
1598
- addLine(max[0], max[1], max[2], max[0], max[1], max[2] - lz);
1640
+ addLine(max2[0], min[1], min[2], max2[0] - lx, min[1], min[2]);
1641
+ addLine(max2[0], min[1], min[2], max2[0], min[1] + ly, min[2]);
1642
+ addLine(max2[0], min[1], min[2], max2[0], min[1], min[2] + lz);
1643
+ addLine(min[0], max2[1], min[2], min[0] + lx, max2[1], min[2]);
1644
+ addLine(min[0], max2[1], min[2], min[0], max2[1] - ly, min[2]);
1645
+ addLine(min[0], max2[1], min[2], min[0], max2[1], min[2] + lz);
1646
+ addLine(max2[0], max2[1], min[2], max2[0] - lx, max2[1], min[2]);
1647
+ addLine(max2[0], max2[1], min[2], max2[0], max2[1] - ly, min[2]);
1648
+ addLine(max2[0], max2[1], min[2], max2[0], max2[1], min[2] + lz);
1649
+ addLine(min[0], min[1], max2[2], min[0] + lx, min[1], max2[2]);
1650
+ addLine(min[0], min[1], max2[2], min[0], min[1] + ly, max2[2]);
1651
+ addLine(min[0], min[1], max2[2], min[0], min[1], max2[2] - lz);
1652
+ addLine(max2[0], min[1], max2[2], max2[0] - lx, min[1], max2[2]);
1653
+ addLine(max2[0], min[1], max2[2], max2[0], min[1] + ly, max2[2]);
1654
+ addLine(max2[0], min[1], max2[2], max2[0], min[1], max2[2] - lz);
1655
+ addLine(min[0], max2[1], max2[2], min[0] + lx, max2[1], max2[2]);
1656
+ addLine(min[0], max2[1], max2[2], min[0], max2[1] - ly, max2[2]);
1657
+ addLine(min[0], max2[1], max2[2], min[0], max2[1], max2[2] - lz);
1658
+ addLine(max2[0], max2[1], max2[2], max2[0] - lx, max2[1], max2[2]);
1659
+ addLine(max2[0], max2[1], max2[2], max2[0], max2[1] - ly, max2[2]);
1660
+ addLine(max2[0], max2[1], max2[2], max2[0], max2[1], max2[2] - lz);
1599
1661
  return new Float32Array(vertices);
1600
1662
  }
1601
1663
  /**
@@ -1908,6 +1970,7 @@ class MeshRenderer {
1908
1970
  __publicField(this, "renderer");
1909
1971
  __publicField(this, "camera");
1910
1972
  __publicField(this, "items", []);
1973
+ __publicField(this, "overlayItems", []);
1911
1974
  // 有纹理的管线
1912
1975
  __publicField(this, "pipelineTextured");
1913
1976
  __publicField(this, "pipelineTexturedDoubleSided");
@@ -1916,6 +1979,11 @@ class MeshRenderer {
1916
1979
  __publicField(this, "pipelineUntextured");
1917
1980
  __publicField(this, "pipelineUntexturedDoubleSided");
1918
1981
  __publicField(this, "bindGroupLayoutUntextured");
1982
+ // 覆盖层管线(带 depthBias,用于贴面热点/指示器)
1983
+ __publicField(this, "overlayPipelineTextured");
1984
+ __publicField(this, "overlayPipelineTexturedDoubleSided");
1985
+ __publicField(this, "overlayPipelineUntextured");
1986
+ __publicField(this, "overlayPipelineUntexturedDoubleSided");
1919
1987
  __publicField(this, "sampler");
1920
1988
  __publicField(this, "defaultTexture");
1921
1989
  // 光照方向
@@ -1969,6 +2037,19 @@ class MeshRenderer {
1969
2037
  { shaderLocation: 2, offset: 24, format: "float32x2" }
1970
2038
  ]
1971
2039
  };
2040
+ const depthStencilNormal = {
2041
+ format: this.renderer.depthFormat,
2042
+ depthWriteEnabled: true,
2043
+ depthCompare: "less"
2044
+ };
2045
+ const depthStencilOverlay = {
2046
+ format: this.renderer.depthFormat,
2047
+ depthWriteEnabled: false,
2048
+ depthCompare: "less",
2049
+ depthBias: -2e3,
2050
+ depthBiasSlopeScale: -4,
2051
+ depthBiasClamp: 0
2052
+ };
1972
2053
  const basePipelineDescTextured = {
1973
2054
  layout: pipelineLayoutTextured,
1974
2055
  vertex: {
@@ -1982,7 +2063,7 @@ class MeshRenderer {
1982
2063
  targets: [{ format: this.renderer.format }]
1983
2064
  },
1984
2065
  primitive: { topology: "triangle-list", frontFace: "ccw" },
1985
- depthStencil: { format: this.renderer.depthFormat, depthWriteEnabled: true, depthCompare: "less" }
2066
+ depthStencil: depthStencilNormal
1986
2067
  };
1987
2068
  this.pipelineTextured = device.createRenderPipeline({
1988
2069
  ...basePipelineDescTextured,
@@ -1992,6 +2073,15 @@ class MeshRenderer {
1992
2073
  ...basePipelineDescTextured,
1993
2074
  primitive: { ...basePipelineDescTextured.primitive, cullMode: "none" }
1994
2075
  });
2076
+ const overlayDescTextured = { ...basePipelineDescTextured, depthStencil: depthStencilOverlay };
2077
+ this.overlayPipelineTextured = device.createRenderPipeline({
2078
+ ...overlayDescTextured,
2079
+ primitive: { ...overlayDescTextured.primitive, cullMode: "back" }
2080
+ });
2081
+ this.overlayPipelineTexturedDoubleSided = device.createRenderPipeline({
2082
+ ...overlayDescTextured,
2083
+ primitive: { ...overlayDescTextured.primitive, cullMode: "none" }
2084
+ });
1995
2085
  const shaderModuleUntextured = device.createShaderModule({ code: shaderCodeUntextured });
1996
2086
  this.bindGroupLayoutUntextured = device.createBindGroupLayout({
1997
2087
  entries: [
@@ -2021,7 +2111,7 @@ class MeshRenderer {
2021
2111
  targets: [{ format: this.renderer.format }]
2022
2112
  },
2023
2113
  primitive: { topology: "triangle-list", frontFace: "ccw" },
2024
- depthStencil: { format: this.renderer.depthFormat, depthWriteEnabled: true, depthCompare: "less" }
2114
+ depthStencil: depthStencilNormal
2025
2115
  };
2026
2116
  this.pipelineUntextured = device.createRenderPipeline({
2027
2117
  ...basePipelineDescUntextured,
@@ -2031,6 +2121,15 @@ class MeshRenderer {
2031
2121
  ...basePipelineDescUntextured,
2032
2122
  primitive: { ...basePipelineDescUntextured.primitive, cullMode: "none" }
2033
2123
  });
2124
+ const overlayDescUntextured = { ...basePipelineDescUntextured, depthStencil: depthStencilOverlay };
2125
+ this.overlayPipelineUntextured = device.createRenderPipeline({
2126
+ ...overlayDescUntextured,
2127
+ primitive: { ...overlayDescUntextured.primitive, cullMode: "back" }
2128
+ });
2129
+ this.overlayPipelineUntexturedDoubleSided = device.createRenderPipeline({
2130
+ ...overlayDescUntextured,
2131
+ primitive: { ...overlayDescUntextured.primitive, cullMode: "none" }
2132
+ });
2034
2133
  }
2035
2134
  /**
2036
2135
  * 添加网格(带材质)- 每个 mesh 创建独立的 uniform buffer
@@ -2070,7 +2169,7 @@ class MeshRenderer {
2070
2169
  this.items.push({ mesh, material: mat, uniformBuffer, bindGroup });
2071
2170
  }
2072
2171
  /**
2073
- * 移除网格
2172
+ * 移除网格(销毁 GPU 资源)
2074
2173
  */
2075
2174
  removeMesh(mesh) {
2076
2175
  const index = this.items.findIndex((item) => item.mesh === mesh);
@@ -2081,6 +2180,65 @@ class MeshRenderer {
2081
2180
  this.items.splice(index, 1);
2082
2181
  }
2083
2182
  }
2183
+ /**
2184
+ * 从渲染列表中分离网格(不销毁 GPU 资源,可重新添加)
2185
+ */
2186
+ detachMesh(mesh) {
2187
+ const index = this.items.findIndex((item) => item.mesh === mesh);
2188
+ if (index !== -1) {
2189
+ const item = this.items[index];
2190
+ item.uniformBuffer.destroy();
2191
+ this.items.splice(index, 1);
2192
+ }
2193
+ }
2194
+ /**
2195
+ * 添加覆盖层网格(使用带 depthBias 的管线,贴面不被 3DGS 深度遮挡)
2196
+ */
2197
+ addOverlayMesh(mesh, material) {
2198
+ const device = this.renderer.device;
2199
+ const mat = material || {
2200
+ baseColorFactor: [0.8, 0.8, 0.8, 1],
2201
+ baseColorTexture: null,
2202
+ metallicFactor: 0,
2203
+ roughnessFactor: 0.5,
2204
+ doubleSided: false
2205
+ };
2206
+ const uniformBuffer = device.createBuffer({
2207
+ size: UNIFORM_BUFFER_SIZE,
2208
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
2209
+ });
2210
+ let bindGroup;
2211
+ if (mesh.hasUV) {
2212
+ const texture = mat.baseColorTexture || this.defaultTexture;
2213
+ bindGroup = device.createBindGroup({
2214
+ layout: this.bindGroupLayoutTextured,
2215
+ entries: [
2216
+ { binding: 0, resource: { buffer: uniformBuffer } },
2217
+ { binding: 1, resource: this.sampler },
2218
+ { binding: 2, resource: texture.createView() }
2219
+ ]
2220
+ });
2221
+ } else {
2222
+ bindGroup = device.createBindGroup({
2223
+ layout: this.bindGroupLayoutUntextured,
2224
+ entries: [
2225
+ { binding: 0, resource: { buffer: uniformBuffer } }
2226
+ ]
2227
+ });
2228
+ }
2229
+ this.overlayItems.push({ mesh, material: mat, uniformBuffer, bindGroup });
2230
+ }
2231
+ /**
2232
+ * 从覆盖层列表中分离网格(不销毁 vertex/index buffer)
2233
+ */
2234
+ detachOverlayMesh(mesh) {
2235
+ const index = this.overlayItems.findIndex((item) => item.mesh === mesh);
2236
+ if (index !== -1) {
2237
+ const item = this.overlayItems[index];
2238
+ item.uniformBuffer.destroy();
2239
+ this.overlayItems.splice(index, 1);
2240
+ }
2241
+ }
2084
2242
  /**
2085
2243
  * 按索引移除网格
2086
2244
  */
@@ -2095,7 +2253,7 @@ class MeshRenderer {
2095
2253
  return false;
2096
2254
  }
2097
2255
  /**
2098
- * 清空所有网格
2256
+ * 清空所有网格(含覆盖层)
2099
2257
  */
2100
2258
  clear() {
2101
2259
  for (const item of this.items) {
@@ -2103,6 +2261,11 @@ class MeshRenderer {
2103
2261
  item.uniformBuffer.destroy();
2104
2262
  }
2105
2263
  this.items = [];
2264
+ for (const item of this.overlayItems) {
2265
+ item.mesh.destroy();
2266
+ item.uniformBuffer.destroy();
2267
+ }
2268
+ this.overlayItems = [];
2106
2269
  }
2107
2270
  /**
2108
2271
  * 设置光照方向
@@ -2126,10 +2289,9 @@ class MeshRenderer {
2126
2289
  return this.ambientIntensity;
2127
2290
  }
2128
2291
  /**
2129
- * 渲染所有网格
2292
+ * 渲染所有网格(普通 + 覆盖层)
2130
2293
  */
2131
2294
  render(pass) {
2132
- if (this.items.length === 0) return;
2133
2295
  const device = this.renderer.device;
2134
2296
  const vpMatrix = new Float32Array(this.camera.viewProjectionMatrix);
2135
2297
  const lightData = new Float32Array([
@@ -2138,7 +2300,12 @@ class MeshRenderer {
2138
2300
  this.lightDir[2],
2139
2301
  this.ambientIntensity
2140
2302
  ]);
2141
- for (const item of this.items) {
2303
+ this.renderItems(pass, this.items, device, vpMatrix, lightData, false);
2304
+ this.renderItems(pass, this.overlayItems, device, vpMatrix, lightData, true);
2305
+ }
2306
+ renderItems(pass, items, device, vpMatrix, lightData, overlay) {
2307
+ if (items.length === 0) return;
2308
+ for (const item of items) {
2142
2309
  const { mesh, material, uniformBuffer, bindGroup } = item;
2143
2310
  device.queue.writeBuffer(uniformBuffer, 0, vpMatrix.buffer);
2144
2311
  device.queue.writeBuffer(uniformBuffer, 64, mesh.modelMatrix.buffer);
@@ -2146,10 +2313,18 @@ class MeshRenderer {
2146
2313
  device.queue.writeBuffer(uniformBuffer, 128, colorData.buffer);
2147
2314
  device.queue.writeBuffer(uniformBuffer, 144, lightData.buffer);
2148
2315
  let pipeline;
2149
- if (mesh.hasUV) {
2150
- pipeline = material.doubleSided ? this.pipelineTexturedDoubleSided : this.pipelineTextured;
2316
+ if (overlay) {
2317
+ if (mesh.hasUV) {
2318
+ pipeline = material.doubleSided ? this.overlayPipelineTexturedDoubleSided : this.overlayPipelineTextured;
2319
+ } else {
2320
+ pipeline = material.doubleSided ? this.overlayPipelineUntexturedDoubleSided : this.overlayPipelineUntextured;
2321
+ }
2151
2322
  } else {
2152
- pipeline = material.doubleSided ? this.pipelineUntexturedDoubleSided : this.pipelineUntextured;
2323
+ if (mesh.hasUV) {
2324
+ pipeline = material.doubleSided ? this.pipelineTexturedDoubleSided : this.pipelineTextured;
2325
+ } else {
2326
+ pipeline = material.doubleSided ? this.pipelineUntexturedDoubleSided : this.pipelineUntextured;
2327
+ }
2153
2328
  }
2154
2329
  pass.setPipeline(pipeline);
2155
2330
  pass.setBindGroup(0, bindGroup);
@@ -2171,18 +2346,12 @@ class MeshRenderer {
2171
2346
  }
2172
2347
  return null;
2173
2348
  }
2174
- /**
2175
- * 获取指定索引网格的材质颜色
2176
- */
2177
2349
  getMeshColor(index) {
2178
2350
  if (index >= 0 && index < this.items.length) {
2179
2351
  return [...this.items[index].material.baseColorFactor];
2180
2352
  }
2181
2353
  return null;
2182
2354
  }
2183
- /**
2184
- * 设置指定索引网格的材质颜色
2185
- */
2186
2355
  setMeshColor(index, r, g, b, a = 1) {
2187
2356
  if (index >= 0 && index < this.items.length) {
2188
2357
  this.items[index].material.baseColorFactor = [r, g, b, a];
@@ -2190,9 +2359,6 @@ class MeshRenderer {
2190
2359
  }
2191
2360
  return false;
2192
2361
  }
2193
- /**
2194
- * 设置指定范围内所有网格的材质颜色
2195
- */
2196
2362
  setMeshRangeColor(startIndex, count, r, g, b, a = 1) {
2197
2363
  let modified = 0;
2198
2364
  for (let i = 0; i < count; i++) {
@@ -2202,6 +2368,50 @@ class MeshRenderer {
2202
2368
  }
2203
2369
  return modified;
2204
2370
  }
2371
+ // ============================================
2372
+ // 覆盖层网格查询 / 颜色 / 删除
2373
+ // ============================================
2374
+ getOverlayMeshCount() {
2375
+ return this.overlayItems.length;
2376
+ }
2377
+ getOverlayMeshByIndex(index) {
2378
+ if (index >= 0 && index < this.overlayItems.length) {
2379
+ return this.overlayItems[index].mesh;
2380
+ }
2381
+ return null;
2382
+ }
2383
+ removeOverlayMeshByIndex(index) {
2384
+ if (index >= 0 && index < this.overlayItems.length) {
2385
+ const item = this.overlayItems[index];
2386
+ item.mesh.destroy();
2387
+ item.uniformBuffer.destroy();
2388
+ this.overlayItems.splice(index, 1);
2389
+ return true;
2390
+ }
2391
+ return false;
2392
+ }
2393
+ getOverlayMeshColor(index) {
2394
+ if (index >= 0 && index < this.overlayItems.length) {
2395
+ return [...this.overlayItems[index].material.baseColorFactor];
2396
+ }
2397
+ return null;
2398
+ }
2399
+ setOverlayMeshColor(index, r, g, b, a = 1) {
2400
+ if (index >= 0 && index < this.overlayItems.length) {
2401
+ this.overlayItems[index].material.baseColorFactor = [r, g, b, a];
2402
+ return true;
2403
+ }
2404
+ return false;
2405
+ }
2406
+ setOverlayMeshRangeColor(startIndex, count, r, g, b, a = 1) {
2407
+ let modified = 0;
2408
+ for (let i = 0; i < count; i++) {
2409
+ if (this.setOverlayMeshColor(startIndex + i, r, g, b, a)) {
2410
+ modified++;
2411
+ }
2412
+ }
2413
+ return modified;
2414
+ }
2205
2415
  getCombinedBoundingBox() {
2206
2416
  if (this.items.length === 0) return null;
2207
2417
  let combinedMin = null;
@@ -3189,12 +3399,12 @@ class OBJParser {
3189
3399
  * 解析材质引用 (usemtl name)
3190
3400
  */
3191
3401
  parseUseMaterial(parts) {
3192
- var _a;
3402
+ var _a2;
3193
3403
  if (parts.length > 1) {
3194
3404
  this.currentMaterial = parts.slice(1).join(" ");
3195
3405
  if (this.currentObject && this.currentObject.indices.length > 0) {
3196
3406
  this.finalizeCurrentObject();
3197
- this.createNewObject(((_a = this.currentObject) == null ? void 0 : _a.name) || "default");
3407
+ this.createNewObject(((_a2 = this.currentObject) == null ? void 0 : _a2.name) || "default");
3198
3408
  }
3199
3409
  if (this.currentObject) {
3200
3410
  this.currentObject.materialName = this.currentMaterial;
@@ -3521,11 +3731,14 @@ class OBJLoader {
3521
3731
  this.device.queue.writeBuffer(indexBuffer, 0, indexData);
3522
3732
  } else {
3523
3733
  const indexData = new Uint16Array(obj.indices);
3734
+ const alignedSize = Math.ceil(indexData.byteLength / 4) * 4;
3735
+ const alignedBuffer = new Uint8Array(alignedSize);
3736
+ alignedBuffer.set(new Uint8Array(indexData.buffer, indexData.byteOffset, indexData.byteLength));
3524
3737
  indexBuffer = this.device.createBuffer({
3525
- size: indexData.byteLength,
3738
+ size: alignedSize,
3526
3739
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST
3527
3740
  });
3528
- this.device.queue.writeBuffer(indexBuffer, 0, indexData);
3741
+ this.device.queue.writeBuffer(indexBuffer, 0, alignedBuffer);
3529
3742
  }
3530
3743
  const boundingBox = this.computeBoundingBoxFromPositions(obj.positions);
3531
3744
  const mesh = new Mesh(vertexBuffer, vertexCount, indexBuffer, indexCount, boundingBox);
@@ -4011,7 +4224,7 @@ function createSeededRandom(seed) {
4011
4224
  return ((t ^ t >>> 14) >>> 0) / 4294967296;
4012
4225
  };
4013
4226
  }
4014
- const SH_C0 = 0.28209479177387814;
4227
+ const SH_C0$1 = 0.28209479177387814;
4015
4228
  function computeImportanceSampling(buffer, dataOffset, stride, totalCount, sampleCount, opacityOffset, opacityType, scale0Offset, scale0Type, scale1Offset, scale1Type, scale2Offset, scale2Type, littleEndian, seed) {
4016
4229
  const dataView = new DataView(buffer, dataOffset);
4017
4230
  const random = createSeededRandom(seed);
@@ -4086,12 +4299,12 @@ async function parsePLYBuffer(buffer, options = {}) {
4086
4299
  propMap.set(prop.name, prop);
4087
4300
  }
4088
4301
  const getOffset = (name) => {
4089
- var _a;
4090
- return ((_a = propMap.get(name)) == null ? void 0 : _a.byteOffset) ?? -1;
4302
+ var _a2;
4303
+ return ((_a2 = propMap.get(name)) == null ? void 0 : _a2.byteOffset) ?? -1;
4091
4304
  };
4092
4305
  const getType = (name) => {
4093
- var _a;
4094
- return ((_a = propMap.get(name)) == null ? void 0 : _a.type) ?? "float";
4306
+ var _a2;
4307
+ return ((_a2 = propMap.get(name)) == null ? void 0 : _a2.type) ?? "float";
4095
4308
  };
4096
4309
  const offsets = {
4097
4310
  x: getOffset("x"),
@@ -4186,9 +4399,9 @@ async function parsePLYBuffer(buffer, options = {}) {
4186
4399
  const f_dc_0 = offsets.f_dc_0 >= 0 ? readProperty(dataView, base + offsets.f_dc_0, types.f_dc_0, littleEndian) : 0;
4187
4400
  const f_dc_1 = offsets.f_dc_1 >= 0 ? readProperty(dataView, base + offsets.f_dc_1, types.f_dc_1, littleEndian) : 0;
4188
4401
  const f_dc_2 = offsets.f_dc_2 >= 0 ? readProperty(dataView, base + offsets.f_dc_2, types.f_dc_2, littleEndian) : 0;
4189
- colors[outputIdx * 3 + 0] = 0.5 + SH_C0 * f_dc_0;
4190
- colors[outputIdx * 3 + 1] = 0.5 + SH_C0 * f_dc_1;
4191
- colors[outputIdx * 3 + 2] = 0.5 + SH_C0 * f_dc_2;
4402
+ colors[outputIdx * 3 + 0] = 0.5 + SH_C0$1 * f_dc_0;
4403
+ colors[outputIdx * 3 + 1] = 0.5 + SH_C0$1 * f_dc_1;
4404
+ colors[outputIdx * 3 + 2] = 0.5 + SH_C0$1 * f_dc_2;
4192
4405
  const rawOpacity = offsets.opacity >= 0 ? readProperty(dataView, base + offsets.opacity, types.opacity, littleEndian) : 0;
4193
4406
  opacities[outputIdx] = sigmoid(rawOpacity);
4194
4407
  if (shCoeffs && shProps.length > 0) {
@@ -4377,6 +4590,605 @@ function deserializeSplat(data) {
4377
4590
  }
4378
4591
  return splats;
4379
4592
  }
4593
+ var u8 = Uint8Array, u16 = Uint16Array, i32 = Int32Array;
4594
+ var fleb = new u8([
4595
+ 0,
4596
+ 0,
4597
+ 0,
4598
+ 0,
4599
+ 0,
4600
+ 0,
4601
+ 0,
4602
+ 0,
4603
+ 1,
4604
+ 1,
4605
+ 1,
4606
+ 1,
4607
+ 2,
4608
+ 2,
4609
+ 2,
4610
+ 2,
4611
+ 3,
4612
+ 3,
4613
+ 3,
4614
+ 3,
4615
+ 4,
4616
+ 4,
4617
+ 4,
4618
+ 4,
4619
+ 5,
4620
+ 5,
4621
+ 5,
4622
+ 5,
4623
+ 0,
4624
+ /* unused */
4625
+ 0,
4626
+ 0,
4627
+ /* impossible */
4628
+ 0
4629
+ ]);
4630
+ var fdeb = new u8([
4631
+ 0,
4632
+ 0,
4633
+ 0,
4634
+ 0,
4635
+ 1,
4636
+ 1,
4637
+ 2,
4638
+ 2,
4639
+ 3,
4640
+ 3,
4641
+ 4,
4642
+ 4,
4643
+ 5,
4644
+ 5,
4645
+ 6,
4646
+ 6,
4647
+ 7,
4648
+ 7,
4649
+ 8,
4650
+ 8,
4651
+ 9,
4652
+ 9,
4653
+ 10,
4654
+ 10,
4655
+ 11,
4656
+ 11,
4657
+ 12,
4658
+ 12,
4659
+ 13,
4660
+ 13,
4661
+ /* unused */
4662
+ 0,
4663
+ 0
4664
+ ]);
4665
+ var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
4666
+ var freb = function(eb, start) {
4667
+ var b = new u16(31);
4668
+ for (var i = 0; i < 31; ++i) {
4669
+ b[i] = start += 1 << eb[i - 1];
4670
+ }
4671
+ var r = new i32(b[30]);
4672
+ for (var i = 1; i < 30; ++i) {
4673
+ for (var j = b[i]; j < b[i + 1]; ++j) {
4674
+ r[j] = j - b[i] << 5 | i;
4675
+ }
4676
+ }
4677
+ return { b, r };
4678
+ };
4679
+ var _a = freb(fleb, 2), fl = _a.b, revfl = _a.r;
4680
+ fl[28] = 258, revfl[258] = 28;
4681
+ var _b = freb(fdeb, 0), fd = _b.b;
4682
+ var rev = new u16(32768);
4683
+ for (var i = 0; i < 32768; ++i) {
4684
+ var x = (i & 43690) >> 1 | (i & 21845) << 1;
4685
+ x = (x & 52428) >> 2 | (x & 13107) << 2;
4686
+ x = (x & 61680) >> 4 | (x & 3855) << 4;
4687
+ rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
4688
+ }
4689
+ var hMap = function(cd, mb, r) {
4690
+ var s = cd.length;
4691
+ var i = 0;
4692
+ var l = new u16(mb);
4693
+ for (; i < s; ++i) {
4694
+ if (cd[i])
4695
+ ++l[cd[i] - 1];
4696
+ }
4697
+ var le = new u16(mb);
4698
+ for (i = 1; i < mb; ++i) {
4699
+ le[i] = le[i - 1] + l[i - 1] << 1;
4700
+ }
4701
+ var co;
4702
+ if (r) {
4703
+ co = new u16(1 << mb);
4704
+ var rvb = 15 - mb;
4705
+ for (i = 0; i < s; ++i) {
4706
+ if (cd[i]) {
4707
+ var sv = i << 4 | cd[i];
4708
+ var r_1 = mb - cd[i];
4709
+ var v = le[cd[i] - 1]++ << r_1;
4710
+ for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
4711
+ co[rev[v] >> rvb] = sv;
4712
+ }
4713
+ }
4714
+ }
4715
+ } else {
4716
+ co = new u16(s);
4717
+ for (i = 0; i < s; ++i) {
4718
+ if (cd[i]) {
4719
+ co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
4720
+ }
4721
+ }
4722
+ }
4723
+ return co;
4724
+ };
4725
+ var flt = new u8(288);
4726
+ for (var i = 0; i < 144; ++i)
4727
+ flt[i] = 8;
4728
+ for (var i = 144; i < 256; ++i)
4729
+ flt[i] = 9;
4730
+ for (var i = 256; i < 280; ++i)
4731
+ flt[i] = 7;
4732
+ for (var i = 280; i < 288; ++i)
4733
+ flt[i] = 8;
4734
+ var fdt = new u8(32);
4735
+ for (var i = 0; i < 32; ++i)
4736
+ fdt[i] = 5;
4737
+ var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
4738
+ var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
4739
+ var max = function(a) {
4740
+ var m = a[0];
4741
+ for (var i = 1; i < a.length; ++i) {
4742
+ if (a[i] > m)
4743
+ m = a[i];
4744
+ }
4745
+ return m;
4746
+ };
4747
+ var bits = function(d, p, m) {
4748
+ var o = p / 8 | 0;
4749
+ return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
4750
+ };
4751
+ var bits16 = function(d, p) {
4752
+ var o = p / 8 | 0;
4753
+ return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
4754
+ };
4755
+ var shft = function(p) {
4756
+ return (p + 7) / 8 | 0;
4757
+ };
4758
+ var slc = function(v, s, e) {
4759
+ if (s == null || s < 0)
4760
+ s = 0;
4761
+ if (e == null || e > v.length)
4762
+ e = v.length;
4763
+ return new u8(v.subarray(s, e));
4764
+ };
4765
+ var ec = [
4766
+ "unexpected EOF",
4767
+ "invalid block type",
4768
+ "invalid length/literal",
4769
+ "invalid distance",
4770
+ "stream finished",
4771
+ "no stream handler",
4772
+ ,
4773
+ "no callback",
4774
+ "invalid UTF-8 data",
4775
+ "extra field too long",
4776
+ "date not in range 1980-2099",
4777
+ "filename too long",
4778
+ "stream finishing",
4779
+ "invalid zip data"
4780
+ // determined by unknown compression method
4781
+ ];
4782
+ var err = function(ind, msg, nt) {
4783
+ var e = new Error(msg || ec[ind]);
4784
+ e.code = ind;
4785
+ if (Error.captureStackTrace)
4786
+ Error.captureStackTrace(e, err);
4787
+ if (!nt)
4788
+ throw e;
4789
+ return e;
4790
+ };
4791
+ var inflt = function(dat, st, buf, dict) {
4792
+ var sl = dat.length, dl = dict ? dict.length : 0;
4793
+ if (!sl || st.f && !st.l)
4794
+ return buf || new u8(0);
4795
+ var noBuf = !buf;
4796
+ var resize = noBuf || st.i != 2;
4797
+ var noSt = st.i;
4798
+ if (noBuf)
4799
+ buf = new u8(sl * 3);
4800
+ var cbuf = function(l2) {
4801
+ var bl = buf.length;
4802
+ if (l2 > bl) {
4803
+ var nbuf = new u8(Math.max(bl * 2, l2));
4804
+ nbuf.set(buf);
4805
+ buf = nbuf;
4806
+ }
4807
+ };
4808
+ var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
4809
+ var tbts = sl * 8;
4810
+ do {
4811
+ if (!lm) {
4812
+ final = bits(dat, pos, 1);
4813
+ var type = bits(dat, pos + 1, 3);
4814
+ pos += 3;
4815
+ if (!type) {
4816
+ var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
4817
+ if (t > sl) {
4818
+ if (noSt)
4819
+ err(0);
4820
+ break;
4821
+ }
4822
+ if (resize)
4823
+ cbuf(bt + l);
4824
+ buf.set(dat.subarray(s, t), bt);
4825
+ st.b = bt += l, st.p = pos = t * 8, st.f = final;
4826
+ continue;
4827
+ } else if (type == 1)
4828
+ lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
4829
+ else if (type == 2) {
4830
+ var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
4831
+ var tl = hLit + bits(dat, pos + 5, 31) + 1;
4832
+ pos += 14;
4833
+ var ldt = new u8(tl);
4834
+ var clt = new u8(19);
4835
+ for (var i = 0; i < hcLen; ++i) {
4836
+ clt[clim[i]] = bits(dat, pos + i * 3, 7);
4837
+ }
4838
+ pos += hcLen * 3;
4839
+ var clb = max(clt), clbmsk = (1 << clb) - 1;
4840
+ var clm = hMap(clt, clb, 1);
4841
+ for (var i = 0; i < tl; ) {
4842
+ var r = clm[bits(dat, pos, clbmsk)];
4843
+ pos += r & 15;
4844
+ var s = r >> 4;
4845
+ if (s < 16) {
4846
+ ldt[i++] = s;
4847
+ } else {
4848
+ var c = 0, n = 0;
4849
+ if (s == 16)
4850
+ n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
4851
+ else if (s == 17)
4852
+ n = 3 + bits(dat, pos, 7), pos += 3;
4853
+ else if (s == 18)
4854
+ n = 11 + bits(dat, pos, 127), pos += 7;
4855
+ while (n--)
4856
+ ldt[i++] = c;
4857
+ }
4858
+ }
4859
+ var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
4860
+ lbt = max(lt);
4861
+ dbt = max(dt);
4862
+ lm = hMap(lt, lbt, 1);
4863
+ dm = hMap(dt, dbt, 1);
4864
+ } else
4865
+ err(1);
4866
+ if (pos > tbts) {
4867
+ if (noSt)
4868
+ err(0);
4869
+ break;
4870
+ }
4871
+ }
4872
+ if (resize)
4873
+ cbuf(bt + 131072);
4874
+ var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
4875
+ var lpos = pos;
4876
+ for (; ; lpos = pos) {
4877
+ var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
4878
+ pos += c & 15;
4879
+ if (pos > tbts) {
4880
+ if (noSt)
4881
+ err(0);
4882
+ break;
4883
+ }
4884
+ if (!c)
4885
+ err(2);
4886
+ if (sym < 256)
4887
+ buf[bt++] = sym;
4888
+ else if (sym == 256) {
4889
+ lpos = pos, lm = null;
4890
+ break;
4891
+ } else {
4892
+ var add = sym - 254;
4893
+ if (sym > 264) {
4894
+ var i = sym - 257, b = fleb[i];
4895
+ add = bits(dat, pos, (1 << b) - 1) + fl[i];
4896
+ pos += b;
4897
+ }
4898
+ var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
4899
+ if (!d)
4900
+ err(3);
4901
+ pos += d & 15;
4902
+ var dt = fd[dsym];
4903
+ if (dsym > 3) {
4904
+ var b = fdeb[dsym];
4905
+ dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
4906
+ }
4907
+ if (pos > tbts) {
4908
+ if (noSt)
4909
+ err(0);
4910
+ break;
4911
+ }
4912
+ if (resize)
4913
+ cbuf(bt + 131072);
4914
+ var end = bt + add;
4915
+ if (bt < dt) {
4916
+ var shift = dl - dt, dend = Math.min(dt, end);
4917
+ if (shift + bt < 0)
4918
+ err(3);
4919
+ for (; bt < dend; ++bt)
4920
+ buf[bt] = dict[shift + bt];
4921
+ }
4922
+ for (; bt < end; ++bt)
4923
+ buf[bt] = buf[bt - dt];
4924
+ }
4925
+ }
4926
+ st.l = lm, st.p = lpos, st.b = bt, st.f = final;
4927
+ if (lm)
4928
+ final = 1, st.m = lbt, st.d = dm, st.n = dbt;
4929
+ } while (!final);
4930
+ return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
4931
+ };
4932
+ var et = /* @__PURE__ */ new u8(0);
4933
+ var b2 = function(d, b) {
4934
+ return d[b] | d[b + 1] << 8;
4935
+ };
4936
+ var b4 = function(d, b) {
4937
+ return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0;
4938
+ };
4939
+ var b8 = function(d, b) {
4940
+ return b4(d, b) + b4(d, b + 4) * 4294967296;
4941
+ };
4942
+ function inflateSync(data, opts) {
4943
+ return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
4944
+ }
4945
+ var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
4946
+ var tds = 0;
4947
+ try {
4948
+ td.decode(et, { stream: true });
4949
+ tds = 1;
4950
+ } catch (e) {
4951
+ }
4952
+ var dutf8 = function(d) {
4953
+ for (var r = "", i = 0; ; ) {
4954
+ var c = d[i++];
4955
+ var eb = (c > 127) + (c > 223) + (c > 239);
4956
+ if (i + eb > d.length)
4957
+ return { s: r, r: slc(d, i - 1) };
4958
+ if (!eb)
4959
+ r += String.fromCharCode(c);
4960
+ else if (eb == 3) {
4961
+ c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023);
4962
+ } else if (eb & 1)
4963
+ r += String.fromCharCode((c & 31) << 6 | d[i++] & 63);
4964
+ else
4965
+ r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63);
4966
+ }
4967
+ };
4968
+ function strFromU8(dat, latin1) {
4969
+ if (latin1) {
4970
+ var r = "";
4971
+ for (var i = 0; i < dat.length; i += 16384)
4972
+ r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
4973
+ return r;
4974
+ } else if (td) {
4975
+ return td.decode(dat);
4976
+ } else {
4977
+ var _a2 = dutf8(dat), s = _a2.s, r = _a2.r;
4978
+ if (r.length)
4979
+ err(8);
4980
+ return s;
4981
+ }
4982
+ }
4983
+ var slzh = function(d, b) {
4984
+ return b + 30 + b2(d, b + 26) + b2(d, b + 28);
4985
+ };
4986
+ var zh = function(d, b, z) {
4987
+ var fnl = b2(d, b + 28), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl, bs = b4(d, b + 20);
4988
+ var _a2 = z && bs == 4294967295 ? z64e(d, es) : [bs, b4(d, b + 24), b4(d, b + 42)], sc = _a2[0], su = _a2[1], off = _a2[2];
4989
+ return [b2(d, b + 10), sc, su, fn, es + b2(d, b + 30) + b2(d, b + 32), off];
4990
+ };
4991
+ var z64e = function(d, b) {
4992
+ for (; b2(d, b) != 1; b += 4 + b2(d, b + 2))
4993
+ ;
4994
+ return [b8(d, b + 12), b8(d, b + 4), b8(d, b + 20)];
4995
+ };
4996
+ function unzipSync(data, opts) {
4997
+ var files = {};
4998
+ var e = data.length - 22;
4999
+ for (; b4(data, e) != 101010256; --e) {
5000
+ if (!e || data.length - e > 65558)
5001
+ err(13);
5002
+ }
5003
+ var c = b2(data, e + 8);
5004
+ if (!c)
5005
+ return {};
5006
+ var o = b4(data, e + 16);
5007
+ var z = o == 4294967295 || c == 65535;
5008
+ if (z) {
5009
+ var ze = b4(data, e - 12);
5010
+ z = b4(data, ze) == 101075792;
5011
+ if (z) {
5012
+ c = b4(data, ze + 32);
5013
+ o = b4(data, ze + 48);
5014
+ }
5015
+ }
5016
+ for (var i = 0; i < c; ++i) {
5017
+ var _a2 = zh(data, o, z), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
5018
+ o = no;
5019
+ {
5020
+ if (!c_2)
5021
+ files[fn] = slc(data, b, b + sc);
5022
+ else if (c_2 == 8)
5023
+ files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
5024
+ else
5025
+ err(14, "unknown compression type " + c_2);
5026
+ }
5027
+ }
5028
+ return files;
5029
+ }
5030
+ const SH_C0 = 0.28209479177387814;
5031
+ const COEFFS_PER_BAND = [3, 8, 15];
5032
+ function lerp(a, b, t) {
5033
+ return a + (b - a) * t;
5034
+ }
5035
+ function symUnlog(n) {
5036
+ return Math.sign(n) * (Math.exp(Math.abs(n)) - 1);
5037
+ }
5038
+ async function decodeWebP(bytes) {
5039
+ const blob = new Blob([bytes], { type: "image/webp" });
5040
+ const bitmap = await createImageBitmap(blob, {
5041
+ colorSpaceConversion: "none",
5042
+ premultiplyAlpha: "none"
5043
+ });
5044
+ const w = bitmap.width;
5045
+ const h = bitmap.height;
5046
+ const canvas = new OffscreenCanvas(w, h);
5047
+ const ctx = canvas.getContext("2d");
5048
+ ctx.drawImage(bitmap, 0, 0);
5049
+ bitmap.close();
5050
+ return { width: w, height: h, data: ctx.getImageData(0, 0, w, h).data };
5051
+ }
5052
+ function decodeSHPalette(centroidsImg, codebook, paletteSize, bands) {
5053
+ const numCoeffs = COEFFS_PER_BAND[bands - 1];
5054
+ const palette = new Array(paletteSize);
5055
+ for (let n = 0; n < paletteSize; n++) {
5056
+ const entry = new Float32Array(numCoeffs * 3);
5057
+ for (let c = 0; c < numCoeffs; c++) {
5058
+ const u = n % 64 * numCoeffs + c;
5059
+ const v = Math.floor(n / 64);
5060
+ const off = (v * centroidsImg.width + u) * 4;
5061
+ entry[c * 3 + 0] = codebook[centroidsImg.data[off + 0]];
5062
+ entry[c * 3 + 1] = codebook[centroidsImg.data[off + 1]];
5063
+ entry[c * 3 + 2] = codebook[centroidsImg.data[off + 2]];
5064
+ }
5065
+ palette[n] = entry;
5066
+ }
5067
+ return palette;
5068
+ }
5069
+ async function loadSOG(url, onProgress) {
5070
+ const response = await fetch(url);
5071
+ if (!response.ok) throw new Error(`无法加载 SOG 文件: ${url}`);
5072
+ const buffer = await response.arrayBuffer();
5073
+ return deserializeSOG(buffer, onProgress);
5074
+ }
5075
+ async function deserializeSOG(data, onProgress) {
5076
+ if (onProgress) onProgress(0, "parse");
5077
+ const zipEntries = unzipSync(new Uint8Array(data));
5078
+ const metaKey = Object.keys(zipEntries).find((k) => k.endsWith("meta.json"));
5079
+ if (!metaKey) throw new Error("无效的 SOG 文件: 缺少 meta.json");
5080
+ const meta = JSON.parse(new TextDecoder().decode(zipEntries[metaKey]));
5081
+ if (meta.version !== 2) {
5082
+ throw new Error(`不支持的 SOG 版本: ${meta.version},仅支持版本 2`);
5083
+ }
5084
+ const prefix = metaKey.includes("/") ? metaKey.substring(0, metaKey.lastIndexOf("/") + 1) : "";
5085
+ const findFile = (name) => {
5086
+ const entry = zipEntries[prefix + name] ?? zipEntries[name];
5087
+ if (!entry) throw new Error(`SOG 缺少文件: ${name}`);
5088
+ return entry;
5089
+ };
5090
+ if (onProgress) onProgress(5, "parse");
5091
+ const decodeList = [
5092
+ decodeWebP(findFile(meta.means.files[0])),
5093
+ // 0: means_l
5094
+ decodeWebP(findFile(meta.means.files[1])),
5095
+ // 1: means_u
5096
+ decodeWebP(findFile(meta.scales.files[0])),
5097
+ // 2: scales
5098
+ decodeWebP(findFile(meta.quats.files[0])),
5099
+ // 3: quats
5100
+ decodeWebP(findFile(meta.sh0.files[0]))
5101
+ // 4: sh0
5102
+ ];
5103
+ const hasHigherSH = !!meta.shN;
5104
+ if (hasHigherSH) {
5105
+ decodeList.push(
5106
+ decodeWebP(findFile(meta.shN.files[0])),
5107
+ // 5: shN_centroids
5108
+ decodeWebP(findFile(meta.shN.files[1]))
5109
+ // 6: shN_labels
5110
+ );
5111
+ }
5112
+ const imgs = await Promise.all(decodeList);
5113
+ const [meansL, meansU, scalesImg, quatsImg, sh0Img] = imgs;
5114
+ const centroidsImg = hasHigherSH ? imgs[5] : null;
5115
+ const labelsImg = hasHigherSH ? imgs[6] : null;
5116
+ if (onProgress) onProgress(35, "parse");
5117
+ let shPalette = null;
5118
+ let shBandCoeffs = 0;
5119
+ if (meta.shN && centroidsImg) {
5120
+ shBandCoeffs = COEFFS_PER_BAND[meta.shN.bands - 1];
5121
+ shPalette = decodeSHPalette(
5122
+ centroidsImg,
5123
+ meta.shN.codebook,
5124
+ meta.shN.count,
5125
+ meta.shN.bands
5126
+ );
5127
+ }
5128
+ if (onProgress) onProgress(45, "parse");
5129
+ const count = meta.count;
5130
+ const positions = new Float32Array(count * 3);
5131
+ const scales = new Float32Array(count * 3);
5132
+ const rotations = new Float32Array(count * 4);
5133
+ const colors = new Float32Array(count * 3);
5134
+ const opacities = new Float32Array(count);
5135
+ const shCoeffs = shPalette ? new Float32Array(count * 45) : void 0;
5136
+ const scCBExp = meta.scales.codebook.map((x) => Math.exp(x));
5137
+ const dcCB = meta.sh0.codebook;
5138
+ const SQRT2 = Math.SQRT2;
5139
+ const quatLookup = new Float64Array(256);
5140
+ for (let i = 0; i < 256; i++) {
5141
+ quatLookup[i] = (i / 255 - 0.5) * SQRT2;
5142
+ }
5143
+ for (let i = 0; i < count; i++) {
5144
+ const px = i % meansL.width;
5145
+ const py = Math.floor(i / meansL.width);
5146
+ const off = (py * meansL.width + px) * 4;
5147
+ const qx = meansU.data[off + 0] << 8 | meansL.data[off + 0];
5148
+ const qy = meansU.data[off + 1] << 8 | meansL.data[off + 1];
5149
+ const qz = meansU.data[off + 2] << 8 | meansL.data[off + 2];
5150
+ positions[i * 3 + 0] = symUnlog(lerp(meta.means.mins[0], meta.means.maxs[0], qx / 65535));
5151
+ positions[i * 3 + 1] = symUnlog(lerp(meta.means.mins[1], meta.means.maxs[1], qy / 65535));
5152
+ positions[i * 3 + 2] = symUnlog(lerp(meta.means.mins[2], meta.means.maxs[2], qz / 65535));
5153
+ scales[i * 3 + 0] = scCBExp[scalesImg.data[off + 0]];
5154
+ scales[i * 3 + 1] = scCBExp[scalesImg.data[off + 1]];
5155
+ scales[i * 3 + 2] = scCBExp[scalesImg.data[off + 2]];
5156
+ const r0 = quatLookup[quatsImg.data[off + 0]];
5157
+ const r1 = quatLookup[quatsImg.data[off + 1]];
5158
+ const r2 = quatLookup[quatsImg.data[off + 2]];
5159
+ const rr = Math.sqrt(Math.max(0, 1 - r0 * r0 - r1 * r1 - r2 * r2));
5160
+ const rOrder = quatsImg.data[off + 3] - 252;
5161
+ const qX = rOrder === 0 ? r0 : rOrder === 1 ? rr : r1;
5162
+ const qY = rOrder <= 1 ? r1 : rOrder === 2 ? rr : r2;
5163
+ const qZ = rOrder <= 2 ? r2 : rr;
5164
+ const qW = rOrder === 0 ? rr : r0;
5165
+ rotations[i * 4 + 0] = qW;
5166
+ rotations[i * 4 + 1] = qX;
5167
+ rotations[i * 4 + 2] = qY;
5168
+ rotations[i * 4 + 3] = qZ;
5169
+ colors[i * 3 + 0] = 0.5 + dcCB[sh0Img.data[off + 0]] * SH_C0;
5170
+ colors[i * 3 + 1] = 0.5 + dcCB[sh0Img.data[off + 1]] * SH_C0;
5171
+ colors[i * 3 + 2] = 0.5 + dcCB[sh0Img.data[off + 2]] * SH_C0;
5172
+ opacities[i] = sh0Img.data[off + 3] / 255;
5173
+ if (shCoeffs && shPalette && labelsImg) {
5174
+ const lOff = (py * labelsImg.width + px) * 4;
5175
+ const label = labelsImg.data[lOff + 0] | labelsImg.data[lOff + 1] << 8;
5176
+ const entry = shPalette[label];
5177
+ if (entry) {
5178
+ const base = i * 45;
5179
+ const len = shBandCoeffs * 3;
5180
+ for (let j = 0; j < len; j++) {
5181
+ shCoeffs[base + j] = entry[j];
5182
+ }
5183
+ }
5184
+ }
5185
+ if (onProgress && (i & 131071) === 0) {
5186
+ onProgress(45 + i / count * 50, "parse");
5187
+ }
5188
+ }
5189
+ if (onProgress) onProgress(95, "parse");
5190
+ return { count, positions, scales, rotations, colors, opacities, shCoeffs };
5191
+ }
4380
5192
  const WORKGROUP_SIZE$1 = 256;
4381
5193
  const RADIX_BITS = 8;
4382
5194
  const RADIX_SIZE = 256;
@@ -4421,7 +5233,7 @@ struct CullingParams {
4421
5233
  screenHeight: f32,
4422
5234
  frustumDilation: f32,
4423
5235
  pixelThreshold: f32,
4424
- _pad1: f32,
5236
+ maxVisibleCount: u32,
4425
5237
  }
4426
5238
 
4427
5239
  @group(0) @binding(0) var<storage, read> splats: array<Splat>;
@@ -4484,6 +5296,15 @@ fn projectAndCull(@builtin(global_invocation_id) gid: vec3<u32>) {
4484
5296
  // 视锥剔除
4485
5297
  if !isInFrustum(clipPos, params.frustumDilation) { return; }
4486
5298
 
5299
+ // 亚像素剔除:Gaussian 可见范围小于阈值的 splat 跳过渲染和排序
5300
+ // scale 是 σ(标准差),Gaussian 可见范围约 3σ(覆盖 99.7%)
5301
+ if params.pixelThreshold > 0.0 {
5302
+ let splatSigma = maxScale(splat.scale) * getModelMaxScale(camera.model);
5303
+ let focalY = abs(camera.proj[1][1]) * params.screenHeight * 0.5;
5304
+ let projectedExtent = splatSigma * 3.0 * focalY / max(abs(viewPos.z), 0.001);
5305
+ if projectedExtent < params.pixelThreshold { return; }
5306
+ }
5307
+
4487
5308
  // 深度编码 (viewPos.z 是负数)
4488
5309
  let depth = viewPos.z;
4489
5310
  let sortableDepth = encodeDepthKey(depth);
@@ -4505,6 +5326,18 @@ fn initIndirectBuffer() {
4505
5326
  atomicStore(&indirectBuffer[2], 0u);
4506
5327
  atomicStore(&indirectBuffer[3], 0u);
4507
5328
  }
5329
+
5330
+ // 排序后截断:只保留最近的 maxVisibleCount 个 splat
5331
+ // 因为 radix sort 是按深度从近到远排序,截断尾部等于丢弃被遮挡的远处 splat
5332
+ @compute @workgroup_size(1)
5333
+ fn clampDrawCount() {
5334
+ let maxCount = params.maxVisibleCount;
5335
+ if maxCount == 0u { return; }
5336
+ let count = atomicLoad(&indirectBuffer[1]);
5337
+ if count > maxCount {
5338
+ atomicStore(&indirectBuffer[1], maxCount);
5339
+ }
5340
+ }
4508
5341
  `
4509
5342
  );
4510
5343
  }
@@ -4719,7 +5552,9 @@ fn spine(
4719
5552
  }
4720
5553
 
4721
5554
  // ============================================================================
4722
- // Pass 3: Downsweep - 使用偏移量散射元素 (稳定排序)
5555
+ // Pass 3: Downsweep - 并行稳定散射
5556
+ // 所有 256 个线程同时工作,通过局部排名计算保持稳定性
5557
+ // 相比原始单线程版本,workgroup 完成时间提升约 20-40 倍
4723
5558
  // ============================================================================
4724
5559
 
4725
5560
  @group(0) @binding(0) var<uniform> downsweepParams: SortParams;
@@ -4734,6 +5569,7 @@ fn spine(
4734
5569
  var<workgroup> localKeys: array<u32, BLOCK_SIZE>;
4735
5570
  var<workgroup> localValues: array<u32, BLOCK_SIZE>;
4736
5571
  var<workgroup> localBins: array<u32, BLOCK_SIZE>;
5572
+ var<workgroup> binBasePos: array<u32, RADIX_SIZE>;
4737
5573
 
4738
5574
  @compute @workgroup_size(256, 1, 1)
4739
5575
  fn downsweep(
@@ -4749,8 +5585,10 @@ fn downsweep(
4749
5585
  let tid = localId.x;
4750
5586
  let partitionStart = partitionId * BLOCK_SIZE;
4751
5587
  let shift = downsweepParams.bitShift;
5588
+ let partitionEnd = min(partitionStart + BLOCK_SIZE, numKeys);
5589
+ let elemsInPartition = partitionEnd - partitionStart;
4752
5590
 
4753
- // 加载元素到共享内存
5591
+ // Phase 1: 所有线程并行加载元素到共享内存
4754
5592
  for (var j = 0u; j < ELEMENTS_PER_THREAD; j++) {
4755
5593
  let keyIdx = partitionStart + tid * ELEMENTS_PER_THREAD + j;
4756
5594
  let localIdx = tid * ELEMENTS_PER_THREAD + j;
@@ -4765,36 +5603,35 @@ fn downsweep(
4765
5603
  }
4766
5604
  }
4767
5605
 
5606
+ // Phase 2: 初始化 bin 基础写入位置(利用 256 线程并行)
5607
+ if tid < RADIX_SIZE {
5608
+ let passIdx = downsweepParams.passIndex;
5609
+ binBasePos[tid] = globalHistogramDownsweep[RADIX_SIZE * passIdx + tid] +
5610
+ partitionHistogramDownsweep[RADIX_SIZE * partitionId + tid];
5611
+ }
5612
+
4768
5613
  workgroupBarrier();
4769
5614
 
4770
- // 线程 0 执行顺序散射以保持稳定性
4771
- // 这是 rfs-gsplat-render 的关键设计,确保稳定排序
4772
- if tid == 0u {
4773
- var binWritePos: array<u32, RADIX_SIZE>;
5615
+ // Phase 3: 并行计算排名并散射
5616
+ // 稳定性保证:rank = 当前元素之前具有相同 bin 的元素数量
5617
+ // 每个线程处理自己的 4 个元素,通过扫描 localBins 确定排名
5618
+ for (var j = 0u; j < ELEMENTS_PER_THREAD; j++) {
5619
+ let localIdx = tid * ELEMENTS_PER_THREAD + j;
5620
+ if localIdx >= elemsInPartition { break; }
4774
5621
 
4775
- let passIdx = downsweepParams.passIndex;
5622
+ let b = localBins[localIdx];
5623
+ if b == 0xFFFFFFFFu { continue; }
4776
5624
 
4777
- // 从全局 + 分区偏移初始化写入位置
4778
- for (var b = 0u; b < RADIX_SIZE; b++) {
4779
- binWritePos[b] = globalHistogramDownsweep[RADIX_SIZE * passIdx + b] +
4780
- partitionHistogramDownsweep[RADIX_SIZE * partitionId + b];
5625
+ // 计算 rank:扫描本分区中在当前元素之前、且属于同一 bin 的元素数
5626
+ var rank = 0u;
5627
+ for (var p = 0u; p < localIdx; p++) {
5628
+ if localBins[p] == b { rank++; }
4781
5629
  }
4782
5630
 
4783
- // 按输入顺序顺序写入 (稳定)
4784
- let partitionEnd = min(partitionStart + BLOCK_SIZE, numKeys);
4785
- for (var k = 0u; k < BLOCK_SIZE; k++) {
4786
- let keyIdx = partitionStart + k;
4787
- if keyIdx < partitionEnd {
4788
- let b = localBins[k];
4789
- if b != 0xFFFFFFFFu {
4790
- let writePos = binWritePos[b];
4791
- if writePos < numKeys {
4792
- downsweepKeysOut[writePos] = localKeys[k];
4793
- downsweepValuesOut[writePos] = localValues[k];
4794
- binWritePos[b]++;
4795
- }
4796
- }
4797
- }
5631
+ let writePos = binBasePos[b] + rank;
5632
+ if writePos < numKeys {
5633
+ downsweepKeysOut[writePos] = localKeys[localIdx];
5634
+ downsweepValuesOut[writePos] = localValues[localIdx];
4798
5635
  }
4799
5636
  }
4800
5637
  }
@@ -4822,6 +5659,7 @@ class GSSplatSorter {
4822
5659
  // Culling Pipelines
4823
5660
  __publicField(this, "initIndirectPipeline");
4824
5661
  __publicField(this, "projectCullPipeline");
5662
+ __publicField(this, "clampDrawCountPipeline");
4825
5663
  __publicField(this, "cullingBindGroupLayout");
4826
5664
  __publicField(this, "cullingBindGroup");
4827
5665
  // Radix Sort Pipelines
@@ -4941,6 +5779,11 @@ class GSSplatSorter {
4941
5779
  compute: { module: cullingModule, entryPoint: "projectAndCull" },
4942
5780
  label: "project-cull-pipeline"
4943
5781
  });
5782
+ this.clampDrawCountPipeline = device.createComputePipeline({
5783
+ layout: cullingPipelineLayout,
5784
+ compute: { module: cullingModule, entryPoint: "clampDrawCount" },
5785
+ label: "clamp-draw-count-pipeline"
5786
+ });
4944
5787
  this.cullingBindGroup = device.createBindGroup({
4945
5788
  layout: this.cullingBindGroupLayout,
4946
5789
  entries: [
@@ -5090,15 +5933,10 @@ class GSSplatSorter {
5090
5933
  view.setFloat32(16, this.screenHeight, true);
5091
5934
  view.setFloat32(20, this.cullingOptions.frustumDilation ?? 0.2, true);
5092
5935
  view.setFloat32(24, this.cullingOptions.pixelThreshold, true);
5093
- view.setFloat32(28, 0, true);
5936
+ view.setUint32(28, this.cullingOptions.maxVisibleCount ?? 0, true);
5094
5937
  this.device.queue.writeBuffer(this.cullingParamsBuffer, 0, cullingParamsData);
5095
5938
  const encoder = this.device.createCommandEncoder({ label: "splat-sort-encoder" });
5096
- encoder.clearBuffer(this.depthKeysBuffer);
5097
- encoder.clearBuffer(this.visibleIndicesBuffer);
5098
- encoder.clearBuffer(this.keysTempBuffer);
5099
- encoder.clearBuffer(this.valuesTempBuffer);
5100
5939
  encoder.clearBuffer(this.globalHistogramBuffer);
5101
- encoder.clearBuffer(this.partitionHistogramBuffer);
5102
5940
  {
5103
5941
  const pass = encoder.beginComputePass({ label: "init-indirect" });
5104
5942
  pass.setPipeline(this.initIndirectPipeline);
@@ -5136,6 +5974,13 @@ class GSSplatSorter {
5136
5974
  pass.end();
5137
5975
  }
5138
5976
  }
5977
+ {
5978
+ const pass = encoder.beginComputePass({ label: "clamp-draw-count" });
5979
+ pass.setPipeline(this.clampDrawCountPipeline);
5980
+ pass.setBindGroup(0, this.cullingBindGroup);
5981
+ pass.dispatchWorkgroups(1);
5982
+ pass.end();
5983
+ }
5139
5984
  this.device.queue.submit([encoder.finish()]);
5140
5985
  }
5141
5986
  /**
@@ -5179,18 +6024,38 @@ const gsOptimizedShader = (
5179
6024
  `
5180
6025
  /**
5181
6026
  * 优化的 3D Gaussian Splatting Shader
5182
- * 参考 rfs-gsplat-render 实现,修复颜色和抗锯齿问题
6027
+ * 支持完整 L3 球谐函数(SH)视角相关颜色
6028
+ * 参考 rfs-gsplat-render / PlayCanvas SuperSplat 实现
5183
6029
  */
5184
6030
 
5185
6031
  const SQRT_8: f32 = 2.82842712475;
5186
6032
  const SH_C0: f32 = 0.28209479177387814;
5187
6033
  const SH_C1: f32 = 0.4886025119029199;
5188
- // Normalized Gaussian 常量 (匹配 SuperSplat)
5189
- const EXP_NEG4: f32 = 0.01831563888873418;
5190
- const INV_ONE_MINUS_EXP_NEG4: f32 = 1.01865736036377408;
5191
- // 低通滤波器 (正则化协方差矩阵)
6034
+ // L2 SH 系数
6035
+ const SH_C2_0: f32 = 1.0925484305920792;
6036
+ const SH_C2_1: f32 = -1.0925484305920792;
6037
+ const SH_C2_2: f32 = 0.31539156525252005;
6038
+ const SH_C2_3: f32 = -1.0925484305920792;
6039
+ const SH_C2_4: f32 = 0.5462742152960396;
6040
+ // L3 SH 系数
6041
+ const SH_C3_0: f32 = -0.5900435899266435;
6042
+ const SH_C3_1: f32 = 2.890611442640554;
6043
+ const SH_C3_2: f32 = -0.4570457994644658;
6044
+ const SH_C3_3: f32 = 0.3731763325901154;
6045
+ const SH_C3_4: f32 = -0.4570457994644658;
6046
+ const SH_C3_5: f32 = 1.4453057213202769;
6047
+ const SH_C3_6: f32 = -0.5900435899266435;
6048
+ // 低通滤波器 (正则化协方差矩阵,避免数值问题)
5192
6049
  const LOW_PASS_FILTER: f32 = 0.3;
6050
+ // Alpha 剔除阈值 (1/255)
5193
6051
  const ALPHA_CULL_THRESHOLD: f32 = 0.00392156863;
6052
+ // Normalized Gaussian 常量 (用于消除边缘雾化)
6053
+ // 使用 k=4 的 Gaussian: exp(-4*A)
6054
+ // EXP_NEG_K = exp(-4) ≈ 0.0183
6055
+ // INV_ONE_MINUS_EXP_NEG_K = 1 / (1 - exp(-4)) ≈ 1.0187
6056
+ const GAUSSIAN_K: f32 = 4.0;
6057
+ const EXP_NEG_K: f32 = 0.01831563888873418;
6058
+ const INV_ONE_MINUS_EXP_NEG_K: f32 = 1.01865736036377408;
5194
6059
 
5195
6060
  struct Uniforms {
5196
6061
  view: mat4x4<f32>,
@@ -5214,14 +6079,51 @@ struct Splat {
5214
6079
  _pad2: array<f32, 3>,
5215
6080
  }
5216
6081
 
5217
- @group(0) @binding(0) var<uniform> uniforms: Uniforms;
5218
- @group(0) @binding(1) var<storage, read> splats: array<Splat>;
5219
- @group(0) @binding(2) var<storage, read> sortedIndices: array<u32>;
6082
+ // 完整 L3 球谐函数求值 (匹配原始 3DGS Python 实现)
6083
+ // SH 系数以 interleaved 格式存储: [R0,G0,B0, R1,G1,B1, ...]
6084
+ // dir: 从相机指向 splat 的归一化方向向量(模型空间)
6085
+ fn evalSH(splat: Splat, dir: vec3<f32>) -> vec3<f32> {
6086
+ let x = dir.x;
6087
+ let y = dir.y;
6088
+ let z = dir.z;
5220
6089
 
5221
- struct VertexOutput {
5222
- @builtin(position) position: vec4<f32>,
5223
- @location(0) fragPos: vec2<f32>,
5224
- @location(1) color: vec3<f32>,
6090
+ var result = vec3<f32>(0.0);
6091
+
6092
+ // L1: 3 个基函数
6093
+ result += (-SH_C1 * y) * vec3<f32>(splat.sh1[0], splat.sh1[1], splat.sh1[2]);
6094
+ result += ( SH_C1 * z) * vec3<f32>(splat.sh1[3], splat.sh1[4], splat.sh1[5]);
6095
+ result += (-SH_C1 * x) * vec3<f32>(splat.sh1[6], splat.sh1[7], splat.sh1[8]);
6096
+
6097
+ // L2: 5 个基函数
6098
+ let xx = x * x; let yy = y * y; let zz = z * z;
6099
+ let xy = x * y; let yz = y * z; let xz = x * z;
6100
+
6101
+ result += (SH_C2_0 * xy) * vec3<f32>(splat.sh2[0], splat.sh2[1], splat.sh2[2]);
6102
+ result += (SH_C2_1 * yz) * vec3<f32>(splat.sh2[3], splat.sh2[4], splat.sh2[5]);
6103
+ result += (SH_C2_2 * (2.0 * zz - xx - yy)) * vec3<f32>(splat.sh2[6], splat.sh2[7], splat.sh2[8]);
6104
+ result += (SH_C2_3 * xz) * vec3<f32>(splat.sh2[9], splat.sh2[10], splat.sh2[11]);
6105
+ result += (SH_C2_4 * (xx - yy)) * vec3<f32>(splat.sh2[12], splat.sh2[13], splat.sh2[14]);
6106
+
6107
+ // L3: 7 个基函数
6108
+ result += (SH_C3_0 * y * (3.0 * xx - yy)) * vec3<f32>(splat.sh3[0], splat.sh3[1], splat.sh3[2]);
6109
+ result += (SH_C3_1 * xy * z) * vec3<f32>(splat.sh3[3], splat.sh3[4], splat.sh3[5]);
6110
+ result += (SH_C3_2 * y * (4.0 * zz - xx - yy)) * vec3<f32>(splat.sh3[6], splat.sh3[7], splat.sh3[8]);
6111
+ result += (SH_C3_3 * z * (2.0 * zz - 3.0 * xx - 3.0 * yy)) * vec3<f32>(splat.sh3[9], splat.sh3[10], splat.sh3[11]);
6112
+ result += (SH_C3_4 * x * (4.0 * zz - xx - yy)) * vec3<f32>(splat.sh3[12], splat.sh3[13], splat.sh3[14]);
6113
+ result += (SH_C3_5 * z * (xx - yy)) * vec3<f32>(splat.sh3[15], splat.sh3[16], splat.sh3[17]);
6114
+ result += (SH_C3_6 * x * (xx - 3.0 * yy)) * vec3<f32>(splat.sh3[18], splat.sh3[19], splat.sh3[20]);
6115
+
6116
+ return result;
6117
+ }
6118
+
6119
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
6120
+ @group(0) @binding(1) var<storage, read> splats: array<Splat>;
6121
+ @group(0) @binding(2) var<storage, read> sortedIndices: array<u32>;
6122
+
6123
+ struct VertexOutput {
6124
+ @builtin(position) position: vec4<f32>,
6125
+ @location(0) fragPos: vec2<f32>,
6126
+ @location(1) color: vec3<f32>,
5225
6127
  @location(2) opacity: f32,
5226
6128
  }
5227
6129
 
@@ -5230,16 +6132,24 @@ const QUAD_POSITIONS = array<vec2<f32>, 4>(
5230
6132
  vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
5231
6133
  );
5232
6134
 
5233
- // ClipCorner 优化 (精确匹配 PlayCanvas/SuperSplat)
5234
- // PlayCanvas: clip = min(1.0, sqrt(-log(1.0 / (255.0 * alpha))) / 2.0)
5235
- // 这根据透明度缩小 quad,排除 alpha < 1/255 的 Gaussian 区域
6135
+ // ClipCorner 优化:根据透明度缩小 quad,排除低于阈值的 Gaussian 区域
6136
+ // 使用 Normalized Gaussian: weight = (exp(-k*A) - exp(-k)) / (1 - exp(-k))
6137
+ // 需要找到 A 使得 alpha * weight >= 1/255
6138
+ // 即 weight >= 1/(255*alpha)
6139
+ // (exp(-k*A) - exp(-k)) / (1 - exp(-k)) >= 1/(255*alpha)
6140
+ // exp(-k*A) >= 1/(255*alpha) * (1 - exp(-k)) + exp(-k)
6141
+ // -k*A >= ln(1/(255*alpha) * (1 - exp(-k)) + exp(-k))
6142
+ // A <= -ln(1/(255*alpha) * (1 - exp(-k)) + exp(-k)) / k
5236
6143
  fn computeClipFactor(alpha: f32) -> f32 {
5237
- // 保护非常小的 alpha 值
5238
- // 当 alpha <= 1/255 时,splat 不可见
5239
6144
  if alpha <= ALPHA_CULL_THRESHOLD { return 0.0; }
5240
- // PlayCanvas 公式: clip = min(1.0, sqrt(-log(1.0 / (255.0 * alpha))) / 2.0)
5241
- // 简化: -log(1/(255*a)) = log(255*a)
5242
- return min(1.0, sqrt(log(255.0 * alpha)) / 2.0);
6145
+ let threshold = 1.0 / (255.0 * alpha);
6146
+ // 如果 threshold >= 1.0,整个 splat 都低于可见阈值
6147
+ if threshold >= 1.0 { return 0.0; }
6148
+ // 计算 clip 因子:找到 normalized gaussian = threshold 的位置
6149
+ let targetExp = threshold * (1.0 - EXP_NEG_K) + EXP_NEG_K;
6150
+ if targetExp >= 1.0 { return 0.0; }
6151
+ let A = -log(targetExp) / GAUSSIAN_K;
6152
+ return min(1.0, sqrt(A));
5243
6153
  }
5244
6154
 
5245
6155
  // 四元数转旋转矩阵 (PLY 格式: w, x, y, z)
@@ -5312,9 +6222,9 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
5312
6222
  let radius = length(vec2<f32>((a - d) * 0.5, b));
5313
6223
 
5314
6224
  let lambda1 = mid + radius;
5315
- let lambda2 = max(mid - radius, 0.1); // PlayCanvas 使用 0.1 最小值
6225
+ let lambda2 = mid - radius;
5316
6226
 
5317
- // 检查特征值是否有效
6227
+ // 非正定协方差矩阵直接剔除
5318
6228
  if lambda2 <= 0.0 {
5319
6229
  result.basis = vec4<f32>(0.0);
5320
6230
  result.adjustedOpacity = 0.0;
@@ -5324,14 +6234,15 @@ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32
5324
6234
  // 使用基于视口的最大限制 (匹配 PlayCanvas)
5325
6235
  let vmin = min(1024.0, min(viewportSize.x, viewportSize.y));
5326
6236
 
5327
- // 计算轴长度: l = 2.0 * min(sqrt(2.0 * lambda), vmin)
5328
- // 这等价于 std_dev * sqrt(lambda),因为 std_dev = sqrt(8) ≈ 2.83
5329
- let l1 = 2.0 * min(sqrt(2.0 * lambda1), vmin);
5330
- let l2 = 2.0 * min(sqrt(2.0 * lambda2), vmin);
6237
+ // 计算轴长度: l = 2 * sqrt(2 * lambda) ≈ 2.83 * sqrt(lambda)
6238
+ // 这与 GAUSSIAN_K=4 配套使用:
6239
+ // UV=1 边界,对应 2*sqrt(2) 个标准差的位置
6240
+ // exp(-4 * 1) = exp(-4) 0.018,Normalized 后精确为 0
6241
+ let l1 = min(2.0 * sqrt(2.0 * lambda1), vmin);
6242
+ let l2 = min(2.0 * sqrt(2.0 * lambda2), vmin);
5331
6243
 
5332
- // 关键: 剔除小于 2 像素的 Gaussian (匹配 PlayCanvas)
5333
- // 这消除了导致"雾化"伪影的亚像素 splat
5334
- if l1 < 2.0 && l2 < 2.0 {
6244
+ // 剔除小于 2 像素的 Gaussian(消除亚像素 splat 造成的雾化)
6245
+ if l1 < 0.5 && l2 < 0.5 {
5335
6246
  result.basis = vec4<f32>(0.0);
5336
6247
  result.adjustedOpacity = 0.0;
5337
6248
  return result;
@@ -5412,7 +6323,7 @@ fn vs_main(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) ins
5412
6323
 
5413
6324
  // 视锥边缘剔除 (匹配 PlayCanvas)
5414
6325
  let maxExtentPixels = max(length(basis.xy), length(basis.zw));
5415
- let pixelToClip = vec2<f32>(clipPos.w, clipPos.w) / uniforms.screenSize;
6326
+ let pixelToClip = 2.0 * vec2<f32>(clipPos.w, clipPos.w) / uniforms.screenSize;
5416
6327
  let splatExtentClip = vec2<f32>(maxExtentPixels, maxExtentPixels) * pixelToClip;
5417
6328
  if any((abs(clipPos.xy) - splatExtentClip) > vec2<f32>(clipPos.w, clipPos.w)) {
5418
6329
  output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output;
@@ -5439,9 +6350,18 @@ fn vs_main(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) ins
5439
6350
  // UV 输出 - 用 clipFactor 缩放以获得正确的 Gaussian 权重
5440
6351
  output.fragPos = quadPos * clipFactor;
5441
6352
 
5442
- // 颜色已在 CPU 端预处理为 (dc * SH_C0 + 0.5),直接使用
5443
- // 这是 3DGS 的标准颜色格式,在 sRGB 空间中
5444
- output.color = splat.colorDC;
6353
+ // 球谐函数颜色计算:
6354
+ // colorDC 已在 CPU 端预处理为 (dc * SH_C0 + 0.5)
6355
+ // evalSH() 计算 L1~L3 的视角相关色彩贡献
6356
+ // 方向需要在模型空间计算(SH 系数在模型空间定义)
6357
+ let shDir = normalize(
6358
+ vec3<f32>(
6359
+ dot(worldPos.xyz - uniforms.cameraPos, uniforms.model[0].xyz),
6360
+ dot(worldPos.xyz - uniforms.cameraPos, uniforms.model[1].xyz),
6361
+ dot(worldPos.xyz - uniforms.cameraPos, uniforms.model[2].xyz)
6362
+ )
6363
+ );
6364
+ output.color = splat.colorDC + evalSH(splat, shDir);
5445
6365
  output.opacity = adjustedOpacity;
5446
6366
  return output;
5447
6367
  }
@@ -5451,35 +6371,284 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
5451
6371
  if input.opacity <= 0.0 { discard; }
5452
6372
 
5453
6373
  // A = 到中心的平方距离,在 UV 空间中
5454
- // 由于 clipCorner 优化,fragPos 在 [-clip, clip] 范围内
5455
6374
  let A = dot(input.fragPos, input.fragPos);
5456
6375
 
5457
6376
  // 丢弃单位圆外的片段
5458
6377
  if A > 1.0 { discard; }
5459
6378
 
5460
- // Normalized Gaussian 衰减 (精确匹配 SuperSplat normExp)
5461
- // 关键修复: A=1 (边界) 时返回精确的 0.0,消除边缘雾化
5462
- // A=0 (中心): weight = 1.0
5463
- // 在 A=1 (边界): weight = 精确的 0.0 (而不是标准 exp(-4) 0.018)
5464
- let weight = (exp(-4.0 * A) - EXP_NEG4) * INV_ONE_MINUS_EXP_NEG4;
6379
+ // Normalized Gaussian 衰减 (关键:消除边缘雾化)
6380
+ // 标准 Gaussian exp(-k*A) 在边界 A=1 时不为 0,会产生累积雾化
6381
+ // Normalized 公式: (exp(-k*A) - exp(-k)) / (1 - exp(-k))
6382
+ // 在 A=0 = 1.0,在 A=1 = 精确的 0.0
6383
+ let weight = (exp(-GAUSSIAN_K * A) - EXP_NEG_K) * INV_ONE_MINUS_EXP_NEG_K;
5465
6384
 
5466
6385
  // 组合 splat 透明度
5467
6386
  let opacity = weight * input.opacity;
5468
6387
 
5469
- // Alpha 阈值丢弃 (匹配 SuperSplat: if (alpha < 1.0 / 255.0) discard)
6388
+ // Alpha 阈值丢弃
5470
6389
  if opacity < ALPHA_CULL_THRESHOLD { discard; }
5471
6390
 
5472
6391
  // 颜色 clamp 到有效范围 (防止负值)
5473
6392
  let color = max(input.color, vec3<f32>(0.0));
5474
6393
 
5475
6394
  // 预乘 alpha 输出 (匹配 blend mode: src=ONE, dst=ONE_MINUS_SRC_ALPHA)
5476
- // 这是 3DGS 渲染的标准混合模式
5477
6395
  return vec4<f32>(color * opacity, opacity);
5478
6396
  }
5479
6397
  `
5480
6398
  );
6399
+ const gsDepthNormalShader = (
6400
+ /* wgsl */
6401
+ `
6402
+ const SQRT_8: f32 = 2.82842712475;
6403
+ const LOW_PASS_FILTER: f32 = 0.3;
6404
+ const ALPHA_CULL_THRESHOLD: f32 = 0.00392156863;
6405
+ const GAUSSIAN_K: f32 = 4.0;
6406
+ const EXP_NEG_K: f32 = 0.01831563888873418;
6407
+ const INV_ONE_MINUS_EXP_NEG_K: f32 = 1.01865736036377408;
6408
+
6409
+ struct Uniforms {
6410
+ view: mat4x4<f32>,
6411
+ proj: mat4x4<f32>,
6412
+ model: mat4x4<f32>,
6413
+ cameraPos: vec3<f32>,
6414
+ _pad: f32,
6415
+ screenSize: vec2<f32>,
6416
+ _pad2: vec2<f32>,
6417
+ }
6418
+
6419
+ struct Splat {
6420
+ mean: vec3<f32>, _pad0: f32,
6421
+ scale: vec3<f32>, _pad1: f32,
6422
+ rotation: vec4<f32>,
6423
+ colorDC: vec3<f32>,
6424
+ opacity: f32,
6425
+ sh1: array<f32, 9>,
6426
+ sh2: array<f32, 15>,
6427
+ sh3: array<f32, 21>,
6428
+ _pad2: array<f32, 3>,
6429
+ }
6430
+
6431
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
6432
+ @group(0) @binding(1) var<storage, read> splats: array<Splat>;
6433
+ @group(0) @binding(2) var<storage, read> sortedIndices: array<u32>;
6434
+
6435
+ struct VertexOutput {
6436
+ @builtin(position) position: vec4<f32>,
6437
+ @location(0) fragPos: vec2<f32>,
6438
+ @location(1) opacity: f32,
6439
+ @location(2) viewDepth: f32,
6440
+ @location(3) worldNormal: vec3<f32>,
6441
+ }
6442
+
6443
+ const QUAD_POSITIONS = array<vec2<f32>, 4>(
6444
+ vec2<f32>(-1.0, -1.0), vec2<f32>(-1.0, 1.0),
6445
+ vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
6446
+ );
6447
+
6448
+ fn computeClipFactor(alpha: f32) -> f32 {
6449
+ if alpha <= ALPHA_CULL_THRESHOLD { return 0.0; }
6450
+ let threshold = 1.0 / (255.0 * alpha);
6451
+ if threshold >= 1.0 { return 0.0; }
6452
+ let targetExp = threshold * (1.0 - EXP_NEG_K) + EXP_NEG_K;
6453
+ if targetExp >= 1.0 { return 0.0; }
6454
+ let A = -log(targetExp) / GAUSSIAN_K;
6455
+ return min(1.0, sqrt(A));
6456
+ }
6457
+
6458
+ fn quatToMat3(q: vec4<f32>) -> mat3x3<f32> {
6459
+ let r = q.x; let x = q.y; let y = q.z; let z = q.w;
6460
+ return mat3x3<f32>(
6461
+ vec3<f32>(1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + r * z), 2.0 * (x * z - r * y)),
6462
+ vec3<f32>(2.0 * (x * y - r * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + r * x)),
6463
+ vec3<f32>(2.0 * (x * z + r * y), 2.0 * (y * z - r * x), 1.0 - 2.0 * (x * x + y * y))
6464
+ );
6465
+ }
6466
+
6467
+ fn computeCovariance3D(scale: vec3<f32>, rotation: vec4<f32>) -> mat3x3<f32> {
6468
+ let R = quatToMat3(rotation);
6469
+ let S = mat3x3<f32>(vec3<f32>(scale.x, 0.0, 0.0), vec3<f32>(0.0, scale.y, 0.0), vec3<f32>(0.0, 0.0, scale.z));
6470
+ let M = R * S;
6471
+ return M * transpose(M);
6472
+ }
6473
+
6474
+ fn projectCovariance(cov3d: mat3x3<f32>, viewCenter: vec4<f32>, focal: vec2<f32>, modelViewMat: mat4x4<f32>) -> vec3<f32> {
6475
+ let v = viewCenter.xyz;
6476
+ let s = 1.0 / (v.z * v.z);
6477
+ let J = mat3x3<f32>(
6478
+ vec3<f32>(focal.x / v.z, 0.0, 0.0),
6479
+ vec3<f32>(0.0, focal.y / v.z, 0.0),
6480
+ vec3<f32>(-(focal.x * v.x) * s, -(focal.y * v.y) * s, 0.0)
6481
+ );
6482
+ let W = mat3x3<f32>(
6483
+ vec3<f32>(modelViewMat[0][0], modelViewMat[0][1], modelViewMat[0][2]),
6484
+ vec3<f32>(modelViewMat[1][0], modelViewMat[1][1], modelViewMat[1][2]),
6485
+ vec3<f32>(modelViewMat[2][0], modelViewMat[2][1], modelViewMat[2][2])
6486
+ );
6487
+ let T = J * W;
6488
+ let cov2d = T * cov3d * transpose(T);
6489
+ return vec3<f32>(cov2d[0][0], cov2d[0][1], cov2d[1][1]);
6490
+ }
6491
+
6492
+ struct ExtentResult {
6493
+ basis: vec4<f32>,
6494
+ adjustedOpacity: f32,
6495
+ }
6496
+
6497
+ fn computeExtentBasisAA(cov2dIn: vec3<f32>, opacity: f32, viewportSize: vec2<f32>) -> ExtentResult {
6498
+ var result: ExtentResult;
6499
+ var cov2d = cov2dIn;
6500
+ var alpha = opacity;
6501
+ cov2d.x += LOW_PASS_FILTER;
6502
+ cov2d.z += LOW_PASS_FILTER;
6503
+ let a = cov2d.x;
6504
+ let d = cov2d.z;
6505
+ let b = cov2d.y;
6506
+ let mid = 0.5 * (a + d);
6507
+ let radius = length(vec2<f32>((a - d) * 0.5, b));
6508
+ let lambda1 = mid + radius;
6509
+ let lambda2 = mid - radius;
6510
+ if lambda2 <= 0.0 {
6511
+ result.basis = vec4<f32>(0.0);
6512
+ result.adjustedOpacity = 0.0;
6513
+ return result;
6514
+ }
6515
+ let vmin = min(1024.0, min(viewportSize.x, viewportSize.y));
6516
+ let l1 = min(2.0 * sqrt(2.0 * lambda1), vmin);
6517
+ let l2 = min(2.0 * sqrt(2.0 * lambda2), vmin);
6518
+ if l1 < 0.5 && l2 < 0.5 {
6519
+ result.basis = vec4<f32>(0.0);
6520
+ result.adjustedOpacity = 0.0;
6521
+ return result;
6522
+ }
6523
+ let diagVec = normalize(vec2<f32>(b, lambda1 - a));
6524
+ let eigenvector1 = diagVec;
6525
+ let eigenvector2 = vec2<f32>(diagVec.y, -diagVec.x);
6526
+ result.basis = vec4<f32>(eigenvector1 * l1, eigenvector2 * l2);
6527
+ result.adjustedOpacity = alpha;
6528
+ return result;
6529
+ }
6530
+
6531
+ // Extract the normal of a gaussian ellipsoid from its scale and rotation.
6532
+ // The normal is the axis corresponding to the smallest scale (shortest axis).
6533
+ // After rotation, this gives the world-space normal of the local surface.
6534
+ fn computeSplatNormal(scale: vec3<f32>, rotation: vec4<f32>, modelMat: mat4x4<f32>) -> vec3<f32> {
6535
+ let R = quatToMat3(rotation);
6536
+ // Find the axis with smallest scale
6537
+ var minAxis: vec3<f32>;
6538
+ if scale.x <= scale.y && scale.x <= scale.z {
6539
+ minAxis = R[0]; // column 0
6540
+ } else if scale.y <= scale.z {
6541
+ minAxis = R[1]; // column 1
6542
+ } else {
6543
+ minAxis = R[2]; // column 2
6544
+ }
6545
+ // Transform to world space via model matrix 3x3 part
6546
+ let worldNormal = vec3<f32>(
6547
+ modelMat[0][0] * minAxis.x + modelMat[1][0] * minAxis.y + modelMat[2][0] * minAxis.z,
6548
+ modelMat[0][1] * minAxis.x + modelMat[1][1] * minAxis.y + modelMat[2][1] * minAxis.z,
6549
+ modelMat[0][2] * minAxis.x + modelMat[1][2] * minAxis.y + modelMat[2][2] * minAxis.z
6550
+ );
6551
+ let len = length(worldNormal);
6552
+ if len < 1e-8 { return vec3<f32>(0.0, 1.0, 0.0); }
6553
+ return worldNormal / len;
6554
+ }
6555
+
6556
+ @vertex
6557
+ fn vs_main(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) instanceIndex: u32) -> VertexOutput {
6558
+ var output: VertexOutput;
6559
+ let splatIndex = sortedIndices[instanceIndex];
6560
+ let splat = splats[splatIndex];
6561
+ let quadPos = QUAD_POSITIONS[vertexIndex];
6562
+
6563
+ if splat.opacity < ALPHA_CULL_THRESHOLD { output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output; }
6564
+ let quatNormSqr = dot(splat.rotation, splat.rotation);
6565
+ if quatNormSqr < 1e-6 { output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output; }
6566
+
6567
+ let worldPos = uniforms.model * vec4<f32>(splat.mean, 1.0);
6568
+ let viewPos = uniforms.view * worldPos;
6569
+ let clipPos = uniforms.proj * viewPos;
6570
+
6571
+ if viewPos.z >= 0.0 { output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output; }
6572
+
6573
+ let pW = 1.0 / (clipPos.w + 0.0000001);
6574
+ let ndcPos = clipPos * pW;
6575
+
6576
+ let clipBound = 1.3;
6577
+ if abs(ndcPos.x) > clipBound || abs(ndcPos.y) > clipBound || ndcPos.z < -0.2 || ndcPos.z > 1.0 {
6578
+ output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output;
6579
+ }
6580
+
6581
+ let cov3d = computeCovariance3D(splat.scale, splat.rotation);
6582
+ let focal = vec2<f32>(
6583
+ abs(uniforms.proj[0][0]) * 0.5 * uniforms.screenSize.x,
6584
+ abs(uniforms.proj[1][1]) * 0.5 * uniforms.screenSize.y
6585
+ );
6586
+ let modelViewMat = uniforms.view * uniforms.model;
6587
+ let cov2d = projectCovariance(cov3d, viewPos, focal, modelViewMat);
6588
+
6589
+ let extentResult = computeExtentBasisAA(cov2d, splat.opacity, uniforms.screenSize);
6590
+ let basis = extentResult.basis;
6591
+ let adjustedOpacity = extentResult.adjustedOpacity;
6592
+
6593
+ if basis.x == 0.0 && basis.y == 0.0 && basis.z == 0.0 && basis.w == 0.0 {
6594
+ output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output;
6595
+ }
6596
+
6597
+ let maxExtentPixels = max(length(basis.xy), length(basis.zw));
6598
+ let pixelToClip = 2.0 * vec2<f32>(clipPos.w, clipPos.w) / uniforms.screenSize;
6599
+ let splatExtentClip = vec2<f32>(maxExtentPixels, maxExtentPixels) * pixelToClip;
6600
+ if any((abs(clipPos.xy) - splatExtentClip) > vec2<f32>(clipPos.w, clipPos.w)) {
6601
+ output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output;
6602
+ }
6603
+
6604
+ let clipFactor = computeClipFactor(adjustedOpacity);
6605
+ if clipFactor <= 0.0 { output.position = vec4<f32>(0.0, 0.0, 2.0, 1.0); return output; }
6606
+
6607
+ let basisViewport = vec2<f32>(1.0 / uniforms.screenSize.x, 1.0 / uniforms.screenSize.y);
6608
+ let basisVector1 = basis.xy * clipFactor;
6609
+ let basisVector2 = basis.zw * clipFactor;
6610
+ let ndcOffset = (quadPos.x * basisVector1 + quadPos.y * basisVector2) * basisViewport * 2.0;
6611
+ output.position = vec4<f32>(ndcPos.xy + ndcOffset, ndcPos.z, 1.0);
6612
+
6613
+ output.fragPos = quadPos * clipFactor;
6614
+ output.opacity = adjustedOpacity;
6615
+ output.viewDepth = -viewPos.z;
6616
+
6617
+ // Normal from shortest ellipsoid axis, oriented toward camera
6618
+ var normal = computeSplatNormal(splat.scale, splat.rotation, uniforms.model);
6619
+ let toCamera = uniforms.cameraPos - worldPos.xyz;
6620
+ if dot(normal, toCamera) < 0.0 {
6621
+ normal = -normal;
6622
+ }
6623
+ output.worldNormal = normal;
6624
+ return output;
6625
+ }
6626
+
6627
+ struct FragOutput {
6628
+ @location(0) depthOut: vec4<f32>,
6629
+ @location(1) normalOut: vec4<f32>,
6630
+ }
6631
+
6632
+ @fragment
6633
+ fn fs_depth_normal(input: VertexOutput) -> FragOutput {
6634
+ if input.opacity <= 0.0 { discard; }
6635
+ let A = dot(input.fragPos, input.fragPos);
6636
+ if A > 1.0 { discard; }
6637
+ let weight = (exp(-GAUSSIAN_K * A) - EXP_NEG_K) * INV_ONE_MINUS_EXP_NEG_K;
6638
+ let opacity = weight * input.opacity;
6639
+ if opacity < ALPHA_CULL_THRESHOLD { discard; }
6640
+
6641
+ var out: FragOutput;
6642
+ // RT0: depth + opacity — RGB blend 1·src+(1-α)·dst accumulates depth, Alpha blend 0·src+(1-α)·dst accumulates transmittance
6643
+ out.depthOut = vec4<f32>(input.viewDepth * opacity, 0.0, 0.0, opacity);
6644
+ // RT1: normal — same blend, RGB accumulates weighted normal, Alpha accumulates transmittance
6645
+ out.normalOut = vec4<f32>(input.worldNormal * opacity, opacity);
6646
+ return out;
6647
+ }
6648
+ `
6649
+ );
5481
6650
  const SPLAT_FLOAT_COUNT = 64;
5482
- class GSSplatRenderer {
6651
+ const _GSSplatRenderer = class _GSSplatRenderer {
5483
6652
  constructor(renderer, camera) {
5484
6653
  __publicField(this, "renderer");
5485
6654
  __publicField(this, "camera");
@@ -5490,8 +6659,9 @@ class GSSplatRenderer {
5490
6659
  __publicField(this, "splatCount", 0);
5491
6660
  __publicField(this, "bindGroup", null);
5492
6661
  __publicField(this, "sorter", null);
5493
- __publicField(this, "shMode", SHMode.L0);
6662
+ __publicField(this, "shMode", SHMode.L3);
5494
6663
  __publicField(this, "boundingBox", null);
6664
+ __publicField(this, "cpuPositions", null);
5495
6665
  // Transform
5496
6666
  __publicField(this, "position", [0, 0, 0]);
5497
6667
  __publicField(this, "rotation", [0, 0, 0]);
@@ -5500,6 +6670,28 @@ class GSSplatRenderer {
5500
6670
  __publicField(this, "modelMatrix", new Float32Array(16));
5501
6671
  // 剔除选项
5502
6672
  __publicField(this, "pixelCullThreshold", 1);
6673
+ __publicField(this, "maxVisibleSplats", 0);
6674
+ // 排序优化:相机变化检测 + 频率控制
6675
+ __publicField(this, "lastSortViewMatrix", new Float32Array(16));
6676
+ __publicField(this, "lastSortProjMatrix", new Float32Array(16));
6677
+ __publicField(this, "lastSortModelMatrix", new Float32Array(16));
6678
+ __publicField(this, "lastSortWidth", 0);
6679
+ __publicField(this, "lastSortHeight", 0);
6680
+ __publicField(this, "lastSortPixelThreshold", -1);
6681
+ __publicField(this, "lastSortMaxVisible", -1);
6682
+ __publicField(this, "sortStateInitialized", false);
6683
+ __publicField(this, "sortFrequency", 1);
6684
+ __publicField(this, "frameCounter", 0);
6685
+ // 深度法线Pass依赖资源
6686
+ __publicField(this, "depthNormalPipeline", null);
6687
+ __publicField(this, "depthRT", null);
6688
+ __publicField(this, "depthRTView", null);
6689
+ __publicField(this, "normalRT", null);
6690
+ __publicField(this, "normalRTView", null);
6691
+ __publicField(this, "dnRTWidth", 0);
6692
+ __publicField(this, "dnRTHeight", 0);
6693
+ // 这一帧的深度法线回读结果,返回指定像素的3*3区域的深度值和法线
6694
+ __publicField(this, "dnResult", null);
5503
6695
  this.renderer = renderer;
5504
6696
  this.camera = camera;
5505
6697
  this.createPipeline();
@@ -5513,9 +6705,21 @@ class GSSplatRenderer {
5513
6705
  });
5514
6706
  this.bindGroupLayout = device.createBindGroupLayout({
5515
6707
  entries: [
5516
- { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
5517
- { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
5518
- { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }
6708
+ {
6709
+ binding: 0,
6710
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
6711
+ buffer: { type: "uniform" }
6712
+ },
6713
+ {
6714
+ binding: 1,
6715
+ visibility: GPUShaderStage.VERTEX,
6716
+ buffer: { type: "read-only-storage" }
6717
+ },
6718
+ {
6719
+ binding: 2,
6720
+ visibility: GPUShaderStage.VERTEX,
6721
+ buffer: { type: "read-only-storage" }
6722
+ }
5519
6723
  ]
5520
6724
  });
5521
6725
  const pipelineLayout = device.createPipelineLayout({
@@ -5531,28 +6735,30 @@ class GSSplatRenderer {
5531
6735
  fragment: {
5532
6736
  module: shaderModule,
5533
6737
  entryPoint: "fs_main",
5534
- targets: [{
5535
- format: this.renderer.format,
5536
- blend: {
5537
- color: {
5538
- srcFactor: "one",
5539
- dstFactor: "one-minus-src-alpha",
5540
- operation: "add"
5541
- },
5542
- alpha: {
5543
- srcFactor: "one",
5544
- dstFactor: "one-minus-src-alpha",
5545
- operation: "add"
6738
+ targets: [
6739
+ {
6740
+ format: this.renderer.format,
6741
+ blend: {
6742
+ color: {
6743
+ srcFactor: "one",
6744
+ dstFactor: "one-minus-src-alpha",
6745
+ operation: "add"
6746
+ },
6747
+ alpha: {
6748
+ srcFactor: "one",
6749
+ dstFactor: "one-minus-src-alpha",
6750
+ operation: "add"
6751
+ }
5546
6752
  }
5547
6753
  }
5548
- }]
6754
+ ]
5549
6755
  },
5550
6756
  primitive: {
5551
6757
  topology: "triangle-strip"
5552
6758
  },
5553
6759
  depthStencil: {
5554
6760
  format: this.renderer.depthFormat,
5555
- depthWriteEnabled: false,
6761
+ depthWriteEnabled: true,
5556
6762
  depthCompare: "always"
5557
6763
  }
5558
6764
  });
@@ -5563,6 +6769,65 @@ class GSSplatRenderer {
5563
6769
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
5564
6770
  });
5565
6771
  }
6772
+ // 创建深度和法线渲染管线,这个管线用于渲染深度和法线信息,用于后续的拾取操作
6773
+ // RGB采用"one add one-minus-src-alpha"的混合模式对深度和法线做AlphaBlending
6774
+ // A采用"zero add one-minus-src-alpha"的混合模式计算Transmittance
6775
+ createDepthNormalPipeline() {
6776
+ const device = this.renderer.device;
6777
+ const shaderModule = device.createShaderModule({ code: gsDepthNormalShader });
6778
+ const pipelineLayout = device.createPipelineLayout({
6779
+ bindGroupLayouts: [this.bindGroupLayout]
6780
+ });
6781
+ this.depthNormalPipeline = device.createRenderPipeline({
6782
+ layout: pipelineLayout,
6783
+ vertex: {
6784
+ module: shaderModule,
6785
+ entryPoint: "vs_main",
6786
+ buffers: []
6787
+ },
6788
+ fragment: {
6789
+ module: shaderModule,
6790
+ entryPoint: "fs_depth_normal",
6791
+ targets: [
6792
+ {
6793
+ format: "rgba16float",
6794
+ blend: {
6795
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6796
+ alpha: { srcFactor: "zero", dstFactor: "one-minus-src-alpha", operation: "add" }
6797
+ }
6798
+ },
6799
+ {
6800
+ format: "rgba16float",
6801
+ blend: {
6802
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
6803
+ alpha: { srcFactor: "zero", dstFactor: "one-minus-src-alpha", operation: "add" }
6804
+ }
6805
+ }
6806
+ ]
6807
+ },
6808
+ primitive: { topology: "triangle-strip" }
6809
+ });
6810
+ }
6811
+ ensureDepthNormalTextures(w, h) {
6812
+ if (this.depthRT && this.dnRTWidth === w && this.dnRTHeight === h) return;
6813
+ const device = this.renderer.device;
6814
+ if (this.depthRT) this.depthRT.destroy();
6815
+ if (this.normalRT) this.normalRT.destroy();
6816
+ this.depthRT = device.createTexture({
6817
+ size: { width: w, height: h },
6818
+ format: "rgba16float",
6819
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
6820
+ });
6821
+ this.depthRTView = this.depthRT.createView();
6822
+ this.normalRT = device.createTexture({
6823
+ size: { width: w, height: h },
6824
+ format: "rgba16float",
6825
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
6826
+ });
6827
+ this.normalRTView = this.normalRT.createView();
6828
+ this.dnRTWidth = w;
6829
+ this.dnRTHeight = h;
6830
+ }
5566
6831
  setPosition(x, y, z) {
5567
6832
  this.position = [x, y, z];
5568
6833
  this.updateModelMatrix();
@@ -5646,6 +6911,56 @@ class GSSplatRenderer {
5646
6911
  setPixelCullThreshold(threshold) {
5647
6912
  this.pixelCullThreshold = threshold;
5648
6913
  }
6914
+ /**
6915
+ * 设置最大可见 splat 数量
6916
+ * 排序后只保留距离最近的 N 个 splat,远处被遮挡的 splat 被丢弃
6917
+ * 0 = 不限制
6918
+ */
6919
+ setMaxVisibleSplats(count) {
6920
+ this.maxVisibleSplats = count;
6921
+ }
6922
+ getMaxVisibleSplats() {
6923
+ return this.maxVisibleSplats;
6924
+ }
6925
+ /**
6926
+ * 设置排序频率
6927
+ * 1 = 每帧排序(默认),2 = 每 2 帧排序一次,以此类推
6928
+ * 相机静止时自动跳过排序,不受此参数影响
6929
+ */
6930
+ setSortFrequency(frequency) {
6931
+ this.sortFrequency = Math.max(1, Math.round(frequency));
6932
+ }
6933
+ getSortFrequency() {
6934
+ return this.sortFrequency;
6935
+ }
6936
+ needsSort() {
6937
+ const view = this.camera.viewMatrix;
6938
+ const proj = this.camera.projectionMatrix;
6939
+ const model = this.modelMatrix;
6940
+ const w = this.renderer.width;
6941
+ const h = this.renderer.height;
6942
+ if (!this.sortStateInitialized || w !== this.lastSortWidth || h !== this.lastSortHeight || this.pixelCullThreshold !== this.lastSortPixelThreshold || this.maxVisibleSplats !== this.lastSortMaxVisible) {
6943
+ this.saveSortState(view, proj, model, w, h);
6944
+ return true;
6945
+ }
6946
+ for (let i = 0; i < 16; i++) {
6947
+ if (view[i] !== this.lastSortViewMatrix[i] || proj[i] !== this.lastSortProjMatrix[i] || model[i] !== this.lastSortModelMatrix[i]) {
6948
+ this.saveSortState(view, proj, model, w, h);
6949
+ return true;
6950
+ }
6951
+ }
6952
+ return false;
6953
+ }
6954
+ saveSortState(view, proj, model, w, h) {
6955
+ this.lastSortViewMatrix.set(view);
6956
+ this.lastSortProjMatrix.set(proj);
6957
+ this.lastSortModelMatrix.set(model);
6958
+ this.lastSortWidth = w;
6959
+ this.lastSortHeight = h;
6960
+ this.lastSortPixelThreshold = this.pixelCullThreshold;
6961
+ this.lastSortMaxVisible = this.maxVisibleSplats;
6962
+ this.sortStateInitialized = true;
6963
+ }
5649
6964
  setData(splats) {
5650
6965
  const device = this.renderer.device;
5651
6966
  if (this.splatBuffer) {
@@ -5663,10 +6978,14 @@ class GSSplatRenderer {
5663
6978
  return;
5664
6979
  }
5665
6980
  this.boundingBox = this.computeBoundingBox(splats);
6981
+ const positions = new Float32Array(this.splatCount * 3);
5666
6982
  const data = new Float32Array(this.splatCount * SPLAT_FLOAT_COUNT);
5667
6983
  for (let i = 0; i < this.splatCount; i++) {
5668
6984
  const splat = splats[i];
5669
6985
  const offset = i * SPLAT_FLOAT_COUNT;
6986
+ positions[i * 3 + 0] = splat.mean[0];
6987
+ positions[i * 3 + 1] = splat.mean[1];
6988
+ positions[i * 3 + 2] = splat.mean[2];
5670
6989
  data[offset + 0] = splat.mean[0];
5671
6990
  data[offset + 1] = splat.mean[1];
5672
6991
  data[offset + 2] = splat.mean[2];
@@ -5702,6 +7021,7 @@ class GSSplatRenderer {
5702
7021
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
5703
7022
  });
5704
7023
  device.queue.writeBuffer(this.splatBuffer, 0, data);
7024
+ this.cpuPositions = positions;
5705
7025
  this.sorter = new GSSplatSorter(
5706
7026
  device,
5707
7027
  this.splatCount,
@@ -5740,6 +7060,7 @@ class GSSplatRenderer {
5740
7060
  return;
5741
7061
  }
5742
7062
  this.boundingBox = this.computeBoundingBoxFromCompact(compactData);
7063
+ this.cpuPositions = new Float32Array(compactData.positions);
5743
7064
  const includeSH = compactData.shCoeffs !== void 0;
5744
7065
  const gpuData = compactDataToGPUBuffer(compactData, includeSH);
5745
7066
  this.splatBuffer = device.createBuffer({
@@ -5797,13 +7118,19 @@ class GSSplatRenderer {
5797
7118
  208,
5798
7119
  new Float32Array([this.renderer.width, this.renderer.height, 0, 0])
5799
7120
  );
5800
- this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
5801
- this.sorter.setCullingOptions({
5802
- nearPlane: this.camera.near,
5803
- farPlane: this.camera.far,
5804
- pixelThreshold: this.pixelCullThreshold
5805
- });
5806
- this.sorter.sort();
7121
+ const changed = this.needsSort();
7122
+ this.frameCounter++;
7123
+ const shouldSort = changed || this.sortFrequency > 1 && this.frameCounter % this.sortFrequency === 0;
7124
+ if (shouldSort) {
7125
+ this.sorter.setScreenSize(this.renderer.width, this.renderer.height);
7126
+ this.sorter.setCullingOptions({
7127
+ nearPlane: this.camera.near,
7128
+ farPlane: this.camera.far,
7129
+ pixelThreshold: this.pixelCullThreshold,
7130
+ maxVisibleCount: this.maxVisibleSplats
7131
+ });
7132
+ this.sorter.sort();
7133
+ }
5807
7134
  pass.setPipeline(this.pipeline);
5808
7135
  pass.setBindGroup(0, this.bindGroup);
5809
7136
  pass.drawIndirect(this.sorter.getDrawIndirectBuffer(), 0);
@@ -5814,31 +7141,42 @@ class GSSplatRenderer {
5814
7141
  getBoundingBox() {
5815
7142
  return this.boundingBox;
5816
7143
  }
7144
+ getCPUPositions() {
7145
+ return this.cpuPositions;
7146
+ }
5817
7147
  computeBoundingBox(splats) {
5818
7148
  if (splats.length === 0) {
5819
7149
  return { min: [0, 0, 0], max: [0, 0, 0], center: [0, 0, 0], radius: 0 };
5820
7150
  }
5821
- const min = [splats[0].mean[0], splats[0].mean[1], splats[0].mean[2]];
5822
- const max = [splats[0].mean[0], splats[0].mean[1], splats[0].mean[2]];
7151
+ const min = [
7152
+ splats[0].mean[0],
7153
+ splats[0].mean[1],
7154
+ splats[0].mean[2]
7155
+ ];
7156
+ const max2 = [
7157
+ splats[0].mean[0],
7158
+ splats[0].mean[1],
7159
+ splats[0].mean[2]
7160
+ ];
5823
7161
  for (let i = 1; i < splats.length; i++) {
5824
7162
  const [x, y, z] = splats[i].mean;
5825
7163
  min[0] = Math.min(min[0], x);
5826
7164
  min[1] = Math.min(min[1], y);
5827
7165
  min[2] = Math.min(min[2], z);
5828
- max[0] = Math.max(max[0], x);
5829
- max[1] = Math.max(max[1], y);
5830
- max[2] = Math.max(max[2], z);
7166
+ max2[0] = Math.max(max2[0], x);
7167
+ max2[1] = Math.max(max2[1], y);
7168
+ max2[2] = Math.max(max2[2], z);
5831
7169
  }
5832
7170
  const center = [
5833
- (min[0] + max[0]) / 2,
5834
- (min[1] + max[1]) / 2,
5835
- (min[2] + max[2]) / 2
7171
+ (min[0] + max2[0]) / 2,
7172
+ (min[1] + max2[1]) / 2,
7173
+ (min[2] + max2[2]) / 2
5836
7174
  ];
5837
- const dx = max[0] - min[0];
5838
- const dy = max[1] - min[1];
5839
- const dz = max[2] - min[2];
7175
+ const dx = max2[0] - min[0];
7176
+ const dy = max2[1] - min[1];
7177
+ const dz = max2[2] - min[2];
5840
7178
  const radius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
5841
- return { min, max, center, radius };
7179
+ return { min, max: max2, center, radius };
5842
7180
  }
5843
7181
  computeBoundingBoxFromCompact(data) {
5844
7182
  if (data.count === 0) {
@@ -5846,7 +7184,7 @@ class GSSplatRenderer {
5846
7184
  }
5847
7185
  const positions = data.positions;
5848
7186
  const min = [positions[0], positions[1], positions[2]];
5849
- const max = [positions[0], positions[1], positions[2]];
7187
+ const max2 = [positions[0], positions[1], positions[2]];
5850
7188
  for (let i = 1; i < data.count; i++) {
5851
7189
  const x = positions[i * 3 + 0];
5852
7190
  const y = positions[i * 3 + 1];
@@ -5854,20 +7192,183 @@ class GSSplatRenderer {
5854
7192
  min[0] = Math.min(min[0], x);
5855
7193
  min[1] = Math.min(min[1], y);
5856
7194
  min[2] = Math.min(min[2], z);
5857
- max[0] = Math.max(max[0], x);
5858
- max[1] = Math.max(max[1], y);
5859
- max[2] = Math.max(max[2], z);
7195
+ max2[0] = Math.max(max2[0], x);
7196
+ max2[1] = Math.max(max2[1], y);
7197
+ max2[2] = Math.max(max2[2], z);
7198
+ }
7199
+ const center = [
7200
+ (min[0] + max2[0]) / 2,
7201
+ (min[1] + max2[1]) / 2,
7202
+ (min[2] + max2[2]) / 2
7203
+ ];
7204
+ const dx = max2[0] - min[0];
7205
+ const dy = max2[1] - min[1];
7206
+ const dz = max2[2] - min[2];
7207
+ const radius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
7208
+ return { min, max: max2, center, radius };
7209
+ }
7210
+ // 1、提交深度和法线渲染Pass;
7211
+ // 2、如果px和py不为-1,则表示需要回读指定像素周围3*3区域的深度和法线信息,这一帧的回读结果保存在dnResult中,下一帧可以获取
7212
+ prepareDepthNormalPass(px = -1, py = -1) {
7213
+ if (this.splatCount === 0 || !this.bindGroup || !this.sorter) return;
7214
+ if (!this.depthNormalPipeline) this.createDepthNormalPipeline();
7215
+ const w = this.renderer.width;
7216
+ const h = this.renderer.height;
7217
+ this.ensureDepthNormalTextures(w, h);
7218
+ const device = this.renderer.device;
7219
+ const encoder = device.createCommandEncoder({ label: "depth-normal-encoder" });
7220
+ const pass = encoder.beginRenderPass({
7221
+ colorAttachments: [
7222
+ {
7223
+ view: this.depthRTView,
7224
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
7225
+ loadOp: "clear",
7226
+ storeOp: "store"
7227
+ },
7228
+ {
7229
+ view: this.normalRTView,
7230
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
7231
+ loadOp: "clear",
7232
+ storeOp: "store"
7233
+ }
7234
+ ]
7235
+ });
7236
+ pass.setPipeline(this.depthNormalPipeline);
7237
+ pass.setBindGroup(0, this.bindGroup);
7238
+ pass.drawIndirect(this.sorter.getDrawIndirectBuffer(), 0);
7239
+ pass.end();
7240
+ const ipx = Math.floor(px);
7241
+ const ipy = Math.floor(py);
7242
+ if (ipx >= 0 && ipx < w && ipy >= 0 && ipy < h) {
7243
+ const x0 = Math.max(0, ipx - 1);
7244
+ const y0 = Math.max(0, ipy - 1);
7245
+ const x1 = Math.min(w, ipx + 2);
7246
+ const y1 = Math.min(h, ipy + 2);
7247
+ const copyW = x1 - x0;
7248
+ const copyH = y1 - y0;
7249
+ const cx = ipx - x0;
7250
+ const cy = ipy - y0;
7251
+ const rawBytesPerRow = copyW * 8;
7252
+ const bytesPerRow = Math.ceil(rawBytesPerRow / 256) * 256;
7253
+ const sliceBytes = bytesPerRow * copyH;
7254
+ const buf = device.createBuffer({
7255
+ size: sliceBytes * 2,
7256
+ // two RT slices (depth + normal)
7257
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
7258
+ });
7259
+ encoder.copyTextureToBuffer(
7260
+ { texture: this.depthRT, origin: { x: x0, y: y0 } },
7261
+ { buffer: buf, offset: 0, bytesPerRow },
7262
+ { width: copyW, height: copyH }
7263
+ );
7264
+ encoder.copyTextureToBuffer(
7265
+ { texture: this.normalRT, origin: { x: x0, y: y0 } },
7266
+ { buffer: buf, offset: sliceBytes, bytesPerRow },
7267
+ { width: copyW, height: copyH }
7268
+ );
7269
+ device.queue.submit([encoder.finish()]);
7270
+ const vm = new Float32Array(this.camera.viewMatrix);
7271
+ const proj = new Float32Array(this.camera.projectionMatrix);
7272
+ const cam = new Float32Array(this.camera.position);
7273
+ buf.mapAsync(GPUMapMode.READ).then(() => {
7274
+ const raw = new Uint16Array(buf.getMappedRange().slice(0));
7275
+ buf.unmap();
7276
+ buf.destroy();
7277
+ this.dnResult = { raw, vm, proj, cam, w, h, px: ipx, py: ipy, copyX: x0, copyY: y0, copyW, copyH, cx, cy };
7278
+ }).catch(() => {
7279
+ buf.destroy();
7280
+ });
7281
+ } else {
7282
+ device.queue.submit([encoder.finish()]);
7283
+ }
7284
+ }
7285
+ static half2Float(h) {
7286
+ const sign = (h & 32768) << 16;
7287
+ const exponent = (h & 31744) >> 10;
7288
+ const mantissa = h & 1023;
7289
+ if (exponent === 0) {
7290
+ if (mantissa === 0) {
7291
+ _GSSplatRenderer._f32[0] = 0;
7292
+ return sign ? -0 : 0;
7293
+ }
7294
+ let e = -1;
7295
+ let m = mantissa;
7296
+ do {
7297
+ e++;
7298
+ m <<= 1;
7299
+ } while ((m & 1024) === 0);
7300
+ _GSSplatRenderer._u32[0] = sign | 127 - 15 - e << 23 | (m & 1023) << 13;
7301
+ } else if (exponent === 31) {
7302
+ _GSSplatRenderer._u32[0] = sign | 2139095040 | mantissa << 13;
7303
+ } else {
7304
+ _GSSplatRenderer._u32[0] = sign | exponent + 127 - 15 << 23 | mantissa << 13;
7305
+ }
7306
+ return _GSSplatRenderer._f32[0];
7307
+ }
7308
+ // 通过上一帧的回读结果dnResult,获取深度和法线信息
7309
+ // 世界位置通过当前像素以及对应深度反向投影计算
7310
+ // 世界法线使用3×3邻域直接平均以降低噪声敏感度
7311
+ getDepthNormal() {
7312
+ if (!this.dnResult) return null;
7313
+ const { raw, vm, proj, cam, w, h, px, py, copyW, copyH, cx, cy } = this.dnResult;
7314
+ const rawBytesPerRow = copyW * 8;
7315
+ const bytesPerRow = Math.ceil(rawBytesPerRow / 256) * 256;
7316
+ const u16PerRow = bytesPerRow / 2;
7317
+ const sliceU16 = u16PerRow * copyH;
7318
+ const h2f = _GSSplatRenderer.half2Float;
7319
+ const cOff = cy * u16PerRow + cx * 4;
7320
+ const accumDepth = h2f(raw[cOff + 0]);
7321
+ const transmittance = h2f(raw[cOff + 3]);
7322
+ const alpha = 1 - transmittance;
7323
+ if (alpha < 1e-6) return null;
7324
+ const viewDepth = accumDepth / alpha;
7325
+ let snx = 0, sny = 0, snz = 0;
7326
+ for (let ky = 0; ky < 3; ky++) {
7327
+ const ry = cy + ky - 1;
7328
+ if (ry < 0 || ry >= copyH) continue;
7329
+ for (let kx = 0; kx < 3; kx++) {
7330
+ const rx = cx + kx - 1;
7331
+ if (rx < 0 || rx >= copyW) continue;
7332
+ const off = sliceU16 + ry * u16PerRow + rx * 4;
7333
+ const a = 1 - h2f(raw[off + 3]);
7334
+ if (a < 1e-6) continue;
7335
+ snx += h2f(raw[off + 0]) / a;
7336
+ sny += h2f(raw[off + 1]) / a;
7337
+ snz += h2f(raw[off + 2]) / a;
7338
+ }
7339
+ }
7340
+ let nx = snx, ny = sny, nz = snz;
7341
+ const nLen = Math.sqrt(nx * nx + ny * ny + nz * nz);
7342
+ if (nLen > 1e-8) {
7343
+ nx /= nLen;
7344
+ ny /= nLen;
7345
+ nz /= nLen;
7346
+ } else {
7347
+ nx = 0;
7348
+ ny = 1;
7349
+ nz = 0;
7350
+ }
7351
+ const ndcX = (px + 0.5) / w * 2 - 1;
7352
+ const ndcY = 1 - (py + 0.5) / h * 2;
7353
+ const viewX = ndcX * viewDepth / proj[0];
7354
+ const viewY = ndcY * viewDepth / proj[5];
7355
+ const viewZ = -viewDepth;
7356
+ const wx = vm[0] * viewX + vm[1] * viewY + vm[2] * viewZ + cam[0];
7357
+ const wy = vm[4] * viewX + vm[5] * viewY + vm[6] * viewZ + cam[1];
7358
+ const wz = vm[8] * viewX + vm[9] * viewY + vm[10] * viewZ + cam[2];
7359
+ const toCamX = cam[0] - wx;
7360
+ const toCamY = cam[1] - wy;
7361
+ const toCamZ = cam[2] - wz;
7362
+ if (nx * toCamX + ny * toCamY + nz * toCamZ < 0) {
7363
+ nx = -nx;
7364
+ ny = -ny;
7365
+ nz = -nz;
5860
7366
  }
5861
- const center = [
5862
- (min[0] + max[0]) / 2,
5863
- (min[1] + max[1]) / 2,
5864
- (min[2] + max[2]) / 2
5865
- ];
5866
- const dx = max[0] - min[0];
5867
- const dy = max[1] - min[1];
5868
- const dz = max[2] - min[2];
5869
- const radius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
5870
- return { min, max, center, radius };
7367
+ return {
7368
+ depth: viewDepth,
7369
+ normal: [nx, ny, nz],
7370
+ worldPos: [wx, wy, wz]
7371
+ };
5871
7372
  }
5872
7373
  supportsSHMode(mode) {
5873
7374
  return mode >= SHMode.L0 && mode <= SHMode.L3;
@@ -5889,11 +7390,24 @@ class GSSplatRenderer {
5889
7390
  this.sorter.destroy();
5890
7391
  this.sorter = null;
5891
7392
  }
7393
+ if (this.depthRT) {
7394
+ this.depthRT.destroy();
7395
+ this.depthRT = null;
7396
+ }
7397
+ if (this.normalRT) {
7398
+ this.normalRT.destroy();
7399
+ this.normalRT = null;
7400
+ }
7401
+ this.dnResult = null;
5892
7402
  this.uniformBuffer.destroy();
5893
7403
  this.splatCount = 0;
5894
7404
  this.bindGroup = null;
7405
+ this.depthNormalPipeline = null;
5895
7406
  }
5896
- }
7407
+ };
7408
+ __publicField(_GSSplatRenderer, "_f32", new Float32Array(1));
7409
+ __publicField(_GSSplatRenderer, "_u32", new Uint32Array(_GSSplatRenderer._f32.buffer));
7410
+ let GSSplatRenderer = _GSSplatRenderer;
5897
7411
  function calculateTextureDimensions(count) {
5898
7412
  if (count <= 0) {
5899
7413
  return { width: 4, height: 4 };
@@ -5915,7 +7429,7 @@ function computeBoundingBox(positions, count) {
5915
7429
  };
5916
7430
  }
5917
7431
  const min = [positions[0], positions[1], positions[2]];
5918
- const max = [positions[0], positions[1], positions[2]];
7432
+ const max2 = [positions[0], positions[1], positions[2]];
5919
7433
  for (let i = 1; i < count; i++) {
5920
7434
  const x = positions[i * 3 + 0];
5921
7435
  const y = positions[i * 3 + 1];
@@ -5923,11 +7437,11 @@ function computeBoundingBox(positions, count) {
5923
7437
  min[0] = Math.min(min[0], x);
5924
7438
  min[1] = Math.min(min[1], y);
5925
7439
  min[2] = Math.min(min[2], z);
5926
- max[0] = Math.max(max[0], x);
5927
- max[1] = Math.max(max[1], y);
5928
- max[2] = Math.max(max[2], z);
7440
+ max2[0] = Math.max(max2[0], x);
7441
+ max2[1] = Math.max(max2[1], y);
7442
+ max2[2] = Math.max(max2[2], z);
5929
7443
  }
5930
- return { min, max };
7444
+ return { min, max: max2 };
5931
7445
  }
5932
7446
  function compressSplatsToTextures(device, data) {
5933
7447
  const count = data.count;
@@ -6723,6 +8237,7 @@ class GSSplatRendererMobile {
6723
8237
  __publicField(this, "positionsBuffer", null);
6724
8238
  // Bounding box
6725
8239
  __publicField(this, "boundingBox", null);
8240
+ __publicField(this, "cpuPositions", null);
6726
8241
  // 帧计数(用于排序频率控制)
6727
8242
  __publicField(this, "frameCount", 0);
6728
8243
  __publicField(this, "sortEveryNFrames", 1);
@@ -6939,7 +8454,7 @@ class GSSplatRendererMobile {
6939
8454
  },
6940
8455
  depthStencil: {
6941
8456
  format: this.renderer.depthFormat,
6942
- depthWriteEnabled: false,
8457
+ depthWriteEnabled: true,
6943
8458
  depthCompare: "always"
6944
8459
  }
6945
8460
  });
@@ -6969,6 +8484,7 @@ class GSSplatRendererMobile {
6969
8484
  }
6970
8485
  this.compressedTextures = compressSplatsToTextures(device, data);
6971
8486
  this.boundingBox = this.computeBoundingBox(data);
8487
+ this.cpuPositions = new Float32Array(data.positions);
6972
8488
  this.positionsBuffer = device.createBuffer({
6973
8489
  size: data.count * 12,
6974
8490
  // 3 floats per position
@@ -7046,7 +8562,7 @@ class GSSplatRendererMobile {
7046
8562
  }
7047
8563
  const positions = data.positions;
7048
8564
  const min = [positions[0], positions[1], positions[2]];
7049
- const max = [positions[0], positions[1], positions[2]];
8565
+ const max2 = [positions[0], positions[1], positions[2]];
7050
8566
  for (let i = 1; i < data.count; i++) {
7051
8567
  const x = positions[i * 3 + 0];
7052
8568
  const y = positions[i * 3 + 1];
@@ -7054,20 +8570,20 @@ class GSSplatRendererMobile {
7054
8570
  min[0] = Math.min(min[0], x);
7055
8571
  min[1] = Math.min(min[1], y);
7056
8572
  min[2] = Math.min(min[2], z);
7057
- max[0] = Math.max(max[0], x);
7058
- max[1] = Math.max(max[1], y);
7059
- max[2] = Math.max(max[2], z);
8573
+ max2[0] = Math.max(max2[0], x);
8574
+ max2[1] = Math.max(max2[1], y);
8575
+ max2[2] = Math.max(max2[2], z);
7060
8576
  }
7061
8577
  const center = [
7062
- (min[0] + max[0]) / 2,
7063
- (min[1] + max[1]) / 2,
7064
- (min[2] + max[2]) / 2
8578
+ (min[0] + max2[0]) / 2,
8579
+ (min[1] + max2[1]) / 2,
8580
+ (min[2] + max2[2]) / 2
7065
8581
  ];
7066
- const dx = max[0] - min[0];
7067
- const dy = max[1] - min[1];
7068
- const dz = max[2] - min[2];
8582
+ const dx = max2[0] - min[0];
8583
+ const dy = max2[1] - min[1];
8584
+ const dz = max2[2] - min[2];
7069
8585
  const radius = Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
7070
- return { min, max, center, radius };
8586
+ return { min, max: max2, center, radius };
7071
8587
  }
7072
8588
  /**
7073
8589
  * 渲染
@@ -7121,6 +8637,9 @@ class GSSplatRendererMobile {
7121
8637
  getBoundingBox() {
7122
8638
  return this.boundingBox;
7123
8639
  }
8640
+ getCPUPositions() {
8641
+ return this.cpuPositions;
8642
+ }
7124
8643
  /**
7125
8644
  * 设置排序频率
7126
8645
  * @param n 每 n 帧排序一次
@@ -7256,14 +8775,40 @@ class SceneManager {
7256
8775
  return this.meshRenderer.removeMeshByIndex(index);
7257
8776
  }
7258
8777
  // ============================================
8778
+ // 覆盖层 Mesh 管理(热点等)
8779
+ // ============================================
8780
+ getOverlayMeshCount() {
8781
+ return this.meshRenderer.getOverlayMeshCount();
8782
+ }
8783
+ getOverlayMeshByIndex(index) {
8784
+ return this.meshRenderer.getOverlayMeshByIndex(index);
8785
+ }
8786
+ getOverlayMeshRange(startIndex, count) {
8787
+ const meshes = [];
8788
+ for (let i = 0; i < count; i++) {
8789
+ const mesh = this.meshRenderer.getOverlayMeshByIndex(startIndex + i);
8790
+ if (mesh) meshes.push(mesh);
8791
+ }
8792
+ return meshes;
8793
+ }
8794
+ removeOverlayMeshByIndex(index) {
8795
+ return this.meshRenderer.removeOverlayMeshByIndex(index);
8796
+ }
8797
+ getOverlayMeshColor(index) {
8798
+ return this.meshRenderer.getOverlayMeshColor(index);
8799
+ }
8800
+ setOverlayMeshRangeColor(startIndex, count, r, g, b, a = 1) {
8801
+ return this.meshRenderer.setOverlayMeshRangeColor(startIndex, count, r, g, b, a);
8802
+ }
8803
+ // ============================================
7259
8804
  // Splat 管理
7260
8805
  // ============================================
7261
8806
  /**
7262
8807
  * 获取 Splat 数量
7263
8808
  */
7264
8809
  getSplatCount() {
7265
- var _a;
7266
- return ((_a = this.gsRenderer) == null ? void 0 : _a.getSplatCount()) ?? 0;
8810
+ var _a2;
8811
+ return ((_a2 = this.gsRenderer) == null ? void 0 : _a2.getSplatCount()) ?? 0;
7267
8812
  }
7268
8813
  /**
7269
8814
  * 清空 Splats
@@ -7281,43 +8826,43 @@ class SceneManager {
7281
8826
  * 设置 Splat 位置
7282
8827
  */
7283
8828
  setSplatPosition(x, y, z) {
7284
- var _a;
7285
- (_a = this.gsRenderer) == null ? void 0 : _a.setPosition(x, y, z);
8829
+ var _a2;
8830
+ (_a2 = this.gsRenderer) == null ? void 0 : _a2.setPosition(x, y, z);
7286
8831
  }
7287
8832
  /**
7288
8833
  * 获取 Splat 位置
7289
8834
  */
7290
8835
  getSplatPosition() {
7291
- var _a;
7292
- return ((_a = this.gsRenderer) == null ? void 0 : _a.getPosition()) ?? null;
8836
+ var _a2;
8837
+ return ((_a2 = this.gsRenderer) == null ? void 0 : _a2.getPosition()) ?? null;
7293
8838
  }
7294
8839
  /**
7295
8840
  * 设置 Splat 旋转
7296
8841
  */
7297
8842
  setSplatRotation(x, y, z) {
7298
- var _a;
7299
- (_a = this.gsRenderer) == null ? void 0 : _a.setRotation(x, y, z);
8843
+ var _a2;
8844
+ (_a2 = this.gsRenderer) == null ? void 0 : _a2.setRotation(x, y, z);
7300
8845
  }
7301
8846
  /**
7302
8847
  * 获取 Splat 旋转
7303
8848
  */
7304
8849
  getSplatRotation() {
7305
- var _a;
7306
- return ((_a = this.gsRenderer) == null ? void 0 : _a.getRotation()) ?? null;
8850
+ var _a2;
8851
+ return ((_a2 = this.gsRenderer) == null ? void 0 : _a2.getRotation()) ?? null;
7307
8852
  }
7308
8853
  /**
7309
8854
  * 设置 Splat 缩放
7310
8855
  */
7311
8856
  setSplatScale(x, y, z) {
7312
- var _a;
7313
- (_a = this.gsRenderer) == null ? void 0 : _a.setScale(x, y, z);
8857
+ var _a2;
8858
+ (_a2 = this.gsRenderer) == null ? void 0 : _a2.setScale(x, y, z);
7314
8859
  }
7315
8860
  /**
7316
8861
  * 获取 Splat 缩放
7317
8862
  */
7318
8863
  getSplatScale() {
7319
- var _a;
7320
- return ((_a = this.gsRenderer) == null ? void 0 : _a.getScale()) ?? null;
8864
+ var _a2;
8865
+ return ((_a2 = this.gsRenderer) == null ? void 0 : _a2.getScale()) ?? null;
7321
8866
  }
7322
8867
  // ============================================
7323
8868
  // SH 模式
@@ -7326,8 +8871,8 @@ class SceneManager {
7326
8871
  * 设置 SH 模式
7327
8872
  */
7328
8873
  setSHMode(mode) {
7329
- var _a;
7330
- if ((_a = this.gsRenderer) == null ? void 0 : _a.setSHMode) {
8874
+ var _a2;
8875
+ if ((_a2 = this.gsRenderer) == null ? void 0 : _a2.setSHMode) {
7331
8876
  this.gsRenderer.setSHMode(mode);
7332
8877
  }
7333
8878
  }
@@ -7335,8 +8880,8 @@ class SceneManager {
7335
8880
  * 获取当前 SH 模式
7336
8881
  */
7337
8882
  getSHMode() {
7338
- var _a, _b;
7339
- return ((_b = (_a = this.gsRenderer) == null ? void 0 : _a.getSHMode) == null ? void 0 : _b.call(_a)) ?? 0;
8883
+ var _a2, _b2;
8884
+ return ((_b2 = (_a2 = this.gsRenderer) == null ? void 0 : _a2.getSHMode) == null ? void 0 : _b2.call(_a2)) ?? 0;
7340
8885
  }
7341
8886
  // ============================================
7342
8887
  // Bounding Box 查询
@@ -7344,15 +8889,15 @@ class SceneManager {
7344
8889
  /**
7345
8890
  * 获取 Mesh 的组合 bounding box
7346
8891
  */
7347
- getMeshBoundingBox() {
8892
+ getBoundingBox() {
7348
8893
  return this.meshRenderer.getCombinedBoundingBox();
7349
8894
  }
7350
8895
  /**
7351
8896
  * 获取 Splat 的 bounding box
7352
8897
  */
7353
8898
  getSplatBoundingBox() {
7354
- var _a;
7355
- return ((_a = this.gsRenderer) == null ? void 0 : _a.getBoundingBox()) ?? null;
8899
+ var _a2;
8900
+ return ((_a2 = this.gsRenderer) == null ? void 0 : _a2.getBoundingBox()) ?? null;
7356
8901
  }
7357
8902
  /**
7358
8903
  * 获取指定 Mesh 范围的组合 bounding box
@@ -7395,16 +8940,16 @@ class SceneManager {
7395
8940
  getSceneBoundingBox() {
7396
8941
  let combinedMin = null;
7397
8942
  let combinedMax = null;
7398
- const meshBBox = this.getMeshBoundingBox();
8943
+ const meshBBox = this.getBoundingBox();
7399
8944
  if (meshBBox) {
7400
- combinedMin = [...meshBBox.min];
7401
- combinedMax = [...meshBBox.max];
8945
+ combinedMin = [meshBBox.min[0], meshBBox.min[1], meshBBox.min[2]];
8946
+ combinedMax = [meshBBox.max[0], meshBBox.max[1], meshBBox.max[2]];
7402
8947
  }
7403
8948
  const splatBBox = this.getSplatBoundingBox();
7404
8949
  if (splatBBox) {
7405
8950
  if (combinedMin === null || combinedMax === null) {
7406
- combinedMin = [...splatBBox.min];
7407
- combinedMax = [...splatBBox.max];
8951
+ combinedMin = [splatBBox.min[0], splatBBox.min[1], splatBBox.min[2]];
8952
+ combinedMax = [splatBBox.max[0], splatBBox.max[1], splatBBox.max[2]];
7408
8953
  } else {
7409
8954
  combinedMin[0] = Math.min(combinedMin[0], splatBBox.min[0]);
7410
8955
  combinedMin[1] = Math.min(combinedMin[1], splatBBox.min[1]);
@@ -9119,7 +10664,7 @@ class ArcShape extends Shape {
9119
10664
  const dynRotY = this._dynamicRotation.y * Math.PI / 180;
9120
10665
  const dynRotZ = this._dynamicRotation.z * Math.PI / 180;
9121
10666
  const dynQuat = Quat.fromEuler(dynRotX, dynRotY, dynRotZ);
9122
- const finalQuat = baseQuat.multiply(dynQuat);
10667
+ const finalQuat = dynQuat.multiply(baseQuat);
9123
10668
  return Mat4.compose(this._position, finalQuat, this._scale);
9124
10669
  }
9125
10670
  /**
@@ -9614,7 +11159,7 @@ const RING_FACING_EPSILON = 1e-4;
9614
11159
  const PERS_SCALE_RATIO = 0.3;
9615
11160
  const MIN_SCALE = 1e-4;
9616
11161
  const RAD_TO_DEG = 180 / Math.PI;
9617
- class TransformGizmoV2 {
11162
+ class TransformGizmo {
9618
11163
  constructor(config) {
9619
11164
  __publicField(this, "renderer");
9620
11165
  __publicField(this, "camera");
@@ -10274,8 +11819,8 @@ class TransformGizmoV2 {
10274
11819
  const forward = gizmoRot.transformVector(new Vec3(0, 0, 1));
10275
11820
  const xArc = this._shapes.get("x");
10276
11821
  if (xArc) {
10277
- const angle = Math.atan2(localFacingDir.z, localFacingDir.y) * RAD_TO_DEG;
10278
- xArc.setDynamicRotation(new Vec3(0, angle - 90, 0));
11822
+ const angle = -Math.atan2(localFacingDir.y, localFacingDir.z) * RAD_TO_DEG;
11823
+ xArc.setDynamicRotation(new Vec3(angle, 0, 0));
10279
11824
  const dot = facingDir.dot(right);
10280
11825
  if (!this._dragging) {
10281
11826
  const showSector = 1 - Math.abs(dot) > RING_FACING_EPSILON;
@@ -10490,7 +12035,7 @@ class TransformGizmoV2 {
10490
12035
  }
10491
12036
  }
10492
12037
  _updateHover(axis, isPlane) {
10493
- var _a, _b, _c, _d, _e, _f, _g;
12038
+ var _a2, _b2, _c, _d, _e, _f, _g;
10494
12039
  if (this._dragging) return;
10495
12040
  if (this._hoverAxis !== axis) {
10496
12041
  for (const shape of this._shapes.values()) {
@@ -10500,8 +12045,8 @@ class TransformGizmoV2 {
10500
12045
  this._hoverIsPlane = isPlane;
10501
12046
  if (axis) {
10502
12047
  if (axis === "xyz") {
10503
- (_a = this._shapes.get("x")) == null ? void 0 : _a.hover(true);
10504
- (_b = this._shapes.get("y")) == null ? void 0 : _b.hover(true);
12048
+ (_a2 = this._shapes.get("x")) == null ? void 0 : _a2.hover(true);
12049
+ (_b2 = this._shapes.get("y")) == null ? void 0 : _b2.hover(true);
10505
12050
  (_c = this._shapes.get("z")) == null ? void 0 : _c.hover(true);
10506
12051
  (_d = this._shapes.get("xyz")) == null ? void 0 : _d.hover(true);
10507
12052
  } else if (axis === "f") {
@@ -10835,7 +12380,7 @@ class GizmoManager {
10835
12380
  this.canvas = canvas;
10836
12381
  this.controls = controls;
10837
12382
  this.viewportGizmo = new ViewportGizmo(renderer, camera, canvas);
10838
- this.transformGizmo = new TransformGizmoV2({ renderer, camera, canvas });
12383
+ this.transformGizmo = new TransformGizmo({ renderer, camera, canvas });
10839
12384
  this.transformGizmo.init();
10840
12385
  this.boundingBoxRenderer = new BoundingBoxRenderer(renderer, camera);
10841
12386
  this.transformGizmo.setOnDragStateChange((isDragging) => {
@@ -10975,6 +12520,946 @@ class GizmoManager {
10975
12520
  this.boundingBoxRenderer.destroy();
10976
12521
  }
10977
12522
  }
12523
+ class HotspotManager {
12524
+ constructor(renderer, camera, canvas, controls, meshRenderer) {
12525
+ __publicField(this, "camera");
12526
+ __publicField(this, "renderer");
12527
+ __publicField(this, "controls");
12528
+ __publicField(this, "meshRenderer");
12529
+ __publicField(this, "objLoader");
12530
+ __publicField(this, "canvas");
12531
+ __publicField(this, "active", false);
12532
+ __publicField(this, "gsRenderer", null);
12533
+ // 圆圈指示器
12534
+ __publicField(this, "indicatorMesh", null);
12535
+ __publicField(this, "indicatorAdded", false);
12536
+ // 已放置的热点
12537
+ __publicField(this, "hotspots", []);
12538
+ // 当前命中点缓存
12539
+ __publicField(this, "currentHit", null);
12540
+ // 指示器变换状态缓存(点击时用于热点放置)
12541
+ __publicField(this, "indicatorTransform", null);
12542
+ // OBJ 模型 URL
12543
+ __publicField(this, "hotspotOBJUrl", "");
12544
+ // 事件处理函数引用
12545
+ __publicField(this, "boundOnMouseMove");
12546
+ __publicField(this, "boundOnMouseDown");
12547
+ __publicField(this, "boundOnMouseUp");
12548
+ __publicField(this, "boundOnKeyDown");
12549
+ // 拖拽检测
12550
+ __publicField(this, "mouseDownPos", null);
12551
+ __publicField(this, "isDragging", false);
12552
+ __publicField(this, "DRAG_THRESHOLD", 5);
12553
+ // rAF 驱动的流畅更新
12554
+ __publicField(this, "pendingMouseX", 0);
12555
+ __publicField(this, "pendingMouseY", 0);
12556
+ __publicField(this, "hasPendingMove", false);
12557
+ __publicField(this, "rafId", 0);
12558
+ // 法线缓存:如果命中点没有大变化则复用上次法线
12559
+ __publicField(this, "lastHitIdx", -1);
12560
+ __publicField(this, "lastNormal", [0, 1, 0]);
12561
+ // 指示器半径(世界空间基准)
12562
+ __publicField(this, "indicatorBaseRadius", 0.02);
12563
+ // 搜索邻域的屏幕空间半径(像素)
12564
+ __publicField(this, "pickRadiusPx", 20);
12565
+ // 回调
12566
+ __publicField(this, "onHotspotPlaced", null);
12567
+ __publicField(this, "onModeChanged", null);
12568
+ // ============================================
12569
+ // 事件处理
12570
+ // ============================================
12571
+ // Current mouse pixel position in device pixels (for GPU readback request)
12572
+ __publicField(this, "pickPixelX", -1);
12573
+ __publicField(this, "pickPixelY", -1);
12574
+ // Last client coords for CPU fallback
12575
+ __publicField(this, "lastClientX", 0);
12576
+ __publicField(this, "lastClientY", 0);
12577
+ // True when mouse has moved since last consumeGPUResult that produced a result
12578
+ __publicField(this, "pickDirty", false);
12579
+ this.renderer = renderer;
12580
+ this.camera = camera;
12581
+ this.canvas = canvas;
12582
+ this.controls = controls;
12583
+ this.meshRenderer = meshRenderer;
12584
+ this.objLoader = new OBJLoader(renderer.device);
12585
+ this.boundOnMouseMove = this.onMouseMove.bind(this);
12586
+ this.boundOnMouseDown = this.onMouseDown.bind(this);
12587
+ this.boundOnMouseUp = this.onMouseUp.bind(this);
12588
+ this.boundOnKeyDown = this.onKeyDown.bind(this);
12589
+ }
12590
+ setGSRenderer(gsRenderer) {
12591
+ this.gsRenderer = gsRenderer;
12592
+ if (gsRenderer) {
12593
+ const bbox = gsRenderer.getBoundingBox();
12594
+ if (bbox) {
12595
+ this.indicatorBaseRadius = bbox.radius * 8e-3;
12596
+ }
12597
+ }
12598
+ }
12599
+ setHotspotOBJUrl(url) {
12600
+ this.hotspotOBJUrl = url;
12601
+ }
12602
+ enter() {
12603
+ var _a2;
12604
+ if (this.active) return;
12605
+ if (!this.gsRenderer || !this.gsRenderer.getCPUPositions()) {
12606
+ console.warn("HotspotManager: 没有可用的 GS 数据");
12607
+ return;
12608
+ }
12609
+ this.active = true;
12610
+ this.canvas.style.cursor = "none";
12611
+ this.createIndicatorMesh();
12612
+ this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
12613
+ this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
12614
+ this.canvas.addEventListener("mouseup", this.boundOnMouseUp);
12615
+ window.addEventListener("keydown", this.boundOnKeyDown);
12616
+ (_a2 = this.onModeChanged) == null ? void 0 : _a2.call(this, true);
12617
+ }
12618
+ exit() {
12619
+ var _a2;
12620
+ if (!this.active) return;
12621
+ this.active = false;
12622
+ this.canvas.style.cursor = "";
12623
+ this.canvas.removeEventListener("mousemove", this.boundOnMouseMove);
12624
+ this.canvas.removeEventListener("mousedown", this.boundOnMouseDown);
12625
+ this.canvas.removeEventListener("mouseup", this.boundOnMouseUp);
12626
+ window.removeEventListener("keydown", this.boundOnKeyDown);
12627
+ if (this.rafId) {
12628
+ cancelAnimationFrame(this.rafId);
12629
+ this.rafId = 0;
12630
+ }
12631
+ this.hasPendingMove = false;
12632
+ this.hideIndicator();
12633
+ this.currentHit = null;
12634
+ this.mouseDownPos = null;
12635
+ this.isDragging = false;
12636
+ this.lastHitIdx = -1;
12637
+ this.pickPixelX = -1;
12638
+ this.pickPixelY = -1;
12639
+ this.pickDirty = false;
12640
+ (_a2 = this.onModeChanged) == null ? void 0 : _a2.call(this, false);
12641
+ }
12642
+ isActive() {
12643
+ return this.active;
12644
+ }
12645
+ getHotspots() {
12646
+ return [...this.hotspots];
12647
+ }
12648
+ setOnHotspotPlaced(cb) {
12649
+ this.onHotspotPlaced = cb;
12650
+ }
12651
+ setOnModeChanged(cb) {
12652
+ this.onModeChanged = cb;
12653
+ }
12654
+ /**
12655
+ * Returns the current pick pixel coordinates in device pixels.
12656
+ * Called by the render loop to pass to prepareDepthNormalPass.
12657
+ * Returns [-1, -1] when no pick is needed.
12658
+ */
12659
+ getPickPixel() {
12660
+ return [this.pickPixelX, this.pickPixelY];
12661
+ }
12662
+ onMouseMove(e) {
12663
+ if (!this.active) return;
12664
+ if (this.mouseDownPos) {
12665
+ const dx = e.clientX - this.mouseDownPos.x;
12666
+ const dy = e.clientY - this.mouseDownPos.y;
12667
+ if (dx * dx + dy * dy > this.DRAG_THRESHOLD * this.DRAG_THRESHOLD) {
12668
+ this.isDragging = true;
12669
+ }
12670
+ }
12671
+ this.pendingMouseX = e.clientX;
12672
+ this.pendingMouseY = e.clientY;
12673
+ if (!this.hasPendingMove) {
12674
+ this.hasPendingMove = true;
12675
+ this.rafId = requestAnimationFrame(() => this.processMouseMove());
12676
+ }
12677
+ }
12678
+ processMouseMove() {
12679
+ this.hasPendingMove = false;
12680
+ if (!this.active) return;
12681
+ const rect = this.canvas.getBoundingClientRect();
12682
+ const dpr = this.canvas.width / rect.width;
12683
+ this.pickPixelX = (this.pendingMouseX - rect.left) * dpr;
12684
+ this.pickPixelY = (this.pendingMouseY - rect.top) * dpr;
12685
+ this.lastClientX = this.pendingMouseX;
12686
+ this.lastClientY = this.pendingMouseY;
12687
+ this.pickDirty = true;
12688
+ }
12689
+ /**
12690
+ * 读取上一帧的深度法线GPU回读结果,更新指示器
12691
+ * 每帧在App.Render()中调用
12692
+ * 如果GPU没有回读结果,则使用CPU拾取
12693
+ */
12694
+ consumeGPUResult() {
12695
+ var _a2;
12696
+ if (!this.active || !this.pickDirty) return;
12697
+ let hit = null;
12698
+ if ((_a2 = this.gsRenderer) == null ? void 0 : _a2.getDepthNormal) {
12699
+ const result = this.gsRenderer.getDepthNormal();
12700
+ if (result) {
12701
+ const dist = Math.sqrt(
12702
+ (result.worldPos[0] - this.camera.position[0]) ** 2 + (result.worldPos[1] - this.camera.position[1]) ** 2 + (result.worldPos[2] - this.camera.position[2]) ** 2
12703
+ );
12704
+ hit = { point: result.worldPos, normal: result.normal, distance: dist };
12705
+ }
12706
+ }
12707
+ if (!hit) {
12708
+ hit = this.raycastSplatsCPU(this.lastClientX, this.lastClientY);
12709
+ }
12710
+ this.pickDirty = false;
12711
+ this.currentHit = hit;
12712
+ if (hit) {
12713
+ this.updateIndicator(hit.point, hit.normal);
12714
+ this.showIndicator();
12715
+ } else {
12716
+ this.hideIndicator();
12717
+ }
12718
+ }
12719
+ onMouseDown(e) {
12720
+ if (!this.active || e.button !== 0) return;
12721
+ this.mouseDownPos = { x: e.clientX, y: e.clientY };
12722
+ this.isDragging = false;
12723
+ }
12724
+ onMouseUp(e) {
12725
+ if (!this.active || e.button !== 0) return;
12726
+ if (!this.isDragging && this.currentHit) {
12727
+ this.placeHotspot(this.currentHit.point, this.currentHit.normal);
12728
+ }
12729
+ this.mouseDownPos = null;
12730
+ this.isDragging = false;
12731
+ }
12732
+ onKeyDown(e) {
12733
+ if (e.key === "Escape") {
12734
+ this.exit();
12735
+ }
12736
+ }
12737
+ // ============================================
12738
+ // 射线拾取
12739
+ // ============================================
12740
+ raycastSplatsCPU(clientX, clientY) {
12741
+ var _a2;
12742
+ const positions = (_a2 = this.gsRenderer) == null ? void 0 : _a2.getCPUPositions();
12743
+ if (!positions) return null;
12744
+ const rect = this.canvas.getBoundingClientRect();
12745
+ const ndcX = (clientX - rect.left) / rect.width * 2 - 1;
12746
+ const ndcY = -((clientY - rect.top) / rect.height * 2 - 1);
12747
+ const rayOrigin = this.camera.position;
12748
+ const modelMatrix = this.gsRenderer.getModelMatrix();
12749
+ const vp = this.camera.viewProjectionMatrix;
12750
+ const count = positions.length / 3;
12751
+ const step = count > 3e5 ? Math.ceil(count / 3e5) : 1;
12752
+ const halfW = rect.width / 2;
12753
+ const halfH = rect.height / 2;
12754
+ const pickRadSq = this.pickRadiusPx * this.pickRadiusPx;
12755
+ let bestCamDistSq = Infinity;
12756
+ let bestIdx = -1;
12757
+ let bestScreenDistSq = Infinity;
12758
+ const m0 = modelMatrix[0], m1 = modelMatrix[1], m2 = modelMatrix[2];
12759
+ const m4 = modelMatrix[4], m5 = modelMatrix[5], m6 = modelMatrix[6];
12760
+ const m8 = modelMatrix[8], m9 = modelMatrix[9], m10 = modelMatrix[10];
12761
+ const m12 = modelMatrix[12], m13 = modelMatrix[13], m14 = modelMatrix[14];
12762
+ const v0 = vp[0], v1 = vp[1], v3 = vp[3];
12763
+ const v4 = vp[4], v5 = vp[5], v7 = vp[7];
12764
+ const v8 = vp[8], v9 = vp[9], v11 = vp[11];
12765
+ const v12 = vp[12], v13 = vp[13], v15 = vp[15];
12766
+ const rox = rayOrigin[0], roy = rayOrigin[1], roz = rayOrigin[2];
12767
+ for (let i = 0; i < count; i += step) {
12768
+ const i3 = i * 3;
12769
+ const lx = positions[i3], ly = positions[i3 + 1], lz = positions[i3 + 2];
12770
+ const tx = m0 * lx + m4 * ly + m8 * lz + m12;
12771
+ const ty = m1 * lx + m5 * ly + m9 * lz + m13;
12772
+ const tz = m2 * lx + m6 * ly + m10 * lz + m14;
12773
+ const cw = v3 * tx + v7 * ty + v11 * tz + v15;
12774
+ if (cw <= 0) continue;
12775
+ const invCw = 1 / cw;
12776
+ const pixX = ((v0 * tx + v4 * ty + v8 * tz + v12) * invCw - ndcX) * halfW;
12777
+ const pixY = ((v1 * tx + v5 * ty + v9 * tz + v13) * invCw - ndcY) * halfH;
12778
+ const screenDistSq = pixX * pixX + pixY * pixY;
12779
+ if (screenDistSq < pickRadSq) {
12780
+ const dx = tx - rox, dy = ty - roy, dz = tz - roz;
12781
+ const camDistSq = dx * dx + dy * dy + dz * dz;
12782
+ if (camDistSq < bestCamDistSq * 0.98 || camDistSq < bestCamDistSq * 1.02 && screenDistSq < bestScreenDistSq) {
12783
+ bestCamDistSq = camDistSq;
12784
+ bestScreenDistSq = screenDistSq;
12785
+ bestIdx = i;
12786
+ }
12787
+ }
12788
+ }
12789
+ if (bestIdx < 0) return null;
12790
+ const hx = positions[bestIdx * 3], hy = positions[bestIdx * 3 + 1], hz = positions[bestIdx * 3 + 2];
12791
+ const hitPoint = [
12792
+ m0 * hx + m4 * hy + m8 * hz + m12,
12793
+ m1 * hx + m5 * hy + m9 * hz + m13,
12794
+ m2 * hx + m6 * hy + m10 * hz + m14
12795
+ ];
12796
+ let normal;
12797
+ if (bestIdx === this.lastHitIdx) {
12798
+ normal = [...this.lastNormal];
12799
+ } else {
12800
+ normal = this.estimateNormal(positions, bestIdx, modelMatrix);
12801
+ this.lastHitIdx = bestIdx;
12802
+ this.lastNormal = [...normal];
12803
+ }
12804
+ const toCamX = rox - hitPoint[0], toCamY = roy - hitPoint[1], toCamZ = roz - hitPoint[2];
12805
+ if (normal[0] * toCamX + normal[1] * toCamY + normal[2] * toCamZ < 0) {
12806
+ normal[0] = -normal[0];
12807
+ normal[1] = -normal[1];
12808
+ normal[2] = -normal[2];
12809
+ }
12810
+ return { point: hitPoint, normal, distance: Math.sqrt(bestCamDistSq) };
12811
+ }
12812
+ // ============================================
12813
+ // 法线估计 (局部 PCA)
12814
+ // ============================================
12815
+ estimateNormal(positions, centerIdx, modelMatrix) {
12816
+ var _a2;
12817
+ const count = positions.length / 3;
12818
+ const m = modelMatrix;
12819
+ const cx = positions[centerIdx * 3 + 0];
12820
+ const cy = positions[centerIdx * 3 + 1];
12821
+ const cz = positions[centerIdx * 3 + 2];
12822
+ const wcx = m[0] * cx + m[4] * cy + m[8] * cz + m[12];
12823
+ const wcy = m[1] * cx + m[5] * cy + m[9] * cz + m[13];
12824
+ const wcz = m[2] * cx + m[6] * cy + m[10] * cz + m[14];
12825
+ const bbox = (_a2 = this.gsRenderer) == null ? void 0 : _a2.getBoundingBox();
12826
+ const searchRadius = bbox ? bbox.radius * 0.015 : 0.3;
12827
+ const searchRadiusSq = searchRadius * searchRadius;
12828
+ const neighbors = [];
12829
+ const maxNeighbors = 80;
12830
+ const step = count > 15e4 ? Math.ceil(count / 15e4) : 1;
12831
+ for (let i = 0; i < count; i += step) {
12832
+ const px = positions[i * 3 + 0];
12833
+ const py = positions[i * 3 + 1];
12834
+ const pz = positions[i * 3 + 2];
12835
+ const wpx = m[0] * px + m[4] * py + m[8] * pz + m[12];
12836
+ const wpy = m[1] * px + m[5] * py + m[9] * pz + m[13];
12837
+ const wpz = m[2] * px + m[6] * py + m[10] * pz + m[14];
12838
+ const dx = wpx - wcx;
12839
+ const dy = wpy - wcy;
12840
+ const dz = wpz - wcz;
12841
+ const distSq = dx * dx + dy * dy + dz * dz;
12842
+ if (distSq < searchRadiusSq && distSq > 1e-12) {
12843
+ neighbors.push({ pos: [wpx, wpy, wpz], distSq });
12844
+ }
12845
+ }
12846
+ if (neighbors.length < 6) {
12847
+ const bigRadiusSq = searchRadiusSq * 4;
12848
+ neighbors.length = 0;
12849
+ for (let i = 0; i < count; i += step) {
12850
+ const px = positions[i * 3 + 0];
12851
+ const py = positions[i * 3 + 1];
12852
+ const pz = positions[i * 3 + 2];
12853
+ const wpx = m[0] * px + m[4] * py + m[8] * pz + m[12];
12854
+ const wpy = m[1] * px + m[5] * py + m[9] * pz + m[13];
12855
+ const wpz = m[2] * px + m[6] * py + m[10] * pz + m[14];
12856
+ const dx = wpx - wcx;
12857
+ const dy = wpy - wcy;
12858
+ const dz = wpz - wcz;
12859
+ const distSq = dx * dx + dy * dy + dz * dz;
12860
+ if (distSq < bigRadiusSq && distSq > 1e-12) {
12861
+ neighbors.push({ pos: [wpx, wpy, wpz], distSq });
12862
+ }
12863
+ }
12864
+ }
12865
+ if (neighbors.length < 3) return [0, 1, 0];
12866
+ if (neighbors.length > maxNeighbors) {
12867
+ neighbors.sort((a, b) => a.distSq - b.distSq);
12868
+ neighbors.length = maxNeighbors;
12869
+ }
12870
+ const roughNormal = this.weightedPCA(neighbors, wcx, wcy, wcz);
12871
+ const [rnx, rny, rnz] = roughNormal;
12872
+ const projections = [];
12873
+ for (const nb of neighbors) {
12874
+ const d = (nb.pos[0] - wcx) * rnx + (nb.pos[1] - wcy) * rny + (nb.pos[2] - wcz) * rnz;
12875
+ projections.push(d);
12876
+ }
12877
+ const absDev = projections.map((d) => Math.abs(d));
12878
+ absDev.sort((a, b) => a - b);
12879
+ const mad = absDev[Math.floor(absDev.length / 2)] || 1e-8;
12880
+ const inlierThreshold = Math.max(mad * 3, searchRadius * 0.05);
12881
+ const inliers = neighbors.filter(
12882
+ (_, i) => Math.abs(projections[i]) < inlierThreshold
12883
+ );
12884
+ if (inliers.length < 3) return roughNormal;
12885
+ return this.weightedPCA(inliers, wcx, wcy, wcz);
12886
+ }
12887
+ /**
12888
+ * 距离加权 PCA:离中心越近的点权重越高
12889
+ */
12890
+ weightedPCA(neighbors, cx, cy, cz) {
12891
+ let totalW = 0;
12892
+ let mx = 0, my = 0, mz = 0;
12893
+ for (const nb of neighbors) {
12894
+ const w = 1 / (nb.distSq + 1e-10);
12895
+ mx += nb.pos[0] * w;
12896
+ my += nb.pos[1] * w;
12897
+ mz += nb.pos[2] * w;
12898
+ totalW += w;
12899
+ }
12900
+ mx /= totalW;
12901
+ my /= totalW;
12902
+ mz /= totalW;
12903
+ let c00 = 0, c01 = 0, c02 = 0;
12904
+ let c11 = 0, c12 = 0, c22 = 0;
12905
+ for (const nb of neighbors) {
12906
+ const w = 1 / (nb.distSq + 1e-10);
12907
+ const dx = nb.pos[0] - mx;
12908
+ const dy = nb.pos[1] - my;
12909
+ const dz = nb.pos[2] - mz;
12910
+ c00 += w * dx * dx;
12911
+ c01 += w * dx * dy;
12912
+ c02 += w * dx * dz;
12913
+ c11 += w * dy * dy;
12914
+ c12 += w * dy * dz;
12915
+ c22 += w * dz * dz;
12916
+ }
12917
+ c00 /= totalW;
12918
+ c01 /= totalW;
12919
+ c02 /= totalW;
12920
+ c11 /= totalW;
12921
+ c12 /= totalW;
12922
+ c22 /= totalW;
12923
+ return this.smallestEigenvector(c00, c01, c02, c11, c12, c22);
12924
+ }
12925
+ /**
12926
+ * 求 3x3 对称矩阵最小特征值对应的特征向量(偏移幂迭代)
12927
+ */
12928
+ smallestEigenvector(a00, a01, a02, a11, a12, a22) {
12929
+ let vx = 0.577, vy = 0.577, vz = 0.577;
12930
+ for (let iter = 0; iter < 30; iter++) {
12931
+ const nx = a00 * vx + a01 * vy + a02 * vz;
12932
+ const ny = a01 * vx + a11 * vy + a12 * vz;
12933
+ const nz = a02 * vx + a12 * vy + a22 * vz;
12934
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
12935
+ if (len < 1e-12) return [0, 1, 0];
12936
+ vx = nx / len;
12937
+ vy = ny / len;
12938
+ vz = nz / len;
12939
+ }
12940
+ const lambdaMax = a00 * vx * vx + a11 * vy * vy + a22 * vz * vz + 2 * a01 * vx * vy + 2 * a02 * vx * vz + 2 * a12 * vy * vz;
12941
+ const b00 = lambdaMax - a00;
12942
+ const b01 = -a01;
12943
+ const b02 = -a02;
12944
+ const b11 = lambdaMax - a11;
12945
+ const b12 = -a12;
12946
+ const b22 = lambdaMax - a22;
12947
+ vx = 0.577;
12948
+ vy = 0.577;
12949
+ vz = 0.577;
12950
+ for (let iter = 0; iter < 30; iter++) {
12951
+ const nx = b00 * vx + b01 * vy + b02 * vz;
12952
+ const ny = b01 * vx + b11 * vy + b12 * vz;
12953
+ const nz = b02 * vx + b12 * vy + b22 * vz;
12954
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
12955
+ if (len < 1e-12) return [0, 1, 0];
12956
+ vx = nx / len;
12957
+ vy = ny / len;
12958
+ vz = nz / len;
12959
+ }
12960
+ return [vx, vy, vz];
12961
+ }
12962
+ // ============================================
12963
+ // 圆圈指示器
12964
+ // ============================================
12965
+ createIndicatorMesh() {
12966
+ if (this.indicatorMesh) return;
12967
+ const device = this.renderer.device;
12968
+ const segments = 48;
12969
+ const outerRadius = this.indicatorBaseRadius;
12970
+ const innerRadius = outerRadius * 0.92;
12971
+ const vertexCount = segments * 2;
12972
+ const vertexData = new Float32Array(vertexCount * 6);
12973
+ for (let i = 0; i < segments; i++) {
12974
+ const angle = i / segments * Math.PI * 2;
12975
+ const cos = Math.cos(angle);
12976
+ const sin = Math.sin(angle);
12977
+ const oi = i * 2 * 6;
12978
+ vertexData[oi] = cos * outerRadius;
12979
+ vertexData[oi + 1] = sin * outerRadius;
12980
+ vertexData[oi + 2] = 0;
12981
+ vertexData[oi + 3] = 0;
12982
+ vertexData[oi + 4] = 0;
12983
+ vertexData[oi + 5] = 1;
12984
+ const ii = (i * 2 + 1) * 6;
12985
+ vertexData[ii] = cos * innerRadius;
12986
+ vertexData[ii + 1] = sin * innerRadius;
12987
+ vertexData[ii + 2] = 0;
12988
+ vertexData[ii + 3] = 0;
12989
+ vertexData[ii + 4] = 0;
12990
+ vertexData[ii + 5] = 1;
12991
+ }
12992
+ const indices = [];
12993
+ for (let i = 0; i < segments; i++) {
12994
+ const o0 = i * 2, i0 = i * 2 + 1;
12995
+ const o1 = (i + 1) % segments * 2, i1 = (i + 1) % segments * 2 + 1;
12996
+ indices.push(o0, o1, i0);
12997
+ indices.push(i0, o1, i1);
12998
+ }
12999
+ const vertexBuffer = device.createBuffer({
13000
+ size: vertexData.byteLength,
13001
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
13002
+ });
13003
+ device.queue.writeBuffer(vertexBuffer, 0, vertexData);
13004
+ const indexData = new Uint16Array(indices);
13005
+ const alignedSize = Math.ceil(indexData.byteLength / 4) * 4;
13006
+ const alignedBuf = new Uint8Array(alignedSize);
13007
+ alignedBuf.set(
13008
+ new Uint8Array(
13009
+ indexData.buffer,
13010
+ indexData.byteOffset,
13011
+ indexData.byteLength
13012
+ )
13013
+ );
13014
+ const indexBuffer = device.createBuffer({
13015
+ size: alignedSize,
13016
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST
13017
+ });
13018
+ device.queue.writeBuffer(indexBuffer, 0, alignedBuf);
13019
+ const bbox = {
13020
+ min: [-outerRadius, -outerRadius, 0],
13021
+ max: [outerRadius, outerRadius, 0],
13022
+ center: [0, 0, 0],
13023
+ radius: outerRadius
13024
+ };
13025
+ this.indicatorMesh = new Mesh(
13026
+ vertexBuffer,
13027
+ vertexCount,
13028
+ indexBuffer,
13029
+ indices.length,
13030
+ bbox
13031
+ );
13032
+ this.indicatorMesh.hasUV = false;
13033
+ this.indicatorMesh.indexFormat = "uint16";
13034
+ }
13035
+ showIndicator() {
13036
+ if (!this.indicatorMesh || this.indicatorAdded) return;
13037
+ const material = {
13038
+ baseColorFactor: [1, 1, 1, 0.85],
13039
+ baseColorTexture: null,
13040
+ metallicFactor: 0,
13041
+ roughnessFactor: 1,
13042
+ doubleSided: true
13043
+ };
13044
+ this.meshRenderer.addOverlayMesh(this.indicatorMesh, material);
13045
+ this.indicatorAdded = true;
13046
+ }
13047
+ hideIndicator() {
13048
+ if (!this.indicatorMesh || !this.indicatorAdded) return;
13049
+ this.meshRenderer.detachOverlayMesh(this.indicatorMesh);
13050
+ this.indicatorAdded = false;
13051
+ }
13052
+ updateIndicator(point, normal) {
13053
+ if (!this.indicatorMesh) return;
13054
+ const camPos = this.camera.position;
13055
+ const dist = Math.sqrt(
13056
+ (point[0] - camPos[0]) ** 2 + (point[1] - camPos[1]) ** 2 + (point[2] - camPos[2]) ** 2
13057
+ );
13058
+ const targetScreenPx = 40;
13059
+ const halfVFov = this.camera.fov / 2;
13060
+ const halfHFov = Math.atan(Math.tan(halfVFov) * this.camera.aspect);
13061
+ const visualRadius = dist * Math.tan(halfHFov) * (targetScreenPx / this.canvas.clientWidth);
13062
+ const [nx, ny, nz] = normal;
13063
+ const up = Math.abs(ny) < 0.99 ? [0, 1, 0] : [1, 0, 0];
13064
+ let rx = up[1] * nz - up[2] * ny;
13065
+ let ry = up[2] * nx - up[0] * nz;
13066
+ let rz = up[0] * ny - up[1] * nx;
13067
+ let rLen = Math.sqrt(rx * rx + ry * ry + rz * rz);
13068
+ if (rLen < 1e-8) {
13069
+ rx = 1;
13070
+ ry = 0;
13071
+ rz = 0;
13072
+ rLen = 1;
13073
+ }
13074
+ rx /= rLen;
13075
+ ry /= rLen;
13076
+ rz /= rLen;
13077
+ let fx = ny * rz - nz * ry;
13078
+ let fy = nz * rx - nx * rz;
13079
+ let fz = nx * ry - ny * rx;
13080
+ let fLen = Math.sqrt(fx * fx + fy * fy + fz * fz);
13081
+ if (fLen < 1e-8) {
13082
+ fx = 0;
13083
+ fy = 0;
13084
+ fz = 1;
13085
+ fLen = 1;
13086
+ }
13087
+ fx /= fLen;
13088
+ fy /= fLen;
13089
+ fz /= fLen;
13090
+ const scale = visualRadius / this.indicatorBaseRadius;
13091
+ const m = this.indicatorMesh.modelMatrix;
13092
+ m[0] = rx * scale;
13093
+ m[1] = ry * scale;
13094
+ m[2] = rz * scale;
13095
+ m[3] = 0;
13096
+ m[4] = fx * scale;
13097
+ m[5] = fy * scale;
13098
+ m[6] = fz * scale;
13099
+ m[7] = 0;
13100
+ m[8] = nx * scale;
13101
+ m[9] = ny * scale;
13102
+ m[10] = nz * scale;
13103
+ m[11] = 0;
13104
+ const normalOffset = dist * 0.02;
13105
+ m[12] = point[0] + nx * normalOffset;
13106
+ m[13] = point[1] + ny * normalOffset;
13107
+ m[14] = point[2] + nz * normalOffset;
13108
+ m[15] = 1;
13109
+ this.indicatorTransform = {
13110
+ point: [...point],
13111
+ normal: [nx, ny, nz],
13112
+ right: [rx, ry, rz],
13113
+ forward: [fx, fy, fz],
13114
+ visualDiameter: visualRadius * 2,
13115
+ normalOffset
13116
+ };
13117
+ }
13118
+ // ============================================
13119
+ // OBJ 热点放置
13120
+ // ============================================
13121
+ async placeHotspot(point, normal) {
13122
+ var _a2;
13123
+ if (!this.hotspotOBJUrl) {
13124
+ console.warn("HotspotManager: 未设置热点 OBJ 模型 URL");
13125
+ return;
13126
+ }
13127
+ const snap = this.indicatorTransform;
13128
+ if (!snap) {
13129
+ console.warn("HotspotManager: 无指示器状态,跳过放置");
13130
+ return;
13131
+ }
13132
+ this.hideIndicator();
13133
+ try {
13134
+ const loadedMeshes = await this.objLoader.load(this.hotspotOBJUrl);
13135
+ if (loadedMeshes.length === 0) {
13136
+ console.warn("HotspotManager: OBJ 加载无网格");
13137
+ return;
13138
+ }
13139
+ const meshStartIndex = this.meshRenderer.getMeshCount();
13140
+ let placedScale = 1;
13141
+ let placedLocalCenter = [0, 0, 0];
13142
+ const firstBbox = loadedMeshes[0].mesh.getLocalBoundingBox();
13143
+ if (firstBbox) {
13144
+ const modelW = firstBbox.max[0] - firstBbox.min[0];
13145
+ const modelH = firstBbox.max[1] - firstBbox.min[1];
13146
+ const modelD = firstBbox.max[2] - firstBbox.min[2];
13147
+ const modelMaxDim = Math.max(modelW, modelH, modelD, 1e-6);
13148
+ placedScale = snap.visualDiameter / modelMaxDim;
13149
+ placedLocalCenter = [
13150
+ (firstBbox.min[0] + firstBbox.max[0]) / 2,
13151
+ (firstBbox.min[1] + firstBbox.max[1]) / 2,
13152
+ (firstBbox.min[2] + firstBbox.max[2]) / 2
13153
+ ];
13154
+ }
13155
+ for (const { mesh, material } of loadedMeshes) {
13156
+ this.applyIndicatorTransform(mesh, snap);
13157
+ this.meshRenderer.addOverlayMesh(mesh, material);
13158
+ }
13159
+ const info = {
13160
+ position: [...snap.point],
13161
+ normal: [...snap.normal],
13162
+ meshStartIndex,
13163
+ meshCount: loadedMeshes.length,
13164
+ billboard: false,
13165
+ placedScale,
13166
+ placedNormalOffset: snap.normalOffset,
13167
+ placedLocalCenter
13168
+ };
13169
+ this.hotspots.push(info);
13170
+ (_a2 = this.onHotspotPlaced) == null ? void 0 : _a2.call(this, info);
13171
+ console.log(
13172
+ `热点已放置: pos=(${snap.point[0].toFixed(3)}, ${snap.point[1].toFixed(3)}, ${snap.point[2].toFixed(3)}) normal=(${snap.normal[0].toFixed(3)}, ${snap.normal[1].toFixed(3)}, ${snap.normal[2].toFixed(3)})`
13173
+ );
13174
+ } catch (error) {
13175
+ console.error("HotspotManager: 放置热点失败:", error);
13176
+ }
13177
+ }
13178
+ /**
13179
+ * 将 OBJ mesh 的变换设置为与指示器完全一致的大小、位置、朝向。
13180
+ *
13181
+ * 使用指示器缓存的基向量 (right, forward, normal) 和 visualDiameter,
13182
+ * 将 OBJ 缩放到与指示器圆圈等大,居中放置在相同位置。
13183
+ */
13184
+ applyIndicatorTransform(mesh, snap) {
13185
+ const { point, normal, right, forward, visualDiameter, normalOffset } = snap;
13186
+ const [nx, ny, nz] = normal;
13187
+ const [rx, ry, rz] = right;
13188
+ const [fx, fy, fz] = forward;
13189
+ const bbox = mesh.getLocalBoundingBox();
13190
+ let objScale = 1;
13191
+ if (bbox) {
13192
+ const modelW = bbox.max[0] - bbox.min[0];
13193
+ const modelH = bbox.max[1] - bbox.min[1];
13194
+ const modelD = bbox.max[2] - bbox.min[2];
13195
+ const modelMaxDim = Math.max(modelW, modelH, modelD, 1e-6);
13196
+ objScale = visualDiameter / modelMaxDim;
13197
+ }
13198
+ let lcx = 0, lcy = 0, lcz = 0;
13199
+ if (bbox) {
13200
+ lcx = (bbox.min[0] + bbox.max[0]) / 2;
13201
+ lcy = (bbox.min[1] + bbox.max[1]) / 2;
13202
+ lcz = (bbox.min[2] + bbox.max[2]) / 2;
13203
+ }
13204
+ const owx = (rx * lcx + fx * lcy + nx * lcz) * objScale;
13205
+ const owy = (ry * lcx + fy * lcy + ny * lcz) * objScale;
13206
+ const owz = (rz * lcx + fz * lcy + nz * lcz) * objScale;
13207
+ const m = mesh.modelMatrix;
13208
+ m[0] = rx * objScale;
13209
+ m[1] = ry * objScale;
13210
+ m[2] = rz * objScale;
13211
+ m[3] = 0;
13212
+ m[4] = fx * objScale;
13213
+ m[5] = fy * objScale;
13214
+ m[6] = fz * objScale;
13215
+ m[7] = 0;
13216
+ m[8] = nx * objScale;
13217
+ m[9] = ny * objScale;
13218
+ m[10] = nz * objScale;
13219
+ m[11] = 0;
13220
+ m[12] = point[0] + nx * normalOffset - owx;
13221
+ m[13] = point[1] + ny * normalOffset - owy;
13222
+ m[14] = point[2] + nz * normalOffset - owz;
13223
+ m[15] = 1;
13224
+ this.decomposeModelMatrix(mesh);
13225
+ }
13226
+ /**
13227
+ * 从 modelMatrix 反向提取 position / rotation / scale,
13228
+ * 使 Mesh 的分离属性与矩阵保持同步(Gizmo 需要)。
13229
+ */
13230
+ decomposeModelMatrix(mesh) {
13231
+ const m = mesh.modelMatrix;
13232
+ mesh.position[0] = m[12];
13233
+ mesh.position[1] = m[13];
13234
+ mesh.position[2] = m[14];
13235
+ const sx = Math.sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]);
13236
+ const sy = Math.sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]);
13237
+ const sz = Math.sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10]);
13238
+ mesh.scale[0] = sx;
13239
+ mesh.scale[1] = sy;
13240
+ mesh.scale[2] = sz;
13241
+ const isx = sx > 1e-8 ? 1 / sx : 0;
13242
+ const isy = sy > 1e-8 ? 1 / sy : 0;
13243
+ const isz = sz > 1e-8 ? 1 / sz : 0;
13244
+ const r00 = m[0] * isx, r10 = m[1] * isx, r20 = m[2] * isx;
13245
+ const r21 = m[6] * isy;
13246
+ const r22 = m[10] * isz;
13247
+ const sinRy = -r20;
13248
+ const ry = Math.asin(Math.max(-1, Math.min(1, sinRy)));
13249
+ const cosRy = Math.cos(ry);
13250
+ let rotX, rotZ;
13251
+ if (Math.abs(cosRy) > 1e-6) {
13252
+ rotX = Math.atan2(r21, r22);
13253
+ rotZ = Math.atan2(r10, r00);
13254
+ } else {
13255
+ rotX = Math.atan2(-m[9] * isz, m[5] * isy);
13256
+ rotZ = 0;
13257
+ }
13258
+ mesh.rotation[0] = rotX;
13259
+ mesh.rotation[1] = ry;
13260
+ mesh.rotation[2] = rotZ;
13261
+ }
13262
+ // ============================================
13263
+ // Billboard 控制
13264
+ // ============================================
13265
+ /**
13266
+ * 设置指定热点的 billboard 模式
13267
+ * @param hotspotIndex 热点索引(在 hotspots 数组中的位置)
13268
+ * @param enabled 是否启用 billboard
13269
+ */
13270
+ setHotspotBillboard(hotspotIndex, enabled) {
13271
+ if (hotspotIndex < 0 || hotspotIndex >= this.hotspots.length) return false;
13272
+ const info = this.hotspots[hotspotIndex];
13273
+ if (info.billboard === enabled) return true;
13274
+ info.billboard = enabled;
13275
+ if (!enabled) {
13276
+ this.restoreHotspotOrientation(info);
13277
+ }
13278
+ return true;
13279
+ }
13280
+ getHotspotBillboard(hotspotIndex) {
13281
+ if (hotspotIndex < 0 || hotspotIndex >= this.hotspots.length) return false;
13282
+ return this.hotspots[hotspotIndex].billboard;
13283
+ }
13284
+ getHotspotCount() {
13285
+ return this.hotspots.length;
13286
+ }
13287
+ /**
13288
+ * 通过 overlay mesh 起始索引找到对应的热点索引
13289
+ */
13290
+ findHotspotIndexByMeshStart(overlayMeshStartIndex) {
13291
+ return this.hotspots.findIndex(
13292
+ (h) => h.meshStartIndex === overlayMeshStartIndex
13293
+ );
13294
+ }
13295
+ /**
13296
+ * 每帧调用:更新所有 billboard 热点朝向相机
13297
+ */
13298
+ updateBillboards() {
13299
+ const camPos = this.camera.position;
13300
+ for (const info of this.hotspots) {
13301
+ if (!info.billboard) continue;
13302
+ const {
13303
+ position,
13304
+ normal,
13305
+ placedScale,
13306
+ placedNormalOffset,
13307
+ placedLocalCenter
13308
+ } = info;
13309
+ const [nx, ny, nz] = normal;
13310
+ const wx = position[0] + nx * placedNormalOffset;
13311
+ const wy = position[1] + ny * placedNormalOffset;
13312
+ const wz = position[2] + nz * placedNormalOffset;
13313
+ let zx = camPos[0] - wx;
13314
+ let zy = camPos[1] - wy;
13315
+ let zz = camPos[2] - wz;
13316
+ const zLen = Math.sqrt(zx * zx + zy * zy + zz * zz);
13317
+ if (zLen < 1e-8) continue;
13318
+ zx /= zLen;
13319
+ zy /= zLen;
13320
+ zz /= zLen;
13321
+ const up = Math.abs(zy) < 0.99 ? [0, 1, 0] : [1, 0, 0];
13322
+ let rx = up[1] * zz - up[2] * zy;
13323
+ let ry = up[2] * zx - up[0] * zz;
13324
+ let rz = up[0] * zy - up[1] * zx;
13325
+ let rLen = Math.sqrt(rx * rx + ry * ry + rz * rz);
13326
+ if (rLen < 1e-8) {
13327
+ rx = 1;
13328
+ ry = 0;
13329
+ rz = 0;
13330
+ rLen = 1;
13331
+ }
13332
+ rx /= rLen;
13333
+ ry /= rLen;
13334
+ rz /= rLen;
13335
+ let fx = zy * rz - zz * ry;
13336
+ let fy = zz * rx - zx * rz;
13337
+ let fz = zx * ry - zy * rx;
13338
+ let fLen = Math.sqrt(fx * fx + fy * fy + fz * fz);
13339
+ if (fLen < 1e-8) {
13340
+ fx = 0;
13341
+ fy = 1;
13342
+ fz = 0;
13343
+ fLen = 1;
13344
+ }
13345
+ fx /= fLen;
13346
+ fy /= fLen;
13347
+ fz /= fLen;
13348
+ const s = placedScale;
13349
+ const [lcx, lcy, lcz] = placedLocalCenter;
13350
+ const owx = (rx * lcx + fx * lcy + zx * lcz) * s;
13351
+ const owy = (ry * lcx + fy * lcy + zy * lcz) * s;
13352
+ const owz = (rz * lcx + fz * lcy + zz * lcz) * s;
13353
+ for (let i = 0; i < info.meshCount; i++) {
13354
+ const mesh = this.meshRenderer.getOverlayMeshByIndex(
13355
+ info.meshStartIndex + i
13356
+ );
13357
+ if (!mesh) continue;
13358
+ const m = mesh.modelMatrix;
13359
+ m[0] = rx * s;
13360
+ m[1] = ry * s;
13361
+ m[2] = rz * s;
13362
+ m[3] = 0;
13363
+ m[4] = fx * s;
13364
+ m[5] = fy * s;
13365
+ m[6] = fz * s;
13366
+ m[7] = 0;
13367
+ m[8] = zx * s;
13368
+ m[9] = zy * s;
13369
+ m[10] = zz * s;
13370
+ m[11] = 0;
13371
+ m[12] = wx - owx;
13372
+ m[13] = wy - owy;
13373
+ m[14] = wz - owz;
13374
+ m[15] = 1;
13375
+ this.decomposeModelMatrix(mesh);
13376
+ }
13377
+ }
13378
+ }
13379
+ /**
13380
+ * 关闭 billboard 时恢复到放置时的法线朝向
13381
+ */
13382
+ restoreHotspotOrientation(info) {
13383
+ const {
13384
+ position,
13385
+ normal,
13386
+ placedScale,
13387
+ placedNormalOffset,
13388
+ placedLocalCenter
13389
+ } = info;
13390
+ const [nx, ny, nz] = normal;
13391
+ const up = Math.abs(ny) < 0.99 ? [0, 1, 0] : [1, 0, 0];
13392
+ let rx = up[1] * nz - up[2] * ny;
13393
+ let ry = up[2] * nx - up[0] * nz;
13394
+ let rz = up[0] * ny - up[1] * nx;
13395
+ let rLen = Math.sqrt(rx * rx + ry * ry + rz * rz);
13396
+ if (rLen < 1e-8) {
13397
+ rx = 1;
13398
+ ry = 0;
13399
+ rz = 0;
13400
+ rLen = 1;
13401
+ }
13402
+ rx /= rLen;
13403
+ ry /= rLen;
13404
+ rz /= rLen;
13405
+ let fx = ny * rz - nz * ry;
13406
+ let fy = nz * rx - nx * rz;
13407
+ let fz = nx * ry - ny * rx;
13408
+ let fLen = Math.sqrt(fx * fx + fy * fy + fz * fz);
13409
+ if (fLen < 1e-8) {
13410
+ fx = 0;
13411
+ fy = 0;
13412
+ fz = 1;
13413
+ fLen = 1;
13414
+ }
13415
+ fx /= fLen;
13416
+ fy /= fLen;
13417
+ fz /= fLen;
13418
+ const s = placedScale;
13419
+ const [lcx, lcy, lcz] = placedLocalCenter;
13420
+ const owx = (rx * lcx + fx * lcy + nx * lcz) * s;
13421
+ const owy = (ry * lcx + fy * lcy + ny * lcz) * s;
13422
+ const owz = (rz * lcx + fz * lcy + nz * lcz) * s;
13423
+ const wx = position[0] + nx * placedNormalOffset;
13424
+ const wy = position[1] + ny * placedNormalOffset;
13425
+ const wz = position[2] + nz * placedNormalOffset;
13426
+ for (let i = 0; i < info.meshCount; i++) {
13427
+ const mesh = this.meshRenderer.getOverlayMeshByIndex(
13428
+ info.meshStartIndex + i
13429
+ );
13430
+ if (!mesh) continue;
13431
+ const m = mesh.modelMatrix;
13432
+ m[0] = rx * s;
13433
+ m[1] = ry * s;
13434
+ m[2] = rz * s;
13435
+ m[3] = 0;
13436
+ m[4] = fx * s;
13437
+ m[5] = fy * s;
13438
+ m[6] = fz * s;
13439
+ m[7] = 0;
13440
+ m[8] = nx * s;
13441
+ m[9] = ny * s;
13442
+ m[10] = nz * s;
13443
+ m[11] = 0;
13444
+ m[12] = wx - owx;
13445
+ m[13] = wy - owy;
13446
+ m[14] = wz - owz;
13447
+ m[15] = 1;
13448
+ this.decomposeModelMatrix(mesh);
13449
+ }
13450
+ }
13451
+ // ============================================
13452
+ // 生命周期
13453
+ // ============================================
13454
+ destroy() {
13455
+ this.exit();
13456
+ if (this.indicatorMesh && !this.indicatorAdded) {
13457
+ this.indicatorMesh.destroy();
13458
+ }
13459
+ this.indicatorMesh = null;
13460
+ this.hotspots = [];
13461
+ }
13462
+ }
10978
13463
  class App {
10979
13464
  constructor(canvas) {
10980
13465
  __publicField(this, "canvas");
@@ -10987,6 +13472,7 @@ class App {
10987
13472
  // 子系统管理器
10988
13473
  __publicField(this, "sceneManager");
10989
13474
  __publicField(this, "gizmoManager");
13475
+ __publicField(this, "hotspotManager");
10990
13476
  __publicField(this, "isRunning", false);
10991
13477
  __publicField(this, "animationId", 0);
10992
13478
  // 是否使用移动端渲染器
@@ -11015,6 +13501,13 @@ class App {
11015
13501
  this.canvas,
11016
13502
  this.controls
11017
13503
  );
13504
+ this.hotspotManager = new HotspotManager(
13505
+ this.renderer,
13506
+ this.camera,
13507
+ this.canvas,
13508
+ this.controls,
13509
+ this.meshRenderer
13510
+ );
11018
13511
  window.addEventListener("resize", this.boundOnResize);
11019
13512
  }
11020
13513
  // ============================================
@@ -11088,6 +13581,7 @@ class App {
11088
13581
  gsRenderer.setCompactData(compactData);
11089
13582
  if (onProgress) onProgress(100, "upload");
11090
13583
  this.sceneManager.setGSRenderer(gsRenderer);
13584
+ this.hotspotManager.setGSRenderer(gsRenderer);
11091
13585
  return compactData.count;
11092
13586
  } else {
11093
13587
  gsRenderer = new GSSplatRenderer(this.renderer, this.camera);
@@ -11101,6 +13595,7 @@ class App {
11101
13595
  gsRenderer.setCompactData(compactData);
11102
13596
  if (onProgress) onProgress(100, "upload");
11103
13597
  this.sceneManager.setGSRenderer(gsRenderer);
13598
+ this.hotspotManager.setGSRenderer(gsRenderer);
11104
13599
  return compactData.count;
11105
13600
  }
11106
13601
  } catch (error) {
@@ -11132,6 +13627,7 @@ class App {
11132
13627
  const gsRenderer = new GSSplatRenderer(this.renderer, this.camera);
11133
13628
  gsRenderer.setData(splats);
11134
13629
  this.sceneManager.setGSRenderer(gsRenderer);
13630
+ this.hotspotManager.setGSRenderer(gsRenderer);
11135
13631
  this.useMobileRenderer = false;
11136
13632
  if (onProgress) onProgress(100, "upload");
11137
13633
  return splats.length;
@@ -11139,6 +13635,49 @@ class App {
11139
13635
  throw error;
11140
13636
  }
11141
13637
  }
13638
+ /**
13639
+ * 加载 SOG 文件 (Spatially Ordered Gaussians)
13640
+ */
13641
+ async addSOG(urlOrBuffer, onProgress, isLocalFile = false) {
13642
+ try {
13643
+ const isMobile = isMobileDevice();
13644
+ let buffer;
13645
+ if (typeof urlOrBuffer === "string") {
13646
+ buffer = await this.fetchWithProgress(urlOrBuffer, (downloadProgress) => {
13647
+ if (onProgress) {
13648
+ onProgress(downloadProgress * 0.5, "download");
13649
+ }
13650
+ });
13651
+ } else {
13652
+ buffer = urlOrBuffer;
13653
+ if (onProgress && isLocalFile) {
13654
+ onProgress(50, "download");
13655
+ }
13656
+ }
13657
+ if (onProgress) onProgress(50, "parse");
13658
+ const compactData = await deserializeSOG(buffer, (p, stage) => {
13659
+ if (onProgress) {
13660
+ onProgress(50 + p / 100 * 40, stage);
13661
+ }
13662
+ });
13663
+ if (onProgress) onProgress(90, "upload");
13664
+ let gsRenderer;
13665
+ if (isMobile) {
13666
+ gsRenderer = new GSSplatRendererMobile(this.renderer, this.camera);
13667
+ this.useMobileRenderer = true;
13668
+ } else {
13669
+ gsRenderer = new GSSplatRenderer(this.renderer, this.camera);
13670
+ this.useMobileRenderer = false;
13671
+ }
13672
+ gsRenderer.setCompactData(compactData);
13673
+ this.sceneManager.setGSRenderer(gsRenderer);
13674
+ this.hotspotManager.setGSRenderer(gsRenderer);
13675
+ if (onProgress) onProgress(100, "upload");
13676
+ return compactData.count;
13677
+ } catch (error) {
13678
+ throw error;
13679
+ }
13680
+ }
11142
13681
  /**
11143
13682
  * 添加测试立方体
11144
13683
  */
@@ -11181,7 +13720,8 @@ class App {
11181
13720
  }
11182
13721
  render() {
11183
13722
  this.camera.setAspect(this.renderer.getAspectRatio());
11184
- this.camera.updateMatrix();
13723
+ this.controls.update();
13724
+ this.hotspotManager.updateBillboards();
11185
13725
  const pass = this.renderer.beginFrame();
11186
13726
  const gsRenderer = this.sceneManager.getGSRenderer();
11187
13727
  if (gsRenderer) {
@@ -11190,6 +13730,11 @@ class App {
11190
13730
  this.meshRenderer.render(pass);
11191
13731
  this.gizmoManager.render(pass);
11192
13732
  this.renderer.endFrame();
13733
+ this.hotspotManager.consumeGPUResult();
13734
+ if (gsRenderer == null ? void 0 : gsRenderer.prepareDepthNormalPass) {
13735
+ const [px, py] = this.hotspotManager.getPickPixel();
13736
+ gsRenderer.prepareDepthNormalPass(px, py);
13737
+ }
11193
13738
  }
11194
13739
  onResize() {
11195
13740
  this.camera.setAspect(this.renderer.getAspectRatio());
@@ -11219,6 +13764,7 @@ class App {
11219
13764
  }
11220
13765
  clearSplats() {
11221
13766
  this.sceneManager.clearSplats();
13767
+ this.hotspotManager.setGSRenderer(null);
11222
13768
  this.useMobileRenderer = false;
11223
13769
  }
11224
13770
  // ============================================
@@ -11273,6 +13819,25 @@ class App {
11273
13819
  return this.sceneManager.setMeshRangeColor(startIndex, count, r, g, b, a);
11274
13820
  }
11275
13821
  // ============================================
13822
+ // 覆盖层 Mesh(热点等)
13823
+ // ============================================
13824
+ getOverlayMeshCount() {
13825
+ return this.sceneManager.getOverlayMeshCount();
13826
+ }
13827
+ removeOverlayMeshByIndex(index) {
13828
+ return this.sceneManager.removeOverlayMeshByIndex(index);
13829
+ }
13830
+ getOverlayMeshColor(index) {
13831
+ return this.sceneManager.getOverlayMeshColor(index);
13832
+ }
13833
+ setOverlayMeshRangeColor(startIndex, count, r, g, b, a = 1) {
13834
+ return this.sceneManager.setOverlayMeshRangeColor(startIndex, count, r, g, b, a);
13835
+ }
13836
+ createOverlayMeshGroupProxy(startIndex, count) {
13837
+ const meshes = this.sceneManager.getOverlayMeshRange(startIndex, count);
13838
+ return this.gizmoManager.createMeshGroupProxy(meshes);
13839
+ }
13840
+ // ============================================
11276
13841
  // 相机控制
11277
13842
  // ============================================
11278
13843
  frameCurrentModel(animate = true) {
@@ -11366,6 +13931,40 @@ class App {
11366
13931
  return this.useMobileRenderer;
11367
13932
  }
11368
13933
  // ============================================
13934
+ // 热点管理
13935
+ // ============================================
13936
+ getHotspotManager() {
13937
+ return this.hotspotManager;
13938
+ }
13939
+ enterHotspotMode(objUrl) {
13940
+ this.hotspotManager.setHotspotOBJUrl(objUrl);
13941
+ this.hotspotManager.enter();
13942
+ }
13943
+ exitHotspotMode() {
13944
+ this.hotspotManager.exit();
13945
+ }
13946
+ isHotspotModeActive() {
13947
+ return this.hotspotManager.isActive();
13948
+ }
13949
+ getHotspots() {
13950
+ return this.hotspotManager.getHotspots();
13951
+ }
13952
+ setHotspotBillboard(hotspotIndex, enabled) {
13953
+ return this.hotspotManager.setHotspotBillboard(hotspotIndex, enabled);
13954
+ }
13955
+ getHotspotBillboard(hotspotIndex) {
13956
+ return this.hotspotManager.getHotspotBillboard(hotspotIndex);
13957
+ }
13958
+ getHotspotCount() {
13959
+ return this.hotspotManager.getHotspotCount();
13960
+ }
13961
+ findHotspotIndexByMeshStart(overlayMeshStartIndex) {
13962
+ return this.hotspotManager.findHotspotIndexByMeshStart(overlayMeshStartIndex);
13963
+ }
13964
+ getOverlayMeshByIndex(index) {
13965
+ return this.sceneManager.getOverlayMeshByIndex(index);
13966
+ }
13967
+ // ============================================
11369
13968
  // 内部方法
11370
13969
  // ============================================
11371
13970
  async fetchWithProgress(url, onProgress) {
@@ -11413,6 +14012,7 @@ class App {
11413
14012
  window.removeEventListener("resize", this.boundOnResize);
11414
14013
  this.sceneManager.destroy();
11415
14014
  this.gizmoManager.destroy();
14015
+ this.hotspotManager.destroy();
11416
14016
  if (this.meshRenderer) {
11417
14017
  this.meshRenderer.destroy();
11418
14018
  }
@@ -11430,16 +14030,13 @@ exports.Camera = Camera;
11430
14030
  exports.DEFAULT_MATERIAL = DEFAULT_MATERIAL;
11431
14031
  exports.DEFAULT_OBJ_MATERIAL = DEFAULT_OBJ_MATERIAL;
11432
14032
  exports.GLBLoader = GLBLoader;
11433
- exports.GSSHMode = SHMode;
11434
14033
  exports.GSSplatRenderer = GSSplatRenderer;
11435
14034
  exports.GSSplatRendererMobile = GSSplatRendererMobile;
11436
14035
  exports.GSSplatSorter = GSSplatSorter;
11437
14036
  exports.GSSplatSorterMobile = GSSplatSorterMobile;
11438
14037
  exports.GizmoManager = GizmoManager;
11439
- exports.GizmoMeshGroupProxy = MeshGroupProxy;
11440
14038
  exports.GizmoMode = GizmoMode;
11441
- exports.GizmoSplatBoundingBoxProvider = SplatBoundingBoxProvider;
11442
- exports.GizmoSplatTransformProxy = SplatTransformProxy;
14039
+ exports.HotspotManager = HotspotManager;
11443
14040
  exports.MTLParser = MTLParser;
11444
14041
  exports.Mesh = Mesh;
11445
14042
  exports.MeshGroupProxy = MeshGroupProxy;
@@ -11453,7 +14050,7 @@ exports.SceneManager = SceneManager;
11453
14050
  exports.SplatBoundingBoxProvider = SplatBoundingBoxProvider;
11454
14051
  exports.SplatTransformProxy = SplatTransformProxy;
11455
14052
  exports.TextureCache = TextureCache;
11456
- exports.TransformGizmoV2 = TransformGizmoV2;
14053
+ exports.TransformGizmo = TransformGizmo;
11457
14054
  exports.ViewportGizmo = ViewportGizmo;
11458
14055
  exports.calculateTextureDimensions = calculateTextureDimensions;
11459
14056
  exports.compactDataToGPUBuffer = compactDataToGPUBuffer;
@@ -11461,6 +14058,7 @@ exports.compressSplatsToTextures = compressSplatsToTextures;
11461
14058
  exports.computeBoundingBox = computeBoundingBox$1;
11462
14059
  exports.createBoundingBoxFromMinMax = createBoundingBoxFromMinMax;
11463
14060
  exports.createTextureFromImageBitmap = createTextureFromImageBitmap;
14061
+ exports.deserializeSOG = deserializeSOG;
11464
14062
  exports.deserializeSplat = deserializeSplat;
11465
14063
  exports.destroyCompressedTextures = destroyCompressedTextures;
11466
14064
  exports.getRecommendedDPR = getRecommendedDPR;
@@ -11468,6 +14066,7 @@ exports.isMobileDevice = isMobileDevice;
11468
14066
  exports.isWebGPUSupported = isWebGPUSupported;
11469
14067
  exports.loadPLY = loadPLY;
11470
14068
  exports.loadPLYMobile = loadPLYMobile;
14069
+ exports.loadSOG = loadSOG;
11471
14070
  exports.loadSplat = loadSplat;
11472
14071
  exports.loadTextureFromBlob = loadTextureFromBlob;
11473
14072
  exports.loadTextureFromBuffer = loadTextureFromBuffer;