Web AR应用里有一个高频需求:用户在增强现实场景中摆好虚拟模型后,想把当前画面保存成图片分享出去。在浏览器端用Canvas的toDataURL可以勉强实现,但一旦涉及服务端批量生成、加水印、拼接多帧等需求,就需要把截图能力搬到Node.js服务端,这就是WebAR2Image要解决的问题。服务端没有真实的摄像头,也没有GPU渲染环境,如何让原本依赖浏览器的AR渲染流程在Node.js中跑起来,并把渲染结果编码成图片,是整个方案的核心。

Web AR在服务端截图的核心难点
第一个难点是渲染环境缺失。主流Web AR方案(如AR.js、MindAR、8th Wall)都依赖浏览器的WebGL上下文和摄像头视频流,模型渲染发生在GPU上,视频帧作为纹理叠加在渲染管线中。Node.js本身没有DOM、没有WebGL实现,直接把前端代码搬过来运行是不可能的。第二个难点是纹理来源问题,AR场景里的背景是摄像头画面,服务端没有摄像头,必须用预先准备的图片或视频帧来模拟摄像头输入,否则渲染出来的就是纯色背景加一个悬浮模型。
第三个难点是异步时序控制。Three.js等渲染引擎的资源加载、模型解析都是异步的,截图必须发生在场景完全初始化且至少渲染一帧之后。很多人在服务端截图拿到黑屏,就是因为截图时机太早,WebGL的双缓冲机制下画面还没提交到绘图缓冲区。解决这个问题有两个思路:一是渲染后主动调用renderer.render并等待requestAnimationFrame回调,二是在创建WebGL上下文时设置preserveDrawingBuffer为true,避免缓冲区在帧结束后被清空。
方案一:Puppeteer无头浏览器实现WebAR2Image
最稳妥的方案是用Puppeteer驱动一个无头Chrome,让它加载你的AR页面,注入模拟数据后截图。这种方式的好处是前端代码零改动,AR逻辑该怎么跑还怎么跑,服务端只是替用户完成了打开页面和截图的动作。下面是完整的实现代码:
const puppeteer = require('puppeteer');
async function webAR2Image(pageUrl, outputPath) {
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--use-gl=swiftshader', // 用软件渲染模拟GPU
'--enable-unsafe-swiftshader'
]
});
const page = await browser.newPage();
await page.setViewport({ width: 1080, height: 1920 });
// 注入虚拟摄像头帧,替代真实摄像头
await page.evaluateOnNewDocument(() => {
navigator.mediaDevices.getUserMedia = async () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = '/mock-camera-frame.jpg';
await new Promise(r => img.onload = r);
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const stream = canvas.captureStream(30);
return { getVideoTracks: () => stream.getVideoTracks() };
};
});
await page.goto(pageUrl, { waitUntil: 'networkidle0' });
// 等待AR场景渲染完成,由前端暴露的标记判断
await page.waitForFunction('window.__AR_READY__ === true');
// 额外等两帧,确保绘图缓冲区已提交
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.screenshot({ path: outputPath, type: 'jpeg', quality: 92 });
await browser.close();
return outputPath;
}
webAR2Image('https://ipipp.com/ar/scene?id=1001', 'output.jpg')
.then(console.log)
.catch(console.error);代码里有几个关键点值得展开。首先--use-gl=swiftshader让Chrome在无GPU的服务器上用CPU模拟WebGL渲染,这是服务端跑AR的前提,缺点是渲染速度慢,复杂场景一帧可能要几百毫秒。其次evaluateOnNewDocument注入的getUserMedia拦截必须在页面脚本执行前生效,所以不能用evaluate,这是新手最容易踩的坑。最后前端页面需要在场景初始化完成后设置window.__AR_READY__ = true,给服务端一个明确的等待信号,比写死setTimeout等待要可靠得多。
这个方案的缺点也很明显:每次截图都要启动一个完整的浏览器实例,内存占用在200MB以上,单机并发能力有限。如果并发需求高,可以用browser.createIncognitoBrowserContext复用浏览器实例,或者用puppeteer-cluster做任务池管理,把并发数控制在CPU核心数以内,避免SwiftShader渲染线程互相争抢。
方案二:Node.js端直接渲染并编码图片
如果你的AR场景比较简单,比如只是把一个glTF模型渲染到指定背景图上,可以完全绕开浏览器,在Node.js里用three配合gl包(headless-gl)直接渲染。这个方案性能比无头浏览器高一个量级,因为去掉了浏览器自身的开销,代码也能作为纯函数在任意Node进程中调用:
const THREE = require('three');
const gl = require('gl');
const { PNG } = require('pngjs');
const fs = require('fs');
async function renderModelToImage(modelPath, bgPath, outputPath) {
const width = 1080, height = 1080;
// 创建离屏WebGL上下文
const glContext = gl(width, height, { preserveDrawingBuffer: true });
const renderer = new THREE.WebGLRenderer({
context: glContext,
antialias: true
});
renderer.setSize(width, height);
// 加载背景图作为场景纹理,模拟AR摄像头画面
const scene = new THREE.Scene();
const bgTexture = new THREE.TextureLoader().load(bgPath);
scene.background = bgTexture;
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);
camera.position.set(0, 1.2, 3);
camera.lookAt(0, 0.5, 0);
// 加载glTF模型
const { GLTFLoader } = require('three/examples/jsm/loaders/GLTFLoader');
const loader = new GLTFLoader();
const gltf = await loader.loadAsync(modelPath);
scene.add(gltf.scene);
// 渲染一帧并读取像素
renderer.render(scene, camera);
const pixels = new Uint8Array(width * height * 4);
glContext.readPixels(0, 0, width, height,
glContext.RGBA, glContext.UNSIGNED_BYTE, pixels);
// OpenGL原点在左下角,需要垂直翻转
const png = new PNG({ width, height });
for (let y = 0; y < height; y++) {
PNG.bitblt({
data: pixels, width, height
}, png, 0, height - 1 - y, width, 1, 0, y);
}
fs.writeFileSync(outputPath, PNG.sync.write(png));
}
renderModelToImage('model.glb', 'background.jpg', 'result.png');headless-gl方案最大的坑在安装环节,它依赖原生编译环境,Windows上经常报错,Linux下需要安装build-essential和libxi-dev。另外它支持的WebGL版本只到2.0的部分特性,如果模型用了较新的着色器特性可能渲染异常。像素读取后的垂直翻转也别忘了,OpenGL的坐标原点在左下角,直接写PNG会得到上下颠倒的图。
两种方案对比与生产环境建议
从实际项目经验看,两种方案的适用场景分得很清楚。Puppeteer方案适合AR逻辑复杂、前端已经成型、需要截图效果与用户实际看到的完全一致的场景,比如商品AR试摆的分享图生成。headless-gl方案适合批量合成场景,比如给几千个SKU批量生成AR预览图,这时性能优势会非常明显,单张生成时间可以从秒级压到百毫秒级。
| 对比维度 | Puppeteer方案 | headless-gl方案 |
|---|---|---|
| 前端代码复用 | 完全复用 | 需要重写渲染逻辑 |
| 单张生成耗时 | 约2至5秒 | 约100至500毫秒 |
| 内存占用 | 200MB以上每实例 | 50MB左右 |
| 部署复杂度 | 低,npm安装即可 | 高,依赖原生编译 |
| 渲染一致性 | 与浏览器一致 | 取决于three版本适配 |
生产环境还有两个细节要注意。一是跨域纹理问题,无论哪种方案,加载外部图片纹理时都要保证资源返回正确的CORS响应头,否则WebGL会拒绝把跨域图片绘制到带preserveDrawingBuffer的画布上,截图直接变黑。二是要加超时兜底,AR资源加载在服务端可能因为网络问题卡住,Puppeteer的waitForFunction要设置{ timeout: 15000 },超时后销毁页面释放资源,避免僵尸进程堆积把服务器内存吃光。
总结一下,WebAR2Image的本质是把浏览器里的AR渲染流程搬到可控的服务端环境。Puppeteer方案拿一致性换性能,headless-gl方案拿开发成本换吞吐量,理解了WebGL离屏渲染和绘图缓冲区这两个底层机制后,你可以根据业务对时效和规模的要求自由选择,甚至把两者组合起来:无头浏览器处理用户个性化截图,headless-gl处理离线批量生成,各司其职。