s3m-three 0.4.27 → 0.4.29

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/docs/API.md CHANGED
@@ -175,6 +175,7 @@ new S3MViewer(options?: S3MViewerOptions)
175
175
  | `lodRangeScale` | `number` | `1.0` | LOD 切换距离缩放系数,> 1 更倾向保留粗模,< 1 提前细化 |
176
176
  | `lodTransition` | `'binary' \| 'incremental'` | `'binary'` | LOD 细化过渡策略。`'binary'`(默认):整节点二值切换,所有子瓦片全部就绪或失败后整块替换;`'incremental'`:增量细化(primary / fallback 双列表 + LEQUAL 深度推移),子瓦片就绪一个显示一个,未就绪区域由最近父瓦片(fallback)垫底填充,消除整块跳变与慢 child 阻塞,极端角度接缝无缝衔接且不出洞 |
177
177
  | `fallbackOffset` | `{ factor?: number; units?: number }` | `{ factor: 1, units: 2 }` | 增量细化模式下 fallback 兜底瓦片的材质深度偏移系数,向远处推移深度配合 gl.LEQUAL 消除共面 z-fighting |
178
+ | `cullRefine` | `boolean` | `false` | 细化视锥剔除(第一期,Issue #25)。开启后视野外的瓦片不再请求与细化子瓦片,大幅减少转头与大范围场景下的无效下载、Worker 解码与显存上传。默认 `false`(不变式 ①,保持全向加载) |
178
179
  | `lodPixelRatioCap` | `number` | `Infinity` | LOD 判定屏幕高度的 **DPR 上限**(不截断)。默认按 `canvasHeight` 物理像素算,与官方 `calcPixFromCam` 同口径;3x DPR 手机上由此会多细化、多拉瓦片,传 `2` 即按「CSS 高度 × 2」截断 |
179
180
  | `anisotropyCap` | `number` | `Infinity` | 各向异性过滤上限(不封顶 = 取 GPU 最大值,通常 16)。移动端封到 4 / 8 肉眼通常无差,采样成本明显下降。**图层级**:运行时 `setAnisotropyCap()` 会立刻应用到已上传纹理(改采样参数 + 重传,不重解码、不重载瓦片),图集纹理恒为 1 |
180
181
  | `maxTextureSize` | `number` | `Infinity` | 纹理长边像素上限(不限制)。非图集的 RGBA / WebP 产物按 2× 盒式滤波减半到上限内;DXT 压缩直传与图集不受影响。**图层级**,但只影响之后解码的纹理(已解码的不重解) |
@@ -532,6 +533,8 @@ setLodRangeScale(scale: number, datasetId?: string): void
532
533
  readonly lodRangeScale: number
533
534
  readonly lodTransition: 'binary' | 'incremental'
534
535
  setLodTransition(mode: 'binary' | 'incremental'): void
536
+ setCullRefine(enabled: boolean): void // 细化视锥剔除开关,默认 false
537
+ readonly cullRefine: boolean
535
538
  setLodPixelRatioCap(cap: number): void // Infinity = 不截断(默认)
536
539
  readonly lodPixelRatioCap: number
537
540
  setMaxTriangles(n: number, datasetId?: string): void // Infinity = 不限制(默认)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3m-three",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -62,6 +62,7 @@
62
62
  */
63
63
  import { BinaryReader } from './BinaryReader.js';
64
64
  import type {
65
+ Geode,
65
66
  GeoPackageEntry,
66
67
  GroupNode,
67
68
  IndexPackage,
@@ -124,7 +125,13 @@ interface XmlMaterial {
124
125
  addressMode?: { u: number; v: number };
125
126
  }
126
127
 
127
- interface XmlLod {
128
+ export interface XmlGeode {
129
+ geoName: string;
130
+ matrix: number[];
131
+ }
132
+
133
+ export interface XmlLod {
134
+ geodes?: XmlGeode[];
128
135
  geoNames: string[];
129
136
  matrix: number[];
130
137
  childTile: string;
@@ -158,7 +165,7 @@ function rangeModeOf(text: string | null): number {
158
165
  return 1;
159
166
  }
160
167
 
161
- function parseXmlLods(xml: string): XmlLod[] {
168
+ export function parseXmlLods(xml: string): XmlLod[] {
162
169
  const blocks = [...xml.matchAll(/<(?:[A-Za-z0-9_-]+:)?PagedLOD[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_-]+:)?PagedLOD>/gi)];
163
170
  const out: XmlLod[] = [];
164
171
  for (const b of blocks) {
@@ -180,6 +187,39 @@ function parseXmlLods(xml: string): XmlLod[] {
180
187
  radius: num(tag(sphere, 'radius')),
181
188
  });
182
189
  }
190
+ // 兜底:S3M 1.x 叶子瓦片没有 <PagedLOD>,<PageLods> 内部直接是 <Geode>
191
+ if (out.length === 0) {
192
+ const pageLodsBlock = /<(?:[A-Za-z0-9_-]+:)?PageLods[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_-]+:)?PageLods>/i.exec(xml);
193
+ const scope = pageLodsBlock ? pageLodsBlock[1] : xml;
194
+ const geodeBlocks = [...scope.matchAll(/<(?:[A-Za-z0-9_-]+:)?Geode[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_-]+:)?Geode>/gi)];
195
+ if (geodeBlocks.length > 0) {
196
+ const geodes: XmlGeode[] = [];
197
+ for (const gb of geodeBlocks) {
198
+ const gs = gb[1];
199
+ const gName = tag(gs, "GeoName") ?? "";
200
+ const matStr = tag(gs, "GeoDeModMatrix");
201
+ const mat = (matStr ?? "1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1")
202
+ .split(",")
203
+ .map((x) => parseFloat(x.trim()))
204
+ .filter((x) => Number.isFinite(x));
205
+ geodes.push({
206
+ geoName: gName,
207
+ matrix: mat.length === 16 ? mat : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
208
+ });
209
+ }
210
+ out.push({
211
+ geoNames: geodes.map((g) => g.geoName).filter(Boolean),
212
+ matrix: geodes[0]?.matrix ?? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
213
+ geodes,
214
+ childTile: "",
215
+ rangeMode: rangeModeOf(null), // 1 = 屏幕像素
216
+ rangeValue: 0,
217
+ center: { x: 0, y: 0, z: 0 },
218
+ radius: 0,
219
+ });
220
+ }
221
+ }
222
+
183
223
  return out;
184
224
  }
185
225
 
@@ -645,6 +685,108 @@ function readTextures(br: BinaryReader, texSize: number): Record<string, S3MText
645
685
  let warnedV1Attrs = false;
646
686
  let warnedQuantBox = false;
647
687
 
688
+ function computeLodBoundingSphere(
689
+ geodes: { matrix: number[]; skeletonNames: string[] }[],
690
+ meshes: MeshRecord[]
691
+ ): { center: { x: number; y: number; z: number }; radius: number } | null {
692
+ const meshMap = new Map(meshes.map((m) => [`SuperMapCache#${m.geoName}`, m]));
693
+ let minX = Infinity, maxX = -Infinity;
694
+ let minY = Infinity, maxY = -Infinity;
695
+ let minZ = Infinity, maxZ = -Infinity;
696
+ let totalPoints = 0;
697
+
698
+ for (const geode of geodes) {
699
+ const mat = geode.matrix;
700
+ const isIdentity =
701
+ !mat ||
702
+ (mat[0] === 1 && mat[1] === 0 && mat[2] === 0 && mat[3] === 0 &&
703
+ mat[4] === 0 && mat[5] === 1 && mat[6] === 0 && mat[7] === 0 &&
704
+ mat[8] === 0 && mat[9] === 0 && mat[10] === 1 && mat[11] === 0 &&
705
+ mat[12] === 0 && mat[13] === 0 && mat[14] === 0 && mat[15] === 1);
706
+
707
+ for (const name of geode.skeletonNames) {
708
+ const mesh = meshMap.get(name);
709
+ if (!mesh || !mesh.position) continue;
710
+ const pos = mesh.position;
711
+ totalPoints += pos.length / 3;
712
+
713
+ if (isIdentity) {
714
+ for (let i = 0; i < pos.length; i += 3) {
715
+ const x = pos[i]!, y = pos[i + 1]!, z = pos[i + 2]!;
716
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
717
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
718
+ if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
719
+ }
720
+ } else {
721
+ const m0 = mat[0]!, m1 = mat[1]!, m2 = mat[2]!;
722
+ const m4 = mat[4]!, m5 = mat[5]!, m6 = mat[6]!;
723
+ const m8 = mat[8]!, m9 = mat[9]!, m10 = mat[10]!;
724
+ const m12 = mat[12]!, m13 = mat[13]!, m14 = mat[14]!;
725
+ for (let i = 0; i < pos.length; i += 3) {
726
+ const x = pos[i]!, y = pos[i + 1]!, z = pos[i + 2]!;
727
+ const tx = m0 * x + m4 * y + m8 * z + m12;
728
+ const ty = m1 * x + m5 * y + m9 * z + m13;
729
+ const tz = m2 * x + m6 * y + m10 * z + m14;
730
+ if (tx < minX) minX = tx; if (tx > maxX) maxX = tx;
731
+ if (ty < minY) minY = ty; if (ty > maxY) maxY = ty;
732
+ if (tz < minZ) minZ = tz; if (tz > maxZ) maxZ = tz;
733
+ }
734
+ }
735
+ }
736
+ }
737
+
738
+ if (totalPoints === 0 || !Number.isFinite(minX)) return null;
739
+
740
+ const cx = (minX + maxX) * 0.5;
741
+ const cy = (minY + maxY) * 0.5;
742
+ const cz = (minZ + maxZ) * 0.5;
743
+
744
+ let maxR2 = 0;
745
+ for (const geode of geodes) {
746
+ const mat = geode.matrix;
747
+ const isIdentity =
748
+ !mat ||
749
+ (mat[0] === 1 && mat[1] === 0 && mat[2] === 0 && mat[3] === 0 &&
750
+ mat[4] === 0 && mat[5] === 1 && mat[6] === 0 && mat[7] === 0 &&
751
+ mat[8] === 0 && mat[9] === 0 && mat[10] === 1 && mat[11] === 0 &&
752
+ mat[12] === 0 && mat[13] === 0 && mat[14] === 0 && mat[15] === 1);
753
+
754
+ for (const name of geode.skeletonNames) {
755
+ const mesh = meshMap.get(name);
756
+ if (!mesh || !mesh.position) continue;
757
+ const pos = mesh.position;
758
+
759
+ if (isIdentity) {
760
+ for (let i = 0; i < pos.length; i += 3) {
761
+ const dx = pos[i]! - cx, dy = pos[i + 1]! - cy, dz = pos[i + 2]! - cz;
762
+ const r2 = dx * dx + dy * dy + dz * dz;
763
+ if (r2 > maxR2) maxR2 = r2;
764
+ }
765
+ } else {
766
+ const m0 = mat[0]!, m1 = mat[1]!, m2 = mat[2]!;
767
+ const m4 = mat[4]!, m5 = mat[5]!, m6 = mat[6]!;
768
+ const m8 = mat[8]!, m9 = mat[9]!, m10 = mat[10]!;
769
+ const m12 = mat[12]!, m13 = mat[13]!, m14 = mat[14]!;
770
+ for (let i = 0; i < pos.length; i += 3) {
771
+ const x = pos[i]!, y = pos[i + 1]!, z = pos[i + 2]!;
772
+ const tx = m0 * x + m4 * y + m8 * z + m12;
773
+ const ty = m1 * x + m5 * y + m9 * z + m13;
774
+ const tz = m2 * x + m6 * y + m10 * z + m14;
775
+ const dx = tx - cx, dy = ty - cy, dz = tz - cz;
776
+ const r2 = dx * dx + dy * dy + dz * dz;
777
+ if (r2 > maxR2) maxR2 = r2;
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ const radius = Math.sqrt(maxR2);
784
+ return {
785
+ center: { x: cx, y: cy, z: cz },
786
+ radius: radius > 0 ? radius : 0.001,
787
+ };
788
+ }
789
+
648
790
  export async function parseS3M1(arrayBuffer: ArrayBuffer, options: ParseS3M1Options = {}): Promise<ParsedTile> {
649
791
  const inflate = options.inflate ?? defaultInflate;
650
792
  const r = new BinaryReader(arrayBuffer);
@@ -770,17 +912,45 @@ export async function parseS3M1(arrayBuffer: ArrayBuffer, options: ParseS3M1Opti
770
912
  const xmlLods = parseXmlLods(xml);
771
913
  const pageLods: PageLOD[] = [];
772
914
  xmlLods.forEach((lod, i) => {
773
- const named = lod.geoNames.map((n) => keyOf.get(n)).filter((k): k is string => !!k);
774
- // 名字对不上时退回位置匹配,并让上层有机会发现(宁可少一个 LOD 也不要挂错球)
775
- const fallback = meshes[i] ? [`SuperMapCache#${meshes[i].geoName}`] : [];
776
- const skeletonNames = named.length ? [...new Set(named)] : fallback;
777
- if (!skeletonNames.length) return;
915
+ let geodes: Geode[];
916
+ if (lod.geodes && lod.geodes.length > 0) {
917
+ geodes = lod.geodes
918
+ .map((g, gi) => {
919
+ const key = keyOf.get(g.geoName);
920
+ const fallback = meshes[gi] ? [`SuperMapCache#${meshes[gi].geoName}`] : [];
921
+ const skeletonNames = key ? [key] : fallback;
922
+ return {
923
+ matrix: g.matrix,
924
+ skeletonNames,
925
+ };
926
+ })
927
+ .filter((g) => g.skeletonNames.length > 0);
928
+ } else {
929
+ const named = lod.geoNames.map((n) => keyOf.get(n)).filter((k): k is string => !!k);
930
+ // 名字对不上时退回位置匹配,并让上层有机会发现(宁可少一个 LOD 也不要挂错球)
931
+ const fallback = meshes[i] ? [`SuperMapCache#${meshes[i].geoName}`] : [];
932
+ const skeletonNames = named.length ? [...new Set(named)] : fallback;
933
+ geodes = skeletonNames.length ? [{ matrix: lod.matrix, skeletonNames }] : [];
934
+ }
935
+ if (!geodes.length) return;
936
+
937
+ let center = { ...lod.center };
938
+ let radius = lod.radius;
939
+ // 缺包围球(如叶子瓦片)时,从网格顶点实时计算真实几何包围球
940
+ if (radius <= 0) {
941
+ const computed = computeLodBoundingSphere(geodes, meshes);
942
+ if (computed) {
943
+ center = computed.center;
944
+ radius = computed.radius;
945
+ }
946
+ }
947
+
778
948
  pageLods.push({
779
949
  rangeValue: lod.rangeValue,
780
950
  rangeMode: lod.rangeMode,
781
- boundingSphere: { center: { ...lod.center }, radius: lod.radius },
951
+ boundingSphere: { center, radius },
782
952
  childTile: lod.childTile,
783
- geodes: [{ matrix: lod.matrix, skeletonNames }],
953
+ geodes,
784
954
  });
785
955
  });
786
956
  const groupNode: GroupNode = { pageLods };
package/src/index.ts CHANGED
@@ -69,7 +69,7 @@ export type { GeoAdapter, WorldCoordinateResult } from './render/GeoAdapter.js';
69
69
 
70
70
  // —— 瓦片层
71
71
  export { S3MTileset, parseConfigText, classifyDatasetKind } from './tileset/S3MTileset.js';
72
- export type { TileNode, BuildTileFn, S3MTilesetOptions, DatasetKind } from './tileset/S3MTileset.js';
72
+ export type { TileNode, BuildTileFn, S3MTilesetOptions, DatasetKind, TileVisibilityTest } from './tileset/S3MTileset.js';
73
73
 
74
74
  // —— 渲染层
75
75
  export { S3MViewer } from './render/S3MViewer.js';
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import * as THREE from 'three';
24
24
  import { S3MTileset, usableGeoBounds } from '../tileset/S3MTileset.js';
25
- import type { BuildTileFn, TileNode } from '../tileset/S3MTileset.js';
25
+ import type { BuildTileFn, TileNode, TileVisibilityTest } from '../tileset/S3MTileset.js';
26
26
  import type { AuthenticateFn } from '../core/AuthFetch.js';
27
27
  import { setSplatViewport } from './GeometryBuilder.js';
28
28
  import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap, applyAnisotropyCap } from './TextureFactory.js';
@@ -430,6 +430,11 @@ export class S3MViewer {
430
430
  private _getBearing: (() => number) | undefined;
431
431
  /** 鉴权适配(全局默认),构造时注入 */
432
432
  private readonly _authenticate: AuthenticateFn | undefined;
433
+ /** 视锥细化剔除开关,默认 false(issue #25 第一期) */
434
+ private _cullRefine = false;
435
+ /** 逐帧复用:视锥与球心测试用的临时向量,避免每帧 new */
436
+ private _frustum?: THREE.Frustum;
437
+ private _cullTestVec?: THREE.Vector3;
433
438
  private _lodRangeScale = 1.0;
434
439
  /** 图层级降级策略,数据集可覆盖,见 `S3MViewerOptions.unsupported` */
435
440
  private readonly _unsupported: UnsupportedPolicy;
@@ -464,6 +469,8 @@ export class S3MViewer {
464
469
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
465
470
  this._unsupported = options.unsupported ?? 'warn';
466
471
  this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
472
+ // 视锥剔除细化默认关闭:这是可选策略不是 bug 修复,不传就走全向加载的老路径
473
+ this._cullRefine = options.cullRefine === true || options.frustumCullRefine === true;
467
474
  // 瓦片级放置默认开启:旧行为是拿单一常量切平面拟合随纬度变化的墨卡托比例,属于 bug 修复,
468
475
  // 所以默认值跟着改(见 AGENTS.md 不变式 ①)。显式传 false 仍可回退。
469
476
  this._perTilePlacement = options.perTilePlacement !== false;
@@ -1737,16 +1744,29 @@ export class S3MViewer {
1737
1744
 
1738
1745
  private _registerTile(node: TileNode, entry: S3MDatasetEntry): void {
1739
1746
  // 必须在 add / updateMatrixWorld 之前设置:后者会拿当前 matrix 算 matrixWorld 并缓存
1740
- if (this._perTilePlacement && node.sphere) {
1741
- this._applyTilePlacement(node.group, node.sphere.center, entry);
1742
- } else {
1747
+ //
1748
+ // ★ 触发条件**不能**是「有没有包围球」。S3M 1.x 的叶子瓦片 XML 是
1749
+ // `<PageLods><Geode>`(外面没有 `<PagedLOD>` 包装),`parseXmlLods` 解析出来
1750
+ // pageLods 为空 → sphere 为 null。而 `entry.root` 上挂着 align 算出来的整体偏移
1751
+ // (索菲亚教堂是 heightRange.min = 119.84),那个偏移是靠 placement 里的
1752
+ // `- datasetOffset` 抵消的。少摆一块,这块就带着偏移显示 —— 实测 L23 整层
1753
+ // 因此沉到地下 120 m,且 `localZ - worldZ` 恒等于偏移量。
1754
+ // 缺球时用几何包围盒中心兜底;连几何都没有就退回旧口径。
1755
+ const center = node.sphere?.center ?? this._localCenterOf(node.group);
1756
+ if (this._perTilePlacement && center) {
1757
+ this._applyTilePlacement(node.group, center, entry);
1758
+ }
1759
+ // 上面没摆成(关掉本功能 / 缺锚点 / 几何为空)时 matrixAutoUpdate 仍是 true,走旧口径
1760
+ if (node.group.matrixAutoUpdate) {
1743
1761
  const sceneAnchor = this.sceneAnchor;
1744
1762
  if (sceneAnchor) {
1745
1763
  const sEast = sceneAnchor.meterInMercatorEast ?? sceneAnchor.meterInMercator;
1746
1764
  const sNorth = sceneAnchor.meterInMercatorNorth ?? sceneAnchor.meterInMercator;
1747
1765
  const sUp = sceneAnchor.meterInMercatorUp ?? sEast;
1748
- const center = node.sphere ? ([node.sphere.center.x, node.sphere.center.y, node.sphere.center.z] as const) : ([0, 0, 0] as const);
1749
- this._updateTileUniforms(node.group, center, [1.0 / sEast, 1.0 / sNorth, 1.0 / sUp]);
1766
+ const c = node.sphere
1767
+ ? ([node.sphere.center.x, node.sphere.center.y, node.sphere.center.z] as const)
1768
+ : ([0, 0, 0] as const);
1769
+ this._updateTileUniforms(node.group, c, [1.0 / sEast, 1.0 / sNorth, 1.0 / sUp]);
1750
1770
  }
1751
1771
  }
1752
1772
  entry.root.add(node.group);
@@ -1820,6 +1840,22 @@ export class S3MViewer {
1820
1840
  }
1821
1841
  }
1822
1842
 
1843
+ /**
1844
+ * 瓦片几何在**数据集局部 ENU** 里的包围盒中心,给没有包围球的瓦片兜底。
1845
+ *
1846
+ * 调用时机固定在 `entry.root.add()` 之前:此时 group 还没挂到 root 上、matrix 还是
1847
+ * 单位阵,`setFromObject` 得到的就是顶点自己的坐标系,正好是摆放要的枢轴。
1848
+ * 挂上 root 之后再算会带上 root 的 offset,枢轴就偏了。
1849
+ */
1850
+ private _localCenterOf(group: THREE.Object3D): { x: number; y: number; z: number } | null {
1851
+ if (!group.children.length) return null;
1852
+ const box = new THREE.Box3().setFromObject(group);
1853
+ if (box.isEmpty()) return null;
1854
+ const c = box.getCenter(new THREE.Vector3());
1855
+ if (!Number.isFinite(c.x) || !Number.isFinite(c.y) || !Number.isFinite(c.z)) return null;
1856
+ return { x: c.x, y: c.y, z: c.z };
1857
+ }
1858
+
1823
1859
  /**
1824
1860
  * 把一块瓦片摆到「用它自己中心纬度算出的尺度」上(`perTilePlacement` 开启时才走)。
1825
1861
  *
@@ -3243,6 +3279,28 @@ export class S3MViewer {
3243
3279
  this._requestRepaint();
3244
3280
  }
3245
3281
 
3282
+ /** 当前是否开启细化视锥剔除(第一期,issue #25) */
3283
+ get cullRefine(): boolean {
3284
+ return this._cullRefine;
3285
+ }
3286
+
3287
+ set cullRefine(enabled: boolean) {
3288
+ this.setCullRefine(enabled);
3289
+ }
3290
+
3291
+ /**
3292
+ * 开关细化视锥剔除(第一期,issue #25)。
3293
+ *
3294
+ * 只影响「要不要继续细化」:视野外的瓦片不再 `_enqueue` 子瓦片,
3295
+ * 已加载的瓦片、`lastSeen` 刷新、淘汰与显存预算都不动。
3296
+ */
3297
+ setCullRefine(enabled: boolean): void {
3298
+ const val = !!enabled;
3299
+ if (this._cullRefine === val) return;
3300
+ this._cullRefine = val;
3301
+ this._requestRepaint();
3302
+ }
3303
+
3246
3304
  /**
3247
3305
  * 动态调整 LOD 切换范围缩放系数。
3248
3306
  * scale > 1 时倾向保留粗模(降低瓦片请求与面数);scale < 1 时提前细化加载高精瓦片。
@@ -3364,6 +3422,15 @@ export class S3MViewer {
3364
3422
  // LOD 判定高度:默认就是绘图缓冲高度(物理像素,与官方口径一致);
3365
3423
  // 配了 lodPixelRatioCap 才按 CSS 高度截断,默认状态下这两行不产生任何开销。
3366
3424
  const lodScreenHeight = this._lodScreenHeight(opts.canvasHeight);
3425
+
3426
+ let frustum: THREE.Frustum | null = null;
3427
+ let cullTestVec: THREE.Vector3 | null = null;
3428
+ if (this._cullRefine) {
3429
+ frustum = this._frustum ??= new THREE.Frustum();
3430
+ frustum.setFromProjectionMatrix(mvp);
3431
+ cullTestVec = this._cullTestVec ??= new THREE.Vector3();
3432
+ }
3433
+
3367
3434
  for (const entry of this.datasets.values()) {
3368
3435
  if (!entry.visible || !entry.tileset) continue;
3369
3436
  const local: [number, number, number] = [
@@ -3371,7 +3438,45 @@ export class S3MViewer {
3371
3438
  camEnu[1] - entry.offset[1],
3372
3439
  camEnu[2] - entry.offset[2],
3373
3440
  ];
3374
- if (entry.tileset.update(local, opts.fovRad, lodScreenHeight)) {
3441
+
3442
+ let isVisible: TileVisibilityTest | undefined;
3443
+ if (frustum && cullTestVec) {
3444
+ const f = frustum;
3445
+ const v = cullTestVec;
3446
+ const entryOffset = entry.offset;
3447
+ isVisible = (center, radius, node) => {
3448
+ let worldX: number;
3449
+ let worldY: number;
3450
+ let worldZ: number;
3451
+ let worldRadius = radius;
3452
+
3453
+ if (node?.group && node.group.parent) {
3454
+ v.set(center.x, center.y, center.z).applyMatrix4(node.group.matrixWorld);
3455
+ worldX = v.x;
3456
+ worldY = v.y;
3457
+ worldZ = v.z;
3458
+ worldRadius *= node.group.matrixWorld.getMaxScaleOnAxis();
3459
+ } else {
3460
+ worldX = center.x + entryOffset[0];
3461
+ worldY = center.y + entryOffset[1];
3462
+ worldZ = center.z + entryOffset[2];
3463
+ }
3464
+
3465
+ // 边界余量:按球半径的 15% 且至少保留 10 米余量,避免视野边缘临界抖动与转头瞬间缺块
3466
+ const margin = Math.max(worldRadius * 0.15, 10);
3467
+ const limit = -(worldRadius + margin);
3468
+
3469
+ const planes = f.planes;
3470
+ for (let i = 0; i < 6; i++) {
3471
+ const p = planes[i];
3472
+ const dist = p.normal.x * worldX + p.normal.y * worldY + p.normal.z * worldZ + p.constant;
3473
+ if (dist < limit) return false;
3474
+ }
3475
+ return true;
3476
+ };
3477
+ }
3478
+
3479
+ if (entry.tileset.update(local, opts.fovRad, lodScreenHeight, isVisible)) {
3375
3480
  tilesetChanged = true;
3376
3481
  }
3377
3482
  }
@@ -171,6 +171,16 @@ export interface S3MViewerOptions {
171
171
  * 见 `render/tilePlacement.ts` 的文件头。
172
172
  */
173
173
  perTilePlacement?: boolean;
174
+ /**
175
+ * 视锥剔除细化(第一期,默认 false,见 issue #25)。
176
+ * 开启后视野外的瓦片不再继续请求/细化子瓦片,大幅减少转头时的无效下载、解析与上传。
177
+ *
178
+ * 这不是 bug 修复,属于「另一种可选策略」,所以默认关闭(AGENTS.md 不变式 ①):
179
+ * 不传时全向加载的老路径逐字不变。
180
+ */
181
+ cullRefine?: boolean;
182
+ /** `cullRefine` 的别名,两者取或 */
183
+ frustumCullRefine?: boolean;
174
184
  /**
175
185
  * 是否对瓦片逐顶点烘焙精确 Web 墨卡托偏移(消除全球大尺度下的视差游动/呼吸效应)。
176
186
  * - undefined(默认):自动判定。当瓦片集根节点包围球半径 > 25 km 时自动启用,<= 25 km 时保持关闭(逐字节保持原有 ENU 顶点缓冲不变)。
@@ -289,6 +289,13 @@ export interface TileNode {
289
289
  stats?: { meshes: number; vertices: number; triangles: number; memory?: number };
290
290
  }
291
291
 
292
+ /** 视锥可见性判定回调(返回 false 表示瓦片完全在视锥外,应跳过进一步细化) */
293
+ export type TileVisibilityTest = (
294
+ center: { x: number; y: number; z: number },
295
+ radius: number,
296
+ node?: TileNode
297
+ ) => boolean;
298
+
292
299
  /** 瓦片构建器可注入,用于测试 / 服务端渲染(此时不需要 three) */
293
300
  export type BuildTileFn = (
294
301
  tile: ParsedTile,
@@ -951,6 +958,15 @@ export class S3MTileset {
951
958
  victim = node;
952
959
  continue;
953
960
  }
961
+ // 可见性优先于时间戳:没有包围球的瓦片算不出距离,`lastSeen` 天然比别的瓦片旧,
962
+ // 只看时间戳会把「正在显示」的那块先踢掉(开洞 → 回补 → 再踢 = 闪)。
963
+ // 只有不可见的候选分不出来时,才轮到 lastSeen / distance 决定。
964
+ const nodeVisible = node.group.visible ? 1 : 0;
965
+ const victimVisible = victim.group.visible ? 1 : 0;
966
+ if (nodeVisible !== victimVisible) {
967
+ if (nodeVisible < victimVisible) victim = node;
968
+ continue;
969
+ }
954
970
  const a = node.lastSeen ?? -1;
955
971
  const b = victim.lastSeen ?? -1;
956
972
  if (a < b || (a === b && (node.distance ?? 0) > (victim.distance ?? 0))) victim = node;
@@ -1507,12 +1523,22 @@ export class S3MTileset {
1507
1523
  * @param screenHeight 视口像素高度
1508
1524
  * @returns 瓦片加载队列或可见性是否发生变化
1509
1525
  */
1510
- update(camEnu: [number, number, number], fovRad: number, screenHeight: number): boolean {
1526
+ update(
1527
+ camEnu: [number, number, number],
1528
+ fovRad: number,
1529
+ screenHeight: number,
1530
+ isVisible?: TileVisibilityTest
1531
+ ): boolean {
1511
1532
  this._frame++;
1512
1533
  let hasChanges = false;
1513
1534
 
1514
1535
  for (const node of this.nodes.values()) {
1515
1536
  if (!node.sphere) {
1537
+ // 没有包围球(S3M 1.x 叶子瓦片:XML 是 `<PageLods><Geode>`,没有 PagedLOD)时
1538
+ // 算不出距离,但**仍要记「最近用过」**:`lastSeen` 恒为 undefined 会让它在
1539
+ // `_pickVictim` 里永远排最前(`?? -1`),硬上限一触发 `allowUncovered`,
1540
+ // 第一块被踢掉的就是正在显示的那块 —— 画面立刻一个洞,下一帧又加载回来。
1541
+ node.lastSeen = this._frame;
1516
1542
  node.needRefine = false;
1517
1543
  continue;
1518
1544
  }
@@ -1576,10 +1602,13 @@ export class S3MTileset {
1576
1602
  })
1577
1603
  : null;
1578
1604
 
1579
- const needRefine = splat
1605
+ let needRefine = splat
1580
1606
  ? splat.needRefine
1581
1607
  : childUrls.length > 0 &&
1582
1608
  (byDistance ? metric < threshold * hFactor : metric > threshold * hFactor);
1609
+ if (needRefine && isVisible && !isVisible(c, node.sphere.radius, node)) {
1610
+ needRefine = false;
1611
+ }
1583
1612
  node.needRefine = needRefine;
1584
1613
 
1585
1614
  const dbgSeen = ((globalThis as { __S3M_LOD_SEEN?: Set<string> }).__S3M_LOD_SEEN ??= new Set<string>());