昨天我們學會了頂點數據的動態更新,通過動態創建和局部更新頂點屬性,來優化了渲染效能。接下來就讓我們結合排序演算法,實作三維場景中的動畫吧!
建議先看過這兩篇文章:
我們需要結合過去所學,將動態頂點更新、響應式數據以及排序演算法動畫相結合,並提供豐富的圖形變化和參數控制。具體來說,我們需要實現以下幾點:
讓我們一起來看一下具體如何實作這個三維長條圖動畫,並逐步解析其中的技術細節。以下是一個展示影片,能夠幫助你快速了解最終的效果:
首先,我們需要建立一個長條圖物件 column,裡面包含了 geometryData(響應式數據)、geometry 和 mesh,我們會將 geometryData 提供給排序演算法操作,然後動態修改 geometry 中的頂點數據:
// 餅就是要畫大一點!興許未來哪天就做三維的粒子系統
class ParticleSystem3D{
    constructor(){
        this.sort = new SortAlgorithm();
        this.mesh = new THREE.Group();
        this.column = this.createColumn(length, maxHeight, radius, depth);
        this.mesh.add(this.column.mesh);
    }
    getMesh(){
        return this.mesh;
    }
    update(){
        this.#transitionRadian+= this.#trasitionOmega;
        this.sort.update(this.column.geometryData);
    }
}
接著,再將 mesh 加入到前幾天我們所使用的 Three.js 場景,這樣每次渲染更新時,排序演算法的結果就會在畫布上:
this.system = new ParticleSystem3D();
this.scene.add(this.system.getMesh());
這裡稍微複習一下 BufferGeometry,和前幾篇相同,由於我們需要頻繁修改頂數據,因此我們初始化先提供空值,後續再針對每個柱體將頂點數據覆蓋到相應的緩衝區:
// 餅就是要畫大一點!興許未來哪天就做三維的粒子系統
class ParticleSystem3D{
    constructor(){
        this.sort = new SortAlgorithm();
    }
    createColumn(length, maxHeight, radius, depth){
        const column = {length, maxHeight, radius, depth};
        const attribute = new THREE.BufferAttribute(new Float32Array(length * 36 * 3), 3);
        const colorAttribute = new THREE.BufferAttribute(new Float32Array(length * 36 * 3), 3);
        column.geometry = new THREE.BufferGeometry();
        column.geometry.setAttribute('position', attribute);
        column.geometry.setAttribute('color', colorAttribute);
        const material = new THREE.MeshBasicMaterial({ 'vertexColors': true });
        column.mesh = new THREE.Mesh( column.geometry, material ); 
        // ......下略
    }
}
我們用四個基本資訊來建立我們的長條圖,包含:
雖然我們提供給 Three 的頂點數據是緊密排列的,針對當中的每一個柱體,我們仍需要儲存關於它們的信息,除了整個圖形的長度、半徑、深度,還需要考慮單個柱體的高度、相對座標、更新邏輯,方便我們控制它的動畫:
createColumn(length, maxHeight, radius, depth){
    const column = {length, maxHeight, radius, depth};
    // ......上略
    column.updateVertices = (index) => {}
    column.geometryData = new Array(length).fill().map((v, index) => {
        return this.createGeometryData(column, index);
    });
    column.geometryData.forEach((data, index) => {
        column.updateVertices(index);
    })
}
如果你還記得上個主題的排序演算法,應該對這個地方不陌生,我們會提供四筆資料,分別是三個變數和一個動畫物件 path。當初在做二維的排序演算法時,我們並沒有引入響應式數據的概念,而是直接用高度來進行排序,因此沿用。
createGeometryData(column, index){
    const {length, radius, unitHeight} = column;
    const angle = (index / length) * Math.PI * 2;
    const x = radius * Math.cos(angle);
    const y = radius * Math.sin(angle);
    const height = index * unitHeight;
    const path = new Path(x, y);
    const geometryData = {x, y, height, path};
}
我們先從一個簡單的問題切入,unitHeight 顧名思義就是每單位高度(m/unit),所以我們可以這樣計算它:
const unitHeight = maxHeight / length;
但是問題來了,如果使用者想要修改長條圖陣列的長度或高度,那這個值並不會變化,而是被定死了!為了解決這個問題,我們可以透過 getter 來設定這個數值讀取方法:
Object.defineProperty(column, 'unitHeight', {
    get() {
        return column.maxHeight / column.length;
    }
}); 
這樣我們就有一個很基本的響應式數據了,不過要注意到的是,我們沒有設定 setter,相當於是一個空函式。也就是說當你嘗試賦值給 unitHeight,什麼都不會發生。這也是我所希望的,讓這個值動態更新的同時,只讀不寫。
那麼回到主軸,排序演算法的核心是用交換兩個柱體,並為 path 動畫設置目標點:
class SortAlgorithm{
    static swapColumn(a, b, frame){
        [a.path.pointX, b.path.pointX] = [b.path.pointX, a.path.pointX];
        [a.path.pointY, b.path.pointY] = [b.path.pointY, a.path.pointY];
        [a.height, b.height] = [b.height, a.height];
        a.path.NewTarget(a.x, a.y, frame);
        b.path.NewTarget(b.x, b.y, frame);
    }
}
因此我們可以監控 pointX 和 pointY,但這麼做會有一點問題,我們需要避免重複更新頂點數據(浪費資源),所以需要進行狀態管理和延遲:
createGeometryData(column, index){
    // ......
    const path = new Path(x, y);
    const geometryData = {x, y, height, path};
    let _pointX = path.pointX;
    let _pointY = path.pointY;
    let isUpdating = false;
    Object.defineProperty(path, 'pointX', {
        get() {
            return _pointX;
        },
        set(newX) {
            if (_pointX !== newX) {
                _pointX = newX;
                if (isUpdating) return; // 如果正在更新,則直接返回
                isUpdating = true; // 設置為正在更新
                requestAnimationFrame(() => {
                    column.update(index, path);
                    isUpdating = false; // 更新結束
                });
            }
        }
    });
    Object.defineProperty(path, 'pointY', {
        get() {
            return _pointY;
        },
        set(newY) {
            if (_pointY !== newY) {
                _pointY = newY;
                if (isUpdating) return; // 如果正在更新,則直接返回
                isUpdating = true; // 設置為正在更新
                requestAnimationFrame(() => {
                    column.updateVertices(index);
                    isUpdating = false; // 更新結束
                });
            }
        }
    });
}
功能確實是實現了,但是...好像有點複雜哎,讓我們重新思考這個問題然後簡化它。
說到底,動畫的切入點是什麼?的確,座標被更新,就需要移動,但是還記得嗎?在系列文 A2 我們有設計一個倒數計時器呀!
所以接下來,我們把邏輯改成,每次倒數計時更新頂點數據(動畫執行時):
createGeometryData(column, index){
    const path = new Path(x, y);
    const geometryData = {x, y, height, path};
    // 初始化內部變數
    let _timer = path.timer;
    Object.defineProperty(path, 'timer', {
        get() {
            return _timer;
        },
        set(newT) {
            _timer = newT;
            column.updateVertices(index);
        }
    });
}
是不是簡潔了許多呢!
在這篇文章中,我們探索了如何使用響應式數據來實現動畫效果,特別是針對排序演算法的動態柱體顯示。
最初,我們通過 Object.defineProperty 設置動態的 unitHeight,讓長條圖的高度與陣列長度保持同步變動。這為我們引入了基本的響應式數據機制。接下來,我們討論了在動畫過程中,如何讓柱體的 pointX 和 pointY 坐標進行動態更新。這涉及到如何避免重複更新頂點數據,並透過狀態管理來確保只有必要時才執行視覺變化,以優化性能。
當我們重新審視動畫的核心需求,發現可以利用倒數計時器的更新來進行簡化。通過每次動畫執行時更新數據,我們得以將邏輯簡化為較為乾淨的設計。那麼,明天我們將繼續完成後續步驟。