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; pbr?: Record; } /** 造一个含 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 { 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> }>(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> }>(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>; 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>; 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> }>(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(); }); });