Files
opendance-web/web-server/src/models/pmx-materials.ts
T
opendance eebccd55b4 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)。
2026-09-16 18:55:25 +08:00

208 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 只读 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;
}
}