炫砲的 3D Threejs 圖片變形結合使用 Swiper 的輪播特效

大約從2018、2019年開始,網站開始越來越多使用 Shader、WebGL,應用在圖片上的範例,當年覺得很酷(現在是有點老套了),按照著國外教學直接臨摹了出來。但一直找不到願意使用的客戶(大多客戶更在乎他的圖片要看得夠清楚),就只好把技術一直放在 codepen.io,最近我們把它打包起來,讓它可以更好被使用。

需求條件

最初我們在玩這特效是大約 2019 年時,按照此篇教學文一步步做起來,今天不會特別聊到整個特效的原理,只有如何將該文章所提供的特效程式,整理成我們自己所需要使用的方式。

  1. 可以簡單地被複製使用
  2. 未來可以擴充更多控制項目/功能
  3. 可容易與其他程式/套件結合

在我這的需求,因為希望每個 Slider 輪播都可以應用上,我們目前公司所使用的輪播是 Swiper,希望可與 Swiper 使用相同的方式,只要傳入 Dom、輸入控制項目即可。

由於已經使用了 Swiper 我就不希望還要自己去計算 Index,這樣更能與 Swiper 同步,所以 Index 計算的部分,就直接使用 Function 傳入參數來驅動計算誰是下一張,整體可以讓程式更簡單,只要負責運算特效就好。

先讓大家看看效果

這次的範例基於懶人原則,直接用目前專案已經寫好的 Typescript 版本來示範,如有需要 Javascript 版本可自行調整,或者來信跟我們要囉。

首先我把 vertextfragmentdispUrl 這幾個參數特別拉出來放著,方便以後慢慢調整,dispUrl 是變形所需的圖片。

接著把其他基本要應用的參數全域命名。

width: any
height: any
scene: any
sRunning: boolean
renderer: any
camera: any
material: any
uniforms: Object
plane: any
slideTextures: string[] = [];
currentIdx: number
totalImgslength: number

constructor 的部分呢,目前範例僅需要三個數值,DOM元素、圖片陣列、自動播放(測試用),之後自動播放會由 Swiper 來控制。

constructor(
    public DATA: { 
        container: HTMLElement,
        images: string[],
        autoplay: boolean
    }
)

接著初始生成數值

// 初始數值
this.totalImgslength = this.DATA.images.length
this.currentIdx = 0
this.uniforms = { value: 1, type: 'f', min: 0.0, max: 3 } // 特效設定
this.width = this.DATA.container.offsetWidth
this.height = this.DATA.container.offsetHeight
this.scene = new THREE.Scene() // 3Dt場景建立

this.renderer = new THREE.WebGLRenderer() // 渲染建立
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.setSize(this.width, this.height)
this.renderer.setClearColor(0x000000, 1)
THREE.Cache.enabled = true

// 攝影機建立
this.camera = new THREE.PerspectiveCamera(70, this.width / this.height, 0.001, 1000)
this.camera.position.set(0, 0, 2)

先來看看我們總共需要哪幾個 Function

  1. init() 生成時要做的事情
  2. createPlane() 因為他的特效原理是建立在 3D 板子上面的,所以要建立一個板子
  3. resize() 當螢幕發生變化時
  4. next(idx) 我們需要利用 Swiper 來驅動執行下一張圖片
  5. changeSlide(idx) 為了往後客製化更方便我把更換圖片的 Function 獨立拉出來
  6. render() 渲染丟進 requestAnimationFrame()

是不是超少? 對! 把其他複雜輪播的邏輯就丟給 Swiper 吧!我只想寫特效而已~接著就開始完善全部功能吧。

init()

生成時我要先讀取圖片,首先我使用了 THREE.loadingManager() 他自帶的讀取功能,我就不需要自己寫 Promise 了(而且好像用人家完整一套的東西比較安全),接著 forEach 把圖片拿出來個別讀取。這邊比較要注意的是,因為讀取圖片是使用 TextureLoader,但為了讓 manager 可以全部共同管理讀取,所以在 new 這個功能時要帶入 manager TextureLoader(manager)

自動播放只是會另外寫一下 index 的算法。

// 圖片讀取
const manager = new THREE.LoadingManager()
this.DATA.images.forEach((url, idx) => {
    const imgLoader = new THREE.TextureLoader(manager)
    this.slideTextures[idx] = imgLoader.load(url)
})
manager.onLoad = () => {
    console.log('all images loaded', this.slideTextures)

    // 自動播放
    if(this.DATA.autoplay) {
        setInterval(() => {
            this.next(this.currentIdx + 1 > this.totalImgslength - 1 ? 0 : this.currentIdx + 1)
        }, 2000)
    }

    // listeners
    window.addEventListener('resize', this.resize.bind(this))
}

createPlane()

生成後確保讀片拿到後就要建立板子。這裡的材質設計,是依賴 codrop 教學寫過來的,所以目前就暫不多解釋 uniforms 原理。主要就是把參數帶進去 shader,讓這些數值可被控制,你也可以使用外部參數即時改變這些內容,讓特效有些特別的差異變化。

this.material = new THREE.ShaderMaterial({
    extensions: {
        derivatives: '#extension GL_OES_standard_derivatives : enable'
    },
    side: THREE.DoubleSide,
    uniforms: {
        time: { type: 'f', value: 0 },
        progress: { type: 'f', value: 0 },
        border: { type: 'f', value: 0 },
        intensity: { type: 'f', value: 1 },
        scaleX: { type: 'f', value: 40 },
        scaleY: { type: 'f', value: 40 },
        transition: { type: 'f', value: 40 },
        swipe: { type: 'f', value: 0 },
        width: { type: 'f', value: 0 },
        radius: { type: 'f', value: 0 },
        texture1: { type: 'f', value: this.slideTextures[0] },
        texture2: { type: 'f', value: this.slideTextures[1] },
        displacement: {
            type: 'f',
            value: new THREE.TextureLoader().load(dispUrl)
        },
        resolution: { type: 'v4', value: new THREE.Vector4() }
    },
    vertexShader: vertex,
    fragmentShader: fragment
})
const geometry = new THREE.PlaneGeometry(1, 1, 2, 2)
this.plane = new THREE.Mesh(geometry, this.material)
this.scene.add(this.plane)

resize()

圖片與網頁高度關係的設定,resize() 在初始 init() 的時候就要使用一次,接著往後也要綁上 window resize 的 event,在每次變化高度時,就要調整鏡頭、材質、圖片三樣東西。

this.width = this.DATA.container.offsetWidth
this.height = this.DATA.container.offsetHeight
this.renderer.setSize(this.width, this.height)
this.camera.aspect = this.width / this.height

// 這裡的寬高不是隨便都拿得到的喔
let imageAspect = this.slideTextures[0].image.height / this.slideTextures[0].image.width
let a1, a2

if (this.height / this.width > imageAspect) {
    a1 = (this.width / this.height) * imageAspect
    a2 = 1
} else {
    a1 = 1
    a2 = this.height / this.width / imageAspect
}

this.material.uniforms.resolution.value.x = this.width
this.material.uniforms.resolution.value.y = this.height
this.material.uniforms.resolution.value.z = a1
this.material.uniforms.resolution.value.w = a2

this.camera.fov = 2 * (180 / Math.PI) * Math.atan(1 / (2 * this.camera.position.z))

this.plane.scale.x = this.camera.aspect
this.plane.scale.y = 1

this.camera.updateProjectionMatrix()

next(idx) & changeSlide(idx)

剛剛有說過這裡是為了日後客製化方便拆出來寫,next(idx) 的部分主要拿來判斷,在外部進來的 idx 不等於特效目前的 idx 才可以執行。

if (idx !== this.currentIdx) {
    this.currentIdx = idx
    this.changeSlide(this.currentIdx)
}

changeSlide(idx) 就是開始執行換圖時,用 gsap 跑動畫時間,要丟到 requestAnimationFrame 自己寫動畫也是可以,只是這次因為要用某些客製化的緣故選擇用 gsap 當作範例。

const gsapTimeline = gsap.timeline()
const nextTexture = this.slideTextures[idx]
this.material.uniforms.texture2.value = nextTexture
gsapTimeline.to(
    this.material.uniforms.progress,  1, // duration
    {
        value: 1,
        ease: 'power2.out',
        onComplete: () => {
            this.material.uniforms.texture1.value = nextTexture
            this.material.uniforms.progress.value = 0
        }
    }
)

接著套一下 render()

requestAnimationFrame(this.render.bind(this))
this.renderer.render(this.scene, this.camera)

最後就把以上我們寫好的東西裝進去 init() 囉~ 是不是非常容易呢?

const manager = new THREE.LoadingManager()
this.DATA.images.forEach((url, idx) => {
    const imgLoader = new THREE.TextureLoader(manager)
    this.slideTextures[idx] = imgLoader.load(url)
})
manager.onLoad = () => {
    console.log('all images loaded', this.slideTextures)

    // 裝起來~裝起來~
    this.createPlane()
    this.resize()
    this.render()

    if(this.DATA.autoplay) {
        setInterval(() => {
            this.next(this.currentIdx + 1 > this.totalImgslength - 1 ? 0 : this.currentIdx + 1)
        }, 2000)
    }

    // listeners
    window.addEventListener('resize', this.resize.bind(this))
}

應用到 Swiper

以上是我大致上的把程式碼分裝邏輯講解一下,沒有細部講到 Threejs 使用的方式與原理,是因為感覺光講那些,這篇就要寫到天荒地老了。最後我們就可以把功能套到 Swiper 上囉。Swiper 也是大家用到爛掉的套件,不用再教學了吧?

new 一個特效出來

一般來說這裡如果是使用 Vue,則直接把 data 的圖片陣列帶進去就好,但要記得資料處理時,給 Swiper 的陣列順序要跟圖片一樣喔。

const carousel = new transformCarousel({
    container: document.getElementById('contianer'),
    images: [
        'http://imgurl.jpg',
        'http://imgurl.jpg',
        'http://imgurl.jpg',
        'http://imgurl.jpg',
        'http://imgurl.jpg',
    ]
})

接著啟動 Swiper,這邊我們使用了一個比較少見的 Swiper event,當有 Slide 變化時,我就呼叫我剛剛所寫的 next(idx) 功能,然後帶入 Swiper 的 index。

var swiper = new Swiper(".swiper", {
    autoplay: true,
    on: {
        slideChange: (event) => {
            carousel.next(event.realIndex)
        }
    }
});

未來要將這個特效應用到 jQuery slick、Owl、bootstrap,甚至客製化輪播,都可以使用同樣的方式,如果套件沒有提供 Event 可以監聽,那你也可以自己想辦法監聽。最精髓應該就是使用別的 index 來驅動動畫特效。就不用自己算 index 啦~

以下附上完整程式碼的 Codepen,有需要請盡情享用~ https://codepen.io/esdesignstudio/pen/mdGREML?editors=0010