使用 REGL + gl-transitions 实现视频/图片丝滑转场切换 在现代 Web 应用中图片轮播、视频背景、广告 Banner 等场景往往需要平滑的切换效果。普通 CSS 过渡只能实现简单的淡入淡出或位移难以做出CrossWarp、ZoomIn、PageCurl等电影级转场。本文将介绍如何使用REGL声明式 WebGL和开源的gl-transitions库封装一个 Vue 3 Composable轻松实现任意两种媒体图片/视频之间的高性能转场。技术选型技术作用REGL轻量级 WebGL 封装以函数式方式管理着色器、纹理、缓冲大幅降低原生 WebGL 的样板代码gl-transitions收集了 100 种 GLSL 转场效果官网每个转场仅需实现一个transition (uv)函数Vue Composition API将 WebGL 状态、资源加载、动画循环封装为可复用的 Hook核心设计思路统一纹理模型图片和视频都通过regl.texture()转为纹理转场着色器只需处理from和to两个纹理。动态着色器编译根据转场名称从window.GLTransitions中取出 GLSL 代码动态拼接顶点/片元着色器实现“热插拔”任意转场效果。帧驱动动画使用requestAnimationFrame驱动progress从 0 → 1每帧更新 uniform渲染器自动插值。宽高比自适应保持视频/图片原始比例在画布上下加黑边letterbox/pillarbox避免拉伸变形。关键实现解析1. 初始化 REGL 实例import createREGL from regl const initRegl (canvas: HTMLCanvasElement) { return createREGL({ canvas, pixelRatio: 1, extensions: [OES_texture_float, EXT_texture_filter_anisotropic], }) }2. 动态构造转对着色器gl-transitions 中的每个转场提供一个glsl字符串包含transition(vec2 uv) - vec4函数。我们需要将其嵌入完整的片元着色器并注入uniformsfrom, to, progress, resolution, contentSize。const buildFragmentShader (transitionGlsl: string) precision mediump float; varying vec2 uv; uniform sampler2D from; uniform sampler2D to; uniform float progress; uniform vec2 resolution; uniform vec2 contentSize; vec4 getFromColor(vec2 uv) { return texture2D(from, uv); } vec4 getToColor(vec2 uv) { return texture2D(to, uv); } ${transitionGlsl} void main() { // 自适应宽高比计算居中后的 UV vec2 adjustedUv uv; float canvasAspect resolution.x / resolution.y; float contentAspect contentSize.x / contentSize.y; if (canvasAspect contentAspect) { float scale contentAspect / canvasAspect; adjustedUv.x uv.x * scale (1.0 - scale) / 2.0; } else { float scale canvasAspect / contentAspect; adjustedUv.y uv.y * scale (1.0 - scale) / 2.0; } gl_FragColor transition(adjustedUv); gl_FragColor.a 1.0; } 顶点着色器固定为全屏四边形const vertexShader attribute vec2 position; varying vec2 uv; void main() { uv position * 0.5 0.5; gl_Position vec4(position, 0.0, 1.0); } 3. 编译绘制命令const compileDrawCommand (regl, transition, canvas, getContentSize) { return regl({ vert: vertexShader, frag: buildFragmentShader(transition.glsl), attributes: { position: [-1, -1, 1, -1, -1, 1, 1, 1] }, elements: [0, 1, 2, 1, 2, 3], uniforms: { from: () contentTextures.from, to: () contentTextures.to, progress: () transitionProgress, resolution: () [canvas.clientWidth, canvas.clientHeight], contentSize: getContentSize, }, count: 6, }) }4. 纹理创建与更新图片纹理一次性创建数据不变。视频纹理每帧调用subimage更新纹理数据。const initContentTexture (regl, content, isVideo false) { const tex regl.texture({ data: content, flipY: true, wrapS: clamp, wrapT: clamp, min: linear, mag: linear, }) if (isVideo) { // 在渲染循环中持续更新 const update () { tex.subimage({ data: content, flipY: true }) requestAnimationFrame(update) } update() } return tex }5. 转场动画驱动const runTransition (duration 1500) { return new Promise((resolve) { const start performance.now() const animate (now) { const elapsed now - start transitionProgress Math.min(elapsed / duration, 1) render() // 执行 drawTransitionCommand if (transitionProgress 1) { requestAnimationFrame(animate) } else { resolve() } } requestAnimationFrame(animate) }) }6. 完整 Composable 接口export function useWebGLTransition(canvasRef, videoList, imageList) { let regl, drawCommand, fromTex, toTex, progress 0, animating false const switchToImage async (index) { /* 预加载图片创建toTex运行动画 */ } const switchToVideo async (index) { /* 预加载视频创建toTex运行动画 */ } const setMute (muted) { /* 控制所有视频静音 */ } const destroy () { /* 销毁regl释放纹理取消动画 */ } return { switchToImage, switchToVideo, setMute, destroy } }7. 完整示例// useWebGLTransition.ts import { ref, type Ref } from vue import createREGL from regl import type REGL from regl // 内容类型 type ContentType image | video // 媒体项定义 interface VideoItem { src: string transition?: string // 转场名称若不提供则使用默认 loop?: boolean } interface ImageItem { id: number src: string } // Hook 参数 interface UseWebGLTransitionOptions { canvasRef: RefHTMLCanvasElement | null videoList: VideoItem[] imageList: ImageItem[] defaultTransition?: string // 默认转场名称 transitionDuration?: number // 转场时长(ms)默认1500 onVideoEnd?: (index: number) void // 视频结束回调 } // 返回值 interface UseWebGLTransitionReturn { playVideo: (targetIndex: number) Promisevoid playImage: (targetIndex: number) Promisevoid setMute: (muted: boolean) void destroy: () void isTransitioning: Refboolean } // 全局转场库类型声明需要在页面中预先加载 gl-transitions 脚本 declare global { interface Window { GLTransitions: Array{ name: string glsl: string } } } export function useWebGLTransition( options: UseWebGLTransitionOptions ): UseWebGLTransitionReturn { const { canvasRef, videoList, imageList, defaultTransition CrossWarp, transitionDuration 1500, onVideoEnd, } options // 响应式状态 const isTransitioning ref(false) // 内部核心变量 let regl: REGL.Regl | null null let drawCommand: REGL.DrawCommand | null null let animationId: number | null null let renderLoopId: number | null null // 纹理对象 let fromTexture: REGL.Texture2D | null null let toTexture: REGL.Texture2D | null null // 过渡进度 (0~1) let transitionProgress 0 // 当前显示的内容类型及索引 let currentContentType: ContentType | null null let currentImageIndex -1 let currentVideoIndex -1 // 目标内容索引转场中 let targetImageIndex -1 let targetVideoIndex -1 // 预加载的媒体元素 const videoElements: HTMLVideoElement[] [] const imageElements: HTMLImageElement[] [] // 当前使用的转场 GLSL let currentTransitionGlsl: string // 静音标志 let muted false // 定时器视频结束后跳转等 let endTimer: number | null null // 资源加载完成标志 let videosLoaded false let imagesLoaded false // 辅助函数 const findTransitionGlsl (name: string): string { if (!window.GLTransitions) { console.warn(GLTransitions not loaded, use default simple crossfade) // 返回一个简单的混合转场作为 fallback return vec4 transition(vec2 uv) { return mix(getFromColor(uv), getToColor(uv), progress); } } const target window.GLTransitions.find( (t) t.name.toLowerCase() name.toLowerCase() ) if (!target) { console.warn(Transition ${name} not found, use default crossfade) return vec4 transition(vec2 uv) { return mix(getFromColor(uv), getToColor(uv), progress); } } return target.glsl } // 构建片元着色器嵌入转场 GLSL 宽高比自适应 const buildFragmentShader (transitionGlsl: string): string { return precision mediump float; varying vec2 uv; uniform sampler2D from; uniform sampler2D to; uniform float progress; uniform vec2 resolution; uniform vec2 contentSize; vec4 getFromColor(vec2 uv) { return texture2D(from, uv); } vec4 getToColor(vec2 uv) { return texture2D(to, uv); } ${transitionGlsl} void main() { // 保持内容宽高比居中显示letterbox/pillarbox vec2 adjustedUv uv; float canvasAspect resolution.x / resolution.y; float contentAspect contentSize.x / contentSize.y; if (canvasAspect contentAspect) { float scale contentAspect / canvasAspect; adjustedUv.x uv.x * scale (1.0 - scale) / 2.0; } else { float scale canvasAspect / contentAspect; adjustedUv.y uv.y * scale (1.0 - scale) / 2.0; } gl_FragColor transition(adjustedUv); gl_FragColor.a 1.0; } } // 顶点着色器全屏四边形 const vertexShader attribute vec2 position; varying vec2 uv; void main() { uv position * 0.5 0.5; gl_Position vec4(position, 0.0, 1.0); } // 编译绘制命令 const compileDrawCommand () { if (!regl || !canvasRef.value || !currentTransitionGlsl) return const getContentSize (): [number, number] { if (currentContentType image imageElements[currentImageIndex]) { const img imageElements[currentImageIndex] return [img.width || 1920, img.height || 1080] } if (currentContentType video videoElements[currentVideoIndex]) { const video videoElements[currentVideoIndex] return [video.videoWidth || 1920, video.videoHeight || 1080] } return [1920, 1080] } drawCommand regl({ vert: vertexShader, frag: buildFragmentShader(currentTransitionGlsl), attributes: { position: [-1, -1, 1, -1, -1, 1, 1, 1], }, elements: [0, 1, 2, 1, 2, 3], uniforms: { from: () fromTexture, to: () toTexture, progress: () transitionProgress, resolution: () [canvasRef.value!.clientWidth, canvasRef.value!.clientHeight], contentSize: getContentSize, }, count: 6, }) } // 创建纹理 const createTexture ( source: HTMLImageElement | HTMLVideoElement ): REGL.Texture2D { return regl!.texture({ data: source, flipY: true, wrapS: clamp, wrapT: clamp, min: linear, mag: linear, }) } // 更新纹理数据用于视频 const updateTexture (texture: REGL.Texture2D, source: HTMLVideoElement) { texture.subimage({ data: source, flipY: true, }) } // 渲染一帧如果正在转场则执行绘制命令否则只刷新当前纹理 const render () { if (!regl || !canvasRef.value) return // 若当前是视频且没有转场则需要持续更新 fromTexture 的内容 if (!isTransitioning.value currentContentType video fromTexture) { const currentVideo videoElements[currentVideoIndex] if (currentVideo !currentVideo.paused) { updateTexture(fromTexture, currentVideo) } } // 如果有绘制命令且存在 from 纹理则渲染 if (drawCommand fromTexture) { regl.clear({ color: [0, 0, 0, 1], depth: 1 }) drawCommand() } } // 持续渲染循环处理视频动态帧 const startRenderLoop () { const loop () { render() renderLoopId requestAnimationFrame(loop) } renderLoopId requestAnimationFrame(loop) } // 停止动画循环 const stopRenderLoop () { if (renderLoopId) { cancelAnimationFrame(renderLoopId) renderLoopId null } } // 转场动画 (Promise 化) const runTransition (): Promisevoid { return new Promise((resolve) { const startTime performance.now() transitionProgress 0 const animate (now: number) { const elapsed now - startTime transitionProgress Math.min(elapsed / transitionDuration, 1) render() // 每帧调用 renderdrawCommand 会使用最新的 progress if (transitionProgress 1) { animationId requestAnimationFrame(animate) } else { // 转场完成 animationId null resolve() } } animationId requestAnimationFrame(animate) }) } // 取消正在进行的动画 const cancelTransition () { if (animationId) { cancelAnimationFrame(animationId) animationId null } transitionProgress 0 } // 清除视频结束定时器 const clearVideoTimer () { if (endTimer) { clearTimeout(endTimer) endTimer null } } // 设置单个视频的 ended 监听 const setupVideoEndListener (video: HTMLVideoElement, index: number) { const onEnded () { clearVideoTimer() if (videoList[index]?.loop) { // 循环视频重新播放 video.currentTime 0 video.play().catch(e console.warn(loop play failed, e)) } else { // 非循环视频延迟执行回调可用来跳转屏保等 endTimer window.setTimeout(() { onVideoEnd?.(index) }, 1000) } } video.removeEventListener(ended, onEnded) video.addEventListener(ended, onEnded) } // 资源预加载 const preloadImages (): Promisevoid { const promises imageList.map((item, idx) { return new PromiseHTMLImageElement((resolve, reject) { const img new Image() img.crossOrigin anonymous img.src item.src img.onload () { imageElements[idx] img resolve(img) } img.onerror (err) reject(new Error(图片加载失败 ${item.src}: ${err})) }) }) return Promise.all(promises).then(() { imagesLoaded true }) } const preloadVideos (): Promisevoid { const promises videoList.map((item, idx) { return new PromiseHTMLVideoElement((resolve, reject) { const video document.createElement(video) video.src item.src video.muted muted video.loop item.loop ?? false video.preload auto video.setAttribute(playsinline, true) video.style.display none setupVideoEndListener(video, idx) const handleCanPlay () { video.removeEventListener(canplaythrough, handleCanPlay) videoElements[idx] video resolve(video) } const handleError (e: any) { video.removeEventListener(error, handleError) reject(new Error(视频加载失败 ${item.src}: ${e})) } video.addEventListener(canplaythrough, handleCanPlay) video.addEventListener(error, handleError) video.load() }) }) return Promise.all(promises).then(() { videosLoaded true }) } // 初始化 REGL 和默认转场 const initRegl () { if (!canvasRef.value) throw new Error(Canvas element not ready) if (regl) { regl.destroy() regl null } regl createREGL({ canvas: canvasRef.value, pixelRatio: 1, extensions: [OES_texture_float, EXT_texture_filter_anisotropic], }) currentTransitionGlsl findTransitionGlsl(defaultTransition) compileDrawCommand() } // 公开方法播放图片 const playImage async (targetIndex: number): Promisevoid { if (!regl || !imagesLoaded || isTransitioning.value || targetIndex currentImageIndex) { return } // 清除定时器 clearVideoTimer() // 获取目标图片的转场如果没有单独配置使用默认 const transitionName videoList[0]?.transition || defaultTransition currentTransitionGlsl findTransitionGlsl(transitionName) compileDrawCommand() // 暂停当前视频如果有 if (currentContentType video currentVideoIndex 0) { const oldVideo videoElements[currentVideoIndex] if (oldVideo) oldVideo.pause() } // 创建目标纹理 const targetImg imageElements[targetIndex] if (!targetImg) return isTransitioning.value true targetImageIndex targetIndex // 如果 toTexture 存在则销毁旧的 if (toTexture) toTexture.destroy() toTexture createTexture(targetImg) // 首次切换直接显示目标图片无转场 if (fromTexture null) { fromTexture toTexture toTexture null currentContentType image currentImageIndex targetIndex isTransitioning.value false render() return } // 执行转场动画 await runTransition() // 转场完成交换纹理 if (fromTexture) fromTexture.destroy() fromTexture toTexture toTexture null currentContentType image currentImageIndex targetIndex isTransitioning.value false render() } // 公开方法播放视频 const playVideo async (targetIndex: number): Promisevoid { if (!regl || !videosLoaded || isTransitioning.value || targetIndex currentVideoIndex) { return } clearVideoTimer() const targetVideo videoElements[targetIndex] if (!targetVideo) return // 加载目标视频的转场每个视频可单独配置 const targetTransition videoList[targetIndex]?.transition || defaultTransition currentTransitionGlsl findTransitionGlsl(targetTransition) compileDrawCommand() isTransitioning.value true targetVideoIndex targetIndex // 创建目标纹理 if (toTexture) toTexture.destroy() toTexture createTexture(targetVideo) // 如果当前是视频先暂停并“冻结”当前帧用于转场可选延迟一帧确保纹理最新 if (currentContentType video currentVideoIndex 0) { const currentVideo videoElements[currentVideoIndex] if (currentVideo !currentVideo.paused) { currentVideo.pause() // 确保 fromTexture 是最新帧 if (fromTexture) updateTexture(fromTexture, currentVideo) } } // 首次切换无 fromTexture直接显示 if (fromTexture null) { fromTexture toTexture toTexture null currentContentType video currentVideoIndex targetIndex isTransitioning.value false // 播放目标视频 targetVideo.currentTime 0 targetVideo.muted muted targetVideo.play().catch(e console.warn(autoplay failed, e)) render() return } // 执行转场动画 await runTransition() // 转场完成交换纹理停止旧视频播放新视频 if (fromTexture) fromTexture.destroy() fromTexture toTexture toTexture null currentContentType video currentVideoIndex targetIndex isTransitioning.value false // 播放目标视频 targetVideo.currentTime 0 targetVideo.muted muted await targetVideo.play().catch(e console.warn(video play failed, e)) // 停止并重置旧视频可选释放资源 if (currentVideoIndex ! targetIndex videoElements[currentVideoIndex]) { const oldVideo videoElements[currentVideoIndex] oldVideo.pause() oldVideo.currentTime 0 } render() } // 设置全局静音 const setMute (mutedState: boolean) { muted mutedState videoElements.forEach(v { if (v) v.muted muted }) } // 销毁所有资源 const destroy () { cancelTransition() stopRenderLoop() if (regl) { regl.destroy() regl null } if (fromTexture) { fromTexture.destroy() fromTexture null } if (toTexture) { toTexture.destroy() toTexture null } // 暂停所有视频并移除监听 videoElements.forEach((video, idx) { if (video) { video.pause() video.src video.load() video.removeEventListener(ended, () {}) } }) videoElements.length 0 imageElements.length 0 clearVideoTimer() currentContentType null currentImageIndex -1 currentVideoIndex -1 isTransitioning.value false } // 初始化入口 const init async () { destroy() await Promise.all([preloadImages(), preloadVideos()]) initRegl() startRenderLoop() // 默认显示第一张图片或视频如果有图片优先图片否则第一个视频 if (imageList.length 0) { await playImage(0) } else if (videoList.length 0) { await playVideo(0) } } // 立即执行初始化也可以在组件中手动调用这里自动运行 // 注意由于使用了 canvasRef需要在 onMounted 中保证 canvas 已挂载因此 init 应由外部调用 // 本 Hook 仅提供方法不自动 init留出控制权 return { playVideo, playImage, setMute, destroy, isTransitioning, // 额外暴露一个初始化方法如果需要手动控制时机 init, } }