一张平平无奇的 2D 图片,凭什么能被鼠标拖拽着翻转,甚至像浮雕一样凸出屏幕?如果你是第一次看到这种效果,大概率会以为背后得先有 Blender 建模,再走一遍渲染烘焙。其实不用。img2threejs 这个只有 8.7k Star 的开源项目,让我真正印象深刻的不是它把图片变得多炫,而是它把“图片转 3D”这件事拆成了一条非常清晰的代码流水线——从加载图片、估算深度、细分网格、位移顶点,到贴材质、加交互,每一步都看得见、改得动、换得掉。这篇文章不是项目文档的复刻,而是我基于自己实测和源码阅读后的完整拆解,顺带把那些文档不会写的坑也一起说了。如果你现在想做个动态封面、产品展示页,或者纯粹想搞明白“伪 3D 到底是怎么动起来的”,这篇应该能让你少走不少弯路。
1. img2threejs 到底解决了什么问题
1.1 一张图变 3D 的表面原理
先说个最基础的问题:一张照片或插画,本质上就是一个二维的像素矩阵,每个像素只有 RGB 颜色信息,没有“离我多远”的深度概念。人的立体感其实来自两只眼睛的视差、物体的遮挡关系、光影变化,以及你轻微移动头部时看到的相对位移。img2threejs 做的就是把“深度信息”这个缺失的维度补出来,再让画面里不同位置根据深度发生不同程度的位移,从而骗过眼睛,产生 3D 感。
具体到实现,它通常不会去真正重建一个复杂的四面体网格,而是把原有图像当作一层“贴皮”,用一个细分后的平面网格作为基底,给每个顶点算出一个 Z 轴偏移量。Z 轴偏移量大,视觉上就感觉“凸出来”;偏移量小或者为负,就感觉“凹进去”。当鼠标移动或摄像机轻微旋转时,不同深度的点移动速度不一样,近处速度快、远处速度慢,这就是经典的“视差滚动”效果。说得直白点,它是把一个平面做成了带有起伏的浮雕,再用动态视差把它激活。
1.2 它不是建模工具,是一条“伪 3D/深度合成”流水线
很多人第一次看到“图片转 3D”会把思路往 3D 建模上靠,比如用 Blender 重新描一遍轮廓,再用生成式 AI 补全侧面、背面。但 img2threejs 从头到尾就不是这条路。它不会生成一个可以放进 Blender 里继续编辑的可导出网格,也不会给你一个带贴图的 GLTF 文件,它生成的是“实时渲染的深度网格 + 原图像纹理”的组合。
这种做法的本质是一种 2.5D 效果,你也可以叫它视差 3D、浮雕 3D、伪 3D。好处非常明显:计算量小、浏览器直接跑、不需要 GPU 工作站,一张图十几毫秒就能完成网格初始化。坏处也很直接:它不能让你绕过障碍物看到物体的背面,只能对着一个方向看。如果是需要完整 360 度查看产品时,这套方案就不适用。但如果你只是想要“一张图动起来”的视觉冲击力,这套流水线是成本最低、效果最稳的选择。
1.3 为什么它能攒到 8.7k Star
开源项目能冲到 8.7k Star,通常不是因为技术无人能及,而是因为它切中了一个高频需求:内容创作者希望你手里那张漂亮的画、产品图、同人图,能够“活”过来。做电商主图的想做动态展示,做个人主页的想放个能转的封面,做数字藏品收藏的想把自己的藏品做成可交互的藏品页。img2threejs 正好把这些人的门槛拉到了最低。
另一个原因在于它的设计思路非常“程序员友好”。普通的图片转 3D 工具大多是一个黑盒网页,上传图片等结果,参数改不了、逻辑看不见。而 img2threejs 把整个流程拆开,每一步都是独立的模块或函数,你想替换深度估计方式就替换,想改网格细分密度就改密度。这种可编程、可介入、可定制的感觉,对开发者来说吸引力是巨大的。它不像一个单纯的“玩具”,更像一套可以不断往上加东西的“流水线骨架”。
2. 把“模型生成”拆成一条代码流水线
2.1 整条流水线从输入到输出
整个流程可以概括成这样:
- 输入一张图片(URL、响应头允许跨域的图片)
- 通过深度估计逻辑,生成一张灰度深度图(亮度或特定值表示远近)
- 创建一个平面 BufferGeometry,并根据需要细分(比如横竖各 128 份或 256 份)
- 逐段把深度图映射到网格顶点上,沿法线方向偏移 Z 轴
- 把原图作为漫反射纹理贴回网格
- 设置摄像机、灯光和鼠标交互(悬停视差、拖拽旋转、自动旋转)
- 进入渲染循环,每一帧根据鼠标位置更新相机或网格的偏移量
这条流水线的核心洞察在于:深度图才是灵魂,网格只是载体,材质负责还原颜色,交互负责激活立体感。这也意味着,任何一步都可以单独替换。比如你想让效果更好看,不用去改 Three.js 渲染逻辑,只要把深度图换成由 AI 模型估算出来的真实深度图就行。这个“可替换性”就是它和普通 demo 最大的区别,也是“代码流水线”这个表述的真正含义。
2.2 每个环节在做什么、为什么这么拆
先看深度图估计。这是整个流程里最容易出效果、也最容易出事故的一步。最简单的做法是拿图片本身的像素亮度当作伪深度:亮的地方凸起,暗的地方凹陷。这种方法对背景干净、有明确主体的图立竿见影,比如一个明亮的灯光主体配黑色背景,立刻就会有向外冲的感觉。但缺点也很明显,遇到复杂纹理的照片时,衣服上的花纹、树叶的阴影都会被错误地当成深度起伏,画面会变得非常“脏”。成熟的方案是用 MiDaS、Depth Anything 这类单目深度估计模型,它们能从一张彩色图里直接预测每个像素的相对深度,拿来当深度图用,效果要自然得多。
然后是网格细分。想象一下,如果你想在一个平面上折出山峦起伏,就必须先把平面切割成足够多的小三角形。三角形太少,效果就像纸折的棱角,不够圆润;三角形太多,顶点数量飙升,性能直线下降。实测下来,对于一张展示尺寸大约 1024 像素的图,把平面切成 128x128 段已经能获得相当细腻的浮雕效果,256x256 几乎是浏览器性能的甜点区,再往上就需要用 gpu 来做顶点更新才扛得住了。
接着是顶点位移。这一步的技术原理并不复杂,复杂度更多在于如何用深度图采样。如果你的深度图是一张和原图等大的灰度纹理,那你可以直接以原图的 UV 坐标去采样深度纹理,拿到一个 0 到 1 之间的值,然后映射到你期望的最大位移范围,比如-1.2到1.2。注意这里的位移方向和单位需要跟你的场景尺寸匹配。在实际项目中,深度强度系数(depthStrength)通常会做成一个可调参数,方便在不修改几何体的情况下整体增减浮雕感。
材质和交互则是最后的点睛之笔。原图作为map贴到网格上,立体感已经出来了,但如果没有灯光,它看起来就像一张被揉皱的纸。一般我会加一个基础的方向光和一点环境光,让凹槽和凸起因为有阴影而更有体积感。交互上,最常用的做法不是真的让网格转动,而是根据鼠标在页面上的相对位置,给相机加上一个轻微的角度偏移,再配合阻尼插值,画面就有了“轻轻转动”的丝滑感。也有人直接把网格旋转,但这样做容易穿帮,因为网格边缘是断的,转多了会看到侧面和背面,不如只让相机小幅偏移来得雅观。
2.3 为什么不直接用 CSS 的 perspective
看到这里你可能会问:CSS 的transform: perspective(...) rotateY(...)不是也能让图片倾斜旋转吗,为什么还要费劲用 Three.js 搭流水线?
区别就在“深度”这两个字上。CSS 3D transform 只能把整个图片当成一个刚性平面,做整体透视变换。不管你怎么旋转,画面里面的所有像素都是被钉在同一块玻璃板上平移的,不会有“前景的桌子比背景的墙移动得快”这种层级差异。而 img2threejs 这种基于顶点位移的方案,每个像素实际都在不同高度上,高处的点随着视角变化会天然遮挡低处的点,配合光照还可能出现实时阴影变化。这种微妙的层次变化,才是 3D 观感真正的来源。
另外一个被忽略的点是:CSS 方案很难继续往场景里加东西。我如果想把这张 3D 图片放进一个包含粒子系统、多个物体、镜面反射的完整 Three.js 场景里,CSS 就完全无能为力了。代码流水线的价值就在于,你的 3D 图片不再是孤岛,它可以和场景里其他物体共享同一套坐标系、同一盏灯、同一个渲染循环,随时可以与业务逻辑融合。
3. 亲手跑通一次“图片变 3D”
3.1 准备工程:纯静态最简单
我也见过有人把 img2threejs 集成到 React、Vue 项目里,但从跑通的角度来说,静态页面最省心。你只需要一个能存文件的目录和一个简单的 HTTP 服务。之所以要 HTTP 服务,是因为浏览器安全策略不允许file://协议下跨域读取图片并处理像素数据,所以你直接双击 HTML 大概率会报错。
我的建议是,先建一个空目录,在里面放一个index.html和一个main.js,然后用任意静态服务器跑起来。Node 环境下最轻量的是npx serve,Python 用户可以用python -m http.server 8000。这个阶段不引入构建工具,最能看清整条流水线的原始结构。
3.2 最小示例代码:从 Canvas 算深度到 Three.js 重组
下面这套是我按常见实践写的一个极简实现,核心思路是“先画图取灰度,再改顶点”。这里我把深度图估计简化成了图片亮度映射,方便你理解流程;后续你想接入正经的深度估计模型,换掉getDepthFromImage这一段逻辑就行。
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>img2threejs 快速验证</title> <style> body { margin: 0; overflow: hidden; background: #111; } canvas { display: block; } </style> </head> <body> <script type="importmap"> { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/" } } </script> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const PATCH_IMAGE_URL = 'https://your-image-host.com/your-image.jpg'; const SEGMENTS = 160; const DEPTH_STRENGTH = 1.4; // ---------- 1. 用图片生成伪深度数据,主要靠 Canvas 读像素 ---------- function getDepthDataFromImage(img, width, height) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); // 注意:如果图片来源不允许跨域,这里会被浏览器安全策略拦住 const { data } = ctx.getImageData(0, 0, width, height); const depthData = new Float32Array(width * height); for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; // 把颜色转成灰度,再归一化到 0~1,亮部视为近处 depthData[i / 4] = (0.299 * r + 0.587 * g + 0.114 * b) / 255; } return depthData; } async function init() { // ---------- 2. 创建 Three.js 基础场景 ---------- const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100); camera.position.set(0, 0, 6); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); new OrbitControls(camera, renderer.domElement); // ---------- 3. 加载原图,并取得深度数据 ---------- const textureLoader = new THREE.TextureLoader(); const texture = await textureLoader.loadAsync(PATCH_IMAGE_URL); const img = texture.image; const depthWidth = 256; const depthHeight = 256; const depthData = getDepthDataFromImage(img, depthWidth, depthHeight); // ---------- 4. 创建细分平面,逐顶点设置 Z 偏移 ---------- const geometry = new THREE.PlaneGeometry(3.2, 3.2 * (img.height / img.width), SEGMENTS, SEGMENTS); const positionAttr = geometry.attributes.position; const uvAttr = geometry.attributes.uv; for (let i = 0; i < positionAttr.count; i++) { const u = uvAttr.getX(i); const v = uvAttr.getY(i); const px = Math.floor(u * (depthWidth - 1)); const py = Math.floor((1 - v) * (depthHeight - 1)); const depth = depthData[py * depthWidth + px]; const offset = (depth - 0.5) * DEPTH_STRENGTH; positionAttr.setZ(i, offset); } geometry.computeVertexNormals(); // ---------- 5. 把原图贴回网格,加一点灯光让起伏可见 ---------- const material = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(2, 3, 4); scene.add(dirLight); // ---------- 6. 渲染循环 ---------- function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate(); window.addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); }); } init(); </script> </body> </html>这段代码运行后,你就能看到一张静态图片变成了一个可以被 OrbitControls 拖拽旋转的浮雕。不过说实话,默认的 OrbitControls 虽然方便,但在产品展示场景里会显得太“重”,因为它允许用户 360 度旋转,容易把伪 3D 的穿帮暴露出来。我更推荐换成“鼠标悬停视差”模式:相机随鼠标微小偏移,加一个缓动,体验更轻巧,也更有高级感。
3.3 高层封装:把参数变成配置,把逻辑变成函数
如果你在 GitHub 上直接翻 img2threejs 相关仓库,会发现大家通常都会做类似的封装:输入一个options对象,字段包括imageUrl、depthStrength、segments、autoRotate、interactive等。底层不管用老版本的THREE.ImageUtils,还是新版TextureLoader,核心逻辑都绕不开我刚才说的流水线。
我个人的建议是,不要只停留在复制粘贴上。你可以学它的架构,把自己项目里的流程也拆成三个函数:createDisplacementFromImage(imageOrDepthData)、buildDisplacementMesh(texture, depthData, config)、mountInteraction(scene, camera, config)。这样以后你想换深度图来源、换交互方式、甚至换成 WebGPU 渲染,都只需要动一个函数。这就是“代码流水线”思想对你日常工作最有价值的地方:不是某个项目多厉害,而是它教你怎样把复杂的渲染流程切分成边界清晰的模块。
3.4 参数调整的实战经验
- 深度强度(DEPTH_STRENGTH):我建议从 1.0 起步,往 2.0 方向缓慢加。太大会把人脸横截面拉得像悬崖峭壁,太小又看不出凸起。如果图片里有大面积天空或纯色背景,可以把背景区域的深度值主动压低,防止背景也跟着鼓起来。
- 网格段数(SEGMENTS):移动端建议 100 以下,桌面端 160 到 220 完全够用。段数翻倍,顶点数接近四次方增长,肉眼可能看不出区别但帧率会明显下滑。
- 灯光方向:默认方向光从左上打下来比较符合阅读习惯。如果想强调某些物体的轮廓,可以加一个很弱的背光,让边缘高光更明显。
- 纹理尺寸:没必要用原始 4K 大图。现代 WebGL 对纹理最敏感的是纵横比和像素尺寸,我通常把图片限制到最大 2048 宽,体积小、加载快,视觉上几乎没有损失。
4. 我实际踩过的坑与排查方法
4.1 跨域问题:canvas 被污染
这是新手最容易摔的一跤。我用图床上的图片测试时,逻辑看起来完全没问题,但getImageData()就是抛出安全错误:Unable to get image data from canvas because the canvas has been tainted by cross-origin data。原因是浏览器为了安全,不允许在 Canvas 里读取跨域图片的像素数据。
解决办法是在加载图片时指定crossOrigin = 'anonymous',并且图片服务器必须返回Access-Control-Allow-Origin响应头。TextureLoader 可以这样设置:textureLoader.setCrossOrigin('anonymous'),但注意有些图床虽然允许加载图片,却不允许跨域读取像素,这时候只能把图片下载到本地或自己的后端,再通过同源路径访问。
4.2 file:// 协议下无法运行
如果你直接双击 HTML 文件,浏览器地址栏是file:///...,TextureLoader 加载本地图片时会触发 CORS 限制,Modules 也可能因为安全策略被阻止。这不是代码的错,是浏览器故意的。我建议养成一个习惯:所有涉及 WebGL、Canvas 像素操作的小项目,一律先起一个本地服务。
用 VS Code 的话,装 Live Server 插件,右键点击 HTML 选择“Open with Live Server”就行。用 Node 的,npx serve只需要几秒。这个动作看起来小,能省掉一半的调试时间。
4.3 伪深度算法容易引发“橡皮泥”感
用亮度做伪深度时,如果原图有大量复杂的明暗纹理,最后生成的网格会像一团被捏过的橡皮泥,脸的鼻梁可能没问题,但脸上的高光阴影会变成整体位移的一部分,导致五官扭曲。这是亮度假深度最大的坑。
解决思路分两层:第一层,优先用专门的深度估计模型生成深度图,不要自己写亮度映射。第二层,如果条件不允许,那就对深度图做后处理,比如高斯模糊去掉纹理细节,再加大深度强度的阈值,让大的轮廓得到保留,小的纹理被磨平。很多看起来自然的效果,背后并不是模型多聪明,而是把深度图做了“磨皮”。
4.4 性能优化:顶点更新与纹理大小
顶点位移是一次性的 CPU 计算,只在初始化时跑一次,之后渲染时不需要重复改动。所以就算段数到 200x200,初始化 4 万个顶点也不算特别夸张,真正怕的是每一帧都在更新顶点位置。如果你要做类似“呼吸起伏”的动画,频繁改变 position 属性,切记在修改后设置geometry.attributes.position.needsUpdate = true,并且把矢量计算的中间变量尽量复用,减少 GC 压力。
纹理方面,如果图片原尺寸超过 4096,GPU 解码和采样都会吃力。除非画面内物体占屏幕面积很大,不然 2048 是性价比最高的上限。如果追求极致流畅,可以在加载纹理时开启texture.generateMipmaps = true,并设置texture.anisotropy = renderer.capabilities.getMaxAnisotropy(),能有效减少俯视时的摩尔纹。
4.5 常见报错速查表
| 报错现象 | 可能原因 | 处理方法 |
|---|---|---|
Failed to fetch图片加载 404 | URL 错误或防盗链机制 | 换图床,检查控制台 Network 里图片请求状态 |
canvas has been tainted | 跨域图片被 Canvas 读取 | 设置crossOrigin,需要图片服务器允许跨域 |
THREE.BufferAttribute needs to be defined | 网格 geometry 初始化顺序不对 | 确认在修改 position 前geometry已创建,且顶点数量匹配 |
| 画面全黑或只看到纯色 | 灯光、材质、相机位置组合问题 | 先加 AmbientLight,再调整 DirectionalLight 位置 |
| 画面出现大面积三角形棱角 | 网格细分段数太少 | 增加SEGMENTS,比如从 60 提到 160 |
| 移动端卡顿掉帧明显 | 段数过高、纹理过大、没有限制像素比 | setPixelRatio(Math.min(devicePixelRatio, 2)),降低段数 |
这张表是我自己边做边记的。大多数问题都不是算法多难,而是 WebGL 生态里约定俗成的那些“小暗礁”。提前知道这些,能把试错成本压缩到一顿午饭的时间。
5. 从代码流水线看后续扩展的方向
5.1 把流水线接到 AI 生图工作流
现在很多内容创作者会把 Stable Diffusion、Midjourney 之类的 AI 生图工具生成的图片,直接喂给这条流水线。典型玩法是先用生图提示词产出好看的视觉,再丢进 img2threejs 生成动态展示。相比传统 3D 建模,这种组合最大的价值在于“快”:从想法浮现到动态页面落地,可能只用十分钟,而传统流程里光建模和贴图就够折腾大半天。
我试过之后,个人觉得最值得花时间的是深度图那一步。如果只用亮度伪深度,AI 图里那些精美的光影细节反而会成为噪声。这时不妨把 AI 生成图分拆成“色彩图”和“深度提示图”,比如用 Depth Anything 生成深度图后再做一次局部修正,效果会有质的飞跃。如果你手头有现成的深度估计服务,直接通过 API 接入流水线,那张 AI 图就能变成动态展示页中的主角。
5.2 变成批量生产工具
代码流水线的另一个好处是天然适合批处理。你可以把整个流程写成 Node 脚本,接收一批图片路径,逐张生成深度网格并导出三个文件:HTML 页面、JSON 深度数据、缩略图。配合 GitHub Actions 或者你自己的服务器,每来一张新图就跑一次任务,产出一个新的展示链接。我见过有人拿这套思路做数字藏品展示平台,藏品图片上传后自动生成可交互的 3D 展示页,用户进来后里里外外转一圈,体验比单纯的静态缩略图强太多。
当然,批处理的时候要考虑资源占用。比如给 1000 张图同时生成 256 段网格,内存会有些紧张。稳妥的做法是串行处理,每张图生成完后主动geometry.dispose()、material.dispose(),把 GPU 资源还回去。
5.3 未来的真正升级方向
从技术演进角度看,当前这种“2D 图片 + 深度映射”的方案只是过渡形态。将来的升级方向主要有三个:第一,深度估计模型从单目深度走向多视角预测,能得到更可靠的空间关系;第二,渲染从 Three.js 的 WebGL 走向 WebGPU,网格密度和顶点数量可以有一个数量级的提升;第三,从“伪 3D”走向“真 3D 模型生成”,比如直接基于图片做体积重建、点云网格化、纹理烘焙,输出可编辑的 GLTF 资产。
但我不建议一开始就追新概念。对绝大多数业务场景来说,稳定、轻量、易维护的伪 3D 流水线已经能覆盖 80% 的“让图动起来”需求。与其追求高深算法,不如先把图片加载、深度图处理、网格位移、交互缓动这套基本功练好。任何时候,把基础流水线玩得滚瓜烂熟,都比挂一堆花哨依赖来得踏实。
最后再分享一个小技巧:当你在调深度强度时,不妨把场景灯光调暗一点,这样你能明显看到每个顶点凸起的光影变化,而不是被耀眼的贴图分散注意力。调完深度再恢复灯光,你会发现最终效果比一直盯着完整画面反复试要精准得多。这套“拆开调参”的思路,其实就是代码流水线带给我的最大启发:如果你想控制一样复杂的东西,先把它拆成独立的小环节,每个环节单独调舒服了,再组合起来,整个效果自然不会差。