按《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。
1429 lines
49 KiB
TypeScript
1429 lines
49 KiB
TypeScript
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;
|
||
}
|
||
}
|