前面花了幾個章節製作的康威生命遊戲,功能已經大致上完成了,不過我們還無法確定其效能是否還有可以優化的空間,必須要用一些方式來偵測才行。



偵測Canvas的FPS(Frames Per Second)

測量效能的指標,最常用的自然就是整個遊戲畫面的FPS了。

我們可以新增以下這個TypeScript模組,將其存檔為www/src/fps.ts

const BUFFER_SIZE = 60;

export class Fps {
    private readonly frames: number[] = [];
    private lastFrameTimeStamp: number = performance.now();

    constructor(private readonly element: HTMLElement) {
        this.update();
    }

    private update(numbers = { mean: 0, min: 0, max: 0 }) {
        this.element.innerHTML = `&ensp;&ensp;FPS<br/>Mean:&ensp;${Math.round(numbers.mean)}<br/>&ensp;Min:&ensp;${Math.round(numbers.min)}<br/>&ensp;Max:&ensp;${Math.round(numbers.max)}`;
    }

    render() {
        const now = performance.now();

        const interval = now - this.lastFrameTimeStamp;

        this.lastFrameTimeStamp = now;

        const fps = 1 / interval * 1000;

        this.frames.push(fps);

        if (this.frames.length > BUFFER_SIZE) {
            this.frames.shift();
        }

        let min = Infinity;
        let max = -Infinity;
        let sum = 0;

        for (const frame of this.frames) {
            sum += frame;
            min = Math.min(frame, min);
            max = Math.max(frame, max);
        }
        
        const mean = sum / this.frames.length;

        this.update({ mean, min, max });
    }

    show() {
        this.element.classList.remove("fps-hide");
    }

    hide() {
        this.element.classList.add("fps-hide");
    }
}

這個模組可以用來測量遊戲畫面的FPS,並且輸出到某個HTML元素中。

所以我們就來新增一個專門顯示FPS的元素到HTML網頁中吧!

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Conway's Game of Life</title>
    <link href="./css/bundle.min.css" rel="stylesheet">
</head>
<body>
    <div id="fps" class="fps-hide"></div>
    <div id="controls">
        <button id="play-pause">Play</button>
        <button id="next">Next</button>
        <button id="clear">Clear</button>
    </div>
    <canvas id="game-of-life-canvas"></canvas>
    <script src="./js/bundle.min.js"></script>
</body>
</html>

SCSS/CSS中也要加入以下兩個元素:

#fps {
    font-family: monospace, monospace;
    color: #FFFFFF;
    background-color: rgba(0, 0, 0, 0.5);
    position: fixed;
    bottom: 0;
    left: 0;
    padding: 5px;
}

.fps-hide {
    display: none !important;
}

接著就是在www/src/lib.ts中引用www/src/fps.ts模組,並建立Fps的實體,在renderLoop函數中呼叫Fpsrender方法。當然,為了讓FPS偵測結果能夠在遊戲播放時顯示在HTML網頁上,播放與暫停按鈕的事件也要去呼叫showhide方法。

程式碼修改如下:

...

import { Fps } from "./fps.js";

...

export const init = () => {
    ...

    const fps = new Fps(document.getElementById("fps") as HTMLDivElement);

    const renderLoop = () => {
        fps.render();

        universe.tick();

        drawCells();

        if (!pause) {
            requestAnimationFrame(renderLoop);
        }
    };

    ...

    buttonPlayPause.addEventListener("click", () => {
        if (pause) {
            pause = false;
            requestAnimationFrame(renderLoop);

            fps.show();

            ...
        } else {
            pause = true;

            fps.hide();

            ...
        }
    });
};

修改完成後,在康威生命遊戲開始播放時,HTML網頁的左下角就會出現FPS的偵測結果了。

rust-webassembly-conways-game-of-life-performance

正常情況下,目前我們製作的康威生命遊戲的平均FPS是能達到60的!

測量某段程式的執行時間

利用console提供的timetimeEnd函數,我們可以很輕易地測量某段程式的執行時間。

例如將TypeScript程式改寫如下:

...

export const init = () => {
    ...

    const renderLoop = () => {
        ...

        console.time("tick_tock");

        universe.tick();

        console.timeEnd("tick_tock");

        ...
    };

    ...
};

這邊要注意的是,使用consoletimetimeEnd函數時可傳入一個標籤,用來分辨要用哪個計時器。

如下圖所示,當康威生命遊戲正在播放時,網頁瀏覽器的主控台中就會顯示出tick方法的執行時間。

rust-webassembly-conways-game-of-life-performance

除此之外,在網頁瀏覽器提供的效能檢查工具中,也可以看到consoletimetimeEnd函數所測量到的執行時間。

rust-webassembly-conways-game-of-life-performance

找出使效能變差的程式碼

在32x32尺寸的培養皿下,執行一次requestAnimationFrame函數的所需時間約為5毫秒,執行一次tick所花的時間為1毫秒左右。為了使執行時間更容易觀察,我們先改變JavaScript程式,將培養皿的尺寸加到128x128,這樣執行一次requestAnimationFrame函數的所需時間就大概是42毫秒。在這個尺寸的培養皿下,執行一次tick所花的時間為4毫秒左右。所以我們從增長比例來判斷可以知道tick不會是造成requestAnimationFrame函數執行時間增長的主因。

那麼會大大影響執行速度的因素大概就是drawCells函數了。經過不斷地修改程式和測量效能,我們可以發現,在迴圈中指派ctx.fillStyle的值會嚴重地影響程式效能。所以drawCells函數需要被改寫成以下這樣:

...

export const init = () => {
    const drawCells = () => {
        ...

        ctx.strokeStyle = BORDER_COLOR;
        ctx.lineWidth = borderSize;

        const drawRect = (fillStyle: string, block: Block) => {
            ctx.fillStyle = fillStyle;

            for (let row = 0;row < HEIGHT;row++) {
                for (let column = 0;column < WIDTH;column++) {
                    const index = universe.get_index(row, column);
    
                    if (blocks[index] === block) {
                        const x = (column * size) + borderSize + marginSize;
                        const y = (row * size) + borderSize + marginSize;
    
                        ctx.fillRect(x, y, blockSize, blockSize);
                        ctx.strokeRect(x, y, blockSize, blockSize);
                    }
                }
            }
        };

        drawRect(BLOCK_COLOR, Block.NoCell);
        drawRect(CELL_COLOR, Block.HasCell);
    };
};

如此修改完之後,requestAnimationFrame函數的執行所需時間只剩下28毫秒左右,已被顯著地優化!

限制FPS

由於我們使用requestAnimationFrame函數來繪製畫面,這個函數會根據螢幕的刷新頻率來決定其執行的速度。一般螢幕的刷新頻率為60Hz,所以requestAnimationFrame函數繪製畫面的速度也大概是60fps。不過在這個康威生命遊戲中,過快的FPS會導致遊戲畫面中的活細胞像是發瘋一般亂竄,因此最好加個機制來限制畫面的更新頻率。

將TypeScript程式改寫如下:

...

const WIDTH = 32;
const HEIGHT = 32;
const MAX_FPS = 15;

...

export const init = () => {
    ...

    const fps = new Fps(document.getElementById("fps") as HTMLDivElement);
    const fpsInterval = 1 / MAX_FPS * 1000;

    let startTime = Date.now();

    const renderLoop = () => {
        const now = Date.now();

        if (now - startTime > fpsInterval) {
            startTime = now;

            fps.render();

            universe.tick();

            drawCells();
        }

        if (!pause) {
            requestAnimationFrame(renderLoop);
        }
    };

    ...
};

將康威生命遊戲的最大FPS限制在15之後,遊戲體驗變得更好了呢!

rust-webassembly-conways-game-of-life-performance

總結

康威生命遊戲至此可以說是已經製作完成了,展示網頁的連結如下:

在下一個章節中,我們要來針對WebAssembly程式的大小進行深入的探討。

下一章:縮小Web­Assembly程式