简介:这份工具包面向从事倾斜摄影三维建模、Web 三维发布与 GIS 可视化的技术人员,用于把 OSGB 格式的倾斜摄影模型批量转换为可在 Cesium 等平台加载的 3dtile 瓦片,解决 OSGB 数据难以直接在浏览器端高效渲染的问题。压缩包共 178 个文件、约 12.39MB,主体为 1 个 exe 可执行程序,并配套 89 个 dll 运行库与 35 个 gfs 配置模板,另有 csv、wkt、xsd、xml 等坐标系与投影参数文件,以及少量 dgn、dxf、svg、json、ini 等辅助资源,覆盖转换过程中所需的投影定义与参数配置,无需另行搭建编译环境。已有 580 人学习下载。使用者可借助目录内现成的配置与说明,按目录结构准备 OSGB 数据后直接调用命令行完成转换,并将生成的 3dtile 部署到 tomcat 中验证浏览效果,适合需要快速打通倾斜摄影数据上桌与发布链路的中高级使用者参考。
1. 拿到 38GB 倾斜摄影成果,先想清楚 OSGB 转 3dtile 卡在哪
空三跑完,交付目录里躺着Data文件夹、一万七千多个.osgb分块,外加一份metadata.xml——这是实景三维项目里很常见的现场。想在 Web 端把它立起来,第一道坎就是OSGB 转 3dtile:OSGB 是桌面渲染器认的场景图格式,3D Tiles 才是浏览器和三维引擎认的瓦片规范。这个工具干的就是中间这段搬运活儿:读目录层级、把每块 osgb 拆成 LOD 层级、转 glTF、包 b3dm 壳、拼出tileset.json。适合手里有倾斜摄影成果、要在 Web 端做展示和量测的从业者,也适合想弄明白 LOD 与坐标变换究竟在哪一步翻车的人。
2. OSGB 与 3D Tiles 之间差了什么:目录、b3dm、ENU 变换
2.1 目录名就是索引:Tile_+000_+000 里藏着的三层信息
ContextCapture 系(其他建模软件输出结构大同小异)导出的倾斜摄影成果,通常长这样:
Data/ ├── metadata.xml ├── Tile_+000_+000/ │ ├── Tile_+000_+000.osgb │ └── ... ├── Tile_+000_+001/ └── Tile_+001_+000/目录名里的+000_+000是瓦片在平面网格里的行列号,不是坐标值,而是一个相对索引。真正的空间位置由metadata.xml里的SRS(空间参考系)和SRSOrigin(局部坐标原点)决定。SRSOrigin一般写成Easting,Northing,Height三个数,是整份成果的局部原点在源投影坐标系下的坐标;每个 osgb 内部的顶点坐标都是相对这个原点的偏移量。
这一步决定了后面所有事情。转换器读目录,不是随机遍历,而是按行列号重建四叉树:+000_+000是根,+000_+001和+001_+000是它的邻居,越往正方向走层级越细。有的产出用_L1、_L2后缀分文件,有的把所有 LOD 塞进一个 osgb 的 paging 节点里——前者转换器按文件名切层,后者得靠 OpenSceneGraph 的场景图遍历去抽。判断方法很简单:看单个瓦片目录里是不是有多个同前缀文件,有就是分文件型。
还有一点容易忽略:目录层级数和 LOD 层数不是一回事。一个Tile_+003_+005目录里可能只有 1 层几何,也可能有 5 层。转换器要输出的是「每个 LOD 层一个 b3dm」,而不是「每个目录一个 b3dm」。如果工具默认按目录出瓦片,出来的tileset.json会缺失 LOD 梯度,远处不会自动降级,首屏直接拉满。
2.2 b3dm 只是一层壳,真正转的是里面的 glTF
3D Tiles 的b3dm(Batched 3D Model)结构非常薄:一个 28 字节的定长头,加上 feature table、batch table,后面直接拼接 glTF 二进制(.glb)的载荷。
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 4 字节 | 固定b3dm |
| version | 4 字节 | 当前为 1 |
| byteLength | 4 字节 | 整个 b3dm 文件的总字节数 |
| featureTableJSONByteLength | 4 字节 | 要素表 JSON 长度 |
| featureTableBinaryByteLength | 4 字节 | 要素表二进制长度 |
| batchTableJSONByteLength | 4 字节 | 批处理表 JSON 长度 |
| batchTableBinaryByteLength | 4 字节 | 批处理表二进制长度 |
倾斜摄影模型没有单体化 ID,所以BATCH_LENGTH通常写 0,batch table 留空。真正吃工作量的是 glTF 转换:OSGB 是 Z 轴向上(Z-up),glTF 2.0 规范要求 Y 轴向上(Y-up),转换器必须做一次(x, y, z) → (x, z, -y)的轴系翻转。这个翻转要么烘进顶点,要么放在tileset.json根节点的transform矩阵里——两派做法都有人用,但只能做一次,做两次模型就躺平了,这是后面避坑章要展开的第一个大坑。
另外,纹理也是个决策点。OSGB 里的贴图是外链 jpg,转 glb 时要么内嵌到二进制里(单文件体积涨 30% 左右,但分发干净),要么保持外链(体积小,但一个 b3dm 旁边得带一堆图,路径一错就白模)。做 Web 分发我一般选内嵌,省掉路径穿越的麻烦。
2.3 根节点 transform:把局部 ENU 摆到 ECEF 上
3D Tiles 的坐标系是地心地固系(ECEF),而 OSGB 的坐标是某个投影带下的平面坐标加高程。转换器要在tileset.json的root.transform里写一个 4×4 矩阵,把「局部东-北-天(ENU)坐标」映射到 ECEF。
这个矩阵的构造分两步:先把原点经纬高转成 ECEF 的平移量,再用原点的经度 λ、纬度 φ 构造三个轴向向量。
- 东向单位向量 e = (-sinλ, cosλ, 0)
- 北向单位向量 n = (-sinφ·cosλ, -sinφ·sinλ, cosφ)
- 天向单位向量 u = (cosφ·cosλ, cosφ·sinλ, sinφ)
拼成矩阵后按列主序展开成 16 个浮点数——3D Tiles 规范要求transform数组是列主序的,这一点写错的话模型要么转 90°,要么飞到大西洋。
import math A = 6378137.0 # WGS84 长半轴 F = 1 / 298.257223563 # WGS84 扁率 E2 = 2 * F - F * F # 第一偏心率平方 def lla_to_ecef(lon, lat, h): """经纬高(度/米)转 ECEF(米)""" lon, lat = math.radians(lon), math.radians(lat) n = A / math.sqrt(1 - E2 * math.sin(lat) ** 2) # 卯酉圈曲率半径 x = (n + h) * math.cos(lat) * math.cos(lon) y = (n + h) * math.cos(lat) * math.sin(lon) z = (n * (1 - E2) + h) * math.sin(lat) return x, y, z def enu_transform(lon, lat, h): """生成列主序 4x4 矩阵:局部 ENU -> ECEF""" lon_r, lat_r = math.radians(lon), math.radians(lat) sl, cl = math.sin(lat_r), math.cos(lat_r) so, co = math.sin(lon_r), math.cos(lon_r) east = (-so, co, 0.0) north = (-sl * co, -sl * so, cl) up = ( cl * co, cl * so, sl) tx, ty, tz = lla_to_ecef(lon, lat, h) # 列主序:第 0~3 个元素是第一列,依此类推 return [ east[0], east[1], east[2], 0.0, north[0], north[1], north[2], 0.0, up[0], up[1], up[2], 0.0, tx, ty, tz, 1.0, ] if __name__ == "__main__": m = enu_transform(116.397, 39.909, 50.0) print([round(v, 6) for v in m])这段代码里三个向量的顺序不能换。改成east, up, north会得到另一个合法但语义不同的矩阵——如果你的 glTF 内容已经做过 Y-up 翻转,那transform的第二列应该对应天向,这样 Y-up 的「上」才落到真实的天顶方向。所以矩阵列序和内容轴系是一对耦合参数,必须同时确认。
lla_to_ecef里的h是椭球高,不是正常高。有些成果的SRSOrigin写的是 85 高程,直接当椭球高用,在局部会有几十米的系统性偏差。这种偏差在单个瓦片上看不出来,把模型和路网矢量叠一起时就非常刺眼。工程上要么拿到高程异常值做改正,要么在引擎侧整体调一个偏移量,别指望转换器自己猜。
2.4 LOD 树怎么剪:REPLACE 与 ADD 的取舍
tileset.json里每个节点有refine属性,两选一:
REPLACE:子节点加载完成后替换父节点,屏幕上的三角形总量保持稳定。倾斜摄影默认用这个。ADD:子节点叠加在父节点之上,总量累加。适合给粗模补细节的场景,用在实景三维上会直接炸显存。
还有geometricError,它决定「什么时候切到下一层」。规范里的屏幕空间误差公式是:
SSE = geometricError × 屏幕高度 / (距离 × 2 × tan(fov/2))引擎在SSE > maximumScreenSpaceError时加载子节点。所以geometricError不是美术参数,是几何尺寸量纲,填错了要么远处一片糊,要么近处疯狂请求。下一章会把倒推方法给全。
3. 跑通一次转换:从 Data 目录到能加载的 tileset.json
3.1 开跑前的三件事:运行环境、内存上限、路径规范
转换过程本质是「解场景图 + 转几何 + 压纹理 + 打包二进制」,是典型的 CPU 和内存双高任务。开工前先确认三样东西。
第一是运行时版本。这类工具多数是 Node.js 写的(底层调 OpenSceneGraph 的解析库或者纯 JS 解 osgb),Node 16 以上基本都能跑,但要注意 glb 打包用到的 Buffer API 在旧版本上行为不同,能升就升到 LTS。
第二是堆内存。默认 Node 堆上限只有 1.5~2GB,一个 500MB 的 osgb 解出来的顶点数组加上纹理缓存,很容易顶到天花板,报JavaScript heap out of memory直接中断。启动时显式拉高:
# 把堆上限拉到 8GB,适合 16GB 内存以上的工作站 node --max-old-space-size=8192 ./bin/osgb2tiles.js --help--max-old-space-size的单位是 MB,设成物理内存的 50%~60% 比较稳,留出页缓存给文件读写。
第三是路径。源目录、输出目录、工具所在目录,全部用纯英文加下划线,不要有中文、空格、括号。这不是洁癖,是血泪经验:osgb 内部的纹理引用是相对路径,某些解析库在拼路径时不做过 URI 编码,一遇到中文字符就会拼出乱码路径,表现为转换成功但模型全是白模。
3.2 单块转换:命令行参数逐个过一遍
先拿一个瓦片目录做试跑,别一上来就整包。参数名以你手上这包的--help为准,下面这套是这类工具最常见的形态:
./bin/osgb2tiles \ --input /data/proj/Data \ --output /data/proj/tiles \ --srs EPSG:4547 \ --origin 500000,3300000,45.6 \ --max-lod 5 \ --texture-format jpg \ --texture-max-size 2048 \ --geometric-error 8 \ --threads 4 \ --log-level info逐个说清楚:
--input指向Data目录本身,不是它的父目录。指向父目录时工具找不到metadata.xml,会退回默认参数。--srs是源数据的投影坐标系。CGCS2000 三度带在 EPSG 里对应 4491~4512 区间,六度带在 4491 之前的 4501 段附近,具体带号对错了,模型会整体平移几百公里。实在拿不准就从metadata.xml的SRS字段里抄。--origin覆盖metadata.xml里的SRSOrigin。什么时候用?当元数据缺失、或者原点是 0,0,0 但顶点里已经烘了绝对坐标时,需要手工指定。填的就是三个数:东坐标、北坐标、椭球高。--max-lod限制抽几层。层级越深文件越多,tileset.json的 JSON 体积会指数膨胀。5 层对城市规划级别的展示够用,要看清建筑立面再往上加。--texture-format决定内嵌贴图格式。jpg最省体积,有透明通道的树、栏杆会破图;要保留透明就用png,代价是包体翻倍。--texture-max-size是单张纹理的最大边长。原图常见 4096,压到 2048 肉眼几乎看不出差别,显存占用降到四分之一。--geometric-error是根节点的初始误差值,子节点会按规则递减。下一章细说。--threads并发数。不是越大越好,每个线程都有自己的解码缓冲,4~8 是常见甜点。
跑之前先加--dry-run(如果有)看一遍解析结果:识别到多少瓦片、多少 LOD 层、原点坐标是多少。这一步的价值在于尽早发现原点解析错误,而不是等到转换跑了四十分钟才发现模型在海上。
3.3 产物冒烟校验:先看头的 28 个字节
转换完不要急着丢进引擎。用一段脚本把tileset.json和 b3dm 头扫一遍,两分钟能省掉后面两小时的排查。
import json, os, struct def check_b3dm(path): """校验 b3dm 头:magic、版本、长度自洽、8 字节对齐""" size = os.path.getsize(path) with open(path, "rb") as f: head = f.read(28) if len(head) < 28: return f"{path}: 文件不足 28 字节" magic, ver, blen, ftj, ftb, btj, btb = struct.unpack("<4sIIIIII", head) if magic != b"b3dm": return f"{path}: magic 错误 {magic!r}" if ver != 1: return f"{path}: 版本号异常 {ver}" if blen != size: return f"{path}: 头部声明 {blen} 与实际 {size} 不符" # 规范要求 feature table JSON 之后填充到 8 字节边界 if (28 + ftj) % 8 != 0: return f"{path}: feature table 未按 8 字节对齐" return None def walk_tileset(root_dir): errors = [] for dirpath, _, files in os.walk(root_dir): for fn in files: if fn.endswith(".b3dm"): err = check_b3dm(os.path.join(dirpath, fn)) if err: errors.append(err) return errors if __name__ == "__main__": ts = "tiles/tileset.json" with open(ts, "r", encoding="utf-8") as f: data = json.load(f) print("asset:", data.get("asset")) print("root geometricError:", data["root"]["geometricError"]) print("root refine:", data["root"].get("refine")) errs = walk_tileset("tiles") print(f"发现 {len(errs)} 个异常 b3dm") for e in errs[:10]: print(" ", e)blen != size是最高频的失败信号。它意味着打包过程中文件被截断了——通常是磁盘写满,或者转换进程被系统 OOM killer 干掉但没报错。(28 + ftj) % 8不对则说明 feature table 的 padding 没做,某些加载器会直接拒绝渲染,表现为「模型加载 100% 但不显示」,非常迷惑。
3.4 挂到引擎里看一眼:最小验证环境
别等到集成到业务系统才验证。起个静态服务就能看:
# 在 tiles 目录的上一级启动,注意不能直接 file:// 打开 python -m http.server 8080 --directory /data/proj然后在一个最小页面里加载tileset.json。三个必看的点:
- 模型是否落在正确的经纬度上。用引擎的相机飞到目标点,看模型是不是在视野里。
- 由远到近推镜头,看有没有明显的「跳变」或者某一层突然消失。消失说明 LOD 链断了。
- 打开浏览器的网络面板,看 b3dm 请求数。如果首屏就拉了几百个请求,说明
geometricError给小了,剪枝没起作用。
4. 参数调优:geometricError、纹理和并发到底怎么定
4.1 geometricError 从屏幕误差倒推,不要拍脑袋填
很多人把根节点的geometricError填成场景半径,结果远处不加载、近处过载。正确做法是从「希望在多远切换到下一层」倒推。
上面那个 SSE 公式变形一下:
geometricError = 目标切换距离 × maximumScreenSpaceError × 2 × tan(fov/2) / 屏幕高度假设屏幕高 1080px,垂直视场角 60°(tan30° ≈ 0.5774),引擎的maximumScreenSpaceError设 16px,希望 200 米处切到下一层:
geometricError = 200 × 16 × 2 × 0.5774 / 1080 ≈ 3.42 米所以根节点的误差可以取约 3.4 米,子节点按每层减半(或按瓦片实际包围盒尺寸的 1/2)递推。这个数如果直接填 100,引擎会认为整块模型在 100 米外就是「足够精细」,永远不请求子节点——表现就是无论怎么推镜头都是一坨粗模。
反过来,填 0.1 会让引擎在几公里外就开始请求最深层瓦片,首屏几十秒白屏,然后内存爆掉。geometricError 是唯一一个「填大了糊、填小了卡」的参数,值得单独调一轮。
调的时候有个技巧:不要改tileset.json再重新转换,直接编辑 JSON 里的数值,刷新页面看效果,定下来之后再回写到转换参数里。改 JSON 是秒级的,重新转换是小时级的。
4.2 纹理与网格压缩:Draco 能省多少,什么时候别用
倾斜摄影的体积大头是纹理,通常占 70%~85%。所以第一刀砍纹理:
| 手段 | 典型收益 | 代价 |
|---|---|---|
| 纹理边长 4096 → 2048 | 体积 -60%~70% | 近景文字模糊 |
| 纹理转 jpg 质量 80 | 体积 -20%~30% | 无透明通道 |
| Draco 压缩几何 | 几何部分 -50%~80% | 解码耗时,需加载器支持 |
| KTX2 + Basis 压缩 | 显存占用 -70% | 需 GPU 支持对应格式 |
Draco 要谨慎。几何在倾斜摄影里本来占比就小(大头是贴图),压完收益有限,但解码是同步的,一次解码几百 KB 的几何在低端设备上会卡帧。真正需要压缩几何的场景是「城市级 + 移动端」,普通 PC 端展示关掉它更省心。
KTX2 是另一个思路,它不减小文件体积太多,但把纹理直接从压缩态送进 GPU,显存占用直降。这个压缩对首屏帧率提升最明显,代价是生成过程慢,且部分老设备不支持。我的习惯是:先不压,跑通;再看首屏内存,超过 1.5GB 才上 KTX2。
还有个容易踩的:--texture-max-size压到 1024 以下时,建筑檐口、广告牌这类细节会糊成一团,验收时被挑刺。2048 是一个被反复验证过比较均衡的值。
4.3 大场景拆块与并发:把内存峰值压下来
整包转换最常见的失败不是参数错,是内存炸。几个降峰值的做法,按优先级排:
先降并发。--threads从 8 降到 4,内存峰值能降 40% 左右,总耗时涨不到 30%。这笔买卖划算。
再分批。按行列号把Data拆成几个子目录,每个子目录单独转,最后合并tileset.json。合并不是字符串拼接那么简单,要把各子集的root.children挂到同一个根节点下,并把boundingVolume重算成并集——包围盒算错的表现是模型在视野边缘被错误裁剪。
最后才是加机器。32GB 内存的机器跑城市级(20~50GB 原始数据)是底线,16GB 会很痛苦。
# 分批转换:按行号切分,每批独立进程,串行执行避免内存叠加 for row in 000 001 002; do ./bin/osgb2tiles \ --input /data/proj/Data/Tile_${row}_* \ --output /data/proj/tiles_part${row} \ --srs EPSG:4547 \ --max-lod 5 \ --threads 4 \ --log-level info done注意这里是串行for循环,不是&后台并发。并发跑三个进程,内存是叠加的,等于把--threads设成 12,反而更容易炸。要并行就得分机器,一台机器上分进程并行在这个场景里没有收益。
5. 常见问题排查:五个真正会让人加班的坑
5.1 模型整体偏移几百米,或者埋在土里
现象:加载出来模型在正确位置附近,但整体平移了一个固定距离,或者高程差了几十米,和底图矢量对不上。
原因有三层,按概率排:一是SRSOrigin里的高程是正常高,被当成椭球高用了,差一个高程异常值;二是--srs的带号选错,平面坐标解算到了相邻投影带;三是transform矩阵和 glTF 内容里的 Y-up 翻转重复应用了一次。
解决:先用 2.3 节的脚本,把metadata.xml里的SRSOrigin转成 ECEF,和引擎里模型的实际位置比对,差值如果是常数就是原点问题,如果是旋转关系就是矩阵问题。高程差在几十米量级的,去查高程异常改正;差在几米量级的,多半是原点精度不够,考虑用控制点重新解算。
5.2 一片白模或者一片黑模
现象:模型轮廓正常,但完全没有贴图,或者贴图全黑。
原因:白模是纹理没找到。内嵌模式下这不该发生,一旦发生说明转换时用了外链,而 b3dm 里的相对路径和实际目录结构对不上——常见于源目录有中文或空格,路径拼接时被截断。黑模则是法线方向反了,光照算出来全是背光面,或者用了不支持的光照模型。
解决:白模先看 b3dm 同目录下有没有textures/文件夹,再看 glb 内部的images引用是 uri 还是 bufferView。黑模检查两点:转换参数里有没有「翻转法线」选项,试一下;以及引擎里的光照方向,实景三维一般用无光照的 unlit 材质,误开了 PBR 光照就会发黑。
5.3 首屏白屏十几秒,然后内存爆掉
现象:页面打开后长时间空白,网络面板里几百个 b3dm 请求排队,最后浏览器标签页崩溃。
原因:geometricError太小,剪枝失效,加上maximumScreenSpaceError设得过小(常见于抄来的配置里写 1 或 2)。两者叠加,引擎会尝试把整个场景的最高精度层全拉下来。
解决:先把maximumScreenSpaceError调到 16,再按 4.1 节倒推geometricError。改完刷新,请求数应该从几百降到十几个。如果还不行,检查tileset.json的层级深度——超过 8 层的树在浏览器里本身就很难剪枝,得考虑在转换阶段合并层级。
5.4 中文路径、超长文件名、单文件超限
现象:转换中途报 ENOENT,或者转换成功但部分瓦片缺失。
原因:Windows 下路径长度上限 260 字符,Data/Tile_+000_+000/Tile_+000_+000_L3.osgb这种长名字叠加深层目录很容易超限。中文路径则是解码问题,OSGB 内部字符串多为本地编码。
解决:转换前把整个工程挪到盘符根目录下的短英文路径,比如D:\osgb\。这一步花五分钟,能省掉后面所有的玄学问题。
5.5 转换进程静默退出,日志停在某个瓦片
现象:没有任何报错,进程消失,输出目录里最后一个 b3dm 只有几十字节。
原因:系统 OOM killer 把进程杀了,Node 来不及打印错误。Linux 下dmesg | tail能看到Out of memory: Killed process。Windows 下则可能是杀毒软件扫描大文件时锁住了句柄。
解决:降--threads、分批、加--max-old-space-size。另外给转换脚本加一层「断点续跑」——转换前检查输出目录里已有的 b3dm,跳过已完成的瓦片。这个后悔药值得提前准备。
6. 批量自动化与验收:把一次转换固化成流水线
单次转换跑通只是开始,真实项目里这套东西一周要跑七八次。我的做法是把「转换 + 校验 + 报告」写成一个脚本,输入是Data目录,输出是一份能贴进验收单的检查结果。
import json, os, subprocess, sys from concurrent.futures import ThreadPoolExecutor def convert(part_dir, out_dir, srs): cmd = ["./bin/osgb2tiles", "--input", part_dir, "--output", out_dir, "--srs", srs, "--max-lod", "5", "--texture-max-size", "2048", "--threads", "4", "--log-level", "warn"] r = subprocess.run(cmd, capture_output=True, text=True) return part_dir, r.returncode, r.stderr[-500:] def summarize(tileset_path): with open(tileset_path, encoding="utf-8") as f: ts = json.load(f) stats = {"nodes": 0, "max_depth": 0, "content": 0} def walk(node, depth): stats["nodes"] += 1 stats["max_depth"] = max(stats["max_depth"], depth) if "content" in node: stats["content"] += 1 for c in node.get("children", []): walk(c, depth + 1) walk(ts["root"], 1) return stats if __name__ == "__main__": srs = sys.argv[1] parts = sys.argv[2:] with ThreadPoolExecutor(max_workers=2) as pool: # 控制并发,防内存叠加 for part, code, err in pool.map(lambda p: convert(p, p + "_tiles", srs), parts): print(f"[{'OK' if code == 0 else 'FAIL'}] {part} {err if code else ''}")这里的max_workers=2是刻意的。并发数不是越高越好,两个进程各自 4 线程,内存峰值大约等于单进程的 1.8 倍,比开四个进程安全得多。输出路径用part + "_tiles"而不是统一目录,是为了失败时能快速定位是哪个分片出的问题。
跑完之后过一张验收表,六项,少一项都不签字:
| 检查项 | 合格标准 | 不通过的典型后果 |
|---|---|---|
| tileset.json 可解析 | JSON 结构完整,asset.version 为 1.0 | 引擎直接拒绝加载 |
| b3dm 头自洽 | byteLength 与文件实际大小一致 | 部分瓦片静默丢失 |
| 坐标落点 | 与已知控制点偏差 < 1 米 | 与业务数据套合错位 |
| LOD 层级数 | 与源数据一致,无断链 | 近景糊或远景卡 |
| 首屏请求数 | < 20 个(1080p,默认视角) | 白屏、崩溃 |
| 纹理覆盖率 | 抽查 10 个瓦片,贴图无缺失 | 白模,观感直接崩 |
最后一项我一般人工看。脚本能算贴图文件在不在,但算不出「贴图贴歪了」——这种情况在转换参数里叫纹理坐标原点不一致,只有肉眼能发现。
这套流程跑了两年多,最大的教训是别信「转换成功」这四个字。工具返回退出码 0 只代表进程没崩,不代表产物能用。我现在每次转换完,先跑 b3dm 头校验,再开页面推一遍镜头,两件事做完才敢往测试环境发。希望帮到你。
本文还有配套的精品资源,点击获取