尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

纯CSS 3D打造星空隧道Loading:不依赖Three.js的炫酷加载动画

纯CSS 3D打造星空隧道Loading:不依赖Three.js的炫酷加载动画 1. 项目概述与核心思路1.1 一个让人眼前一亮的 Loading真的有必要用 Three.js 吗先说说这个项目的起因。公司有个数据大屏项目需要在首屏加载时展示一个有质感的 Loading 动画。产品经理给的需求就一句话“要炫但别耽误加载。”我第一反应是上 Three.js毕竟 WebGL 做粒子系统太成熟了网上随便找找就是一堆星空隧道的案例。但问题来了——这个 Loading 只是整个项目里的一个配角为了它引入一个 60 多 KB 的 Three.js 核心库还要考虑 WebGL 上下文初始化、shader 编译、兼容性兜底一套流程下来光调试就得小半天。而且我们这项目还有不少老机器WebGL 出问题的时候 Loading 本身就先“崩”了这不是本末倒置吗于是我把目光转向了 CSS 3D。不用引入任何外部依赖浏览器原生支持渲染压力完全在 GPU 的合成器上——理论上只要能用 CSS 表达出来的效果它渲染起来比 WebGL 更轻量。关键是CSS 的transform-style: preserve-3d配合perspective完全可以模拟出“摄像机在隧道中穿行”的视觉效果。事实证明这条路走得通。最终实现出来的效果是一段由很多个发光圆环组成的星空隧道视角沿着 Z 轴不停向前推进圆环呈波浪形排列周围点缀着大小不一的“星星”。整体跑起来稳定在 60fps代码量也就三百行左右没有任何 JavaScript 动画库参与。这篇文章就把整个实现过程拆开讲一遍包括几个卡了我两天的细节问题希望你可以直接照着抄。1.2 这个项目适合谁来参考如果你是刚接触 CSS 动画的前端新手这个项目能帮你把transform、perspective、animation这几个概念串起来理解“浏览器如何把平面元素变成 3D 世界”的基本原理。如果你是已经有几年经验的前端工程师正在找一个纯 CSS 实现复杂视觉效果的案例这篇文章里的圆环分布计算、动画时序控制和性能优化技巧可以直接搬到你的项目里用。如果你只是恰好看到了这个效果觉得“挺好看”想实现一个同款但不想碰 Three.js——那你来对地方了我保证你跟着文章做一个小时之内就能跑出效果。2. 为什么选择纯 CSS 3D 方案2.1 先聊聊 Three.js 和 CSS 3D 各自的定位很多前端同学对 CSS 3D 的印象停留在“旋转卡片”“立方体翻转”这类效果上觉得它就是个玩具。但事实上CSS 3D 的能力边界远比想象中要宽。Three.js 的优势在于它是一个完整的 3D 引擎有场景图、材质系统、光照模型、后处理管线你可以用它做任意复杂度的 3D 场景。但代价是库体积大、学习曲线陡、运行时需要 WebGL 支持。而 CSS 3D 本质上是浏览器渲染引擎对 2D 元素施加 3D 变换它没有真正的“光照”和“材质”但它有几个不可替代的优点零依赖不需要下载任何库不存在版本冲突问题。声明式样式即代码动效逻辑直接写在 CSS 里团队协作时心智负担小。GPU 合成transform和opacity的动画走的是合成器线程不阻塞主线程的 JS 执行对首屏加载场景友好好。降级自然即使浏览器不支持preserve-3d比如 IE页面也只是退回平面加载动画不会直接白屏。尤其对于 Loading 这种场景它存在的意义就是“等待期间不让用户觉得无聊”这时候任何额外的 JS 逻辑都是在跟加载过程抢主线程资源。用 CSS 3D 做反而能把主线程完全让给真正的页面初始化逻辑。2.2 这个场景的物理模型一辆驶入隧道的车星空隧道这个效果拆开来看其实就是一个非常朴素的物理模型——摄像机不动场景中的所有物体沿着视线方向朝摄像机运动。或者说等效于你站在一个隧道口一辆一辆的圆环火车从深处向你驶来再穿过你身体远去。这个模型在 Three.js 里通常是这样实现的创建多个圆环几何体沿 Z 轴排布然后每一帧更新它们的位置。但在 CSS 3D 中我们完全没有“每一帧更新位置”的概念。CSS 动画是通过animation属性声明关键帧浏览器负责在中间插值我们只需要告诉元素“从哪到哪花多长时间”。于是核心问题变成了如何用有限数量的 DOM 元素表达出无限延伸的隧道感我的答案是“循环复用”。设计 N 个圆环让它们在 Z 轴上均匀排布然后让整组圆环沿着 Z 轴往前运动。当一组圆环完全穿过摄像机也就是视口之后把它们的位移重置回原点。换句话说我让一组圆环永远在 Z 轴上循环移动而不是创建几万几十万个圆环。这样既保证了渲染性能又实现了视觉上的无限隧道。这个思路和 CSS 不可能三角博弈的答案类似用空间换时间用数量换动态。先说清楚这个底层的循环逻辑后面所有代码都好理解了。3. CSS 3D 透视与坐标系到底是怎么一回事3.1 透视距离给视觉带来的变化CSS 3D 的透视效果是由perspective属性控制的它决定了“摄像机离屏幕的距离”。这个值越小近大远小的效果越强烈越大物体的大小变化越平缓。理解这些之前先明确一个坐标概念在 CSS 3D 变换中translateZ正值表示元素朝用户方向移动负值表示远离用户。当perspective属性加在父容器上时子元素的translateZ是相对于这个父容器的透视中心的。我项目里设置的透视值是800px。为什么是 800因为对于一个全屏 Loading 来说如果透视值太小圆环靠近时会有强烈的畸变看起来像鱼眼镜头如果太大圆环靠近时大小变化不明显隧道感就弱了。实测下来600 到 1000 之间是比较舒服的范围大家可以根据自己页面的大小微调。透视的另一个重要特性是元素的translateZ值一旦接近甚至大于透视值它就会“越过”摄像机这时候元素在屏幕上是消失的——因为它在你的脑后了。这个特性后来成了我做通道循环的关键技巧之一。3.2 preserve-3d 是如何让子元素拥有空间感的父容器设置了perspective只是让子元素有了透视投影但子元素之间如果想要有真正的空间关系比如一个元素在前面遮挡另一个元素或者在后面被遮挡那么父容器还必须设置transform-style: preserve-3d。这里有一个容易踩坑的点preserve-3d和overflow: hidden不能共存。一旦父元素设置了overflow: hidden子元素的 3D 空间感就会被“拍扁”——浏览器被迫降级为平面合成模式。这个特性让很多想做“3D 空间里的遮罩效果”的人抓狂。我最初的版本想在容器边缘做一个渐隐的遮罩结果一加overflow: hidden隧道瞬间变成了一堆平面圆环重叠3D 感全没了。后来的解决方案是不用overflow做遮罩而是用一个覆盖在隧道上面的radial-gradient背景层用 CSS mask 或者直接叠加半透明渐变实现边缘模糊效果。这个思路大家也可以记一下在 3D 场景需要遮罩的时候优先考虑加一层 2D 的叠加元素而不是去裁剪 3D 容器。3.3 让元素“抬头低头”rotateX 和 rotateY 的组合隧道中的圆环默认是垂直于 Z 轴的。如果只是让圆环往前飞视觉上会非常单调所以我在排列圆环时给每一环加了一些旋转角度。旋转的方式是让每个圆环绕自身 X 轴旋转一定角度同时保持整体隧道沿 Z 轴排列。这里需要补充一个常识元素的变换顺序不是随意排列的transform属性中的函数是从右往左应用的。比如transform: rotateX(15deg) translateZ(-100px);这个写法表示先将元素沿 Z 轴移动 -100px再绕 X 轴旋转 15 度旋转的中心是元素自身的原点。如果我颠倒顺序写成transform: translateZ(-100px) rotateX(15deg);效果就会完全不同——元素先旋转再移动旋转后的 Z 轴方向已经变了结果可能导致圆环飞向奇怪的方向。这个顺序问题在写循环排列时非常重要后面我还会再强调。4. 星空隧道核心实现从零写出完整效果4.1 搭建最基础的 HTML 骨架用 CSS 3D 实现这个效果HTML 结构非常简单就三个层级div classloading-scene div classtunnel idtunnel !-- 圆环们 -- /div /div.loading-scene是透视容器负责建立 3D 上下文.tunnel是所有圆环的父层我给它设置transform-style: preserve-3d让它成为一个可以承载 3D 子元素的空间容器。圆环则直接作为.tunnel的子元素。在这个结构里为了让隧道无限循环我实际上会创建两组圆环一组在当前可见区域排列另一组作为“替补”排在更远处当第一组全部运动到摄像机身后之后第二组无缝跟上。这个方案的实现方式是在 JS 中动态生成圆环节点并给它们设置交错排列的动画延迟。为什么不把所有圆环写在 HTML 里因为圆环数量较多每个节点的样式虽然有规律但手动写 40 个div加 40 段 CSS 样式会很啰嗦用 JS 动态生成并统一管理显然是更好的方案。这并不违背“没有 Three.js 之外的库”的原则——这里的 JS 只负责生成 DOM 结构和初始样式动画本身完全由 CSS 驱动运行时没有任何 JS 介入。4.2 动态生成圆环的 JS 逻辑下面是生成圆环的简化代码我加了一些注释const tunnel document.getElementById(tunnel); const ringCount 40; const ringDistance 180; // 每个圆环沿 Z 轴的间距单位 px for (let i 0; i ringCount; i) { const ring document.createElement(div); ring.className ring; ring.style.width 400px; ring.style.height 400px; // 计算每个圆环的位置和旋转形成一个波浪形排列 const z -i * ringDistance; // 从深处向外排列 const rotateY i * 18; const rotateX Math.sin(i * 0.5) * 10; ring.style.transform translateZ(${z}px) rotateY(${rotateY}deg) rotateX(${rotateX}deg); // 为每个圆环设置动画延迟让波浪看起来是连贯的 const delay (i * 0.12).toFixed(2); ring.style.animationDelay ${delay}s; tunnel.appendChild(ring); }圆环的尺寸我用了固定像素值实际项目里也可以用百分比或者视口单位取决于你的布局容器。关键在于transform的赋值顺序——先移动后旋转。如果顺序写反了圆环会绕自身中心旋转后再沿世界 Z 轴移动导致排列方式乱套。很多新手在一开始容易搞混的点就在这里。圆环自身的基础样式.ring { position: absolute; top: 50%; left: 50%; margin-top: -200px; /* 宽度/高度的负一半 */ margin-left: -200px; border-radius: 50%; border: 2px solid rgba(180, 220, 255, 0.6); box-shadow: 0 0 10px rgba(100, 200, 255, 0.3), inset 0 0 10px rgba(100, 200, 255, 0.3); transform-style: preserve-3d; backface-visibility: visible; animation: flow 6s linear infinite; }这里有个细节margin-top和margin-left设置为宽度和高度的一半负数是为了让每个圆环的中心对齐到舞台中心。如果不设置这个偏移圆环的定位点是左上角所有圆环都会挤在舞台的右下角。4.3 让圆环动起来的关键帧动画动效的核心是这段关键帧keyframes flow { 0% { transform: translateZ(1200px) rotateY(0deg) rotateX(0deg); opacity: 0; } 10% { opacity: 1; } 90% { opacity: 1; } 100% { transform: translateZ(-1400px) rotateY(360deg) rotateX(0deg); opacity: 0; } }我在每个圆环的基础静态 transform 之外又叠加了一段动画变化。这段动画做了三件事把圆环从 Z 轴正方向用户面前很远处移动到 Z 轴负方向用户身后远处实现在隧道中穿行的效果。在移动过程中让圆环绕 Y 轴旋转一整圈增加了动态感。控制透明度刚出现时淡入快离开视野时淡出避免在身后突然消失的突兀感。注意最终动画的 transform 和静态 transform 之间的关系。因为 CSS 动画在播放时会覆盖元素原本的transform属性所以我需要把静态位置的 transform 也完整地写进动画关键帧中。换句话说0% 和 100% 的关键帧 transform 值必须是在原有静态 transform 的基础上叠加动画位移后的结果。这是整个实现里最容易搞混的地方。比如某个圆环静态时的 transform 是translateZ(-180px) rotateY(18deg)那么 0% 关键帧就应该是translateZ(1200px - 180px) rotateY(0deg 18deg)100% 关键帧则是translateZ(-1400px - 180px) rotateY(360deg 18deg)。为了方便控制我实际是在 JS 生成时直接用最终值来拼装动画而不是让静态 transform 和动画 transform 分开存在。const targetZ -i * ringDistance; const baseRotateY i * 18; const baseRotateX Math.sin(i * 0.5) * 10; ring.style.animation flow 6s linear ${delay}s infinite; // 在 CSS 自定义属性中存基础值方便关键帧引用 ring.style.setProperty(--base-z, targetZ px); ring.style.setProperty(--base-ry, baseRotateY deg); ring.style.setProperty(--base-rx, baseRotateX deg);然后在关键帧中使用 CSS 变量keyframes flow { 0% { transform: translateZ(1200px) translateZ(var(--base-z)) rotateY(0deg) rotateY(var(--base-ry)) rotateX(var(--base-rx)); opacity: 0; } 10% { opacity: 1; } 90% { opacity: 1; } 100% { transform: translateZ(-1400px) translateZ(var(--base-z)) rotateY(360deg) rotateY(var(--base-ry)) rotateX(var(--base-rx)); opacity: 0; } }这里用了多个相邻的translateZ是因为 CSS transform 列表中的多个平移会累加rotateY(0deg) rotateY(var(--base-ry))等效于rotateY(var(--base-ry))这样写的好处是不需要为每个圆环重新拼一整套动画样式。其实更推荐直接在 JS 中为每个圆环生成独立的keyframes并通过style标签注入但在代码量不多的情况下用 CSS 变量更简洁维护起来也不容易出错。4.4 星星用 box-shadow 模拟满天星圆环是隧道的主体但一个只有圆环的隧道略显空旷。要有“星空”的感觉最简单的方案是往舞台上撒一些“星星”。我的做法是创建一定数量的小圆点元素让它们随机分布在 3D 空间里。随机分布怎么生成如果用 JS 给每个星星写死top、left、translateZ代码量会很大而且不好看。所以我换了一种思路——用一个元素复制多份 box-shadow 来生成。具体操作先创建 8 个“星星种子”元素每个元素本身是一个很小的圆形然后给它们每各设置多个box-shadow点再让它们整体参与隧道的流动动画。因为box-shadow不会受到transform-style: preserve-3d的影响我在另一个容器中单独铺满整个屏幕靠纯 2D 方式来运动这样反而更容易控制星星的疏密。最终星星的实现是使用一个覆盖全屏的 2D 容器背景使用多个radial-gradient模拟星空点然后让这个星空层以比隧道更慢的速度平缓移动制造出视觉上的“景深差”。这其实是用视差手法来偷懒但在视觉上效果非常自然。5. 关键细节打磨让效果“高级感”拉满5.1 颜色方案与发光质感隧道圆环的默认颜色我用了蓝白色系。背景色是深蓝到黑的渐变圆环的边框是淡青色带有半透明并且通过box-shadow模拟发光。发光效果的参数需要反复调box-shadow: 0 0 8px rgba(120, 220, 255, 0.55), 0 0 20px rgba(120, 220, 255, 0.25), 0 0 40px rgba(80, 160, 255, 0.15);小半径的阴影决定了边缘的锐利度大半径的阴影补充了辉光。三层叠加的效果比单层大阴影自然得多。如果浏览器性能较差可以只保留前两层减少合成时的模糊计算量。整个页面的背景我用了一个 CSS 渐变模拟星光闪烁background: radial-gradient(1px 1px at 20% 30%, rgba(255,255,255,0.8), transparent), radial-gradient(1px 1px at 40% 70%, rgba(255,255,255,0.6), transparent), radial-gradient(1.5px 1.5px at 60% 20%, rgba(255,255,255,0.7), transparent), radial-gradient(1px 1px at 80% 50%, rgba(255,255,255,0.5), transparent), linear-gradient(180deg, #020516 0%, #0b1133 50%, #020516 100%);这些radial-gradient位置都是写死的星星点加上背景渐变的底色让整个场景有了一个基本的“深夜星空”氛围。5.2 渐入渐出与加载文案的时机配合Loading 页面除了动画本身还需要文字提示一般显示“正在加载中…”。文案的出现和动画的循环之间会有一个节奏感的问题。我的处理方式是把文字放在隧道动画的视觉中心——也就是舞台的正中央给文字设置一个独立的动画周期让它和隧道的流动保持同一个持续时间也就是 6 秒。这样每次隧道从深处涌出新圆环时文字恰好也会完成一次“呼吸”。文字的呼吸动画.loading-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgba(220, 240, 255, 0.85); font-size: 16px; letter-spacing: 6px; text-transform: uppercase; animation: breathe 6s ease-in-out infinite; z-index: 10; } keyframes breathe { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }文字必须在隧道动画之上显示所以z-index: 10必不可少。另外注意文字本身没有 3D 变换它是覆盖在隧道视口上的 2D 层这样才能始终保持清晰。5.3 让隧道“动起来”的波浪排列前面 JS 里用到了Math.sin(i * 0.5) * 10来给每个圆环设置一个绕 X 轴的旋转角。这个波浪排列正是隧道看起来不那么死板的关键。如果不加这一行所有圆环都以同样的角度整齐向前飞行视觉上就是一条笔直的管状隧道很规整但不够惊艳。增加波浪后圆环之间有了错落的倾斜感看起来像一条“呼吸”的蛇在隧道中穿行。如果你想让波动更明显把振幅从 10 度改成 20 度甚至 30 度即可。但要注意振幅太大时圆环会发生严重的透视变形某些角度的圆环看起来会非常扁失去原本的圆环轮廓所以要适度。6. 性能优化与避坑指南6.1 为什么变形顺序会影响性能CSS 动画的性能问题和transform的写法有关。浏览器在合成每一帧时会将元素的transform变化提交给 GPU。这里有一个常见的认知误区translateZ和rotateY虽然都是 3D 变换但如果元素本身没有其他 3D 上下文浏览器在处理时依然会走 2D 合成通道性能会更差。给动画元素加上transform-style: preserve-3d或者至少给父容器声明perspective可以强制浏览器把合成器切换到高性能的 3D 路径。实践中我遇到过一种情况某个圆环动画偶尔会有肉眼可见的掉帧排查半天发现是因为我忘了给圆环的父元素设置perspective导致动画退化为平面合成浏览器每次都要执行更多的 CPU 重绘。加上perspective之后立刻恢复了流畅。这个坑值得记录下来。6.2 动画的数量与浏览器的承受能力我在最终版本中保留了 40 个圆环、8 个星星层。在普通笔记本上测试CPU 占用率大约在 5% 到 8%GPU 使用率稳定。如果把圆环数量加到 80 个帧率会有轻微波动在低端手机上尤其明显。关于数量我的建议是桌面端全屏 Loading40 个圆环以内星星层不超过 10 个。移动端全屏 Loading20 个圆环左右星星层 4 个以内。Loading 区域很小比如按钮内8 个圆环就够太多反而看不清。关键是在视觉效果和性能之间找到平衡点。我的 40 个圆环和 6 秒循环时间正好能在任何一个瞬间让屏幕上出现约 10 到 12 个清晰的圆环视觉密度刚刚好。6.3 降级方案并不是所有浏览器都支持preserve-3d比如很老的工具内嵌浏览器就有可能忽略这个属性。这种情况下的表现是3D 圆环全部被压平变成同心圆平铺在屏幕上动画依然会执行但所有元素重叠在一起观感很差。为了优雅降级我加了一个简单的特性检测const supports3D (() { const el document.createElement(div); el.style.transformStyle preserve-3d; return el.style.transformStyle preserve-3d; })(); if (!supports3D) { document.body.classList.add(no-3d); }在.no-3d的样式下我直接把隧道容器隐藏显示一张静态的星空背景图文字提示保留。这样即使 3D 不可用Loading 也不会是一团乱麻。7. 常见问题与排查思路7.1 圆环不显示只看到一堆线条这种问题多半是容器的perspective没设置正确或者父元素的高度塌陷了。.loading-scene必须有一个明确的宽高子元素的translateZ才能有可用的投影空间。排查思路先打开浏览器开发者工具的“3D 视图”Chrome 的 Layers 面板看看容器的高度是否正常。如果容器高度为 0圆环自然看不到。另外确认一下perspective是作用在父元素上而不是圆环自己上。作用在自身上的perspective只影响该元素自己的子元素不会影响它兄弟节点的 3D 投影。7.2 圆环动画是停的一点不动先检查是不是 CSS 动画被其他样式覆盖了。比如animation属性的简写在部分浏览器中如果写法不完整会被忽略。建议把动画相关的属性完整写出animation-name: flow; animation-duration: 6s; animation-timing-function: linear; animation-iteration-count: infinite; animation-delay: 0s;如果这样还是不转检查浏览器开发者工具里元素的计算样式看animation-name和keyframes是否被正确识别是否存在拼写错误。7.3 圆环在远处突然冒出来穿帮明显这是因为 0% 关键帧的opacity: 0设得太晚。如果圆环在translateZ(1200px)的位置时才突然显示用户在屏幕中央能明显看到一个圆环从无到有的瞬间。解决方式把关键帧改成0% { opacity: 0; } 8% { opacity: 1; }让圆环在刚出现在屏幕外沿时就淡入完成。远端的透明度渐变区域要足够长才不会被人眼捕捉到。7.4 为什么有的机器上圆环闪烁闪烁一般是backface-visibility导致的。元素在 3D 变换中如果旋转到了背面默认是显示背面内容的。当圆环旋转到某个角度浏览器对背面的渲染可能和部分 GPU 驱动的 bug 叠加产生闪烁。我的做法是在圆环上显式加上backface-visibility: hidden;绝大多数情况下能解决问题。但如果你的圆环本身要展示正反两面不同效果那就需要另想办法比如用两张半圆拼接代替整圆环。我在实际项目中还遇到过一种低频闪烁圆环的box-shadow比较大某些老显卡在blur半径大的阴影上合成会出错闪烁表现为“圆环外圈有一圈白色噪点”。这个只能通过调小阴影半径或者减少阴影层数来规避。8. 项目总结与进阶方向8.1 从这次实践中得到的核心体会做一个纯 CSS 3D 的 Loading 动画比我想象中需要更多对浏览器渲染机制的理解。比如transform-style的降级陷阱、backface-visibility的冷知识、动画延迟产生流动感的计算方式每一个细节都在考验对 CSS 规范的理解程度而不是单纯堆标签和样式。如果你本来就在考虑要不要为了一个 Loading 引入 Three.js我的建议是如果你的视觉模型只需要圆环、圆柱面、平面贴图这类基本几何形状且运动方式是匀速前进或旋转那么纯 CSS 3D 是完全够用的。等你的效果开始需要真实的纹理映射、逐顶点动画、复杂光照时再考虑 Three.js 不迟。8.2 后续可以怎么扩展这个星空隧道 Loading 的效果底层的动画循环和 3D 排列逻辑可以非常方便地扩展到其他场景。比如改成粒子雨效果把圆环替换成更小的 5px 圆形排列方式从圆环循环改成随机分布然后沿着 Z 轴方向飞。配合文字在隧道中间放置一段 3D 文本让文本和圆环一起流动可以做成品牌展示页的开场动画。交互化改造监听鼠标位置让透视点跟随鼠标轻微移动制造出一种“你在俯视隧道”的错觉。根据我个人经验这类纯 CSS 3D 效果最大的价值不是替代 Three.js而是提供了一种更亲民的可能性——让没有太重 WebGL 需求的普通前端项目也能低成本地享受 3D 视觉带来的产品质感提升。最后再分享一个小技巧代码里所有动画的时长和延迟我都习惯用 CSS 变量统一管理这样后期调数值特别方便你可以在自己的实现里提前规划好这一点会省去很多改样式的麻烦。
返回列表