45%-100% 反向滚动倒放修复实战:ScrollTrigger + GSAP + WebGL
在实现一个多阶段的宇宙主题滚动动画(RSC + GSAP + WebGL)时,我们遇到了一个典型但隐蔽的问题:当滚动到末尾后再向上滚,45%–100% 区间的动画无法倒放,尤其是最后的博客展示阶段(涟漪 + 内容时间线)。本文记录从“现象 → 根因 → 解决方案 → 验证与优化”的完整过程。
相关代码:
src/components/CosmicInterestsSection.tsx
复现症状
- 直达滚动末尾,再快速向上滚:
- 45%–60% 空间扭曲、60%–70% 涟漪、70%–100% 博客阶段均不倒放,或表现非常异常。
- 如果没有到达“最末尾”,中途回滚又能正常倒放。
根因分析
-
被钳制的归一化进度:
- 我们用了两层进度:
rawProgress
(0..1 覆盖全程)与progress
(对前 90% 区间做归一化)。 - 在“尾部停留区”(最后 ~10%)内,
progress
被钳制为 1。 - 第 5 阶段(70%–100%)用的是
progress
,导致在尾部区回滚时计算依旧得到 1,时间线不会倒放。
- 我们用了两层进度:
-
时间线播放状态干扰:
- 代码在滚动驱动的同时还对时间线
play()
/pause()
,可能让时间线停在端点的“播放态”,进而影响回退时的进度定位。
- 代码在滚动驱动的同时还对时间线
-
淡出遮挡造成“看起来没动”:
- 覆盖层在尾部被淡出为 0,回滚初段虽然数值在变化,但视觉层被隐藏,造成“不倒放”的错觉。
-
全局 GSAP 默认值副作用:
gsap.defaults({ ease: "none", duration: 0.1 })
这类全局设置会影响其他时间线,导致时长/缓动异常(此前也遇到过)。
解决方案
1) 用 rawProgress 统一阶段边界与第 5 阶段进度
- 仅将“阶段边界”投影到
rawProgress
上(保持原比例):P1_END = 0.2 * ACTIVE_RANGE
P2_END = 0.45 * ACTIVE_RANGE
P3_END = 0.50 * ACTIVE_RANGE
(提前结束以延长涟漪)P4_END = 0.66 * ACTIVE_RANGE
(用户当前设置)
- 第 5 阶段进度:
- 将博客阶段映射到
[P4_END, ACTIVE_RANGE]
,确保“博客走完 → 才进入尾部淡出”。 - 代码示例:
- 将博客阶段映射到
// rawProgress: 0..1 覆盖全程
const TAIL_HOLD_RATIO = 0.03;
const ACTIVE_RANGE = 1 / (1 + TAIL_HOLD_RATIO); // ≈ 0.971
const P4_END = 0.66 * ACTIVE_RANGE;
const BLOG_END = ACTIVE_RANGE; // 博客阶段做到这里刚好完成
const finalProgress = clamp((rawProgress - P4_END) / (BLOG_END - P4_END), 0, 1);
2) 仅用进度驱动 GSAP 时间线(去除播放干扰)
- 使用
totalProgress()
(或progress()
)+pause()
纯进度驱动: - 对端点补一层
seek()
双保险,确保在极端位置回滚也能精准定位。
if ((window as any).rippleBlogTimeline) {
const tl = (window as any).rippleBlogTimeline;
const dur = typeof tl.duration === 'function' ? tl.duration() : undefined;
if (typeof tl.totalProgress === 'function') tl.totalProgress(finalProgress).pause();
else if (typeof tl.progress === 'function') tl.progress(finalProgress).pause();
if (dur && typeof tl.seek === 'function') tl.seek(finalProgress * dur, false).pause();
}
3) 尾部淡出与可见性优化
- 推迟淡出:设定
TAIL_HOLD_RATIO
,将“淡出”严格安排在博客完成之后。 - 不淡到 0:覆盖层最低透明度留有余量,且回滚时临时抬高最小不透明度,保证回滚瞬间“看得见”。
- 回滚可见:当从末端回滚重新进入阶段 3/4 时,强制
visibility: visible
与pointerEvents: auto
。
// 尾部淡出:黑色遮罩与 overlay 分层控制
const tailProgress = clamp((rawProgress - ACTIVE_RANGE) / (1 - ACTIVE_RANGE));
// 黑色遮罩轻微减弱(1 → 0.85),便于回滚时更快显现内容
const blackAlpha = 1 - tailProgress * 0.15;
gsap.set(blackOverlay, { opacity: blackAlpha, ease: 'none' });
// 覆盖层整体不透明度分段衰减,且回滚时抬高下限
let overlayAlpha = /* 多关键帧分段计算 */
if (self.direction === -1) overlayAlpha = Math.max(overlayAlpha, 0.45);
gsap.set(overlayRef.current, { opacity: overlayAlpha, visibility: 'visible' });
4) 去除全局 gsap.defaults
- 避免全局
ease/duration
干扰其他动画时间线。
// gsap.defaults({ ease: 'none', duration: 0.1 }); // 避免启用全局默认
涟漪阶段的“减敏 + 延时”策略(不影响其他阶段)
- 阶段 4(涟漪衔接):
- 使用幂次缓动降低滚轮敏感度,且在阶段 4 内对涟漪开度封顶到
0.75
,不给“瞬间满开”的视觉。
- 使用幂次缓动降低滚轮敏感度,且在阶段 4 内对涟漪开度封顶到
const RIPPLE_EASE_POW = 1.8; // 前期更慢
const RIPPLE_STAGE4_MAX = 0.75; // 阶段 4 中不满开
const t = clamp((rawProgress - P3_END) / (P4_END - P3_END), 0, 1);
const rippleEase = Math.pow(t, RIPPLE_EASE_POW);
const rippleValue = Math.min(RIPPLE_STAGE4_MAX, rippleEase);
setRippleProgress(rippleValue);
- 阶段 5(博客阶段):
- 让涟漪在整个博客阶段内渐进补齐(
RIPPLE_STAGE5_FILL_RANGE = 1.0
),直到finalProgress = 1
才满开,显著拉长“扩撒”的体感时长。
- 让涟漪在整个博客阶段内渐进补齐(
const rippleFill = clamp(finalProgress / 1.0, 0, 1); // 全阶段补齐
const rippleVal5 = 0.75 + (1 - 0.75) * rippleFill;
setRippleProgress(rippleVal5);
验证清单
- 从头滚到尾再立刻回滚:
- 观察 70%–100% 的博客时间线是否顺滑倒放。
- 60%–70%(或现配置 50%–72%)涟漪是否按回滚逐步回收。
- 45%–60% 的空间扭曲是否按回滚逐步减弱。
- 观察控制台:
- 打开 devtools,确认日志里
BlogTL totalProgress
能从1 → 0.99 → ...
平滑下降。
- 打开 devtools,确认日志里
进一步可调参数(按需微调,仅影响对应手感)
- 尾部停留:
TAIL_HOLD_RATIO = 0.03 ~ 0.1
(越小越晚淡出) - 阶段边界:
P3_END / P4_END
(涟漪开始更早/结束更晚 ⇒ 扩散更长) - 涟漪敏感度:
RIPPLE_EASE_POW = 1.6 ~ 2.3
(越大越“钝”) - 阶段 4 封顶:
RIPPLE_STAGE4_MAX = 0.7 ~ 0.85
- 博客阶段补齐:
RIPPLE_STAGE5_FILL_RANGE = 0.3 ~ 1.0
- 滚动“阻尼”:
scrub = 6 ~ 10
(越大越不敏感)
经验教训
- 不要在共享初始化里设置全局 GSAP 默认值。局部指定才安全。
- 分离“动画完成”与“尾部淡出”。让内容先完整讲完,再退出。
- 滚动驱动时间线尽量“只用进度”。用
totalProgress()/seek()
比较稳健。 - 可见性优化很关键。倒放“看得见”,用户才会觉得“真的在倒放”。
如果你也在做复杂的滚动叙事动画,以上方法可以作为“倒放不生效”的排查与修复模板:先厘清进度映射,再剥离时间线驱动与播放态,最后处理可见性与尾部策略。祝你动画顺滑、体验优雅!