45%-100% 反向滚动倒放修复实战:ScrollTrigger + GSAP + WebGL

Today
3 min read
1,735 views
157 likes
Category
Beginner
#GSAP#ScrollTrigger#React#Three.js#WebGL#动画#调试#性能

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: visiblepointerEvents: 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,不给“瞬间满开”的视觉。
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 → ... 平滑下降。

进一步可调参数(按需微调,仅影响对应手感)

  • 尾部停留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() 比较稳健。
  • 可见性优化很关键。倒放“看得见”,用户才会觉得“真的在倒放”。

如果你也在做复杂的滚动叙事动画,以上方法可以作为“倒放不生效”的排查与修复模板:先厘清进度映射,再剥离时间线驱动与播放态,最后处理可见性与尾部策略。祝你动画顺滑、体验优雅!

CleanLove

Written by CleanLove

Full-stack developer passionate about modern web technologies

Initializing application
Loading page content, please wait...