背景(用户反馈 + 本地用真 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)。
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
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();
|
||
});
|
||
});
|