Files
opendance-web/web-server/test/mmd-materials.spec.ts
T
opendance eebccd55b4 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)。
2026-09-16 18:55:25 +08:00

247 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});