/** * 只读 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; } }