fix(models): MMD 材质对齐——补回双面标志、规范化 PBR 参数、可选 unlit 着色

背景(用户反馈 + 本地用真 Assimp 6.0.4 复现):
PMX 的着色模型(卡通阶梯 + 球面贴图 + 平坦环境光)与 glTF PBR 不是一回事,
Assimp 转换会丢掉其中一部分。实测样本【奥黛塔】_by_原神.zip(39 个材质):

1. 主因:PMX 里 36 个材质带 drawFlag bit0(不剔除背面),而导出的 GLB 里
   doubleSided 一个都没有 -> 头发/裙摆/睫毛这类薄片被单面剔除,
   看起来就是颜色/贴图不对;
2. PMX 的 shininess/specular/ambient 与 PBR 无映射;Assimp 还会带上
   KHR_materials_specular / KHR_materials_volume 等与 MMD 无关的扩展;
3. Assimp 把 accessor.min/max 写坏(实测 min = 2.2250738585072014e-308),
   Godot 等客户端用它算包围盒。

修复:
1. 新增 src/models/pmx-materials.ts:只读 PMX 材质段(布局与权威库 mmd-parser
   一致,注意贴图索引宽度是 1 字节),拿到 name/diffuse/specular/shininess/
   ambient/drawFlag/球面贴图/toon/faceCount;
2. 新增 src/models/glb-materials.ts:转换后按索引把 PMX 参数补回 GLB ——
   doubleSided、metallicFactor=0、roughnessFactor=clamp(1-shininess/100,0.5,1)、
   baseColorFactor x diffuse(含 alpha);清掉与 MMD 无关的扩展;
   修正全部浮点 accessor 的 min/max;支持 KHR_materials_unlit(MODEL_SHADING);
3. 抽出 src/common/glb-chunks.ts(JSON/BIN chunk 读写),vrm-writer 改为复用;
4. 能力边界写清楚:PMX 的球面贴图(该模型 15 项)与 toon 阶梯在 glTF core 里
   没有对应槽位,任何转换器都无法保留,数量写进 conversion_note 供审核查看;
5. 新增 test/mmd-materials.spec.ts 9 项;规模 15 文件 / 220 项;
6. 新增 MODEL_SHADING 配置(默认 pbr,可选 unlit),文档同步(README.md/.txt、DSH.md、.env.example)。
This commit is contained in:
opendance
2026-09-16 18:55:25 +08:00
parent ae8a68e98f
commit eebccd55b4
18 changed files with 982 additions and 86 deletions
+15
View File
@@ -91,6 +91,20 @@ export class AppConfig {
return (this.config.get<string>('modelConvertFormat') ?? 'glb') === 'vrm' ? 'vrm' : 'glb';
}
/**
* 转换后模型的着色模式(`MODEL_SHADING`)。
*
* MMD 的着色模型(卡通阶梯 + 球面贴图 + 平坦环境光)在 glTF core 里没有对应实现,
* 因此提供两种交付:
* `pbr`(默认):标准 PBR;我们按 PMX 参数修正 doubleSided / metallic / roughness,
* 清掉 Assimp 带出的无关扩展;
* `unlit`:给每个材质写 `KHR_materials_unlit`,颜色 = 贴图 x baseColorFactor,
* 与场景光照无关,最接近 MMD 的平涂观感(three.js / Godot 都支持)。
*/
get modelShading(): 'pbr' | 'unlit' {
return (this.config.get<string>('modelShading') ?? 'pbr') === 'unlit' ? 'unlit' : 'pbr';
}
/** 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 */
get modelConvertPmx(): string {
return this.config.get<string>('modelConvertPmx') ?? 'Hatsune Miku.pmx';
@@ -194,6 +208,7 @@ export const configuration = () => {
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'glb',
modelShading: process.env.MODEL_SHADING ?? 'pbr',
modelConvertPmx: process.env.MODEL_CONVERT_PMX ?? 'Hatsune Miku.pmx',
modelConvertTimeoutMs: Number(process.env.MODEL_CONVERT_TIMEOUT_MS ?? 180_000),
defaultModelId: process.env.DEFAULT_MODEL_ID ?? '',