iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
JavaScript

一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲系列 第 3

Day 3|PixiJS 是渲染器,不是遊戲引擎:它不做的每一件事都會變成你的檔案

  • 分享至 

  • xImage
  •  

模組一|立案與選型(Day 1–4)

這個系列在做一款瀏覽器小遊戲:玩家畫一條線,擋住從蜂巢飛出來的蜜蜂,讓狗狗撐過十秒。 打開網頁就能玩,一局十秒可以玩完(https://save-the-dog-web.vercel.app/)。

一款這麼小的遊戲,畫面用什麼畫?我選了 PixiJS 8。這篇要處理的,是選完之後才會發現的那件事:有一整批你以為「這種函式庫應該會附」的東西,它一件都不做——而它不做的每一件,最後都會變成你專案裡的一個檔案。 如果你正打算用某個函式庫開始寫遊戲,這篇可以讓你在動手前先知道自己要多寫多少東西。

(一句前情提要:開工之前我先寫了一份 2,419 行的規格 PRD.md,技術選型在裡面只佔一行。這篇講的就是那一行的後續。)

先攤開事實,看那一行換來多少工作量。這個專案 package.json 的執行期依賴只有三個:pixi.js 8.19.0、matter-js 0.20.0、howler 2.2.4。而 src/ 底下是 46 個檔案、6,081 行。其中 core/ 11 檔 1,219 行、systems/ 8 檔 1,466 行——這兩個目錄合計 2,685 行,裡面沒有任何一個檔案是 PixiJS 提供的

結論先講:PixiJS 把「畫到螢幕上」這件事做完,其他事情做零。 你直覺以為屬於遊戲引擎的東西——物理、碰撞、遊戲狀態機、關卡、存檔——一項都不在盒子裡。搞錯這件事的代價不是學不會,是你會去找一份不存在的文件。


先自我修正一次:官方文件自己叫它 engine

我原本打算把這篇寫成「連 PixiJS 官方都說自己不是遊戲引擎」。去查了,不是這樣。

PixiJS 8 的架構總覽頁開場就邀請你看看引擎蓋底下有什麼——它用的字是 engine。所以「PixiJS 不是遊戲引擎」不是官方立場,是我從盒子裡有什麼推出來的判斷。那就別吵名詞,看清單。

同一頁有一張 Major Components 表,八項。表裡有兩個詞先解釋一下:場景圖(scene graph)就是「畫面上所有要顯示的東西排成的一棵樹」,移動樹上的一個節點,掛在它底下的東西會跟著一起動;Ticker 是「每要畫一格畫面,就回頭叫你一次」的計時器,遊戲裡每一幀要做的事都掛在這種東西上。

元件 官方寫的職責
Renderer 把場景圖畫到螢幕;自動決定要給你 WebGPU 還是 WebGL
Container 建立場景圖,也就是「要被顯示的可繪製物件所構成的樹」
Assets 非同步載入圖片、音檔之類的資源
Ticker 依時鐘週期性回呼
Application 把 Loader、Ticker、Renderer 包成一個物件的便利類別
Events 指標互動:讓物件可點擊、觸發 hover
Accessibility 鍵盤與螢幕閱讀器支援
Filters 濾鏡效果,含自訂 shader

八項全部是「畫面」與「畫面上的互動」。表裡沒有物理、沒有碰撞、沒有遊戲狀態,而且官方也沒有把它們寫成「刻意不做」——就只是沒有出現。

這個「沒有出現」才是坑。如果文件明說「本函式庫不處理碰撞」,你看一眼就走了;它什麼都沒說,你就會一直翻,翻到懷疑是自己關鍵字下得不好。


它不給你的,就是你的工作清單

https://ithelp.ithome.com.tw/upload/images/20260808/20183479NgeLFIpmPy.png
把「一款 2D 遊戲需要什麼」逐項對到這個 repo,長這樣。第一列出現的剛體(rigid body)是物理引擎裡的用語,指「一塊不會變形的硬東西」——狗、蜜蜂、玩家畫的那條線在物理世界裡都是剛體,它們會互撞、會被重力拉下去,但不會被壓扁。

需要的能力 PixiJS 有沒有 這個專案由誰負責(以及那個檔案在做什麼)
剛體、重力、碰撞求解 沒有 matter-js 負責算,src/core/PhysicsManager.js 是包住它的那一層:建立物理世界、每次更新推它一步
物理與畫面的座標對齊 沒有 src/systems/RenderSyncSystem.js:物理算完之後,把剛體的新位置抄到畫面物件上的那一層
誰在什麼時候被 update 只給 Ticker src/core/GameLoop.js:接上 Pixi 的 Ticker,決定每一幀要依序叫哪些東西更新
場景切換與銷毀 沒有 src/core/SceneManager.js:拿著畫面的根節點,負責換場景、以及把舊場景拆乾淨
勝負判定 沒有 src/core/GameStateMachine.js:記住現在是進行中、贏了還是輸了,以及什麼時候換
關卡資料與驗證 沒有 src/levels/(10 檔):每一關的資料放這裡,順便檢查資料本身有沒有寫錯
進度存檔 沒有 src/core/ProgressStore.js:記住玩家目前的進度
畫線輸入 有 Events,但不夠 src/systems/DrawingSystem.js:從手指按下到放開,把玩家畫的那條線收出來

Matter.js 的自我介紹只有一句話,寫在它的 package.json 裡:a 2D rigid body physics engine for the web兩個函式庫加起來剛好把「畫」跟「算」都補齊了,中間那條縫則兩邊都不管。 那條縫等一下講。

最後一列是這張表裡唯一「有給但不夠」的,值得單獨拆開。


唯一「有給但不夠」的那一項

Pixi 的 Events 是真的能用。這個專案的 UI 就用它:src/ui/Button.js:64src/ui/ResultOverlay.js:22 都寫了 this.eventMode = 'static',然後按鈕就會收到指標事件,不必自己算點擊落在哪個矩形裡。

但畫線那條路完全沒走這裡。src/systems/DrawingSystem.js:96-100 是直接掛在 canvas 元素上的五個原生監聽器——pointerdownpointermovepointeruppointercancellostpointercapture——並且在 :153 呼叫 canvas.setPointerCapture()

為什麼要繞過 Pixi 那層,跟 pointercancel 有關,Day 11 完整講。今天只要記住這個形狀:「有這個功能」跟「這個功能夠你用」是兩件事,而分界線通常剛好落在你最在意的那個互動上。


「快」的來源是 batching,而且它有條件

「PixiJS 做 2D 很快」這句話流傳得很廣,但很少人講後半段。快的來源是 batching。

先解釋兩個詞。draw call,是程式對顯示卡下的一次「把這些東西畫出來」的命令;命令下得越多次,畫面就越慢。batching,就是把原本要分好幾次下的命令合併成一次下完。下面表格裡還會出現「材質」,指的是貼在圖形上的那張圖片。

官方的效能建議頁把條件寫得很清楚:

官方說法 意思
Sprite 可以跟最多 16 種不同材質一起被 batch(實際數量看硬體) 不是「同材質才會合併」,而是同一批能帶的材質種類有上限,所以要用 spritesheet 壓低總材質數
Graphics 在 100 個點以下也會被 batch 小的向量圖形跟 Sprite 一樣快
不同的 blend mode 會打斷 batch 官方舉的例子:screen 與 normal 交錯排列會變成 4 次 draw call,分組排列則是 2 次
混著擺 Sprite 與 Graphics 比分開擺慢 繪製順序本身就是效能參數

所以「Pixi 很快」正確的說法是:它在你配合它的前提下很快。 而「配合它」的內容——材質怎麼打包、blend mode 怎麼分組、繪製順序怎麼排——沒有一項是它會自動幫你做的。

上面這張表全部是官方文件的說法,不是我在這個專案量到的。這個專案到現在一次實機效能量測都沒做(那是 Day 27 的題目,資料還沒補齊)。沒有裝置型號、瀏覽器版本、蜜蜂數與節點數的數字,我不會寫進來。


程式碼一:最小啟動實際上拿到什麼

這是整個 repo 裡唯一直接對 PixiJS 做設定的檔案,全文二十五行(src/core/createApplication.js):

import { Application } from 'pixi.js'
import {
  COLORS,
  MAX_RENDERER_RESOLUTION,
  WORLD_HEIGHT,
  WORLD_WIDTH
} from '../config/game.js'

export async function createApplication({
  devicePixelRatio = window.devicePixelRatio || 1
} = {}) {
  const app = new Application()

  await app.init({
    width: WORLD_WIDTH,
    height: WORLD_HEIGHT,
    resolution: Math.min(devicePixelRatio, MAX_RENDERER_RESOLUTION),
    autoDensity: true,
    antialias: true,
    backgroundColor: COLORS.SKY,
    powerPreference: 'high-performance'
  })

  return app
}

跑完之後你手上有四樣東西:app.stage(一個 Container,也就是場景圖那棵樹的根)、app.tickerapp.canvasapp.renderer。就這四樣。

resolution 為什麼夾在 2、而不是直接吃 devicePixelRatio,是 Day 24 的題目。WORLD_WIDTHWORLD_HEIGHT 為什麼要在寫任何一行程式之前就決定,是 Day 5。


程式碼二:其他每一樣都得自己 new

專案啟動的前九行是這樣(src/main.js:32-40):

const app = await createApplication()
const assetManager = new AssetManager({
  manifest: ASSET_MANIFEST
})
const sceneManager = new SceneManager({ stage: app.stage })
const gameLoop = new GameLoop({
  ticker: app.ticker,
  sceneManager
})

AssetManagerSceneManagerGameLoop 三個名字,沒有一個來自 pixi.js,全部是 src/core/ 底下的本地檔案。PixiJS 在這九行裡的貢獻,是被當成參數傳進去的 app.stageapp.ticker 兩個值。

AssetManager 也是。Pixi 明明有 Assets、官方元件表也列了,它仍然被包了一層——因為載入還牽涉到 Manifest 分組與素材路徑要在兩個部署目標上都指對,那是這個專案的問題,不是渲染器的問題。


兩套座標,跟那條被合約寫死方向的縫

https://ithelp.ithome.com.tw/upload/images/20260808/20183479yNPdQP7ASO.png
Matter 的 body(物理世界裡那個剛體)有 positionangle,Pixi 的 display object(場景圖上那個看得見的圖)有 positionrotation它們是兩份數字,不是同一份數字的兩個名字。 物理算完之後,得有人把左邊抄到右邊。

這件事在 AGENTS.md 的 Engineering Rules 裡是一條明文(AGENTS.md:55):

PixiJS handles rendering and Matter.js handles physics; keep synchronization one-way from Matter bodies to Pixi display objects.

執行這條規則的是 src/systems/RenderSyncSystem.js,全檔 51 行。它的檔頭 JSDoc 把「為什麼必須單向」寫了出來:這裡任何東西都不准回頭去讀物理世界,否則模擬就會依賴幀率。

這裡有一個我覺得比程式碼本身更值得講的細節:這層對齊是一張要自己手動維護的名單。 RenderSyncSystem 內部就是一個 entries 陣列,每個要同步的東西都得被明確註冊一次:

註冊什麼 在哪裡 註冊時的參數
src/scenes/GameScene.js:103 offsetY 反向補一次(剛體圓心比圖低),syncAngle: false
每一隻蜜蜂 :145 syncAngle: false
玩家畫的那條線 :163 預設全開,鎖定的當下才註冊

Pixi 沒有「實體」這個概念,Matter 也沒有。所以「這個剛體對應到哪個圖」不會有人替你記,你得自己開一份名單。 名單漏了一項不會有錯誤訊息,只會有一個看起來卡住的東西——這個坑真的踩過,Day 10 講。

順帶一提,「單向同步」寫在合約裡,但沒有任何靜態檢查在執行它。哪些合約條文有檢查、哪些沒有,是 Day 8 與 Day 29 的主題。


為什麼不選 Phaser、純 Canvas、Unity WebGL——這段是事後說明

先講一件不好看的事。我在 PRD.md(2,419 行)、AGENTS.mddocs/src/openspec/ 搜過 phaserunitygodotcocos零命中這個專案從頭到尾沒留下任何選型比較的紀錄。

所以下面這張表不是當初的評估,是我現在回頭補的理由。差別很重要:當初的紀錄可以被檢驗,你能拿它問我「那你怎麼沒考慮 X」;事後的說明只能被相信,而且會不自覺地把結果寫成當初就想清楚了。

方案 我現在給的理由(事後補的)
Phaser 自帶物理、場景管理、輸入抽象。這個專案自己寫的那 2,685 行裡,有一部分它是現成的
純 Canvas 2D 場景圖與 batching 都得自己來,等於重寫半個 Pixi,而那半個跟我想弄懂的東西無關
Unity WebGL 包體與首次載入時間,對一款十秒可以玩完的小遊戲不成比例

這篇最誠實的一句:如果目標是最快做出一款能玩的遊戲,Phaser 大概是更好的選擇。選 PixiJS 是學習取向的決定,不是工程取向的決定。

我沒做過 Phaser 版本,所以也不能說「用 Phaser 會少花幾天」。沒有對照組,那個數字我不編。


交給 AI:選型的下一步是一張閱讀清單

PRD.md 的最後一節叫「優先參考來源」,是一張二十列的表,每列有優先度、官方來源、應查閱的內容、以及在本專案的用途。標「最高」的那七列全部是 PixiJS 與 Matter.js 的官方頁面,而且指定到章節:

優先度 來源 應查閱內容 本專案用途
最高 PixiJS Assets Promise 載入、快取、Alias、Unload AssetManager 基礎
最高 Matter.js Engine Engine 建立與世界更新 PhysicsManager
最高 Matter.js Events Collision Event 註冊與解除 CollisionSystem

寫這張表的時候我沒有意識到它在做什麼。現在回頭看:它把「選了 PixiJS」這個決定,翻譯成「AI 動手寫某個模組之前該對照哪一頁」。 選型不是選完就結束,下一步是一份閱讀清單;這份清單不寫下來,就只存在於我腦子裡,AI 拿不到。

誠實邊界:我沒有留下「AI 每次真的去讀了」的紀錄。 這張表能證明的只有它存在、而且被寫成了可以指派的形式,不能證明它被遵守了幾次。


帶走什麼

一句話:

選函式庫之前先問「它刻意不做什麼」。它不做的每一件事,都會變成你 repo 裡的一個檔案。

三件今天就能做的事:

  1. 打開那個函式庫的架構頁,把它自己列的元件抄下來。 抄不到的那些就是你的工作量;這個專案抄不到的部分最後長成 2,685 行。
  2. 兩套座標之間一定要有一層,而且要明文寫死方向。 這條縫不會有人替你補,也不會有人替你記名單。
  3. 把「有給但不夠」的那一項單獨標出來。 那通常是你最在意的互動,也最容易到後期才發現要重寫。

明天 Day 4,講範圍。MVP 是五個關卡、每關只能畫一條線、蜜蜂同時最多 16 隻——三個數字全部寫在資料裡,不是寫在共識裡。更有意思的是:這五關在動工當天下午被整組重做過一次,改的不是難度參數,是地形。程式碼註解留下了改動的理由,而那個理由推翻了我原本以為的「難度是調出來的」。順便處理一個不好看的數字:PRD 估的工時,跟實際 commit 的跨度差了一個數量級——這次不談規格,談範圍怎麼估、以及估錯在哪一格。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 2|開工前先寫兩千行規格:不是儀式,是給 AI 一份可以違反的合約
下一篇
Day 4|五關、一條線、一張不做清單:範圍是刪出來的,不是排出來的
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言