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
+246
View File
@@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest';
import { buildGlbChunks, parseGlbChunks } from '../src/common/glb-chunks';
import { applyMmdMaterials } from '../src/models/glb-materials';
import { type PmxMaterialInfo, parsePmxMaterials } from '../src/models/pmx-materials';
/**
* MMD(PMX)材质 → glTF 材质的补全。
*
* 背景(真实故障):Assimp 6.0.4 会把 PMX 的**双面(不剔除背面)**标志丢掉——
* 实测 39 个材质里 PMX 有 36 个是双面的,导出的 GLB 里 `doubleSided` 一个都没有,
* 头发/裙摆/睫毛这些薄片被单面剔除,看起来就是"贴图颜色不对"。
* 另外 PMX 的 shininess/specular/ambient 与 PBR 无映射,Assimp 还会带上
* `KHR_materials_specular` / `KHR_materials_volume` 等与 MMD 无关的扩展。
*/
/* ------------------------------------------------------------ PMX 夹具 */
/** 造一个最小可解析的 PMX:1 顶点 + 1 三角形 + 1 贴图 + N 个材质 */
function buildPmx(materials: Array<{ name: string; flag: number; shininess: number; diffuse?: number[] }>): Buffer {
const parts: Buffer[] = [];
const u8 = (v: number) => Buffer.from([v & 0xff]);
const i32 = (v: number) => {
const b = Buffer.alloc(4);
b.writeInt32LE(v, 0);
return b;
};
const u32 = (v: number) => {
const b = Buffer.alloc(4);
b.writeUInt32LE(v, 0);
return b;
};
const f32 = (v: number) => {
const b = Buffer.alloc(4);
b.writeFloatLE(v, 0);
return b;
};
const text = (s: string) => {
const body = Buffer.from(s, 'utf16le');
return Buffer.concat([i32(body.length), body]);
};
parts.push(Buffer.from('PMX ', 'ascii'));
parts.push(f32(2.0));
parts.push(u8(8)); // globals 数量
// encoding=UTF16, additionalUv=0, vertexIndex=4, textureIndex=1, material=1, bone=2, morph=1, rigid=2
parts.push(Buffer.from([0, 0, 4, 1, 1, 2, 1, 2]));
parts.push(text('test'), text('test'), text(''), text('')); // name/en/comment/en-comment
parts.push(i32(1)); // 顶点数
parts.push(Buffer.alloc(12 + 12 + 8)); // position + normal + uv
parts.push(u8(0)); // BDEF1
parts.push(Buffer.alloc(2)); // bone index (2 字节)
parts.push(f32(1)); // edge scale
parts.push(i32(3)); // 索引数
parts.push(Buffer.alloc(3 * 4)); // 3 个 4 字节索引
parts.push(i32(1)); // 贴图数
parts.push(text('tex/a.png'));
parts.push(i32(materials.length));
for (const material of materials) {
parts.push(text(material.name), text(''));
parts.push(Buffer.concat((material.diffuse ?? [1, 1, 1, 1]).map(f32)));
parts.push(Buffer.concat([f32(0), f32(0), f32(0)])); // specular
parts.push(f32(material.shininess));
parts.push(Buffer.concat([f32(0.5), f32(0.5), f32(0.5)])); // ambient
parts.push(u8(material.flag));
parts.push(Buffer.concat([f32(0), f32(0), f32(0), f32(1)])); // edgeColor
parts.push(f32(1)); // edgeSize
parts.push(Buffer.alloc(1)); // textureIndex (1 字节)
parts.push(Buffer.alloc(1)); // sphereTextureIndex (1 字节,0 → 无? 这里当作索引 0)
parts.push(u8(0)); // sphereMode
parts.push(u8(1)); // toonFlag = 共享 toon
parts.push(u8(0)); // toonIndex (int8)
parts.push(text('')); // comment
parts.push(u32(3)); // faceCount(索引数)= 1 个三角形
}
return Buffer.concat(parts);
}
/* ------------------------------------------------------------ GLB 夹具 */
interface MaterialSpec {
name: string;
doubleSided?: boolean;
extensions?: Record<string, unknown>;
pbr?: Record<string, unknown>;
}
/** 造一个含 POSITION(min/max 故意写错)与 N 个材质的 GLB */
function buildGlb(materials: MaterialSpec[]): Buffer {
const positions = float32([0, 0, 0, 1, 0, 0, 0, 2, 0]);
const indices = Buffer.alloc(6);
indices.writeUInt16LE(0, 0);
indices.writeUInt16LE(1, 2);
indices.writeUInt16LE(2, 4);
const bin = Buffer.concat([positions, indices]);
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
materials: materials.map((m) => ({
name: m.name,
...(m.doubleSided === undefined ? {} : { doubleSided: m.doubleSided }),
...(m.extensions ? { extensions: m.extensions } : {}),
pbrMetallicRoughness: { baseColorTexture: { index: 0 }, ...(m.pbr ?? {}) },
})),
textures: [{ source: 0 }],
images: [{ bufferView: 2, mimeType: 'image/png' }],
accessors: [
// 故意写坏的 min(Assimp 实测会写成 2.2e-308)
{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3', min: [5, 5, 5], max: [9, 9, 9] },
{ bufferView: 1, componentType: 5123, count: 3, type: 'SCALAR' },
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: positions.length },
{ buffer: 0, byteOffset: positions.length, byteLength: indices.length },
],
buffers: [{ byteLength: bin.length }],
extensionsUsed: ['KHR_materials_specular', 'KHR_materials_volume'],
};
return buildGlbChunks(json, bin);
}
function float32(values: number[]): Buffer {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
return buffer;
}
function pmxMaterial(overrides: Partial<PmxMaterialInfo> = {}): PmxMaterialInfo {
return {
name: 'm',
diffuse: [1, 1, 1, 1],
specular: [0, 0, 0],
shininess: 1,
ambient: [0.5, 0.5, 0.5],
flag: 1,
doubleSided: true,
sphereTextureIndex: -1,
sphereMode: 0,
toonFlag: 1,
toonIndex: 0,
faceCount: 1,
...overrides,
};
}
/* ------------------------------------------------------------------ 测试 */
describe('pmx-materials:只读 PMX 材质段', () => {
it('读出材质数量与双面标志(drawFlag bit0)', () => {
// flag=31(双面) / 14(单面) / 15(双面)
const pmx = buildPmx([
{ name: '颜', flag: 31, shininess: 1 },
{ name: '裙', flag: 14, shininess: 1 },
{ name: '体', flag: 15, shininess: 20, diffuse: [0.9, 0.8, 0.7, 1] },
]);
const materials = parsePmxMaterials(pmx);
expect(materials).not.toBeNull();
expect(materials!.map((m) => m.name)).toEqual(['颜', '裙', '体']);
expect(materials!.map((m) => m.doubleSided)).toEqual([true, false, true]);
expect(materials![2].shininess).toBe(20);
const diffuse = materials![2].diffuse;
expect(diffuse[0]).toBeCloseTo(0.9, 5);
expect(diffuse[1]).toBeCloseTo(0.8, 5);
expect(diffuse[2]).toBeCloseTo(0.7, 5);
});
it('不是 PMX 时返回 null,不抛错', () => {
expect(parsePmxMaterials(Buffer.from('not a pmx file at all'))).toBeNull();
expect(parsePmxMaterials(Buffer.alloc(0))).toBeNull();
});
});
describe('glb-materials:按 PMX 参数补全并规范化', () => {
it('按 PMX 的 drawFlag 补 doubleSided,并规范化 metallic/roughness', () => {
const glb = buildGlb([{ name: 'm0' }, { name: 'm1', doubleSided: true }]);
const result = applyMmdMaterials(glb, {
pmxMaterials: [pmxMaterial({ shininess: 1 }), pmxMaterial({ doubleSided: false, shininess: 50 })],
});
expect(result).not.toBeNull();
expect(result!.doubleSided).toBe(1); // 只有 m0 需要补
const { json } = parseGlbChunks<{ materials: Array<Record<string, any>> }>(result!.glb);
expect(json.materials[0].doubleSided).toBe(true);
expect(json.materials[1].doubleSided).toBe(false);
expect(json.materials[0].pbrMetallicRoughness.metallicFactor).toBe(0);
// shininess=1 -> 0.99(哑光);shininess=50 -> 0.5(亮面)
expect(json.materials[0].pbrMetallicRoughness.roughnessFactor).toBe(0.99);
expect(json.materials[1].pbrMetallicRoughness.roughnessFactor).toBe(0.5);
});
it('把 PMX diffuse 乘进 baseColorFactor(含 alpha)', () => {
const glb = buildGlb([{ name: 'm0', pbr: { baseColorFactor: [1, 1, 1, 1] } }]);
const result = applyMmdMaterials(glb, {
pmxMaterials: [pmxMaterial({ diffuse: [1, 0.5, 0.5, 0] })],
});
const { json } = parseGlbChunks<{ materials: Array<Record<string, any>> }>(result!.glb);
expect(json.materials[0].pbrMetallicRoughness.baseColorFactor).toEqual([1, 0.5, 0.5, 0]);
});
it('清掉与 MMD 无关的扩展(specular 为黑 / volume 等)', () => {
const glb = buildGlb([
{
name: 'm0',
extensions: { KHR_materials_specular: { specularFactor: 1 }, KHR_materials_volume: { thicknessFactor: 0 } },
},
]);
const result = applyMmdMaterials(glb, { pmxMaterials: [pmxMaterial()] });
expect(result!.strippedExtensions.sort()).toEqual(['KHR_materials_specular', 'KHR_materials_volume']);
const { json } = parseGlbChunks<{ materials: Array<Record<string, any>>; extensionsUsed?: string[] }>(result!.glb);
expect(json.materials[0].extensions).toBeUndefined();
expect(json.extensionsUsed).toBeUndefined();
});
it('unlit 模式:每个材质写 KHR_materials_unlit 并登记 extensionsUsed', () => {
const glb = buildGlb([{ name: 'm0' }, { name: 'm1', doubleSided: true }]);
const result = applyMmdMaterials(glb, { pmxMaterials: [pmxMaterial(), pmxMaterial()], shading: 'unlit' });
expect(result!.unlit).toBe(true);
const { json } = parseGlbChunks<{ materials: Array<Record<string, any>>; extensionsUsed?: string[] }>(result!.glb);
expect(json.materials[0].extensions.KHR_materials_unlit).toEqual({});
expect(json.materials[1].extensions.KHR_materials_unlit).toEqual({});
expect(json.extensionsUsed).toEqual(['KHR_materials_unlit']);
});
it('修正 Assimp 写坏的 accessor min/max(Godot 用它算包围盒)', () => {
const glb = buildGlb([{ name: 'm0' }]);
const result = applyMmdMaterials(glb, { pmxMaterials: null });
expect(result!.minMaxFixed).toBeGreaterThan(0);
const { json } = parseGlbChunks<{ accessors: Array<Record<string, any>> }>(result!.glb);
expect(json.accessors[0].min).toEqual([0, 0, 0]);
expect(json.accessors[0].max).toEqual([1, 2, 0]);
});
it('没有 PMX 材质表时也做通用清理,且不改材质数量', () => {
const glb = buildGlb([{ name: 'm0' }, { name: 'm1' }]);
const result = applyMmdMaterials(glb, { pmxMaterials: null });
const { json } = parseGlbChunks<{ materials: unknown[] }>(result!.glb);
expect(json.materials.length).toBe(2);
expect(result!.doubleSided).toBe(0);
});
it('不是 GLB 时返回 null(后处理失败不能影响入库)', () => {
expect(applyMmdMaterials(Buffer.from('not a glb'), { pmxMaterials: [pmxMaterial()] })).toBeNull();
});
});