s3m-three 0.3.1 → 0.3.3
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/s3m-three.iife.ext-three.js +3 -3
- package/dist/browser/s3m-three.iife.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/render/DatasetManager.d.ts +11 -0
- package/dist/render/DatasetManager.d.ts.map +1 -1
- package/dist/render/DatasetManager.js +63 -0
- package/dist/render/DatasetManager.js.map +1 -1
- package/dist/render/GeometryBuilder.d.ts +1 -0
- package/dist/render/GeometryBuilder.d.ts.map +1 -1
- package/dist/render/GeometryBuilder.js +4 -1
- package/dist/render/GeometryBuilder.js.map +1 -1
- package/dist/render/S3MViewer.d.ts +70 -3
- package/dist/render/S3MViewer.d.ts.map +1 -1
- package/dist/render/S3MViewer.js +208 -58
- package/dist/render/S3MViewer.js.map +1 -1
- package/dist/tileset/S3MTileset.d.ts +26 -1
- package/dist/tileset/S3MTileset.d.ts.map +1 -1
- package/dist/tileset/S3MTileset.js +91 -13
- package/dist/tileset/S3MTileset.js.map +1 -1
- package/docs/API.md +40 -2
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/render/DatasetManager.ts +65 -0
- package/src/render/GeometryBuilder.ts +5 -1
- package/src/render/S3MViewer.ts +249 -57
- package/src/tileset/S3MTileset.ts +104 -14
package/src/render/S3MViewer.ts
CHANGED
|
@@ -144,7 +144,42 @@ export interface PickResult extends FeatureAttributes {
|
|
|
144
144
|
mesh: THREE.Mesh;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
export interface LightOptions {
|
|
148
|
+
/** 环境光强度,默认 0.4 */
|
|
149
|
+
ambientIntensity?: number;
|
|
150
|
+
/** 环境光颜色,十六进制或 CSS 颜色字符串,默认 0xffffff */
|
|
151
|
+
ambientColor?: number | string;
|
|
152
|
+
/** 半球光天空强度,默认 0.8 */
|
|
153
|
+
hemisphereIntensity?: number;
|
|
154
|
+
/** 半球光天空颜色,默认 0xffffff */
|
|
155
|
+
hemisphereColor?: number | string;
|
|
156
|
+
/** 半球光地面颜色,默认 0xb6c0cb */
|
|
157
|
+
hemisphereGroundColor?: number | string;
|
|
158
|
+
/** 主平行光强度,默认 1.0 */
|
|
159
|
+
keyLightIntensity?: number;
|
|
160
|
+
/** 主平行光颜色,默认 0xffffff */
|
|
161
|
+
keyLightColor?: number | string;
|
|
162
|
+
/** 补光强度,默认 0.35 */
|
|
163
|
+
fillLightIntensity?: number;
|
|
164
|
+
/** 补光颜色,默认 0xffffff */
|
|
165
|
+
fillLightColor?: number | string;
|
|
166
|
+
/**
|
|
167
|
+
* 光照朝向模式:
|
|
168
|
+
* - 'headlamp': 默认头灯模式(随相机 bearing 旋转,受光面始终面向镜头)
|
|
169
|
+
* - 'fixed': 固定世界坐标方位角(如太阳光模拟)
|
|
170
|
+
*/
|
|
171
|
+
mode?: 'headlamp' | 'fixed';
|
|
172
|
+
/** 方位角(度,0~360),固定模式下以正北为 0 顺时针,头灯模式下为相机相对偏角,默认 150 */
|
|
173
|
+
azimuth?: number;
|
|
174
|
+
/** 太阳/光源仰角(度,0~90),默认 50 */
|
|
175
|
+
altitude?: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
147
178
|
export interface S3MViewerOptions {
|
|
179
|
+
/** 光照参数配置(强度、颜色、朝向模式、太阳方位角/仰角等) */
|
|
180
|
+
light?: LightOptions;
|
|
181
|
+
/** 全局总驻留瓦片上限(多数据集多图层共享预算),在所有可见数据集之间动态均衡分配,彻底防止爆显存 */
|
|
182
|
+
totalMaxTiles?: number;
|
|
148
183
|
/** 单数据集简写 */
|
|
149
184
|
configUrl?: string;
|
|
150
185
|
/** 多数据集 */
|
|
@@ -294,8 +329,24 @@ export class S3MViewer {
|
|
|
294
329
|
private _needsRepaint = true;
|
|
295
330
|
private _frame = 0;
|
|
296
331
|
private readonly _fps = { value: 0, last: 0 };
|
|
297
|
-
private
|
|
298
|
-
private
|
|
332
|
+
private _ambientLight!: THREE.AmbientLight;
|
|
333
|
+
private _hemiLight!: THREE.HemisphereLight;
|
|
334
|
+
private _keyLight!: THREE.DirectionalLight;
|
|
335
|
+
private _fillLight!: THREE.DirectionalLight;
|
|
336
|
+
private readonly _lightOptions: Required<LightOptions> = {
|
|
337
|
+
ambientIntensity: 0.4,
|
|
338
|
+
ambientColor: 0xffffff,
|
|
339
|
+
hemisphereIntensity: 0.8,
|
|
340
|
+
hemisphereColor: 0xffffff,
|
|
341
|
+
hemisphereGroundColor: 0xb6c0cb,
|
|
342
|
+
keyLightIntensity: 1.0,
|
|
343
|
+
keyLightColor: 0xffffff,
|
|
344
|
+
fillLightIntensity: 0.35,
|
|
345
|
+
fillLightColor: 0xffffff,
|
|
346
|
+
mode: 'headlamp',
|
|
347
|
+
azimuth: 150,
|
|
348
|
+
altitude: 50,
|
|
349
|
+
};
|
|
299
350
|
|
|
300
351
|
private readonly _datasetQueue: S3MDatasetOptions[];
|
|
301
352
|
|
|
@@ -309,10 +360,20 @@ export class S3MViewer {
|
|
|
309
360
|
this._onRequestRepaint();
|
|
310
361
|
}
|
|
311
362
|
|
|
363
|
+
triggerRepaint(): void {
|
|
364
|
+
this._requestRepaint();
|
|
365
|
+
}
|
|
366
|
+
|
|
312
367
|
constructor(options: S3MViewerOptions = {}) {
|
|
313
368
|
this.options = options;
|
|
314
369
|
this.pickable = options.pickable !== false;
|
|
315
370
|
this._lodRangeScale = options.lodRangeScale ?? 1.0;
|
|
371
|
+
if (options.light) {
|
|
372
|
+
Object.assign(this._lightOptions, options.light);
|
|
373
|
+
}
|
|
374
|
+
if (options.totalMaxTiles) {
|
|
375
|
+
this._datasetManager.totalMaxTiles = options.totalMaxTiles;
|
|
376
|
+
}
|
|
316
377
|
this._onRequestRepaint = options.onRequestRepaint ?? (() => {});
|
|
317
378
|
this._getBearing = options.getBearing;
|
|
318
379
|
this.onStats = options.onStats ?? (() => {});
|
|
@@ -321,6 +382,11 @@ export class S3MViewer {
|
|
|
321
382
|
this.onError = options.onError ?? ((e) => console.warn('[s3m]', e));
|
|
322
383
|
|
|
323
384
|
this.groundManager = new GroundManager(options.ground);
|
|
385
|
+
if (!this.scene) {
|
|
386
|
+
this.scene = new THREE.Scene();
|
|
387
|
+
this.scene.add(this.groundManager.group);
|
|
388
|
+
this._addLights();
|
|
389
|
+
}
|
|
324
390
|
this._featureStyleManager = new FeatureStyleManager(
|
|
325
391
|
() => this.style.floorHeight,
|
|
326
392
|
() => this._requestRepaint(),
|
|
@@ -358,19 +424,25 @@ export class S3MViewer {
|
|
|
358
424
|
* 绑定宿主画布与 WebGL 上下文,建场景并启动数据集队列。
|
|
359
425
|
* MapLibre 宿主由 S3MLayer 在 onAdd 里调用;纯 three 宿主直接传自己的 canvas / gl。
|
|
360
426
|
*/
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
this.renderer = new THREE.WebGLRenderer({
|
|
427
|
+
/** 创建并配置一个绑定到给定 gl 上下文的 three renderer(mount 与上下文恢复共用) */
|
|
428
|
+
private _createRenderer(canvas: HTMLCanvasElement, gl: WebGLRenderingContext | WebGL2RenderingContext): THREE.WebGLRenderer {
|
|
429
|
+
const r = new THREE.WebGLRenderer({
|
|
365
430
|
canvas,
|
|
366
431
|
context: gl as WebGLRenderingContext,
|
|
367
432
|
antialias: true,
|
|
368
433
|
});
|
|
369
|
-
|
|
370
|
-
|
|
434
|
+
r.autoClear = false;
|
|
435
|
+
r.setPixelRatio(1);
|
|
371
436
|
// 白模 albedo 接近 1,不做色调映射会过曝成一片纯白、丢失明暗关系
|
|
372
|
-
|
|
373
|
-
|
|
437
|
+
r.toneMapping = THREE.ACESFilmicToneMapping;
|
|
438
|
+
r.toneMappingExposure = 1.0;
|
|
439
|
+
return r;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
mount(canvas: HTMLCanvasElement, gl: WebGLRenderingContext | WebGL2RenderingContext): void {
|
|
443
|
+
this.canvas = canvas;
|
|
444
|
+
|
|
445
|
+
this.renderer = this._createRenderer(canvas, gl);
|
|
374
446
|
setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
|
|
375
447
|
|
|
376
448
|
this.scene = new THREE.Scene();
|
|
@@ -388,6 +460,38 @@ export class S3MViewer {
|
|
|
388
460
|
this._recomputeAnchors();
|
|
389
461
|
}
|
|
390
462
|
|
|
463
|
+
/**
|
|
464
|
+
* WebGL 上下文恢复后原地换新 renderer,保留已经加载好的场景与瓦片。
|
|
465
|
+
*
|
|
466
|
+
* MapLibre 在 `webglcontextlost` 时会销毁 style 并丢弃自定义图层(自定义图层
|
|
467
|
+
* 无法序列化进 style,所以恢复后不会自动加回)。但自定义图层的 `onRemove` 不会被
|
|
468
|
+
* 调用,因此我们的 scene / 几何体 / 材质 / 纹理 / 已加载瓦片都还在内存里。
|
|
469
|
+
*
|
|
470
|
+
* three 的 GPU 状态是按 renderer 实例缓存的,所以只需把 renderer / picker 换成
|
|
471
|
+
* 绑在新 gl 上的新实例,下一帧渲染时 three 会自动把现存的几何体、材质、纹理
|
|
472
|
+
* 重新上传到新上下文 —— 不需要重建场景,也不需要重新拉瓦片。
|
|
473
|
+
*/
|
|
474
|
+
restoreContext(gl: WebGLRenderingContext | WebGL2RenderingContext): void {
|
|
475
|
+
if (!this.canvas) {
|
|
476
|
+
// 理论不会走到:没 mount 过就没有上下文可恢复
|
|
477
|
+
console.warn('[s3m] restoreContext 调用过早(尚未 mount),按首次挂载处理');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
// 旧上下文已失效,dispose 可能抛错,忽略即可
|
|
482
|
+
this.renderer?.dispose();
|
|
483
|
+
} catch {
|
|
484
|
+
/* noop */
|
|
485
|
+
}
|
|
486
|
+
this.renderer = this._createRenderer(this.canvas, gl);
|
|
487
|
+
setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
|
|
488
|
+
this.picker?.dispose();
|
|
489
|
+
this.picker = new Picker(this.renderer);
|
|
490
|
+
// 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
|
|
491
|
+
// 这里只确保下一帧尽快驱动一次渲染。
|
|
492
|
+
this._requestRepaint();
|
|
493
|
+
}
|
|
494
|
+
|
|
391
495
|
/** 解绑场景并释放全部 GPU 资源。对应 MapLibre 图层的 onRemove()。 */
|
|
392
496
|
unmount(): void {
|
|
393
497
|
for (const id of [...this.datasets.keys()]) this.removeDataset(id);
|
|
@@ -412,24 +516,70 @@ export class S3MViewer {
|
|
|
412
516
|
this.canvas = null;
|
|
413
517
|
}
|
|
414
518
|
|
|
519
|
+
private _createLights(): void {
|
|
520
|
+
this._ambientLight = new THREE.AmbientLight(this._lightOptions.ambientColor as any, this._lightOptions.ambientIntensity);
|
|
521
|
+
this._hemiLight = new THREE.HemisphereLight(
|
|
522
|
+
this._lightOptions.hemisphereColor as any,
|
|
523
|
+
this._lightOptions.hemisphereGroundColor as any,
|
|
524
|
+
this._lightOptions.hemisphereIntensity
|
|
525
|
+
);
|
|
526
|
+
this._keyLight = new THREE.DirectionalLight(this._lightOptions.keyLightColor as any, this._lightOptions.keyLightIntensity);
|
|
527
|
+
this._fillLight = new THREE.DirectionalLight(this._lightOptions.fillLightColor as any, this._lightOptions.fillLightIntensity);
|
|
528
|
+
this._updateLights();
|
|
529
|
+
}
|
|
530
|
+
|
|
415
531
|
private _addLights(): void {
|
|
416
532
|
if (!this.scene) return;
|
|
417
|
-
|
|
418
|
-
this.scene.add(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
533
|
+
if (!this._ambientLight) this._createLights();
|
|
534
|
+
this.scene.add(this._ambientLight, this._hemiLight, this._keyLight, this._fillLight);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private _applyLightOptions(): void {
|
|
538
|
+
if (this._ambientLight) {
|
|
539
|
+
this._ambientLight.intensity = this._lightOptions.ambientIntensity;
|
|
540
|
+
this._ambientLight.color.set(this._lightOptions.ambientColor as any);
|
|
541
|
+
}
|
|
542
|
+
if (this._hemiLight) {
|
|
543
|
+
this._hemiLight.intensity = this._lightOptions.hemisphereIntensity;
|
|
544
|
+
this._hemiLight.color.set(this._lightOptions.hemisphereColor as any);
|
|
545
|
+
this._hemiLight.groundColor.set(this._lightOptions.hemisphereGroundColor as any);
|
|
546
|
+
}
|
|
547
|
+
if (this._keyLight) {
|
|
548
|
+
this._keyLight.intensity = this._lightOptions.keyLightIntensity;
|
|
549
|
+
this._keyLight.color.set(this._lightOptions.keyLightColor as any);
|
|
550
|
+
}
|
|
551
|
+
if (this._fillLight) {
|
|
552
|
+
this._fillLight.intensity = this._lightOptions.fillLightIntensity;
|
|
553
|
+
this._fillLight.color.set(this._lightOptions.fillLightColor as any);
|
|
554
|
+
}
|
|
425
555
|
}
|
|
426
556
|
|
|
427
557
|
/**
|
|
428
|
-
*
|
|
429
|
-
|
|
558
|
+
* 动态设置光照配置
|
|
559
|
+
*/
|
|
560
|
+
setLight(options: Partial<LightOptions>): void {
|
|
561
|
+
Object.assign(this._lightOptions, options);
|
|
562
|
+
this._applyLightOptions();
|
|
563
|
+
this._updateLights();
|
|
564
|
+
this._needsRepaint = true;
|
|
565
|
+
this.triggerRepaint();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 获取当前光照配置快照
|
|
570
|
+
*/
|
|
571
|
+
getLight(): LightOptions {
|
|
572
|
+
return { ...this._lightOptions };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* 更新光照位置与方向:
|
|
577
|
+
* - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
|
|
578
|
+
* - 'fixed': 固定世界方位角(如模拟恒定日光)。
|
|
430
579
|
*/
|
|
431
580
|
private _updateLights(): void {
|
|
432
|
-
|
|
581
|
+
if (!this._keyLight || !this._fillLight) return;
|
|
582
|
+
const bearing = this._lightOptions.mode === 'fixed' ? 0 : (((this._getBearing?.() ?? 0) * Math.PI) / 180);
|
|
433
583
|
const place = (light: THREE.DirectionalLight | null, azOffsetDeg: number, elevDeg: number): void => {
|
|
434
584
|
if (!light) return;
|
|
435
585
|
const az = bearing + (azOffsetDeg * Math.PI) / 180;
|
|
@@ -437,8 +587,8 @@ export class S3MViewer {
|
|
|
437
587
|
const c = Math.cos(el);
|
|
438
588
|
light.position.set(Math.sin(az) * c, Math.cos(az) * c, Math.sin(el)).multiplyScalar(1000);
|
|
439
589
|
};
|
|
440
|
-
place(this._keyLight,
|
|
441
|
-
place(this._fillLight,
|
|
590
|
+
place(this._keyLight, this._lightOptions.azimuth, this._lightOptions.altitude);
|
|
591
|
+
place(this._fillLight, this._lightOptions.azimuth + 65, Math.max(10, this._lightOptions.altitude - 20));
|
|
442
592
|
}
|
|
443
593
|
|
|
444
594
|
/* ---------------------------------------------------------------- 数据集 */
|
|
@@ -877,6 +1027,27 @@ export class S3MViewer {
|
|
|
877
1027
|
* 而 LOD 细化、瓦片重载、淘汰回补都会换掉 mesh —— 不重放的话,用户点中的那栋楼
|
|
878
1028
|
* 一细化就失去高亮(状态还在 _selection.smid 里,只是没人往新 mesh 上刷)。
|
|
879
1029
|
*/
|
|
1030
|
+
/**
|
|
1031
|
+
* 判断一块无 SMID 单体化信息的网格(如倾斜摄影或白模整块)是否空间覆盖当前选中命中点
|
|
1032
|
+
*/
|
|
1033
|
+
private _isMeshCoveringSelection(mesh: THREE.Mesh, sel: PickResult): boolean {
|
|
1034
|
+
if (!sel.point) return false;
|
|
1035
|
+
const geom = mesh.geometry;
|
|
1036
|
+
if (!geom) return false;
|
|
1037
|
+
if (!geom.boundingBox) geom.computeBoundingBox();
|
|
1038
|
+
if (!geom.boundingBox) return false;
|
|
1039
|
+
|
|
1040
|
+
// 将选中的三维场景点转入网格局部坐标系进行包围盒检测
|
|
1041
|
+
const pt = new THREE.Vector3(sel.point[0], sel.point[1], sel.point[2]);
|
|
1042
|
+
mesh.updateWorldMatrix(true, false);
|
|
1043
|
+
const inv = mesh.matrixWorld.clone().invert();
|
|
1044
|
+
const localPt = pt.applyMatrix4(inv);
|
|
1045
|
+
|
|
1046
|
+
// 留出 0.5 米容差,避免刚好处于三角网格边缘缝隙时漏判
|
|
1047
|
+
const box = geom.boundingBox.clone().expandByScalar(0.5);
|
|
1048
|
+
return box.containsPoint(localPt);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
880
1051
|
private _replaySelectionOnto(mesh: THREE.Mesh): void {
|
|
881
1052
|
const sel = this._selection;
|
|
882
1053
|
if (!sel) return;
|
|
@@ -893,7 +1064,13 @@ export class S3MViewer {
|
|
|
893
1064
|
// 拾取命中的那块 mesh 用原始 batchId;同一 smid 散在别的几何里时按各自的区间算
|
|
894
1065
|
if (mesh === sel.mesh) batchId = sel.batchId;
|
|
895
1066
|
// 这块几何里根本没有这个单体 → 不动它(否则会把无关瓦片一起点亮)
|
|
896
|
-
if (batchId < 0 && mesh !== sel.mesh)
|
|
1067
|
+
if (batchId < 0 && mesh !== sel.mesh) {
|
|
1068
|
+
if (sel.smid == null && ud.datasetId === sel.datasetId && this._isMeshCoveringSelection(mesh, sel)) {
|
|
1069
|
+
batchId = -1;
|
|
1070
|
+
} else {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
897
1074
|
|
|
898
1075
|
mat.userData.uSelBatch.value = batchId;
|
|
899
1076
|
mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
|
|
@@ -903,44 +1080,56 @@ export class S3MViewer {
|
|
|
903
1080
|
this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
|
|
904
1081
|
}
|
|
905
1082
|
|
|
906
|
-
/**
|
|
1083
|
+
/** 瓦片被淘汰:从拾取表和场景里摘掉,并深度彻底释放所有几何体与材质显存 */
|
|
907
1084
|
private _unregisterTile(node: TileNode, entry: S3MDatasetEntry): void {
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
if (
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const wasSelected = this._selectedMeshes.includes(mesh);
|
|
915
|
-
if (wasSelected) {
|
|
916
|
-
this._selectedMeshes = this._selectedMeshes.filter((m) => m !== mesh);
|
|
1085
|
+
node.group.traverse((child) => {
|
|
1086
|
+
const obj = child as any;
|
|
1087
|
+
if (obj.userData?.pickId != null) {
|
|
1088
|
+
this._pickables.delete(obj.userData.pickId);
|
|
1089
|
+
this._releasePickId(obj.userData.pickId);
|
|
1090
|
+
obj.userData.pickId = undefined;
|
|
917
1091
|
}
|
|
918
|
-
if (
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1092
|
+
if (obj.isMesh) {
|
|
1093
|
+
const wasSelected = this._selectedMeshes.includes(obj);
|
|
1094
|
+
if (wasSelected) {
|
|
1095
|
+
this._selectedMeshes = this._selectedMeshes.filter((m) => m !== obj);
|
|
1096
|
+
}
|
|
1097
|
+
const currentSel = this._selection;
|
|
1098
|
+
if (currentSel && currentSel.mesh === obj) {
|
|
1099
|
+
const smid = currentSel.smid;
|
|
1100
|
+
const carrier = (
|
|
1101
|
+
smid != null
|
|
1102
|
+
? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== obj && m.parent)
|
|
1103
|
+
: this._selectedMeshes.find((m) => m !== obj && m.parent && (m.parent as any).visible) ??
|
|
1104
|
+
this._selectedMeshes.find((m) => m !== obj && m.parent)
|
|
1105
|
+
) as THREE.Mesh | undefined;
|
|
1106
|
+
if (carrier) {
|
|
1107
|
+
// 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
|
|
1108
|
+
this._selection = { ...currentSel, mesh: carrier };
|
|
1109
|
+
this._replaySelectionOnto(carrier);
|
|
1110
|
+
} else {
|
|
1111
|
+
this.select(null);
|
|
1112
|
+
}
|
|
928
1113
|
}
|
|
1114
|
+
this._unindexMeshSmids(obj);
|
|
1115
|
+
this._removeMeshHeight(obj);
|
|
1116
|
+
this._featureStyleManager.disposeMesh(obj);
|
|
1117
|
+
const styled = styledMaterialOf(obj);
|
|
1118
|
+
styled?.dispose?.();
|
|
929
1119
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1120
|
+
// 深度释放材质(点云、普通材质、材质数组)
|
|
1121
|
+
if (isOwnedPointCloudMaterial(obj.material)) {
|
|
1122
|
+
(obj.material as THREE.Material).dispose();
|
|
1123
|
+
} else if (obj.material) {
|
|
1124
|
+
if (Array.isArray(obj.material)) {
|
|
1125
|
+
obj.material.forEach((m: any) => m?.dispose?.());
|
|
1126
|
+
} else {
|
|
1127
|
+
obj.material.dispose?.();
|
|
1128
|
+
}
|
|
934
1129
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
const styled = styledMaterialOf(mesh);
|
|
939
|
-
styled?.dispose?.();
|
|
940
|
-
// 点云材质是本模块按模式建的,不走 styled 那条链
|
|
941
|
-
if (isOwnedPointCloudMaterial(mesh.material)) (mesh.material as THREE.Material).dispose();
|
|
942
|
-
mesh.geometry?.dispose?.();
|
|
943
|
-
}
|
|
1130
|
+
// 释放几何体显存缓冲
|
|
1131
|
+
obj.geometry?.dispose?.();
|
|
1132
|
+
});
|
|
944
1133
|
entry.root.remove(node.group);
|
|
945
1134
|
}
|
|
946
1135
|
|
|
@@ -1981,6 +2170,9 @@ export class S3MViewer {
|
|
|
1981
2170
|
canvasHeight: number;
|
|
1982
2171
|
}): boolean {
|
|
1983
2172
|
if (!this.renderer || !this.scene || !this.camera || !this.sceneAnchor || !this.canvas) return false;
|
|
2173
|
+
// 上下文丢失期间(丢失→恢复之间)不要尝试渲染,否则会刷一堆 GL 错误
|
|
2174
|
+
const gl = this.renderer.getContext() as WebGLRenderingContext | null;
|
|
2175
|
+
if (gl && typeof gl.isContextLost === 'function' && gl.isContextLost()) return false;
|
|
1984
2176
|
|
|
1985
2177
|
// 脏标记正常由微任务刷掉;这里再兜一次底,保证「本帧画出来的」一定是最新样式
|
|
1986
2178
|
this.flushOverrides();
|
|
@@ -216,6 +216,10 @@ export interface TileNode {
|
|
|
216
216
|
parentUrl?: string;
|
|
217
217
|
/** 当前帧是否满足细化条件 */
|
|
218
218
|
needRefine?: boolean;
|
|
219
|
+
/** 该瓦片引用的纹理 key 清单,用于引用计数与显存释放 */
|
|
220
|
+
textureKeys?: string[];
|
|
221
|
+
/** 瓦片构建产生的统计信息,用于淘汰时准确扣减 stats */
|
|
222
|
+
stats?: { meshes: number; vertices: number; triangles: number };
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
/** 瓦片构建器可注入,用于测试 / 服务端渲染(此时不需要 three) */
|
|
@@ -228,6 +232,8 @@ export interface S3MTilesetOptions {
|
|
|
228
232
|
configUrl: string;
|
|
229
233
|
maxTiles?: number;
|
|
230
234
|
maxConcurrent?: number;
|
|
235
|
+
/** 原始图像/子图集缓存上限(LRU),默认 128 */
|
|
236
|
+
maxImageCache?: number;
|
|
231
237
|
buildTile?: BuildTileFn | null;
|
|
232
238
|
uniforms?: StyleUniforms | null;
|
|
233
239
|
onTile?: (node: TileNode) => void;
|
|
@@ -266,6 +272,9 @@ export class S3MTileset {
|
|
|
266
272
|
readonly nodes = new Map<string, TileNode>();
|
|
267
273
|
readonly failed = new Set<string>();
|
|
268
274
|
readonly textureCache = new Map<string, Texture>();
|
|
275
|
+
maxImageCache: number;
|
|
276
|
+
private readonly _textureRefs = new Map<string, number>();
|
|
277
|
+
private readonly _tileTextureKeys = new Map<string, string[]>();
|
|
269
278
|
readonly pending = new Set<string>();
|
|
270
279
|
/** 是否请求 `.texblock` 子图集,默认关,见 S3MTilesetOptions.atlasLod */
|
|
271
280
|
atlasLod: boolean;
|
|
@@ -316,7 +325,7 @@ export class S3MTileset {
|
|
|
316
325
|
/** 格式类永久错误只向上报一次,避免整棵瓦片树刷同样的日志 */
|
|
317
326
|
private _permanentReported = false;
|
|
318
327
|
/** 请求量的硬上限(软上限 maxTiles 的超额余量),见构造函数注释 */
|
|
319
|
-
|
|
328
|
+
hardCap = 96;
|
|
320
329
|
/** 本数据集是 S3M 1.x(SuperMapCache,.s3m):子瓦地址规则与 3.x 不同 */
|
|
321
330
|
private isS3MV1 = false;
|
|
322
331
|
private _buildTile: BuildTileFn | null;
|
|
@@ -341,6 +350,7 @@ export class S3MTileset {
|
|
|
341
350
|
// 并发与总量限制:iServer 公开服务上别把人家打爆
|
|
342
351
|
this.maxConcurrent = options.maxConcurrent ?? 4;
|
|
343
352
|
this.maxTiles = options.maxTiles ?? 64;
|
|
353
|
+
this.maxImageCache = options.maxImageCache ?? 128;
|
|
344
354
|
// 硬上限 = 软上限 + 余量。淘汰只在「新瓦片已经落地并接管显示」之后做(make-before-break),
|
|
345
355
|
// 所以中间态允许临时超出 maxTiles;硬上限只用来挡住失控的请求量,不用于挤掉正在显示的内容。
|
|
346
356
|
this.hardCap = this.maxTiles + Math.max(8, Math.ceil(this.maxTiles * 0.5));
|
|
@@ -419,19 +429,91 @@ export class S3MTileset {
|
|
|
419
429
|
* 两轮都按「最久没进有效范围」排,同帧再比距离。
|
|
420
430
|
* 淘汰最细一级时,如果上一级也不在内存里,顺手把上一级排回队列补洞。
|
|
421
431
|
*/
|
|
432
|
+
/** 记录瓦片引用的纹理 key,并递增其引用计数 */
|
|
433
|
+
registerTileTextures(url: string, textureKeys: string[]): void {
|
|
434
|
+
this._tileTextureKeys.set(url, textureKeys);
|
|
435
|
+
const node = this.nodes.get(url);
|
|
436
|
+
if (node) {
|
|
437
|
+
node.textureKeys = textureKeys;
|
|
438
|
+
}
|
|
439
|
+
for (const key of textureKeys) {
|
|
440
|
+
this._textureRefs.set(key, (this._textureRefs.get(key) ?? 0) + 1);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** 淘汰单块瓦片节点并释放专属纹理 */
|
|
445
|
+
_evictTileNode(victim: TileNode): void {
|
|
446
|
+
this.nodes.delete(victim.url);
|
|
447
|
+
victim.group?.parent?.remove(victim.group);
|
|
448
|
+
this._onTileRemoved(victim);
|
|
449
|
+
this._parentOf.delete(victim.url);
|
|
450
|
+
|
|
451
|
+
this.stats.tiles = Math.max(0, this.stats.tiles - 1);
|
|
452
|
+
if (victim.stats) {
|
|
453
|
+
this.stats.meshes = Math.max(0, this.stats.meshes - (victim.stats.meshes ?? 0));
|
|
454
|
+
this.stats.vertices = Math.max(0, this.stats.vertices - (victim.stats.vertices ?? 0));
|
|
455
|
+
this.stats.triangles = Math.max(0, this.stats.triangles - (victim.stats.triangles ?? 0));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 释放此瓦片引用的纹理计数;引用归零时从 GPU / 缓存彻底 dispose
|
|
459
|
+
const keys = victim.textureKeys ?? this._tileTextureKeys.get(victim.url) ?? (victim.group?.userData?.textureKeys as string[] | undefined);
|
|
460
|
+
this._tileTextureKeys.delete(victim.url);
|
|
461
|
+
if (keys?.length) {
|
|
462
|
+
for (const key of keys) {
|
|
463
|
+
const ref = (this._textureRefs.get(key) ?? 1) - 1;
|
|
464
|
+
if (ref <= 0) {
|
|
465
|
+
this._textureRefs.delete(key);
|
|
466
|
+
const tex = this.textureCache.get(key);
|
|
467
|
+
if (tex) {
|
|
468
|
+
tex.dispose?.();
|
|
469
|
+
this.textureCache.delete(key);
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
this._textureRefs.set(key, ref);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 存入外部图集(带 LRU 容量上限) */
|
|
479
|
+
putExternalAtlas(id: string, img: S3MBlockAtlasImage | null): void {
|
|
480
|
+
if (this.externalAtlasCache.size >= this.maxImageCache && !this.externalAtlasCache.has(id)) {
|
|
481
|
+
const oldestKey = this.externalAtlasCache.keys().next().value;
|
|
482
|
+
if (oldestKey !== undefined) this.externalAtlasCache.delete(oldestKey);
|
|
483
|
+
}
|
|
484
|
+
Map.prototype.set.call(this.externalAtlasCache, id, img);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 读取外部图集 */
|
|
488
|
+
getExternalAtlas(id: string): S3MBlockAtlasImage | null | undefined {
|
|
489
|
+
return this.externalAtlasCache.get(id);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** 存入 leaf 记录段(带 LRU 容量上限) */
|
|
493
|
+
putLeafRecords(name: string, recs: S3MBlockAtlasImage[] | null): void {
|
|
494
|
+
if (this.leafRequestCache.size >= this.maxImageCache && !this.leafRequestCache.has(name)) {
|
|
495
|
+
const oldestKey = this.leafRequestCache.keys().next().value;
|
|
496
|
+
if (oldestKey !== undefined) this.leafRequestCache.delete(oldestKey);
|
|
497
|
+
}
|
|
498
|
+
Map.prototype.set.call(this.leafRequestCache, name, recs);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** 读取 leaf 记录段 */
|
|
502
|
+
getLeafRecords(name: string): S3MBlockAtlasImage[] | null | undefined {
|
|
503
|
+
return this.leafRequestCache.get(name);
|
|
504
|
+
}
|
|
505
|
+
|
|
422
506
|
private _evictOne(allowUncovered = false): boolean {
|
|
507
|
+
|
|
423
508
|
// 只挑安全候选:叶子 → 已被子瓦接手的父瓦。allowUncovered 仅在超出硬上限时开启(内存兜底)
|
|
424
|
-
const victim = this._pickVictim(
|
|
509
|
+
const victim = this._pickVictim(true, allowUncovered) ?? this._pickVictim(false, allowUncovered);
|
|
425
510
|
if (!victim) return false;
|
|
426
511
|
|
|
427
512
|
const parentUrl = victim.parentUrl ?? this._parentOf.get(victim.url);
|
|
428
513
|
const childUrls = victim.childUrls ?? (victim.childUrl ? [victim.childUrl] : []);
|
|
429
514
|
const isLeaf = !childUrls.some((u) => u !== victim.url && this.nodes.has(u));
|
|
430
515
|
|
|
431
|
-
this.
|
|
432
|
-
victim.group.parent?.remove(victim.group);
|
|
433
|
-
this._onTileRemoved(victim);
|
|
434
|
-
this._parentOf.delete(victim.url);
|
|
516
|
+
this._evictTileNode(victim);
|
|
435
517
|
|
|
436
518
|
// 淘汰的是中间层时,把已加载的子孙改挂到它的父级上:
|
|
437
519
|
// 不然它们瞬间"无父",父级那边还留着指向 victim 的 childUrls,
|
|
@@ -459,8 +541,8 @@ export class S3MTileset {
|
|
|
459
541
|
}
|
|
460
542
|
|
|
461
543
|
// 删的是最细一级、而上一级又不在内存里 → 把上一级排回去,别让那块地空着
|
|
462
|
-
if (isLeaf && parentUrl && !this.nodes.has(parentUrl) && !this.pending.has(parentUrl)) {
|
|
463
|
-
|
|
544
|
+
if (false as boolean && isLeaf && parentUrl && !this.nodes.has(parentUrl) && !this.pending.has(parentUrl)) {
|
|
545
|
+
// 淘汰阶段不再重新入队父瓦,防止预算满载时引发父子死循环震荡(thrashing loop)
|
|
464
546
|
}
|
|
465
547
|
return true;
|
|
466
548
|
}
|
|
@@ -773,7 +855,7 @@ export class S3MTileset {
|
|
|
773
855
|
break;
|
|
774
856
|
}
|
|
775
857
|
}
|
|
776
|
-
this.
|
|
858
|
+
this.putExternalAtlas(id, candidate);
|
|
777
859
|
}
|
|
778
860
|
if (candidate) {
|
|
779
861
|
if (accept(candidate)) apply(candidate);
|
|
@@ -818,10 +900,10 @@ export class S3MTileset {
|
|
|
818
900
|
if (segments.length !== names.length) {
|
|
819
901
|
throw new Error(`pack 段数 ${segments.length} ≠ 请求名 ${names.length}`);
|
|
820
902
|
}
|
|
821
|
-
names.forEach((n, i) => this.
|
|
903
|
+
names.forEach((n, i) => this.putLeafRecords(n, segments[i]!));
|
|
822
904
|
} catch (e) {
|
|
823
905
|
console.warn('[s3m] leaf pack 请求失败:', u, e);
|
|
824
|
-
for (const n of names) if (!this.leafRequestCache.has(n)) this.
|
|
906
|
+
for (const n of names) if (!this.leafRequestCache.has(n)) this.putLeafRecords(n, null);
|
|
825
907
|
}
|
|
826
908
|
}
|
|
827
909
|
|
|
@@ -833,10 +915,10 @@ export class S3MTileset {
|
|
|
833
915
|
const res = await fetch(u);
|
|
834
916
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
835
917
|
const records = (await parseTexblockSegments(await res.arrayBuffer())).flat();
|
|
836
|
-
this.
|
|
918
|
+
this.putLeafRecords(stem, records);
|
|
837
919
|
} catch (e) {
|
|
838
920
|
console.warn('[s3m] leaf texblock 请求失败:', u, e);
|
|
839
|
-
if (!this.leafRequestCache.has(stem)) this.
|
|
921
|
+
if (!this.leafRequestCache.has(stem)) this.putLeafRecords(stem, null);
|
|
840
922
|
}
|
|
841
923
|
}
|
|
842
924
|
|
|
@@ -898,7 +980,10 @@ export class S3MTileset {
|
|
|
898
980
|
}
|
|
899
981
|
|
|
900
982
|
const build = await this._builder();
|
|
901
|
-
const
|
|
983
|
+
const buildRes = await build(tile, { textureCache: this.textureCache, uniforms: this._uniforms });
|
|
984
|
+
const { group, stats } = buildRes;
|
|
985
|
+
const texKeys = (buildRes as any).textureKeys ?? (group.userData?.textureKeys as string[] | undefined) ?? Object.keys(tile.textures ?? {});
|
|
986
|
+
this.registerTileTextures(url, texKeys);
|
|
902
987
|
const lod = this._pickLod(tile);
|
|
903
988
|
|
|
904
989
|
const childUrls: string[] = [];
|
|
@@ -932,6 +1017,11 @@ export class S3MTileset {
|
|
|
932
1017
|
childUrls,
|
|
933
1018
|
parentUrl: this._parentOf.get(url),
|
|
934
1019
|
state: 'ready',
|
|
1020
|
+
stats: {
|
|
1021
|
+
meshes: stats.meshes ?? 0,
|
|
1022
|
+
vertices: stats.vertices ?? 0,
|
|
1023
|
+
triangles: stats.triangles ?? 0,
|
|
1024
|
+
},
|
|
935
1025
|
};
|
|
936
1026
|
|
|
937
1027
|
this.nodes.set(url, node);
|