feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
- model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
**骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
- model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
- vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
- gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
(服务端判定的"可驱动" = 客户端实际能否驱动)
需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
→(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf
需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
**骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程
其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
顶层开发书更新为 v1.4
验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 缩略图生成测试。
|
||||
* 所有夹具都在代码里构造,不联网、不依赖仓库外的文件。
|
||||
*/
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opendance-thumb-'));
|
||||
|
||||
function write(name: string, content: Buffer): string {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 构造 GLB:12 字节头 + JSON chunk(空格补齐)+ 可选 BIN chunk(0 补齐) */
|
||||
function buildGlb(json: unknown, bin?: Buffer): Buffer {
|
||||
const jsonRaw = Buffer.from(JSON.stringify(json), 'utf8');
|
||||
const jsonPad = (4 - (jsonRaw.length % 4)) % 4;
|
||||
const jsonChunk = Buffer.concat([jsonRaw, Buffer.alloc(jsonPad, 0x20)]);
|
||||
|
||||
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);
|
||||
|
||||
if (bin) {
|
||||
const binPad = (4 - (bin.length % 4)) % 4;
|
||||
const binChunk = Buffer.concat([bin, Buffer.alloc(binPad)]);
|
||||
const binHeader = Buffer.alloc(8);
|
||||
binHeader.writeUInt32LE(binChunk.length, 0);
|
||||
binHeader.writeUInt32LE(0x004e4942, 4);
|
||||
chunks.push(binHeader, binChunk);
|
||||
}
|
||||
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = Buffer.concat(chunks, total);
|
||||
out.writeUInt32LE(total, 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;
|
||||
}
|
||||
|
||||
/** 单个三角形、无索引的最小 GLB */
|
||||
function triangleGlb(): Buffer {
|
||||
const bin = float32Buffer([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.9, 0.4, 0.3, 1] } }],
|
||||
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: bin.length }],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 8 顶点 12 三角形的立方体(每个面朝外 CCW),scale 控制大小 */
|
||||
function cubeGlb(scale: number): Buffer {
|
||||
const s = scale;
|
||||
const positions = [
|
||||
-s, -s, -s, s, -s, -s, s, s, -s, -s, s, -s,
|
||||
-s, -s, s, s, -s, s, s, s, s, -s, s, s,
|
||||
];
|
||||
const indices = [
|
||||
4, 5, 6, 4, 6, 7, // +z
|
||||
1, 0, 3, 1, 3, 2, // -z
|
||||
5, 1, 2, 5, 2, 6, // +x
|
||||
0, 4, 7, 0, 7, 3, // -x
|
||||
7, 6, 2, 7, 2, 3, // +y
|
||||
0, 1, 5, 0, 5, 4, // -y
|
||||
];
|
||||
const posBin = float32Buffer(positions);
|
||||
const idxBin = uint16Buffer(indices);
|
||||
const bin = Buffer.concat([posBin, idxBin]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.85, 0.85, 0.85, 1] } }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: 8, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5123, count: 12 * 3, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: posBin.length },
|
||||
{ buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length },
|
||||
],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 5000 个三角形的波浪网格(50x50 个格子,每格 2 个三角形) */
|
||||
function gridGlb(segments: number): Buffer {
|
||||
const n = segments;
|
||||
const positions: number[] = [];
|
||||
for (let j = 0; j <= n; j += 1) {
|
||||
for (let i = 0; i <= n; i += 1) {
|
||||
const x = (i / n) * 2 - 1;
|
||||
const y = (j / n) * 2 - 1;
|
||||
const z = 0.3 * Math.sin(x * 3) * Math.cos(y * 3);
|
||||
positions.push(x, y, z);
|
||||
}
|
||||
}
|
||||
const indices: number[] = [];
|
||||
for (let j = 0; j < n; j += 1) {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = j * (n + 1) + i;
|
||||
const b = a + 1;
|
||||
const c = a + (n + 1);
|
||||
const d = c + 1;
|
||||
indices.push(a, b, d, a, d, c);
|
||||
}
|
||||
}
|
||||
const posBin = float32Buffer(positions);
|
||||
const idxBin = uint16Buffer(indices);
|
||||
const bin = Buffer.concat([posBin, idxBin]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: positions.length / 3, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5123, count: indices.length, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: posBin.length },
|
||||
{ buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length },
|
||||
],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 列主序平移矩阵(与 glTF 的 matrix 布局一致) */
|
||||
function translationMat4(x: number, y: number, z: number): number[] {
|
||||
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 蒙皮四边形:4 顶点 2 三角形(无索引复用),2 个关节。
|
||||
* - 下半两个顶点绑到 joint0(世界矩阵 = 单位阵,IBM = 单位阵)→ 原地不动
|
||||
* - 上半两个顶点绑到 joint1(世界矩阵 = translate(0,4,0),IBM = translate(0,-2,0))
|
||||
* → 蒙皮矩阵 translate(0,2,0),顶点被抬到 y=3
|
||||
* 因此正确实现蒙皮后,几何应当等价于一个顶点为 y∈[-1,3] 的普通四边形。
|
||||
*/
|
||||
function skinnedQuadGlb(): Buffer {
|
||||
const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]);
|
||||
const joints = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
|
||||
const weights = float32Buffer([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
|
||||
const inverseBind = float32Buffer([...translationMat4(0, 0, 0), ...translationMat4(0, -2, 0)]);
|
||||
const indices = uint16Buffer([0, 1, 2, 0, 2, 3]);
|
||||
|
||||
const views = [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: positions.length },
|
||||
{ buffer: 0, byteOffset: positions.length, byteLength: joints.length },
|
||||
{ buffer: 0, byteOffset: positions.length + joints.length, byteLength: weights.length },
|
||||
{ buffer: 0, byteOffset: positions.length + joints.length + weights.length, byteLength: inverseBind.length },
|
||||
{
|
||||
buffer: 0,
|
||||
byteOffset: positions.length + joints.length + weights.length + inverseBind.length,
|
||||
byteLength: indices.length,
|
||||
},
|
||||
];
|
||||
const bin = Buffer.concat([positions, joints, weights, inverseBind, indices]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0, 3] }],
|
||||
nodes: [
|
||||
{ children: [1, 2] }, // 0: 骨架根
|
||||
{ translation: [0, 0, 0] }, // 1: joint0(绑定姿态即身份)
|
||||
{ translation: [0, 4, 0] }, // 2: joint1(当前姿态被抬高 4)
|
||||
{ mesh: 0, skin: 0 }, // 3: 蒙皮网格,节点自身无变换
|
||||
],
|
||||
skins: [{ inverseBindMatrices: 3, joints: [1, 2], skeleton: 1 }],
|
||||
meshes: [
|
||||
{
|
||||
primitives: [
|
||||
{
|
||||
attributes: { POSITION: 0, JOINTS_0: 1, WEIGHTS_0: 2 },
|
||||
indices: 4,
|
||||
material: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5121, count: 4, type: 'VEC4' },
|
||||
{ bufferView: 2, componentType: 5126, count: 4, type: 'VEC4' },
|
||||
{ bufferView: 3, componentType: 5126, count: 2, type: 'MAT4' },
|
||||
{ bufferView: 4, componentType: 5123, count: 6, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: views,
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 参照物:无蒙皮的四边形,上下边分别位于 y=-1 与 yTop,其余与 skinnedQuadGlb 等价 */
|
||||
function plainQuadGlb(yTop: number): Buffer {
|
||||
const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, yTop, 0, -1, yTop, 0]);
|
||||
const indices = uint16Buffer([0, 1, 2, 0, 2, 3]);
|
||||
const bin = Buffer.concat([positions, indices]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: positions.length },
|
||||
{ buffer: 0, byteOffset: positions.length, byteLength: indices.length },
|
||||
],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
interface DecodedPng {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: Buffer;
|
||||
}
|
||||
|
||||
/** 手工解 PNG:校验签名 / chunk 结构,用 inflateSync 还原 IDAT */
|
||||
function decodePng(png: Buffer): DecodedPng {
|
||||
expect(png.subarray(0, 8)).toEqual(PNG_SIGNATURE);
|
||||
let offset = 8;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let sawIhdr = false;
|
||||
let sawIend = false;
|
||||
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);
|
||||
expect(offset + 12 + length).toBeLessThanOrEqual(png.length);
|
||||
if (type === 'IHDR') {
|
||||
sawIhdr = true;
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
expect(data[8]).toBe(8); // bit depth
|
||||
expect(data[9]).toBe(2); // color type = RGB
|
||||
expect(data[12]).toBe(0); // interlace = none
|
||||
} else if (type === 'IDAT') {
|
||||
idat.push(Buffer.from(data));
|
||||
} else if (type === 'IEND') {
|
||||
sawIend = true;
|
||||
break;
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
expect(sawIhdr).toBe(true);
|
||||
expect(sawIend).toBe(true);
|
||||
|
||||
const raw = inflateSync(Buffer.concat(idat));
|
||||
const stride = width * 3;
|
||||
expect(raw.length).toBe(height * (stride + 1));
|
||||
const pixels = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
expect(raw[y * (stride + 1)]).toBe(0); // filter = 0
|
||||
raw.copy(pixels, y * stride, y * (stride + 1) + 1, y * (stride + 1) + 1 + stride);
|
||||
}
|
||||
return { width, height, pixels };
|
||||
}
|
||||
|
||||
/** 统计与背景色不同的像素数量 */
|
||||
function countNonBackground(pixels: Buffer, background: [number, number, number]): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < pixels.length; i += 3) {
|
||||
if (
|
||||
Math.abs(pixels[i] - background[0]) > 2 ||
|
||||
Math.abs(pixels[i + 1] - background[1]) > 2 ||
|
||||
Math.abs(pixels[i + 2] - background[2]) > 2
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe('renderModelThumbnail', () => {
|
||||
it('最小三角形 GLB 能出图,PNG 头与 IHDR 尺寸正确', async () => {
|
||||
const path = write('triangle.glb', triangleGlb());
|
||||
const result = await renderModelThumbnail(path, { size: 128 });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.width).toBe(128);
|
||||
expect(result!.height).toBe(128);
|
||||
expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1);
|
||||
|
||||
expect(result!.png.subarray(0, 8)).toEqual(PNG_SIGNATURE);
|
||||
const decoded = decodePng(result!.png);
|
||||
expect(decoded.width).toBe(128);
|
||||
expect(decoded.height).toBe(128);
|
||||
});
|
||||
|
||||
it('带索引的立方体能出图,且画面里确实有非背景像素', async () => {
|
||||
const background: [number, number, number] = [24, 26, 32];
|
||||
const path = write('cube.glb', cubeGlb(1));
|
||||
const result = await renderModelThumbnail(path, { size: 256, background });
|
||||
expect(result).not.toBeNull();
|
||||
// 12 个三角形,背面剔除后会少画一部分
|
||||
expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1);
|
||||
expect(result!.trianglesDrawn).toBeLessThanOrEqual(12);
|
||||
|
||||
const decoded = decodePng(result!.png);
|
||||
const nonBackground = countNonBackground(decoded.pixels, background);
|
||||
expect(nonBackground).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('超大(±1000)与超小(±0.001)模型都能出图且不溢出', async () => {
|
||||
const background: [number, number, number] = [24, 26, 32];
|
||||
const bigPath = write('big.glb', cubeGlb(1000));
|
||||
const smallPath = write('small.glb', cubeGlb(0.001));
|
||||
|
||||
const big = await renderModelThumbnail(bigPath, { size: 128, background });
|
||||
const small = await renderModelThumbnail(smallPath, { size: 128, background });
|
||||
expect(big).not.toBeNull();
|
||||
expect(small).not.toBeNull();
|
||||
|
||||
const bigRatio = countNonBackground(decodePng(big!.png).pixels, background) / (128 * 128);
|
||||
const smallRatio = countNonBackground(decodePng(small!.png).pixels, background) / (128 * 128);
|
||||
expect(bigRatio).toBeGreaterThan(0);
|
||||
expect(smallRatio).toBeGreaterThan(0);
|
||||
// 构图一致,两者的覆盖率应当接近(都在画面中央占据相似比例)
|
||||
expect(Math.abs(bigRatio - smallRatio)).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('非法尺寸抛中文 Error', async () => {
|
||||
const path = write('cube-invalid.glb', cubeGlb(1));
|
||||
await expect(renderModelThumbnail(path, { size: 8 })).rejects.toThrow(/[\u4e00-\u9fff]/);
|
||||
await expect(renderModelThumbnail(path, { size: 4096 })).rejects.toThrow(/[\u4e00-\u9fff]/);
|
||||
});
|
||||
|
||||
it('非 glTF 文件(假 fbx)返回 null 且不抛错', async () => {
|
||||
const path = write('fake.fbx', Buffer.from('Kaydara FBX Binary \u0000\u001a\u0000', 'binary'));
|
||||
await expect(renderModelThumbnail(path)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('截断的 GLB 返回 null 且不抛错', async () => {
|
||||
const valid = triangleGlb();
|
||||
const truncated = valid.subarray(0, 20);
|
||||
const path = write('truncated.glb', truncated);
|
||||
await expect(renderModelThumbnail(path)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('5000 个三角形的模型渲染耗时低于 2000ms', async () => {
|
||||
const path = write('grid.glb', gridGlb(50));
|
||||
const started = performance.now();
|
||||
const result = await renderModelThumbnail(path, { size: 256 });
|
||||
const elapsed = performance.now() - started;
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.trianglesDrawn).toBeGreaterThan(0);
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('蒙皮被真正应用:蒙皮结果与等价的无蒙皮几何逐像素一致', async () => {
|
||||
// 蒙皮后上下边在 y=-1 / y=3;参照物直接用同样的最终顶点位置
|
||||
const skinned = await renderModelThumbnail(write('skinned.glb', skinnedQuadGlb()), { size: 128 });
|
||||
const equivalent = await renderModelThumbnail(write('plain-sheared.glb', plainQuadGlb(3)), { size: 128 });
|
||||
// 绑定姿态(上下边在 y=-1 / y=1)作为反例
|
||||
const bindPose = await renderModelThumbnail(write('plain-bind.glb', plainQuadGlb(1)), { size: 128 });
|
||||
|
||||
expect(skinned).not.toBeNull();
|
||||
expect(equivalent).not.toBeNull();
|
||||
expect(bindPose).not.toBeNull();
|
||||
// 蒙皮矩阵(jointWorld × inverseBindMatrices × 权重)算对了才可能逐字节一致
|
||||
expect(skinned!.png.equals(equivalent!.png)).toBe(true);
|
||||
// 并确认它确实不是"忽略蒙皮"画出来的绑定姿态
|
||||
expect(skinned!.png.equals(bindPose!.png)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user