OCC 星球漫游 · 美术资产管线
项目里美术资产总量是 943MB / 473 个文件,但作品运行时真正加载的只有其中很小一部分。 这份文档讲清三件事:资产怎么来的、作品实际用哪些、发布时怎么瘦身。
核对基线:2026-10-16 实测(du / 文件计数)。2026-10-16 给建筑/矿产/无人机做了瘦身(§5.2)。
0. 资产总览
| 目录 | 文件数 | 体积 | 主要格式 | 内容 |
|---|---|---|---|---|
assets/buildings/ | 133 | 240MB | png 109 / glb 43 | 20 栋建筑的模型(原始 + 瘦身两版)、设定图、四视图参考 |
assets/mecha/ | 137 | 590MB | png 99 / glb 23 | 机甲模型与减面版、载具素材、设定图、展示页 |
assets/resources/ | 36 | 73MB | png 30 / glb 12 | 6 种矿产的地表模型(两版)与设定图 |
assets/icons/ | 152 | 12MB | png 101 / svg 49 | 52 个物品图标(两代) |
assets/drones/ | 18 | 30MB | png 15 / glb 6 | 3 种无人机的模型(两版)与设定图 |
| 合计 | 473 | 943MB |
目录内部有统一的"三件套"约定:img/(设定图)、models/<编号>_<名字>/(模型与预览)、ref/(四视图参考)。
1. 作品运行时实际引用哪些
这一节是发布瘦身的唯一依据。
| 引用点(代码) | 资产路径 |
|---|---|
public/src/mecha.js | assets/mecha/model/rig.js、anim.js、reduced/aegis07_voxel_pbr.r004.flat.opt.glb |
public/src/build.js | assets/buildings/models/<bNN>/bNN_final.opt.glb(20 栋,优先;退回 .glb) |
public/src/resources.js | assets/resources/models/<rNN>/rNN_final.opt.glb(6 种,同上) |
public/src/logistics.js | assets/drones/models/d01_cargo/d01_final.opt.glb(同上) |
public/src/vehicle-models.js | public/assets/vehicles/npc_car.glb、elite_tank.glb(注意在 public/ 下,不在 assets/ 下) |
| 物品图标(服务端下发 id) | assets/icons/game/<id>.png(128px,49 个) |
| 世界 Boss「天罗」 | 客户端程序化生成,assets/mecha/models/arachne/ 只是美术参考 |
结论:发布包只需要 4 个机甲文件 + 29 个瘦身版模型(20 建筑 + 6 矿产 + 3 无人机)+ 49 个图标, 其余(设定图、四视图、旧减面版、展示页、29 个原始 GLB)全是开发期素材。 (无人机运行时只用到 d01_cargo,d02/d03 一起瘦身后备用。)
2. 四条工具链
2.1 tools/voxel_build/ —— 程序化体素建模与建筑/资源/无人机
| 脚本 | 作用 |
|---|---|
voxelkit.mjs | 体素库(搭几何的基础设施) |
build_buildings.mjs | 按设计稿生成建筑模型 |
build_icons.mjs | 第一代 8×8 体素 SVG 图标 |
gen_sheets.py | 生成建筑四格设定图 |
split_sheet.py | 四格设定图切成单视角(喂给图生 3D) |
rodin_upload.py / rodin_download.py | 图生 3D 的上下行 |
blender_process.py / blender_postprocess.py | Planar 减面 / 减面 + 缩放 + 重命名 |
blender_render.py | 三视图渲染 |
build_model_viewer.mjs / build_sheets_page.mjs | 生成文档页 |
产出落点:assets/{buildings,resources,drones}/ 的 img / models / ref 三件套 + docs/art/模型预览.html。
2.2 tools/mecha_hero/ —— 机甲设定图与载具
| 脚本 | 作用 |
|---|---|
gen.py / gen_qwen.py / gen_voxel.py | 本地 ComfyUI(127.0.0.1:8188)生设定图 |
gen_spider_boss*.py | Boss「天罗」设定图 |
gen_vehicles.py | 载具设定图 |
fetch_models.py | BANG 分件下载 |
rename_parts.py | root.N → 语义名 |
mirror_glb_part.py | 镜像补对称件 |
decimate_glb.py | 顶点聚类减面 |
rodin_pipeline.md | 流程记录(含被 SUBSCRIPTION_REQUIRED 阻塞的现状) |
产出:设定图 → assets/mecha/img/;载具 → assets/mecha/models/{tank_bulwark,kart_dasher,arachne}/。
2.3 tools/blender/ —— AEGIS-07 机甲的 Blender 管线
CONTRACT.md 是唯一技术基准(坐标系 / 体素 5cm / 导出规范)。
| 脚本 | 作用 |
|---|---|
run_build.py | 建模入口(出 .blend / .glb + JSON 报告) |
render_shots.py | 渲染 8 类图(hero / front / side / back / exploded / rig / scale / parts) |
aegis_voxel/*.py | palette / parts / rigdef / voxlib 模块 |
verify_glb.cjs + verify_glb.html | 用 three.js 侧验证 GLB 内容 |
产出:assets/mecha/model/blender/(.blend、rigged/static glb、renders/)。
2.4 tools/icons/ —— 物品图标
| 脚本 | 作用 |
|---|---|
gen_item_icons.py | 本地 ComfyUI + Qwen-Image 生成 52 个物品图标(含 3 种无人机) |
postprocess_icons.py | flood-fill 抠底 → 透明 PNG,输出 512 hd + 128 game 两档 |
extract_building_ref.py / build_building_icons.py | 建筑图标直接从设计图切"3/4 主视"派生,保证与设计图逐像素同源 |
build_gallery.mjs | 生成 docs/art/物品图标.html |
产出:assets/icons/hd/.png(512)、assets/icons/game/.png(128)、icons/manifest.json。 旧的 ico_*.svg(第一代 8×8 体素)保留可回退,未删除。
3. GLB 瘦身(部署前必做)
tools/glb_optimize.mjs:贴图转 WebP + 降到 1024,产出 .opt.glb。
| 项 | 值 |
|---|---|
| 输入 | aegis07_voxel_pbr.r004.flat.glb(减面后仍 9.2MB) |
| 输出 | aegis07_voxel_pbr.r004.flat.opt.glb(约 0.55MB) |
| 谁在用 | public/src/mecha.js 的 MECHA_URL 指向 .opt.glb |
tools/mesh_reduce.js 是 QEM 减面(保 UV / 材质),用在更早的一步。
⚠️ 顶点数上限:skinGeometry 的蒙皮索引用 Uint16Array 存 ⇒ 顶点数不能超过 65535。 选 r004 这一档正是因为它的 14045 顶点留有充足余量(原版 50 万面会直接越界)。
4. 减面版归档
assets/mecha/model/reduced/ 下有 7 个减面档位(r0015 / r01 / r004,各含 .glb 与 .flat.glb)。 只有 r004.flat.opt.glb 是运行时用的,其余是历史档位,发布时排除。
5. 发布瘦身
deploy/prod/push.sh 里有一张硬编码排除表(见 部署与发布 §2.6),覆盖:
assets/mecha/img/(设定图,93MB)assets/mecha/models/(载具素材,272MB)assets/mecha/model/bang/(60MB)、model/blender/(18MB)- 未减面机甲 GLB(31MB + 27MB)
- 旧减面版 6 个文件
- 一批展示用 HTML
- 2026-10-15 新增:
assets/{buildings,resources,drones}/{img,ref}/、assets/icons/hd/
(设计参考图与生成预览,约 110MB —— 逐条 grep 确认过没有任何运行时引用)
🔴 新增素材目录时必须手动加一条 --exclude —— 这张表是硬编码的,不会自动跟随目录变化。 判断标准是"作品运行时到底需不需要"。
发布包实测体积:314MB(瘦身前 943MB 资产全量)。
5.1 运行时真正需要的资产清单
| 路径 | 内容 | 体积 |
|---|---|---|
assets/mecha/model/reduced/aegis07_voxel_pbr.r004.flat.opt.glb | 机甲瘦身版 | 0.55MB |
assets/icons/game/*.png | 52 个物品图标(server/items.js 的 ICON 前缀) | 1.1MB |
assets/buildings/models/<dir>/<code>_final.opt.glb | 20 栋建筑(瘦身版) | 约 30MB |
assets/resources/models/<dir>/<code>_final.opt.glb | 6 种矿产(瘦身版) | 约 9MB |
assets/drones/models/<dir>/<code>_final.opt.glb | 3 种物流无人机(瘦身版) | 约 3.4MB |
5.2 建筑 / 矿产 / 无人机 GLB 瘦身(2026-10-16)
tools/slim_assets.mjs(一次性构建脚本,不是运行时依赖)。 243.31 MB → 42.23 MB,↓82.6%,省 201MB(29 个模型,单栋 6.5~10.4MB → 1.1~1.8MB)。
三步,每步收益都实测过(指挥中心 b01,7.25MB):
| 步 | 做什么 | 结果 | 靠什么 |
|---|---|---|---|
| 1 | 贴图 2048² PNG → 1024² WebP(q88) | 4.46MB(-38%) | EXT_texture_webp(GLTFLoader 原生支持) |
| 2 | quantize() 顶点量化(position→int16 / normal→int8 / uv→uint16) | 3.01MB(-59%) | KHR_mesh_quantization(原生支持) |
| 3 | meshopt() 几何走顶点/索引编解码器 | 1.38MB(-81%) | EXT_meshopt_compression(要在加载器上挂解码器) |
为什么这批不能照抄机甲那套(只降贴图就够)—— 它们的大头是几何,不是贴图: 指挥中心 7.25MB = 贴图 3.02MB + 网格约 4.2MB(14 个 part、8.3 万三角形、12.7 万顶点,全 f32 属性)。
验证:node tools/glb_verify.mjs —— 逐个跟原始版比三角形数 / 顶点数 / 场景空间包围盒 / 贴图格式 / 扩展, 29/29 通过(包围盒偏差 <1%)。
⚠️ 比包围盒一定要取场景空间(getBounds()带节点变换)。quantize()会把"反量化用的缩放/偏移" 烘进节点变换(这是它无损的原因),于是量化后 accessor 里的值铺满 int16 全域(±1 这种), 和原始文件的浮点坐标不是一个域 —— 直接比会得出"偏差 26%~125%"的假警报。
5.3 客户端怎么读这些模型
public/src/glb-loader.js 是全项目唯一的 GLTFLoader 入口:
createGLTFLoader()—— 建加载器并挂上 meshopt 解码器(public/vendor/addons/libs/meshopt_decoder.module.js,
29KB 内联 WASM)。任何地方都别再 new GLTFLoader(),漏挂的报错 setMeshoptDecoder must be called before loading compressed files 看着像时序问题,其实是绕过了这个文件。
slimCandidates(url)+loadFirst(loader, urls)—— 先试_final.opt.glb,失败退回_final.glb。
⇒ 漏生成某一栋只是慢一点,不会整栋不显示。
dequantizeGeometry(geo)—— 🔴 凡是准备动顶点的地方,先调它。
为什么必须有 dequantizeGeometry("量化 + 改几何"的必踩坑): 量化后 position 是 Int16Array(normalized=true)。three 的读安全 (getX/fromBufferAttribute/getComponent 都会 denormalize),写不安全 —— BufferAttribute.setXYZ() 只是 array[i] = x,往 Int16 里塞 1.2347 会被截成 1。 而 BufferGeometry.applyMatrix4() 恰好是"读出来 → 变换 → setXYZ 写回去" ⇒ 对量化过的几何调它 = 把顶点压扁到 ±1 的格子(模型变成一团乱麻)。 项目里"把变换烘焙进几何"的地方有两处:resources.js(矿产按材质合并)、logistics.js(无人机同理)。 实测不反量化的包围盒偏差:指挥中心 46% / 矿产 39% / 无人机 17%。
浏览器端验证:node tools/e2e/glb_probe.cjs(页面 public/_glb_probe.html)—— 四个代表模型都走 .opt.glb、尺寸正常、贴图在、复刻烘焙流程后包围盒与场景包围盒一致。 (之所以要单独一页:作品里这些 GLB 是按需加载的,普通 smoke 只加载机甲, EXT_meshopt_compression / KHR_mesh_quantization 两条路径根本走不到。)
5.4 矿产模型:两条锥 → 一堆(2026-10-16)
老大报「有几个资源的模型不对,变成了上下两块。要调整成一堆」,另外「资源堆大小需要和机甲差不多」。
病因:这批矿的参考图 assets/resources/ref/rs__front.png 是 384×1024, 而设计总图 assets/resources/img/rs_.png 是 1536×1024(4 列) ⇒ 每张 ref 里其实竖着并排放了 2 个变体,生成管线按列切完就喂给了体素化 ⇒ 一个模型里有两条锥形矿堆上下叠着。
| 矿 | 中间空白带占全高 | 结论 |
|---|---|---|
| 铜 r01 · 石 r04 · 富矿 r06 | 23% / 33% / 22% | 两条锥(就是老大说的"上下两块") |
| 铁 r02 · 锡 r03 · 石油 r05 | 0 | 参考图里只有一条,本来就对(对照 rs_r02_iron_front.png:只有一条锥、四周全是留白) |
修法:tools/ore_settle → tools/ore_trim.mjs —— 在中段那条空白带处竖切, 只留三角面更多的那一条。(两条是同一资产的变体,按面数取多的,不引入随机性、重跑结果一致。)
⚠️ 必须按三角形切(不是删顶点):跨切面的三角形会把模型撕出洞。判据用三角形重心的 Y。 ⚠️ 第一版把 kept/dropped 变量 declare 在循环外累加,于是 if (!kept) continue 只对第一个 part 生效 —— 整块落在另一侧的 part 被原样保留,结果"既没切干净又删错了东西"(石料裁完还剩上下两堆)。 ⚠️ 先用"把上面那块沉下去"(合堆)试过,结果石料变成蘑菇形(窄底座 + 宽帽)—— 因为它上下两块本来就不是同一个锥形轮廓。先量清楚形状再动手。
尺寸:resources.js 的归一化目标从 CELL × 0.45 ≈ 4.64 改成 cfg.mecha.height(6.2) —— 老大要的"和机甲差不多"。口径写明白,以后机甲改高度它自动跟。堆内散布半径 PILE_RADIUS 0.85 → 1.15 格 (块大了,散布也要跟着放大才不会糊成一坨;上限是 PILE.STEP/2=2.5 格,再大相邻两堆就连片了)。
5.5 焦土专属植被几何(2026-10-16)
焦土重做时新加三种程序化几何(public/src/models.js,无外部素材):
| 函数 | 顶替哪一档 | 形状 |
|---|---|---|
makeCharredTree() | 针叶树 | 光秃黑树干 + 4 根斜出枯枝 + 顶端烧断的茬口。没有树冠 |
makeCharredStump() | 阔叶树 | 烧断的粗树桩 + 张开的根盘 + 顶面参差断口 |
makeAshMound() | 灌木 | 灰白浮灰堆 + 三块焦炭(灰偏亮,焦土整体压得暗,需要浅色撑开地面层次) |
🔴 三条硬约束(改之前先看):
- 基准高度必须 1.0(与原树同约定)——
flora.js的scale:[9,26]与collide()都按
"归一化模型 × scale"算;高度一变,视觉体量和碰撞半径就对不上(穿模 / 看不见的墙)。
- 不消耗随机数:几何是构建期一次生成的,与
flora.js的落点 rng 无关
⇒ 换几何不会移动任何一棵树、也不会改障碍物 id(记忆铁律:「障碍物 id = 生成顺序」)。
- 配色直接
paint进顶点色(与其它模型一致),逐实例冷暖靠主题的tint微调。
⚠️ 草叶是例外:makeGrassTexture() 出的贴图是当 map 用的,草叶颜色画死在贴图里 ⇒ 逐实例 tint 乘不掉绿色。所以它加了 colors 参数,主题给 7 根草叶各自的炭化色。
6. 版本库与资产的关系
.gitignore 只把两个大目录排除在仓库外:
| 排除项 | 原因 |
|---|---|
assets/mecha/img/ | 大量设定图(93MB) |
assets/mecha/models/arachne/ | Boss 参考素材 |
其余素材(含 assets/buildings、assets/resources、assets/drones、assets/icons) 是进仓库的 —— 2026-10-15 发布时确认过(老大要求全量推 Gitee)。 2026-10-16 瘦身之后仓库里同时有原始版和 *_final.opt.glb (瘦身版也进库:约 42MB,git clone 完就有能跑的数据)。 所以这些资产随 git clone 一起走;生产发布仍走 push.sh 的 tar 打包 (排除开发素材 + 排除原始 GLB,只发瘦身版)。
7. 缓存策略(改资产后要注意)
| 路径 | 缓存 | 改动后 |
|---|---|---|
/assets/mecha/model 下带版本号 .glb | 30 天 immutable | 必须换文件名(版本号),否则用户拿到旧的 |
/assets/mecha/model 下固定名(rig.js 等) | 1 小时 | 等一小时或强刷 |
/assets/icons | 1 小时 | 同上 |
/assets/buildings、resources、drones、models | 7 天(不加 immutable) | 强刷一般能拿到 |
⚠️ 建筑 / 矿产 / 无人机的瘦身版是同名覆盖(b01_final.opt.glb内容变了还是这个名字), 所以那几个路由绝不能加immutable(server.js与 nginx 都只给max-age=7d)。 改了瘦身参数重新生成之后,老用户最坏 7 天后才拿到新的 —— 需要立刻生效就强刷或换文件名。
| /public | 5 分钟协商 | 正常刷新即可 |
🔴 给public/下的 JS/CSS 加immutable是明令禁止的:文件名没有内容哈希,加了之后老用户会一直跑旧代码。
8. 文档页与美术稿
| 产出 | 生成方式 |
|---|---|
docs/art/建筑.html、矿产资源.html、无人机.html | 手写静态 HTML(设计稿),图片由 docs/_build/attach-images.mjs 挂入 |
docs/art/总览.html | 手写(体素规范与配色体系) |
docs/art/物品图标.html | tools/icons/build_gallery.mjs 生成 |
docs/art/模型预览.html | tools/voxel_build/build_model_viewer.mjs 生成 |
⚠️ docs/art/ 下是手写页,不要被 md2html.mjs 覆盖(构建链只处理 docs/*.md → docs/guide/)。
相关文档
- 发布流程与排除表:部署与发布
- 图标与物品的对应关系:背包与物品系统方案-v1
- 美术规范(体素粒度 / 配色 / 命名):
docs/art/总览.html