背景(用户反馈 + 本地用真 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)。
208 lines
6.6 KiB
TypeScript
208 lines
6.6 KiB
TypeScript
/**
|
||
* 只读 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;
|
||
}
|
||
}
|