/** * 把 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; 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; [key: string]: unknown; } interface LooseDocument { materials?: LooseMaterial[]; meshes?: { primitives?: LoosePrimitive[] }[]; accessors?: LooseAccessor[]; bufferViews?: LooseBufferView[]; extensionsUsed?: string[]; extensions?: Record; [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 = { 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(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(); 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(); 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', }; }