s3m-three 0.3.14 → 0.4.16

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.
@@ -24,6 +24,7 @@ import * as THREE from 'three';
24
24
  import { S3MTileset, usableGeoBounds } from '../tileset/S3MTileset.js';
25
25
  import type { BuildTileFn, TileNode } from '../tileset/S3MTileset.js';
26
26
  import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap } from './TextureFactory.js';
27
+ import { setMaterialQuality } from './StyleMaterial.js';
27
28
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
28
29
  import {
29
30
  STYLE,
@@ -32,10 +33,22 @@ import {
32
33
  DEFAULT_STYLE,
33
34
  createSharedUniforms,
34
35
  createOutlineMaterial,
36
+ resolveEdgeModes,
37
+ sameEdgeModes,
38
+ } from './StyleMaterial.js';
39
+ import type {
40
+ OutlineMaterial,
41
+ OutlineStyleUniforms,
42
+ StyledMaterial,
43
+ StyleMode,
44
+ SelectMode,
45
+ S3MStyle,
46
+ EdgeModes,
47
+ EdgesOption,
35
48
  } from './StyleMaterial.js';
36
- import type { OutlineMaterial, StyledMaterial, StyleMode, SelectMode, S3MStyle } from './StyleMaterial.js';
37
49
  import { applyPointCloudStyle, isOwnedPointCloudMaterial } from './PointCloudMaterial.js';
38
50
  import { Picker } from './Picker.js';
51
+ import { GpuEdgePass } from './GpuEdgePass.js';
39
52
  import { Animator } from '../anim/Animator.js';
40
53
  import type { Channel, TransitionOptions } from '../anim/Animator.js';
41
54
  import { Path } from '../anim/Path.js';
@@ -235,6 +248,12 @@ export interface S3MViewerOptions {
235
248
  * 见 `S3MTilesetOptions.maxTriangles`:瓦片数管不住的场景(精模、大面片)用这条刹车。
236
249
  */
237
250
  maxTriangles?: number;
251
+ /**
252
+ * 纹理缓存条数上限(默认 128)。一条对应一张已解码的瓦片纹理,
253
+ * 1024² 的 RGBA 一张约 4MB,128 条就是 512MB 显存 —— 低端设备建议 32~48。
254
+ * 与 `maxTextureSize` 配合使用:后者降单张尺寸,这条降张数。
255
+ */
256
+ maxImageCache?: number;
238
257
  /**
239
258
  * 各向异性过滤上限(默认不封顶,取 GPU 报的最大值,通常 16)。
240
259
  * 移动端封到 4 或 8 肉眼通常无差,采样成本明显下降。只影响**之后创建**的纹理。
@@ -246,6 +265,17 @@ export interface S3MViewerOptions {
246
265
  * 图集(UV 已重映射)不受影响。手机上 1024 与 2048 肉眼几乎无差,显存差 4 倍。
247
266
  */
248
267
  maxTextureSize?: number;
268
+ /**
269
+ * 光照模型(默认 `'pbr'`)。
270
+ *
271
+ * - `'pbr'`:`MeshStandardMaterial`,GGX + 环境球谐,质感最好也最贵
272
+ * - `'lambert'`:`MeshLambertMaterial`,只保留漫反射 + 平行光,片元开销约省一半;
273
+ * 白模 / 纯色 / 按高度着色这类不需要高光的模式肉眼差别很小
274
+ *
275
+ * 模块级全局状态:同页多个图层会互相覆盖,后创建的那个决定全局。
276
+ * 只影响**之后创建**的材质 —— 已加载的瓦片要重新加载才会换。
277
+ */
278
+ materialQuality?: 'pbr' | 'lambert';
249
279
  /** 自定义瓦片构建器(测试 / 服务端渲染时注入) */
250
280
  buildTile?: BuildTileFn | null;
251
281
  /**
@@ -290,12 +320,18 @@ interface MeshUserData {
290
320
  isPointCloud?: boolean;
291
321
  /** CPU 端现算的棱线对象(等宽线 Mesh) */
292
322
  edges?: THREE.Object3D | null;
323
+ /** 背面外扩轮廓壳(edges 含 'hull' 时挂上) */
324
+ edgeHull?: THREE.Mesh | null;
293
325
  /** 数据自带棱线包(此时不再用 EdgesGeometry 现算) */
294
326
  nativeEdges?: boolean;
327
+ /** 数据本身有没有棱线包(不受 forceCpuEdges 影响,用来运行中切换时重算 nativeEdges) */
328
+ nativeEdgesRaw?: boolean;
295
329
  /** 数据自带的棱线对象本身 */
296
330
  isEdgeLines?: boolean;
297
331
  /** CPU 现算的等宽棱线(与 isEdgeLines 区分,便于刷新样式) */
298
332
  isThickEdge?: boolean;
333
+ /** 背面外扩轮廓壳(edges 含 'hull' 时挂上) */
334
+ isEdgeHull?: boolean;
299
335
  /** 选中单体的棱线对象 */
300
336
  isSelectionEdge?: boolean;
301
337
  /** 该网格正在淡入时占用的动画通道 key,瓦片淘汰时要回收 */
@@ -350,6 +386,8 @@ const STYLE_SCALAR_KEYS = [
350
386
  'ambientGain',
351
387
  'edgeWidth',
352
388
  'edgeOpacity',
389
+ 'edgeOffset',
390
+ 'edgeHullWidth',
353
391
  'outlineWidth',
354
392
  ] as const;
355
393
 
@@ -414,6 +452,8 @@ export class S3MViewer {
414
452
  private readonly _edgeStyleUniforms: ThickLineStyleUniforms;
415
453
  /** 选中棱线的外观 uniform */
416
454
  private readonly _selEdgeStyleUniforms: ThickLineStyleUniforms;
455
+ /** 全局外扩轮廓的外观 uniform:逐网格轮廓壳共享这些对象 */
456
+ private readonly _hullStyleUniforms: OutlineStyleUniforms;
417
457
 
418
458
  /** 3D 物理地表面 / 基坑开挖管理器 */
419
459
  readonly groundManager: GroundManager;
@@ -455,6 +495,12 @@ export class S3MViewer {
455
495
  private _selectionEdges: THREE.Mesh | null = null;
456
496
  private _selectionEdgeMeshes: THREE.Mesh[] = [];
457
497
  private _edgesEnabled = false;
498
+ /** 当前生效的棱线画法(line / offset / hull / gpu),决定要不要建 / 拆哪种装饰 */
499
+ private _edgeModes: EdgeModes = resolveEdgeModes(false);
500
+ /** GPU 描边后处理(深度 + 法线),只在 edges 含 'gpu' 时按帧执行 */
501
+ private _gpuEdge: GpuEdgePass | null = null;
502
+ /** 给 GPU 描边传色用的临时 Color,避免每帧 new */
503
+ private readonly _gpuEdgeColor = new THREE.Color();
458
504
  /** 瓦片淡入通道的自增序号(不用 pickId:pickId 会被回收复用) */
459
505
  private _tileFadeSeq = 0;
460
506
  /** setMode 编排的代次,用来作废被新调用顶掉的旧编排 */
@@ -492,6 +538,8 @@ export class S3MViewer {
492
538
  attributeQuery: AttributeQuery | null = null;
493
539
 
494
540
  private readonly _enuMatrix = new THREE.Matrix4();
541
+ /** 每帧复用:MVP 来自宿主,不要每帧 new(高频分配会带来 GC 抖动) */
542
+ private readonly _frameMvp = new THREE.Matrix4();
495
543
  /** 颜色换算用的临时对象,避免每帧 new */
496
544
  private readonly _colorScratch = new THREE.Color();
497
545
  private _needsRepaint = true;
@@ -540,6 +588,7 @@ export class S3MViewer {
540
588
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
541
589
  this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
542
590
  setTextureSizeCap(options.maxTextureSize ?? Infinity);
591
+ setMaterialQuality(options.materialQuality ?? 'pbr');
543
592
  if (options.light) {
544
593
  Object.assign(this._lightOptions, options.light);
545
594
  }
@@ -591,6 +640,7 @@ export class S3MViewer {
591
640
  uColor: { value: new THREE.Color(this.style.edgeColor) },
592
641
  uWidth: { value: Math.max(0.1, this.style.edgeWidth ?? 1.5) },
593
642
  uOpacity: { value: 0.6 },
643
+ uOffset: { value: this.style.edgeOffset ?? 0.15 },
594
644
  };
595
645
  this.edgeMaterial = createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms);
596
646
  this._selEdgeStyleUniforms = {
@@ -600,11 +650,17 @@ export class S3MViewer {
600
650
  uElevation: { value: this.style.selectElevation ?? 0 },
601
651
  };
602
652
  this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms);
653
+ // 全局外扩轮廓的外观 uniform:逐网格轮廓壳复用这几个对象,改一次全场景生效
654
+ this._hullStyleUniforms = {
655
+ uColor: { value: new THREE.Color(this.style.edgeColor) },
656
+ uWidth: { value: Math.max(0, this.style.edgeHullWidth ?? 0.9) },
657
+ uOpacity: { value: 0.6 },
658
+ };
603
659
 
604
660
  // 关键:style.edges 是个开关,但构造函数从没据此启用过棱线,导致
605
661
  // `style: { edges: true }` 形同虚设、_edgesEnabled 永远是 false。
606
662
  // 这里按 style 实际值调一次 setEdges,让开关真正生效(与 setEdges 后来的手工调用一致)。
607
- this.setEdges(!!this.style.edges);
663
+ this.setEdges(this.style.edges);
608
664
 
609
665
  // 初始化图层整体不透明度:确保共享 uniform uOpacity、材质混合开关与棱线外观与初始 style.opacity 严格一致
610
666
  this._applyOpacity();
@@ -691,6 +747,9 @@ export class S3MViewer {
691
747
  this._syncTextureCapabilities(gl);
692
748
  this.picker?.dispose();
693
749
  this.picker = new Picker(this.renderer);
750
+ // GPU 描边的 RT / 材质绑定在旧上下文上,整体丢弃,下一帧按需重建
751
+ this._gpuEdge?.dispose();
752
+ this._gpuEdge = null;
694
753
  // 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
695
754
  // 这里只确保下一帧尽快驱动一次渲染。
696
755
  this._requestRepaint();
@@ -704,6 +763,8 @@ export class S3MViewer {
704
763
  this.picker?.dispose();
705
764
  this.picker = null;
706
765
  this.outlineMaterial.dispose();
766
+ this._gpuEdge?.dispose();
767
+ this._gpuEdge = null;
707
768
  this.edgeMaterial.dispose();
708
769
  this.selectionEdgeMaterial.dispose();
709
770
  this.scene?.clear();
@@ -885,7 +946,8 @@ export class S3MViewer {
885
946
  this._requestRepaint();
886
947
  },
887
948
  },
888
- this.options.maxTriangles ?? Infinity
949
+ this.options.maxTriangles ?? Infinity,
950
+ this.options.maxImageCache ?? 128
889
951
  );
890
952
  return entry;
891
953
  }
@@ -1747,12 +1809,18 @@ export class S3MViewer {
1747
1809
  node.group.updateMatrixWorld(true);
1748
1810
 
1749
1811
  const hasNativeEdges = node.group.userData.hasNativeEdges === true;
1812
+ // forceCpuEdges:无视数据自带的棱线包,改由 CPU 现算。native 是 1px 的
1813
+ // LineBasicMaterial,edgeWidth / 外扩 / 单体跟随全都吃不上。
1814
+ // gpu 画法由 shader 自己描边,native 线段包同样不能留
1815
+ const useNativeEdges =
1816
+ hasNativeEdges && this.style.forceCpuEdges !== true && !this._edgeModes.gpu;
1750
1817
 
1751
1818
  for (const child of node.group.children) {
1752
1819
  // 数据自带的棱线包:只跟着 edges 开关显示,不参与拾取、不参与着色
1753
1820
  if ((child as THREE.LineSegments).isLineSegments && (child.userData as MeshUserData).isEdgeLines) {
1754
- child.visible = this._edgesEnabled;
1821
+ // 一律登记进 _nativeEdgeLines:forceCpuEdges 之后又关掉时还要能把它们放出来
1755
1822
  this._nativeEdgeLines.add(child as THREE.LineSegments);
1823
+ child.visible = useNativeEdges && this._edgesEnabled;
1756
1824
  this._styleEdgeLine(child as THREE.LineSegments);
1757
1825
  continue;
1758
1826
  }
@@ -1765,7 +1833,8 @@ export class S3MViewer {
1765
1833
  const ud = mesh.userData as MeshUserData;
1766
1834
  ud.pickId = this._allocPickId();
1767
1835
  ud.datasetId = entry.id;
1768
- ud.nativeEdges = hasNativeEdges;
1836
+ ud.nativeEdges = useNativeEdges;
1837
+ ud.nativeEdgesRaw = hasNativeEdges;
1769
1838
  this._pickables.set(ud.pickId, {
1770
1839
  datasetId: entry.id,
1771
1840
  mesh,
@@ -1777,8 +1846,11 @@ export class S3MViewer {
1777
1846
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义
1778
1847
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义。
1779
1848
  // 自动描边受三道开关控制:全局 style.edges / 数据集 enableEdges / 地形绿地等关键字跳过。
1780
- if (!hasNativeEdges && !isPoints && this._datasetEdgesEnabled(entry) && !this._shouldSkipAutoEdges(ud, entry.name)) {
1781
- this._ensureEdges(mesh);
1849
+ if (!useNativeEdges && !isPoints && this._datasetEdgesEnabled(entry) && !this._shouldSkipAutoEdges(ud, entry.name)) {
1850
+ // 数据集强制开启(enableEdges: true)时全局画法可能还是关的,退回默认折边线;
1851
+ // 但 gpu 画法本来就不建几何,别在这里多此一举
1852
+ if (this._edgeModes.line || this._edgeModes.hull) this._ensureEdgeDecoration(mesh);
1853
+ else if (!this._edgeModes.gpu) this._ensureEdges(mesh);
1782
1854
  }
1783
1855
 
1784
1856
  // 新瓦片要继承当前的不透明度与着色方式
@@ -2017,7 +2089,7 @@ export class S3MViewer {
2017
2089
  if ('edges' in patch) {
2018
2090
  const { edges, ...others } = patch;
2019
2091
  rest = others;
2020
- this.setEdges(!!edges, wantAnim ? transition : undefined);
2092
+ this.setEdges(edges as EdgesOption, wantAnim ? transition : undefined);
2021
2093
  if (Object.keys(rest).length === 0) {
2022
2094
  this._requestRepaint();
2023
2095
  return;
@@ -2135,7 +2207,13 @@ export class S3MViewer {
2135
2207
  return;
2136
2208
  }
2137
2209
  this._applyStyleUniforms();
2138
- if (key === 'edgeColor' || key === 'edgeWidth' || key === 'edgeOpacity') {
2210
+ if (
2211
+ key === 'edgeColor' ||
2212
+ key === 'edgeWidth' ||
2213
+ key === 'edgeOpacity' ||
2214
+ key === 'edgeOffset' ||
2215
+ key === 'edgeHullWidth'
2216
+ ) {
2139
2217
  this._refreshEdgeLines();
2140
2218
  }
2141
2219
  }
@@ -2169,10 +2247,18 @@ export class S3MViewer {
2169
2247
  // 临界角变了要重建 CPU 棱线(EdgesGeometry 的参数),开 / 关走 setEdges 整体刷新。
2170
2248
  // 必须先 _disposeEdges():setEdges 只挑「还没有棱线」的网格来建,
2171
2249
  // 不先拆掉的话已经建好的会被全部跳过,临界角改了等于没改。
2172
- if ('edgeThreshold' in patch && this._edgesEnabled) {
2250
+ if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edgesEnabled) {
2173
2251
  this._disposeEdges();
2174
- this.setEdges(true);
2175
- } else if ('edgeColor' in patch || 'edgeWidth' in patch || 'opacity' in patch || 'edgeOpacity' in patch) {
2252
+ this.setEdges(this.style.edges);
2253
+ } else if (
2254
+ 'edgeColor' in patch ||
2255
+ 'edgeWidth' in patch ||
2256
+ 'opacity' in patch ||
2257
+ 'edgeOpacity' in patch ||
2258
+ 'edgeOffset' in patch ||
2259
+ 'edgeHullWidth' in patch
2260
+ ) {
2261
+ // 颜色 / 粗细 / 外推距离 / 外扩厚度都只是 uniform,拖着滑块逐帧改也不重建几何
2176
2262
  this._refreshEdgeLines();
2177
2263
  }
2178
2264
  if (this._selection) {
@@ -2311,7 +2397,7 @@ export class S3MViewer {
2311
2397
  const camera = this.camera;
2312
2398
  if (!r || !scene || !camera) return;
2313
2399
 
2314
- const mats: THREE.MeshStandardMaterial[] = [];
2400
+ const mats: StyledMaterial[] = [];
2315
2401
  const seen = new Set<THREE.Material>();
2316
2402
  for (const p of this._pickables.values()) {
2317
2403
  const m = styledMaterialOf(p.mesh);
@@ -2437,8 +2523,15 @@ export class S3MViewer {
2437
2523
  /**
2438
2524
  * 全局棱线开关。
2439
2525
  *
2440
- * 数据自带棱线包(primitiveType 13)时直接用,成本为零;
2441
- * 没有的话才用 EdgesGeometry 现算 —— 那是 CPU 端的活,几十万面一次性建会卡住主线程
2526
+ * 取值是 `boolean | EdgeStyle | EdgeStyle[]`(见 resolveEdgeModes):
2527
+ * - `true` / `'line'` 折边线(旧口径)
2528
+ * - `'offset'` 折边线 + 沿边法线外推 `edgeOffset` 米
2529
+ * - `'hull'` 背面外扩轮廓(选中侧 outlineStyle:'hull' 的整场景版)
2530
+ * - `'gpu'` 纯片元描边:零几何、零额外 pass,线宽约 1 像素
2531
+ * - 数组 叠加,例如 `['line', 'hull']`
2532
+ *
2533
+ * 数据自带棱线包(primitiveType 13)时直接用,成本为零;`style.forceCpuEdges`
2534
+ * 可以无视它,改由 CPU 现算 —— 那是 CPU 端的活,几十万面一次性建会卡住主线程
2442
2535
  * (实测 20 个网格 1.9s),所以按 8ms 一帧切片,边建边出。
2443
2536
  *
2444
2537
  * 棱线本身用「屏幕空间等宽线」画(见 EdgeLines.ts):WebGL 的 lineWidth 基本无效,
@@ -2448,25 +2541,37 @@ export class S3MViewer {
2448
2541
  * 开:立刻置位 + 开始分片建线,同时 `uEdgeFade` 0→1 —— 建好一块亮一块,视觉上是渐显;
2449
2542
  * 关:`uEdgeFade` 1→0,淡完才 dispose,避免棱线「啪」一下消失。
2450
2543
  */
2451
- setEdges(enabled: boolean, transition?: TransitionOptions): void {
2544
+ setEdges(edges: EdgesOption, transition?: TransitionOptions): void {
2452
2545
  const wantAnim = !!transition && ((transition.duration ?? 0) > 0 || !!transition.spring);
2453
- this._edgesEnabled = enabled;
2454
- this.style.edges = enabled;
2455
- this._styleTarget.edges = enabled;
2456
-
2457
- // 数据自带的棱线:直接切可见性
2458
- for (const entry of this.datasets.values()) {
2459
- entry.root.traverse((o) => {
2460
- const ud = o.userData as MeshUserData;
2461
- if (ud.isEdgeLines) {
2462
- o.visible = enabled;
2463
- this._styleEdgeLine(o as THREE.LineSegments);
2464
- }
2465
- });
2546
+ const modes = resolveEdgeModes(edges);
2547
+ // 换画法(line ↔ hull ↔ offset)是结构变更:旧装饰必须拆掉再建,
2548
+ // 否则会出现「切到 hull 了折边线还留着」这种叠影。
2549
+ // 同一画法重复调用(滑块逐帧 setEdges(true))不重建,几何是复用的。
2550
+ const modesChanged = !sameEdgeModes(this._edgeModes, modes);
2551
+
2552
+ this._edgeModes = modes;
2553
+ this._edgesEnabled = modes.enabled;
2554
+ this.style.edges = edges;
2555
+ this._styleTarget.edges = edges;
2556
+
2557
+ // 数据自带的棱线:直接切可见性(forceCpuEdges 时永远收起,改由 CPU 现算)。
2558
+ // 只遍历注册时收集好的那批 LineSegments —— 全数据集 traverse 在几千节点下太贵。
2559
+ // gpu 模式下 shader 自己描边,native 线段包必须收起,否则叠成双线
2560
+ const nativeVisible = modes.enabled && !modes.gpu && this.style.forceCpuEdges !== true;
2561
+ for (const lines of this._nativeEdgeLines) {
2562
+ lines.visible = nativeVisible;
2563
+ this._styleEdgeLine(lines);
2466
2564
  }
2467
2565
  this._syncEdgeStyle();
2468
2566
 
2469
- if (!enabled) {
2567
+ // 关掉 gpu 画法就把后处理资源还回去(整屏 RT 白占显存没意义)。
2568
+ // 必须放在「整体关闭」的早退之前,否则关棱线时永远轮不到它。
2569
+ if (!modes.gpu && this._gpuEdge) {
2570
+ this._gpuEdge.dispose();
2571
+ this._gpuEdge = null;
2572
+ }
2573
+
2574
+ if (!modes.enabled) {
2470
2575
  if (wantAnim) {
2471
2576
  this.animator.to('style.edgeFade', 0, {
2472
2577
  ...transition,
@@ -2484,17 +2589,23 @@ export class S3MViewer {
2484
2589
  return;
2485
2590
  }
2486
2591
 
2592
+ if (modesChanged) this._disposeEdges();
2593
+
2487
2594
  const pending = [...this._pickables.values()]
2488
2595
  .map((p) => p.mesh)
2489
2596
  .filter((m) => {
2490
2597
  const ud = m.userData as MeshUserData;
2491
2598
  // 点云没有棱可描
2492
- return !ud.edges && !ud.nativeEdges && !isPointCloudObject(m);
2599
+ if (isPointCloudObject(m)) return false;
2600
+ // native 棱线还算不算数,取决于**当前**的 forceCpuEdges(它可以在运行中切换),
2601
+ // 所以这里按注册时记下的原始标记重算,而不是沿用注册那一刻的结论。
2602
+ ud.nativeEdges = ud.nativeEdgesRaw === true && this.style.forceCpuEdges !== true;
2603
+ return !ud.nativeEdges && this._needsEdgeDecoration(ud);
2493
2604
  });
2494
2605
  let i = 0;
2495
2606
  const step = (): void => {
2496
2607
  const t0 = performance.now();
2497
- while (i < pending.length && performance.now() - t0 < 8) this._ensureEdges(pending[i++]);
2608
+ while (i < pending.length && performance.now() - t0 < 8) this._ensureEdgeDecoration(pending[i++]);
2498
2609
  if (i < pending.length) setTimeout(step, 0);
2499
2610
  else this._requestRepaint();
2500
2611
  };
@@ -2567,10 +2678,11 @@ export class S3MViewer {
2567
2678
  }
2568
2679
 
2569
2680
  /**
2570
- * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity。
2681
+ * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity / edgeOffset。
2571
2682
  *
2572
- * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存,
2573
- * 所以棱线淡入淡出只要改一个 uniform,新建瓦片自动继承当前进度。
2683
+ * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存
2684
+ * + 一批共享外观对象的轮廓壳,所以棱线淡入淡出只要改一个 uniform,
2685
+ * 新建瓦片自动继承当前进度。
2574
2686
  */
2575
2687
  private _syncEdgeStyle(): void {
2576
2688
  const s = this.style;
@@ -2583,6 +2695,14 @@ export class S3MViewer {
2583
2695
  u.uColor.value.set(s.edgeColor);
2584
2696
  u.uWidth.value = Math.max(0.1, s.edgeWidth ?? 1.5);
2585
2697
  u.uOpacity.value = opacity;
2698
+ // 外推距离只在 offset 画法下非 0;其它画法把线钉在面上(与改动前一致)
2699
+ u.uOffset.value = this._edgeModes.offset ? Math.max(0, s.edgeOffset ?? 0.15) : 0;
2700
+ // 外扩轮廓壳与折边线共用颜色 / 透明度,厚度是米制的单独一项
2701
+ const h = this._hullStyleUniforms;
2702
+ h.uColor.value.set(s.edgeColor);
2703
+ h.uWidth.value = Math.max(0, s.edgeHullWidth ?? 0.9);
2704
+ h.uOpacity.value = opacity;
2705
+ // GPU 描边(edges: 'gpu')是后处理,参数每帧从 style 现取,这里不用同步 uniform
2586
2706
  // 预制棱线缓存(LineBasicMaterial)同步颜色 / 不透明度
2587
2707
  if (this._creaseLines) {
2588
2708
  const m = this._creaseLines.material as THREE.LineBasicMaterial;
@@ -2642,6 +2762,23 @@ export class S3MViewer {
2642
2762
  return S3MViewer._EDGE_SKIP_KEYWORDS.some((k) => hay.includes(k));
2643
2763
  }
2644
2764
 
2765
+ /**
2766
+ * 这块网格还缺哪种棱线装饰(缺就建,齐了就不动)。
2767
+ *
2768
+ * setEdges 的分片建线靠它筛「还没建完的」,注册新瓦片也靠它决定要不要动手。
2769
+ */
2770
+ private _needsEdgeDecoration(ud: MeshUserData): boolean {
2771
+ if (this._edgeModes.line && !ud.edges) return true;
2772
+ if (this._edgeModes.hull && !ud.edgeHull) return true;
2773
+ return false;
2774
+ }
2775
+
2776
+ /** 按当前画法补齐这块网格的棱线装饰 */
2777
+ private _ensureEdgeDecoration(mesh: THREE.Mesh): void {
2778
+ if (this._edgeModes.line) this._ensureEdges(mesh);
2779
+ if (this._edgeModes.hull) this._ensureHull(mesh);
2780
+ }
2781
+
2645
2782
  private _ensureEdges(mesh: THREE.Mesh): void {
2646
2783
  const ud = mesh.userData as MeshUserData;
2647
2784
  if (ud.edges || ud.nativeEdges || !mesh.geometry) return;
@@ -2661,12 +2798,22 @@ export class S3MViewer {
2661
2798
  const welded = mergeVerticesByPosition(mesh.geometry, 0.005);
2662
2799
  // 2. 调大临界角(默认 55°,可在 style.edgeThreshold 调),过滤浅坡与曲面细分,只保留真正的棱。
2663
2800
  const thresholdAngle = this.style.edgeThreshold ?? 55;
2664
- const source = new THREE.EdgesGeometry(welded, thresholdAngle);
2665
- welded.dispose();
2666
2801
 
2667
- const attr = source.getAttribute('position') as THREE.BufferAttribute | undefined;
2668
- const segments = attr ? (attr.array as Float32Array) : null;
2669
- source.dispose();
2802
+ // 'offset' 画法要额外带一条「边法线」才知道往哪推,所以走本地实现(它本来就算好了
2803
+ // 相邻两面法线);其余画法保持 EdgesGeometry 这条老路,几何输出与改动前逐字一致。
2804
+ let segments: Float32Array | null;
2805
+ let normals: Float32Array | undefined;
2806
+ if (this._edgeModes.offset) {
2807
+ const raw: number[] = [];
2808
+ segments = buildFeatureEdgeSegments(welded, null, thresholdAngle, raw);
2809
+ if (raw.length > 0) normals = new Float32Array(raw);
2810
+ } else {
2811
+ const source = new THREE.EdgesGeometry(welded, thresholdAngle);
2812
+ const attr = source.getAttribute('position') as THREE.BufferAttribute | undefined;
2813
+ segments = attr ? (attr.array as Float32Array) : null;
2814
+ source.dispose();
2815
+ }
2816
+ welded.dispose();
2670
2817
  if (!segments || segments.length < 6) return;
2671
2818
 
2672
2819
  // 3. 回填每段棱线所属的单体 batchId。
@@ -2686,7 +2833,7 @@ export class S3MViewer {
2686
2833
  }
2687
2834
 
2688
2835
  const lines = new THREE.Mesh(
2689
- buildThickLineGeometry(segments, batchIds),
2836
+ buildThickLineGeometry(segments, batchIds, normals),
2690
2837
  this._createEdgeMaterial(mesh)
2691
2838
  );
2692
2839
  lines.renderOrder = 2;
@@ -2698,6 +2845,49 @@ export class S3MViewer {
2698
2845
  mesh.add(lines); // 作为子节点,自动继承 mesh 的世界变换
2699
2846
  }
2700
2847
 
2848
+ /**
2849
+ * 给一块网格挂「背面外扩轮廓」(整场景版)。
2850
+ *
2851
+ * 复用选中侧那套 `createOutlineMaterial`:顶点沿法线鼓 `edgeHullWidth` 米、只画背面,
2852
+ * 于是被本体挡住的部分不显示,露出来的那圈就是描边。与折边线的差别在于——
2853
+ * 这里**不建任何新几何**,成本只是把同一块网格多画一遍,折边判定那套 CPU 活全省了。
2854
+ *
2855
+ * 实例化几何拿不到逐实例变换(轮廓壳是 mesh 的子节点,不吃 instanceMatrix),
2856
+ * 直接跳过:宁可这栋没描边,也不要所有实例的壳叠在原点。
2857
+ */
2858
+ private _ensureHull(mesh: THREE.Mesh): void {
2859
+ const ud = mesh.userData as MeshUserData;
2860
+ if (ud.edgeHull || !mesh.geometry) return;
2861
+ if ((mesh as unknown as { isInstancedMesh?: boolean }).isInstancedMesh) return;
2862
+ // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
2863
+ if (this._shouldSkipAutoEdges(ud, '')) return;
2864
+
2865
+ // 逐网格一份材质(各绑各的单体状态纹理),外观 uniform 复用共享对象
2866
+ const mat = createOutlineMaterial(
2867
+ this.uniforms,
2868
+ this.style.edgeHullWidth ?? 0.9,
2869
+ this._hullStyleUniforms,
2870
+ true
2871
+ );
2872
+ // 整块几何都要外扩:选中侧靠 uSelBatch 只扩一栋,全局必须置 whole
2873
+ mat.uniforms.uSelWhole.value = 1;
2874
+ mat.uniforms.uSelBatch.value = -1;
2875
+ const styled = styledMaterialOf(mesh);
2876
+ if (styled?.userData?.uBatchTex) {
2877
+ mat.uniforms.uBatchTex.value = styled.userData.uBatchTex.value as THREE.Texture;
2878
+ mat.uniforms.uBatchTexWidth.value = styled.userData.uBatchTexWidth.value as number;
2879
+ mat.uniforms.uBatchTexActive.value = styled.userData.uBatchTexActive.value as number;
2880
+ }
2881
+
2882
+ const hull = new THREE.Mesh(mesh.geometry, mat);
2883
+ hull.renderOrder = 2;
2884
+ hull.frustumCulled = false;
2885
+ hull.raycast = () => {};
2886
+ (hull.userData as MeshUserData).isEdgeHull = true;
2887
+ ud.edgeHull = hull;
2888
+ mesh.add(hull); // 作为子节点,自动继承 mesh 的世界变换
2889
+ }
2890
+
2701
2891
  /**
2702
2892
  * 给一块网格造专属的棱线材质。
2703
2893
  *
@@ -2745,18 +2935,30 @@ export class S3MViewer {
2745
2935
 
2746
2936
  private _removeEdges(mesh: THREE.Mesh): void {
2747
2937
  const ud = mesh.userData as MeshUserData;
2938
+
2748
2939
  const lines = ud.edges;
2749
- if (!lines) return;
2750
- mesh.remove(lines);
2751
- (lines as THREE.Mesh).geometry?.dispose();
2752
- // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
2753
- const mat = (lines as THREE.Mesh).material;
2754
- if (Array.isArray(mat)) {
2755
- mat.forEach((m) => (m as any)?.dispose?.());
2756
- } else {
2757
- (mat as any)?.dispose?.();
2940
+ if (lines) {
2941
+ mesh.remove(lines);
2942
+ (lines as THREE.Mesh).geometry?.dispose();
2943
+ // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
2944
+ const mat = (lines as THREE.Mesh).material;
2945
+ if (Array.isArray(mat)) {
2946
+ mat.forEach((m) => (m as any)?.dispose?.());
2947
+ } else {
2948
+ (mat as any)?.dispose?.();
2949
+ }
2950
+ ud.edges = null;
2951
+ }
2952
+
2953
+ // 外扩壳的 geometry 是**复用主网格的**,只能释放材质,不能 dispose 几何
2954
+ const hull = ud.edgeHull;
2955
+ if (hull) {
2956
+ mesh.remove(hull);
2957
+ const hmat = hull.material;
2958
+ if (Array.isArray(hmat)) hmat.forEach((m) => (m as any)?.dispose?.());
2959
+ else (hmat as any)?.dispose?.();
2960
+ ud.edgeHull = null;
2758
2961
  }
2759
- ud.edges = null;
2760
2962
  }
2761
2963
 
2762
2964
  /* ---------------------------------------------------------------- 拾取 */
@@ -3466,6 +3668,36 @@ export class S3MViewer {
3466
3668
 
3467
3669
  /* ---------------------------------------------------------------- 渲染 */
3468
3670
 
3671
+ /**
3672
+ * 执行一帧 GPU 描边(法线 + 深度后处理)。
3673
+ *
3674
+ * 参数全部来自 style,不缓存:`edgeWidth` 在这里是「采样偏移」,线宽 ≈ 2×它;
3675
+ * `edgeThreshold` 是折边敏感度(夹角小于它不描),和 CPU 折边线同一口径。
3676
+ */
3677
+ private _renderGpuEdges(canvas: HTMLCanvasElement): void {
3678
+ if (!this.renderer || !this.scene || !this.camera) return;
3679
+ const s = this.style;
3680
+ const fade = this.uniforms.uEdgeFade?.value ?? 1;
3681
+ const opacity =
3682
+ Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity) * fade;
3683
+ if (opacity <= 0.004) return;
3684
+
3685
+ if (!this._gpuEdge) this._gpuEdge = new GpuEdgePass();
3686
+ if (!this._gpuEdge.supported(this.renderer)) return;
3687
+
3688
+ // 度数 → 归一化法线差(|Δn| = 2·sin(θ/2)),乘 0.6 让阈值落在「刚好能描出」的位置
3689
+ const deg = Math.min(179, Math.max(1, s.edgeThreshold ?? 55));
3690
+ const normalThreshold = 2 * Math.sin((deg * Math.PI) / 360) * 0.6;
3691
+
3692
+ this._gpuEdge.render(this.renderer, this.scene, this.camera, canvas.width, canvas.height, {
3693
+ color: this._gpuEdgeColor.set(s.edgeColor),
3694
+ opacity,
3695
+ radius: Math.max(1, s.edgeWidth ?? 1.5),
3696
+ normalThreshold,
3697
+ depthThreshold: 0.08,
3698
+ });
3699
+ }
3700
+
3469
3701
  /** 等宽线要按设备像素换算线宽:uResolution 用绘图缓冲尺寸,uDpr 是缓冲 / CSS 的比值 */
3470
3702
  private _updateLineResolution(canvas: HTMLCanvasElement): void {
3471
3703
  const w = canvas.width || 1;
@@ -3515,6 +3747,29 @@ export class S3MViewer {
3515
3747
  return Math.min(canvasHeight, cssHeight * cap);
3516
3748
  }
3517
3749
 
3750
+ /** 当前纹理缓存条数上限 */
3751
+ get maxImageCache(): number {
3752
+ return this.options.maxImageCache ?? 128;
3753
+ }
3754
+
3755
+ /**
3756
+ * 调整纹理缓存条数。不传 `datasetId` 时对所有已存在数据集立即生效,
3757
+ * 并作为之后新增数据集的默认值。缓存是 LRU,降下来不会立刻释放,下次淘汰时生效。
3758
+ */
3759
+ setMaxImageCache(n: number, datasetId?: string): void {
3760
+ const next = Number.isFinite(n) ? Math.max(1, Math.floor(n)) : 128;
3761
+ if (datasetId) {
3762
+ const entry = this.datasets.get(datasetId);
3763
+ if (entry?.tileset) entry.tileset.maxImageCache = next;
3764
+ } else {
3765
+ this.options.maxImageCache = next;
3766
+ for (const entry of this.datasets.values()) {
3767
+ if (entry.tileset) entry.tileset.maxImageCache = next;
3768
+ }
3769
+ }
3770
+ this._requestRepaint();
3771
+ }
3772
+
3518
3773
  /** 当前每个数据集的驻留面数预算。Infinity = 不限制 */
3519
3774
  get maxTriangles(): number {
3520
3775
  return this.options.maxTriangles ?? Infinity;
@@ -3634,7 +3889,7 @@ export class S3MViewer {
3634
3889
 
3635
3890
  // 正交模式由宿主在传入 mvp 前自行替换(见 s3m-maplibre 的 S3MLayer._applyOrtho),
3636
3891
  // 视图矩阵是从宿主输入反推的(P⁻¹×MVP),不会和宿主的 pan/zoom/pitch 错位。
3637
- const mvp = new THREE.Matrix4().fromArray(opts.mvp as number[]);
3892
+ const mvp = this._frameMvp.fromArray(opts.mvp as number[]);
3638
3893
  mvp.multiply(this._enuMatrix);
3639
3894
  this.camera.projectionMatrix.copy(mvp);
3640
3895
  this.camera.projectionMatrixInverse.copy(mvp).invert();
@@ -3689,6 +3944,9 @@ export class S3MViewer {
3689
3944
  // 这里跳过的是「本层的绘制」,宿主自己的清屏 / 底图照旧。
3690
3945
  if ((this.uniforms.uOpacity?.value ?? 1) > OPACITY_EPS) {
3691
3946
  this.renderer.render(this.scene, this.camera);
3947
+ // GPU 描边必须画在主场景之后:它的全屏 quad 参与宿主深度测试,
3948
+ // 依赖本帧已经写好的深度(被地形挡住的地方不该描边)
3949
+ if (this._edgeModes.gpu) this._renderGpuEdges(canvas);
3692
3950
  }
3693
3951
 
3694
3952
  this._frame++;