构建3D无限轮播与响应式背景渐变
在本教程中,您将学习如何构建一个优雅的无限循环卡片滑块,使其感觉流畅、响应迅速且视觉连贯。关键理念是从活动图像中提取柔和、渐变的背景,并将其绘制到<canvas>上,从而在内容和背景之间创建无缝过渡。
概念与架构
在深入代码之前,让我们了解实现此效果的结构。轮播建立在两个协调的层上,它们共同创造深度、运动和色彩和谐。
前景(DOM):一系列绝对定位的.card元素排列成无缝的水平循环。每个卡片接收一个3D变换(translateZ + rotateY + scale),根据其与视口中心的距离动态调整,产生空间中的深度和旋转错觉。
背景(Canvas):由多个径向渐变创建的柔和模糊、漂移的色彩场。该层持续移动和演变。当活动卡片更改时,我们从其图像中提取主色并平滑调整渐变调色板,在图像与其环境背景之间创建微妙的视觉连接。
最小化标记
让我们从基本要素开始。我们的轮播建立在简洁、最小化的HTML结构上——只需几个关键元素即可定位、渲染和动画化体验。
我们将使用一个stage元素来容纳所有内容,一个背景<canvas>来绘制动态渐变,以及一个空的#cards容器,稍后我们将使用JavaScript填充它。
1
2
3
4
|
<main class="stage" aria-live="polite">
<canvas id="bg" aria-hidden="true"></canvas>
<section id="cards" class="cards" aria-label="Infinite carousel of images"></section>
</main>
|
样式要点
现在我们有了结构,是时候给它一些视觉基础了。一些精心选择的CSS规则将建立深度错觉,定义渲染边界,并在所有内容在3D空间中移动时保持性能平滑。
以下是我们将依赖的关键样式原则:
- 舞台上的透视定义了观看者的深度点,使3D变换感觉有形且电影化。
- Preserve-3d确保每个卡片在3D中旋转或平移时保持其空间关系。
- 包含限制布局和绘制范围,通过隔离每个卡片的渲染区域来提高性能。
- Canvas模糊应用柔和的高斯模糊,保持背景渐变平滑且梦幻,不会将注意力从前景移开。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
:root {
--perspective: 1800px;
}
.stage {
perspective: var(--perspective);
overflow: hidden;
}
.cards {
position: absolute;
inset: 0;
transform-style: preserve-3d;
}
.card {
position: absolute;
top: 50%;
left: 50%;
transform-style: preserve-3d;
backface-visibility: hidden;
contain: layout paint;
transform-origin: 90% center;
}
#bg {
position: absolute;
inset: 0;
filter: blur(24px) saturate(1.05);
}
|
创建卡片
布局和样式准备好后,下一步是填充轮播。所有卡片都是从IMAGES数组动态生成并注入到#cards容器中的。这保持了标记的轻量级和灵活性,同时允许我们稍后轻松交换或扩展图像集。
我们首先清除容器内的任何现有内容,然后循环遍历每个图像路径。对于每个路径,我们创建一个包含<img>的<article class="card">元素。为了保持性能,我们使用DocumentFragment,以便所有卡片在一个高效的操作中附加到DOM,而不是多次重排。
每个卡片引用也存储在items数组中,稍后将帮助我们管理轮播中的定位、变换和动画。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
const IMAGES = [
'./img/img01.webp',
'./img/img02.webp',
'./img/img03.webp',
'./img/img04.webp',
'./img/img05.webp',
'./img/img06.webp',
'./img/img07.webp',
'./img/img08.webp',
'./img/img09.webp',
'./img/img10.webp',
];
const cardsRoot = document.getElementById('cards');
let items = [];
function createCards() {
cardsRoot.innerHTML = '';
items = [];
const fragment = document.createDocumentFragment();
IMAGES.forEach((src, i) => {
const card = document.createElement('article');
card.className = 'card';
card.style.willChange = 'transform'; // 强制GPU合成
const img = new Image();
img.className = 'card__img';
img.decoding = 'async';
img.loading = 'eager';
img.fetchPriority = 'high';
img.draggable = false;
img.src = src;
card.appendChild(img);
fragment.appendChild(card);
items.push({ el: card, x: i * STEP });
});
cardsRoot.appendChild(fragment);
}
|
屏幕空间变换
现在我们创建了卡片,我们需要给它们一种深度和透视感。这就是3D变换的作用。对于每个卡片,我们计算其相对于视口中心的位置,并使用该值确定它在空间中的显示方式。
我们首先标准化卡片的X位置。这告诉我们它离屏幕中心有多远。然后,该标准化值驱动三个关键视觉属性:
- 旋转(Y轴):卡片朝向或远离观看者转动的程度。
- 深度(Z平移):卡片被推向或远离相机的程度。
- 缩放:卡片根据其与中心的距离显示的大小。
靠近中心的卡片显得更大更近,而侧面的卡片略微缩小并倾斜远离。结果是当轮播移动时,产生微妙而令人信服的3D曲率感。下面的函数返回完整的变换字符串和计算的深度值,稍后我们将使用它进行正确的分层。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
const MAX_ROTATION = 28; // 度
const MAX_DEPTH = 140; // 像素
const MIN_SCALE = 0.8;
const SCALE_RANGE = 0.20;
let VW_HALF = innerWidth * 0.5;
function transformForScreenX(screenX) {
const norm = Math.max(-1, Math.min(1, screenX / VW_HALF));
const absNorm = Math.abs(norm);
const invNorm = 1 - absNorm;
const ry = -norm * MAX_ROTATION;
const tz = invNorm * MAX_DEPTH;
const scale = MIN_SCALE + invNorm * SCALE_RANGE;
return {
transform: `translate3d(${screenX}px,-50%,${tz}px) rotateY(${ry}deg) scale(${scale})`,
z: tz,
};
}
|
有了这个变换逻辑,我们的卡片自然地排列成弯曲布局,产生连续3D环的错觉。
输入与惯性
为了使轮播感觉自然和响应迅速,我们不直接基于滚动或拖动事件移动卡片。相反,我们将用户输入转换为速度值,驱动平滑的惯性运动。连续的动画循环每帧更新位置,并使用摩擦力逐渐减慢它,就像物理动量随时间消退一样。这使轮播在释放触摸或停止滚动时具有轻松、滑动的感觉。
以下是系统在概念上的行为:
- 滚轮或拖动 → 添加到当前速度,轻推轮播向前或向后。
- 每帧 → 位置根据速度移动,然后通过摩擦因子减少。
- 位置被无缝包裹,因此轮播可以在任一方向上无限循环。
- 非常小的速度被钳制为零,以防止运动应该静止时的微抖动。
这种方法在所有输入类型(鼠标、触摸或触控板)上保持运动平滑一致。
以下是我们如何捕获滚动或滚轮输入并将其转换为速度的简单示例:
1
2
3
4
5
6
7
8
9
10
11
|
stage.addEventListener(
'wheel',
(e) => {
if (isEntering) return;
e.preventDefault();
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
vX += delta * WHEEL_SENS * 20;
},
{ passive: false }
);
|
然后,在主动画循环中,我们应用速度,逐渐衰减它,并更新变换,使卡片自然滑动和稳定:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
function tick(t) {
const dt = lastTime ? (t - lastTime) / 1000 : 0;
lastTime = t;
// 应用速度到滚动位置
SCROLL_X = mod(SCROLL_X + vX * dt, TRACK);
// 对速度应用摩擦
const decay = Math.pow(FRICTION, dt * 60);
vX *= decay;
if (Math.abs(vX) < 0.02) vX = 0;
updateCarouselTransforms();
rafId = requestAnimationFrame(tick);
}
|
图像 → 颜色调色板
为了使我们的背景渐变感觉生动并与图像连接,我们需要直接从每个图像中提取颜色。这允许canvas背景反映活动卡片的主色调,在前景和背景之间创建微妙的和谐。
该过程轻量且快速。我们首先测量图像的宽高比,以便将其按比例缩小到一个微小的离屏canvas,最长边最多48像素。此步骤在不浪费不必要像素的内存的情况下保持颜色数据准确。一旦绘制到这个迷你canvas,我们使用getImageData()获取其像素数据,这给了我们一个RGBA值数组,稍后我们可以分析以构建渐变调色板。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function extractColors(img, idx) {
const MAX = 48;
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1;
const tw = ratio >= 1 ? MAX : Math.max(16, Math.round(MAX * ratio));
const th = ratio >= 1 ? Math.max(16, Math.round(MAX / ratio)) : MAX;
const c = document.createElement('canvas');
c.width = tw;
c.height = th;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0, tw, th);
const data = ctx.getImageData(0, 0, tw, th).data;
}
|
在这个阶段,我们还没有选择保留哪些颜色,我们只是捕获原始像素数据。在接下来的步骤中,将分析此信息以找到与每个图像视觉匹配的主色调和渐变。
Canvas背景:多斑点场
现在我们可以提取颜色,让我们让背景生动起来。我们将在全屏<canvas>元素上绘制两个柔和漂移的径向斑点。每个斑点使用从当前图像调色板中提取的颜色,当活动卡片更改时,这些色调使用GSAP补间平滑混合。结果是一个活生生的、呼吸的渐变场,随着轮播的运动而演变。
为了保持响应性,系统动态调整其渲染节奏:在过渡期间以更高的帧率绘制以实现平滑的颜色转换,然后在一切稳定后空闲在30 fps左右。这种平衡在不不必要地消耗性能的情况下保持视觉流畅。
为什么使用canvas?在基于DOM或CSS的渐变中频繁的颜色更新成本高昂,通常触发布局和绘制重新计算。使用<canvas>,我们可以精细控制每帧的绘制频率和精度,使渲染可预测、高效且 beautifully 平滑。
1
2
3
4
5
|
const g1 = bgCtx.createRadialGradient(x1, y1, 0, x1, y1, r1);
g1.addColorStop(0, `rgba(${gradCurrent.r1},${gradCurrent.g1},${gradCurrent.b1},0.68)`);
g1.addColorStop(1, 'rgba(255,255,255,0)');
bgCtx.fillStyle = g1;
bgCtx.fillRect(0, 0, w, h);
|
通过分层和动画化几个具有略微不同位置和半径的这些渐变,我们创建了一个柔和、有机的色彩场,具有完美的环境背景,增强了轮播的氛围,而不会分散内容的注意力。
性能
为了使轮播从第一次交互开始就感觉即时和如丝般顺滑,我们可以应用一些小型但强大的性能技术。这些优化帮助浏览器提前准备资源并预热GPU,使动画无缝启动,没有可见的延迟或卡顿。
开始前解码图像
在任何动画或变换开始之前,我们确保所有卡片图像完全解码并准备好渲染。我们遍历每个项目,获取其<img>元素,如果浏览器支持异步img.decode()方法,我们调用它来预解码图像到内存中。所有这些解码操作返回promises,我们使用Promise.allSettled()收集和等待。这确保即使一个或两个图像未能正确解码,设置也会继续,并且它给浏览器一个机会提前准备纹理数据——帮助我们的第一个动画感觉更平滑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
async function decodeAllImages() {
const tasks = items.map((it) => {
const img = it.el.querySelector('img');
if (!img) return Promise.resolve();
if (typeof img.decode === 'function') {
return img.decode().catch(() => {});
}
return Promise.resolve();
});
await Promise.allSettled(tasks);
}
|
合成预热
接下来,我们使用一个巧妙的技巧来"预滚动"轮播并预热GPU的合成缓存。想法是以小增量轻轻地将轮播移出屏幕,每次更新变换。这个过程强制浏览器为所有卡片预计算纹理和层数据。每几步,我们等待requestAnimationFrame将控制权交还给主线程,防止阻塞或卡顿。一旦我们覆盖了完整循环,我们恢复原始位置,并给浏览器几个空闲帧来稳定。
结果:当用户第一次交互时,所有内容已经被缓存并准备好移动——没有延迟绘制,没有冷启动问题,只有即时的流畅性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
async function warmupCompositing() {
const originalScrollX = SCROLL_X;
const stepSize = STEP * 0.5;
const numSteps = Math.ceil(TRACK / stepSize);
for (let i = 0; i < numSteps; i++) {
SCROLL_X = mod(originalScrollX + i * stepSize, TRACK);
updateCarouselTransforms();
if (i % 3 === 0) {
await new Promise((r) => requestAnimationFrame(r));
}
}
SCROLL_X = originalScrollX;
updateCarouselTransforms();
await new Promise((r) => requestAnimationFrame(r));
await new Promise((r) => requestAnimationFrame(r));
}
|
整合一切
现在是时候将所有移动的部分整合在一起了。在轮播变得可交互之前,我们经历一个精确的初始化序列,为平滑播放准备每个视觉和性能层。这确保当用户第一次交互时,体验感觉即时、流畅且视觉丰富。
我们首先预加载和创建所有卡片,然后测量它们的布局并应用初始3D变换。一旦完成,我们等待每个图像完全加载和解码,以便浏览器在GPU内存中准备好它们。我们甚至强制绘制以确保所有内容在运动开始前渲染一次。
接下来,我们从每个图像中提取颜色数据以构建渐变调色板,并找到哪个卡片最靠近视口中心。该卡片的主色调成为我们的初始背景渐变,在单个统一场景中连接轮播及其环境背景。
然后我们初始化背景canvas,用基色填充它,并执行GPU"预热"传递以缓存层和纹理。短暂的闲置延迟给浏览器一个时刻来稳定,然后我们开始背景动画循环。最后,我们通过柔和的进入动画显示可见卡片,隐藏加载器,并启用用户输入,将控制权交给主轮播循环。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
|
async function init() {
// 预加载图像以加快加载速度
preloadImageLinks(IMAGES);
// 创建DOM元素
createCards();
measure();
updateCarouselTransforms();
stage.classList.add('carousel-mode');
// 等待所有图像加载
await waitForImages();
// 解码图像以防止卡顿
await decodeAllImages();
// 强制浏览器绘制图像
items.forEach((it) => {
const img = it.el.querySelector('img');
if (img) void img.offsetHeight;
});
// 从图像中提取颜色以用于渐变
buildPalette();
// 查找并设置初始居中卡片
const half = TRACK / 2;
let closestIdx = 0;
let closestDist = Infinity;
for (let i = 0; i < items.length; i++) {
let pos = items[i].x - SCROLL_X;
if (pos < -half) pos += TRACK;
if (pos > half) pos -= TRACK;
const d = Math.abs(pos);
if (d < closestDist) {
closestDist = d;
closestIdx = i;
}
}
setActiveGradient(closestIdx);
// 初始化背景canvas
resizeBG();
if (bgCtx) {
const w = bgCanvas.clientWidth || stage.clientWidth;
const h = bgCanvas.clientHeight || stage.clientHeight;
bgCtx.fillStyle = '#f6f7f9';
bgCtx.fillRect(0, 0, w, h);
}
// 预热GPU合成
await warmupCompositing();
// 等待浏览器空闲时间
if ('requestIdleCallback' in window) {
await new Promise((r) => requestIdleCallback(r, { timeout: 100 }));
}
// 开始背景动画
startBG();
await new Promise((r) => setTimeout(r, 100)); // 让背景稳定
// 准备可见卡片的进入动画
const viewportWidth = window.innerWidth;
const visibleCards = [];
for (let i = 0; i < items.length; i++) {
let pos = items[i].x - SCROLL_X;
if (pos < -half) pos += TRACK;
if (pos > half) pos -= TRACK;
const screenX = pos;
if (Math.abs(screenX) < viewportWidth * 0.6) {
visibleCards.push({ item: items[i], screenX, index: i });
}
}
// 从左到右排序卡片
visibleCards.sort((a, b) => a.screenX - b.screenX);
// 隐藏加载器
if (loader) loader.classList.add('loader--hide');
// 动画卡片进入
await animateEntry(visibleCards);
// 启用用户交互
isEntering = false;
// 开始主轮播循环
startCarousel();
}
|
适应与调整
一旦主要设置工作,您可以开始调整轮播的感觉和深度以匹配项目的风格。以下是关键的可调常量。每个调整3D运动、间距或背景氛围的特定方面。这里的一些微妙变化可以 dramatically 改变体验,从 playful 到 cinematic。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 3D外观
const MAX_ROTATION = 28; // 更高 = 更强的"翻页"效果
const MAX_DEPTH = 140; // translateZ深度
const MIN_SCALE = 0.80; // 更高 = 更平坦的外观
const SCALE_RANGE = 0.20; // 中心的焦点增强
// 布局与间距
let GAP = 28; // 卡片之间的视觉间距
// 运动感觉
const FRICTION = 0.9; // 速度衰减(0-1,越低 = 更多摩擦)
const WHEEL_SENS = 0.6; // 鼠标滚轮灵敏度
const DRAG_SENS = 1.0; // 拖动灵敏度
// 背景(canvas)
/// (在CSS中) #bg { filter: blur(24px) } // 增加以获得更奶油状的背景
|
尝试这些值以找到响应性和深度之间的正确平衡。例如,更高的MAX_ROTATION和MAX_DEPTH将使轮播感觉更具雕塑感,而更低的FRICTION将增加更具动感的自由流动运动。背景模糊也在氛围中扮演重要角色:更柔和的模糊创造梦幻、沉浸的感觉,而更清晰的模糊感觉 crisp 和现代。