Files
slogan/scripts/avatar-render/gen-templates.js
T
admin fd13efb9c0 feat: 3D 化身服务端预渲染(Node 渲染器 + 帧轮播 + 降级)
- 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
2026-07-31 15:37:02 +08:00

139 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
// 用法: node gen-templates.js --out <dir> [--faces 6] [--bodies 6] [--skins 5]
// 索引对齐 MatchTemplatesface 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 无 FileReaderGLTFExporter 二进制导出需要它(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}`);