// 化身 GLB -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。 // 用法: node render.js --glb --out [--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 --out [--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); }