现象(用户反馈):导入 PMX 转换出的 GLB 后,列表页/审核面板里的模型是白模, 脸部颜色信息全部丢失。 定位过程(本地装真 Assimp 6.0.4 + 该模型完整复现): 1. 转换本身正常:23.7MB GLB、643 骨骼、93,350 面; 2. 18 张贴图逐字节与压缩包原图一致;用一个权威 PMX 解析器比对后确认 39 个材质 -> 贴图的对应关系与 PMX 完全一致(不是映射错); 3. UV 采样也正确:脸部材质取到的就是皮肤色 (246,221,216); 4. 真正的问题在**我们自己的缩略图渲染器**:它刻意不解码 baseColorTexture, 只用 baseColorFactor 上色(见文件头取舍说明);而 MMD 模型的 baseColorFactor 基本都是白色 -> 缩略图必然全白。管理端 3D 预览有贴图, 只有列表/审核的缩略图是白的。 修复: 1. model-thumbnail.ts 内置极简 PNG 解码器(zlib inflate + 逐行反滤波, 8 位非隔行,colorType 0/2/3/4/6),按 baseColorFactor x 贴图逐像素采样, 透视校正 UV;解码失败/非 PNG/隔行扫描回退纯色,绝不抛错; 2. 按 alphaMode 处理透明度:OPAQUE 忽略贴图 alpha(MMD 语义)、 MASK 按 alphaCutoff 丢弃、BLEND 做 source-over; 3. 取景收紧 FRAMING 1.6 -> 1.3,40px 列表缩略图主体更清楚; 4. 新增 test/model-thumbnail-texture.spec.ts 6 项(含白模回归: baseColorFactor 全白 + 红贴图必须输出偏红,以及 MASK/OPAQUE/坏图回退); 5. 版本 0.2.4,README(.md/.txt)/DSH.md 补修订说明与 FAQ(含重新生成缩略图 的 reanalyze-models.mjs --force 命令)。
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { inflateSync } from 'node:zlib';
|
||
import { describe, expect, it } from 'vitest';
|
||
import { renderModelThumbnail } from '../src/models/model-thumbnail';
|
||
|
||
/**
|
||
* 缩略图**贴图采样**测试。
|
||
*
|
||
* 背景(真实故障):缩略图渲染器原先刻意不解码 baseColorTexture,只用
|
||
* `baseColorFactor` 上色;而 MMD(PMX 转换)模型的 baseColorFactor 基本全是白色,
|
||
* 于是缩略图全是"白模"——列表页与审核面板看到的都是白色模型,用户报的正是这个。
|
||
* 现在渲染器内置 PNG 解码并逐像素采样贴图,这里锁住行为。
|
||
*/
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'opendance-thumb-tex-'));
|
||
|
||
function write(name: string, content: Buffer): string {
|
||
const path = join(dir, name);
|
||
writeFileSync(path, content);
|
||
return path;
|
||
}
|
||
|
||
/** 12 字节头 + JSON chunk + BIN chunk */
|
||
function buildGlb(json: unknown, bin: Buffer): Buffer {
|
||
const jsonRaw = Buffer.from(JSON.stringify(json), 'utf8');
|
||
const jsonChunk = Buffer.concat([jsonRaw, Buffer.alloc((4 - (jsonRaw.length % 4)) % 4, 0x20)]);
|
||
const binChunk = Buffer.concat([bin, Buffer.alloc((4 - (bin.length % 4)) % 4)]);
|
||
const chunks: Buffer[] = [];
|
||
const header = Buffer.alloc(12);
|
||
header.write('glTF', 0, 'ascii');
|
||
header.writeUInt32LE(2, 4);
|
||
chunks.push(header);
|
||
const jsonHeader = Buffer.alloc(8);
|
||
jsonHeader.writeUInt32LE(jsonChunk.length, 0);
|
||
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
|
||
chunks.push(jsonHeader, jsonChunk);
|
||
const binHeader = Buffer.alloc(8);
|
||
binHeader.writeUInt32LE(binChunk.length, 0);
|
||
binHeader.writeUInt32LE(0x004e4942, 4);
|
||
chunks.push(binHeader, binChunk);
|
||
const out = Buffer.concat(chunks);
|
||
out.writeUInt32LE(out.length, 8);
|
||
return out;
|
||
}
|
||
|
||
function float32Buffer(values: number[]): Buffer {
|
||
const buffer = Buffer.alloc(values.length * 4);
|
||
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
|
||
return buffer;
|
||
}
|
||
|
||
function uint16Buffer(values: number[]): Buffer {
|
||
const buffer = Buffer.alloc(values.length * 2);
|
||
values.forEach((value, index) => buffer.writeUInt16LE(value, index * 2));
|
||
return buffer;
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 贴图夹具 */
|
||
|
||
/** 2x2 纯红(colorType 6,带 alpha=255) */
|
||
const RED_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=',
|
||
'base64',
|
||
);
|
||
/** 2x2 纯绿(colorType 2,无 alpha) */
|
||
const GREEN_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVR4nGNg+M8AQhAKABvyA/1tVLjHAAAAAElFTkSuQmCC',
|
||
'base64',
|
||
);
|
||
/** 2x2 全透明(RGBA 的 alpha=0) */
|
||
const TRANSPARENT_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGP4z8DAAMIMMAYAKegD/QE6Y34AAAAASUVORK5CYII=',
|
||
'base64',
|
||
);
|
||
|
||
interface QuadOptions {
|
||
texture?: Buffer;
|
||
mimeType?: string;
|
||
baseColorFactor?: number[];
|
||
alphaMode?: string;
|
||
}
|
||
|
||
/** 一个正对相机(+Z)的方形面片,UV 铺满贴图;baseColorFactor 默认纯白(MMD 的常见情况) */
|
||
function texturedQuadGlb(options: QuadOptions = {}): Buffer {
|
||
const positions = [-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0];
|
||
const uvs = [0, 1, 1, 1, 1, 0, 0, 0];
|
||
const indices = [0, 1, 2, 0, 2, 3];
|
||
const posBin = float32Buffer(positions);
|
||
const uvBin = float32Buffer(uvs);
|
||
const idxBin = uint16Buffer(indices);
|
||
const image = options.texture ?? RED_PNG;
|
||
const bin = Buffer.concat([posBin, uvBin, idxBin, image]);
|
||
const imageOffset = posBin.length + uvBin.length + idxBin.length;
|
||
|
||
const json = {
|
||
asset: { version: '2.0' },
|
||
scene: 0,
|
||
scenes: [{ nodes: [0] }],
|
||
nodes: [{ mesh: 0 }],
|
||
meshes: [{ primitives: [{ attributes: { POSITION: 0, TEXCOORD_0: 1 }, indices: 2, material: 0 }] }],
|
||
materials: [
|
||
{
|
||
pbrMetallicRoughness: {
|
||
baseColorFactor: options.baseColorFactor ?? [1, 1, 1, 1],
|
||
baseColorTexture: { index: 0 },
|
||
},
|
||
...(options.alphaMode ? { alphaMode: options.alphaMode, alphaCutoff: 0.5 } : {}),
|
||
},
|
||
],
|
||
textures: [{ source: 0 }],
|
||
images: [{ bufferView: 3, mimeType: options.mimeType ?? 'image/png' }],
|
||
accessors: [
|
||
{ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' },
|
||
{ bufferView: 1, componentType: 5126, count: 4, type: 'VEC2' },
|
||
{ bufferView: 2, componentType: 5123, count: 6, type: 'SCALAR' },
|
||
],
|
||
bufferViews: [
|
||
{ buffer: 0, byteOffset: 0, byteLength: posBin.length },
|
||
{ buffer: 0, byteOffset: posBin.length, byteLength: uvBin.length },
|
||
{ buffer: 0, byteOffset: posBin.length + uvBin.length, byteLength: idxBin.length },
|
||
{ buffer: 0, byteOffset: imageOffset, byteLength: image.length },
|
||
],
|
||
buffers: [{ byteLength: bin.length }],
|
||
};
|
||
return buildGlb(json, bin);
|
||
}
|
||
|
||
/** 解 PNG(我们自己写的编码器:8 位 RGB、filter=0),返回平均 RGB */
|
||
function averageColor(png: Buffer): { r: number; g: number; b: number; nonBackground: number } {
|
||
let offset = 8;
|
||
let width = 0;
|
||
let height = 0;
|
||
const idat: Buffer[] = [];
|
||
while (offset + 8 <= png.length) {
|
||
const length = png.readUInt32BE(offset);
|
||
const type = png.toString('ascii', offset + 4, offset + 8);
|
||
const data = png.subarray(offset + 8, offset + 8 + length);
|
||
if (type === 'IHDR') {
|
||
width = data.readUInt32BE(0);
|
||
height = data.readUInt32BE(4);
|
||
expect(data[8]).toBe(8);
|
||
expect(data[9]).toBe(2);
|
||
expect(data[12]).toBe(0);
|
||
} else if (type === 'IDAT') {
|
||
idat.push(Buffer.from(data));
|
||
} else if (type === 'IEND') {
|
||
break;
|
||
}
|
||
offset += 12 + length;
|
||
}
|
||
const raw = inflateSync(Buffer.concat(idat));
|
||
let r = 0;
|
||
let g = 0;
|
||
let b = 0;
|
||
let count = 0;
|
||
let nonBackground = 0;
|
||
for (let y = 0; y < height; y += 1) {
|
||
const rowStart = y * (1 + width * 3);
|
||
expect(raw[rowStart]).toBe(0); // filter=0
|
||
for (let x = 0; x < width; x += 1) {
|
||
const i = rowStart + 1 + x * 3;
|
||
r += raw[i];
|
||
g += raw[i + 1];
|
||
b += raw[i + 2];
|
||
count += 1;
|
||
// 默认背景是 [24,26,32](深色);明显偏离即视为主体像素
|
||
if (Math.abs(raw[i] - 24) > 12 || Math.abs(raw[i + 1] - 26) > 12 || Math.abs(raw[i + 2] - 32) > 12) {
|
||
nonBackground += 1;
|
||
}
|
||
}
|
||
}
|
||
return { r: r / count, g: g / count, b: b / count, nonBackground };
|
||
}
|
||
|
||
async function render(glb: Buffer, name: string, extra: Record<string, unknown> = {}) {
|
||
const result = await renderModelThumbnail(write(name, glb), { size: 128, ...extra });
|
||
expect(result).not.toBeNull();
|
||
return averageColor((result as { png: Buffer }).png);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ 测试 */
|
||
|
||
describe('缩略图贴图采样', () => {
|
||
it('baseColorFactor 全白时也按贴图上色(回归:不再输出"白模")', async () => {
|
||
const color = await render(texturedQuadGlb(), 'red.glb');
|
||
expect(color.nonBackground).toBeGreaterThan(0);
|
||
// 贴图是纯红:主体像素整体应明显偏红
|
||
expect(color.r).toBeGreaterThan(color.g + 20);
|
||
expect(color.r).toBeGreaterThan(color.b + 20);
|
||
});
|
||
|
||
it('colorType 2(无 alpha 通道)的 PNG 也能解码', async () => {
|
||
const color = await render(texturedQuadGlb({ texture: GREEN_PNG }), 'green.glb');
|
||
expect(color.g).toBeGreaterThan(color.r + 20);
|
||
expect(color.g).toBeGreaterThan(color.b + 20);
|
||
});
|
||
|
||
it('OPAQUE:忽略贴图 alpha(MMD 语义,alpha=0 也照样画出来)', async () => {
|
||
const color = await render(
|
||
texturedQuadGlb({ texture: TRANSPARENT_PNG, baseColorFactor: [0.9, 0.1, 0.1, 1] }),
|
||
'opaque.glb',
|
||
);
|
||
expect(color.nonBackground).toBeGreaterThan(0);
|
||
expect(color.r).toBeGreaterThan(color.g + 20);
|
||
});
|
||
|
||
it('MASK:alpha 低于阈值的主体像素被丢弃(只剩背景)', async () => {
|
||
const color = await render(
|
||
texturedQuadGlb({ texture: TRANSPARENT_PNG, baseColorFactor: [0.9, 0.1, 0.1, 1], alphaMode: 'MASK' }),
|
||
'mask.glb',
|
||
);
|
||
expect(color.nonBackground).toBe(0);
|
||
});
|
||
|
||
it('贴图解码失败时回退 baseColorFactor 纯色,且不抛错', async () => {
|
||
const broken = Buffer.from('这不是 PNG 数据', 'utf8');
|
||
const color = await render(
|
||
texturedQuadGlb({ texture: broken, baseColorFactor: [0.1, 0.1, 0.9, 1] }),
|
||
'broken.glb',
|
||
);
|
||
expect(color.nonBackground).toBeGreaterThan(0);
|
||
expect(color.b).toBeGreaterThan(color.r + 20);
|
||
});
|
||
|
||
it('默认视角是正面(相机在 +Z):+Z 面颜色明显主导', async () => {
|
||
// 面片正对 +Z,默认视角下必须看得见(若默认看背面,主体像素会接近 0)
|
||
const color = await render(texturedQuadGlb(), 'front.glb');
|
||
expect(color.nonBackground).toBeGreaterThan(128 * 128 * 0.05);
|
||
});
|
||
});
|