s3m-three 0.3.12 → 0.3.14

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.
Files changed (53) hide show
  1. package/dist/browser/assets/s3mWorker-BjPOUgjg.js +1 -0
  2. package/dist/browser/s3m-three.iife.ext-three.js +21 -21
  3. package/dist/browser/s3m-three.iife.js +21 -21
  4. package/dist/core/TextureDecode.d.ts +9 -0
  5. package/dist/core/TextureDecode.d.ts.map +1 -1
  6. package/dist/core/TextureDecode.js +66 -0
  7. package/dist/core/TextureDecode.js.map +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/render/DatasetManager.d.ts +3 -1
  13. package/dist/render/DatasetManager.d.ts.map +1 -1
  14. package/dist/render/DatasetManager.js +2 -1
  15. package/dist/render/DatasetManager.js.map +1 -1
  16. package/dist/render/OrthoProjection.d.ts +22 -0
  17. package/dist/render/OrthoProjection.d.ts.map +1 -1
  18. package/dist/render/OrthoProjection.js +60 -0
  19. package/dist/render/OrthoProjection.js.map +1 -1
  20. package/dist/render/S3MViewer.d.ts +70 -0
  21. package/dist/render/S3MViewer.d.ts.map +1 -1
  22. package/dist/render/S3MViewer.js +100 -5
  23. package/dist/render/S3MViewer.js.map +1 -1
  24. package/dist/render/TextureFactory.d.ts +3 -2
  25. package/dist/render/TextureFactory.d.ts.map +1 -1
  26. package/dist/render/TextureFactory.js +26 -6
  27. package/dist/render/TextureFactory.js.map +1 -1
  28. package/dist/tileset/S3MTileset.d.ts +26 -1
  29. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  30. package/dist/tileset/S3MTileset.js +29 -2
  31. package/dist/tileset/S3MTileset.js.map +1 -1
  32. package/dist/vendor/crunch_decoder.module.js +451 -450
  33. package/dist/vendor/draco_decoder.module.js +3032 -3031
  34. package/dist/worker/S3MWorkerClient.d.ts +6 -3
  35. package/dist/worker/S3MWorkerClient.d.ts.map +1 -1
  36. package/dist/worker/S3MWorkerClient.js +8 -5
  37. package/dist/worker/S3MWorkerClient.js.map +1 -1
  38. package/dist/worker/s3mWorker.js +3639 -54
  39. package/dist/worker/s3mWorker.js.map +1 -1
  40. package/docs/API.md +61 -0
  41. package/package.json +2 -2
  42. package/src/core/TextureDecode.ts +87 -0
  43. package/src/index.ts +2 -1
  44. package/src/render/DatasetManager.ts +5 -1
  45. package/src/render/OrthoProjection.ts +58 -0
  46. package/src/render/S3MViewer.ts +134 -7
  47. package/src/render/TextureFactory.ts +35 -8
  48. package/src/tileset/S3MTileset.ts +41 -2
  49. package/src/vendor/crunch_decoder.module.js +451 -450
  50. package/src/vendor/draco_decoder.module.js +3032 -3031
  51. package/src/worker/S3MWorkerClient.ts +9 -5
  52. package/src/worker/s3mWorker.ts +2 -2
  53. package/dist/browser/assets/s3mWorker-D7AV6O30.js +0 -1
package/docs/API.md CHANGED
@@ -161,6 +161,7 @@ new S3MViewer(options?: S3MViewerOptions)
161
161
  | `ground` | `GroundOptions` | — | 3D 物理地表面 / 基坑开挖配置 |
162
162
  | `pickable` | `boolean` | `true` | 是否响应拾取 |
163
163
  | `maxTiles` | `number` | `64` | 每个数据集驻留瓦片上限,超了淘汰最久没用的(不是停止加载) |
164
+ | `maxTriangles` | `number` | `Infinity` | 每个数据集**驻留三角形总数**的软上限(不限制)。`maxTiles` 数不了「一块精模几十万面」,这条按实际面数做刹车,走同一套 make-before-break 淘汰,不开洞 |
164
165
  | `totalMaxTiles` | `number` | — | 多数据集全局驻留瓦片总预算;开启后在可见数据集间动态均衡分配,防止叠加爆显存 |
165
166
  | `light` | `LightOptions` | — | 场景光照配置,可配环境光与主平行光的颜色、强度、模式与方位角/高度角 |
166
167
  | `maxConcurrent` | `number` | `4` | 每个数据集并发请求数 |
@@ -169,6 +170,9 @@ new S3MViewer(options?: S3MViewerOptions)
169
170
  | (随 `useWorker`)几何组装 | `boolean` | `true` | 顶点解交错 / 法线计算 / 同材质合批合并(`core/TileGeometry.ts`)随解析在 Worker 完成,主线程只做 three 对象包装;Worker 不可用时主线程跑同一份实现,产物一致 |
170
171
  | (随 `useWorker`)DXT 压缩直传 | 自动 | 自动 | 宿主 GL 同时支持 `WEBGL_compressed_texture_s3tc`(+srgb) 时,dxt1/dxt5(含 CRN 转码产物)不解码直接 `CompressedTexture` 上传,显存与上传带宽降为 1/4~1/8;块长与 4×4 对齐公式不符、flipY 图集、移动端无扩展时自动回退 RGBA 解码 |
171
172
  | `lodRangeScale` | `number` | `1.0` | LOD 切换距离缩放系数,> 1 更倾向保留粗模,< 1 提前细化 |
173
+ | `lodPixelRatioCap` | `number` | `Infinity` | LOD 判定屏幕高度的 **DPR 上限**(不截断)。默认按 `canvasHeight` 物理像素算,与官方 `calcPixFromCam` 同口径;3x DPR 手机上由此会多细化、多拉瓦片,传 `2` 即按「CSS 高度 × 2」截断 |
174
+ | `anisotropyCap` | `number` | `Infinity` | 各向异性过滤上限(不封顶 = 取 GPU 最大值,通常 16)。移动端封到 4 / 8 肉眼通常无差,采样成本明显下降。只影响之后创建的纹理 |
175
+ | `maxTextureSize` | `number` | `Infinity` | 纹理长边像素上限(不限制)。非图集的 RGBA / WebP 产物按 2× 盒式滤波减半到上限内;DXT 压缩直传与图集不受影响 |
172
176
  | `buildTile` | `BuildTileFn \| null` | — | 自定义瓦片构建器(测试 / 服务端渲染时注入) |
173
177
  | `attributes` | `AttributeQueryOptions` | — | 业务属性查询配置,配了才能 `colorByField()`,见[按业务字段着色](#按业务字段着色) |
174
178
  | `onRequestRepaint` | `() => void` | — | 宿主请求重画的信号:纯 three 宿主接自己的渲染循环 |
@@ -366,11 +370,21 @@ getFeatureBounds(smid: number): [[number, number], [number, number]] | null
366
370
  ```ts
367
371
  setLodRangeScale(scale: number, datasetId?: string): void
368
372
  readonly lodRangeScale: number
373
+ setLodPixelRatioCap(cap: number): void // Infinity = 不截断(默认)
374
+ readonly lodPixelRatioCap: number
375
+ setMaxTriangles(n: number, datasetId?: string): void // Infinity = 不限制(默认)
376
+ readonly maxTriangles: number
377
+ setAnisotropyCap(cap: number): void // Infinity = 取 GPU 最大值(默认)
378
+ readonly anisotropyCap: number
379
+ setMaxTextureSize(n: number): void // Infinity = 不限制(默认)
380
+ readonly maxTextureSize: number
369
381
  readonly stats: Stats
370
382
  readonly fps: number
371
383
  getBounds(): [[number, number], [number, number]] | null // 合并经纬度范围
372
384
  ```
373
385
 
386
+ `lodPixelRatioCap` 只在**有限值**时才去读 `canvas.clientHeight`,默认状态下不产生任何额外开销;离屏 canvas(`clientHeight` 为 0)自动退回物理像素口径。截断会把每块瓦片的屏幕投影放大同样倍数,几何误差与纹理密度同步变差,取舍见[性能调优](#性能调优与显存管理)。
387
+
374
388
  ```ts
375
389
  interface Stats {
376
390
  tiles: number; loading: number; failed: number;
@@ -1189,6 +1203,10 @@ buildAttributes({ smid, box, triangles, floorHeight }) // → S3MFeatureAttribu
1189
1203
  |---|---|
1190
1204
  | `totalMaxTiles` | 多数据集全局瓦片总预算(如 `96` 或 `128`),自动在可见数据集间均衡分配配额,隐藏数据集时自动回收预算,防止多图层叠加爆显存 |
1191
1205
  | `maxTiles` | 单数据集瓦片驻留上限,默认 64。叠加多个数据集时建议配合 `totalMaxTiles` 或下调至 32~48 |
1206
+ | `maxTriangles` | 单数据集驻留面数软上限,默认不限制。`maxTiles` 只数瓦片个数,精模场景(一块几十万面)建议配 60 万~120 万 |
1207
+ | `lodPixelRatioCap` | LOD 判定用屏幕高度的 DPR 上限,默认不截断。见下方「移动端 / 差设备」 |
1208
+ | `anisotropyCap` | 各向异性过滤上限,默认取 GPU 最大值。移动端 4 / 8 通常肉眼无差 |
1209
+ | `maxTextureSize` | 纹理长边像素上限,默认不限制。手机上 1024 即可,显存降到 1/4 |
1192
1210
  | `maxImageCache` | 原始图集与 leaf 段图像记录的 LRU 缓存容量上限(默认 128),超限自动淘汰旧缓存,防止漫游时内存不断累积 |
1193
1211
  | 纹理引用计数 | 瓦片淘汰时按引用计数释放未被其他瓦片复用的 `Texture` 并调用 `dispose()`,真正归还 GPU 显存 |
1194
1212
  | 几何/材质深度释放 | 瓦片注销时深度遍历对象树,彻底释放 `Mesh`、`Points`(点云/高斯泼溅)以及 `LineSegments`(棱线包)的 `BufferGeometry` 与材质 |
@@ -1201,6 +1219,49 @@ buildAttributes({ smid, box, triangles, floorHeight }) // → S3MFeatureAttribu
1201
1219
 
1202
1220
  实测(上海某区,5 数据集叠加,45 万三角面,1440×900):**85~90 fps**。
1203
1221
 
1222
+ ### 移动端 / 差设备
1223
+
1224
+ 库本身不检测设备,所有刹车都由接入方按档位决定,**默认全部不启用,行为与旧版一致**:
1225
+
1226
+ ```ts
1227
+ // 低端档
1228
+ new S3MLayer({
1229
+ lodPixelRatioCap: 2, // 3x 屏 → 判定高度 2556 压到 ~1704,少约半层细化
1230
+ maxTriangles: 800_000, // 驻留面数超了就淘汰最久没用的瓦片
1231
+ maxTiles: 40,
1232
+ maxConcurrent: 2, // 并发 4 → 2:Worker 省了解析,但几何组装与纹理上传仍在主线程
1233
+ anisotropyCap: 4, // 各向异性 16 → 4
1234
+ maxTextureSize: 1024, // 纹理长边 2048 → 1024,显存降到 1/4
1235
+ });
1236
+ ```
1237
+
1238
+ 五个开关性质不同,按「改不改画质」分两类:
1239
+
1240
+ | 开关 | 性质 | 观感代价 |
1241
+ |---|---|---|
1242
+ | `lodPixelRatioCap` | 画质换性能 | 有,见下表 |
1243
+ | `maxTextureSize` | 画质换显存 | 贴图略软;1024 与 2048 在手机上肉眼几乎无差 |
1244
+ | `anisotropyCap` | 画质换采样成本 | 斜视角立面清晰度略降;4 / 8 通常看不出 |
1245
+ | `maxConcurrent` | 纯加载节奏 | 无(只是瓦片到达得慢一点,掉帧少) |
1246
+ | `maxTriangles` | 兜底刹车 | 正常无感,见下 |
1247
+
1248
+ **为什么 `lodPixelRatioCap` 在手机上收益最大**:LOD 的屏幕空间误差按 `canvasHeight` 物理像素算(与官方 `calcPixFromCam` 同口径),而手机 DPR 3 的绘图缓冲高度比 1440×900 桌面还高——细化层数与瓦片请求会多出近 3 倍,同时片元填充量再乘 2 倍多。截断只吃掉超采样的那部分,代价是每块瓦片在屏幕上铺得更开,几何误差与纹理密度同步变差:
1249
+
1250
+ | 力度 | 判定高度(iPhone 15) | 几何误差 / 纹理密度 | 体感 |
1251
+ |---|---|---|---|
1252
+ | 不截断(默认) | 2556 | 基准 | 最清晰,最费 |
1253
+ | `2` | 1704 | ×1.5 / ×1/1.5 | 近景细看略软,整体几乎无感 |
1254
+ | `1.5` | 1278 | ×2 / ×1/2 | 立面与地面贴图明显变软 |
1255
+ | `1`(等价于按 CSS 高度) | 852 | ×2.84 / ×1/2.84 | 糊,圆形构件出棱角,不建议 |
1256
+
1257
+ **`maxTextureSize` 的三个边界**(都需要知道,否会踩意外的坑):
1258
+
1259
+ - **图集不缩放**:S3MBLOCK 的 `AtlasPack` 大图与 leaf 组合画布的 UV 已重映射到子区域,缩放会让整体错位,`createTexture` 里直接跳过。
1260
+ - **压缩直传不缩放**:走 `CompressedTexture`(DXT / CRN 转码)时纹理在 GPU 里就是块数据,重采样没意义;真要省显存就在调用侧关掉压缩直传,或接受它已经是 1/4~1/8 的体积。
1261
+ - **缩放发生在 Worker 里**:`maxSize` 随解码请求一起传给共享 Worker,主线程零成本;Worker 不可用时才回落到主线程(4096² 减半一次约 10~50ms,属于一次性成本)。
1262
+
1263
+ **`maxTriangles` 是兜底刹车而不是画质档位**:没超预算时代码路径不触发,画质零损失;超了只在「不显示」或「位置已被可见子瓦接手」的瓦片里淘汰,所以不会开洞。
1264
+
1204
1265
  ### 批量改单体样式
1205
1266
 
1206
1267
  单体状态(颜色 / 抬高 / 隐藏)写在**每个网格各自一张一维纹理**里,改一个单体就是重写这张纹理。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3m-three",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "node": ">=18"
42
42
  },
43
43
  "scripts": {
44
- "build": "tsc -p tsconfig.build.json && node ../../tools/copy-assets.mjs s3m-three && vite build --config vite.browser.config.ts && vite build --config vite.browser.config.ts --mode ext",
44
+ "build": "tsc -p tsconfig.build.json && node ../../tools/copy-assets.mjs s3m-three && vite build --config vite.worker.config.ts && vite build --config vite.browser.config.ts && vite build --config vite.browser.config.ts --mode ext",
45
45
  "typecheck": "tsc --noEmit -p tsconfig.json",
46
46
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
47
47
  },
@@ -43,6 +43,82 @@ export interface DecodeOptions {
43
43
  * flipY 纹理走不了压缩上传(WebGL 对压缩纹理忽略 UNPACK_FLIP_Y),自动回退解码。
44
44
  */
45
45
  compressed?: boolean;
46
+ /**
47
+ * 解码产物长边的像素上限(默认不限制)。
48
+ *
49
+ * 手机上 1024 与 2048 肉眼几乎无差,但显存与上传带宽差 4 倍,所以允许接入方封顶。
50
+ * 只处理 RGBA / WebP 位图产物;**压缩直传(blocks)不缩放**——DXT 块不能重采样,
51
+ * 而且要直传的场合本就是想省显存。图集(flipY / packed / composed)的 UV 已经
52
+ * 重映射到子区域,缩放会整体错位,必须由调用方排除,这里不做判断。
53
+ */
54
+ maxSize?: number;
55
+ }
56
+
57
+ /** 2×2 盒式滤波减半(RGBA8)。奇数边丢弃最后一行/列,保证输出尺寸为 ⌊w/2⌋ × ⌊h/2⌋ */
58
+ function halveRgba(
59
+ src: Uint8Array,
60
+ w: number,
61
+ h: number
62
+ ): { rgba: Uint8Array; width: number; height: number } {
63
+ const nw = Math.max(1, w >> 1);
64
+ const nh = Math.max(1, h >> 1);
65
+ const out = new Uint8Array(nw * nh * 4);
66
+ const row = w * 4;
67
+ for (let y = 0; y < nh; y++) {
68
+ const r0 = y * 2 * row;
69
+ const r1 = r0 + row;
70
+ let o = y * nw * 4;
71
+ for (let x = 0; x < nw; x++) {
72
+ const i0 = r0 + x * 8;
73
+ const i1 = r1 + x * 8;
74
+ out[o++] = (src[i0] + src[i0 + 4] + src[i1] + src[i1 + 4]) >> 2;
75
+ out[o++] = (src[i0 + 1] + src[i0 + 5] + src[i1 + 1] + src[i1 + 5]) >> 2;
76
+ out[o++] = (src[i0 + 2] + src[i0 + 6] + src[i1 + 2] + src[i1 + 6]) >> 2;
77
+ out[o++] = (src[i0 + 3] + src[i0 + 7] + src[i1 + 3] + src[i1 + 7]) >> 2;
78
+ }
79
+ }
80
+ return { rgba: out, width: nw, height: nh };
81
+ }
82
+
83
+ /**
84
+ * 按 `maxSize` 把产物缩到长边不超过上限(逐次 2× 减半,不引入任意比例插值)。
85
+ * 未配置或没超限时原样返回**同一个对象**,调用方不会多一次拷贝。
86
+ */
87
+ async function applyMaxSize(
88
+ data: DecodedTextureData,
89
+ maxSize: number | undefined
90
+ ): Promise<DecodedTextureData> {
91
+ if (!maxSize || !Number.isFinite(maxSize) || maxSize < 1) return data;
92
+ const longest = Math.max(data.width, data.height);
93
+ if (longest <= maxSize) return data;
94
+
95
+ // 压缩块不能重采样;真要省显存就在调用侧关掉压缩直传
96
+ if (data.blocks) return data;
97
+
98
+ if (data.bitmap) {
99
+ const scale = maxSize / longest;
100
+ const w = Math.max(1, Math.round(data.width * scale));
101
+ const h = Math.max(1, Math.round(data.height * scale));
102
+ const resized = await createImageBitmap(data.bitmap, {
103
+ resizeWidth: w,
104
+ resizeHeight: h,
105
+ resizeQuality: 'high',
106
+ });
107
+ (data.bitmap as ImageBitmap & { close?: () => void }).close?.();
108
+ return { ...data, width: w, height: h, bitmap: resized };
109
+ }
110
+
111
+ let rgba = data.rgba;
112
+ if (!rgba) return data;
113
+ let w = data.width;
114
+ let h = data.height;
115
+ while (Math.max(w, h) > maxSize && w >= 2 && h >= 2) {
116
+ const next = halveRgba(rgba, w, h);
117
+ rgba = next.rgba;
118
+ w = next.width;
119
+ h = next.height;
120
+ }
121
+ return { ...data, width: w, height: h, rgba };
46
122
  }
47
123
 
48
124
  /** 行序翻转(原地)。S3MBLOCK 图集按 GL 方向存储(v=0 在最后一行),解码后要翻一次行 */
@@ -123,6 +199,17 @@ export function expectedDxtByteLength(width: number, height: number, format: 'dx
123
199
  export async function decodeTextureData(
124
200
  tex: S3MTexture,
125
201
  options: DecodeOptions = {}
202
+ ): Promise<DecodedTextureData> {
203
+ // 尺寸收口放在这一层:raw 里各分支(DXT / CRN / raw / WebP)有多个 return,
204
+ // 统一在外层套一次就不会漏掉任何一条路径。
205
+ const data = await decodeTextureDataRaw(tex, options);
206
+ return applyMaxSize(data, options.maxSize);
207
+ }
208
+
209
+ /** 各解码分支的原样产物(尺寸未收口),由上面的导出函数包一层 */
210
+ async function decodeTextureDataRaw(
211
+ tex: S3MTexture,
212
+ options: DecodeOptions = {}
126
213
  ): Promise<DecodedTextureData> {
127
214
  const empty: DecodedTextureData = {
128
215
  kind: 'unsupported(0/0)',
package/src/index.ts CHANGED
@@ -109,7 +109,7 @@ export type {
109
109
  export { buildTileObjects } from './render/GeometryBuilder.js';
110
110
  export type { BuildTileOptions, BuildTileResult, TileBuildStats } from './render/GeometryBuilder.js';
111
111
 
112
- export { createTexture, PixelFormat, setMaxAnisotropy } from './render/TextureFactory.js';
112
+ export { createTexture, PixelFormat, setMaxAnisotropy, setMaxTextureSize } from './render/TextureFactory.js';
113
113
  export type { DecodedTexture } from './render/TextureFactory.js';
114
114
 
115
115
  export { resolveTextureKind, dxtPixelFormatOf, TextureCompress } from './core/TextureFormat.js';
@@ -137,6 +137,7 @@ export type {
137
137
  export {
138
138
  buildOrthoMVP,
139
139
  buildBlendedMVP,
140
+ buildNearScaledMVP,
140
141
  extractProjectionMatrix,
141
142
  extractNearFar,
142
143
  extractFovRadians,
@@ -20,6 +20,8 @@ export interface S3MDatasetOptions {
20
20
  configUrl: string;
21
21
  visible?: boolean;
22
22
  maxTiles?: number;
23
+ /** 该数据集驻留三角形总数的软上限(默认跟随图层级设置;都不传则不限制) */
24
+ maxTriangles?: number;
23
25
  lodRangeScale?: number;
24
26
  /**
25
27
  * 垂直对齐基准(默认 'origin'):
@@ -164,7 +166,8 @@ export class DatasetManager {
164
166
  buildTile: any,
165
167
  maxTiles: number,
166
168
  maxConcurrent: number,
167
- callbacks: DatasetManagerCallbacks
169
+ callbacks: DatasetManagerCallbacks,
170
+ maxTriangles: number = Infinity
168
171
  ): Promise<S3MDatasetEntry> {
169
172
  const existing = this.datasets.get(ds.id);
170
173
  if (existing) return existing;
@@ -194,6 +197,7 @@ export class DatasetManager {
194
197
  configUrl: ds.configUrl,
195
198
  maxTiles: ds.maxTiles ?? maxTiles,
196
199
  maxConcurrent: maxConcurrent,
200
+ maxTriangles: ds.maxTriangles ?? maxTriangles,
197
201
  lodRangeScale: ds.lodRangeScale ?? defaultLodRangeScale,
198
202
  buildTile: buildTile ?? null,
199
203
  useWorker: ds.useWorker,
@@ -188,6 +188,64 @@ export function buildOrthoMVP(input: OrthoInput, params: OrthoParams): OrthoMVP
188
188
  return { matrix: ortho.projectionMatrix.clone().multiply(view), view };
189
189
  }
190
190
 
191
+ /**
192
+ * 只缩小近裁剪面的透视 MVP(视场角、远平面完全不变)。
193
+ *
194
+ * 为什么要它:MapLibre 的近裁剪面是**硬编码**的 `nearZ = canvasHeight / 50`(CSS 像素),
195
+ * 在 zoom 16.5 / 纬度 31° 时 1 px ≈ 0.72 m,也就是近平面离相机约 **13 米**——
196
+ * 相机推进到楼体 13 米以内,整个楼面就被裁掉,画面变成一片空白。
197
+ * MapLibre 没有公开 API 改它,所以只能在自定义图层里自己换一个投影矩阵。
198
+ *
199
+ * 数学上只动两个元素:标准透视矩阵里只有
200
+ * e[10] = -(far + near) / (far - near)
201
+ * e[14] = -2 · far · near / (far - near)
202
+ * 依赖 near/far,其余元素(含 e[0] = 2n/(r-l)、e[5])都不动 —— 这等价于把
203
+ * left/right/top/bottom 按 near 的比例等比缩放,所以**视场角逐点重合**,
204
+ * 缩放后画面与原画面完全一致,只是近处的几何不再被裁。
205
+ *
206
+ * 代价是深度缓冲的非线性变陡:near/far 比值每缩小 10 倍,远处可用的深度精度
207
+ * 就少一截。所以默认不启用,由接入方按「要不要贴脸看」决定。
208
+ *
209
+ * @param scale (0, 1) 之间:near' = near × scale。传 1 或 <= 0 表示不处理(返回 null)
210
+ * @returns null = 输入不是标准透视矩阵 / 参数不可用,调用方应保持原 MVP
211
+ */
212
+ export function buildNearScaledMVP(input: OrthoInput, scale: number): THREE.Matrix4 | null {
213
+ if (!(scale > 0) || scale >= 1) return null;
214
+ const p = input.projectionMatrix;
215
+ if (!p || p.length < 16) return null;
216
+
217
+ const P = new THREE.Matrix4().fromArray(Array.from(p));
218
+ if (!Number.isFinite(P.determinant()) || P.determinant() === 0) return null;
219
+ // 只对标准透视投影生效:makePerspective 的 e[11] 恒为 -1,正交矩阵不是
220
+ if (Math.abs((P.elements[11] as number) + 1) > 1e-6) return null;
221
+
222
+ // near/far 优先用宿主给的;缺失就从矩阵反解
223
+ // A = -e[10] = (f+n)/(f-n) → n = -e[14]/(A+1), f = n(A+1)/(A-1)
224
+ let near = input.nearZ != null && input.nearZ > 0 ? input.nearZ : NaN;
225
+ let far = input.farZ != null && input.farZ > near ? input.farZ : NaN;
226
+ if (!(near > 0) || !(far > near)) {
227
+ const A = -P.elements[10];
228
+ if (!(A > 1)) return null;
229
+ near = -P.elements[14] / (A + 1);
230
+ far = (near * (A + 1)) / (A - 1);
231
+ if (!(near > 0) || !(far > near)) return null;
232
+ }
233
+
234
+ const near2 = near * scale;
235
+ if (!(near2 > 0) || !(near2 < far)) return null;
236
+
237
+ const scaled = P.clone();
238
+ scaled.elements[10] = -(far + near2) / (far - near2);
239
+ scaled.elements[14] = (-2 * far * near2) / (far - near2);
240
+
241
+ // MVP' = P' × P⁻¹ × MVP:等价于「同一视图矩阵换个投影」,不需要显式解 V
242
+ const out = scaled.multiply(P.clone().invert()).multiply(new THREE.Matrix4().fromArray(Array.from(input.mvp)));
243
+ for (let i = 0; i < 16; i++) {
244
+ if (!Number.isFinite(out.elements[i] as number)) return null;
245
+ }
246
+ return out;
247
+ }
248
+
191
249
  /**
192
250
  * 平滑过渡正交与透视 MVP:
193
251
  * t = 0 时纯透视,t = 1 时纯正交,0 < t < 1 时在投影矩阵元素级平滑线性插值。
@@ -23,7 +23,7 @@
23
23
  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
- import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode } from './TextureFactory.js';
26
+ import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap } from './TextureFactory.js';
27
27
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
28
28
  import {
29
29
  STYLE,
@@ -219,6 +219,33 @@ export interface S3MViewerOptions {
219
219
  useWorker?: boolean;
220
220
  /** LOD 切换范围缩放系数(默认 1.0),> 1 更倾向保留粗模,< 1 提前换细模 */
221
221
  lodRangeScale?: number;
222
+ /**
223
+ * LOD 判定用屏幕高度时的「设备像素比上限」(默认不限制,行为与旧版一致)。
224
+ *
225
+ * LOD 的屏幕空间误差按 `canvasHeight` 物理像素算(与官方 `calcPixFromCam` 同口径),
226
+ * 于是 3x DPR 的手机上判定高度是 2556 而不是 852,细化层数与瓦片数都会远多于桌面。
227
+ * 传 2 表示「按 CSS 高度 × 2 截断」,砍掉 3x 屏的超采样余量;传 `Infinity` = 不截断。
228
+ *
229
+ * 代价:截断会把每块瓦片的屏幕投影放大同样倍数,几何误差与纹理密度同步变差,
230
+ * 所以默认不启用,由接入方按设备档位自行决定。运行时可用 `setLodPixelRatioCap()` 调整。
231
+ */
232
+ lodPixelRatioCap?: number;
233
+ /**
234
+ * 每个数据集驻留三角形总数的软上限(默认不限制)。
235
+ * 见 `S3MTilesetOptions.maxTriangles`:瓦片数管不住的场景(精模、大面片)用这条刹车。
236
+ */
237
+ maxTriangles?: number;
238
+ /**
239
+ * 各向异性过滤上限(默认不封顶,取 GPU 报的最大值,通常 16)。
240
+ * 移动端封到 4 或 8 肉眼通常无差,采样成本明显下降。只影响**之后创建**的纹理。
241
+ */
242
+ anisotropyCap?: number;
243
+ /**
244
+ * 纹理长边像素上限(默认不限制)。
245
+ * 非图集的 RGBA / WebP 纹理会按 2× 盒式滤波逐次减半到上限内;DXT 压缩直传与
246
+ * 图集(UV 已重映射)不受影响。手机上 1024 与 2048 肉眼几乎无差,显存差 4 倍。
247
+ */
248
+ maxTextureSize?: number;
222
249
  /** 自定义瓦片构建器(测试 / 服务端渲染时注入) */
223
250
  buildTile?: BuildTileFn | null;
224
251
  /**
@@ -495,6 +522,8 @@ export class S3MViewer {
495
522
  private _onRequestRepaint: () => void = () => {};
496
523
  private _getBearing: (() => number) | undefined;
497
524
  private _lodRangeScale = 1.0;
525
+ /** LOD 判定屏幕高度的 DPR 上限。Infinity = 不截断(默认,行为同旧版) */
526
+ private _lodPixelRatioCap = Infinity;
498
527
 
499
528
  /** 宿主请求重画(MapLibre 传 triggerRepaint,纯 three 宿主传自己的渲染循环) */
500
529
  private _requestRepaint(): void {
@@ -509,6 +538,8 @@ export class S3MViewer {
509
538
  this.options = options;
510
539
  this.pickable = options.pickable !== false;
511
540
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
541
+ this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
542
+ setTextureSizeCap(options.maxTextureSize ?? Infinity);
512
543
  if (options.light) {
513
544
  Object.assign(this._lightOptions, options.light);
514
545
  }
@@ -543,8 +574,7 @@ export class S3MViewer {
543
574
 
544
575
  this.uniforms = createSharedUniforms();
545
576
  // 纹理解码优先派给共享解析 Worker(崩溃自动回主线程,见 decodeTextureWithWorker)
546
- setTextureOffThreadDecode(decodeTextureWithWorker);
547
- this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
577
+ setTextureOffThreadDecode(decodeTextureWithWorker); this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
548
578
  this._styleTarget = { ...this.style };
549
579
  this._opacityTransparent = (this.style.opacity ?? 1) < 0.999;
550
580
  this._applyStyleUniforms();
@@ -615,7 +645,7 @@ export class S3MViewer {
615
645
  this.canvas = canvas;
616
646
 
617
647
  this.renderer = this._createRenderer(canvas, gl);
618
- setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
648
+ setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy(), this.options.anisotropyCap ?? Infinity);
619
649
  this._syncTextureCapabilities(gl);
620
650
 
621
651
  this.scene = new THREE.Scene();
@@ -657,7 +687,7 @@ export class S3MViewer {
657
687
  /* noop */
658
688
  }
659
689
  this.renderer = this._createRenderer(this.canvas, gl);
660
- setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
690
+ setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy(), this.options.anisotropyCap ?? Infinity);
661
691
  this._syncTextureCapabilities(gl);
662
692
  this.picker?.dispose();
663
693
  this.picker = new Picker(this.renderer);
@@ -854,7 +884,8 @@ export class S3MViewer {
854
884
  triggerRepaint: () => {
855
885
  this._requestRepaint();
856
886
  },
857
- }
887
+ },
888
+ this.options.maxTriangles ?? Infinity
858
889
  );
859
890
  return entry;
860
891
  }
@@ -3449,6 +3480,99 @@ export class S3MViewer {
3449
3480
  return this._lodRangeScale;
3450
3481
  }
3451
3482
 
3483
+ /* ------------------------------------------------ LOD 屏幕高度 / 面数预算 */
3484
+
3485
+ /** 当前 LOD 判定用的 DPR 上限。Infinity = 不截断 */
3486
+ get lodPixelRatioCap(): number {
3487
+ return this._lodPixelRatioCap;
3488
+ }
3489
+
3490
+ /**
3491
+ * 调整 LOD 判定屏幕高度的 DPR 上限(默认 Infinity = 不截断,行为与旧版一致)。
3492
+ *
3493
+ * 传 2 = 「按 CSS 高度 × 2 截断」:3x DPR 手机上把 2556 的判定高度压到 ~1704,
3494
+ * 少约半层 LOD 的细化,观感几乎无损。传 `Infinity` 恢复原口径。
3495
+ */
3496
+ setLodPixelRatioCap(cap: number): void {
3497
+ const next = cap > 0 ? cap : Infinity;
3498
+ this._lodPixelRatioCap = next;
3499
+ this.options.lodPixelRatioCap = next;
3500
+ this._requestRepaint();
3501
+ }
3502
+
3503
+ /**
3504
+ * 计算 LOD 判定用的屏幕高度(像素)。
3505
+ *
3506
+ * 默认原样返回 `canvasHeight`(绘图缓冲物理像素,官方 `calcPixFromCam` 的口径)。
3507
+ * 只有在 `lodPixelRatioCap` 有限时才去读 `clientHeight` 并按 CSS 高度截断——
3508
+ * 离屏 canvas 没有布局尺寸(clientHeight 为 0)时退回原口径。
3509
+ */
3510
+ private _lodScreenHeight(canvasHeight: number): number {
3511
+ const cap = this._lodPixelRatioCap;
3512
+ if (!Number.isFinite(cap)) return canvasHeight;
3513
+ const cssHeight = this.canvas?.clientHeight ?? 0;
3514
+ if (cssHeight <= 0) return canvasHeight;
3515
+ return Math.min(canvasHeight, cssHeight * cap);
3516
+ }
3517
+
3518
+ /** 当前每个数据集的驻留面数预算。Infinity = 不限制 */
3519
+ get maxTriangles(): number {
3520
+ return this.options.maxTriangles ?? Infinity;
3521
+ }
3522
+
3523
+ /**
3524
+ * 调整每个数据集驻留三角形总数的软上限(默认 Infinity = 不限制)。
3525
+ * 不传 `datasetId` 时对所有已存在数据集立即生效,并作为之后新增数据集的默认值。
3526
+ * 实际裁剪发生在下一次 `update()` 末尾,不会当场掉内容。
3527
+ */
3528
+ setMaxTriangles(n: number, datasetId?: string): void {
3529
+ const next = n > 0 ? n : Infinity;
3530
+ if (datasetId) {
3531
+ const entry = this.datasets.get(datasetId);
3532
+ if (entry?.tileset) entry.tileset.setMaxTriangles(next);
3533
+ } else {
3534
+ this.options.maxTriangles = next;
3535
+ for (const entry of this.datasets.values()) {
3536
+ if (entry.tileset) entry.tileset.setMaxTriangles(next);
3537
+ }
3538
+ }
3539
+ }
3540
+
3541
+ /** 当前各向异性过滤上限。Infinity = 不封顶 */
3542
+ get anisotropyCap(): number {
3543
+ return this.options.anisotropyCap ?? Infinity;
3544
+ }
3545
+
3546
+ /**
3547
+ * 调整各向异性过滤上限(默认 Infinity = 取 GPU 最大值)。
3548
+ * 只影响**之后创建**的纹理——已上传的纹理需要重新上传才会变,代价远大于收益,
3549
+ * 所以切换档位请在建图层前决定,或接受「新瓦片按新档位、旧瓦片保持原样」。
3550
+ */
3551
+ setAnisotropyCap(cap: number): void {
3552
+ const next = Number.isFinite(cap) && cap > 0 ? Math.max(1, cap) : Infinity;
3553
+ this.options.anisotropyCap = next;
3554
+ const gpuMax = this.renderer?.capabilities.getMaxAnisotropy?.() ?? 1;
3555
+ setMaxAnisotropy(gpuMax, next);
3556
+ this._requestRepaint();
3557
+ }
3558
+
3559
+ /** 当前纹理长边像素上限。Infinity = 不限制 */
3560
+ get maxTextureSize(): number {
3561
+ return this.options.maxTextureSize ?? Infinity;
3562
+ }
3563
+
3564
+ /**
3565
+ * 调整纹理长边像素上限(默认 Infinity = 不限制)。
3566
+ * 只影响之后解码的纹理;非图集的 RGBA / WebP 产物按 2× 减半逼近上限。
3567
+ * 注意这是**模块级**设置(图集 / 解码 Worker 共享),多 viewer 实例会互相覆盖。
3568
+ */
3569
+ setMaxTextureSize(n: number): void {
3570
+ const next = Number.isFinite(n) && n >= 1 ? n : Infinity;
3571
+ this.options.maxTextureSize = next;
3572
+ setTextureSizeCap(next);
3573
+ this._requestRepaint();
3574
+ }
3575
+
3452
3576
  /**
3453
3577
  * 动态调整 LOD 切换范围缩放系数。
3454
3578
  * scale > 1 时倾向保留粗模(降低瓦片请求与面数);scale < 1 时提前细化加载高精瓦片。
@@ -3529,6 +3653,9 @@ export class S3MViewer {
3529
3653
  // 弧度视场角。MapLibre v5 起 transform.fov 是「度」,当弧度用会让
3530
3654
  // LOD 屏幕误差算大 20% 以上(提前加载更细的瓦片)—— 换算由宿主负责。
3531
3655
  let tilesetChanged = false;
3656
+ // LOD 判定高度:默认就是绘图缓冲高度(物理像素,与官方口径一致);
3657
+ // 配了 lodPixelRatioCap 才按 CSS 高度截断,默认状态下这两行不产生任何开销。
3658
+ const lodScreenHeight = this._lodScreenHeight(opts.canvasHeight);
3532
3659
  for (const entry of this.datasets.values()) {
3533
3660
  if (!entry.visible || !entry.tileset) continue;
3534
3661
  const local: [number, number, number] = [
@@ -3536,7 +3663,7 @@ export class S3MViewer {
3536
3663
  camEnu[1] - entry.offset[1],
3537
3664
  camEnu[2] - entry.offset[2],
3538
3665
  ];
3539
- if (entry.tileset.update(local, opts.fovRad, opts.canvasHeight)) {
3666
+ if (entry.tileset.update(local, opts.fovRad, lodScreenHeight)) {
3540
3667
  tilesetChanged = true;
3541
3668
  }
3542
3669
  }
@@ -17,8 +17,28 @@ export { PixelFormat } from '../core/TextureFormat.js';
17
17
  /** 由 S3MLayer 在拿到 GL 上下文后注入,用于斜视角下保持纹理清晰 */
18
18
  let maxAnisotropy = 1;
19
19
 
20
- export function setMaxAnisotropy(n: number): void {
20
+ /**
21
+ * 各向异性过滤的**上限**(默认 Infinity = 不封顶,取 GPU 报的最大值)。
22
+ *
23
+ * 各向异性对斜视角立面清晰度的提升比提高分辨率更直接,所以默认全量启用;
24
+ * 但它每像素要多采样,移动端 GPU 上封到 4 或 8 通常肉眼无差、成本明显下降。
25
+ * 由 `setMaxAnisotropy(gpuMax, cap)` 一起注入,接入方通过 `anisotropyCap` 配置。
26
+ */
27
+ let anisotropyCap = Infinity;
28
+
29
+ export function setMaxAnisotropy(n: number, cap = Infinity): void {
21
30
  maxAnisotropy = Math.max(1, n | 0);
31
+ anisotropyCap = Number.isFinite(cap) && cap > 0 ? Math.max(1, cap) : Infinity;
32
+ }
33
+
34
+ /**
35
+ * 纹理长边像素上限(默认 Infinity = 不限制)。
36
+ * 只作用于非图集的 RGBA / WebP 产物,见 `core/TextureDecode.ts` 的 `maxSize`。
37
+ */
38
+ let maxTextureSize = Infinity;
39
+
40
+ export function setMaxTextureSize(n: number): void {
41
+ maxTextureSize = Number.isFinite(n) && n >= 1 ? n : Infinity;
22
42
  }
23
43
 
24
44
  /**
@@ -42,23 +62,28 @@ export interface DecodedTexture {
42
62
  /**
43
63
  * 注入「离线解码」实现(S3MViewer 装配:走共享解析 Worker)。
44
64
  * 返回 null = Worker 不可用 / 崩溃,createTexture 自动退回主线程 decodeTextureData。
65
+ * `maxSize` 一并带过去,缩放也在 Worker 里做(同样是 CPU 活)。
45
66
  */
46
67
  let offThreadDecode:
47
- | ((tex: S3MTexture, compressed: boolean) => Promise<DecodedTextureData | null>)
68
+ | ((tex: S3MTexture, compressed: boolean, maxSize?: number) => Promise<DecodedTextureData | null>)
48
69
  | null = null;
49
70
 
50
71
  export function setTextureOffThreadDecode(
51
- fn: ((tex: S3MTexture, compressed: boolean) => Promise<DecodedTextureData | null>) | null
72
+ fn: ((tex: S3MTexture, compressed: boolean, maxSize?: number) => Promise<DecodedTextureData | null>) | null
52
73
  ): void {
53
74
  offThreadDecode = fn;
54
75
  }
55
76
 
56
- async function decodeTexture(tex: S3MTexture, compressed: boolean): Promise<DecodedTextureData> {
77
+ async function decodeTexture(
78
+ tex: S3MTexture,
79
+ compressed: boolean,
80
+ maxSize: number
81
+ ): Promise<DecodedTextureData> {
57
82
  if (offThreadDecode) {
58
- const viaWorker = await offThreadDecode(tex, compressed);
83
+ const viaWorker = await offThreadDecode(tex, compressed, maxSize);
59
84
  if (viaWorker) return viaWorker;
60
85
  }
61
- return decodeTextureData(tex, { compressed });
86
+ return decodeTextureData(tex, { compressed, maxSize });
62
87
  }
63
88
 
64
89
  function configure<T extends THREE.Texture>(tex: T, srgb = true): T {
@@ -68,7 +93,7 @@ function configure<T extends THREE.Texture>(tex: T, srgb = true): T {
68
93
  tex.minFilter = THREE.LinearMipmapLinearFilter;
69
94
  tex.generateMipmaps = true;
70
95
  // 各向异性过滤对斜视角立面清晰度的提升比提高分辨率更直接
71
- tex.anisotropy = maxAnisotropy;
96
+ tex.anisotropy = anisotropyCap === Infinity ? maxAnisotropy : Math.min(maxAnisotropy, anisotropyCap);
72
97
  if (srgb) tex.colorSpace = THREE.SRGBColorSpace;
73
98
  tex.flipY = false;
74
99
  tex.needsUpdate = true;
@@ -103,7 +128,9 @@ export async function createTexture(tex: S3MTexture | null | undefined): Promise
103
128
 
104
129
  // 压缩直传只在「宿主 GL 支持 + 非图集」时开:图集要 flipY / 无 mip,压缩上传做不了翻行
105
130
  const wantCompressed = compressedTexturesSupported && !isAtlas;
106
- const data = await decodeTexture(tex, wantCompressed);
131
+ // 尺寸上限同样跳过图集:UV 已重映射到子区域,缩放会让整体错位
132
+ const sizeCap = isAtlas ? Infinity : maxTextureSize;
133
+ const data = await decodeTexture(tex, wantCompressed, sizeCap);
107
134
 
108
135
  if (!data.rgba && !data.blocks && !data.bitmap) {
109
136
  if (data.error) {