Files
slogan/scripts/avatar-render/render.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

116 lines
3.7 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 -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。
// 用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]
import { argv } from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import createGL from 'gl';
import { PNG } from 'pngjs';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
function parseArgs() {
const a = {};
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;
}
}
if (!a.glb || !a.out) {
console.error('用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]');
process.exit(1);
}
a.frames = a.frames === true ? 36 : parseInt(a.frames, 10) || 36;
const [w, h] = (a.size === true ? '256x512' : String(a.size)).split('x').map(Number);
a.width = w || 256;
a.height = h || 512;
return a;
}
const args = parseArgs();
const { width, height, frames } = args;
const gl = createGL(width, height, { preserveDrawingBuffer: true });
if (!gl) {
console.error('headless-gl 初始化失败(容器内需 mesa/libglvnd');
process.exit(2);
}
const canvas = {
width,
height,
style: {},
addEventListener: () => {},
removeEventListener: () => {},
clientWidth: width,
clientHeight: height,
getContext: () => gl,
};
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: false });
renderer.setClearColor(0xffffff, 1);
renderer.setSize(width, height, false);
const scene = new THREE.Scene();
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.4);
dirLight.position.set(3, 6, 4);
scene.add(dirLight);
scene.add(new THREE.DirectionalLight(0xffffff, 0.5).translateY(-4).translateX(-3));
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 100);
const loader = new GLTFLoader();
const loadGlb = () =>
new Promise((resolve, reject) => {
// Buffer 需转成 ArrayBuffer 才能触发 GLB 头解析
const bin = fs.readFileSync(args.glb);
const ab = bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
loader.parse(ab, '', (gltf) => resolve(gltf.scene), (err) => reject(err));
});
loadGlb()
.then((object) => {
scene.add(object);
render(object);
})
.catch((err) => {
console.error('GLB 解析失败:', err && err.message ? err.message : err);
process.exit(3);
});
function render(object) {
// 包围盒 -> 相机半径与观测高度
const box = new THREE.Box3().setFromObject(object);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const radius = Math.max(size.x, size.z) * 1.6 + 0.6;
const lookY = center.y + size.y * 0.35;
const cameraY = center.y + size.y * 0.35;
fs.mkdirSync(args.out, { recursive: true });
const pixels = new Uint8Array(width * height * 4);
const rowSize = width * 4;
const png = new PNG({ width, height });
for (let i = 0; i < frames; i++) {
const angle = (i / frames) * Math.PI * 2;
camera.position.set(Math.sin(angle) * radius, cameraY, Math.cos(angle) * radius);
camera.lookAt(0, lookY, 0);
renderer.render(scene, camera);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const buf = Buffer.from(pixels.buffer);
for (let y = 0; y < height; y++) {
buf.copy(png.data, y * rowSize, (height - 1 - y) * rowSize, (height - y) * rowSize);
}
const out = path.join(args.out, `frame_${String(i).padStart(3, '0')}.png`);
fs.writeFileSync(out, PNG.sync.write(png));
}
console.log(`rendered ${frames} frames -> ${args.out}`);
process.exit(0);
}