2025年9月2日
•33 分鐘閱讀
對於自己來說,前端的動畫效果、3D 動畫等都是又愛又恨,喜歡的點是自己也很喜歡追求好看的使用者效果,但就像造輪子一樣,需要去定義每一個時間點的效果是什麼、技術語言很多,因此當面對一些複雜的動畫效果時,會很煩躁,不知道從哪裡開始下手。
這邊簡單紀錄實作 ScrollTrigger Hijacked My Video Playback (Now It Plays on Scroll) 的過程,希望能夠幫助到有需要的人。
ScrollTrigger 實現影片播放效果的關鍵在於將滾動進度映射到幀索引,具體流程如下:
圖片資源來自於 Davinci Resolve 轉換後的 PNG frames,可以透過 輪播圖片來源 來查看。
const frameCount = 251; // 總共 251 張圖片
const frameAnimation = { frame: 0 }; // 動畫對象,追蹤當前幀
ScrollTrigger.create({
trigger: ".hero",
start: "top top",
end: "+=300%", // 滾動 300% 的距離
scrub: 0.8, // 與滾動同步
pin: true, // 固定 hero 區域
animation: gsap.to(frameAnimation, {
frame: frameCount - 1, // 從 0 到 250
ease: "none",
duration: 1,
}),
onUpdate: function (self) {
// 關鍵:將 frameAnimation.frame 轉換為幀索引
const frameIndex = Math.round(frameAnimation.frame);
renderFrame(frameIndex); // 渲染對應的幀
},
});
滾動進度 → 幀索引的轉換:
frameAnimation.frame = 0 → 顯示第 1 幀frameAnimation.frame = 125 → 顯示第 126 幀frameAnimation.frame = 250 → 顯示第 251 幀scrub: 0.8 讓動畫與滾動完全同步,滾動一點點就更新一幀Math.round() 確保幀索引是整數,避免顯示不完整的圖片pin: true 讓 hero 區域在滾動時保持固定,用戶感覺在”播放影片”用戶滾動時:
原生的滾動行為:較為卡頓、不利於動畫效果
Lenis:
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smooth: true,
mouseMultiplier: 1,
smoothTouch: false,
touchMultiplier: 2,
infinite: false,
});
// GSAP 會根據 Lenis 實體來控制動畫效果
lenis.on("scroll", ScrollTrigger.update);
// 同步 GSAP 與 Lenis 的時間軸
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
// 由於使用 Lenis 控制 Scroll 效果,因此禁用 GSAP 原生的『延遲平滑』
gsap.ticker.lagSmoothing(0);
註解 1: 眾所皆知 Javascript 是單一執行緒的語言,如果使用 setTimeout 或 setInterval 來控制動畫效果,會導致整個單線程被卡住。raf 是 requestAnimationFrame(callback) 的縮寫,表示 Request Animation Frame。requestAnimationFrame(callback) 是瀏覽器提供的 API,並不會即時觸發 callback 函數,而是會在繪製下一個畫面重繪前呼叫 callback 函數,因此不會導致整個單線程被卡住。
因此使用 GSAP + Lenis 代表:
同時同步兩者的時間軸,避免出現不同步的問題。
最直白想到抽換圖片的做法,是不斷抽換 <img src="..." /> 的 src 屬性來達到抽換圖片的動畫效果,但這會不斷更新 DOM 元素,頻繁的觸發瀏覽器的 Reflow 與 Repaint,導致效能下降(或是客戶端效能太差,可能會造成閃爍)。
而在 <canvas> 中,我們是在同一個 DOM 元素去呼叫 Canvas API(如:context.drawImage()、context.clearRect())來達到抽換圖片的動畫效果,因此不會觸發瀏覽器的 Reflow 與 Repaint,但學習曲線相對較高。
註解: 什麼行為會觸發 Reflow & Repaint?
Reflow
Repaint 則是指瀏覽器在渲染網頁時,重新繪製元素的視覺樣式,但不涉及佈局的改變。當一個元素的背景色、文字顏色、透明度、可見性等不影響其佔據空間的屬性發生改變時,就會觸發 Repaint。
然後再讓我們拉回來:ScrollTrigger 如何與 canvas 互動?
gsap.registerPlugin(ScrollTrigger);
const canvas = document.querySelector("canvas");
const context = canvas.getContext("2d");
const frameCount = 250;
const images = [];
let imagesLoaded = 0;
context 是 Canvas API 的上下文,用於繪製圖片frameCount 是圖片的總數images 是圖片的陣列imagesLoaded 是已載入的圖片數量// 設定 Canvas 尺寸
function setCanvasSize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
setCanvasSize();
// 預載入所有圖片
function preloadImages() {
for (let i = 1; i <= frameCount; i++) {
const img = new Image(); // 透過 Image 來預載入圖片,並儲存在記憶體。
img.src = `./frames/frame_${i.toString().padStart(4, "0")}.png`;
images.push(img);
img.onload = () => {
loadedCount++;
if (loadedCount === frameCount) {
console.log("所有圖片已載入");
initAnimation();
}
};
}
}
// 渲染指定的幀
function renderFrame(index) {
const img = images[index];
if (!img) return;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(img, 0, 0, canvas.width, canvas.height);
}
// 初始化 ScrollTrigger 動畫
function initAnimation() {
// 初始顯示第一幀
renderFrame(0);
// 創建一個簡單的物件來追蹤幀數
const frameTracker = { frame: 0 };
gsap.to(frameTracker, {
frame: frameCount - 1, // 目標幀數
ease: "none",
scrollTrigger: {
trigger: ".scroll-container",
start: "top top",
end: "bottom top",
scrub: 1, // 將滾動進度與動畫同步
onUpdate: (self) => {
// 根據滾動進度更新幀數
const frameIndex = Math.round(self.progress * (frameCount - 1));
renderFrame(frameIndex);
},
},
});
}
// 開始執行
preloadImages();
setCanvasSize() 設定 Canvas 的尺寸preloadImages() 預載入所有圖片renderFrame() 渲染指定的幀initAnimation() 初始化 ScrollTrigger 動畫Q: GSAP 的 ScrollTrigger 初學者介紹的案例都是從 a 移動到 b ,為什麼會有輪播圖片的效果?
ScrollTrigger 之所以能產生輪播圖片的效果,是因為它把一個原本是空間變化 (position) 的概念,巧妙地轉化成了時間變化 (animation progress)。
關鍵在於 scrub 屬性。
當你設定 scrub: true 或一個數值時,ScrollTrigger 會做兩件事:
這個進度值代表了動畫從開始到結束的百分比。
gsap.to(frameAnimation, {
frame: frameCount - 1, // 從 0 到 249 的變化過程
ease: "none",
scrollTrigger: {
// ...
scrub: 1,
onUpdate: (self) => {
const frameIndex = Math.round(self.progress * (frameCount - 1));
renderFrame(frameIndex);
},
},
});
虛擬動畫:gsap.to 創建了一個虛擬動畫,它把 frame 這個數值從 0 平順地變到 249。
滾動同步:ScrollTrigger 的 scrub 屬性將這個虛擬動畫的進度,與你的滾動進度綁定。
onUpdate 監聽:每當滾動發生,onUpdate 就會被呼叫,並給你當前的進度值 self.progress。
0.5 * (250 - 1) 約等於 124。渲染圖片:renderFrame(124) 函式接著會將第 124 張圖片繪製到 <canvas> 上。
ScrollTrigger Hijacked My Video Playback (Now It Plays on Scroll) — 參考範例
輪播圖片來源 — 輪播圖片來源
Davinci Resolve — MP4 轉換為 PNG frames