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/dist/browser/assets/s3mWorker-B2u-xt54.js +1 -0
- package/dist/browser/s3m-three.iife.ext-three.js +40 -40
- package/dist/browser/s3m-three.iife.js +40 -40
- package/dist/core/S3M1Parser.d.ts +19 -0
- package/dist/core/S3M1Parser.d.ts.map +1 -1
- package/dist/core/S3M1Parser.js +179 -8
- package/dist/core/S3M1Parser.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/render/S3MViewer.d.ts +23 -0
- package/dist/render/S3MViewer.d.ts.map +1 -1
- package/dist/render/S3MViewer.js +106 -7
- package/dist/render/S3MViewer.js.map +1 -1
- package/dist/render/viewer/types.d.ts +10 -0
- package/dist/render/viewer/types.d.ts.map +1 -1
- package/dist/render/viewer/types.js.map +1 -1
- package/dist/tileset/S3MTileset.d.ts +7 -1
- package/dist/tileset/S3MTileset.d.ts.map +1 -1
- package/dist/tileset/S3MTileset.js +20 -2
- package/dist/tileset/S3MTileset.js.map +1 -1
- package/dist/worker/s3mWorker.js +654 -524
- package/dist/worker/s3mWorker.js.map +1 -1
- package/docs/API.md +3 -0
- package/package.json +1 -1
- package/src/core/S3M1Parser.ts +179 -9
- package/src/index.ts +1 -1
- package/src/render/S3MViewer.ts +112 -7
- package/src/render/viewer/types.ts +10 -0
- package/src/tileset/S3MTileset.ts +31 -2
- package/dist/browser/assets/s3mWorker-BfSBNvF5.js +0 -1
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
package/src/core/S3M1Parser.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
951
|
+
boundingSphere: { center, radius },
|
|
782
952
|
childTile: lod.childTile,
|
|
783
|
-
geodes
|
|
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';
|
package/src/render/S3MViewer.ts
CHANGED
|
@@ -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
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
|
1749
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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>());
|