- Node 渲染器(three r162 + headless-gl + pngjs):gen-templates 程序化生成简化人体 GLB(6 脸 × 6 体 × 5 肤色),render.js 绕 Y 轴 36 帧 256x512 白底渲染 - agent/render.go:RenderAvatarFrames 帧目录缓存复用 + GLB 缺失自动生成 + 15 分钟超时;render.enabled/node_bin 配置,node_bin 路径缺失回退 PATH - Build 异步化:写库 processing → goroutine 渲染 → done+frames_url / failed+error(客户端降级静态占位);修 BodyMeasurement 无记录时 Build 报错 - avatar_model 容错 ALTER 加 frames_url 列;AvatarGetRes 透传 frames_url - TDD:renderJob 状态机测试(成功 done / 失败 failed 降级) - 客户端 avatar_viewer 帧轮播(120ms × 36 帧,errorBuilder 降级占位),avatar_provider 解析 frames_url - Dockerfile 双阶段:node:20-alpine 编译 gl 原生模块 + 运行时带 nodejs/libglvnd
139 lines
4.6 KiB
JavaScript
139 lines
4.6 KiB
JavaScript
// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
|
||
// 用法: node gen-templates.js --out <dir> [--faces 6] [--bodies 6] [--skins 5]
|
||
// 索引对齐 MatchTemplates:face 0 起、body/skin 1 起;已存在的 GLB 跳过(幂等)。
|
||
import { argv } from 'node:process';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import * as THREE from 'three';
|
||
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
|
||
|
||
// Node 无 FileReader,GLTFExporter 二进制导出需要它(onloadend 回调风格)
|
||
if (typeof globalThis.FileReader === 'undefined') {
|
||
globalThis.FileReader = class {
|
||
constructor() { this.result = null; }
|
||
set onloadend(fn) { this._onloadend = fn; }
|
||
get onloadend() { return this._onloadend; }
|
||
readAsArrayBuffer(blob) {
|
||
blob.arrayBuffer().then((buf) => {
|
||
this.result = buf;
|
||
this._onloadend && this._onloadend();
|
||
});
|
||
}
|
||
};
|
||
}
|
||
|
||
function parseArgs() {
|
||
const a = { faces: 6, bodies: 6, skins: 5 };
|
||
for (let i = 2; i < argv.length; i++) {
|
||
if (argv[i].startsWith('--')) {
|
||
const key = argv[i].slice(2);
|
||
const val = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
||
a[key] = val;
|
||
}
|
||
}
|
||
a.out = a.out || '.';
|
||
return a;
|
||
}
|
||
|
||
// 5 档肤色(PBR base color)
|
||
const SKIN_TONES = ['#FDE4CF', '#F3C39B', '#D99B6C', '#A96B3F', '#6B4226'];
|
||
|
||
// 脸型变体:发型形状/颜色差异
|
||
const FACE_HAIR = [
|
||
{ style: 'short', color: '#2B2B2B' }, // 短发
|
||
{ style: 'fringe', color: '#4A3728' }, // 刘海长发
|
||
{ style: 'bob', color: '#8C5A2B' }, // 波波头
|
||
];
|
||
|
||
// 体型变体:宽度/高度比例
|
||
const BODY_VARIANTS = [
|
||
{ name: 'slim', width: 0.85, height: 1.0 },
|
||
{ name: 'normal', width: 1.0, height: 1.0 },
|
||
{ name: 'broad', width: 1.18, height: 0.96 },
|
||
];
|
||
|
||
function buildAvatar(faceIdx, bodyIdx, skinIdx) {
|
||
const face = FACE_HAIR[faceIdx % FACE_HAIR.length];
|
||
const body = BODY_VARIANTS[bodyIdx % BODY_VARIANTS.length];
|
||
const skin = SKIN_TONES[skinIdx % SKIN_TONES.length];
|
||
|
||
const root = new THREE.Group();
|
||
const skinMat = new THREE.MeshStandardMaterial({ color: skin, roughness: 0.7 });
|
||
const hairMat = new THREE.MeshStandardMaterial({ color: face.color, roughness: 0.85 });
|
||
const clothMat = new THREE.MeshStandardMaterial({ color: '#3D5A80', roughness: 0.8 });
|
||
|
||
const W = body.width;
|
||
const H = body.height;
|
||
|
||
// 躯干:胶囊体
|
||
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.22 * W, 0.5, 8, 16), clothMat);
|
||
torso.position.y = 1.12 * H;
|
||
root.add(torso);
|
||
|
||
// 头:球体
|
||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.16 * W, 24, 18), skinMat);
|
||
head.position.y = 1.68 * H;
|
||
root.add(head);
|
||
|
||
// 发型:覆盖头顶的半球壳(按脸型变体)
|
||
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.175 * W, 24, 12, 0, Math.PI * 2, 0, Math.PI * 0.52), hairMat);
|
||
hair.position.y = 1.68 * H + 0.03;
|
||
root.add(hair);
|
||
if (face.style === 'fringe') {
|
||
const fringe = new THREE.Mesh(new THREE.BoxGeometry(0.16 * W, 0.05, 0.2 * W), hairMat);
|
||
fringe.position.y = 1.7 * H;
|
||
fringe.position.z = -0.12 * W;
|
||
fringe.rotation.x = -0.25;
|
||
root.add(fringe);
|
||
} else if (face.style === 'bob') {
|
||
const back = new THREE.Mesh(new THREE.BoxGeometry(0.3 * W, 0.3, 0.06), hairMat);
|
||
back.position.y = 1.52 * H;
|
||
back.position.z = 0.14 * W;
|
||
root.add(back);
|
||
}
|
||
|
||
// 上肢
|
||
for (const side of [-1, 1]) {
|
||
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.075 * W, 0.42, 6, 10), skinMat);
|
||
arm.position.set(side * 0.32 * W, 1.32 * H, 0);
|
||
arm.rotation.z = side * 0.06;
|
||
root.add(arm);
|
||
}
|
||
|
||
// 下肢
|
||
for (const side of [-1, 1]) {
|
||
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * W, 0.62, 6, 10), clothMat);
|
||
leg.position.set(side * 0.12 * W, 0.55 * H, 0);
|
||
root.add(leg);
|
||
}
|
||
|
||
return root;
|
||
}
|
||
|
||
const args = parseArgs();
|
||
fs.mkdirSync(args.out, { recursive: true });
|
||
const exporter = new GLTFExporter();
|
||
|
||
let generated = 0;
|
||
for (let f = 0; f < args.faces; f++) {
|
||
for (let b = 1; b <= args.bodies; b++) {
|
||
for (let s = 1; s <= args.skins; s++) {
|
||
const name = `avatar_f${f}_b${b}_s${s}.glb`;
|
||
const outPath = path.join(args.out, name);
|
||
if (fs.existsSync(outPath)) continue;
|
||
const scene = buildAvatar(f, b, s);
|
||
const buf = await new Promise((resolve, reject) => {
|
||
exporter.parse(
|
||
scene,
|
||
(result) => resolve(Buffer.from(result)),
|
||
(err) => reject(err),
|
||
{ binary: true }
|
||
);
|
||
});
|
||
fs.writeFileSync(outPath, buf);
|
||
generated++;
|
||
}
|
||
}
|
||
}
|
||
console.log(`generated ${generated} GLB (skipped existing) -> ${args.out}`);
|