ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯CSS+JS打造3D立体旋转相册:原理、公式与避坑

纯CSS+JS打造3D立体旋转相册:原理、公式与避坑

做前端开发的,尤其是平时喜欢折腾CSS动画和JavaScript特效的,应该都刷到过那种类似走马灯一样、图片环成一圈旋转展示的3D相册。第一次看到的效果确实挺惊艳的,但不少人的第一反应是:这得多复杂的代码?用什么库?Three.js全家桶?

其实不用。纯CSS配合几十行原生JavaScript就能做出来,核心就两个东西:transform-style: preserve-3d加一个定时器。原理没你想的那么玄乎,但坑也不少。这篇就把我从零实现这个“3D立体旋转相册”的完整过程、参数怎么算、踩过什么坑,一次性说清楚。

1. 项目思路拆解:先用两个W搞明白核心逻辑

1.1 这个效果的本质是什么

3D旋转相册,说白了就是把一堆图片摆在3D空间里,围成一个圆柱或者球面的形状,然后让这个“圆柱”或者“球”绕着某个轴转。看到的是“相册在旋转”,本质上是相机没动、图片在转,或者图片没动、坐标系在转。

这玩意儿最常用的实现手段就是依赖CSS的3D变换体系。你需要记住三个关键属性:

  • perspective:视距,简单理解为人眼离屏幕的距离,决定了3D效果的“立体感”强度。
  • rotateY():绕Y轴旋转,让图片“侧过身”去,排成一个圈。
  • translateZ():沿Z轴移动,把图片“推”到距离圆心一定距离的位置。

关键点来了:这几张图片并不是先排好位置再旋转的,而是“先旋转、再推进去”。每张图片都先绕着Y轴转了一个角度,然后沿着它当前朝向的那个方向平移了同样的距离,这样它们首尾相连就围成了一个圆环。

1.2 为什么不用Three.js或者Canvas

我知道很多人一看到“3D”就想着上WebGL。但这类纯展示型的相册,用WebGL是大炮打蚊子,还得引入额外的库增加体积。CSS 3D的优点是:

  • 原生支持,不需要任何依赖,一个HTML文件写完直接浏览器打开就能跑。
  • 图片本身就是普通的<img>或者DOM元素,点击、拖拽、加文字描述都方便。
  • 性能足够应付几十张图片的旋转场景,浏览器会帮你做硬件加速,比很多人想的流畅得多。

当然你要做上千张图片的粒子化3D效果,那CSS 3D确实顶不住,但相册场景几十张完全没问题。

1.3 选型的坑:需要规避的三个方向

先说几个我试过之后直接放弃或大幅调整的方案,省得你走弯路。

第一个坑是三维立体旋转用纯CSS做“交互拖拽”。鼠标拖拽调整旋转角度,如果直接改rotateY的实时值,会有一个问题:角度是线性的,鼠标移动多少度,图片就转多少度,看起来生硬。更麻烦的是,如果相册在自动旋转,你一拖它就立刻跳变,体验很糟糕。所以最终我采用的是“自动旋转 + 手动微调叠加”的方式,拖拽时暂停自动旋转,松手后继续。

第二个坑是图片背面透视。当你把8~10张图片围成一个圈,旋转到某些角度时,你会直接看到图片的背面,如果不加处理,它是反的(镜像),而且会透出后面的内容,观感非常奇怪。这个问题后面在“避坑”小节里细说,这里你只需要知道backface-visibility这个属性是必须的。

第三个坑是外层的容器旋转。很多教学会把整个外圈容器直接做rotateY旋转,这样图片会跟着整体转。但这么做有个副作用:如果这个外圈容器里还有其它装饰元素,它们也会被一起转得歪七扭八。所以我建议只控制一个单独的“旋转层”,把图片列表放进去,其它UI放外层。

2. 先把3D空间搭起来:布局与关键CSS

2.1 HTML结构的最小骨架

我用的是最精简的三层结构:

<div class="stage"> <div class="carousel" id="carousel"> <div class="cell"><img src="1.jpg" alt="照片1"></div> <div class="cell"><img src="2.jpg" alt="照片2"></div> <div class="cell"><img src="3.jpg" alt="照片3"></div> <!-- 更多 .cell --> </div> </div>
  • .stage:等同于一个摄影棚,它负责设置perspective(视距),决定你看这个3D世界的“景深”。
  • .carousel:照片旋转舞台。它本身是一个3D容器,里面所有图片的坐标都在这个容器里做变换。
  • .cell:每个图片的外层盒子,用于定位和翻转。

为什么中间非要套一层.carousel?因为如果直接把perspective属性放在图片排列的容器上,并且图片的rotateY和translateZ都在这个容器里生效,那么图片的“透视灭点”是相对的,但容器本身的旋转会和图片的变换耦合,导致当容器旋转时,图片的3D位置会跳。分离之后,.carousel只管“旋转整体布局”,每张.cell只管“自己在3D空间里的位置”,互不干扰,后续加交互也清爽。

2.2 核心CSS与透视原理

基础样式这样写:

.stage { width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; /* 视距:800px 是一个比较折中的值 */ perspective: 800px; perspective-origin: 50% 50%; overflow: hidden; } .carousel { width: 300px; height: 200px; position: relative; /* 关键:开启3D渲染 */ transform-style: preserve-3d; transition: transform 0.3s ease-out; } .cell { width: 300px; height: 200px; position: absolute; top: 0; left: 0; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 30px rgba(0, 0, 0, 0.6); /* 关键:隐藏背面 */ backface-visibility: hidden; -webkit-backface-visibility: hidden; } .cell img { width: 100%; height: 100%; object-fit: cover; display: block; }

这里需要理解perspective和preserve-3d的关系。

perspective是给整个“舞台”设置了一个观察视距,就像你站在距离桌面800px的地方观察桌面上的立体模型。数值越小,说明你站得越近,透视感越强(近大远小越夸张);数值越大,透视感越弱,甚至趋向于平行投影。如果你设成none,3D效果直接变成扁平效果,图片之间的前后关系是乱的。

perspective-origin: 50% 50%是设置你观察视线的焦点位置,默认就在中心,一般不需要改,但如果你想做从侧面看的视觉效果,可以调整这个值。

而preserve-3d是告诉浏览器:我这个容器内部的子元素,它们的3D变换不要被压平,保留真实的3D空间位置关系。如果不加,子元素的translateZ、rotateX等会全部失效或者被投影到2D平面上。

2.3 图片如何在空间里排一圈

这是整个相册的灵魂,也是唯一的数学点。

假设相册一圈有N张图片,那么每张图片之间的夹角是360 / N度。我们需要让第i张图片先绕Y轴旋转i * (360 / N)度,再沿Z轴方向平移一段距离,使它们首尾相接。

这个平移的距离怎么算?有一个很经典的公式:

半径 R = (图片宽度 / 2) / tan(π / N)

或者更通俗一点,直接用半角公式:

R = 图片半宽 / tan(半个夹角)

为什么是半角?因为当图片首尾相接围成多边形的时候,每张图片的“半宽”对应的角,恰好是圆心角的一半。如果不除以2,图片会互相重叠,错开间距,照片与照片之间会有明显的缝隙或重叠。我最初直接用了(图片宽度 / 2) / tan(360 / N),结果图片全都叠在一起,就是因为没除以2的问题。

举个例子:假设每张图片宽度为300px,N=8张,那每张夹角是45度,半角是22.5度。

R = (300 / 2) / tan(22.5°) = 150 / 0.4142 ≈ 362px

也就是说,每张图片需要沿Z轴平移362px。图片宽300px,平移362px之后,相邻图片才会刚刚好挨在一起,形成标准八角形。

如果想让照片之间稍微间隔一点儿,露出一点背景,可以把半径稍微调大,比如调到380px、400px,视觉效果会更透气。这个没有标准答案,纯看审美。

3. 让相册转起来:JavaScript核心逻辑

3.1 定位函数:动态生成图片的3D坐标

HTML里手动排8张图片的坐标,累死个人。而且图片数量一变,前面算好的角度全部要重算。既然是工程化实现,就必须让JavaScript来生成位置。

下面这段是核心逻辑:

function carouselInit(carousel, opts = {}) { const cells = Array.from(carousel.children); const count = cells.length; const cellWidth = opts.width || 300; const gap = opts.gap || 20; // 额外的间隔,像素 const angle = (2 * Math.PI) / count; // 每张的弧度 const radius = ((cellWidth + gap) / 2) / Math.tan(angle / 2); cells.forEach((cell, index) => { const currentAngle = index * angle; // 当前图片在圆周上的弧度 const deg = (currentAngle * 180) / Math.PI; cell.style.transform = `rotateY(${deg}deg) translateZ(${radius}px)`; }); return radius; }

注意这里radius的算法,我把gap也加了进去,相当于假定“每张图片的有效宽度”是图片宽度加间距,这样算出来的半径自然带着照片间距,省得后面再手动调。

这里有一个我踩过坑的地方:transform的书写顺序不能乱。必须是“先rotateY再translateZ”。因为CSS的变换是相对于元素当前坐标系进行的。你先translateZ(362px),再rotateY,那这张图会先被推出去,然后绕着原来的Y轴原地打转,根本不会出现在圆周上。而“先rotateY再translateZ”,图片是沿着旋转后的朝向向外平移,所以它才能正好站在圆周的切线方向上。

3.2 自动旋转动画的优雅实现

旋转动画有两种做法:requestAnimationFrame和CSS transition控制角度。我两者都试过,最终的方案是:用requestAnimationFrame按帧更新角度,适合自动旋转;用CSStransition做手动拖拽后的惯性过渡。

先说自动旋转的标准写法:

let currentAngle = 0; let isAutoPlaying = true; const speed = 0.2; // 每帧旋转角度(度) function autoRotate() { if (!isAutoPlaying) return; currentAngle = (currentAngle + speed) % 360; carousel.style.transform = `rotateY(${currentAngle}deg)`; requestAnimationFrame(autoRotate); } autoRotate();

这里speed = 0.2表示每帧(约16ms)转0.2度,一秒大约转12.5度,转完一圈360度大约29秒。这个速度比较舒缓,适合浏览照片。如果想让相册看起来更有“律动感”,可以改成0.35,大约17秒一圈,但别太快,太快会看得头晕。

3.3 让交互操控与自动旋转共存

纯自动旋转太单调了,加上鼠标拖拽才有意思。这里有个设计细节:拖拽时不要让图片固定跟随鼠标角度,而是让“目标角度”跟随鼠标变化,然后越接近目标角度越好。

我的做法是:

let targetAngle = 0; let isDragging = false; let lastMouseX = 0; carousel.addEventListener('mousedown', (e) => { isDragging = true; lastMouseX = e.clientX; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const delta = e.clientX - lastMouseX; lastMouseX = e.clientX; // 每移动1px旋转0.1度,3倍减速手感更顺 targetAngle += delta * 0.1; }); document.addEventListener('mouseup', () => { isDragging = false; });

然后在原本的autoRotate里,改成“当前角度向目标角度靠近”:

function autoRotate() { if (!isDragging) { // 匀速旋转 targetAngle += speed; } // 让当前角平滑逼近目标角 currentAngle += (targetAngle - currentAngle) * 0.08; carousel.style.transform = `rotateY(${currentAngle}deg)`; requestAnimationFrame(autoRotate); }

这个“当前角向目标角插值”的思路很关键,它带来了两个好处:

  • 鼠标拖拽时,相册不会瞬间“跳”过去,而是有一种阻尼感,更接近物理惯性。
  • 松手后,currentAngle会追赶targetAngle,自动从拖拽状态平滑过渡到自动旋转状态,不会出现“猛地一抽”的卡顿感。

系数0.08是阻尼因子。越大跟手越快,越小越平滑。我实测0.08是一个顺手值,如果你想让手感更“重”,可以调到0.05。

4. 进阶:加一点“3D立体”的味道

4.1 阴影与光影模拟

纯旋转的相册,3D感其实是有限的。要让图片看起来真的像在空间里旋转,需要一点光影辅助。

每张图片在旋转过程中,因为是正面朝向平面对着我们或者侧对着我们,它的明暗应该是变化的。正对着你的图片最亮,转到侧面的图片应该暗一点。

这个效果可以用JS动态判断每张图片的旋转状态,根据它与视角的夹角调整亮度:

const cells = carousel.children; function updateShadows() { const baseRad = (currentAngle * Math.PI) / 180; Array.from(cells).forEach((cell, index) => { const cellAngle = baseRad + index * angle; // 计算一个余弦值,范围[-1, 1] const cos = Math.cos(cellAngle); // 映射为亮度,正面亮,背面暗 const brightness = 0.35 + 0.65 * Math.abs(cos); cell.style.filter = `brightness(${brightness})`; }); }

这里用的Math.abs(cos)是因为背面虽然我们看不见,但在正反转换的瞬间,如果亮度突然从0.35跳到1,视觉上会有一闪的不自然感。取绝对值后,正反面都亮,只有侧面1/4角度位置会明显变暗,配合backface-visibility: hidden,刚好营造出“卡片转过侧棱”的感觉。

这个updateShadows函数要在autoRotate里每帧调用,实测对性能几乎没有影响,因为只是修改CSS的filter属性,浏览器底层会对这个属性做合成层优化。

4.2 点击交互:选中某一张放大

光看旋转还不够,点击某张图片让它“停下来放大”是刚需。传统做法是点击后把相册旋转到那图片正对屏幕的角度。

实现方式是记录每张图的绝对角度,然后计算需要额外转动多少度才能把它转到正前方:

cells.forEach((cell, index) => { cell.addEventListener('click', () => { const currentAbsAngle = currentAngle + index * (360 / count); // 寻找最小旋转路径 let diff = -currentAbsAngle; // 确保旋转角度在 -180~180 之间,选最近路径 diff = ((diff % 360) + 540) % 360 - 180; targetAngle += diff; }); });

核心逻辑就是“目标绝对角度 = 当前的旋转角 + 索引对应的偏移角”,然后让目标角度的变化量最小(不超过180度),这样相册永远是朝最近的路径转过去,不会绕一大圈。

试想一下如果相册已经转了330度,你想点第4张图,它的绝对角度是 330 + 135 = 465度,如果直接设目标为465,那相册会从330转到465,多跑135度但方向可能反了。用上面的取模逻辑,会转成 -15度之类的等效角度,保证旋转路径最短。

4.3 触屏支持

桌面端做完,移动端不跟上就落伍了。其实改动很小,把鼠标事件换成触摸事件即可:

carousel.addEventListener('touchstart', (e) => { isDragging = true; lastMouseX = e.touches[0].clientX; }); document.addEventListener('touchmove', (e) => { if (!isDragging) return; const delta = e.clientX - lastMouseX; lastMouseX = e.clientX; targetAngle += delta * 0.1; }); document.addEventListener('touchend', () => { isDragging = false; });

这里有个小细节:touchmove事件一定要绑定在document上,而不是carousel上。因为手指一旦在旋转过程中移动速度稍快,光标或者触点可能会滑出元素区域,绑在元素上就会中断拖拽体验。绑document才能在手指离开元素后继续捕捉拖拽位移。

4.4 增加轮播索引提示

图片多了之后,光靠肉眼很难判断自己看的是第几张。我在相册中央下方加了一排小圆点,根据当前组的朝向动态切换:

const dots = document.querySelectorAll('.dot'); function updateDots() { const halfAngle = angle / 2; // 找到当前离“正前方向”最近的图片索引 const normalizedAngle = ((currentAngle % 360) + 360) % 360; let activeIndex = Math.round((360 - normalizedAngle) / (360 / count)) % count; dots.forEach((dot, i) => { dot.classList.toggle('active', i === activeIndex); }); }

这里有个容易绕晕的点:相册整体是绕Y轴旋转的,正前方的索引号和旋转方向是反的。相册逆时针转,视觉上正前方的卡片索引是往前递增还是递减?我为了这个逻辑调试了半天,最后加了个360 - normalizedAngle才稳定下来。如果你在实现时也遇到小圆点位置错乱,绝大多数就是这里的正负号问题。

5. 常见问题与避坑总结

5.1 为什么图片没有3D效果,只有扁平的旋转

这是最经典的问题,排查顺序通常是:

  1. 检查.carousel有没有设置transform-style: preserve-3d,如果没有,所有子元素的translateZ都白搭。
  2. 检查是否在.stage上设置了perspective,注意是设置在“3D容器的父级”上,不是容器自身。
  3. 有些第三方CSS库或reset样式会设置transform-style: flat,会覆盖你的设置,就会出现“旋转但全部图层压扁”的现象。用浏览器开发者工具检查element的computed style最直接。

5.2 图片背面反向且透视

明明设置了backface-visibility: hidden,但有时还是能看到背面的内容倒过来?原因是你可能把backface-visibility写到了img元素上,而不是.cell容器上。图片本身在3D空间里如果没有组成独立的“卡片”,那么img的背面并不总是会被隐藏,因为父级的3D变换会影响子元素的背面判断。

正确做法:让.cell作为独立的3D卡片,overflow为hidden,然后把backface-visibility: hidden写在.cell上,这样整个卡片转到背面时,背面的图片和内容统一被隐藏,只看到一个空白的背面或者背面阴影效果。

5.3 性能卡顿的排查

如果图片数量在15张以上,旋转开始掉帧,先看两个点:

  • 图片尺寸是否过大。CSS 3D旋转时,每帧都在对图片做纹理映射和重绘,如果图片是4K原图,GPU吃不消。解决方式是用CSS或JS统一把图片缩放到实际显示宽度两倍以内,比如显示300px宽,使用600px宽左右的图片就够。
  • 是否对.cell使用了box-shadow这类属性。我实测box-shadow在3D变换中非常消耗性能,因为每一帧阴影都要重新计算。建议阴影只加在.stage外圈或者用一层半透明渐变模拟,而不是每张卡片都带阴影。

5.4 旋转相册周围出现“撕裂感”或“闪烁”

这个通常发生在图片数量较少(如6张)且旋转速度较快时,侧面和正面切换的一瞬间。因为图片切换时,3D变换的插值和backface-visibility判定边界可能差1帧,就会出现极短的闪烁或者边缘锯齿感。

我的应对方案:给.cell加一条接近背景色的边框,或者干脆加一个outline,让边缘在切换瞬间多一层遮蔽:

.outline: 1px solid rgba(255, 255, 255, 0.02);

这层透明度极低的白边人眼几乎感知不到,但足以把GPU合成时的亚像素缝隙盖住。

5.5 为什么拖拽后相册不听使唤、乱转

这个问题如果出现,十有八九是targetAngle和currentAngle的边界处理出了问题。比如拖到350度,自动旋转又给targetAngle加了5度变成355度,但currentAngle还是从某个负角度去逼近,差值就会变成300多度,插值公式会把相册往回猛甩一大圈。

解决办法就是统一做角度规范化。我封装了一个这样的函数:

function normalizeAngle(angle) { return ((angle % 360) + 360) % 360; }

然后在每次设置targetAngle之前先规范化,同时在计算diff时也规范化,保证两个角度始终在0~360之间比较,不存在负数或者超过一周的大数,插值也就稳了。

6. 一处关键参数速查表

我把最终稳定版的参数列个表,方便你直接抄去调参:

参数建议值说明
图片张数N8~12张少于6张太稀,多于16张会看不清单张
图片尺寸300x200或400x250宽高比3:2比较适合相册
旋转半径R(宽 + 间距) / 2 / tan(π/N)间距20px左右视觉比较舒服
视距perspective800px~1000px小于600px透视过强,大于1200px近乎平面
自动旋转速度0.2度/帧大约30秒一圈,适合浏览
拖拽阻尼系数0.08越小越“重”,0.05~0.15之间调
阴影亮度范围0.35~1亮度差太大会闪,太小的没立体感
卡片圆角12px~16px太小生硬,太大不像相册

这个表的参数都在我本机实测过,你可以直接用来当初始值,再按自己的图片尺寸等比微调。

7. 个人经验谈

做这个3D旋转相册,最让我受益的不是最后的效果有多炫,而是对CSS 3D坐标系的“手感”建立。以前我写rotateY和translateZ全靠猜,来回改数值试错,浪费了不少时间。自从用“先旋转后推进”的思路总结了公式之后,任何类似的环形布局,什么旋转标签云、环绕菜单,我都能手到擒来,核心套路都一样。

还有一点是想提醒你的:这种项目,鸿沟不在代码量,而在数学和空间想象力。你最好先在草稿纸上把8张图围一圈的俯视图画出来,标清楚每张图的朝向、半径、夹角,然后写代码才有方向感。我就是因为一开始没画图,纯靠脑子想,导致圆角算错、图片重叠、角度符号搞反,来回修了将近两三个小时。

如果你也是刚开始接触CSS 3D,建议不要一次就想做很多功能。先把静态布局和自动旋转做出来,再逐步加拖拽、光影、点击选中。每加一个功能,都对CSS 3D的机制理解深一层。愿意的话,你还可以再加一个rotateX方向上的微倾斜,让相册从上往下看也有一定倾角,立体感会再强一个档次。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进