Files
opendance-web/web-server/src/models/model-thumbnail.ts
T
opendance aad60cbd2e feat(motions): 动作预览——骨架缩略图 + 管理端 3D 回放(契约 data-models 0.6.0)
按《DSH 开发文档 v1.6》4.11(T-WEB-014,用户标注「重要」):

服务端:
- 抽出公共 PNG 编码器 src/common/png.ts(模型缩略图改为复用,零原生依赖不变)
- 新增 src/motions/motion-thumbnail.ts:关键点序列 -> 正视图骨架 PNG
  (复用 33 点 PARENT_TABLE 连线,包围盒按整段计算,低置信度点不画)
- motions 增 thumbnail_asset_id(迁移 AddMotionThumbnail)+ AssetKind.MotionThumbnail
- /preprocess 生成缩略图并落库,失败只记 warning;MotionDto 增 thumbnailUrl(契约 0.6.0)

管理端:
- 新增 MotionPreview:懒加载 NDJSON -> 骨架线框 + 关节实例球体
  支持播放/暂停、上一帧/下一帧、时间轴拖动、0.25x-2x 变速、循环
  叠加帧号/当前时间/总时长,按整段包围盒自动取景
  three.js 单独分包(lazy + Suspense),主包 241 kB 不涨
- 动作列表新增 40px 骨架缩略图列(点击开预览)+ 预览按钮 + 内联预览面板
- 新增 admin/src/lib/keypoint-sequence.ts 与 lib/skeleton-topology.ts

测试:新增 8 项;规模 18 文件 / 237 项。版本 0.5.0。
2026-09-17 00:21:09 +08:00

1429 lines
49 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 { readFile } from 'node:fs/promises';
import { inflateSync } from 'node:zlib';
import { encodePngRgb } from '../common/png';
import { dirname, resolve as resolvePath } from 'node:path';
/**
* 服务端模型缩略图生成(纯 TypeScript 软件光栅化)。
*
* 背景:管理端列表页需要一张缩略图(`models.thumbnail_asset_id` 一直为空),
* 前端那套 three.js 预览在服务端跑不了,又不能引入 headless-gl / canvas / puppeteer
* 这类原生依赖。所以这里自己走一遍最小渲染管线:
*
* 读 GLB/glTF → 取 POSITION/indices accessor → 应用节点层级与蒙皮 →
* 按包围盒自动构图 → 透视投影 → z-buffer 逐三角形平面着色 → 手写 PNG 编码。
*
* ## 涉及取舍(重要)
*
* 1. **蒙皮(skinning)已实现**:本项目主力模型是 VRM,每个顶点 4 骨骼权重。
* 我们按 glTF 规范用 `skins[].inverseBindMatrices` × 关节世界矩阵算出每个顶点的
* 蒙皮矩阵,权重取自 `JOINTS_0` / `WEIGHTS_0` 并做归一化。
* - 只支持每顶点 4 权重(规范里最常见;没有 8 权重的 `JOINTS_1` 支持)。
* - 当 `inverseBindMatrices` 读不到、或权重和近似为 0、或 accessor 数量不匹配时,
* **退化为不蒙皮**,按节点世界矩阵画绑定姿态(bind pose)——多数 VRM 的绑定姿态
* 就是可看的 T/A 姿态,比错误的蒙皮矩阵导致的"爆炸形状"安全得多。
* - 按 glTF 规范,蒙皮网格节点自身的变换被忽略(顶点直接由关节矩阵驱动)。
* 2. **采样 baseColorTexture**:内置极简 PNG 解码器(zlib + 反滤波,8 位、非隔行、
* colorType 0/2/3/4/6),按 `baseColorFactor x 贴图` 逐像素取色,并按
* `alphaMode`(OPAQUE / MASK / BLEND)处理透明度。
* 这一版之前**不采样贴图**:MMD 模型(PMX 转换而来)的 `baseColorFactor` 几乎
* 都是白色,于是缩略图全是"白模"——用户报的正是这个。
* 解码失败/非 PNG/隔行扫描时回退为 `baseColorFactor` 纯色,绝不抛错。
* 3. **稀疏 accessor(sparse)不支持**:遇到就直接跳过该 primitive。
* 4. **只画三角形(mode=4)**,其余图元(点/线/条带/扇形)跳过。
* 5. 大模型(>17M 索引)不会出现,索引统一按 uint32 读取。
*
* 约定:**参数非法抛中文 Error;读到文件但无法解析返回 null(不抛错)**。
* 绝不允许产出损坏的 PNG。
*/
/** 渲染结果 */
export interface ThumbnailResult {
png: Buffer;
width: number;
height: number;
/** 实际画出的三角形数量,便于日志与测试 */
trianglesDrawn: number;
}
/** 渲染参数 */
export interface ThumbnailOptions {
/** 输出边长(正方形),默认 512,允许 64..1024 */
size?: number;
/** 水平视角(度),默认 -30(略微侧前方) */
yawDeg?: number;
/** 俯仰视角(度),默认 12(略微俯视) */
pitchDeg?: number;
/** 背景色 RGB,默认深灰 [24,26,32] */
background?: [number, number, number];
}
// ---------------------------------------------------------------------------
// 常量
// ---------------------------------------------------------------------------
const GLB_MAGIC = 0x46546c67; // 'glTF'
const CHUNK_JSON = 0x4e4f534a; // 'JSON'
const CHUNK_BIN = 0x004e4942; // 'BIN\0'
const DEFAULT_SIZE = 512;
const MIN_SIZE = 64;
const MAX_SIZE = 1024;
const DEFAULT_BACKGROUND: [number, number, number] = [24, 26, 32];
/** 垂直视场角(度) */
const FOV_Y_DEG = 35;
/** 相机距离系数:让模型占画面约 1/1.6,保证不裁剪 */
// 1.6 -> 1.3:列表页只有 40px,主体太小就只剩一团像素
const FRAMING = 1.3;
/** 主光方向(世界空间,未归一化,用前会归一化) */
const LIGHT_DIR: [number, number, number] = normalize3(0.4, 0.8, 0.6);
/** 环境光强度 */
const AMBIENT = 0.3;
/** 漫反射强度 */
const DIFFUSE = 0.7;
// ---------------------------------------------------------------------------
// 数学小工具
// ---------------------------------------------------------------------------
type Mat4 = Float64Array;
function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
function normalize3(x: number, y: number, z: number): [number, number, number] {
const len = Math.hypot(x, y, z);
if (!(len > 1e-12)) return [0, 0, 1];
return [x / len, y / len, z / len];
}
function mat4Identity(): Mat4 {
const m = new Float64Array(16);
m[0] = 1;
m[5] = 1;
m[10] = 1;
m[15] = 1;
return m;
}
/** 列主序矩阵乘法:out = a * b */
function mat4Multiply(a: Mat4, b: Mat4): Mat4 {
const out = new Float64Array(16);
for (let col = 0; col < 4; col += 1) {
const b0 = b[col * 4];
const b1 = b[col * 4 + 1];
const b2 = b[col * 4 + 2];
const b3 = b[col * 4 + 3];
for (let row = 0; row < 4; row += 1) {
out[col * 4 + row] = a[row] * b0 + a[4 + row] * b1 + a[8 + row] * b2 + a[12 + row] * b3;
}
}
return out;
}
/** 由 glTF 的 translation / rotation(四元数 xyzw) / scale 组合出局部矩阵(列主序) */
function mat4FromTRS(
translation: number[] | undefined,
rotation: number[] | undefined,
scale: number[] | undefined,
): Mat4 {
const tx = num(translation, 0, 0);
const ty = num(translation, 1, 0);
const tz = num(translation, 2, 0);
let rx = num(rotation, 0, 0);
let ry = num(rotation, 1, 0);
let rz = num(rotation, 2, 0);
let rw = num(rotation, 3, 1);
const sx = num(scale, 0, 1);
const sy = num(scale, 1, 1);
const sz = num(scale, 2, 1);
// 四元数归一化,避免上游写入了未归一化的数据
const qlen = Math.hypot(rx, ry, rz, rw);
if (qlen > 1e-12) {
rx /= qlen;
ry /= qlen;
rz /= qlen;
rw /= qlen;
} else {
rx = 0;
ry = 0;
rz = 0;
rw = 1;
}
const x2 = rx + rx;
const y2 = ry + ry;
const z2 = rz + rz;
const xx = rx * x2;
const xy = rx * y2;
const xz = rx * z2;
const yy = ry * y2;
const yz = ry * z2;
const zz = rz * z2;
const wx = rw * x2;
const wy = rw * y2;
const wz = rw * z2;
const m = new Float64Array(16);
m[0] = (1 - (yy + zz)) * sx;
m[1] = (xy + wz) * sx;
m[2] = (xz - wy) * sx;
m[3] = 0;
m[4] = (xy - wz) * sy;
m[5] = (1 - (xx + zz)) * sy;
m[6] = (yz + wx) * sy;
m[7] = 0;
m[8] = (xz + wy) * sz;
m[9] = (yz - wx) * sz;
m[10] = (1 - (xx + yy)) * sz;
m[11] = 0;
m[12] = tx;
m[13] = ty;
m[14] = tz;
m[15] = 1;
return m;
}
function num(arr: number[] | undefined, index: number, fallback: number): number {
if (!arr) return fallback;
const v = arr[index];
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
}
// ---------------------------------------------------------------------------
// glTF 数据结构(只声明用得到的部分)
// ---------------------------------------------------------------------------
interface GltfBufferRef {
uri?: string;
byteLength?: number;
}
interface GltfBufferView {
buffer?: number;
byteOffset?: number;
byteLength?: number;
byteStride?: number;
}
interface GltfAccessor {
bufferView?: number;
byteOffset?: number;
componentType?: number;
normalized?: boolean;
count?: number;
type?: string;
sparse?: unknown;
}
interface GltfPrimitive {
attributes?: Record<string, number>;
indices?: number;
material?: number;
mode?: number;
}
interface GltfMesh {
primitives?: GltfPrimitive[];
}
interface GltfNode {
children?: number[];
mesh?: number;
skin?: number;
matrix?: number[];
translation?: number[];
rotation?: number[];
scale?: number[];
}
interface GltfSkin {
inverseBindMatrices?: number;
joints?: number[];
skeleton?: number;
}
interface GltfMaterial {
pbrMetallicRoughness?: {
baseColorFactor?: number[];
baseColorTexture?: { index?: number; texCoord?: number };
};
alphaMode?: string;
alphaCutoff?: number;
doubleSided?: boolean;
}
interface GltfTexture {
source?: number;
sampler?: number;
}
interface GltfImage {
bufferView?: number;
mimeType?: string;
uri?: string;
}
interface GltfSampler {
wrapS?: number;
wrapT?: number;
}
interface GltfScene {
nodes?: number[];
}
interface GltfDocument {
asset?: { version?: string };
scene?: number;
scenes?: GltfScene[];
nodes?: GltfNode[];
meshes?: GltfMesh[];
accessors?: GltfAccessor[];
bufferViews?: GltfBufferView[];
buffers?: GltfBufferRef[];
materials?: GltfMaterial[];
textures?: GltfTexture[];
images?: GltfImage[];
samplers?: GltfSampler[];
skins?: GltfSkin[];
}
/** 解析产物:JSON 文档 + 每个 buffer 的字节(null 表示读不到,引用它的 primitive 跳过) */
interface GltfContainer {
doc: GltfDocument;
buffers: Array<Buffer | null>;
}
interface AccessorData {
values: Float32Array;
count: number;
components: number;
}
/** 收集好的三角形:positions 每 9 个数一个三角形,colors 每 3 个数一个三角形 */
interface DecodedImage {
width: number;
height: number;
/** RGBA,行优先,长度 = width*height*4 */
rgba: Uint8Array;
}
interface SceneGeometry {
positions: Float32Array;
colors: Float32Array;
/** 每顶点 UV(2 个/顶点);没有 TEXCOORD_0 时为 null */
uvs: Float32Array | null;
/** 每顶点贴图槽位(索引进 textures);-1 表示该顶点无贴图 */
texSlots: Int32Array | null;
/** 每顶点 alpha 模式:0=OPAQUE 1=MASK 2=BLEND */
alphaModes: Uint8Array | null;
/** 每顶点 MASK 阈值 */
cutoffs: Float32Array | null;
/** 解码后的贴图(RGBA) */
textures: DecodedImage[];
triangleCount: number;
}
// ---------------------------------------------------------------------------
// 容器解析
// ---------------------------------------------------------------------------
function align4(value: number): number {
return value + ((4 - (value % 4)) % 4);
}
function componentSize(componentType: number | undefined): number {
switch (componentType) {
case 5120: // BYTE
case 5121: // UNSIGNED_BYTE
return 1;
case 5122: // SHORT
case 5123: // UNSIGNED_SHORT
return 2;
case 5125: // UNSIGNED_INT
case 5126: // FLOAT
return 4;
default:
return 0;
}
}
function componentCount(type: string | undefined): number {
switch (type) {
case 'SCALAR':
return 1;
case 'VEC2':
return 2;
case 'VEC3':
return 3;
case 'VEC4':
return 4;
case 'MAT2':
return 4;
case 'MAT3':
return 9;
case 'MAT4':
return 16;
default:
return 0;
}
}
/** 解码 data: URI(只处理 base64 形式,glTF 里非 base64 的极少见) */
function decodeDataUri(uri: string): Buffer | null {
const comma = uri.indexOf(',');
if (comma < 0) return null;
const meta = uri.slice(5, comma);
const payload = uri.slice(comma + 1);
try {
if (/;base64/i.test(meta)) return Buffer.from(payload, 'base64');
return Buffer.from(decodeURIComponent(payload), 'binary');
} catch {
return null;
}
}
/** 解析 GLB 容器;结构不完整(截断、无 JSON chunk、版本非 2)返回 null */
function parseGlbContainer(file: Buffer): GltfContainer | null {
if (file.length < 12) return null;
if (file.readUInt32LE(4) !== 2) return null; // 只支持 glTF 2.0
const declaredLength = file.readUInt32LE(8);
const end = Math.min(declaredLength > 0 ? declaredLength : file.length, file.length);
let offset = 12;
let doc: GltfDocument | null = null;
let bin: Buffer | null = null;
while (offset + 8 <= end) {
const chunkLength = file.readUInt32LE(offset);
const chunkType = file.readUInt32LE(offset + 4);
const dataStart = offset + 8;
// 截断的 GLB:chunk 声明的长度超出实际文件
if (chunkLength < 0 || dataStart + chunkLength > file.length) return null;
const data = file.subarray(dataStart, dataStart + chunkLength);
if (chunkType === CHUNK_JSON) {
try {
doc = JSON.parse(data.toString('utf8')) as GltfDocument;
} catch {
return null;
}
} else if (chunkType === CHUNK_BIN) {
bin = data;
}
offset = dataStart + align4(chunkLength);
}
if (!doc || typeof doc !== 'object') return null;
// GLB 规范:buffer 0 无 uri 时指向 BIN chunk
const buffers = (doc.buffers ?? []).map((_, index) => (index === 0 ? bin : null));
return { doc, buffers };
}
/** 解析文本 .gltf;外部 buffer 读不到的记为 null(引用它的 primitive 会被跳过) */
async function parseTextGltf(file: Buffer, absPath: string): Promise<GltfContainer | null> {
let doc: GltfDocument;
try {
doc = JSON.parse(file.toString('utf8')) as GltfDocument;
} catch {
return null;
}
if (!doc || typeof doc !== 'object') return null;
const baseDir = dirname(absPath);
const refs = doc.buffers ?? [];
const buffers: Array<Buffer | null> = [];
for (const ref of refs) {
const uri = typeof ref.uri === 'string' ? ref.uri : '';
if (uri.length === 0) {
buffers.push(null);
continue;
}
if (uri.startsWith('data:')) {
buffers.push(decodeDataUri(uri));
continue;
}
try {
buffers.push(await readFile(resolvePath(baseDir, decodeURIComponent(uri))));
} catch {
buffers.push(null);
}
}
return { doc, buffers };
}
/** 判断并解析 glTF 容器;不是 glTF 就返回 null */
async function loadGltfContainer(file: Buffer, absPath: string): Promise<GltfContainer | null> {
if (file.length >= 12 && file.readUInt32LE(0) === GLB_MAGIC) {
return parseGlbContainer(file);
}
// 文本 glTF:跳过前导空白后应当是 '{'
const head = file.toString('utf8', 0, Math.min(file.length, 64));
if (!/^\s*\{/.test(head)) return null;
return parseTextGltf(file, absPath);
}
// ---------------------------------------------------------------------------
// accessor 读取
// ---------------------------------------------------------------------------
function readComponent(buffer: Buffer, offset: number, componentType: number): number {
switch (componentType) {
case 5120:
return buffer.readInt8(offset);
case 5121:
return buffer.readUInt8(offset);
case 5122:
return buffer.readInt16LE(offset);
case 5123:
return buffer.readUInt16LE(offset);
case 5125:
return buffer.readUInt32LE(offset);
case 5126:
return buffer.readFloatLE(offset);
default:
return Number.NaN;
}
}
/** 按规范归一化整数分量 */
function denormalize(value: number, componentType: number): number {
switch (componentType) {
case 5121:
return value / 255;
case 5120:
return Math.max(value / 127, -1);
case 5123:
return value / 65535;
case 5122:
return Math.max(value / 32767, -1);
default:
return value;
}
}
/**
* 读取 accessor 为浮点数组(归一化的整数会按规范还原到 0..1 / -1..1)。
* 越界 / 截断 / 稀疏 / 未知类型一律返回 null,由调用方跳过该 primitive。
*/
function readAccessorFloats(
doc: GltfDocument,
buffers: Array<Buffer | null>,
accessorIndex: number | undefined,
expectedComponents: number,
): AccessorData | null {
if (typeof accessorIndex !== 'number') return null;
const accessor = doc.accessors?.[accessorIndex];
if (!accessor || accessor.sparse) return null;
const components = componentCount(accessor.type);
if (components !== expectedComponents) return null;
const componentType = accessor.componentType;
if (typeof componentType !== 'number') return null;
const compSize = componentSize(componentType);
if (compSize === 0) return null;
if (typeof accessor.bufferView !== 'number') return null;
const view = doc.bufferViews?.[accessor.bufferView];
if (!view || typeof view.buffer !== 'number') return null;
const buffer = buffers[view.buffer];
if (!buffer) return null;
const count = accessor.count ?? 0;
if (!Number.isFinite(count) || count <= 0) return null;
const elementSize = compSize * components;
const base = (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0);
const stride = typeof view.byteStride === 'number' && view.byteStride > 0 ? view.byteStride : elementSize;
// 注意交错缓冲:最后一个元素也要落在 buffer 内
if (base < 0 || base + stride * (count - 1) + elementSize > buffer.length) return null;
const normalized = accessor.normalized === true;
const values = new Float32Array(count * components);
for (let i = 0; i < count; i += 1) {
let offset = base + i * stride;
for (let c = 0; c < components; c += 1) {
const raw = readComponent(buffer, offset, componentType);
values[i * components + c] =
normalized && componentType !== 5126 && componentType !== 5125
? denormalize(raw, componentType)
: raw;
offset += compSize;
}
}
return { values, count, components };
}
/** 读取索引 accessor(5121 / 5123 / 5125),同样支持 byteStride */
function readIndexAccessor(
doc: GltfDocument,
buffers: Array<Buffer | null>,
accessorIndex: number | undefined,
): Uint32Array | null {
if (typeof accessorIndex !== 'number') return null;
const accessor = doc.accessors?.[accessorIndex];
if (!accessor || accessor.sparse) return null;
if (accessor.type !== 'SCALAR') return null;
const componentType = accessor.componentType;
if (componentType !== 5121 && componentType !== 5123 && componentType !== 5125) return null;
const compSize = componentSize(componentType);
if (typeof accessor.bufferView !== 'number') return null;
const view = doc.bufferViews?.[accessor.bufferView];
if (!view || typeof view.buffer !== 'number') return null;
const buffer = buffers[view.buffer];
if (!buffer) return null;
const count = accessor.count ?? 0;
if (!Number.isFinite(count) || count <= 0) return null;
const base = (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0);
const stride = typeof view.byteStride === 'number' && view.byteStride > 0 ? view.byteStride : compSize;
if (base < 0 || base + stride * (count - 1) + compSize > buffer.length) return null;
const out = new Uint32Array(count);
for (let i = 0; i < count; i += 1) {
out[i] = readComponent(buffer, base + i * stride, componentType);
}
return out;
}
// ---------------------------------------------------------------------------
// 节点层级与蒙皮
// ---------------------------------------------------------------------------
function localMatrix(node: GltfNode): Mat4 {
if (Array.isArray(node.matrix) && node.matrix.length === 16) {
const m = new Float64Array(16);
for (let i = 0; i < 16; i += 1) {
const v = node.matrix[i];
m[i] = typeof v === 'number' && Number.isFinite(v) ? v : i % 5 === 0 ? 1 : 0;
}
return m;
}
return mat4FromTRS(node.translation, node.rotation, node.scale);
}
/** 遍历场景图,算出每个节点的世界矩阵(同时起到防环作用) */
function buildWorldMatrices(doc: GltfDocument): Map<number, Mat4> {
const nodes = doc.nodes ?? [];
const out = new Map<number, Mat4>();
const scenes = doc.scenes ?? [];
const sceneIndex = doc.scene ?? 0;
const chosen = scenes[sceneIndex] ?? scenes[0];
let roots = chosen?.nodes ?? [];
if (roots.length === 0) roots = nodes.map((_, index) => index);
const identity = mat4Identity();
const visit = (index: number, parent: Mat4): void => {
if (!Number.isInteger(index) || index < 0 || index >= nodes.length) return;
if (out.has(index)) return; // 同一节点被多次引用 / 出现环
const world = mat4Multiply(parent, localMatrix(nodes[index]));
out.set(index, world);
for (const child of nodes[index].children ?? []) visit(child, world);
};
for (const root of roots) visit(root, identity);
return out;
}
/**
* 计算每个关节的蒙皮矩阵:jointWorld * inverseBindMatrix。
* inverseBindMatrices 缺失或读不到时返回 null(调用方退化为不蒙皮)。
*/
function buildSkinMatrices(
doc: GltfDocument,
buffers: Array<Buffer | null>,
skinIndex: number,
worldByNode: Map<number, Mat4>,
cache: Map<number, Array<Mat4 | null> | null>,
): Array<Mat4 | null> | null {
const cached = cache.get(skinIndex);
if (cached !== undefined) return cached;
let result: Array<Mat4 | null> | null = null;
const skin = doc.skins?.[skinIndex];
const joints = skin?.joints;
if (skin && Array.isArray(joints) && joints.length > 0) {
const ibm = readAccessorFloats(doc, buffers, skin.inverseBindMatrices, 16);
if (ibm) {
result = joints.map((jointIndex, i) => {
if ((i + 1) * 16 > ibm.values.length) return null;
const inverseBind = new Float64Array(16);
for (let k = 0; k < 16; k += 1) inverseBind[k] = ibm.values[i * 16 + k];
const jointWorld = worldByNode.get(jointIndex) ?? mat4Identity();
return mat4Multiply(jointWorld, inverseBind);
});
}
}
cache.set(skinIndex, result);
return result;
}
/**
* 混合出单个顶点的蒙皮矩阵(最多 4 权重)。
* 返回 false 表示权重无效,调用方退化为节点世界矩阵。
*/
function skinVertexMatrix(
jointValues: Float32Array,
weightValues: Float32Array,
vertexIndex: number,
skinMatrices: Array<Mat4 | null>,
out: Mat4,
): boolean {
const o = vertexIndex * 4;
const w0 = weightValues[o];
const w1 = weightValues[o + 1];
const w2 = weightValues[o + 2];
const w3 = weightValues[o + 3];
const sum = w0 + w1 + w2 + w3;
if (!(sum > 1e-8) || !Number.isFinite(sum)) return false;
out.fill(0);
let used = false;
for (let k = 0; k < 4; k += 1) {
const w = weightValues[o + k] / sum;
if (!(w > 0)) continue;
const joint = Math.trunc(jointValues[o + k]);
const matrix = skinMatrices[joint];
if (!matrix) continue;
used = true;
for (let i = 0; i < 16; i += 1) out[i] += w * matrix[i];
}
return used;
}
// ---------------------------------------------------------------------------
// 几何收集
// ---------------------------------------------------------------------------
function materialBaseColor(doc: GltfDocument, materialIndex: number | undefined): [number, number, number] {
if (typeof materialIndex === 'number') {
const factor = doc.materials?.[materialIndex]?.pbrMetallicRoughness?.baseColorFactor;
if (Array.isArray(factor) && factor.length >= 3) {
return [clamp(num(factor, 0, 0.8), 0, 1), clamp(num(factor, 1, 0.8), 0, 1), clamp(num(factor, 2, 0.8), 0, 1)];
}
}
// 没有材质或没有 baseColorFactor 时用中性灰;
// 注意:这里不采样 baseColorTexture,纯色即可(见文件头取舍说明)。
return [0.8, 0.8, 0.8];
}
/** 取 bufferView 的原始字节(贴图解压用);越界返回 null */
function readBufferViewBytes(
doc: GltfDocument,
buffers: Array<Buffer | null>,
bufferViewIndex: number,
): Buffer | null {
const view = doc.bufferViews?.[bufferViewIndex];
if (!view || typeof view.buffer !== 'number') return null;
const buffer = buffers[view.buffer];
if (!buffer) return null;
const offset = view.byteOffset ?? 0;
const length = view.byteLength ?? 0;
if (offset < 0 || length <= 0 || offset + length > buffer.length) return null;
return buffer.subarray(offset, offset + length);
}
/** 解码 GLB 内嵌的 PNG 贴图;外部 uri / 非 PNG / 解码失败都返回 null(回退纯色) */
function decodeEmbeddedImage(
doc: GltfDocument,
buffers: Array<Buffer | null>,
imageIndex: number,
): DecodedImage | null {
const image = doc.images?.[imageIndex];
if (!image || typeof image.bufferView !== 'number') return null;
if (typeof image.mimeType === 'string' && !image.mimeType.toLowerCase().includes('png')) return null;
const bytes = readBufferViewBytes(doc, buffers, image.bufferView);
if (!bytes) return null;
return decodePng(bytes);
}
/** 遍历场景图收集世界空间三角形 */
function collectTriangles(container: GltfContainer): SceneGeometry | null {
const { doc, buffers } = container;
const nodes = doc.nodes ?? [];
const worldByNode = buildWorldMatrices(doc);
const skinCache = new Map<number, Array<Mat4 | null> | null>();
const positions: number[] = [];
const colors: number[] = [];
const uvs: number[] = [];
const texSlots: number[] = [];
const alphaModes: number[] = [];
const cutoffs: number[] = [];
const textures: DecodedImage[] = [];
const materialTextureCache = new Map<number, { slot: number; alphaMode: number; cutoff: number }>();
const imageCache = new Map<number, DecodedImage | null>();
const skinScratch = new Float64Array(16);
/**
* 解析材质的 baseColorTexture:解码一次并登记槽位。
* MMD 模型的 baseColorFactor 基本都是白色,颜色全在贴图里 —— 不解码就是"白模"。
*/
const resolveMaterialTexture = (
materialIndex: number | undefined,
): { slot: number; alphaMode: number; cutoff: number } => {
const key = typeof materialIndex === 'number' ? materialIndex : -1;
const cached = materialTextureCache.get(key);
if (cached) return cached;
const material = typeof materialIndex === 'number' ? doc.materials?.[materialIndex] : undefined;
const alphaMode = material?.alphaMode === 'MASK' ? 1 : material?.alphaMode === 'BLEND' ? 2 : 0;
const rawCutoff = typeof material?.alphaCutoff === 'number' ? material.alphaCutoff : 0.5;
const cutoff = clamp(rawCutoff, 0, 1);
let slot = -1;
const textureIndex = material?.pbrMetallicRoughness?.baseColorTexture?.index;
if (typeof textureIndex === 'number') {
const imageIndex = doc.textures?.[textureIndex]?.source;
if (typeof imageIndex === 'number') {
if (!imageCache.has(imageIndex)) {
imageCache.set(imageIndex, decodeEmbeddedImage(doc, buffers, imageIndex));
}
const image = imageCache.get(imageIndex) ?? null;
if (image) {
slot = textures.length;
textures.push(image);
}
}
}
const resolved = { slot, alphaMode, cutoff };
materialTextureCache.set(key, resolved);
return resolved;
};
const appendMesh = (nodeIndex: number): void => {
const node = nodes[nodeIndex];
if (!node || typeof node.mesh !== 'number') return;
const mesh = doc.meshes?.[node.mesh];
if (!mesh) return;
const world = worldByNode.get(nodeIndex) ?? mat4Identity();
const skinMatrices =
typeof node.skin === 'number'
? buildSkinMatrices(doc, buffers, node.skin, worldByNode, skinCache)
: null;
for (const primitive of mesh.primitives ?? []) {
if ((primitive.mode ?? 4) !== 4) continue; // 只画三角形
const attributes = primitive.attributes ?? {};
const positionData = readAccessorFloats(doc, buffers, attributes.POSITION, 3);
if (!positionData) continue;
const indexData =
typeof primitive.indices === 'number' ? readIndexAccessor(doc, buffers, primitive.indices) : null;
const jointData = skinMatrices ? readAccessorFloats(doc, buffers, attributes.JOINTS_0, 4) : null;
const weightData = skinMatrices ? readAccessorFloats(doc, buffers, attributes.WEIGHTS_0, 4) : null;
const useSkin = !!(
skinMatrices &&
jointData &&
weightData &&
jointData.count === positionData.count &&
weightData.count === positionData.count
);
const vertexCount = positionData.count;
const [cr, cg, cb] = materialBaseColor(doc, primitive.material);
const materialTexture = resolveMaterialTexture(primitive.material);
const uvData =
materialTexture.slot >= 0 ? readAccessorFloats(doc, buffers, attributes.TEXCOORD_0, 2) : null;
const usableUv = uvData && uvData.count === vertexCount ? uvData : null;
// 顶点只变换一次:蒙皮或节点世界矩阵
const px = new Float64Array(vertexCount);
const py = new Float64Array(vertexCount);
const pz = new Float64Array(vertexCount);
for (let i = 0; i < vertexCount; i += 1) {
const lx = positionData.values[i * 3];
const ly = positionData.values[i * 3 + 1];
const lz = positionData.values[i * 3 + 2];
let m: Mat4 = world;
if (useSkin && jointData && weightData) {
// 权重无效时退化:用节点世界矩阵画绑定姿态
if (skinVertexMatrix(jointData.values, weightData.values, i, skinMatrices as Array<Mat4 | null>, skinScratch)) {
m = skinScratch;
}
}
px[i] = m[0] * lx + m[4] * ly + m[8] * lz + m[12];
py[i] = m[1] * lx + m[5] * ly + m[9] * lz + m[13];
pz[i] = m[2] * lx + m[6] * ly + m[10] * lz + m[14];
}
const triangleCount = indexData
? Math.floor(indexData.length / 3)
: Math.floor(vertexCount / 3);
for (let t = 0; t < triangleCount; t += 1) {
const i0 = indexData ? indexData[t * 3] : t * 3;
const i1 = indexData ? indexData[t * 3 + 1] : t * 3 + 1;
const i2 = indexData ? indexData[t * 3 + 2] : t * 3 + 2;
if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) continue;
const x0 = px[i0];
const y0 = py[i0];
const z0 = pz[i0];
const x1 = px[i1];
const y1 = py[i1];
const z1 = pz[i1];
const x2 = px[i2];
const y2 = py[i2];
const z2 = pz[i2];
// 丢弃含 NaN / Infinity 的三角形,避免污染包围盒与光栅化
if (
!Number.isFinite(x0) || !Number.isFinite(y0) || !Number.isFinite(z0) ||
!Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(z1) ||
!Number.isFinite(x2) || !Number.isFinite(y2) || !Number.isFinite(z2)
) {
continue;
}
positions.push(x0, y0, z0, x1, y1, z1, x2, y2, z2);
colors.push(cr, cg, cb);
if (usableUv) {
uvs.push(
usableUv.values[i0 * 2], usableUv.values[i0 * 2 + 1],
usableUv.values[i1 * 2], usableUv.values[i1 * 2 + 1],
usableUv.values[i2 * 2], usableUv.values[i2 * 2 + 1],
);
} else {
uvs.push(0, 0, 0, 0, 0, 0);
}
texSlots.push(materialTexture.slot, materialTexture.slot, materialTexture.slot);
alphaModes.push(materialTexture.alphaMode, materialTexture.alphaMode, materialTexture.alphaMode);
cutoffs.push(materialTexture.cutoff, materialTexture.cutoff, materialTexture.cutoff);
}
}
};
// 只渲染当前场景可达的节点
const scenes = doc.scenes ?? [];
const sceneIndex = doc.scene ?? 0;
const chosen = scenes[sceneIndex] ?? scenes[0];
let roots = chosen?.nodes ?? [];
if (roots.length === 0) roots = nodes.map((_, index) => index);
const visited = new Set<number>();
const walk = (index: number): void => {
if (!Number.isInteger(index) || index < 0 || index >= nodes.length) return;
if (visited.has(index)) return;
visited.add(index);
appendMesh(index);
for (const child of nodes[index].children ?? []) walk(child);
};
for (const root of roots) walk(root);
const triangleCount = Math.floor(colors.length / 3);
if (triangleCount === 0) return null;
return {
positions: Float32Array.from(positions),
colors: Float32Array.from(colors),
uvs: Float32Array.from(uvs),
texSlots: Int32Array.from(texSlots),
alphaModes: Uint8Array.from(alphaModes),
cutoffs: Float32Array.from(cutoffs),
textures,
triangleCount,
};
}
// ---------------------------------------------------------------------------
// 相机 / 投影 / 光栅化
// ---------------------------------------------------------------------------
/** 由 viewProj 变换单个点,返回 [sx, sy, depth, w] */
function projectPoint(
viewProj: Mat4,
x: number,
y: number,
z: number,
size: number,
out: Float64Array,
): void {
const w = viewProj[3] * x + viewProj[7] * y + viewProj[11] * z + viewProj[15];
const cx = viewProj[0] * x + viewProj[4] * y + viewProj[8] * z + viewProj[12];
const cy = viewProj[1] * x + viewProj[5] * y + viewProj[9] * z + viewProj[13];
const cz = viewProj[2] * x + viewProj[6] * y + viewProj[10] * z + viewProj[14];
out[0] = (cx / w) * 0.5 * size + 0.5 * size;
out[1] = (0.5 - (cy / w) * 0.5) * size;
out[2] = cz / w;
out[3] = w;
}
interface RasterOptions {
size: number;
background: [number, number, number];
cullBackFace: boolean;
}
interface RasterOutput {
pixels: Buffer;
drawn: number;
}
function edge(ax: number, ay: number, bx: number, by: number, px: number, py: number): number {
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
}
/**
* z-buffer + 逐三角形平面着色。
* 屏幕空间有符号面积为负的是正面(NDC 里 CCW 正面经 y 翻转后变成负),
* 因此 cullBackFace 时丢弃 area >= 0 的三角形。
*/
/** 采样贴图(REPEAT 环绕;glTF/PNG 的 UV 原点在左上,v 不翻转) */
function sampleTexture(image: DecodedImage, u: number, v: number, out: Float32Array): void {
const wrap = (value: number): number => value - Math.floor(value);
const x = Math.min(image.width - 1, Math.floor(wrap(u) * image.width));
const y = Math.min(image.height - 1, Math.floor(wrap(v) * image.height));
const o = (y * image.width + x) * 4;
out[0] = image.rgba[o] / 255;
out[1] = image.rgba[o + 1] / 255;
out[2] = image.rgba[o + 2] / 255;
out[3] = image.rgba[o + 3] / 255;
}
function rasterizeTriangles(
geometry: SceneGeometry,
viewProj: Mat4,
options: RasterOptions,
): RasterOutput {
const { size, background, cullBackFace } = options;
const { positions, colors, uvs, texSlots, alphaModes, cutoffs, textures, triangleCount } = geometry;
const pixels = Buffer.alloc(size * size * 3);
for (let i = 0; i < size * size; i += 1) {
pixels[i * 3] = clamp(Math.round(background[0]), 0, 255);
pixels[i * 3 + 1] = clamp(Math.round(background[1]), 0, 255);
pixels[i * 3 + 2] = clamp(Math.round(background[2]), 0, 255);
}
const depth = new Float64Array(size * size).fill(Number.POSITIVE_INFINITY);
const p0 = new Float64Array(4);
const p1 = new Float64Array(4);
const p2 = new Float64Array(4);
let drawn = 0;
for (let t = 0; t < triangleCount; t += 1) {
const o = t * 9;
const ax = positions[o];
const ay = positions[o + 1];
const az = positions[o + 2];
const bx = positions[o + 3];
const by = positions[o + 4];
const bz = positions[o + 5];
const cx = positions[o + 6];
const cy = positions[o + 7];
const cz = positions[o + 8];
// 世界空间法线(平面着色)
const ux = bx - ax;
const uy = by - ay;
const uz = bz - az;
const vx = cx - ax;
const vy = cy - ay;
const vz = cz - az;
let nx = uy * vz - uz * vy;
let ny = uz * vx - ux * vz;
let nz = ux * vy - uy * vx;
const nlen = Math.hypot(nx, ny, nz);
if (!(nlen > 1e-12)) continue; // 退化三角形
nx /= nlen;
ny /= nlen;
nz /= nlen;
projectPoint(viewProj, ax, ay, az, size, p0);
projectPoint(viewProj, bx, by, bz, size, p1);
projectPoint(viewProj, cx, cy, cz, size, p2);
// w <= 0 表示顶点在相机后方;不做近平面裁剪,直接丢弃该三角形
if (!(p0[3] > 1e-6) || !(p1[3] > 1e-6) || !(p2[3] > 1e-6)) continue;
if (!Number.isFinite(p0[0]) || !Number.isFinite(p1[0]) || !Number.isFinite(p2[0])) continue;
if (!Number.isFinite(p0[1]) || !Number.isFinite(p1[1]) || !Number.isFinite(p2[1])) continue;
if (!Number.isFinite(p0[2]) || !Number.isFinite(p1[2]) || !Number.isFinite(p2[2])) continue;
const area = edge(p0[0], p0[1], p1[0], p1[1], p2[0], p2[1]);
if (!Number.isFinite(area) || Math.abs(area) < 1e-12) continue;
if (cullBackFace && area >= 0) continue;
const minXf = Math.min(p0[0], p1[0], p2[0]);
const maxXf = Math.max(p0[0], p1[0], p2[0]);
const minYf = Math.min(p0[1], p1[1], p2[1]);
const maxYf = Math.max(p0[1], p1[1], p2[1]);
const minX = Math.max(0, Math.floor(minXf));
const maxX = Math.min(size - 1, Math.ceil(maxXf));
const minY = Math.max(0, Math.floor(minYf));
const maxY = Math.min(size - 1, Math.ceil(maxYf));
if (maxX < minX || maxY < minY) continue; // 完全在画面外
drawn += 1;
// 简单 Lambert:环境项 + 主光;用 abs 兼顾绕序不一致的模型,避免整片死黑
const ndotl = Math.abs(nx * LIGHT_DIR[0] + ny * LIGHT_DIR[1] + nz * LIGHT_DIR[2]);
const intensity = AMBIENT + DIFFUSE * ndotl;
const baseR = colors[t * 3];
const baseG = colors[t * 3 + 1];
const baseB = colors[t * 3 + 2];
// 贴图与透明度(每三角形常量;顶点数组按 3 顶点/三角形展开)
const slot = texSlots ? texSlots[t * 3] : -1;
const image = slot >= 0 ? textures[slot] ?? null : null;
const alphaMode = alphaModes ? alphaModes[t * 3] : 0;
const cutoff = cutoffs ? cutoffs[t * 3] : 0.5;
const u0 = uvs ? uvs[t * 6] : 0;
const v0 = uvs ? uvs[t * 6 + 1] : 0;
const u1 = uvs ? uvs[t * 6 + 2] : 0;
const v1 = uvs ? uvs[t * 6 + 3] : 0;
const u2 = uvs ? uvs[t * 6 + 4] : 0;
const v2 = uvs ? uvs[t * 6 + 5] : 0;
const texel = new Float32Array(4);
// 透视校正用:1/w 在三个顶点上的值
const iw0 = 1 / p0[3];
const iw1 = 1 / p1[3];
const iw2 = 1 / p2[3];
const invArea = 1 / area;
for (let y = minY; y <= maxY; y += 1) {
const py = y + 0.5;
const rowBase = y * size;
for (let x = minX; x <= maxX; x += 1) {
const px = x + 0.5;
const w0 = edge(p1[0], p1[1], p2[0], p2[1], px, py) * invArea;
const w1 = edge(p2[0], p2[1], p0[0], p0[1], px, py) * invArea;
const w2 = 1 - w0 - w1;
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
const d = w0 * p0[2] + w1 * p1[2] + w2 * p2[2];
const index = rowBase + x;
if (d < depth[index]) {
let r = clamp(Math.round(baseR * intensity * 255), 0, 255);
let g = clamp(Math.round(baseG * intensity * 255), 0, 255);
let b = clamp(Math.round(baseB * intensity * 255), 0, 255);
if (image) {
const iw = w0 * iw0 + w1 * iw1 + w2 * iw2;
const u = (w0 * iw0 * u0 + w1 * iw1 * u1 + w2 * iw2 * u2) / (iw || 1);
const v = (w0 * iw0 * v0 + w1 * iw1 * v1 + w2 * iw2 * v2) / (iw || 1);
sampleTexture(image, u, v, texel);
if (alphaMode === 1 && texel[3] < cutoff) continue; // MASK:整像素丢弃
const a = alphaMode === 0 ? 1 : texel[3];
r = clamp(Math.round(baseR * texel[0] * intensity * 255), 0, 255);
g = clamp(Math.round(baseG * texel[1] * intensity * 255), 0, 255);
b = clamp(Math.round(baseB * texel[2] * intensity * 255), 0, 255);
if (alphaMode === 2 && a < 1) {
// BLEND:与背景/已画像素做 source-over(缩略图不做排序,够看即可)
const q = index * 3;
r = clamp(Math.round(r * a + pixels[q] * (1 - a)), 0, 255);
g = clamp(Math.round(g * a + pixels[q + 1] * (1 - a)), 0, 255);
b = clamp(Math.round(b * a + pixels[q + 2] * (1 - a)), 0, 255);
}
}
depth[index] = d;
const p = index * 3;
pixels[p] = r;
pixels[p + 1] = g;
pixels[p + 2] = b;
}
}
}
}
return { pixels, drawn };
}
/** 按包围盒自动构图,返回 viewProj 矩阵 */
function buildViewProjection(geometry: SceneGeometry, size: number, yawDeg: number, pitchDeg: number): Mat4 {
const { positions, triangleCount } = geometry;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let minZ = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
let maxZ = Number.NEGATIVE_INFINITY;
const vertexCount = triangleCount * 3;
for (let i = 0; i < vertexCount; i += 1) {
const x = positions[i * 3];
const y = positions[i * 3 + 1];
const z = positions[i * 3 + 2];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const cz = (minZ + maxZ) / 2;
const dx = maxX - minX;
const dy = maxY - minY;
const dz = maxZ - minZ;
let radius = 0.5 * Math.hypot(dx, dy, dz);
if (!Number.isFinite(radius) || radius <= 0) radius = Number.MIN_VALUE;
const fov = (FOV_Y_DEG * Math.PI) / 180;
const distance = (radius / Math.tan(fov / 2)) * FRAMING;
// glTF 是 Y-up;yaw 绕 Y,pitch 抬高相机
const yaw = (yawDeg * Math.PI) / 180;
const pitch = (pitchDeg * Math.PI) / 180;
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
const dirX = Math.sin(yaw) * cp;
const dirY = sp;
const dirZ = Math.cos(yaw) * cp;
const eyeX = cx + dirX * distance;
const eyeY = cy + dirY * distance;
const eyeZ = cz + dirZ * distance;
// lookAt(右手系,z 轴指向相机后方)
let zx = dirX;
let zy = dirY;
let zz = dirZ;
const zlen = Math.hypot(zx, zy, zz) || 1;
zx /= zlen;
zy /= zlen;
zz /= zlen;
let ux = 0;
let uy = 1;
let uz = 0;
let xx = uy * zz - uz * zy;
let xy = uz * zx - ux * zz;
let xz = ux * zy - uy * zx;
let xlen = Math.hypot(xx, xy, xz);
if (!(xlen > 1e-9)) {
// 视线与 up 平行时的退化处理
ux = 0;
uy = 0;
uz = 1;
xx = uy * zz - uz * zy;
xy = uz * zx - ux * zz;
xz = ux * zy - uy * zx;
xlen = Math.hypot(xx, xy, xz) || 1;
}
xx /= xlen;
xy /= xlen;
xz /= xlen;
const yx = zy * xz - zz * xy;
const yy = zz * xx - zx * xz;
const yz = zx * xy - zy * xx;
const view = new Float64Array(16);
view[0] = xx;
view[1] = yx;
view[2] = zx;
view[4] = xy;
view[5] = yy;
view[6] = zy;
view[8] = xz;
view[9] = yz;
view[10] = zz;
view[12] = -(xx * eyeX + xy * eyeY + xz * eyeZ);
view[13] = -(yx * eyeX + yy * eyeY + yz * eyeZ);
view[14] = -(zx * eyeX + zy * eyeY + zz * eyeZ);
view[15] = 1;
const near = Math.max(distance - radius * 1.5, distance * 0.01, Number.MIN_VALUE);
const far = distance + radius * 2 + near;
const f = 1 / Math.tan(fov / 2);
const proj = new Float64Array(16);
proj[0] = f; // aspect = 1(正方形输出)
proj[5] = f;
proj[10] = (far + near) / (near - far);
proj[11] = -1;
proj[14] = (2 * far * near) / (near - far);
return mat4Multiply(proj, view);
}
// ---------------------------------------------------------------------------
// PNG 解码(缩略图要贴图才不是"白模";只做 8 位非隔行,够用且零原生依赖)
// ---------------------------------------------------------------------------
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* 极简 PNG 解码器:zlib 解压 + 逐行反滤波,输出 RGBA。
* 不支持 16 位 / 隔行扫描 / 未知 colorType —— 返回 null 由调用方回退纯色。
*/
function decodePng(buffer: Buffer): DecodedImage | null {
if (buffer.length < 8 + 25 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
let offset = 8;
let width = 0;
let height = 0;
let bitDepth = 0;
let colorType = 0;
let interlace = 0;
let palette: Uint8Array | null = null;
let transparency: Uint8Array | null = null;
const idat: Buffer[] = [];
while (offset + 8 <= buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString('ascii', offset + 4, offset + 8);
const dataStart = offset + 8;
if (dataStart + length + 4 > buffer.length) return null;
const data = buffer.subarray(dataStart, dataStart + length);
if (type === 'IHDR') {
if (length < 13) return null;
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
interlace = data[12];
} else if (type === 'PLTE') {
palette = new Uint8Array(data);
} else if (type === 'tRNS') {
transparency = new Uint8Array(data);
} else if (type === 'IDAT') {
idat.push(Buffer.from(data));
} else if (type === 'IEND') {
break;
}
offset = dataStart + length + 4;
}
if (bitDepth !== 8 || interlace !== 0) return null;
if (!(width > 0) || !(height > 0) || width * height > 64 * 1024 * 1024) return null;
if (idat.length === 0) return null;
const channels =
colorType === 0 ? 1 : colorType === 2 ? 3 : colorType === 3 ? 1 : colorType === 4 ? 2 : colorType === 6 ? 4 : 0;
if (channels === 0) return null;
if (colorType === 3 && !palette) return null;
let raw: Buffer;
try {
raw = inflateSync(Buffer.concat(idat));
} catch {
return null;
}
const stride = width * channels;
if (raw.length < (stride + 1) * height) return null;
const out = new Uint8Array(width * height * 4);
const line = new Uint8Array(stride);
const prev = new Uint8Array(stride);
let rawPos = 0;
for (let y = 0; y < height; y += 1) {
const filter = raw[rawPos];
rawPos += 1;
for (let i = 0; i < stride; i += 1) {
const value = raw[rawPos + i];
const left = i >= channels ? line[i - channels] : 0;
const up = prev[i];
const upLeft = i >= channels ? prev[i - channels] : 0;
let restored: number;
switch (filter) {
case 0: restored = value; break;
case 1: restored = value + left; break;
case 2: restored = value + up; break;
case 3: restored = value + ((left + up) >> 1); break;
case 4: {
const p = left + up - upLeft;
const pa = Math.abs(p - left);
const pb = Math.abs(p - up);
const pc = Math.abs(p - upLeft);
restored = value + (pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft);
break;
}
default: return null;
}
line[i] = restored & 0xff;
}
rawPos += stride;
for (let x = 0; x < width; x += 1) {
const o = (y * width + x) * 4;
if (colorType === 6) {
out[o] = line[x * 4];
out[o + 1] = line[x * 4 + 1];
out[o + 2] = line[x * 4 + 2];
out[o + 3] = line[x * 4 + 3];
} else if (colorType === 2) {
out[o] = line[x * 3];
out[o + 1] = line[x * 3 + 1];
out[o + 2] = line[x * 3 + 2];
out[o + 3] = 255;
} else if (colorType === 0) {
const g = line[x];
out[o] = g; out[o + 1] = g; out[o + 2] = g; out[o + 3] = 255;
} else if (colorType === 4) {
const g = line[x * 2];
out[o] = g; out[o + 1] = g; out[o + 2] = g; out[o + 3] = line[x * 2 + 1];
} else {
const index = line[x];
out[o] = palette![index * 3] ?? 0;
out[o + 1] = palette![index * 3 + 1] ?? 0;
out[o + 2] = palette![index * 3 + 2] ?? 0;
out[o + 3] = transparency && index < transparency.length ? transparency[index] : 255;
}
}
prev.set(line);
}
return { width, height, rgba: out };
}
// ---------------------------------------------------------------------------
// 对外入口
// ---------------------------------------------------------------------------
/**
* 渲染 GLB/VRM 缩略图;文件不是可解析的 glTF 容器时返回 null(不抛错)。
*
* @param absPath 模型文件绝对路径(.glb / .vrm / .gltf)
* @param options 尺寸与视角;size 必须在 64..1024 之间,否则抛中文 Error
*/
export async function renderModelThumbnail(
absPath: string,
options?: ThumbnailOptions,
): Promise<ThumbnailResult | null> {
// ---- 参数校验:非法参数直接抛错,不静默降级 ----
const size = options?.size ?? DEFAULT_SIZE;
if (typeof size !== 'number' || !Number.isInteger(size) || size < MIN_SIZE || size > MAX_SIZE) {
throw new Error(`缩略图尺寸非法:${String(size)},允许 64..1024 之间的整数`);
}
const background = options?.background ?? DEFAULT_BACKGROUND;
if (
!Array.isArray(background) ||
background.length !== 3 ||
background.some((v) => typeof v !== 'number' || !Number.isFinite(v))
) {
throw new Error('缩略图背景色非法:需要 [r, g, b] 三个有限数字');
}
// 默认看**正面**:eye = 中心 + (sin yaw, .., cos yaw)*d,yaw=0 时相机在 +Z;
// MMD(PMX)与 VRM 1.0 都是 +Z 朝前,因此 -30 是"正面偏 30 度"的常规 3/4 视角
const yawDeg = options?.yawDeg ?? -30;
const rawPitchDeg = options?.pitchDeg ?? 12;
if (!Number.isFinite(yawDeg) || !Number.isFinite(rawPitchDeg)) {
throw new Error('缩略图视角参数非法:yawDeg / pitchDeg 必须是有限数字');
}
// 俯仰角夹到 ±89°,避免视线与 up 平行导致视图矩阵退化
const pitchDeg = clamp(rawPitchDeg, -89, 89);
// ---- 读文件:读不到(不存在 / 无权限)也返回 null,交给调用方兜底 ----
let file: Buffer;
try {
file = await readFile(absPath);
} catch {
return null;
}
try {
const container = await loadGltfContainer(file, absPath);
if (!container) return null;
const geometry = collectTriangles(container);
if (!geometry || geometry.triangleCount === 0) return null;
const viewProj = buildViewProjection(geometry, size, yawDeg, pitchDeg);
// 先按 CCW 正面剔除;若一个三角形都没画出来,说明模型绕序相反,退回双面绘制
let output = rasterizeTriangles(geometry, viewProj, { size, background, cullBackFace: true });
if (output.drawn === 0) {
output = rasterizeTriangles(geometry, viewProj, { size, background, cullBackFace: false });
}
if (output.drawn === 0) return null;
return {
png: encodePngRgb(output.pixels, size, size),
width: size,
height: size,
trianglesDrawn: output.drawn,
};
} catch {
// 任何解析 / 渲染异常都视为"无法解析",返回 null,绝不产出损坏的 PNG
return null;
}
}