PixiJS原始碼分析系列:第四章 響應 Pointer 互動事件(上篇)

池中物王二狗發表於2024-08-02

響應 Pointer 互動事件(上篇)

上一章我們分析了 sprite 在 canvasRenderer 上的渲染,那麼接下來得看看互動上最重要的事件系統了

image

最簡單的 demo

還是用一個最簡單的 demo 演示 example/sprite-pointerdown.html

為 sprite 新增一個 pointerdown 事件,即點選事件,移動裝置上就是 touch 事件, desktop 裝置上即 click 事件

const app = new PIXI.Application({ width: 800, height: 600, autoStart: false });  
document.body.appendChild(app.view);  

const sprite = PIXI.Sprite.from('logo.png');

sprite.on('pointerdown', ()=> {
    console.log('clicked')
})

app.stage.addChild(sprite);  
app.start()

試著用滑鼠點選 sprite ,會發現控制檯並未輸出期望的 'clicked'

奇奇怪怪... 看下官網的例子,需要為 sprite 新增 sprite.eventMode = 'static';;

再執行,就可以看到控制檯正常輸出 'clicked' 了

顯示物件沒有自己的事件

Canvas 本身不像 DOM 那樣每個元素都有自帶的事件的系統用於響應事件

需要自己實現事件系統,可互動的元素都應該是 DisplayObject 及繼承自它的子類元素

/packages/display/src/DisplayObject.ts 第 210 行

export abstract class DisplayObject extends utils.EventEmitter<DisplayObjectEvents>

說明,DisplayObject 繼承了 EventEmitter 類,因此就有了自定義的事件系統,所有對應的 API

eventemitter3: https://github.com/primus/eventemitter3

eventemitter3 的 REAMDME 過於簡單

得看它的 測試用例 https://github.com/primus/eventemitter3/blob/master/test/test.js

可以發現 監聽事件可以用 on, 觸發事件可以用 emit

所以 PixiJS 中的 DisplayObject 類例項物件就可以用 on 監聽事件,用 emit 觸發事件,即有了自定義事件的能力

當顯示物件有了自定義事件能力後,需要一個事件管理系統來管理顯示物件的事件觸發、監聽、移除

來看看 EventSystem 類

/packages/events/src/EventSystem.ts 204 -238 行

constructor(renderer: IRenderer)
  {
    this.renderer = renderer;
    this.rootBoundary = new EventBoundary(null);
    EventsTicker.init(this);

    this.autoPreventDefault = true;
    this.eventsAdded = false;

    this.rootPointerEvent = new FederatedPointerEvent(null);
    this.rootWheelEvent = new FederatedWheelEvent(null);

    this.cursorStyles = {
        default: 'inherit',
        pointer: 'pointer',
    };

    this.features = new Proxy({ ...EventSystem.defaultEventFeatures }, {
        set: (target, key, value) =>
        {
            if (key === 'globalMove')
            {
                this.rootBoundary.enableGlobalMoveEvents = value;
            }
            target[key as keyof EventSystemFeatures] = value;

            return true;
        }
    });
    this.onPointerDown = this.onPointerDown.bind(this);
    this.onPointerMove = this.onPointerMove.bind(this);
    this.onPointerUp = this.onPointerUp.bind(this);
    this.onPointerOverOut = this.onPointerOverOut.bind(this);
    this.onWheel = this.onWheel.bind(this);
  }

EventSystem.ts 的最後一行 extensions.add(EventSystem); 會將它以擴充套件外掛的方式整合到 pixiJS 內

可以看到建構函式內很簡單,

  1. 傳入了渲染器例項

  2. rootBoudary “根邊界” 這個物件很重要,後面會具體介紹

  3. 單獨建立一個 ticker 用於管理事件,確保執行狀態下顯示物件的碰撞檢測事件

  4. 例項化了兩個事件物件,用於觸發時傳遞,事件物件內的資料結構

  5. onPointerDown/onPointerMove/onPointerUp/onPointerOverOut/onWheel 等繫結到當前 this 上

當 EventSystem 加入 PixiJS 管理後,會被觸發 'init' 這個 Runner , 可理解這個 init 生命週期函式被觸發

在 EventSystem.ts 第 245 - 254 行:

init(options: EventSystemOptions): void
{
    const { view, resolution } = this.renderer;

    this.setTargetElement(view as HTMLCanvasElement);
    this.resolution = resolution;
    EventSystem._defaultEventMode = options.eventMode ?? 'auto';
    Object.assign(this.features, options.eventFeatures ?? {});
    this.rootBoundary.enableGlobalMoveEvents = this.features.globalMove;
}

可以看到,setTargetElement 用於設定事件目標元素,就是 渲染器對應的 view, 可以認為這個 view 就是 canvas 本身,它是可以響應瀏覽器的 DOM 事件的, 當然包括,滑鼠的點選,移動 等。

setTargetElement 函式 最終會調到 addEvents()

在 EventSystem.ts 第 483 - 546 行:

private addEvents(): void
    {
      ... 省略部分原始碼
        if (this.supportsPointerEvents)
        {
            globalThis.document.addEventListener('pointermove', this.onPointerMove, true);
            this.domElement.addEventListener('pointerdown', this.onPointerDown, true);
            ... 省略部分原始碼
            globalThis.addEventListener('pointerup', this.onPointerUp, true);
        }
        else
        {
            globalThis.document.addEventListener('mousemove', this.onPointerMove, true);
            this.domElement.addEventListener('mousedown', this.onPointerDown, true);
            ... 省略部分原始碼

            if (this.supportsTouchEvents)
            {
                this.domElement.addEventListener('touchstart', this.onPointerDown, true);
               ... 省略部分原始碼
            }
        }

        this.domElement.addEventListener('wheel', this.onWheel, {
            passive: true,
            capture: true,
        });

        this.eventsAdded = true;
    }

此函式是真正為根元素(或者說是整個 canvas 內自定義事件發起事件的元素)新增事件監聽器的地方。

如果支援 pointer 事件則使用 pointer 事件,

如果不支援 pointer 事件則使用 mouse 事件。

如果支援 touch 事件則也要新增上 touch 事件

注意 move 相關的事件是新增在 document 元素上的

至此,當使用者點選 canvas 元素時,就相關的回撥函式就會執行,如註冊的 this.onPointerDown、this.onPointerUp 等

觸發的回撥函式內會去觸發 eventemitter3 的自定義事件。

還是以我們在 sprite-pointer.html 中的例子為例,我們註冊了 sprite-pointer.html 中 sprite.on('pointerdown', function() {}),那麼當使用者點選 canvas 元素時,就會觸發這個回撥函式。

在 onPointerDown

在 EventSystem.ts 第 343 - 377 行:

private onPointerDown(nativeEvent: MouseEvent | PointerEvent | TouchEvent): void
{
... 省略部分原始碼
this.rootBoundary.rootTarget = this.renderer.lastObjectRendered as DisplayObject;

const events = this.normalizeToPointerData(nativeEvent);
... 省略部分原始碼

for (let i = 0, j = events.length; i < j; i++)
{
    const nativeEvent = events[i];
    const federatedEvent = this.bootstrapEvent(this.rootPointerEvent, nativeEvent);

    this.rootBoundary.mapEvent(federatedEvent);
}

... 省略部分

}

  1. 先指定當前 rootBoundary.rootTarget = this.renderer.lastObjectRendered 即響應事件的目標物件"為渲染器最上層的一個顯示物件"

  2. 適配瀏覽器原生事件 nativeEvent 後,呼叫 this.rootBoundary.mapEvent(federatedEvent), federatedEvent 即標準化為 PixiJS 自定義事件

事件邊界 EventBoundary

canvas 內繪製的元素要準確的響應使用者點選的操作,必須先確定使用者點選的範圍在哪裡,然後將範圍內的 DisplayObject 顯示元素觸發對應使用者繫結的點選事件回撥

/packages/events/src/EventBoundary.ts

EventBoundary.ts 建構函式 149 - 172 行 :

constructor(rootTarget?: DisplayObject)
  {
      this.rootTarget = rootTarget;

      this.hitPruneFn = this.hitPruneFn.bind(this);
      this.hitTestFn = this.hitTestFn.bind(this);
      this.mapPointerDown = this.mapPointerDown.bind(this);
      this.mapPointerMove = this.mapPointerMove.bind(this);
      this.mapPointerOut = this.mapPointerOut.bind(this);
      this.mapPointerOver = this.mapPointerOver.bind(this);
      this.mapPointerUp = this.mapPointerUp.bind(this);
      this.mapPointerUpOutside = this.mapPointerUpOutside.bind(this);
      this.mapWheel = this.mapWheel.bind(this);

      this.mappingTable = {};
      this.addEventMapping('pointerdown', this.mapPointerDown);
      this.addEventMapping('pointermove', this.mapPointerMove);
      this.addEventMapping('pointerout', this.mapPointerOut);
      this.addEventMapping('pointerleave', this.mapPointerOut);
      this.addEventMapping('pointerover', this.mapPointerOver);
      this.addEventMapping('pointerup', this.mapPointerUp);
      this.addEventMapping('pointerupoutside', this.mapPointerUpOutside);
      this.addEventMapping('wheel', this.mapWheel);
  }

建構函式內表明例項化後, 由 addEventMapping 方法 將pointerdown,pointermove, pointerout .... 等 8 類事件的回撥對映函式儲存在了 mappingTable 物件內

後續使用過程中使用者新增到顯示物件上的互動事件,都會被儲存到對應的這 8 類事件列表中

當滑鼠點選例子中的 sprite 顯示物件時,這個 mapPointerDown 會被觸發

EventBoundary.ts 建構函式 672 - 701 行 :

protected mapPointerDown(from: FederatedEvent): void
{
    if (!(from instanceof FederatedPointerEvent))
    {
        console.warn('EventBoundary cannot map a non-pointer event as a pointer event');

        return;
    }

    const e = this.createPointerEvent(from);
    console.log(e.target)
    this.dispatchEvent(e, 'pointerdown');

    if (e.pointerType === 'touch')
    {
        this.dispatchEvent(e, 'touchstart');
    }
    else if (e.pointerType === 'mouse' || e.pointerType === 'pen')
    {
        const isRightButton = e.button === 2;

        this.dispatchEvent(e, isRightButton ? 'rightdown' : 'mousedown');
    }

    const trackingData = this.trackingData(from.pointerId);

    trackingData.pressTargetsByButton[from.button] = e.composedPath();

    this.freeEvent(e);
}

當 view 被點選後,先建立事件物件,然後向目標物件傳送事件,接下來就是找到那個目標物件了

找到目標物件即點選的物件

把 mapPointerDown 函式的 const e = this.createPointerEvent(from); 的事件物件打出來看看

image

圖 4-1

果然 e.target 把當前點選的就是 sprite,顯然在這一步確定了 當前點選的物件 this.createPointerEvent(from); 方法呼叫非常重要

createPointerEvent 該當在 EventBoundary.ts 檔案的 1181 - 1205 行 :

protected createPointerEvent(
    from: FederatedPointerEvent,
    type?: string,
    target?: FederatedEventTarget
): FederatedPointerEvent
{
    const event = this.allocateEvent(FederatedPointerEvent);

    this.copyPointerData(from, event);
    this.copyMouseData(from, event);
    this.copyData(from, event);

    event.nativeEvent = from.nativeEvent;
    event.originalEvent = from;
    event.target = target
        ?? this.hitTest(event.global.x, event.global.y) as FederatedEventTarget
        ?? this._hitElements[0];

    if (typeof type === 'string')
    {
        event.type = type;
    }

    return event;
}

可以看到正是在這個 createPointerEvent 方法內呼叫 hitTest 或 _hitElements

注意 mapPointerDown 方法內呼叫 const e = this.createPointerEvent(from); 時只傳了一個引數 from

所以此處 target 的確定就是由 this.hitTest(event.global.x, event.global.y) as FederatedEventTarget 來決定的

向 hitTest 方法 傳入了當前事件的全域性的 x, y 座標

在 EventBoundary.ts 檔案的 247 - 265 行 :

public hitTest(
    x: number,
    y: number,
): DisplayObject
{
    EventsTicker.pauseUpdate = true;
    // if we are using global move events, we need to hit test the whole scene graph
    const useMove = this._isPointerMoveEvent && this.enableGlobalMoveEvents;
    const fn = useMove ? 'hitTestMoveRecursive' : 'hitTestRecursive';
    console.log(this.rootTarget)
    const invertedPath = this[fn](
        this.rootTarget,
        this.rootTarget.eventMode,
        tempHitLocation.set(x, y),
        this.hitTestFn,
        this.hitPruneFn,
    );

    return invertedPath && invertedPath[0];
}

由於對 move 事件需要特殊處理,所以需要判斷 在 hitTest 函式內呼叫了 hitTestMoveRecursive || hitTestRecursive

我們當前 demo 中用的是點選事件,所以呼叫的會是 hitTestRecursive

把 this.rootTarget 列印出來看看

image

圖 4-2

可以看到圖 4-2 當前 rootTarget 是一個 container 物件

當點選事件發生時,需要判斷的不止是當前物件,而是當前 container 下的所有子物件,所以才需要用到 hitTestRecursive 即是遞迴判斷

遞迴遍歷

hitTestRecursive 函式的最生兩個引數分別是用於具體碰撞檢測的 hitTestFn 函式 和 用於判斷是否可剔除用於碰撞判斷的 hitPruneFn 函式

在 EventBoundary.ts 檔案的 407 - 539 行 :

protected hitTestRecursive(
    currentTarget: DisplayObject,
    eventMode: EventMode,
    location: Point,
    testFn: (object: DisplayObject, pt: Point) => boolean,
    pruneFn?: (object: DisplayObject, pt: Point) => boolean
): DisplayObject[]
{
    // Attempt to prune this DisplayObject and its subtree as an optimization.
    if (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location))
    {
        return null;
    }

    if (currentTarget.eventMode === 'dynamic' || eventMode === 'dynamic')
    {
        EventsTicker.pauseUpdate = false;
    }

    // Find a child that passes the hit testing and return one, if any.
    if (currentTarget.interactiveChildren && currentTarget.children)
    {
        const children = currentTarget.children;

        for (let i = children.length - 1; i >= 0; i--)
        {
            const child = children[i] as DisplayObject;

            const nestedHit = this.hitTestRecursive(
                child,
                this._isInteractive(eventMode) ? eventMode : child.eventMode,
                location,
                testFn,
                pruneFn
            );

            if (nestedHit)
            {
                // Its a good idea to check if a child has lost its parent.
                // this means it has been removed whilst looping so its best
                if (nestedHit.length > 0 && !nestedHit[nestedHit.length - 1].parent)
                {
                    continue;
                }

                // Only add the current hit-test target to the hit-test chain if the chain
                // has already started (i.e. the event target has been found) or if the current
                // target is interactive (i.e. it becomes the event target).
                const isInteractive = currentTarget.isInteractive();

                if (nestedHit.length > 0 || isInteractive) nestedHit.push(currentTarget);

                return nestedHit;
            }
        }
    }

    const isInteractiveMode = this._isInteractive(eventMode);
    const isInteractiveTarget = currentTarget.isInteractive();

    // Finally, hit test this DisplayObject itself.
    if (isInteractiveMode && testFn(currentTarget, location))
    {
        // The current hit-test target is the event's target only if it is interactive. Otherwise,
        // the first interactive ancestor will be the event's target.
        return isInteractiveTarget ? [currentTarget] : [];
    }

    return null;
}

函式大致流程

  1. 進來先判斷是否需要進行碰撞檢測 if (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location))

    剔除需要進行碰撞檢測的物件,比如遮罩、不可見、不可互動、無需渲染等物件

  2. 如果有子顯示物件,則需要迴圈所有子顯示物件,並遞迴檢測子顯示物件

  3. 如果碰撞檢測成功(點選位置有子顯示物件)則在返回的 nextedHit 陣列內把 currentTarget 新增進隊尾, 並返回 nestedHit 陣列

    if (nestedHit.length > 0 || isInteractive) nestedHit.push(currentTarget);
    return nestedHit;
    
  4. 最後 如果是 isInteractiveMode 並且 testFn 碰撞檢測成功,則把當前碰撞物件放到陣列內返回

注意在這個函式內的這一行 const isInteractiveMode = this._isInteractive(eventMode);

在 EventBoundary.ts 檔案的 541 -544 行 :

private _isInteractive(int: EventMode): int is 'static' | 'dynamic'
{
    return int === 'static' || int === 'dynamic';
}

到這裡終於知道我們 demo 中當沒有指定 eventMode 為 static 或 dynamic 時,沒有響應點選事件的原因了

這是檢測 rootTarget 父級物件的, 如果父級物件比如 container 都不支援互動了,就不必再對其子顯示物件進行碰撞檢測了

還有 const isInteractiveTarget = currentTarget.isInteractive(); 這一行,判斷元素本身是否可互動 也是判斷 eventMode 這是檢測當前 target 的

/packages/events/src/FederatedEventTarget.ts 事件定義內 657 - 660 行:

isInteractive()
{
    return this.eventMode === 'static' || this.eventMode === 'dynamic';
},

接下來就要用碰撞檢測來檢測是否是點選物件了

找到碰撞檢測函式

注意看最後的 testFn 即傳入來的 hitTestFn 碰撞檢測函式

在 EventBoundary.ts 檔案的 615 - 637 行 :

protected hitTestFn(displayObject: DisplayObject, location: Point): boolean
{
    // If the displayObject is passive then it cannot be hit directly.
    if (displayObject.eventMode === 'passive')
    {
        return false;
    }

    // If the display object failed pruning with a hitArea, then it must pass it.
    if (displayObject.hitArea)
    {
        return true;
    }

    if ((displayObject as any).containsPoint)
    {
        return (displayObject as any).containsPoint(location) as boolean;
    }

    // TODO: Should we hit test based on bounds?

    return false;
}

主要進行了三個判斷

  1. eventMode === 'passive' 直接不進行碰撞檢測,用於最佳化效能,比如在滾動區域內滾動時可以設定內部的元素為 passive

  2. displayObject.hitArea 判斷 主要作用

    • 自定義互動區域:你可以定義一個特定的區域來響應使用者互動,而不是使用顯示物件的整個邊界框。這在某些情況下非常有用,例如當你有一個複雜形狀的物件,但只希望某個部分響應互動。
    • 提高效能:透過定義較小的互動區域,可以減少不必要的命中測試,從而提高效能。
    • 精確控制:你可以精確控制哪些區域應該響應使用者互動,這在遊戲開發和複雜的使用者介面中非常有用。
  3. displayObject.containsPoint 檢測,可以看到,containsPoint 是由顯示物件各自自己實現的方法

注意: GraphicsGeometry.containsPoint 方法,內可知,如果你繪製的是直線、貝塞爾曲線等線條新增滑鼠事件是不會起作用的,因為這些只是路徑,並不是形狀,你需要為這些新增 hitArea 後才互動事件才會起作用

以 sprite 類實現的 containsPoint 舉例

/packages/sprite/src/Sprite.ts 第 439 - 459 行:

public containsPoint(point: IPointData): boolean
{
    this.worldTransform.applyInverse(point, tempPoint);

    const width = this._texture.orig.width;
    const height = this._texture.orig.height;
    const x1 = -width * this.anchor.x;
    let y1 = 0;

    if (tempPoint.x >= x1 && tempPoint.x < x1 + width)
    {
        y1 = -height * this.anchor.y;

        if (tempPoint.y >= y1 && tempPoint.y < y1 + height)
        {
            return true;
        }
    }

    return false;
}

sprite 的 containsPoint 判斷座標點是否在顯示物件的矩形內比較簡單,就是將全域性座標點轉換為 sprite 的本地座標點,然後判斷是否在矩形內

this.worldTransform.applyInverse 方法,傳入一個座標點,返回一個由世界座標轉換成本地座標的新座標點,這個新座標點就是 sprite 本地座標點

如何處理 sprite 疊加時的碰撞檢測

如果只是簡單的點與形狀的碰撞檢測,那麼如果兩個顯示物件疊加在一起時,點選上層的顯示對像,如果不加處理,疊在下面的物件也會響應點選事件

新建個 demo 演示 example/two-sprite-pointerdown.html

const app = new PIXI.Application({ width: 800, height: 600, autoStart: false });  
document.body.appendChild(app.view);  

const sprite = PIXI.Sprite.from('logo.png');  
sprite.eventMode = 'static';
sprite._Name = 'sprite1';
sprite.on('pointerdown', ()=> {
    console.log('clicked')
})

const sprite2 = PIXI.Sprite.from('logo.png');
sprite2.tint = 'red';
sprite2.eventMode = 'static';
sprite2._Name = 'sprite2';
sprite2.x = 100
sprite2.on('pointerdown', ()=> {
    console.log('clicked2')
})

app.stage.addChild(sprite);  
app.stage.addChild(sprite2);  
app.start()
  1. 其它程式碼與 sprite-pointerdown.html 幾乎一樣,就是新增了兩個 sprite 且有一部分重疊在一起

  2. 分別給這兩個 sprite 分別新增了 _Name 屬性,方便除錯 sprite1 和 sprite2

  3. sprite2 的 tint 屬性設定為紅色

  4. 修改了 sprite2 的x 值,使得 sprite2 只覆蓋一部分 sprite1

如圖:4-3

image

圖 4-3

在 hitTestRecursive 函式內把 for 迴圈內的 nestedHit 列印出來

在 EventBoundary.ts 檔案的 407 - 539 行 :

protected hitTestRecursive(
    currentTarget: DisplayObject,
    eventMode: EventMode,
    location: Point,
    testFn: (object: DisplayObject, pt: Point) => boolean,
    pruneFn?: (object: DisplayObject, pt: Point) => boolean
): DisplayObject[]
{
    ...省略部分程式碼
    if (currentTarget.interactiveChildren && currentTarget.children)
    {
        const children = currentTarget.children;

        for (let i = children.length - 1; i >= 0; i--)
        {
            const child = children[i] as DisplayObject;

            const nestedHit = this.hitTestRecursive(
                child,
                this._isInteractive(eventMode) ? eventMode : child.eventMode,
                location,
                testFn,
                pruneFn
            );
            console.log(nestedHit)
            ...省略部分程式碼
        }
    }
    ...省略部分程式碼
}

測試,點選與左側logo重疊的位置右側紅色的 pixijs logo

image

圖 4-4

輸出結果確實是正確的,並沒有把 sprite1 輸出,仔細看 for 迴圈可以發現它是倒序遍歷的,也就是新增在最後的顯示物件,先響應碰撞

因為後面新增的物件理論上是覆蓋在上層,所以應該先響應碰撞

如果把 for 迴圈的遍歷順序改成正序,那麼就會輸出 sprite1

...省略部分程式碼
for (let i = 0; i < children.length; i++)
{
    const child = children[i] as DisplayObject;

    const nestedHit = this.hitTestRecursive(
        child,
        this._isInteractive(eventMode) ? eventMode : child.eventMode,
        location,
        testFn,
        pruneFn
    );
    console.log(nestedHit)
    ...省略部分程式碼
}

image

圖 4-5

可以看到,透過 for 迴圈的倒序遍歷就實現了蓋在上層的顯示物件優先響應的功能

本章小節

碰撞檢測的粗略流程:

EventSystem.ts 的 init -> pointerdown -> onPointerDown -> mapPointerDown -> createPointerEvent -> hitTest -> hitTestRecursive -> hitTestFn -> containsPoint

事件的檢測為什麼不是畫素級:

與 EaselJS 庫的畫素級碰撞檢測不同, PixiJS 採用的是點與形狀的碰撞檢測

https://github.com/pixijs/pixijs/wiki/v5-Hacks#pixel-perfect-interaction

繞了一圈函式呼叫,才成功實現碰撞檢測,下一篇再關注一下,碰撞檢測成功後,派發事件


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

相關文章