s3m-three 0.4.15 → 0.4.17

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.
@@ -23,11 +23,12 @@
23
23
  import * as THREE from 'three';
24
24
  import { usableGeoBounds } from '../tileset/S3MTileset.js';
25
25
  import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap } from './TextureFactory.js';
26
- import { setMaterialQuality } from './StyleMaterial.js';
26
+ import { getMaterialQuality, setMaterialQuality } from './StyleMaterial.js';
27
27
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
28
- import { STYLE, STYLE_LABELS, SELECT_MODE, DEFAULT_STYLE, createSharedUniforms, createOutlineMaterial, } from './StyleMaterial.js';
28
+ import { STYLE, STYLE_LABELS, SELECT_MODE, DEFAULT_STYLE, createSharedUniforms, createStyledMaterial, createOutlineMaterial, resolveEdgeModes, sameEdgeModes, } from './StyleMaterial.js';
29
29
  import { applyPointCloudStyle, isOwnedPointCloudMaterial } from './PointCloudMaterial.js';
30
30
  import { Picker } from './Picker.js';
31
+ import { GpuEdgePass } from './GpuEdgePass.js';
31
32
  import { Animator } from '../anim/Animator.js';
32
33
  import { Path } from '../anim/Path.js';
33
34
  import { GPUPathManager } from './GPUPathManager.js';
@@ -82,6 +83,8 @@ const STYLE_SCALAR_KEYS = [
82
83
  'ambientGain',
83
84
  'edgeWidth',
84
85
  'edgeOpacity',
86
+ 'edgeOffset',
87
+ 'edgeHullWidth',
85
88
  'outlineWidth',
86
89
  ];
87
90
  /** 能逐帧插值的颜色键(在线性空间插值,与 shader 消费端一致) */
@@ -120,6 +123,8 @@ export class S3MViewer {
120
123
  _edgeStyleUniforms;
121
124
  /** 选中棱线的外观 uniform */
122
125
  _selEdgeStyleUniforms;
126
+ /** 全局外扩轮廓的外观 uniform:逐网格轮廓壳共享这些对象 */
127
+ _hullStyleUniforms;
123
128
  /** 3D 物理地表面 / 基坑开挖管理器 */
124
129
  groundManager;
125
130
  /** 多数据集生命周期与锚点管理器 */
@@ -157,6 +162,12 @@ export class S3MViewer {
157
162
  _selectionEdges = null;
158
163
  _selectionEdgeMeshes = [];
159
164
  _edgesEnabled = false;
165
+ /** 当前生效的棱线画法(line / offset / hull / gpu),决定要不要建 / 拆哪种装饰 */
166
+ _edgeModes = resolveEdgeModes(false);
167
+ /** GPU 描边后处理(深度 + 法线),只在 edges 含 'gpu' 时按帧执行 */
168
+ _gpuEdge = null;
169
+ /** 给 GPU 描边传色用的临时 Color,避免每帧 new */
170
+ _gpuEdgeColor = new THREE.Color();
160
171
  /** 瓦片淡入通道的自增序号(不用 pickId:pickId 会被回收复用) */
161
172
  _tileFadeSeq = 0;
162
173
  /** setMode 编排的代次,用来作废被新调用顶掉的旧编排 */
@@ -281,6 +292,7 @@ export class S3MViewer {
281
292
  uColor: { value: new THREE.Color(this.style.edgeColor) },
282
293
  uWidth: { value: Math.max(0.1, this.style.edgeWidth ?? 1.5) },
283
294
  uOpacity: { value: 0.6 },
295
+ uOffset: { value: this.style.edgeOffset ?? 0.15 },
284
296
  };
285
297
  this.edgeMaterial = createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms);
286
298
  this._selEdgeStyleUniforms = {
@@ -290,10 +302,16 @@ export class S3MViewer {
290
302
  uElevation: { value: this.style.selectElevation ?? 0 },
291
303
  };
292
304
  this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms);
305
+ // 全局外扩轮廓的外观 uniform:逐网格轮廓壳复用这几个对象,改一次全场景生效
306
+ this._hullStyleUniforms = {
307
+ uColor: { value: new THREE.Color(this.style.edgeColor) },
308
+ uWidth: { value: Math.max(0, this.style.edgeHullWidth ?? 0.9) },
309
+ uOpacity: { value: 0.6 },
310
+ };
293
311
  // 关键:style.edges 是个开关,但构造函数从没据此启用过棱线,导致
294
312
  // `style: { edges: true }` 形同虚设、_edgesEnabled 永远是 false。
295
313
  // 这里按 style 实际值调一次 setEdges,让开关真正生效(与 setEdges 后来的手工调用一致)。
296
- this.setEdges(!!this.style.edges);
314
+ this.setEdges(this.style.edges);
297
315
  // 初始化图层整体不透明度:确保共享 uniform uOpacity、材质混合开关与棱线外观与初始 style.opacity 严格一致
298
316
  this._applyOpacity();
299
317
  this._fps.last = performance.now();
@@ -371,6 +389,9 @@ export class S3MViewer {
371
389
  this._syncTextureCapabilities(gl);
372
390
  this.picker?.dispose();
373
391
  this.picker = new Picker(this.renderer);
392
+ // GPU 描边的 RT / 材质绑定在旧上下文上,整体丢弃,下一帧按需重建
393
+ this._gpuEdge?.dispose();
394
+ this._gpuEdge = null;
374
395
  // 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
375
396
  // 这里只确保下一帧尽快驱动一次渲染。
376
397
  this._requestRepaint();
@@ -384,6 +405,8 @@ export class S3MViewer {
384
405
  this.picker?.dispose();
385
406
  this.picker = null;
386
407
  this.outlineMaterial.dispose();
408
+ this._gpuEdge?.dispose();
409
+ this._gpuEdge = null;
387
410
  this.edgeMaterial.dispose();
388
411
  this.selectionEdgeMaterial.dispose();
389
412
  this.scene?.clear();
@@ -1302,11 +1325,16 @@ export class S3MViewer {
1302
1325
  entry.root.add(node.group);
1303
1326
  node.group.updateMatrixWorld(true);
1304
1327
  const hasNativeEdges = node.group.userData.hasNativeEdges === true;
1328
+ // forceCpuEdges:无视数据自带的棱线包,改由 CPU 现算。native 是 1px 的
1329
+ // LineBasicMaterial,edgeWidth / 外扩 / 单体跟随全都吃不上。
1330
+ // gpu 画法由 shader 自己描边,native 线段包同样不能留
1331
+ const useNativeEdges = hasNativeEdges && this.style.forceCpuEdges !== true && !this._edgeModes.gpu;
1305
1332
  for (const child of node.group.children) {
1306
1333
  // 数据自带的棱线包:只跟着 edges 开关显示,不参与拾取、不参与着色
1307
1334
  if (child.isLineSegments && child.userData.isEdgeLines) {
1308
- child.visible = this._edgesEnabled;
1335
+ // 一律登记进 _nativeEdgeLines:forceCpuEdges 之后又关掉时还要能把它们放出来
1309
1336
  this._nativeEdgeLines.add(child);
1337
+ child.visible = useNativeEdges && this._edgesEnabled;
1310
1338
  this._styleEdgeLine(child);
1311
1339
  continue;
1312
1340
  }
@@ -1320,7 +1348,8 @@ export class S3MViewer {
1320
1348
  const ud = mesh.userData;
1321
1349
  ud.pickId = this._allocPickId();
1322
1350
  ud.datasetId = entry.id;
1323
- ud.nativeEdges = hasNativeEdges;
1351
+ ud.nativeEdges = useNativeEdges;
1352
+ ud.nativeEdgesRaw = hasNativeEdges;
1324
1353
  this._pickables.set(ud.pickId, {
1325
1354
  datasetId: entry.id,
1326
1355
  mesh,
@@ -1332,8 +1361,13 @@ export class S3MViewer {
1332
1361
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义
1333
1362
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义。
1334
1363
  // 自动描边受三道开关控制:全局 style.edges / 数据集 enableEdges / 地形绿地等关键字跳过。
1335
- if (!hasNativeEdges && !isPoints && this._datasetEdgesEnabled(entry) && !this._shouldSkipAutoEdges(ud, entry.name)) {
1336
- this._ensureEdges(mesh);
1364
+ if (!useNativeEdges && !isPoints && this._datasetEdgesEnabled(entry) && !this._shouldSkipAutoEdges(ud, entry.name)) {
1365
+ // 数据集强制开启(enableEdges: true)时全局画法可能还是关的,退回默认折边线;
1366
+ // 但 gpu 画法本来就不建几何,别在这里多此一举
1367
+ if (this._edgeModes.line || this._edgeModes.hull)
1368
+ this._ensureEdgeDecoration(mesh);
1369
+ else if (!this._edgeModes.gpu)
1370
+ this._ensureEdges(mesh);
1337
1371
  }
1338
1372
  // 新瓦片要继承当前的不透明度与着色方式
1339
1373
  if (isPoints) {
@@ -1347,8 +1381,11 @@ export class S3MViewer {
1347
1381
  mat.transparent = o < 0.999;
1348
1382
  const keepDepth = this.style.depthWrite !== false;
1349
1383
  mat.depthWrite = mat.transparent ? keepDepth : (this.style.depthWrite !== false);
1350
- mat.flatShading = this.style.shading !== 'smooth';
1351
- mat.needsUpdate = true;
1384
+ // shading 走共享 uniform(uFlatShading),不写 mat.flatShading / needsUpdate——
1385
+ // transparent / depthWrite 也不是 program 决定项,整套样式继承零重编译
1386
+ if (mat.userData.uFlatShading) {
1387
+ mat.userData.uFlatShading.value = this.style.shading !== 'smooth' ? 1 : 0;
1388
+ }
1352
1389
  this._featureStyleManager.applyOverridesToMesh(mesh);
1353
1390
  this._startTileFade(mesh, mat);
1354
1391
  }
@@ -1571,7 +1608,7 @@ export class S3MViewer {
1571
1608
  if ('edges' in patch) {
1572
1609
  const { edges, ...others } = patch;
1573
1610
  rest = others;
1574
- this.setEdges(!!edges, wantAnim ? transition : undefined);
1611
+ this.setEdges(edges, wantAnim ? transition : undefined);
1575
1612
  if (Object.keys(rest).length === 0) {
1576
1613
  this._requestRepaint();
1577
1614
  return;
@@ -1682,7 +1719,11 @@ export class S3MViewer {
1682
1719
  return;
1683
1720
  }
1684
1721
  this._applyStyleUniforms();
1685
- if (key === 'edgeColor' || key === 'edgeWidth' || key === 'edgeOpacity') {
1722
+ if (key === 'edgeColor' ||
1723
+ key === 'edgeWidth' ||
1724
+ key === 'edgeOpacity' ||
1725
+ key === 'edgeOffset' ||
1726
+ key === 'edgeHullWidth') {
1686
1727
  this._refreshEdgeLines();
1687
1728
  }
1688
1729
  }
@@ -1717,11 +1758,17 @@ export class S3MViewer {
1717
1758
  // 临界角变了要重建 CPU 棱线(EdgesGeometry 的参数),开 / 关走 setEdges 整体刷新。
1718
1759
  // 必须先 _disposeEdges():setEdges 只挑「还没有棱线」的网格来建,
1719
1760
  // 不先拆掉的话已经建好的会被全部跳过,临界角改了等于没改。
1720
- if ('edgeThreshold' in patch && this._edgesEnabled) {
1761
+ if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edgesEnabled) {
1721
1762
  this._disposeEdges();
1722
- this.setEdges(true);
1723
- }
1724
- else if ('edgeColor' in patch || 'edgeWidth' in patch || 'opacity' in patch || 'edgeOpacity' in patch) {
1763
+ this.setEdges(this.style.edges);
1764
+ }
1765
+ else if ('edgeColor' in patch ||
1766
+ 'edgeWidth' in patch ||
1767
+ 'opacity' in patch ||
1768
+ 'edgeOpacity' in patch ||
1769
+ 'edgeOffset' in patch ||
1770
+ 'edgeHullWidth' in patch) {
1771
+ // 颜色 / 粗细 / 外推距离 / 外扩厚度都只是 uniform,拖着滑块逐帧改也不重建几何
1725
1772
  this._refreshEdgeLines();
1726
1773
  }
1727
1774
  if (this._selection) {
@@ -1837,26 +1884,74 @@ export class S3MViewer {
1837
1884
  }
1838
1885
  /** 平面/平滑着色:FLAT_SHADED 是编译期宏,改它必须重编译 */
1839
1886
  _applyShading() {
1840
- const flat = this.style.shading !== 'smooth';
1887
+ // shading 现在是共享 uniform(uFlatShading)驱动的运行期分支,不再是
1888
+ // flatShading define:翻一个 float 就完成切换,零重编译、零卡顿。
1889
+ const target = this.style.shading !== 'smooth' ? 1 : 0;
1841
1890
  for (const p of this._pickables.values()) {
1842
1891
  const m = styledMaterialOf(p.mesh);
1843
- if (!m || m.flatShading === flat)
1892
+ const u = m?.userData.uFlatShading;
1893
+ if (!u || u.value === target)
1844
1894
  continue;
1845
- m.flatShading = flat;
1846
- m.needsUpdate = true;
1895
+ u.value = target;
1847
1896
  }
1848
1897
  this._requestRepaint();
1849
1898
  }
1850
1899
  /**
1851
- * 预热另一种 `shading` 变体,让首次切换不再同步编译着色器。
1900
+ * 运行时切换材质质感(`'pbr'` | `'lambert'`)。
1852
1901
  *
1853
- * 做法是「把材质翻到另一种变体 → 编译 → 翻回来」:three 的 program 缓存按
1854
- * cacheKey(含 flatShading / defines)分桶,编译一次就留在缓存里,之后
1855
- * `_applyShading()` 切过去只是取缓存。
1902
+ * 档位是模块级设置(影响之后新建的材质);已加载瓦片的材质在这里**原位重建**:
1903
+ * 不重拉瓦片、不重建几何,按创建时记录的参数(`userData.s3mOpts`)换一个
1904
+ * 新材质实例,并保留逐材质状态(选中编号、瓦片淡入、批量状态纹理)。
1905
+ * 切换后 program 需要重新编译一次(lambert / pbr 是不同着色器),这是唯一开销。
1906
+ */
1907
+ setMaterialQuality(q) {
1908
+ setMaterialQuality(q);
1909
+ this._rebuildStyledMaterials();
1910
+ this._requestRepaint();
1911
+ }
1912
+ /** 当前材质质感档位 */
1913
+ get materialQuality() {
1914
+ return getMaterialQuality();
1915
+ }
1916
+ /** 按创建参数原位重建所有 styled 材质(materialQuality 切换用) */
1917
+ _rebuildStyledMaterials() {
1918
+ // 逐材质 own uniform 的状态要在重建时搬过去,否则选中高亮 / 瓦片淡入会闪没
1919
+ const PRESERVED = [
1920
+ 'uSelBatch',
1921
+ 'uSelWhole',
1922
+ 'uBatchTex',
1923
+ 'uBatchTex2',
1924
+ 'uBatchTexWidth',
1925
+ 'uBatchTexActive',
1926
+ 'uTileFade',
1927
+ ];
1928
+ for (const p of this._pickables.values()) {
1929
+ const mesh = p.mesh;
1930
+ const raw = mesh.material;
1931
+ if (Array.isArray(raw))
1932
+ continue; // styled 材质恒为单材质;数组材质(点云等)不归这里管
1933
+ const old = raw;
1934
+ if (!old?.userData?.isStyled || !old.userData?.s3mOpts)
1935
+ continue;
1936
+ const opts = old.userData.s3mOpts;
1937
+ const fresh = createStyledMaterial({ ...opts, uniforms: this.uniforms });
1938
+ for (const key of PRESERVED) {
1939
+ const src = old.userData[key];
1940
+ const dst = fresh.userData[key];
1941
+ if (src && dst)
1942
+ dst.value = src.value;
1943
+ }
1944
+ mesh.material = fresh;
1945
+ old.dispose();
1946
+ }
1947
+ }
1948
+ /**
1949
+ * 预热着色 program,避免首帧同步编译。
1856
1950
  *
1857
- * 翻转期间材质是「错误变体」,所以用 `_prewarming` 让 renderFrame 跳过渲染——
1858
- * 少画一两帧看不出来,把平滑的楼画成平面的就很明显。
1859
- * 优先用 `compileAsync`(KHR_parallel_shader_compile),没有就退回同步 `compile`。
1951
+ * shading 改为 `uFlatShading` 共享 uniform 驱动后(见 `_applyShading`),
1952
+ * flat / smooth 共用同一个 program,不再存在「另一种变体」,也不需要
1953
+ * 「翻变体 → 编译 → 翻回来」的那套流程与 `_prewarming` 跳帧。这里只把当前
1954
+ * 唯一的 program 编一次(优先 `compileAsync`,KHR_parallel_shader_compile)。
1860
1955
  */
1861
1956
  async prewarmShading() {
1862
1957
  if (this._shadingPrewarmed || this._prewarming)
@@ -1866,24 +1961,17 @@ export class S3MViewer {
1866
1961
  const camera = this.camera;
1867
1962
  if (!r || !scene || !camera)
1868
1963
  return;
1869
- const mats = [];
1870
- const seen = new Set();
1964
+ // 场上还没有材质就没必要预热:program 会在首个材质出现时编译
1965
+ let hasMaterial = false;
1871
1966
  for (const p of this._pickables.values()) {
1872
- const m = styledMaterialOf(p.mesh);
1873
- if (!m || seen.has(m))
1874
- continue;
1875
- seen.add(m);
1876
- mats.push(m);
1967
+ if (styledMaterialOf(p.mesh)) {
1968
+ hasMaterial = true;
1969
+ break;
1970
+ }
1877
1971
  }
1878
- if (mats.length === 0)
1972
+ if (!hasMaterial)
1879
1973
  return;
1880
1974
  this._shadingPrewarmed = true;
1881
- this._prewarming = true;
1882
- // 当前是 smooth(flatShading = false)就预热 flat,反之预热 smooth
1883
- const target = this.style.shading === 'smooth';
1884
- const saved = mats.map((m) => m.flatShading);
1885
- for (const m of mats)
1886
- m.flatShading = target;
1887
1975
  try {
1888
1976
  const anyR = r;
1889
1977
  if (typeof anyR.compileAsync === 'function')
@@ -1892,16 +1980,9 @@ export class S3MViewer {
1892
1980
  r.compile(scene, camera);
1893
1981
  }
1894
1982
  catch {
1895
- // 预热失败不影响主流程:最坏情况退回「首次切换时编译一次」
1896
- }
1897
- finally {
1898
- for (let i = 0; i < mats.length; i++) {
1899
- mats[i].flatShading = saved[i];
1900
- mats[i].needsUpdate = true;
1901
- }
1902
- this._prewarming = false;
1903
- this._requestRepaint();
1983
+ // 预热失败不影响主流程:最坏情况退回「首次渲染时编译一次」
1904
1984
  }
1985
+ this._requestRepaint();
1905
1986
  }
1906
1987
  /**
1907
1988
  * 高度范围增量维护。
@@ -2004,8 +2085,15 @@ export class S3MViewer {
2004
2085
  /**
2005
2086
  * 全局棱线开关。
2006
2087
  *
2007
- * 数据自带棱线包(primitiveType 13)时直接用,成本为零;
2008
- * 没有的话才用 EdgesGeometry 现算 —— 那是 CPU 端的活,几十万面一次性建会卡住主线程
2088
+ * 取值是 `boolean | EdgeStyle | EdgeStyle[]`(见 resolveEdgeModes):
2089
+ * - `true` / `'line'` 折边线(旧口径)
2090
+ * - `'offset'` 折边线 + 沿边法线外推 `edgeOffset` 米
2091
+ * - `'hull'` 背面外扩轮廓(选中侧 outlineStyle:'hull' 的整场景版)
2092
+ * - `'gpu'` 纯片元描边:零几何、零额外 pass,线宽约 1 像素
2093
+ * - 数组 叠加,例如 `['line', 'hull']`
2094
+ *
2095
+ * 数据自带棱线包(primitiveType 13)时直接用,成本为零;`style.forceCpuEdges`
2096
+ * 可以无视它,改由 CPU 现算 —— 那是 CPU 端的活,几十万面一次性建会卡住主线程
2009
2097
  * (实测 20 个网格 1.9s),所以按 8ms 一帧切片,边建边出。
2010
2098
  *
2011
2099
  * 棱线本身用「屏幕空间等宽线」画(见 EdgeLines.ts):WebGL 的 lineWidth 基本无效,
@@ -2015,23 +2103,33 @@ export class S3MViewer {
2015
2103
  * 开:立刻置位 + 开始分片建线,同时 `uEdgeFade` 0→1 —— 建好一块亮一块,视觉上是渐显;
2016
2104
  * 关:`uEdgeFade` 1→0,淡完才 dispose,避免棱线「啪」一下消失。
2017
2105
  */
2018
- setEdges(enabled, transition) {
2106
+ setEdges(edges, transition) {
2019
2107
  const wantAnim = !!transition && ((transition.duration ?? 0) > 0 || !!transition.spring);
2020
- this._edgesEnabled = enabled;
2021
- this.style.edges = enabled;
2022
- this._styleTarget.edges = enabled;
2023
- // 数据自带的棱线:直接切可见性
2024
- for (const entry of this.datasets.values()) {
2025
- entry.root.traverse((o) => {
2026
- const ud = o.userData;
2027
- if (ud.isEdgeLines) {
2028
- o.visible = enabled;
2029
- this._styleEdgeLine(o);
2030
- }
2031
- });
2108
+ const modes = resolveEdgeModes(edges);
2109
+ // 换画法(line ↔ hull ↔ offset)是结构变更:旧装饰必须拆掉再建,
2110
+ // 否则会出现「切到 hull 了折边线还留着」这种叠影。
2111
+ // 同一画法重复调用(滑块逐帧 setEdges(true))不重建,几何是复用的。
2112
+ const modesChanged = !sameEdgeModes(this._edgeModes, modes);
2113
+ this._edgeModes = modes;
2114
+ this._edgesEnabled = modes.enabled;
2115
+ this.style.edges = edges;
2116
+ this._styleTarget.edges = edges;
2117
+ // 数据自带的棱线:直接切可见性(forceCpuEdges 时永远收起,改由 CPU 现算)。
2118
+ // 只遍历注册时收集好的那批 LineSegments —— 全数据集 traverse 在几千节点下太贵。
2119
+ // gpu 模式下 shader 自己描边,native 线段包必须收起,否则叠成双线
2120
+ const nativeVisible = modes.enabled && !modes.gpu && this.style.forceCpuEdges !== true;
2121
+ for (const lines of this._nativeEdgeLines) {
2122
+ lines.visible = nativeVisible;
2123
+ this._styleEdgeLine(lines);
2032
2124
  }
2033
2125
  this._syncEdgeStyle();
2034
- if (!enabled) {
2126
+ // 关掉 gpu 画法就把后处理资源还回去(整屏 RT 白占显存没意义)。
2127
+ // 必须放在「整体关闭」的早退之前,否则关棱线时永远轮不到它。
2128
+ if (!modes.gpu && this._gpuEdge) {
2129
+ this._gpuEdge.dispose();
2130
+ this._gpuEdge = null;
2131
+ }
2132
+ if (!modes.enabled) {
2035
2133
  if (wantAnim) {
2036
2134
  this.animator.to('style.edgeFade', 0, {
2037
2135
  ...transition,
@@ -2049,18 +2147,25 @@ export class S3MViewer {
2049
2147
  this._requestRepaint();
2050
2148
  return;
2051
2149
  }
2150
+ if (modesChanged)
2151
+ this._disposeEdges();
2052
2152
  const pending = [...this._pickables.values()]
2053
2153
  .map((p) => p.mesh)
2054
2154
  .filter((m) => {
2055
2155
  const ud = m.userData;
2056
2156
  // 点云没有棱可描
2057
- return !ud.edges && !ud.nativeEdges && !isPointCloudObject(m);
2157
+ if (isPointCloudObject(m))
2158
+ return false;
2159
+ // native 棱线还算不算数,取决于**当前**的 forceCpuEdges(它可以在运行中切换),
2160
+ // 所以这里按注册时记下的原始标记重算,而不是沿用注册那一刻的结论。
2161
+ ud.nativeEdges = ud.nativeEdgesRaw === true && this.style.forceCpuEdges !== true;
2162
+ return !ud.nativeEdges && this._needsEdgeDecoration(ud);
2058
2163
  });
2059
2164
  let i = 0;
2060
2165
  const step = () => {
2061
2166
  const t0 = performance.now();
2062
2167
  while (i < pending.length && performance.now() - t0 < 8)
2063
- this._ensureEdges(pending[i++]);
2168
+ this._ensureEdgeDecoration(pending[i++]);
2064
2169
  if (i < pending.length)
2065
2170
  setTimeout(step, 0);
2066
2171
  else
@@ -2133,10 +2238,11 @@ export class S3MViewer {
2133
2238
  this._requestRepaint();
2134
2239
  }
2135
2240
  /**
2136
- * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity。
2241
+ * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity / edgeOffset。
2137
2242
  *
2138
- * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存,
2139
- * 所以棱线淡入淡出只要改一个 uniform,新建瓦片自动继承当前进度。
2243
+ * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存
2244
+ * + 一批共享外观对象的轮廓壳,所以棱线淡入淡出只要改一个 uniform,
2245
+ * 新建瓦片自动继承当前进度。
2140
2246
  */
2141
2247
  _syncEdgeStyle() {
2142
2248
  const s = this.style;
@@ -2148,6 +2254,14 @@ export class S3MViewer {
2148
2254
  u.uColor.value.set(s.edgeColor);
2149
2255
  u.uWidth.value = Math.max(0.1, s.edgeWidth ?? 1.5);
2150
2256
  u.uOpacity.value = opacity;
2257
+ // 外推距离只在 offset 画法下非 0;其它画法把线钉在面上(与改动前一致)
2258
+ u.uOffset.value = this._edgeModes.offset ? Math.max(0, s.edgeOffset ?? 0.15) : 0;
2259
+ // 外扩轮廓壳与折边线共用颜色 / 透明度,厚度是米制的单独一项
2260
+ const h = this._hullStyleUniforms;
2261
+ h.uColor.value.set(s.edgeColor);
2262
+ h.uWidth.value = Math.max(0, s.edgeHullWidth ?? 0.9);
2263
+ h.uOpacity.value = opacity;
2264
+ // GPU 描边(edges: 'gpu')是后处理,参数每帧从 style 现取,这里不用同步 uniform
2151
2265
  // 预制棱线缓存(LineBasicMaterial)同步颜色 / 不透明度
2152
2266
  if (this._creaseLines) {
2153
2267
  const m = this._creaseLines.material;
@@ -2205,6 +2319,25 @@ export class S3MViewer {
2205
2319
  const hay = `${ud.skeletonName ?? ''} ${datasetName}`.toLowerCase();
2206
2320
  return S3MViewer._EDGE_SKIP_KEYWORDS.some((k) => hay.includes(k));
2207
2321
  }
2322
+ /**
2323
+ * 这块网格还缺哪种棱线装饰(缺就建,齐了就不动)。
2324
+ *
2325
+ * setEdges 的分片建线靠它筛「还没建完的」,注册新瓦片也靠它决定要不要动手。
2326
+ */
2327
+ _needsEdgeDecoration(ud) {
2328
+ if (this._edgeModes.line && !ud.edges)
2329
+ return true;
2330
+ if (this._edgeModes.hull && !ud.edgeHull)
2331
+ return true;
2332
+ return false;
2333
+ }
2334
+ /** 按当前画法补齐这块网格的棱线装饰 */
2335
+ _ensureEdgeDecoration(mesh) {
2336
+ if (this._edgeModes.line)
2337
+ this._ensureEdges(mesh);
2338
+ if (this._edgeModes.hull)
2339
+ this._ensureHull(mesh);
2340
+ }
2208
2341
  _ensureEdges(mesh) {
2209
2342
  const ud = mesh.userData;
2210
2343
  if (ud.edges || ud.nativeEdges || !mesh.geometry)
@@ -2225,11 +2358,23 @@ export class S3MViewer {
2225
2358
  const welded = mergeVerticesByPosition(mesh.geometry, 0.005);
2226
2359
  // 2. 调大临界角(默认 55°,可在 style.edgeThreshold 调),过滤浅坡与曲面细分,只保留真正的棱。
2227
2360
  const thresholdAngle = this.style.edgeThreshold ?? 55;
2228
- const source = new THREE.EdgesGeometry(welded, thresholdAngle);
2361
+ // 'offset' 画法要额外带一条「边法线」才知道往哪推,所以走本地实现(它本来就算好了
2362
+ // 相邻两面法线);其余画法保持 EdgesGeometry 这条老路,几何输出与改动前逐字一致。
2363
+ let segments;
2364
+ let normals;
2365
+ if (this._edgeModes.offset) {
2366
+ const raw = [];
2367
+ segments = buildFeatureEdgeSegments(welded, null, thresholdAngle, raw);
2368
+ if (raw.length > 0)
2369
+ normals = new Float32Array(raw);
2370
+ }
2371
+ else {
2372
+ const source = new THREE.EdgesGeometry(welded, thresholdAngle);
2373
+ const attr = source.getAttribute('position');
2374
+ segments = attr ? attr.array : null;
2375
+ source.dispose();
2376
+ }
2229
2377
  welded.dispose();
2230
- const attr = source.getAttribute('position');
2231
- const segments = attr ? attr.array : null;
2232
- source.dispose();
2233
2378
  if (!segments || segments.length < 6)
2234
2379
  return;
2235
2380
  // 3. 回填每段棱线所属的单体 batchId。
@@ -2247,7 +2392,7 @@ export class S3MViewer {
2247
2392
  batchMap.get(positionQuantKey(segments[s], segments[s + 1], segments[s + 2], 1 / TOL)) ?? -1;
2248
2393
  }
2249
2394
  }
2250
- const lines = new THREE.Mesh(buildThickLineGeometry(segments, batchIds), this._createEdgeMaterial(mesh));
2395
+ const lines = new THREE.Mesh(buildThickLineGeometry(segments, batchIds, normals), this._createEdgeMaterial(mesh));
2251
2396
  lines.renderOrder = 2;
2252
2397
  // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
2253
2398
  lines.frustumCulled = false;
@@ -2256,6 +2401,44 @@ export class S3MViewer {
2256
2401
  ud.edges = lines;
2257
2402
  mesh.add(lines); // 作为子节点,自动继承 mesh 的世界变换
2258
2403
  }
2404
+ /**
2405
+ * 给一块网格挂「背面外扩轮廓」(整场景版)。
2406
+ *
2407
+ * 复用选中侧那套 `createOutlineMaterial`:顶点沿法线鼓 `edgeHullWidth` 米、只画背面,
2408
+ * 于是被本体挡住的部分不显示,露出来的那圈就是描边。与折边线的差别在于——
2409
+ * 这里**不建任何新几何**,成本只是把同一块网格多画一遍,折边判定那套 CPU 活全省了。
2410
+ *
2411
+ * 实例化几何拿不到逐实例变换(轮廓壳是 mesh 的子节点,不吃 instanceMatrix),
2412
+ * 直接跳过:宁可这栋没描边,也不要所有实例的壳叠在原点。
2413
+ */
2414
+ _ensureHull(mesh) {
2415
+ const ud = mesh.userData;
2416
+ if (ud.edgeHull || !mesh.geometry)
2417
+ return;
2418
+ if (mesh.isInstancedMesh)
2419
+ return;
2420
+ // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
2421
+ if (this._shouldSkipAutoEdges(ud, ''))
2422
+ return;
2423
+ // 逐网格一份材质(各绑各的单体状态纹理),外观 uniform 复用共享对象
2424
+ const mat = createOutlineMaterial(this.uniforms, this.style.edgeHullWidth ?? 0.9, this._hullStyleUniforms, true);
2425
+ // 整块几何都要外扩:选中侧靠 uSelBatch 只扩一栋,全局必须置 whole
2426
+ mat.uniforms.uSelWhole.value = 1;
2427
+ mat.uniforms.uSelBatch.value = -1;
2428
+ const styled = styledMaterialOf(mesh);
2429
+ if (styled?.userData?.uBatchTex) {
2430
+ mat.uniforms.uBatchTex.value = styled.userData.uBatchTex.value;
2431
+ mat.uniforms.uBatchTexWidth.value = styled.userData.uBatchTexWidth.value;
2432
+ mat.uniforms.uBatchTexActive.value = styled.userData.uBatchTexActive.value;
2433
+ }
2434
+ const hull = new THREE.Mesh(mesh.geometry, mat);
2435
+ hull.renderOrder = 2;
2436
+ hull.frustumCulled = false;
2437
+ hull.raycast = () => { };
2438
+ hull.userData.isEdgeHull = true;
2439
+ ud.edgeHull = hull;
2440
+ mesh.add(hull); // 作为子节点,自动继承 mesh 的世界变换
2441
+ }
2259
2442
  /**
2260
2443
  * 给一块网格造专属的棱线材质。
2261
2444
  *
@@ -2302,19 +2485,30 @@ export class S3MViewer {
2302
2485
  _removeEdges(mesh) {
2303
2486
  const ud = mesh.userData;
2304
2487
  const lines = ud.edges;
2305
- if (!lines)
2306
- return;
2307
- mesh.remove(lines);
2308
- lines.geometry?.dispose();
2309
- // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
2310
- const mat = lines.material;
2311
- if (Array.isArray(mat)) {
2312
- mat.forEach((m) => m?.dispose?.());
2313
- }
2314
- else {
2315
- mat?.dispose?.();
2488
+ if (lines) {
2489
+ mesh.remove(lines);
2490
+ lines.geometry?.dispose();
2491
+ // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
2492
+ const mat = lines.material;
2493
+ if (Array.isArray(mat)) {
2494
+ mat.forEach((m) => m?.dispose?.());
2495
+ }
2496
+ else {
2497
+ mat?.dispose?.();
2498
+ }
2499
+ ud.edges = null;
2500
+ }
2501
+ // 外扩壳的 geometry 是**复用主网格的**,只能释放材质,不能 dispose 几何
2502
+ const hull = ud.edgeHull;
2503
+ if (hull) {
2504
+ mesh.remove(hull);
2505
+ const hmat = hull.material;
2506
+ if (Array.isArray(hmat))
2507
+ hmat.forEach((m) => m?.dispose?.());
2508
+ else
2509
+ hmat?.dispose?.();
2510
+ ud.edgeHull = null;
2316
2511
  }
2317
- ud.edges = null;
2318
2512
  }
2319
2513
  /* ---------------------------------------------------------------- 拾取 */
2320
2514
  /** 获取指定屏幕像素对应的场景射线(用于高精度求交与穿透拾取) */
@@ -3001,6 +3195,35 @@ export class S3MViewer {
3001
3195
  this._selectionEdges = null;
3002
3196
  }
3003
3197
  /* ---------------------------------------------------------------- 渲染 */
3198
+ /**
3199
+ * 执行一帧 GPU 描边(法线 + 深度后处理)。
3200
+ *
3201
+ * 参数全部来自 style,不缓存:`edgeWidth` 在这里是「采样偏移」,线宽 ≈ 2×它;
3202
+ * `edgeThreshold` 是折边敏感度(夹角小于它不描),和 CPU 折边线同一口径。
3203
+ */
3204
+ _renderGpuEdges(canvas) {
3205
+ if (!this.renderer || !this.scene || !this.camera)
3206
+ return;
3207
+ const s = this.style;
3208
+ const fade = this.uniforms.uEdgeFade?.value ?? 1;
3209
+ const opacity = Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity) * fade;
3210
+ if (opacity <= 0.004)
3211
+ return;
3212
+ if (!this._gpuEdge)
3213
+ this._gpuEdge = new GpuEdgePass();
3214
+ if (!this._gpuEdge.supported(this.renderer))
3215
+ return;
3216
+ // 度数 → 归一化法线差(|Δn| = 2·sin(θ/2)),乘 0.6 让阈值落在「刚好能描出」的位置
3217
+ const deg = Math.min(179, Math.max(1, s.edgeThreshold ?? 55));
3218
+ const normalThreshold = 2 * Math.sin((deg * Math.PI) / 360) * 0.6;
3219
+ this._gpuEdge.render(this.renderer, this.scene, this.camera, canvas.width, canvas.height, {
3220
+ color: this._gpuEdgeColor.set(s.edgeColor),
3221
+ opacity,
3222
+ radius: Math.max(1, s.edgeWidth ?? 1.5),
3223
+ normalThreshold,
3224
+ depthThreshold: 0.08,
3225
+ });
3226
+ }
3004
3227
  /** 等宽线要按设备像素换算线宽:uResolution 用绘图缓冲尺寸,uDpr 是缓冲 / CSS 的比值 */
3005
3228
  _updateLineResolution(canvas) {
3006
3229
  const w = canvas.width || 1;
@@ -3223,6 +3446,10 @@ export class S3MViewer {
3223
3446
  // 这里跳过的是「本层的绘制」,宿主自己的清屏 / 底图照旧。
3224
3447
  if ((this.uniforms.uOpacity?.value ?? 1) > OPACITY_EPS) {
3225
3448
  this.renderer.render(this.scene, this.camera);
3449
+ // GPU 描边必须画在主场景之后:它的全屏 quad 参与宿主深度测试,
3450
+ // 依赖本帧已经写好的深度(被地形挡住的地方不该描边)
3451
+ if (this._edgeModes.gpu)
3452
+ this._renderGpuEdges(canvas);
3226
3453
  }
3227
3454
  this._frame++;
3228
3455
  const now = performance.now();
@@ -3257,6 +3484,10 @@ export class S3MViewer {
3257
3484
  vertices: 0,
3258
3485
  triangles: 0,
3259
3486
  datasets: this.datasets.size,
3487
+ drawCalls: 0,
3488
+ programs: 0,
3489
+ textures: 0,
3490
+ geometries: 0,
3260
3491
  };
3261
3492
  for (const entry of this.datasets.values()) {
3262
3493
  const s = entry.tileset?.stats;
@@ -3269,6 +3500,15 @@ export class S3MViewer {
3269
3500
  total.vertices += s.vertices;
3270
3501
  total.triangles += s.triangles;
3271
3502
  }
3503
+ // GPU 侧预算观测:draw call / program / 显存驻留。没有渲染器(未 mount /
3504
+ // 纯解析场景)时保持 0,不抛错。
3505
+ const info = this.renderer?.info;
3506
+ if (info) {
3507
+ total.drawCalls = info.render.calls;
3508
+ total.programs = info.programs?.length ?? 0;
3509
+ total.textures = info.memory.textures;
3510
+ total.geometries = info.memory.geometries;
3511
+ }
3272
3512
  return total;
3273
3513
  }
3274
3514
  /** 所有数据集合并后的经纬度包围盒 */