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
+207
View File
@@ -0,0 +1,207 @@
/**
* 只读 PMX 的**材质段**(布局与 PMX 2.0 规范 / mmd-parser 一致)。
*
* 为什么需要:Assimp 6.0.4 的 PMX 导入会把 MMD 的材质参数大量丢弃——
* 实测 `【奥黛塔】_by_原神.zip`(39 个材质)里 PMX 有 36 个材质带
* **不剔除背面(双面)**标志,而导出的 GLB 里 `doubleSided` 一个都没有:
* 头发、裙摆、睫毛这类薄片会被单面剔除掉,看起来就是"颜色/贴图不对"。
* 因此转换后按 PMX 原始参数把 GLB 材质补回来(见 `glb-materials.ts`)。
*
* 只读、尽力而为:任何越界/未知版本都返回 null,绝不让调用方失败。
*/
/** PMX 材质的关键参数(我们关心的子集) */
export interface PmxMaterialInfo {
name: string;
diffuse: [number, number, number, number];
specular: [number, number, number];
/** 镜面反射强度(MMD 的 specular power,常见的 0~100+) */
shininess: number;
ambient: [number, number, number];
/** 原始 drawFlag:bit0 = 不剔除背面(双面) */
flag: number;
/** 是否双面渲染(drawFlag bit0) */
doubleSided: boolean;
/** 球面贴图(SPH/SPA)索引,-1 表示没有 */
sphereTextureIndex: number;
/** 球面贴图模式:0 关闭 / 1 乘算 / 2 加算 / 3 子贴图 */
sphereMode: number;
/** 0 = 使用 toonIndex 指定的贴图;1 = 使用共享 toon */
toonFlag: number;
toonIndex: number;
/** 该材质覆盖的三角形数 */
faceCount: number;
}
const PMX_MAGIC = 'PMX ';
class Cursor {
private offset = 0;
constructor(private readonly buffer: Buffer) {}
get position(): number {
return this.offset;
}
get remaining(): number {
return this.buffer.length - this.offset;
}
u8(): number {
const value = this.buffer.readUInt8(this.offset);
this.offset += 1;
return value;
}
i8(): number {
const value = this.buffer.readInt8(this.offset);
this.offset += 1;
return value;
}
u16(): number {
const value = this.buffer.readUInt16LE(this.offset);
this.offset += 2;
return value;
}
i32(): number {
const value = this.buffer.readInt32LE(this.offset);
this.offset += 4;
return value;
}
u32(): number {
const value = this.buffer.readUInt32LE(this.offset);
this.offset += 4;
return value;
}
f32(): number {
const value = this.buffer.readFloatLE(this.offset);
this.offset += 4;
return value;
}
floats(count: number): number[] {
const out: number[] = [];
for (let i = 0; i < count; i += 1) out.push(this.f32());
return out;
}
/** 按字节宽度读索引(PMX 允许 1 / 2 / 4 字节,**有符号**:-1 表示无) */
index(size: number): number {
if (size === 1) return this.i8();
if (size === 2) {
const value = this.buffer.readInt16LE(this.offset);
this.offset += 2;
return value;
}
return this.i32();
}
skip(bytes: number): void {
this.offset += bytes;
}
/** PMX 文本:int32 字节长度 + 内容(UTF-16LE 或 UTF-8) */
text(utf16: boolean): string {
const length = this.i32();
if (length < 0) throw new Error('PMX 文本长度非法');
const raw = this.buffer.subarray(this.offset, this.offset + length);
this.offset += length;
return raw.toString(utf16 ? 'utf16le' : 'utf8');
}
}
/**
* 解析 PMX 的材质段;不是 PMX / 结构异常一律返回 null。
* 会跳过顶点、面、贴图段,只为拿到材质参数,不做几何解析。
*/
export function parsePmxMaterials(buffer: Buffer): PmxMaterialInfo[] | null {
try {
if (buffer.length < 32 || buffer.toString('ascii', 0, 4) !== PMX_MAGIC) return null;
const cursor = new Cursor(buffer);
cursor.skip(4);
cursor.f32(); // version
const globalCount = cursor.u8();
if (globalCount < 8) return null;
const globals = [];
for (let i = 0; i < globalCount; i += 1) globals.push(cursor.u8());
const encoding = globals[0]; // 0 = UTF-16LE, 1 = UTF-8
const additionalUvNum = globals[1];
const vertexIndexSize = globals[2];
const textureIndexSize = globals[3];
const boneIndexSize = globals[5];
const utf16 = encoding === 0;
// 头部 4 段文本:name / nameEn / comment / commentEn
for (let i = 0; i < 4; i += 1) cursor.text(utf16);
// 顶点段:跳过
const vertexCount = cursor.i32();
if (vertexCount < 0) return null;
for (let i = 0; i < vertexCount; i += 1) {
cursor.skip(12 + 12 + 8 + additionalUvNum * 16);
const type = cursor.u8();
if (type === 0) cursor.skip(boneIndexSize);
else if (type === 1) cursor.skip(boneIndexSize * 2 + 4);
else if (type === 2) cursor.skip(boneIndexSize * 4 + 16);
else if (type === 3) cursor.skip(boneIndexSize * 2 + 4 + 36);
else if (type === 4) cursor.skip(boneIndexSize * 4 + 16); // QDEF(PMX 2.1)
else return null;
cursor.skip(4); // edge scale
if (cursor.remaining <= 0) return null;
}
// 面段:索引个数 × 索引宽度
const faceIndexCount = cursor.i32();
if (faceIndexCount < 0) return null;
cursor.skip(faceIndexCount * vertexIndexSize);
// 贴图段:跳过
const textureCount = cursor.i32();
if (textureCount < 0) return null;
for (let i = 0; i < textureCount; i += 1) cursor.text(utf16);
// 材质段
const materialCount = cursor.i32();
if (materialCount < 0 || materialCount > 100_000) return null;
const materials: PmxMaterialInfo[] = [];
for (let i = 0; i < materialCount; i += 1) {
const name = cursor.text(utf16);
cursor.text(utf16); // englishName
const diffuse = cursor.floats(4) as [number, number, number, number];
const specular = cursor.floats(3) as [number, number, number];
const shininess = cursor.f32();
const ambient = cursor.floats(3) as [number, number, number];
const flag = cursor.u8();
cursor.skip(16 + 4); // edgeColor(4f) + edgeSize(1f)
const textureIndex = cursor.index(textureIndexSize);
const sphereTextureIndex = cursor.index(textureIndexSize);
const sphereMode = cursor.u8();
const toonFlag = cursor.u8();
const toonIndex = toonFlag === 0 ? cursor.index(textureIndexSize) : cursor.i8();
cursor.text(utf16); // comment
const faceCount = Math.floor(cursor.u32() / 3);
materials.push({
name,
diffuse,
specular,
shininess,
ambient,
flag,
doubleSided: (flag & 0x01) !== 0,
sphereTextureIndex,
sphereMode,
toonFlag,
toonIndex,
faceCount,
});
}
return materials;
} catch {
return null;
}
}