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