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; 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; } 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 { 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 = []; 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 { 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, 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, 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 { const nodes = doc.nodes ?? []; const out = new Map(); 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, skinIndex: number, worldByNode: Map, cache: Map | null>, ): Array | null { const cached = cache.get(skinIndex); if (cached !== undefined) return cached; let result: Array | 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, 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, 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, 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 | 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(); const imageCache = new Map(); 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, 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(); 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 { // ---- 参数校验:非法参数直接抛错,不静默降级 ---- 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; } }