s3m-three 0.3.0 → 0.3.1

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 (109) hide show
  1. package/README.md +1 -1
  2. package/dist/browser/s3m-three.iife.ext-three.js +163 -15
  3. package/dist/browser/s3m-three.iife.js +190 -42
  4. package/dist/core/AtlasAlpha.d.ts +31 -0
  5. package/dist/core/AtlasAlpha.d.ts.map +1 -0
  6. package/dist/core/AtlasAlpha.js +82 -0
  7. package/dist/core/AtlasAlpha.js.map +1 -0
  8. package/dist/core/AtlasPack.d.ts +57 -0
  9. package/dist/core/AtlasPack.d.ts.map +1 -0
  10. package/dist/core/AtlasPack.js +81 -0
  11. package/dist/core/AtlasPack.js.map +1 -0
  12. package/dist/core/AtlasUpgrade.d.ts +68 -0
  13. package/dist/core/AtlasUpgrade.d.ts.map +1 -0
  14. package/dist/core/AtlasUpgrade.js +154 -0
  15. package/dist/core/AtlasUpgrade.js.map +1 -0
  16. package/dist/core/DXTTextureDecode.d.ts.map +1 -1
  17. package/dist/core/DXTTextureDecode.js +28 -6
  18. package/dist/core/DXTTextureDecode.js.map +1 -1
  19. package/dist/core/Draco.d.ts +52 -0
  20. package/dist/core/Draco.d.ts.map +1 -0
  21. package/dist/core/Draco.js +323 -0
  22. package/dist/core/Draco.js.map +1 -0
  23. package/dist/core/MeshOpt.d.ts +14 -3
  24. package/dist/core/MeshOpt.d.ts.map +1 -1
  25. package/dist/core/MeshOpt.js +101 -18
  26. package/dist/core/MeshOpt.js.map +1 -1
  27. package/dist/core/S3M1Parser.d.ts +16 -0
  28. package/dist/core/S3M1Parser.d.ts.map +1 -0
  29. package/dist/core/S3M1Parser.js +367 -0
  30. package/dist/core/S3M1Parser.js.map +1 -0
  31. package/dist/core/S3MBParser.d.ts +43 -1
  32. package/dist/core/S3MBParser.d.ts.map +1 -1
  33. package/dist/core/S3MBParser.js +101 -23
  34. package/dist/core/S3MBParser.js.map +1 -1
  35. package/dist/core/S3MBlockParser.d.ts +209 -0
  36. package/dist/core/S3MBlockParser.d.ts.map +1 -0
  37. package/dist/core/S3MBlockParser.js +789 -0
  38. package/dist/core/S3MBlockParser.js.map +1 -0
  39. package/dist/core/TextureFormat.d.ts +0 -1
  40. package/dist/core/TextureFormat.d.ts.map +1 -1
  41. package/dist/core/TextureFormat.js +19 -2
  42. package/dist/core/TextureFormat.js.map +1 -1
  43. package/dist/core/types.d.ts +65 -0
  44. package/dist/core/types.d.ts.map +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/render/DatasetManager.d.ts.map +1 -1
  50. package/dist/render/DatasetManager.js +5 -3
  51. package/dist/render/DatasetManager.js.map +1 -1
  52. package/dist/render/GeometryBuilder.d.ts +2 -0
  53. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  54. package/dist/render/GeometryBuilder.js +185 -16
  55. package/dist/render/GeometryBuilder.js.map +1 -1
  56. package/dist/render/GroundManager.d.ts +57 -1
  57. package/dist/render/GroundManager.d.ts.map +1 -1
  58. package/dist/render/GroundManager.js +149 -21
  59. package/dist/render/GroundManager.js.map +1 -1
  60. package/dist/render/Picker.d.ts.map +1 -1
  61. package/dist/render/Picker.js +12 -4
  62. package/dist/render/Picker.js.map +1 -1
  63. package/dist/render/PointCloudMaterial.d.ts +36 -0
  64. package/dist/render/PointCloudMaterial.d.ts.map +1 -0
  65. package/dist/render/PointCloudMaterial.js +159 -0
  66. package/dist/render/PointCloudMaterial.js.map +1 -0
  67. package/dist/render/S3MViewer.d.ts +19 -0
  68. package/dist/render/S3MViewer.d.ts.map +1 -1
  69. package/dist/render/S3MViewer.js +143 -22
  70. package/dist/render/S3MViewer.js.map +1 -1
  71. package/dist/render/StyleMaterial.d.ts +6 -0
  72. package/dist/render/StyleMaterial.d.ts.map +1 -1
  73. package/dist/render/StyleMaterial.js +125 -2
  74. package/dist/render/StyleMaterial.js.map +1 -1
  75. package/dist/render/TextureFactory.d.ts.map +1 -1
  76. package/dist/render/TextureFactory.js +64 -10
  77. package/dist/render/TextureFactory.js.map +1 -1
  78. package/dist/tileset/S3MTileset.d.ts +152 -1
  79. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  80. package/dist/tileset/S3MTileset.js +669 -53
  81. package/dist/tileset/S3MTileset.js.map +1 -1
  82. package/dist/vendor/crunch_decoder.module.js +12 -18
  83. package/dist/vendor/draco_decoder.module.js +3178 -0
  84. package/docs/API.md +110 -9
  85. package/package.json +1 -1
  86. package/src/core/AtlasAlpha.ts +82 -0
  87. package/src/core/AtlasPack.ts +127 -0
  88. package/src/core/AtlasUpgrade.ts +182 -0
  89. package/src/core/DXTTextureDecode.ts +28 -6
  90. package/src/core/Draco.ts +409 -0
  91. package/src/core/MeshOpt.ts +124 -18
  92. package/src/core/S3M1Parser.ts +430 -0
  93. package/src/core/S3MBParser.ts +112 -21
  94. package/src/core/S3MBlockParser.ts +953 -0
  95. package/src/core/TextureFormat.ts +20 -2
  96. package/src/core/types.ts +63 -0
  97. package/src/index.ts +2 -1
  98. package/src/render/DatasetManager.ts +5 -5
  99. package/src/render/GeometryBuilder.ts +207 -15
  100. package/src/render/GroundManager.ts +174 -20
  101. package/src/render/Picker.ts +13 -4
  102. package/src/render/PointCloudMaterial.ts +174 -0
  103. package/src/render/S3MViewer.ts +140 -24
  104. package/src/render/StyleMaterial.ts +135 -2
  105. package/src/render/TextureFactory.ts +67 -11
  106. package/src/tileset/S3MTileset.ts +701 -58
  107. package/src/vendor/crunch_decoder.module.js +12 -18
  108. package/src/vendor/draco_decoder.module.d.ts +128 -0
  109. package/src/vendor/draco_decoder.module.js +3178 -0
package/docs/API.md CHANGED
@@ -97,6 +97,12 @@ three 场景原点固定在**场景锚点**(第一个成功加载的数据集
97
97
 
98
98
  锚点来自数据集配置的 `position`,但会做一致性校验:若 `position` 与 `geoBounds` 中心偏差超过 0.5°,判定为生产时的占位脏数据,回退到 `geoBounds` 中心。
99
99
 
100
+ 回退的前提是 `geoBounds` 本身可用(`usableGeoBounds()`):字段缺失、非有限值、超出经纬度量级、或**退化成全 0 / 一个点**的都算不可用。这条是踩过坑加的——OSGB 那类配置根本没有经纬度范围,全 0 的框反过来把唯一正确的 `position` 判成异常,结果是视角和模型一起跑到几内亚湾。`getBounds()` 同样只统计可用的框,宁可不移动视角,也不甩到 `(0,0)`。
101
+
102
+ `position` 有两种写法都认:老数据是 `position.x/y/z` + `units`,v3.01 起改成 `position.point3D.{x,y,z}` + `unit`。第二个键名必须认,否则投影单位是米的数据会被当成度。
103
+
104
+ OSGB(`SuperMapCache` XML)的配置只给 `<sml:Position>`(经纬度原点)和 `<sml:BoundingBox>`(以原点为中心的**米制**局部盒),解析时按等距圆柱近似反算成 `geoBounds`:`Δlon = x / (111320·cos φ)`、`Δlat = y / 110540`。
105
+
100
106
  ### 瓦片与 LOD
101
107
 
102
108
  配置里的 `tiles`(桌面导出)或 `rootTiles`(iServer,可多棵)是瓦片树入口。LOD 按规范里的 `RangeMode` 判定:
@@ -105,7 +111,11 @@ three 场景原点固定在**场景锚点**(第一个成功加载的数据集
105
111
  |---|---|---|
106
112
  | 0 | `Distance_From_EyePoint` | 相机到包围球中心距离 < rangeValue 时细化 |
107
113
  | 1 | `Pixel_Size_OnScreen` | 包围球投影像素半径 > rangeValue 时细化 |
108
- | 2 | `Geometric_Error` | 按屏幕像素半径近似处理 |
114
+ | 2 | `Geometric_Error` | 规范枚举里没有这一档。按屏幕像素半径近似处理 |
115
+
116
+ 关于 2:线上高斯泼溅与点云服务的 pageLOD 全是这一档,且 `rangeValue` 与包围球半径成固定比例(实测根瓦片 `0.664 / 10.455`、子瓦片 `0.332 / 3.7`,比例都约 0.064),更像「相对屏幕的几何误差」而不是绝对像素。按 1 处理方向是对的(近处细化),只是阈值偏小时会多下几层,用 `lodRangeScale` 收。
117
+
118
+ 多骨架瓦片(每块 geode 一个 pageLOD)的 `sphere` / `rangeValue` 取自其中一块,细化判定是整块瓦片共用的;子引用则会把**每个 pageLOD 的 `childTile` 都收集**进 `childUrls`,所以一层细化会正确扇出到 N 块子瓦。
109
119
 
110
120
  ### 单体化
111
121
 
@@ -276,15 +286,20 @@ clearFilter(): void
276
286
  ```ts
277
287
  setGroundMode(mode: GroundMode): void
278
288
  setGroundOpacity(opacity: number): void
289
+ setGroundOcclude(enabled: boolean): void
279
290
  setExcavationBounds(bounds: [number, number, number, number] | null): void
280
291
  ```
281
292
 
282
293
  自带的物理地表网格锚定在**场景 ENU 高程**,解决的是「模型自身地上 / 地下结构的物理覆盖与透视关系」(地铁站、基坑这类数据没有真实地表 DEM 可用):
283
294
 
284
- - `'translucent'` 半透明地表 + `depthWrite = false` ⇒ 地下结构透过地表显现
285
- - `'excavate'` 基坑范围内片元 `discard` ⇒ 挖空井口
295
+ - `'translucent'` 半透明地表,地下结构透过地表显现(遮挡默认关闭)
296
+ - `'excavate'` 基坑范围内片元 `discard` ⇒ 挖空井口,坑外地下部分被地表挡住(遮挡默认开启)
286
297
 
287
- 它与 MapLibre terrain 是**两个正交维度**,不存在替代关系:terrain 解决「真实世界地面的起伏与遮挡」,物理地表解决「模型自身的地上 / 地下语义」。贴地(`setDatasetHeight`)后物理地表网格的高度会跟随主数据集的抬升量自动同步,保证覆盖关系不错位。
298
+ **遮挡是独立开关**(`ground.occlude` / `setGroundOcclude`),且**与地表模式解耦**:`mode: 'none'` 时也可以单独开启 —— 不显示任何地表,只留一块隐形地面把地面以下切掉。它由一块 `colorWrite = false`、`renderOrder = -1000` 的纯深度代理完成——遮挡是在片元绘制那一刻由深度测试决定的,半透明覆盖网格排在 transparent 队列末尾,写不写深度都挡不住已经画完的建筑。开挖模式下代理会在坑口同步开洞,否则井口就白挖了。
299
+
300
+ **地表高度默认跟随主数据集抬升量**(`ground.trackHeight`,默认 `true`):贴地时模型整体抬到地面高程,切割面必须跟到同一高程,否则整个模型被齐根切掉。设为 `false` 则钉死在当前高度——高程调整只动模型不动切割面,适合「调埋深 / 控制切掉多少」的交互。
301
+
302
+ 它与 MapLibre terrain 不存在替代关系,但也不是互斥的两套机制:两者都只是往同一个深度缓冲里写深度,谁更近谁赢。terrain 表达「真实世界地面的起伏遮挡」,物理地表代理表达「模型自身的一刀切地上 / 地下语义」——平面表达不了山体那种半山腰遮挡,而地形已写入更近深度的像素上,平面代理也写不进去。贴地(`setDatasetHeight`)后物理地表网格的高度会跟随主数据集的抬升量自动同步,保证覆盖关系不错位。
288
303
 
289
304
  ### 拾取与空间查询
290
305
 
@@ -665,6 +680,25 @@ console.log(tile.version, Object.keys(tile.geoPackage));
665
680
 
666
681
  解析结果可用 `bytesRead === bytesTotal` 自检——不等说明偏移算错了。
667
682
 
683
+ **v3 骨架条目额外暴露 `obb`**(`GeoPackageEntry.obb`,局部 ENU 系的中心 + 三个半轴)。多骨架瓦片里每块 geode 范围各不相同,而 pageLOD 的包围球只取自其中一块,做按块剔除 / 贴地要用条目自带的这个。它是按 `skeletonStreamSize` 定位到「条目末尾 96 字节」读出来的,而不是靠猜——几何段与 OBB 之间可能还有对齐填充(实测高斯泼溅 / 点云多块瓦片里就有块比别的块多 4 字节),靠固定长度往后推会整条流错位,下一块骨架的名字长度会读到垃圾,报出来是「未知骨架标记」。
684
+
685
+ **S3M 1.x 容器会被识别并抛错**,而不是抛一个看不懂的类型错误:
686
+
687
+ ```ts
688
+ import { parseS3MB, S3MFormatError } from 's3m-three/core';
689
+
690
+ try {
691
+ await parseS3MB(buf);
692
+ } catch (e) {
693
+ if (e instanceof S3MFormatError) {
694
+ // e.permanent === true:格式层面的永久错误,重试没有意义
695
+ // 典型是 .s3m(OSGB 转的 SuperMapCache 瓦片,头 4 字节是魔数 "s3m\0" 而不是版本号)
696
+ }
697
+ }
698
+ ```
699
+
700
+ 不识别的话,`s3m\0` 会被 float32 读成 1e-38 的「版本号」,接着把 zlib 头 `78 da cc bd` 当压缩长度,最终报 `RangeError: Invalid typed array length: 3184319096`——完全看不出是格式问题。`S3MTileset` 见到 `permanent` 就不退避重试,同类错误也只上报一次。
701
+
668
702
  ### MeshOpt 压缩几何
669
703
 
670
704
  v3 瓦片默认用 MeshOpt 压几何。解析器内部按需 `import` 内联的 meshoptimizer wasm 解码器(约 22KB),只有真的遇到 MeshOpt 瓦片才会加载,v1/v2 与未压缩的 v3 不受影响。
@@ -680,11 +714,39 @@ normal = octDecode(int16 / 32767)
680
714
  回归校验(60 个真实瓦片,校验偏移、索引范围、法线归一化、包围盒与 OBB 尺寸):
681
715
 
682
716
  ```bash
683
- npm run test:meshopt
717
+ bun run test:meshopt
684
718
  ```
685
719
 
686
720
  需要 WebAssembly;不支持 wasm 的环境会在遇到 MeshOpt 瓦片时抛明确错误。
687
721
 
722
+ **自定义属性流与高斯泼溅属性不再被丢弃**:`CUSTOM0` / `CUSTOM1` / `GS_SCALE` / `GS_ROTATION` 四条语义会原样保留成顶点属性(`custom0` / `custom1` / `gsScale` / `gsRotation`,每点 4 字节),名字到槽位的映射来自数据自带的 `vertexAttributeDescript`(实测高斯服务是 `{"GS_Rotation":1,"GS_Scale":0}`)。以前这几条流解码后直接扔掉,于是 `3DGaussianSplatting` 数据到渲染层只剩「位置 + 颜色」,上层拿不到泼溅的尺寸与朝向。
723
+
724
+ 每块骨架还带一份原始流清单 `vertexPackage.meshOptInfo`(各语义的压缩字节数与解码后每点 stride),排查新数据时先看它,比对着十六进制猜快得多。
725
+
726
+ **这两条 GS 流的量化格式还没标定**(4 字节 / 点,既不像 3 个 float,也不像明显四元数),所以渲染层目前只是把它们带到顶点属性上备用,没有接进真正的 splat 着色器。
727
+
728
+ ### Draco 点云几何
729
+
730
+ v3.01 的点云数据还有一条 Draco 压缩分支(骨架 tag = `DRACO`)。接法与 MeshOpt 相同:内联 wasm 解码器(约 266KB)只在真的遇到时动态 `import`,解码后并入同一套 attribute 组装,点云按 `primitiveType = 1` 交给 `THREE.Points`,分类码 / 强度 / 权重三种自定义属性一并解出来。
731
+
732
+ **这个分支的字段顺序与规范描述对不上,是从真实瓦片逐字节标定的**(偏移以骨架 tag 字段之后为 0):
733
+
734
+ ```
735
+ u32 payloadSize // 覆盖「本字段之后 … 条目末尾 OBB 之前」
736
+ u32 未知(实测恒 32)
737
+ i32 pos / normal / color / secondColor 各自的 draco 属性 uid(-1 = 无)
738
+ u32 自定义属性个数(实测 3)+ i32[] 各自的 uid
739
+ u32 vertexAttrCount // 实测恒 0
740
+ u32 dracoSize; byte[dracoSize] // 标准 draco 比特流,开头 5 字节 "DRACO"
741
+ String descript // {"PointCloudClassCode":2,"PointCloudIntensity":1,"VertexWeight":0}
742
+ ```
743
+
744
+ `descript` 的值是「该属性在自定义 uid 数组里的**下标**」,不是分量数。OBB 不在这段里读,由 `parseSkeletonEntries` 统一按条目末尾 96 字节取。
745
+
746
+ 官方 SDK 的 `parseDracoSkeleton` 在这个分支上**解析不了同一个服务**:字段顺序里多了一个 `materialCount`,而且 `version === 3` 的判定对 v3.01 瓦片不成立。所以这里不能照官方实现抄。
747
+
748
+ 读偏的兜底是四重自校验(`payloadSize` 覆盖范围 / `dracoSize` / `"DRACO"` 魔数 / 骨架自带的 `skeletonStreamSize`)加单骨架 64M 点数上限。实测两个瓦片 5 个骨架逐字节一致;**这条路径尚未纳入 `tests/`**,改动 `core/Draco.ts` 前请拿真实瓦片复验。
749
+
688
750
  ### `S3MTileset`
689
751
 
690
752
  单个数据集的瓦片树。不依赖 three,可以用注入的 `buildTile` 在 Node 里跑通完整链路(URL 解析 → 解析 → LOD 调度),适合做数据体检。
@@ -695,6 +757,26 @@ await ts.init();
695
757
  ts.update([0, 0, 500], 0.64, 900); // 相机在局部 ENU 的位置
696
758
  ```
697
759
 
760
+ `configUrl` 必须指向**某个数据源的 config**,不是 `datas` 目录:
761
+
762
+ ```
763
+ https://host/iserver/services/3D-xxx/rest/realspace/datas/<数据源名>/config
764
+ ```
765
+
766
+ 数据源名可以是中文(如 `寿宁阁`),`new URL()` 会自动百分号编码;后缀只能写 `config`,写 `config.xml` 会 406。只给到 `…/realspace/datas` 时拿到的是一个资源清单数组,`JSON.parse` 不报错但没有 `position` / `rootTiles`,表现为「视角跳到 (0,0) + 一句缺少 root tile」——先用 `…/datas.json` 列出数据源,再逐个拼 `/config`。
767
+
768
+ 配套导出的三个纯函数,方便自建数据集列表与做体检:
769
+
770
+ ```ts
771
+ parseConfigText(text) // JSON 与 SuperMapCache XML 两种配置统一成同一种形状(含 fileType)
772
+ usableGeoBounds(bounds) // 判断 geoBounds 是否真的可用;不可用返回 null(见「锚点」)
773
+ fixPathEncoding(url) // 补路径里没编码但服务端会改写的字符:+ → %2B、空格 → %20
774
+ ```
775
+
776
+ `fixPathEncoding` 这条是硬需求:OSGB 转出来的瓦片名就是 `Tile_+002_+001`,而 iServer 按 `URLDecoder` 解路径,未编码的 `+` 会变成空格 → 404「瓦片数据不存在」。`new URL()` 认为 `+` 在 path 里合法,不会替我们编码,所以拼完 URL 必须补这一刀(query 里的 `+` 不动,那本来就是空格约定)。
777
+
778
+ 失败处理分两类:**网络类**错误退避重试(`maxRetries`,默认 2 次);**格式类**错误(`e.permanent === true`,例如 S3M 1.x 容器)直接判失败不重试,并且整棵瓦片树里同类错误只回调一次 `onError`,避免几十块瓦片刷同样的日志。
779
+
698
780
  ### 单体属性工具
699
781
 
700
782
  ```ts
@@ -743,12 +825,31 @@ viewer.flushOverrides();
743
825
 
744
826
  ## 已知限制
745
827
 
746
- - **Draco 压缩几何**能识别、不能解码,遇到会抛明确错误。接法与 MeshOpt 相同(内联 wasm、按需 `import`、解码后走同一套 attribute 组装),但必须先拿 Draco 瓦片标定容器结构。MeshOpt 已支持,且是逐字节对过 60 个真实瓦片才做对的。
828
+ - **Draco 压缩几何**已支持(v3.01 点云分支):内联 wasm 按需 `import`,解码后与标准骨架同路输出 attribute,点云按 `primitiveType = 1` 走 `THREE.Points`,分类码 / 强度 / 权重三种自定义属性一并解出。两点要留意:一是**容器与规范描述对不上**,字段顺序是从真实瓦片逐字节标定的(`payloadSize` / `dracoSize` / `"DRACO"` 魔数 / `skeletonStreamSize` 四重自校验兜读偏,另有单骨架 64M 点数上限),官方 SDK 的 `parseDracoSkeleton` 字段顺序多一个 `materialCount` 且 `version === 3` 的判定对 v3.01 不成立,解析不了同一个服务,不能照抄;二是这条路径**还没有回归测试钉住**(目前只有排查探针脚本),改 `core/Draco.ts` 的字段顺序必须拿真实瓦片复验。
747
829
  - **实例化单体**(`pickInfo` 里给的是 `instanceIds` 而不是 `batchId`,白模常见)只做了渲染,没做按单体控制:`setFeatureStyle` / `filterByField` / `elevateByField` / GPU 拾取走的都是 `aBatchId`,这条路径对实例化几何不生效。要支持得把每个实例单独上色,得改 InstancedMesh 的 instanceColor + 拾取编码。
748
- - **CRN 纹理**已支持,且已在真实服务上验证:内联的 crunch wasm 转回 DXT 后走现成解码,DXT 具体格式看 CRN 头,wasm 约 76KB、只有真遇到 CRN 时才动态加载。**判定看 `compressType` 而不是 `pixelFormat`**——实测 v3.00 是 `ct=26 / pf=13`、行标是 `ct=50000 / pf=32856`,按 `pixelFormat` 26 / 27 判断会把真实 CRN 整包丢掉(模型变灰模)。判定收敛在 `packages/s3m-three/src/core/TextureFormat.ts`(`resolveTextureKind` / `TextureCompress` / `dxtPixelFormatOf`,已从包入口导出)。用 `npm run probe:crn -- --crn <数据集 config 地址>` 可以对任意服务跑「尺寸吻合 / 色数 / 粗糙度 / ASCII 预览」的实测。
749
- - **纹理分包(`s3m:TextureSharing`)未支持**:这类数据集的 `tiles` 全是 `.s3mblock`,不是 `.s3mb`,加载会失败(例:SuperMap Online `3D-CBD/Xiaopin@CBD`)。
750
- - **多块 MeshOpt 骨架**(`blockCount > 1`)暂未对齐:例 `3D-WorkSpace_pbr1/pbrmodel`,第二块头部按现有布局读会错位,会给出「流尺寸异常」的可定位报错而不是底层 RangeError。
830
+ - **CRN 纹理**已支持,且已在真实服务上验证:内联的 crunch wasm 转回 DXT 后走现成解码,DXT 具体格式看 CRN 头,wasm 约 76KB、只有真遇到 CRN 时才动态加载。**判定看 `compressType` 而不是 `pixelFormat`**——实测 v3.00 是 `ct=26 / pf=13`、行标是 `ct=50000 / pf=32856`,按 `pixelFormat` 26 / 27 判断会把真实 CRN 整包丢掉(模型变灰模)。判定收敛在 `packages/s3m-three/src/core/TextureFormat.ts`(`resolveTextureKind` / `TextureCompress` / `dxtPixelFormatOf`,已从包入口导出)。用 `bun run probe:crn -- --crn <数据集 config 地址>` 可以对任意服务跑「尺寸吻合 / 色数 / 粗糙度 / ASCII 预览」的实测。
831
+ - **纹理分包(`s3m:TextureSharing` / S3MBLOCK)已完整支持**:
832
+ - **容器结构**:`.s3mblock` 单文件容器 = 若干 RO 组,每组包含一段 pageLOD 元信息 + zlib 压缩的 v2 瓦片体(段目录:groupNode / 骨架 / secondColor / 图集 / 材质 / 单体化);纹理是图集(`isSaveData`),材质按图集 id(`-N.texblock`)引用,几何顶点区间绑定到图集子区域;
833
+ - **解析与 UV 重映射**:解析端把顶点 UV 用 `fract` 平铺语义重映射进图集空间,多图集自动走货架式装箱合并(`AtlasPack`),下游材质直接绑定整张图集;
834
+ - **细级 LOD(leaf)外部图集加载**:细级瓦片的图集未内嵌原始像素(`data = null`),由 `_fillExternalAtlases` 解析子请求名(`_subRequestNames`):
835
+ - 支持展开 `Tex_x.texblock#<三位数字串>` 多精灵容器索引;
836
+ - 对齐官方 `Uci` 规范的 pack 请求:URL 拼装 `extratiles=<names>` 批量合并拉取,避免高并发零碎请求;
837
+ - 响应体通过 `parseTexblockSegments` 解码 `kMi` 分段记录,并通过 `leafRequestCache` 与 `_leafPending` 实现跨瓦片并发请求自动去重缓存;
838
+ - **组合画布与装配**:对 leaf 模式,解出子精灵 RGBA 缓冲,按 `subTexInfos[i]` 的偏移量(`offsetX`、`offsetY`)以标准自上而下行序拷贝进高分辨率组合画布;
839
+ - **安全判定与重拼**:外部图集接入前通过 `evaluateAtlasUpgrade` 进行长宽比与下采样色差校验,判定通过后由 `rebuildBlockTextureState` 重新生成拼图与重算归一化 `texRect`;
840
+ - 实现位于 `core/S3MBlockParser.ts`、`core/AtlasPack.ts` 与 `tileset/S3MTileset.ts`,并通过 `tests/unit/s3mblock.test.ts`、`tests/unit/leaf-atlas.test.ts` 和 `tests/unit/leaf-uv-render.test.ts` 交叉验证钉住。
841
+ - **多块 MeshOpt 骨架**(`blockCount > 1`)已支持。这类骨架里**每块后面紧跟自己的索引包**(不是所有块之后再统一放一份),索引是块内局部编号;解析时按块顺序合并顶点流,再把每块索引加上顶点基数偏移,对外仍是一份顶点缓冲 + 多组索引包。实测 `3D-WorkSpace_pbr1/pbrmodel` 的 `blockCount=2` / `3` 瓦片可正常加载。
751
842
  - **切线顶点流**只跳过不解码(渲染没有法线贴图通道),带 TANGENT 的数据不会被判失败。
843
+ - **S3M 1.x(`.s3m`,SuperMapCache)已支持**。这类服务的 `config` 返回 `SuperMapCache` XML(`sml:FileType = OSGBFile`),位置与范围按米制盒反算(见「锚点」);瓦片是 `73 33 6D 00` 魔数 + zlib 的老容器,`parseS3MB` 认出魔数后交给 `core/S3M1Parser.ts`,输出同形状的 `ParsedTile`,下游渲染 / LOD / 拾取零改动。
844
+ **注意:它的下一级 LOD 并不是 `.osgb`** —— XML 里 `RangeDataList` 写的 `..._L16_0.osgb` 只是切片时的原始文件名,服务实际发布的是同名换 `.s3m` 后缀,走 `datas/<数据源>/data/path/<瓦片目录>/<名>.s3m`(实测 L16/L17/L18 全部 200,`?sceneName=` 不参与寻址)。所以整棵 LOD 树都是同一种容器,**不需要 OSG Binary 解码器**;早先「因为子瓦是 .osgb 所以做不了」的结论是没验证就下的,已作废。
845
+ 已标定:`73 33 6D 00` 魔数 + zlib;解压后 `uint32 xmlSize + XML + 补齐 4 字节对齐(实测 2~3 个 0x30,不是固定标记)+ (uint32 size + data) × 3`,三段长度链在四块样本上全部精确闭合;几何流 = `uint32 meshCount` + 每块网格 `{ geoName, 7, 772, V, 0 }` + `V × 16B` 顶点 `(x, y, z, z)` + 数组区 + `uint32 1` + `uint32 materialId`(与 XML 材质号一致);纹理流 = `uint32 count` + 每条 `{ id, width, height, format, size, flag }` + `size` 字节(实测 `format=14`、0.5 B/px = DXT1,`28 + size` 精确等于段长);索引确认为 u16(值域最大恰为 `V-1`);几何里**没有法线数组**,接解码时要 `computeVertexNormals()`。
846
+ 接这种格式时有三个坑,都是实测出来的,别凭直觉:
847
+ 1. **几何里没有法线数组**(全段扫 `|v|≈1` 的最长游程只有 4~10 个),建完要 `computeVertexNormals()`;
848
+ 2. **头里的 `format` 不是 `S3MPixelFormat` 枚举** —— 实测四块瓦片都是 `14`,而这枚举里 14 = BGRA(4 B/px),照搬就会把 0.5 B/px 的 DXT1 当 4 字节读,满屏彩噪。必须按 `size / (width*height)` 反推(`pixelFormatOfBpp`);
849
+ 3. **UV 是「v = 0 对应文件首行」**,与 DXT 解码器内部那次 GL 方向的 Y 翻转相反。不抵消的话几何的 v(实测 0.483~0.983)会正好采到图集空的那半张 —— 区域均值亮度 5.6 对比 76.1,模型上就是一片黑块。所以 v1 纹理要置 `S3MTexture.flipY = true`。
850
+
851
+ 子瓦地址规则见 `s3m1ChildUrl`:XML 的 `RangeDataList` 给的是切片时残留的 `.osgb` 名,实际发布是同名换 `.s3m` 并插在 `datas/<数据源>/data/path/<瓦片目录>/` 下;父瓦自己已经在 `data/path` 下时只能换文件名,再插一层就成了 `data/path/data/path/...` 全部 404(有回归测试钉住)。
852
+ - **高斯泼溅(`3DGaussianSplatting`)只按点渲染**。加载、细化、出图整条链路是通的(这类瓦片没有索引包,顶点即点,`GeometryBuilder` 会补一个「顶点即点」的伪包走 `THREE.Points`;不补的话整块瓦片落到「无可用几何」被丢掉,这是之前"加载不出来"的第二个原因)。但画出来的是带顶色的点集,不是真正的泼溅:`gsScale` / `gsRotation` 两条自定义流的量化格式还没标定,而且颜色是 RGBA(A 通道是泼溅不透明度),现在的顶点色路径会丢掉 alpha。要上 EWA splatting 得先按深度层级取样把这两条流对准,再补协方差 + 深度排序的 alpha 混合。
752
853
  - **KTX2** 不在 S3M 的 `pixelFormat` 枚举里,iServer 也不会发;要支持需自行引入 `basis_transcoder.wasm` 并解析容器,属于「换数据源才需要考虑」的项。
753
854
  - **骨骼动画**解析了但未驱动。
754
855
  - 跨城市量级的数据集叠加会有 float32 精度问题。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3m-three",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,82 @@
1
+ import DXTTextureDecode from './DXTTextureDecode.js';
2
+
3
+ /**
4
+ * 解出图集的 alpha 掩码(1 B/像素)。只对 DXT 图集做,其它格式返回 null。
5
+ *
6
+ * 用途:图集里的每个精灵是「建筑立面 + 上方天空」的一整块照片,天空是 alpha=0。
7
+ * 官方材质 alphaMode 全是 opaque,说明它拿到的精灵本来就没有天空 —— 也就是
8
+ * **rect 只框立面本体**。我们手里的 rect 是"照片块",所以用掩码把它收缩到
9
+ * 不透明包围盒,等价于官方那份裁好的 rect。
10
+ */
11
+ export function atlasAlphaMask(tex: {
12
+ width: number;
13
+ height: number;
14
+ pixelFormat: number;
15
+ compressType: number;
16
+ data: Uint8Array | null;
17
+ }): Uint8Array | null {
18
+ if (!tex.data || !tex.width || !tex.height) return null;
19
+ const ct = tex.compressType;
20
+ const isDxt = ct === 14 || ct === 13 || ct === 12 || ct === 17 || ct === 19 || ct === 20 || ct === 21 || tex.pixelFormat >= 17;
21
+ if (!isDxt) return null;
22
+ // compressType 14 → DXT5(pixelFormat 13),见 TextureFormat.ts 的说明
23
+ const pf = ct === 14 ? 21 : tex.pixelFormat;
24
+ if (pf !== 19 && pf !== 20 && pf !== 21) return null; // 只有带 alpha 的格式值得做
25
+ const w = tex.width;
26
+ const h = tex.height;
27
+ const rgba = new Uint8Array(w * h * 4);
28
+ try {
29
+ DXTTextureDecode.decode(rgba, w, h, tex.data, pf);
30
+ } catch {
31
+ return null;
32
+ }
33
+ const mask = new Uint8Array(w * h);
34
+ for (let i = 0; i < w * h; i++) mask[i] = rgba[i * 4 + 3]!;
35
+ return mask;
36
+ }
37
+
38
+ /**
39
+ * 把 rect(uv 空间、y 向上的归一化矩形)收缩到掩码里的不透明包围盒。
40
+ * 找不到不透明像素时原样返回,避免把合法 rect 弄成退化矩形。
41
+ */
42
+ export function shrinkRectToOpaque(
43
+ rect: { x: number; y: number; w: number; h: number },
44
+ mask: Uint8Array | null,
45
+ W: number,
46
+ H: number
47
+ ): { x: number; y: number; w: number; h: number } {
48
+ if (!mask) return rect;
49
+ const px0 = Math.max(0, Math.floor(rect.x * W));
50
+ const px1 = Math.min(W, Math.ceil((rect.x + rect.w) * W));
51
+ // rect.y 是 uv 空间(0 在底部),掩码是图像空间(0 在顶部)
52
+ const py0 = Math.max(0, Math.floor(rect.y * H));
53
+ const py1 = Math.min(H, Math.ceil((rect.y + rect.h) * H));
54
+ if (px1 <= px0 || py1 <= py0) return rect;
55
+
56
+ let bx0 = Infinity, bx1 = -Infinity, by0 = Infinity, by1 = -Infinity;
57
+ for (let py = py0; py < py1; py++) {
58
+ const iy = H - 1 - py;
59
+ if (iy < 0) continue;
60
+ for (let px = px0; px < px1; px++) {
61
+ if (mask[iy * W + px]! <= 8) continue;
62
+ if (px < bx0) bx0 = px;
63
+ if (px > bx1) bx1 = px;
64
+ if (py < by0) by0 = py;
65
+ if (py > by1) by1 = py;
66
+ }
67
+ }
68
+ if (!(bx1 >= bx0) || !(by1 >= by0)) return rect;
69
+
70
+ // 内缩半像素,避开边缘的混色 alpha
71
+ const x0 = bx0;
72
+ const x1 = bx1 + 1;
73
+ const y0 = by0;
74
+ const y1 = by1 + 1;
75
+ const eps = 0.5;
76
+ return {
77
+ x: (x0 + eps) / W,
78
+ w: Math.max(1 / W, (x1 - x0 - 2 * eps) / W),
79
+ y: (y0 + eps) / H,
80
+ h: Math.max(1 / H, (y1 - y0 - 2 * eps) / H),
81
+ };
82
+ }
@@ -0,0 +1,127 @@
1
+ import DXTTextureDecode from './DXTTextureDecode.js';
2
+
3
+ /**
4
+ * 把「一个块里的多张图集」合并成一张。
5
+ *
6
+ * 为什么必须合并:S3MBLOCK 的一个 geo(= 一个网格)里,不同顶点区间可以绑定到**不同的图集**
7
+ * (实测 41_00.s3mblock 里 6 张图集,尺寸 120x144 / 192x128 / 216x288 …,各自的 subTexInfo
8
+ * 绑定不同的 vertexOffset 区间)。而我们的材质只有一个 `map`,只能喂一张图 ——
9
+ * 其它图集那部分顶点就会拿着「自己图集里的归一化坐标」去采另一张图集,采到的是完全无关的
10
+ * 内容(表现就是墙上贴着别处的立面照)。
11
+ *
12
+ * 官方用的是纹理数组(着色器里 `atlas_batchTable_xywh(batchId, batchTextureAtlas, …)` +
13
+ * `uTexAtlasDim` 复数形式),每个 batch 自己带图集序号。这里用等价但更省事的做法:
14
+ * 解析阶段把所有图集按「货架」拼进一张大图,rect 全部换算到这张大图的坐标系,
15
+ * 于是单张 map 就够用了。
16
+ *
17
+ * 坐标系约定:拼图按**自上而下**的行序存放,并让纹理 `flipY = false`,
18
+ * 也就是 UV 的 v=0 对应图片顶行 —— 这样 rect 的 y 直接用 `offsetY / height` 即可。
19
+ */
20
+
21
+ interface AtlasLike {
22
+ id?: string;
23
+ width: number;
24
+ height: number;
25
+ pixelFormat: number;
26
+ compressType: number;
27
+ data?: Uint8Array | null;
28
+ }
29
+
30
+ export interface PackedAtlases {
31
+ width: number;
32
+ height: number;
33
+ data: Uint8Array;
34
+ /** 图集 id → 它在拼图里的左上角像素坐标 */
35
+ offsets: Map<string, { x: number; y: number }>;
36
+ }
37
+
38
+ /** 解码出的图集图像 + 行序约定 */
39
+ export interface DecodedAtlasImage {
40
+ rgba: Uint8Array;
41
+ /**
42
+ * `true` = 缓冲已是自上而下(v=0 顶行),直接拷;
43
+ * `false` = 底朝上(DXT 解码输出即是:源第 sy 行写到 height-1-sy),贴进画布要逐行倒序。
44
+ */
45
+ topDown: boolean;
46
+ }
47
+
48
+ /**
49
+ * 解出图集图像。非 DXT 且非「已经是解好的 RGBA」时返回 null。
50
+ *
51
+ * - `compressType=0 + pixelFormat=28`(leaf 组合画布 / 拼图产物):本来就是自上而下 RGBA,原样返回;
52
+ * - DXT 族:走 `DXTTextureDecode`,输出按仓库约定是**底朝上**的(见 packAtlases 的行序注释)。
53
+ */
54
+ export function decodeAtlasImage(tex: AtlasLike): DecodedAtlasImage | null {
55
+ if (!tex.data || !tex.width || !tex.height) return null;
56
+ const ct = tex.compressType;
57
+ if ((ct === 0 || ct === undefined) && tex.pixelFormat === 28 && tex.data.length === tex.width * tex.height * 4) {
58
+ return { rgba: tex.data, topDown: true };
59
+ }
60
+ const isDxt =
61
+ ct === 12 || ct === 13 || ct === 14 || ct === 17 || ct === 19 || ct === 20 || ct === 21 || tex.pixelFormat >= 17;
62
+ if (!isDxt) return null;
63
+ const out = new Uint8Array(tex.width * tex.height * 4);
64
+ try {
65
+ DXTTextureDecode.decode(out, tex.width, tex.height, tex.data, tex.pixelFormat);
66
+ } catch {
67
+ return null;
68
+ }
69
+ return { rgba: out, topDown: false };
70
+ }
71
+
72
+ /**
73
+ * 合并一个块里的全部图集。只有一张图集时返回 null(没必要合并,也避免多一次解码)。
74
+ */
75
+ export function packAtlases(tp: Record<string, AtlasLike>): PackedAtlases | null {
76
+ const entries = Object.entries(tp).filter(([, t]) => t.data && t.width > 0 && t.height > 0);
77
+ if (entries.length < 2) return null;
78
+
79
+ const images = new Map<string, { w: number; h: number; rgba: Uint8Array; topDown: boolean }>();
80
+ for (const [key, tex] of entries) {
81
+ const img = decodeAtlasImage(tex);
82
+ if (!img) return null; // 有一张解不出来就整体放弃,保持原样比拼错强
83
+ images.set(key, { w: tex.width, h: tex.height, rgba: img.rgba, topDown: img.topDown });
84
+ }
85
+
86
+ // 货架式装箱:按宽度累加,超宽换行。图集都是小块,这样做足够紧凑。
87
+ const MAX_W = 1024;
88
+ let x = 0;
89
+ let y = 0;
90
+ let rowH = 0;
91
+ let usedW = 0;
92
+ const offsets = new Map<string, { x: number; y: number }>();
93
+ for (const [id, img] of images) {
94
+ if (x > 0 && x + img.w > MAX_W) {
95
+ x = 0;
96
+ y += rowH;
97
+ rowH = 0;
98
+ }
99
+ offsets.set(id, { x, y });
100
+ x += img.w;
101
+ rowH = Math.max(rowH, img.h);
102
+ usedW = Math.max(usedW, x);
103
+ }
104
+ const W = Math.max(1, usedW);
105
+ const H = Math.max(1, y + rowH);
106
+ const data = new Uint8Array(W * H * 4);
107
+
108
+ for (const [id, img] of images) {
109
+ const off = offsets.get(id)!;
110
+ // ★ DXT 解码缓冲必须逐行**倒序**拷贝(topDown=false 时)。
111
+ //
112
+ // DXTTextureDecode 输出的是「源图上下颠倒」的缓冲(源第 sy 行写到 height-1-sy),
113
+ // 而 rect 的 y 是按源图**自上而下**的 offsetY 算的。原样拷贝会让拼图的行序和
114
+ // rect 的约定相反;更糟的是同一块里各图集高度不同(192 / 184 / 64 / 128),
115
+ // 颠倒量随图集而异,于是「哪些精灵对、哪些精灵错」取决于它属于哪张图集 ——
116
+ // 表现为同一面墙一半正常一半贴到别处。
117
+ // 倒序拷进来之后,拼图行 0 就对应源图顶行,rect.y = (off.y + offsetY) / H 对全部图集统一成立。
118
+ // 已经是自上而下的缓冲(leaf 组合画布,topDown=true)则原样拷贝,不能再翻一次。
119
+ for (let row = 0; row < img.h; row++) {
120
+ const srcRow = img.topDown ? row : img.h - 1 - row;
121
+ const src = srcRow * img.w * 4;
122
+ data.set(img.rgba.subarray(src, src + img.w * 4), ((off.y + row) * W + off.x) * 4);
123
+ }
124
+ }
125
+
126
+ return { width: W, height: H, data, offsets };
127
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 外部图集文件(`requestNames` 指向的 `.texblock`)能不能顶替块里自带的那份?
3
+ * —— **先验证,再替换**。
4
+ *
5
+ * 官方 `createTexture` 的判定(SuperMap3D.js,`lM.prototype.createTexture`):
6
+ *
7
+ * ```js
8
+ * if (!n || !E) { // 非根瓦片,或没开 textureLod
9
+ * this._subRequestNames = [];
10
+ * for (...) this._subRequestNames.push(yke(h.requestNames[D])); // 只取 requestNames
11
+ * }
12
+ * return this.isLeaf = (!n || !E) && !(h.requestNames.length === 1 && h.requestNames[0] === a);
13
+ * ```
14
+ *
15
+ * 关键两点:
16
+ * 1. **只认 `requestNames`**。`childTexNames` 是**下一级 LOD 的图集指针**(官方 `_ancestorTexture`
17
+ * 只用它继承 batchTable 的 rect),同一个精灵在父级叫 `1460_3` 96×64、子级叫 `1460_2` 192×128,
18
+ * 排布完全不同。拿父级的 rect 去套子级像素必然整张错位 —— 这是曾经踩过的坑。
19
+ * 2. `requestNames.length === 1 && requestNames[0] === 自身 id` → `isLeaf = false` → **整张替换**;
20
+ * 否则是 leaf → 逐精灵贴进图集(那条路要解析多精灵容器,见 HANDOFF)。
21
+ *
22
+ * 本模块只负责「整张替换」这一支的判据:替换前后必须是**同一套排布**,否则 rect 全错。
23
+ *
24
+ * | 缩略图 | 外部文件 | 长宽比偏差 | 下采样后色差 | 结论 |
25
+ * |---|---|---|---|---|
26
+ * | 96×64 | 192×128 | 0.0% | **8.5** | 同一张图,换对了 |
27
+ * | 108×192 | 204×384 | −5.6% | 43.3 | 不是同一张 |
28
+ * | 96×192 | 192×128 | +200% | 63.4 | 根本不是这张 |
29
+ *
30
+ * 判据(三条全过才允许换):
31
+ * 1. 长宽比偏差 ≤ 2%
32
+ * 2. 两轴必须是**同一个整数倍**(`subTexInfos` 的像素 offset 要能精确换算,见 rectScale)
33
+ * 3. 面积平均缩到缩略图尺寸后,不透明区域 RGB 平均绝对误差 ≤ 24/255
34
+ * 纯色/全透明图集无法判定,一律不换(宁可不换,不能换错)。
35
+ */
36
+
37
+ import DXTTextureDecode from './DXTTextureDecode.js';
38
+ import { dxtPixelFormatOf, resolveTextureKind } from './TextureFormat.js';
39
+
40
+ export interface AtlasPixels {
41
+ width: number;
42
+ height: number;
43
+ data: Uint8Array;
44
+ /** 文件里声明的格式;DXT 族的真实块格式以 compressType 为准(pf 常是 13/ABGR 的错标签) */
45
+ compressType: number;
46
+ pixelFormat: number;
47
+ }
48
+
49
+ export interface UpgradeVerdict {
50
+ ok: boolean;
51
+ reason: 'ok' | 'aspect' | 'scale' | 'content' | 'undecodable' | 'empty';
52
+ /** |hi.aspect / lo.aspect - 1|,0 表示完全等比 */
53
+ aspectDelta: number;
54
+ /** 等比倍数(hi / lo)。只有 ok 时才保证是**整数**且两轴相同 */
55
+ scale: number;
56
+ /** 不透明区域 RGB 的平均绝对误差(0..255);无法比较时为 NaN */
57
+ meanAbsError: number;
58
+ }
59
+
60
+ export const DEFAULT_MAX_ASPECT_DELTA = 0.02;
61
+ export const DEFAULT_MAX_MEAN_ERROR = 24;
62
+
63
+ /**
64
+ * 解成 RGBA。**不翻行**:比较双方都用同一套方向,翻不翻不影响结论。
65
+ * 非 DXT / 解不出来返回 null。
66
+ */
67
+ function decodeRgba(tex: AtlasPixels): Uint8Array | null {
68
+ if (!tex.data?.length || !tex.width || !tex.height) return null;
69
+ // 已经是 RGBA(AtlasPack 拼图、或某些服务端直接给 RGBA 的 .texblock)
70
+ const need = tex.width * tex.height * 4;
71
+ if (tex.compressType === 0 && tex.pixelFormat === 28 && tex.data.length >= need) {
72
+ return tex.data.subarray(0, need);
73
+ }
74
+ const kind = resolveTextureKind(tex.compressType, tex.pixelFormat);
75
+ if (!kind.startsWith('dxt')) return null;
76
+ const out = new Uint8Array(tex.width * tex.height * 4);
77
+ try {
78
+ DXTTextureDecode.decode(out, tex.width, tex.height, tex.data, dxtPixelFormatOf(kind as 'dxt1'));
79
+ } catch {
80
+ return null;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** 面积平均缩小到 dw×dh(最近邻会在比较时引入大量假误差,必须用面积平均) */
86
+ export function boxDownsample(
87
+ src: Uint8Array,
88
+ sw: number,
89
+ sh: number,
90
+ dw: number,
91
+ dh: number
92
+ ): Uint8Array {
93
+ const out = new Uint8Array(dw * dh * 4);
94
+ for (let ty = 0; ty < dh; ty++) {
95
+ const y0 = (ty * sh) / dh;
96
+ const y1 = ((ty + 1) * sh) / dh;
97
+ for (let tx = 0; tx < dw; tx++) {
98
+ const x0 = (tx * sw) / dw;
99
+ const x1 = ((tx + 1) * sw) / dw;
100
+ let r = 0, g = 0, b = 0, a = 0, n = 0;
101
+ for (let y = Math.floor(y0); y < Math.ceil(y1); y++) {
102
+ for (let x = Math.floor(x0); x < Math.ceil(x1); x++) {
103
+ const cx = Math.min(sw - 1, x);
104
+ const cy = Math.min(sh - 1, y);
105
+ const o = (cy * sw + cx) * 4;
106
+ r += src[o]!;
107
+ g += src[o + 1]!;
108
+ b += src[o + 2]!;
109
+ a += src[o + 3]!;
110
+ n++;
111
+ }
112
+ }
113
+ const o = (ty * dw + tx) * 4;
114
+ out[o] = r / n;
115
+ out[o + 1] = g / n;
116
+ out[o + 2] = b / n;
117
+ out[o + 3] = a / n;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * 判断 `hi` 是否可以顶替 `lo`。
125
+ *
126
+ * @param lo 块内嵌的缩略图(`subTexInfos` 的坐标就是按它算的)
127
+ * @param hi `.texblock` 里解析出来的高清图
128
+ */
129
+ export function evaluateAtlasUpgrade(
130
+ lo: AtlasPixels,
131
+ hi: AtlasPixels,
132
+ opts: { maxAspectDelta?: number; maxMeanError?: number } = {}
133
+ ): UpgradeVerdict {
134
+ const maxAspect = opts.maxAspectDelta ?? DEFAULT_MAX_ASPECT_DELTA;
135
+ const maxErr = opts.maxMeanError ?? DEFAULT_MAX_MEAN_ERROR;
136
+
137
+ const loAspect = lo.width / lo.height;
138
+ const hiAspect = hi.width / hi.height;
139
+ const aspectDelta = Math.abs(hiAspect / loAspect - 1);
140
+ const scale = hi.width / lo.width;
141
+ const scaleY = hi.height / lo.height;
142
+ const fail = (reason: UpgradeVerdict['reason']): UpgradeVerdict => ({
143
+ ok: false,
144
+ reason,
145
+ aspectDelta,
146
+ scale,
147
+ meanAbsError: NaN,
148
+ });
149
+
150
+ if (!Number.isFinite(aspectDelta)) return fail('aspect');
151
+ if (aspectDelta > maxAspect) return fail('aspect');
152
+ // 高清版必须真的更大,否则换了没意义
153
+ if (hi.width * hi.height <= lo.width * lo.height) return fail('content');
154
+ // ★ 必须**整数等比**:subTexInfos 的 offset/width 是缩略图像素坐标,换图后要按倍数
155
+ // 精确换算(见 S3MBlockTexture.rectScale)。非整数倍会让 rect 落到半个纹素上,
156
+ // 平铺时逐格漂移 —— 宁可不换。
157
+ if (scale !== scaleY || !Number.isInteger(scale)) return fail('scale');
158
+
159
+ const loPx = decodeRgba(lo);
160
+ const hiPx = decodeRgba(hi);
161
+ if (!loPx || !hiPx) return fail('undecodable');
162
+
163
+ const down = boxDownsample(hiPx, hi.width, hi.height, lo.width, lo.height);
164
+ let err = 0;
165
+ let n = 0;
166
+ for (let i = 0; i < lo.width * lo.height; i++) {
167
+ // 只比不透明区域:透明填充的色值没有意义
168
+ if (loPx[i * 4 + 3]! < 8) continue;
169
+ for (let c = 0; c < 3; c++) err += Math.abs(loPx[i * 4 + c]! - down[i * 4 + c]!);
170
+ n += 3;
171
+ }
172
+ if (n === 0) return fail('empty');
173
+
174
+ const meanAbsError = err / n;
175
+ return {
176
+ ok: meanAbsError <= maxErr,
177
+ reason: meanAbsError <= maxErr ? 'ok' : 'content',
178
+ aspectDelta,
179
+ scale,
180
+ meanAbsError,
181
+ };
182
+ }
@@ -218,8 +218,10 @@ function DecompressImage(rgba: Uint8Array, width: number, height: number, blocks
218
218
  const sx = x + px;
219
219
  const sy = y + py;
220
220
  if (sx < width && sy < height) {
221
- // flip Y
222
- let nBegin = 4 * (width * (height - sy) + sx);
221
+ // flip Y:squish 的原式是 height - 1 - sy。写成 height - sy 会让 sy = 0
222
+ // 落到 row = height 越界(TypedArray 越界写是静默丢弃),于是缓冲区第 0 行
223
+ // 永远是全 0(透明黑),其余行整体错位一行。
224
+ let nBegin = 4 * (width * (height - 1 - sy) + sx);
223
225
 
224
226
  for (let i = 0; i < 4; ++i) rgba[nBegin++] = targetRgba[nOffsetTarget++];
225
227
  } else {
@@ -245,11 +247,31 @@ const DXTTextureDecode = {
245
247
  decode(out: Uint8Array, width: number, height: number, block: Uint8Array, format: number): void {
246
248
  if (out == null || block == null || height === 0 || width === 0) return;
247
249
 
248
- // 有 alpha 通道 → RGBA,否则走 rgb565
249
- // S3MPixelFormat.BGR -> 11 / LUMINANCE_ALPHA -> 5
250
- const flags = format > 11 || format === 5 ? kDxt5 : kDxt1 | krgb565;
250
+ // 块大小与 alpha 编码只由压缩格式决定:DXT1 = 8 B/块,DXT3/4/5 = 16 B/块。
251
+ //
252
+ // 不能再靠「format > 11」判有没有 alpha:S3MPixelFormat 里 DXT1 = 17 也 > 11,
253
+ // 那样会把 DXT1 当 16 字节块读——512×512 的 DXT1 实际只有 131072 B(0.5 B/px),
254
+ // 按 16 字节块要 262144 B,正好差一倍,越界部分解成全 0。
255
+ // 官方 JS 那个判断是在「无 alpha 时纹理标成 RGB(10)/BGR(11)」的前提下才成立。
256
+ let flags: number;
257
+ switch (format) {
258
+ case 17: // DXT1
259
+ flags = kDxt1;
260
+ break;
261
+ case 18:
262
+ case 19: // DXT3
263
+ flags = kDxt3;
264
+ break;
265
+ case 20: // DXT4
266
+ case 21: // DXT5
267
+ flags = kDxt5;
268
+ break;
269
+ default:
270
+ // 老数据只在 RGB / BGR / LUMINANCE_ALPHA 里出现:无 alpha 时走 DXT1 + rgb565 快路径
271
+ flags = format > 11 || format === 5 ? kDxt5 : kDxt1 | krgb565;
272
+ }
251
273
 
252
- if (flags & kDxt1 && flags & krgb565) {
274
+ if ((flags & kDxt1) !== 0 && (flags & krgb565) !== 0) {
253
275
  DecompressImage565(asU16(out), width, height, asU16(block));
254
276
  } else {
255
277
  DecompressImage(out, width, height, block, flags);