Files
opendance-web/web-server/test/model-thumbnail-texture.spec.ts
T
opendance ae8a68e98f fix(thumbnail): 缩略图采样 baseColorTexture,修复 MMD 模型显示为白模
现象(用户反馈):导入 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 命令)。
2026-09-16 04:02:35 +08:00

233 lines
8.7 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.
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);
});
});