PixiJS原始碼分析系列: 第一章 從最簡單的例子入手

池中物王二狗發表於2024-07-12

從最簡單的例子入手分析 PixiJS 原始碼

image

我一般是以使用角度作為切入點檢視分析原始碼,例子中用到什麼類,什麼方法,再入原始碼。

高屋建瓴的角度咱也做不到啊,畢竟水平有限

pixijs 的原始碼之前折騰了半天都執行不起來,文件也沒有明確說明如何調式
我在 github 上看到過也有歪果仁在問如何本地調式最後他放棄了轉用了別的庫...
還有就是 npm 在我們迷之大陸確實不太友好

原始碼 pixijs 7.3.2 版下載地址 https://github.com/pixijs/pixijs/tree/v7.3.2

本地調式環境說明

npm 8.19.2

Node.js v16.18.0

安裝命令

npm install

執行命令

npm start

serve 靜態伺服器全域性安裝

https://www.npmjs.com/package/serve

原始碼目錄結構

  • 根目錄
    • bundles 打包後原始碼
    • examples 例子
    • packages 原始碼
    • scripts 工程指令碼
    • test 測試目錄 (我們用不到)
    • tools 服務於測試的工具目錄 (我們用不到)

專案原始碼根目錄下有個主包的 package.json name 是 pixi.js-monorepo

從名字可以看出來,這個專案是用 monorepo 方式來組織管理程式碼的

在 rollup.config.mjs 配置檔案內配置有一個方法:

await workspacesRun.default({ cwd: process.cwd(), orderByDeps: true }, async (pkg) =>
{
    if (!pkg.config.private)
    {
        packages.push(pkg);
    }
});

主要作用就是遍歷所有子專案,將非私有專案加入到 'packages' 陣列變數中,然後分析依賴關係再打包輸出

PixiJS 原始碼在 packages 目錄

/packages 目錄下每一個 "大類" 模組都是單獨的專案

每一個 "大類" 都有自己單獨 package.json 檔案, 在 package.json 檔案內指定自己的依賴

比如 app 模組的 package.json 檔案內指定了依賴:

"peerDependencies": {
    "@pixi/core": "file:../core",
    "@pixi/display": "file:../display"
  }

其中的 src 就是此"大類"原始碼目錄,與 src 同級的 test 是此"大類"的測試用例

調式過程中我發現編譯真的挺慢的 ...

調式步驟

為了調式大致需要以下幾步

  1. npm install 安裝依賴包
  2. npm start 將原始碼執行起來
  3. 我就將調式用的 html 網頁放到 example 資料夾下
  4. 在 html 檔案中引用 <script src="/bundles/pixi.js/dist/pixi.js"></script>
  5. terminal 在根目錄起一個 serve 靜態服務 serve .
  6. 瀏覽器訪問靜態服務跳轉到 example 目錄下的具體 html 例子中

完成以上步驟後,你就可以在 /packages 目錄下的任意原始碼內新增 console.log 或 debugger 進行原始碼調式了

相信上面步驟最大的挑戰是 npm install T_T!

嘗試第一個原始碼調式

原始碼中新增一個 console.log 看看能不能成功輸出先

測試的 example/simple.html 檔案如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title> 最簡單的例子 </title>
  <style type="text/css">
    *{
      margin: 0;
      padding: 0;
    }
  </style>
</head>
<body>
  <script src="/bundles/pixi.js/dist/pixi.js"></script>
  <script type="text/javascript">
    const app = new PIXI.Application({ width: 800, height: 600 });  
    document.body.appendChild(app.view);  
  
    const rectangle = PIXI.Sprite.from('logo.png');  
    rectangle.x = 100;  
    rectangle.y = 100;  
    rectangle.anchor.set(0.5);  
    rectangle.rotation = Math.PI / 4;  
    app.stage.addChild(rectangle);  
  
    app.ticker.add(() => {  
      rectangle.rotation += 0.01;  
    });  
  </script>

</body>

</html>

以上例子中實現的功能:

  1. simple.html 首先中引入 pixi.js 檔案
  2. 透過 new PIXI.Application 建一個 800*800 的畫布例項 app
  3. 利用 PIXI.Sprite.from 方法引入 logo.png 圖片例項 rectangle
  4. 為 rectangle 設定座標、anchor、旋轉角度
  5. 透過 app.stage.addChild 將 rectangle新增到舞臺上
  6. 在 app.ticker 定時器內新增一個回撥用定時更新旋轉

如果你在本地伺服器環境下開啟 simple.html 檔案,你將會看到一個旋轉的 logo.png

這裡用到了二個類 Application、Sprite

Application 類是 PixiJS 的入口類在 /packages/app/src/Application.ts

原始碼中已說明這個類是建立 PixiJS 應用的便捷類,這個類會自動建立 renderer, ticker 和 root container

Application.ts 原始碼的 constructor 構造方法內新增個 console.log 試試能不能成功輸出

Application.ts 71-85 行

  constructor(options)
  {
      // The default options
      options = Object.assign({
          forceCanvas: false,
      }, options);

      this.renderer = autoDetectRenderer(options);
      console.log('hello', 88888);
      // install plugins here
      Application._plugins.forEach((plugin) =>
      {
          plugin.init.call(this, options);
      });
  }

移除掉 typescript 型別的原始碼果然看起來眉清目秀一些

成功的關鍵要注意兩點

  1. 先 npm start 專案, 作用是 watch 原始碼變化自動化編譯到 bundles 目錄

  2. 確保你是在本地伺服器環境下開啟網頁就像這樣訪問 http://localhost:3000/examples/simple

開啟網頁調式器如果輸出 hello 88888 就說明成功可以調式原始碼了

Amazing!

Application 的構造方法就做了兩件事,建立渲染器 (renderer) 和 初始化外掛 (plugin)

renderer 是 PixiJS 的渲染器,渲染器會根據瀏覽器環境自動選擇渲染方式,如 WebGL、Canvas

_plugins 靜態屬性是一個用於存放外掛陣列

Application 類本身的其它主要屬性:

  • stage 主要用於新增子顯示物件
  • renderer 渲染器
  • view canvas dom 渲染 元素引用
  • screen 螢幕資訊,更準確的說應該是畫布資訊,x,y,width,height

在例子程式碼中 app.ticker ticker 物件即是 /packages/ticker/TickerPlugin.ts "定時器" 外掛, 後面會深入其原始碼細節

autoDetectRenderer

autoDetectRenderer 用於自動判斷使用哪種方式渲染,如 WebGL、Canvas

/packages/core/src/autoDetectRenderer.ts 第 41-52 行

export function autoDetectRenderer<VIEW extends ICanvas = ICanvas>(options?: Partial<IRendererOptionsAuto>): IRenderer<VIEW>
{
    for (const RendererType of renderers)
    {
        if (RendererType.test(options))
        {
            return new RendererType(options) as IRenderer<VIEW>;
        }
    }

    throw new Error('Unable to auto-detect a suitable renderer.');
}

顯然, 透過迴圈檢測所有的 renderers 渲染器型別 與建構函式傳遞過來的 options 引數進行檢測返回符合條件的渲染器

RendererType.test 就是渲染器的一個檢測方法

而 renderers 陣列就定義在了第 29 -32 行

const renderers: IRendererConstructor<ICanvas>[] = [];

extensions.handleByList(ExtensionType.Renderer, renderers);

這裡又用到了一個叫 extensions 的全域性物件,這個全域性物件顧名思議,就是用來管理所有擴充套件外掛的,嗯,所以渲染器也是一個 extension

extensions 擴充套件外掛簡介

擴充套件外掛原始碼檔案 /packages/extensions/src/index.ts

官方的外掛的型別有這些:

'renderer'
'application'
'renderer-webgl-system'
'renderer-webgl-plugin'
'renderer-canvas-system'
'renderer-canvas-plugin'
'asset'
'load-parser'
'resolve-parser'
'cache-parser'
'detection-parser

具體外掛類或物件都是註冊到對應的型別下的

類先透過 extensions 全域性物件的 handleByListhandleByMap 方法註冊外掛型別

當真正新增外掛時,呼叫的是 extensions 全域性物件的 add 方法外掛就會新增到對應的外掛型別下

比如 TikerPlugin.ts ResizePlugin.ts 就是註冊到了 'application' 型別下

又比如 load 相關的外掛就註冊到了 'load-parser' 型別下

最後具體的外掛是註冊到具體類的 _plugins 屬性上比如: Application._plugins

/packages/extensions/src/index.ts 檔案中第 240-265 行,找到 handleByList 方法

在 extensions/index.ts 244 行加個 console.log 列印一下:

handleByList(type: ExtensionType, list: any[], defaultPriority = -1)
  {
      return this.handle(
          type,
          (extension) =>
          {
              if (list.includes(extension.ref))
              {
                  return;
              }
              console.log(extension.ref);
              list.push(extension.ref);
              list.sort((a, b) => normalizePriority(b, defaultPriority) - normalizePriority(a, defaultPriority));
          },
          (extension) =>
          {
              const index = list.indexOf(extension.ref);

              if (index !== -1)
              {
                  list.splice(index, 1);
              }
          }
      );
  },

輸出:

image

圖 1-1

可以看到輸出了一堆 class 和 物件 (實現了 ExtensionFormat "介面" 的物件), 只知道有這些,現在還不知道具體幹啥

把 handleByList 方法的 type 和 list 也列印出來看看

image

圖 2-2

可以看到每個外掛型別都可以擁有多個 extention 陣列

再看看它的 add 方法

在 extensions/index.ts 152 - 175 行

add(...extensions: Array<ExtensionFormatLoose | any>)
{
    extensions.map(normalizeExtension).forEach((ext) =>
    {
        ext.type.forEach((type) =>
        {
            const handlers = this._addHandlers;
            const queue = this._queue;
            // 如果新增的外掛還沒有外掛型別,就放到 _queue 記憶體起來
            if (!handlers[type])
            {
                queue[type] = queue[type] || [];
                queue[type].push(ext);
            }
            else
            {
                // 如果已經有相應的外掛型別了,就新增到對應外掛型別下
                handlers[type](ext);
            }
        });
    });

    return this;
},

可以看到它接收一個外掛陣列物件 'extensions' 將傳進來的物件進行 '外掛物件標準化'後,該物件擁有 type, name, priority, ref 這些屬性

interface ExtensionFormatLoose
{
    type: ExtensionType | ExtensionType[];
    
    name?: string;
    
    priority?: number;
    
    ref: any;
}

解耦與注入外掛

PixiJS 這種外掛方式的設計就是為了解耦,方便管理和擴充套件更多外掛

邏輯如下:

  1. Application.ts 在全域性 extensions 物件中註冊外掛型別並傳入用於儲存外掛的陣列
    extensions.handleByList(ExtensionType.Application, Application._plugins);

  2. TickerPlugin.ts 在 extensions 注入至對應的 Application 型別外掛陣列
    extensions.add(TickerPlugin);

  3. Application.ts 在例項化時會它所有外掛的 init 方法,將外掛也“例項化”

  4. 其它外掛或自定義外掛實現註冊與呼叫同樣適用,不需要再進入 Application.ts 修改邏輯實現解耦

我們以 /packages/ticker/TickerPlugin.ts 時鐘外掛舉例

在 tickerPlugin.ts 檔案的最後一行有一句 extensions.add(TickerPlugin);

這一句就是將 TickerPlugin 物件新增到了Application 類的 _plugins 陣列

TickerPlugin.ts 35 行標明瞭這個擴充套件屬於 Application 類

static extension: ExtensionMetadata = ExtensionType.Application;

仔細觀察 TickerPlugin.ts 檔案,發現它並沒有 constructor 建構函式

而是有一個公開的 init 函式,這個函式就是外掛的入口函式,它會被 Application 建構函式呼叫並將 this 指向了 Application 物件本身

所以在 init 函式內訪問的 this 就是 Application 物件本身

Ticker

我們都知道與瀏覽器的自動更新渲染方式不同,在 canvas 更新渲染畫面都是透過手動擦掉舊的畫素重新繪製新畫素實現的

時鐘外掛很大一部分工作就是用於管理渲染更新的,它屬於 Application 類的擴充套件外掛.

在 TickerPlugin.ts 的 init 方法內,檔案第 115 行

this.ticker = options.sharedTicker ? Ticker.shared : new Ticker();

即說明例項化 Application 後自動建立了一個 Ticker 例項, sharedTicker 看名字就知道是個共享的時鐘

共有三種 ticker: sharedTicker, systemTicker, 普通 ticker

只要 this.ticker 被賦值,舊的 Application render 方法會刪除並新增一個新的 render 回撥進入 ticker 佇列, 還有個 UPDATE_PRIORITY.LOW 用來管理回撥佇列的優先順序

TickerPlugin.ts 的 init 方法內,檔案第 57 - 75 行:

Object.defineProperty(this, 'ticker',
    {
        set(ticker)
        {
            if (this._ticker)
            {
                this._ticker.remove(this.render, this);
            }
            this._ticker = ticker;
            if (ticker)
            {
                ticker.add(this.render, this, UPDATE_PRIORITY.LOW);
            }
        },
        get()
        {
            return this._ticker;
        },
    });

讓我們進入 Ticker.ts 類看看

渲染相關的回撥透過 Ticker.add 和 Ticker.addOnce 新增加到 Ticker 類中

顧名思義 addOnce 就是一次性的回撥,我們只要理解 add 方法就可以了

Ticker.ts 198 - 201 行:

add<T = any>(fn: TickerCallback<T>, context?: T, priority = UPDATE_PRIORITY.NORMAL): this
{
    return this._addListener(new TickerListener(fn, context, priority));
}

渲染回撥還用 TickerListener.ts 類,包裝了一下,包裝的主要目的是將相應的渲染回撥函式根據 priority 權重組成一個回撥 “連結串列佇列”

priority 權重在 /packages/ticker/const.ts 定義

TickerListener.ts 類主要的兩個方法: emit 觸發函式和 connect 連線函式

/packages/ticker/TickerListener.ts 97 - 106 行 connect 函式:

connect(previous: TickerListener): void
{
    this.previous = previous;
    if (previous.next)
    {
        previous.next.previous = this;
    }
    this.next = previous.next;
    previous.next = this;
}

得結合 Ticker 類的 _addListener 一起看:

/packages/ticker/Ticker.ts 223 - 258 行:

private _addListener(listener: TickerListener): this
{
    // For attaching to head
    let current = this._head.next;
    let previous = this._head;

    // 如果還沒有添過,就新增到 _head 後面
    if (!current)
    {
        listener.connect(previous);
    }
    else
    {
        // priority 優先順序從最高到最低
        while (current)
        {
            if (listener.priority > current.priority)
            {
                listener.connect(previous);
                break;
            }
            previous = current;
            current = current.next;
        }

        // 如果還沒有加入到連結串列中,則加入到連結串列尾部
        if (!listener.previous)
        {
            listener.connect(previous);
        }
    }

    this._startIfPossible();

    return this;
}

可以看到透過 while 迴圈整個 this._head 儲存的連結串列,根據 priority 權重找到需要插入的位置,然後插入到連結串列中。

如果沒找到位置,則新增到連結串列最後

_addListener 函式最後還呼叫了 _startIfPossible 既而呼叫了 _requestIfNeeded 方法

_requestIfNeeded 即刻發起 this._tick “請求”

private _requestIfNeeded(): void
{
    if (this._requestId === null && this._head.next)
    {
        // ensure callbacks get correct delta
        this.lastTime = performance.now();
        this._lastFrame = this.lastTime;
        this._requestId = requestAnimationFrame(this._tick);
    }
}

this._tick 函式定義在 Ticker.ts 的建構函式內

/packages/ticker/Ticker.ts 116 - 137 行

constructor()
{
    this._head = new TickerListener(null, null, Infinity);
    this.deltaMS = 1 / Ticker.targetFPMS;
    this.elapsedMS = 1 / Ticker.targetFPMS;

    this._tick = (time: number): void =>
    {
        this._requestId = null;

        if (this.started)
        {
            // 此處觸發回撥函式,並傳入 delta time
            this.update(time);
            // 回撥函式執行後可能會影響 ticker狀態,所以需要再次檢查
            if (this.started && this._requestId === null && this._head.next)
            {
                // 繼續執行下一幀
                this._requestId = requestAnimationFrame(this._tick);
            }
        }
    };
}

_tick 函式就是每一幀都會執行

this._head 連結串列頭部,為方便處理統一加一個虛擬頭部節點

this.deltaMS 預設為 1/0.06 = 16.66666 重新整理率

this.elaspedMS 幀間隔時間

即使你沒有往畫布中繪製任何圖形,也會執行。不信你可以在 _tick 內新增一個 console.log 看看

當 _tick 觸發時呼叫的就是 update 函式

/packages/ticker/Ticker.ts 369 - 442 行

update(currentTime = performance.now()): void
{
    let elapsedMS;

    // update 也可由使用者主動觸發
    // 如果間隔時間是0或是負數不不需要觸發通知回撥
    // currentTime 
    if (currentTime > this.lastTime)
    {
        // Save uncapped elapsedMS for measurement
        elapsedMS = this.elapsedMS = currentTime - this.lastTime;

        // cap the milliseconds elapsed used for deltaTime
        if (elapsedMS > this._maxElapsedMS)
        {
            elapsedMS = this._maxElapsedMS;
        }

        elapsedMS *= this.speed;

        // If not enough time has passed, exit the function.
        // Get ready for next frame by setting _lastFrame, but based on _minElapsedMS
        // adjustment to ensure a relatively stable interval.
        if (this._minElapsedMS)
        {
            const delta = currentTime - this._lastFrame | 0;

            if (delta < this._minElapsedMS)
            {
                return;
            }

            this._lastFrame = currentTime - (delta % this._minElapsedMS);
        }

        this.deltaMS = elapsedMS;
        this.deltaTime = this.deltaMS * Ticker.targetFPMS;

        // Cache a local reference, in-case ticker is destroyed
        // during the emit, we can still check for head.next
        const head = this._head;

        // Invoke listeners added to internal emitter
        let listener = head.next;

        while (listener)
        {
            listener = listener.emit(this.deltaTime);
        }

        if (!head.next)
        {
            this._cancelIfNeeded();
        }
    }
    else
    {
        this.deltaTime = this.deltaMS = this.elapsedMS = 0;
    }

    this.lastTime = currentTime;
}

額外小知識

對於需要高精度時間戳的動畫或輸入處理,performance.now() 可以提供比 Date.now() 更高的精度。

與 requestAnimationFrame 結合使用:

requestAnimationFrame 的回撥函式接收一個高精度的時間戳作為引數,這個時間戳與 performance.now() 返回的時間戳是同步的。

因此,你可以使用 performance.now() 來與 requestAnimationFrame 回撥中的時間戳進行比較或計算。

需要注意的是,performance.now() 返回的時間戳是相對於某個特定時間點的,而不是絕對的時間(如日期和時間)。因此,它主要用於測量時間間隔,而不是獲取當前的日期和時間。

update 方法主要功能就是判斷當前時間與上一次呼叫的時間差,如果大於最大間隔時間(需要更新一幀時)就執行回撥連結串列

listener.emit(this.deltaTime);

注意 listener.emit() 執行後返回的是下一個回撥函式,即 listener.next 以完成 while 迴圈

PixiJS Ticker 與 EaselJS Ticker 的區別

  1. PixiJS Ticker 預設是開啟的,EaselJS Ticker 直到有新增 Ticker 回撥才開啟

  2. PixiJS Ticker 可被例項化,有建構函式,而 EaselJS Ticker 更像是一個全域性物件

  3. PixiJS Ticker 回撥使用函式採用連結串列方式儲存擁有可調節的權重, EaselJS Ticker 直接使用了 EventDispatcher “標準事件” 方式實現回撥,回撥使用陣列儲存沒有權重可調節

  4. PixiJS Ticker 使用 requestAnimationFrame 實現 tick,EaselJS Ticker 庫較早,所以還支援 setTimeout 方式

本章小節

這一章先介紹原始碼如何下載並搭建本地調式環境,然後用一個簡單的例子來列印出調式資訊

以 Appllication 類為入口進入原始碼, 瞭解了 PixiJS 的基本擴充套件外掛機制

最後分析最重要的 Ticker 實現

說實話我在現實前端專案中從未用到過連結串列,很意外在分析PixiJS原始碼的時候居然發現 Ticker 回撥是用連結串列實現的,look! 沒用的知識又增加了!

上面 simple.html 例子中的 PIXI.Sprite 和 app.stage 還沒有進入原始碼, 下一章先嚐試進入 stage 這一部分,如果可以的話 Sprite 也過一遍


注:轉載請註明出處部落格園:王二狗Sheldon池中物 (willian12345@126.com)

相關文章