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.
@@ -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 _keyLight: THREE.DirectionalLight | null = null;
298
- private _fillLight: THREE.DirectionalLight | null = null;
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
- mount(canvas: HTMLCanvasElement, gl: WebGLRenderingContext | WebGL2RenderingContext): void {
362
- this.canvas = canvas;
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
- this.renderer.autoClear = false;
370
- this.renderer.setPixelRatio(1);
434
+ r.autoClear = false;
435
+ r.setPixelRatio(1);
371
436
  // 白模 albedo 接近 1,不做色调映射会过曝成一片纯白、丢失明暗关系
372
- this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
373
- this.renderer.toneMappingExposure = 1.0;
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(new THREE.AmbientLight(0xffffff, 0.4));
419
- this.scene.add(new THREE.HemisphereLight(0xffffff, 0xb6c0cb, 0.8));
420
- // 主光 / 补光的方向每帧按地图朝向重算(见 _updateLights),
421
- // 这样旋转视角时白模始终是亮的,不会转到背光面就一片死灰。
422
- this._keyLight = new THREE.DirectionalLight(0xffffff, 1.0);
423
- this._fillLight = new THREE.DirectionalLight(0xffffff, 0.35);
424
- this.scene.add(this._keyLight, this._fillLight);
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
- const bearing = ((this._getBearing?.() ?? 0) * Math.PI) / 180;
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, 150, 50);
441
- place(this._fillLight, 215, 30);
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) return;
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
- /** 瓦片被淘汰:从拾取表和场景里摘掉,并释放它自己的 GPU 资源 */
1083
+ /** 瓦片被淘汰:从拾取表和场景里摘掉,并深度彻底释放所有几何体与材质显存 */
907
1084
  private _unregisterTile(node: TileNode, entry: S3MDatasetEntry): void {
908
- for (const child of [...node.group.children]) {
909
- const mesh = child as THREE.Mesh;
910
- if (!mesh.isMesh) continue;
911
- const ud = mesh.userData as MeshUserData;
912
- // 「这块 mesh 没了」≠「用户不再选中」:LOD 细化 / 淘汰回补都会换掉 mesh。
913
- // 以前这里一律 select(null),于是点中的那栋楼一拉近,选中状态就整个被清掉。
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 (this._selection?.mesh === mesh) {
919
- const smid = this._selection.smid;
920
- const carrier =
921
- smid != null ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== mesh && m.parent) : undefined;
922
- if (carrier) {
923
- // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
924
- this._selection = { ...this._selection, mesh: carrier };
925
- this._replaySelectionOnto(carrier);
926
- } else {
927
- this.select(null);
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
- if (ud.pickId != null) {
931
- this._pickables.delete(ud.pickId);
932
- this._releasePickId(ud.pickId);
933
- ud.pickId = undefined;
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
- this._unindexMeshSmids(mesh);
936
- this._removeMeshHeight(mesh);
937
- this._featureStyleManager.disposeMesh(mesh);
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
- private hardCap = 96;
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(false, allowUncovered) ?? this._pickVictim(true, allowUncovered);
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.nodes.delete(victim.url);
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
- this._enqueue(parentUrl);
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.externalAtlasCache.set(id, candidate);
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.leafRequestCache.set(n, segments[i]!));
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.leafRequestCache.set(n, null);
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.leafRequestCache.set(stem, records);
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.leafRequestCache.set(stem, null);
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 { group, stats } = await build(tile, { textureCache: this.textureCache, uniforms: this._uniforms });
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);