第一次在公司评审会上看到那张球鞋图时,我下意识把手机歪了一下——图片里的鞋子居然也跟着偏了个角度,背景里那种压抑的方格纸朝相反方向缓慢偏移,鞋子的阴影也跟着“飘”了起来。整个会议室安静了大概三秒,然后大家都在问同一个问题:这到底怎么做的?明明只有一张图,为什么它就像“跃”出屏幕了?
答案其实不复杂。这个所谓的“3D空间图片”,核心不是建模,不是多视图重建,也不依赖结构光。它就是在一张2D图片上叠加了深度信息和多重分层,再用视差原理,让用户在视角移动时,画面里的不同位置以不同速度发生位移。眼睛被这个差异骗了,大脑就自动补出一个“深度”,于是照片“活”了。
这篇文章我会从技术选型、核心实现到性能优化、常见坑位,完整拆一遍这类功能在电商场景里是怎么落地的。如果你是前端、移动端或者图像算法方向的开发,或者产品经理想了解这功能为什么能提升详情页体验,都值得花几分钟往下看。
1. 先搞清楚:空间图片到底是个什么东西
1.1 从“图片能动”说起——视差效应
视差不是一个稀罕概念。你坐高铁看车窗外面,离你几米远的电线杆一闪而过,几公里外的远山却像钉在天边;火车在走,两者相对速度完全不同。你的大脑在这个信息里完成了深度推断,这就是所有裸眼3D玩法的心理基础。手机屏幕上显示的空间图片,本质上就是把物理世界的视差搬到了一张图上:商品离镜头近,背景离镜头远,当你转动手机或者拖动图片时,前景和后景的移动量不一样。
这张“能动”的图其实没有真实几何,它只是一张平面图在Z轴方向上被拆开、被安排到不同平面,然后通过视角变量驱动位置变化。说得更直白一点:我们看到的立体感是一种视觉错误,但这种错误恰恰是大脑最常用的深度解读方式。所以效果好不好,往往取决于视差的幅度是否合理、层次是否分明,而不是看你用了多复杂的3D引擎。
我在实际开发里会把“视差幅度”当成一个可调节参数来暴露给前端,而不是写死在代码里。因为同样的视差幅度,在手机竖屏和横屏、在低分辨率图和高清图上,观感差异非常大。一个简单的经验:视差偏移量按屏幕宽度的百分比计算,会比固定像素值更稳。
1.2 一张图变3D的关键:深度信息与多图层
一片叶子漂浮在照片中央,你是不知道它离地面有多远的。要让一张画在纸上的图像有前后关系,计算机必须知道两件事:哪些像素是同一个物体(分割),谁在前谁在后(深度)。深度信息落到工程上,就是一张和原图同尺寸的灰度图,每个像素的灰度值表示该位置的相对距离,越亮越靠前。
有了深度图,才可以进一步把原图拆分成“主体”和“背景”,再根据深度差异把背景拆成好几个层。实际落地时并不是逐像素重建点云,考虑到移动端图形渲染的性能,我们用了更工程化的做法:把画面按深度切成3到5个小平面,每个平面叠在Z轴不同位置,各自配一张带透明通道的纹理。渲染时,给每个平面设置不同的位移增益,透视关系自然就出来了。
这个思路和早年网页端流行的“视差滚动”一脉相承,但交互从单向滚动变成了自由拖拽、传感器驱动,复杂度完全不同。单向滚动只需要算一个固定的偏移量,自由视差要处理二维位移、旋转、阻尼回弹,还有陀螺仪数据和手势数据之间的融合,任何一个环节没做好,用户都会立刻觉得“假”。
1.3 为什么电商场景尤其适合这个技术
商品图大多满足两个特点:背景相对干净,主体居中且轮廓完整。这简直是深度估计和图像分割的绝佳输入。再加上用户点进详情页本来就带着“想看清楚商品细节”的动机,一个可以随着手势翻转、动态展示光影变化的商品图,远比十张静态摆拍更能缓解“看不到实物”的焦虑。
从业务角度看,这类效果最直接的价值就是延长用户在详情页的停留时间,多停留几秒,就多一分下单的可能。而且对潮流商品来说,用户买的很多时候不是功能,是“感觉”——鞋面上的光泽、表盘的纹理、背包的轮廓,这些细节在静态图上很难传递,但一个能转动的空间图片就能做到。
不过也要泼盆冷水:空间图片不是所有品类都适用。轮廓简单、纹理匮乏的商品,比如纯色袜子、基础款T恤,做出来效果很弱,用户拖几下就腻了。我们后来专门做了类目白名单,只对鞋、表、数码、箱包这类高感知商品开启,避免把平均数据稀释掉。
2. 技术方案选型:我们最终走了哪条路线
2.1 市面上的三条候选路线
拿到需求后,我第一反应是去开源社区找现成的空间图片SDK,结果发现大部分方案要么是给游戏用的重型3D引擎,要么是纯网页玩具,移动端性能和兼容性都没怎么考虑。于是我把可能的路线分成三类:
| 方案 | 实现成本 | 真实感 | 兼容性 | 适用场景 |
|---|---|---|---|---|
| 纯CSS 3D transform | 低 | 中 | 高 | 通用商品,默认推荐 |
| WebGL渲染 | 高 | 高 | 中 | 高精度商品、高端品类 |
| 预渲染动图/视频 | 高 | 高 | 极高 | 活动页炫耀级效果,但无法交互 |
先说纯CSS 3D transform。把处理后的图层放进一个容器,设置perspective,再用translate3d、rotateY旋转移位。这套方案最大的优点是无关GPU、无关WebGL,几乎所有WebView都支持,实现成本低、调试方便。缺点是切分层数有限,视角超过一定角度就露馅,背景层如果处理不好,低端设备上合成闪烁很难看。
再说WebGL方案。用顶点位移或者fragment shader对平面网格做真正的透视变形,可以做到任意角度下依旧保持正确的视差,还能加凹凸和镜面反射,观感最接近“真正的3D”。缺点是开发量高,要处理纹理、深度插值,还要考虑低端机GPU兼容、功耗和复杂的生命周期管理。除非团队里有写shader的熟练工,否则不建议一上来就碰。
预渲染动图/视频效果最好,但需要离线渲染,制作成本高、更新链路长,而且不支持用户交互,违背了我们“一张图自动生成”的目标,基本没有考虑。
2.2 我们的决策逻辑:不赌单个方案,用混合路线
最终的选择是“默认CSS 3D分层 + 高精度场景WebGL增强”的双通道。所有商品图先走同一套离线处理流程,产出分割结果和深度配置;客户端拿到配置后,绝大多数商品用CSS 3D方案渲染,只有少数需要精细展示的商品启用WebGL模式。这样做的原因很简单:要快,要稳,还要能撑住峰值流量。
我不太建议一上来就全量上WebGL。很多团队容易陷入“技术越高级体验越好”的误区,但移动端用户体验是个系统工程,一个WebGL渲染管道如果有百分之五的兼容性问题,带来的客诉会直接淹没那点视觉提升。渐进式上线、灰度切量,对一个面向C端的功能来说,是性价比最高的路径。
另外,还要考虑团队的技术栈惯性。如果你们前端团队对Three.js很熟,WebGL的成本可能没那么高;如果只是偶尔接触图形学,那CSS 3D方案几乎是最稳的选择。方案本身没有绝对的好坏,关键是能不能在你们的工程体系里稳定维护。
2.3 技术栈与基本架构
整体链路可以分成两段:服务端离线处理端和客户端实时渲染端。服务端主要负责调用分割模型和深度估计模型,输出标准化数据;客户端只负责消费数据,不参与AI计算。这样设计的好处是,AI计算集中在服务端,方便跑批、升级模型,客户端逻辑非常轻,可以做低端机降级。
数据结果我们设计成了一份JSON加几张图,类似这样:
{ "base": "product_123.jpg", "foreground": "product_123_fg.webp", "layers": [ { "url": "bg_wide_0.webp", "depth": 0.1, "blur": 2 }, { "url": "bg_wide_1.webp", "depth": 0.35, "blur": 1 } ], "shadow": { "opacity": 0.25, "offsetYPercent": -0.06 }, "maxAngle": 22, "maxOffset": 24 }base是原图兜底层,foreground是抠出来的前景透明图,layers是拆出的背景层,shadow配置让阴影随视角方向反向移动。前端拿到JSON后只做三件事:创建容器、按layers生成节点、监听交互事件去更新transform。整个渲染逻辑控制在200行以内的轻量模块,方便在App内嵌页面和普通H5之间复用。
3. 核心实现:三步把平面图“抬”出屏幕
3.1 第一步:主体分割,先把商品从背景里抠出来
分割是整条链路里影响体验最大的环节。用现成的显著性分割模型跑一遍,经常出现的问题有三种:商品和背景颜色相近时边缘抠破;鞋带、背包带这种细长结构被断成好几截;透明材质比如玻璃瓶、塑料包装被当成背景。我们调了两版模型后效果还不够理想,最后加了一个“后处理组合拳”:
- 显著性分割拿到初始alpha通道;
- 对商品类目做先验修正:鞋类用边缘检测补出鞋底轮廓,服饰类用形态学闭运算把领口袖口断裂处接上;
- 对alpha做一到两个像素的高斯模糊,同时用原图亮度作为引导,把半透明边缘收敛一下,消除白边黑边。
这里必须强调一个细节:前景输出一定要带原来的阴影。很多人为了追求“干净”会把阴影当成噪点抠掉,但影子和商品本体之间的相对关系,正是空间深度的重要线索。丢掉阴影,做出来的3D效果就像商品被硬生生贴到背景上,毫无漂浮感。
我对阴影的处理是把它单独作为一个半透明图层输出,不直接合进前景。这样前端可以控制阴影的透明度、模糊度以及和前景的相对位移,相当于多了一个调“立体感”的旋钮。你在CSS里甚至不需要额外的图片,可以用box-shadow或者radial-gradient模拟椭圆阴影,效果也相当不错。
3.2 第二步:深度图生成,让处理器知道谁远谁近
深度估计这个环节,我用单目深度估计模型MiDaS的轻量版做初版估算,再结合分割结果做后处理。为什么不用ZoeDepth这类更重的模型?因为商品图分辨率一般有几百K到两三兆,跑重型模型延迟和内存都不太可控,而我们需要的只是“相对深度”,不需要精确到厘米的绝对距离。
做法上,先让模型输出一个0到1的深度图,然后做三件校正:
- 把商品主体区域的深度值强制拉到0.9以上,背景区域按模型输出做归一化,但设置一个0.2的下限,避免白底场景下背景和主体一样“近”;
- 对深度图做中值滤波,去细小的噪点,防止拆层时单个像素跳变导致纹理解体;
- 重新映射回适合拆层的离散区间,比如5个深度级别,这样拆层时才不会出现“一层里既有近景又有远景”的尴尬情况。
深度图出来后,我建议直接可视化看一眼,这是最直观的验证方式。把深度图叠在商品图上做热力图,如果主体区域和背景区域颜色过度接近,那就说明模型没有识别出层次,需要调后处理参数。很多看似复杂的模型问题,最后都是在这个可视化环节被发现的,而不是靠看loss曲线。
3.3 第三步:视差渲染,把静态像素变成有深度的画面
这是客户端最核心的部分。等拿到分割图和深度图后,首先把背景按深度切成多层,每层按深度系数叠加在容器里;然后把商品前景放在最上方,应用一个缓慢的默认呼吸动画让画面动起来;接着监听touch/mouse事件,把用户手指的位移差值转换成视角偏移量,再换算成每层的translate3d值。
伪代码示意:
function updateView(dx, dy) { const ratio = Math.min(1, Math.sqrt(dx * dx + dy * dy) / MAX_DRAG); for (const layer of layers) { const depth = layer.depth; // 0 ~ 1 const offsetX = dx * depth * MAX_OFFSET; const offsetY = dy * depth * MAX_OFFSET; layer.el.style.transform = `translate3d(${offsetX}px, ${offsetY}px, ${-depth * 80}px)`; } }CSS一侧关键是perspective和transform-style。经验值:
.scene { perspective: 600px; transform-style: preserve-3d; } .layer-bg { transform: translate3d(0, 0, -60px); backface-visibility: hidden; } .layer-fg { transform: translate3d(0, 0, 40px); }perspective值越小,视差越明显,但过小会让物体变形明显;600px在手机竖屏上刚好,既有立体感又不会显得比例失衡。还要注意给transform加过渡缓动:用户松手后,所有层用0.2秒的ease-out回到初始位置,模拟“弹性归位”。这个微小交互细节会让人感觉图片是“活的”,而不是机械地移动。
3.4 尽量避免的“纯贴图”感:加光影与微动效
只靠多层位移,用户玩几下之后就会觉得这是一堆贴图在互相移动。要骗过眼睛,还必须补三样东西。
第一,动态阴影。给前景底部加一个半透明黑色椭圆阴影,当用户拖动时,阴影向视角相反方向移动,幅度约为前景的60%。这会让大脑觉得商品确实“悬空”了。阴影不一定要用图片,一个带模糊的CSS渐变就能实现,关键是它的移动方向和幅度要和前景相反,才能产生“离地”感。
第二,微光效。在背景层上叠一层很弱的高斯光斑,透明度做缓慢正弦变化,模拟环境光扫过画面的感觉。幅度控制在0.03以内,宁可让人注意不到,也不要闪瞎眼。这个效果可以用CSS keyframes或者shader里的时间变量实现,成本很低。
第三,反射高光。如果商品是鞋面、表盘、金属件这类有高反射的材质,可以在前景层上额外叠加一个PNG高光层,同样做视差位移,但只在视角角度超过阈值时逐渐显示。这个做法的真实感提升非常明显,代价是模型离线生成时需要多输出一张高光图。
4. 实战中的性能调优与体验优化
4.1 移动端交互:陀螺仪与手势,哪个为主
做一个会随设备转动的空间图片,听起来应该优先用陀螺仪,但实际做下来,陀螺仪的坑比想象中多。iOS 13以后,网页获取DeviceOrientation事件需要向用户弹窗授权,很多用户看到“是否允许访问传感器”会直接拒绝;Android部分机型陀螺仪数据漂移,转着转着画面就歪掉了。
我们最后定的策略是:手势拖动是基本面,陀螺仪是加分项。用户进入页面时默认是静态图,一旦手指开始拖动就进入交互模式,之后才尝试申请陀螺仪权限;如果几秒内没拿到权限,就完全退化为纯手势。交互阈值也要调好:位移小于3px不触发更新,避免手指微动导致画面抽搐;拖动结束后做一个惯性回弹,而不是立即静止,观感会柔和很多。
关于陀螺仪数据,还要注意低通滤波。原始DeviceOrientation事件的数据抖动很大,直接拿去驱动transform会导致画面高频晃动。我当时加了一个简单的一阶低通滤波,取最近几次数据的加权平均,画面就稳定了很多。这个滤波逻辑放在渲染循环外,避免每帧都做大量计算。
4.2 性能预算:图层数量、纹理体积与GPU压力
移动端渲染是有硬预算的。我们的经验值是一张空间图片控制在4到6个合成层以内。背景层的纹理用0.3到0.5倍分辨率生成,比如原图1500px,背景裁到600px宽就够了,超过这个比例,2K屏上也看不出多大区别,GPU压力却成倍增长。前景纹理保留原图分辨率,因为用户看商品细节主要看主体,这层不能糊。
合成时尽量用will-change: transform,把图层提升为独立合成层,避免每次变换都触发整页重绘。如果页面在滚动容器内明显掉帧,就限制只对当前可视区域内的图层做3D变换,或者干脆把perspective关掉,只做2D位移,性能提升立竿见影。
我用过一个很有效的调优手段:给场景加一个“帧率自适应”。在低端机上检测到连续几帧时间超过30ms,就自动下调视差幅度、关闭背景光效、减少图层数。用户感知到的只是效果“柔和”了一点,但帧率恢复平稳,这种体验比满血但卡顿好得多。
4.3 品质细节:边缘伪影、透明层闪烁与旋转渐变
这些细节做不好,再好看的效果也会变廉价。边缘伪影是最常见的:分割后的alpha不带羽化,位移一拉开就露出锯齿状白边,简直像用绿幕抠图没抠干净。我的处理是离线端对alpha做一到两个像素高斯模糊,并且做一次decontamination,把边缘像素的颜色从背景环境色里解耦出来。
透明层闪烁这个坑很隐蔽:多个带透明通道的图层叠在一个transform容器里,低端GPU上偶尔会在特定角度闪一下黑块。强制给每层加backface-visibility: hidden,或者用translateZ(0)主动建立合成层,基本能解决。
旋转渐变是指rotateY超过一定角度时,平面旋转的拉伸感会让用户瞬间出戏。所以控制器里要对视角做clamp,极限设在25度左右,并在靠近极限时用easeOutCurve增加阻尼,让画面像是被弹簧拉住,而不是硬转到极限。
4.4 降级策略与可访问性
功能上线之前就要想好降级方案。我在灰度阶段专门用了一批一两年前的千元机去测,发现有些机型在3D模式下掉帧严重,甚至出现页面滚动卡顿。于是做了一个运行时检测:如果设备内存小于某个阈值,或者WebView版本低于某个基线,直接不进入3D模式,只显示一张已经预处理好层次感的静态图,比如主体微放大加背景高斯模糊。
这个降级不是损失,而是保证基础体验。如果你让一个低端机用户等了500ms加载出效果,却换来卡成PPT的交互,用户不会觉得“高科技”,只会觉得App卡。守卫降级开关,比任何优化技巧都重要。
还有无障碍的问题。空间图片本质上是视觉交互,如果是给视障用户使用,至少要在图片的alt属性里说明商品名称和角度变化提示,并且保证纯手势模式下可以正常回弹,不要让画面卡在一个奇怪的角度。这些细节虽然不直接影响数据,但体现的是产品的基本修养。
5. 常见问题与排查实录
5.1 白底商品图深度估计翻车
这是最常遇到的模型问题。一张产品图是纯白背景,商品也偏浅色,深度模型会把背景和商品识别到同一个深度,结果3D效果整个是“平”的。排查思路其实很简单:别指望模型能处理所有边界情况,在深度后处理里直接根据分割掩码做硬约束——背景区域的深度值统一设成0.05以下,商品区域最低0.8。有了这个约束,即使模型输出质量一般,最终效果也不会翻车。
顺便说一句,深度估计模型的选型,不要只盯着精度指标。在实际业务里,我们更关心的是模型在不同类目商品上的稳定性。鞋、包、数码、美妆,它们的纹理和反光特性完全不同,一个模型很可能在某个类目上表现很好,在另一个类目上彻底摆烂。所以上线前一定要按类目抽样看可视化效果,而不是只看一个平均指标。
5.2 iOS WebView传感器权限收不到
我排过最傻的一个Bug:在iOS的WebView里拿不到DeviceOrientation数据,查了半天发现是授权请求时机不对。这个授权请求必须要发生在用户的点击回调里,直接写在页面onload里会被WebView拦掉,然后就没有然后了。正确姿势是在用户第一次点击图片时调用DeviceOrientationEvent.requestPermission(),然后立即绑定监听,并且把“允许访问传感器”的原因在UI上提前告知,让用户有个心理预期,授权通过率会高很多。
这个坑提醒我:iOS的传感器权限有严格策略,不是“能不能”的问题,而是“什么时候”的问题。一定要在用户最自然点击的场景去触发,弹窗文案也要尽量口语化,比如“开启后可以多角度查看商品”,而不是冷冰冰的“允许访问传感器”。
5.3 低端机掉帧与内存飙升
掉帧问题往往不是单一原因,而是一堆因素叠加。常见组合是:图层设了6层、背景纹理还是2K分辨率、滚动容器里同时存在十几个3D场景。排查时我习惯用浏览器开发者工具的性能面板跑一遍,看主要瓶颈在JS计算还是GPU光栅化。如果瓶颈在GPU,最优解就是降图层数和纹理分辨率,而不是去优化JS。
内存飙升通常发生在页面反复进出的时候。背景纹理用完后一定要调用纹理销毁接口,事件监听也要在组件销毁时移除。有一次我排查了半天,发现每次进入详情页都会新增一个Window-level的mousemove监听,页面退出没有移除,导致内存只增不减。这种基础问题最容易被忽略,却最容易引发线上故障。
5.4 分割后边缘发绿/发灰
这个问题出现在白色或灰色商品上最明显。原因很简单:分割网络输出的alpha是半透的,但半透像素的RGB颜色仍然是原背景混合后的结果,直接叠加到深色背景上就会露出“绿边”“灰边”。处理办法是离线生成前景时,用原图邻域不透明像素填充边缘,再做一次预乘alpha转换。
如果你们用的是现成的抠图服务,可以检查一下输出是否带“去色边”选项。很多开源库其实已经内置了这个能力,但默认不开启。另外,边缘色差和分割精度是两回事,分割精度再高,只要边缘像素的RGB没有解耦,色差就还会存在,这点容易被误解。
6. 上线后的数据表现与个人体会
6.1 从数据看价值:停留时长、点击率的影响
上线后我盯得最多的三个指标是详情页停留时长、首屏主图区的交互率和加购转化。从大盘数据看,支持空间图片的商品,详情页停留时长确实比纯静态图商品有可感知的提升,主图区域的交互率更是直接上了一个台阶。原因也好解释:静态图只能看,空间图片可以玩,一旦用户去拖拽、翻转,他就自然而然把商品的各个角度都看了个遍。
不过这个功能对细节丰富、轮廓清晰的商品增益明显,但对纯色、无纹理的商品几乎没有提升。如果整个类目不分青红皂白全都套用,数据会把平均效果稀释掉。正确的做法是设置类目白名单,把流量资源投给最需要立体展示的商品。
我后来复盘时最深的感触是:做这类体验优化,一定要有数据验证意识,不能停留在“好看”的层面。好看不等于有效,好看加有效才是我们真正追求的结果。
6.2 后续演进方向:从“单图空间化”到“多图融合”
单图的空间图片做到这一步,其实还只是用一张图做视差欺骗。再往前一步,就是把同一商品的多张角度图做空间拼合,让用户拖拽时可以从正面滑到侧面,甚至看到背面,这个体验就接近真正的“线上翻看商品”了。
再往后还能和AR试穿、AI导购结合,让商品先从屏幕里浮起来,再落到现实场景中。这些方向每一步都依赖更扎实的深度估计、视角合成和端上实时推理能力。对我们来说,单图的方案已经沉淀了一套完整的离线处理与在线渲染链路,后面要做的更多是基于这套链路去叠加新的交互模型,而不是推倒重来。
6.3 踩过几次坑之后的真心话
做这个项目的最大感受是:技术本身的难度没那么高,难的是把效果稳定地交付到用户手上。从分割、深度估计、图层合成到性能优化,每个环节单拎出来都有成熟方案,但它们组合起来之后,所有的边缘情况都会被放大——边缘伪影、兼容性、低端机性能、权限弹窗,任何一个细节没处理好,用户看到的就不是“高级”,而是“山寨”。
最后再分享一个小技巧:如果你准备在详情页里做类似效果,一定要让单张图能独立渲染、独立缓存,别把逻辑写死在单个页面组件里。这样后续无论是做商品视频、社区内容还是广告物料,都能直接用同一套SDK,一份投入,多处复用。这是我们在项目结束后回头看,觉得最有价值的沉淀。