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)。
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 只读 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user