Files
opendance-web/web-server/src/models/glb-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

308 lines
11 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.
/**
* 把 MMD/PMX 的材质参数补回 Assimp 导出的 GLB。
*
* 为什么需要(真实故障):MMD 的着色模型与 glTF PBR 不是一回事,Assimp 转换会丢东西。
* 实测样本 `【奥黛塔】_by_原神.zip`(PMX,39 个材质):
* * PMX 里 **36 个材质带"不剔除背面"标志**,导出的 GLB 里 `doubleSided` **一个都没有**
* -> 头发、裙摆、睫毛这类薄片被单面剔除,看起来"贴图/颜色不对";
* * PMX 的 `shininess` / `specular` / `ambient` 与 PBR 的 roughness/specular 无映射,
* Assimp 会写上 `KHR_materials_specular`(甚至 `KHR_materials_volume`)等
* 与 MMD 无关的扩展,各渲染器解释不一致;
* * PMX 的 **球面贴图(SPH/SPA,15 项)与 toon 贴图(卡通阶梯)** 在 glTF core 里
* 没有对应槽位,只能记录并说明,不能凭空补出来。
*
* 这里做三件"有把握"的事:
* 1. 按 PMX 的 drawFlag 补 `doubleSided`;
* 2. 把 MMD 参数规范成可预期的 PBR:`metallicFactor = 0`、
* `roughnessFactor = clamp(1 - shininess/100, 0.5, 1)`、`baseColorFactor` 乘上 PMX diffuse;
* 3. 清掉 Assimp 带出的、与 MMD 无关的扩展;可选 `unlit` 模式让颜色与贴图完全一致。
* 另外顺手修正 Assimp 写坏的 `accessor.min/max`(实测 POSITION/TEXCOORD 的 min 都被写成 2.2e-308,
* Godot 等客户端会用它算包围盒)。
*
* 约定:解析不了就返回 null,绝不抛错——后处理失败不能影响入库。
*/
import type { PmxMaterialInfo } from './pmx-materials';
import { buildGlbChunks, parseGlbChunks } from '../common/glb-chunks';
export type ShadingMode = 'pbr' | 'unlit';
/** 与 MMD 无关、Assimp 会顺带写出的扩展(留着只会让各渲染器表现不一致) */
const STRIPPED_EXTENSIONS = [
'KHR_materials_volume',
'KHR_materials_transmission',
'KHR_materials_ior',
'KHR_materials_emissive_strength',
'KHR_materials_specular',
] as const;
interface LooseMaterial {
name?: string;
doubleSided?: boolean;
alphaMode?: string;
extensions?: Record<string, unknown>;
pbrMetallicRoughness?: {
baseColorFactor?: number[];
metallicFactor?: number;
roughnessFactor?: number;
baseColorTexture?: unknown;
};
[key: string]: unknown;
}
interface LooseAccessor {
bufferView?: number;
byteOffset?: number;
componentType?: number;
count?: number;
type?: string;
min?: number[];
max?: number[];
sparse?: unknown;
}
interface LooseBufferView {
buffer?: number;
byteOffset?: number;
byteLength?: number;
byteStride?: number;
}
interface LoosePrimitive {
attributes?: Record<string, number>;
[key: string]: unknown;
}
interface LooseDocument {
materials?: LooseMaterial[];
meshes?: { primitives?: LoosePrimitive[] }[];
accessors?: LooseAccessor[];
bufferViews?: LooseBufferView[];
extensionsUsed?: string[];
extensions?: Record<string, unknown>;
[key: string]: unknown;
}
export interface ApplyMmdMaterialsOptions {
/** PMX 材质表(与 GLB 材质按索引一一对应;null/缺失时只做通用清理) */
pmxMaterials?: PmxMaterialInfo[] | null;
/** 着色模式:pbr(默认)| unlit(KHR_materials_unlit,颜色 = 贴图 x baseColorFactor) */
shading?: ShadingMode;
}
export interface ApplyMmdMaterialsResult {
glb: Buffer;
/** 被设为双面的材质数 */
doubleSided: number;
/** 按 shininess 调整过粗糙度的材质数 */
roughnessAdjusted: number;
/** 清掉的扩展名 */
strippedExtensions: string[];
/** 修正了 min/max 的浮点 accessor 数 */
minMaxFixed: number;
/** 是否写入了 unlit 扩展 */
unlit: boolean;
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 1;
return Math.min(1, Math.max(0, value));
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/** 按 PMX shininess 推一个 PBR 粗糙度:MMD 常见 1(很哑光),高值才是亮面 */
function roughnessFromShininess(shininess: number): number {
if (!Number.isFinite(shininess)) return 1;
return round2(Math.min(1, Math.max(0.5, 1 - shininess / 100)));
}
/** 读取 accessor 的浮点分量(只处理非稀疏、非交织或规范交织的 float 访问器) */
function readAccessorFloats(
doc: LooseDocument,
bin: Buffer | null,
accessorIndex: number,
components: number,
): Float32Array | null {
const accessor = doc.accessors?.[accessorIndex];
if (!accessor || accessor.sparse || typeof accessor.bufferView !== 'number') return null;
// 注意下标:components=3 对应 'VEC3'(写成 [components] 会取到 VEC4,整段静默跳过)
const expectedType = ['SCALAR', 'VEC2', 'VEC3', 'VEC4'][components - 1];
if (accessor.componentType !== 5126 || accessor.type !== expectedType) {
return null;
}
const view = doc.bufferViews?.[accessor.bufferView];
if (!view || view.buffer !== 0 || !bin) return null;
const elementSize = components * 4;
const stride = view.byteStride ?? elementSize;
const base = (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0);
const count = accessor.count ?? 0;
if (count <= 0 || base < 0) return null;
if (base + (count - 1) * stride + elementSize > bin.length) return null;
const out = new Float32Array(count * components);
for (let i = 0; i < count; i += 1) {
for (let c = 0; c < components; c += 1) {
out[i * components + c] = bin.readFloatLE(base + i * stride + c * 4);
}
}
return out;
}
/**
* 修正 Assimp 写坏的 POSITION accessor min/max(实测 min 是 2.2250738585072014e-308)。
* Godot 等客户端会用 min/max 算包围盒,坏值会导致取景异常。
*/
function fixAccessorMinMax(doc: LooseDocument, bin: Buffer | null): number {
let fixed = 0;
const componentsByType: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
for (let accessorIndex = 0; accessorIndex < (doc.accessors?.length ?? 0); accessorIndex += 1) {
{
const accessor = doc.accessors?.[accessorIndex];
if (!accessor || accessor.sparse || accessor.componentType !== 5126) continue;
const components = componentsByType[accessor.type ?? ''];
if (!components) continue;
const values = readAccessorFloats(doc, bin, accessorIndex, components);
if (!values || values.length === 0) continue;
const min = new Array(components).fill(Number.POSITIVE_INFINITY);
const max = new Array(components).fill(Number.NEGATIVE_INFINITY);
let usable = true;
for (let i = 0; i < values.length; i += components) {
for (let c = 0; c < components; c += 1) {
const value = values[i + c];
if (!Number.isFinite(value)) {
usable = false;
break;
}
if (value < min[c]) min[c] = value;
if (value > max[c]) max[c] = value;
}
if (!usable) break;
}
if (!usable) continue;
const same =
Array.isArray(accessor.min) &&
Array.isArray(accessor.max) &&
accessor.min.length === components &&
accessor.max.length === components &&
accessor.min.every((value, index) => Math.abs(value - min[index]) < 1e-6) &&
accessor.max.every((value, index) => Math.abs(value - max[index]) < 1e-6);
if (same) continue;
accessor.min = min;
accessor.max = max;
fixed += 1;
}
}
return fixed;
}
/**
* 把 MMD 材质参数补进 GLB,并做 PBR 规范化。
* 返回 null 表示这个 GLB 解析不了(调用方应原样使用原 buffer)。
*/
export function applyMmdMaterials(
glb: Buffer,
options: ApplyMmdMaterialsOptions = {},
): ApplyMmdMaterialsResult | null {
let parsed: { json: LooseDocument; bin: Buffer | null };
try {
parsed = parseGlbChunks<LooseDocument>(glb);
} catch {
return null;
}
const { json, bin } = parsed;
const materials = json.materials;
if (!Array.isArray(materials) || materials.length === 0) return null;
const pmxMaterials = options.pmxMaterials ?? null;
const shading: ShadingMode = options.shading === 'unlit' ? 'unlit' : 'pbr';
const stripped = new Set<string>();
let doubleSided = 0;
let roughnessAdjusted = 0;
for (let i = 0; i < materials.length; i += 1) {
const material = materials[i];
if (!material || typeof material !== 'object') continue;
const pmx = pmxMaterials?.[i]; // Assimp 保持材质顺序(实测 39/39 对齐)
if (pmx) {
// 1) 双面:PMX drawFlag bit0
if (pmx.doubleSided) {
if (material.doubleSided !== true) doubleSided += 1;
material.doubleSided = true;
} else if (material.doubleSided === true) {
material.doubleSided = false;
}
// 2) PBR 规范化
const pbr = (material.pbrMetallicRoughness ??= {});
pbr.metallicFactor = 0;
const roughness = roughnessFromShininess(pmx.shininess);
if (pbr.roughnessFactor !== roughness) {
pbr.roughnessFactor = roughness;
roughnessAdjusted += 1;
}
const diffuse = pmx.diffuse;
const existing = Array.isArray(pbr.baseColorFactor) ? pbr.baseColorFactor : [1, 1, 1, 1];
const merged = [
clamp01((existing[0] ?? 1) * (diffuse[0] ?? 1)),
clamp01((existing[1] ?? 1) * (diffuse[1] ?? 1)),
clamp01((existing[2] ?? 1) * (diffuse[2] ?? 1)),
clamp01((existing[3] ?? 1) * (diffuse[3] ?? 1)),
];
const unchanged =
existing.length >= 4 &&
existing[0] === merged[0] &&
existing[1] === merged[1] &&
existing[2] === merged[2] &&
existing[3] === merged[3];
if (!unchanged) pbr.baseColorFactor = merged;
} else {
const pbr = material.pbrMetallicRoughness;
if (pbr && pbr.metallicFactor === undefined) pbr.metallicFactor = 0;
}
// 3) 清掉与 MMD 无关的扩展(specular 为黑色时一并清掉)
if (material.extensions) {
const specularBlack =
!pmx || (pmx.specular[0] === 0 && pmx.specular[1] === 0 && pmx.specular[2] === 0);
for (const name of STRIPPED_EXTENSIONS) {
if (name in material.extensions && (name !== 'KHR_materials_specular' || specularBlack)) {
delete material.extensions[name];
stripped.add(name);
}
}
if (Object.keys(material.extensions).length === 0) delete material.extensions;
}
// 4) 可选 unlit:颜色 = baseColorFactor x 贴图,与场景光照无关(最接近 MMD 的平涂观感)
if (shading === 'unlit') {
material.extensions = { ...(material.extensions ?? {}), KHR_materials_unlit: {} };
}
}
// extensionsUsed 与实际引用保持一致
if (shading === 'unlit') {
json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), 'KHR_materials_unlit'])];
}
if (Array.isArray(json.extensionsUsed)) {
const stillUsed = new Set<string>();
for (const material of materials) {
for (const name of Object.keys(material.extensions ?? {})) stillUsed.add(name);
}
for (const extension of Object.keys(json.extensions ?? {})) stillUsed.add(extension);
json.extensionsUsed = json.extensionsUsed.filter((name) => stillUsed.has(name));
if (json.extensionsUsed.length === 0) delete json.extensionsUsed;
}
const minMaxFixed = fixAccessorMinMax(json, bin);
return {
glb: buildGlbChunks(json, bin),
doubleSided,
roughnessAdjusted,
strippedExtensions: [...stripped],
minMaxFixed,
unlit: shading === 'unlit',
};
}