響應 Pointer 互動事件(上篇)
上一章我們分析了 sprite 在 canvasRenderer 上的渲染,那麼接下來得看看互動上最重要的事件系統了
最簡單的 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 內
可以看到建構函式內很簡單,
-
傳入了渲染器例項
-
rootBoudary “根邊界” 這個物件很重要,後面會具體介紹
-
單獨建立一個 ticker 用於管理事件,確保執行狀態下顯示物件的碰撞檢測事件
-
例項化了兩個事件物件,用於觸發時傳遞,事件物件內的資料結構
-
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);
}
... 省略部分
}
-
先指定當前 rootBoundary.rootTarget = this.renderer.lastObjectRendered 即響應事件的目標物件"為渲染器最上層的一個顯示物件"
-
適配瀏覽器原生事件 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);
的事件物件打出來看看
圖 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 列印出來看看
圖 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;
}
函式大致流程
-
進來先判斷是否需要進行碰撞檢測
if (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location))
剔除需要進行碰撞檢測的物件,比如遮罩、不可見、不可互動、無需渲染等物件
-
如果有子顯示物件,則需要迴圈所有子顯示物件,並遞迴檢測子顯示物件
-
如果碰撞檢測成功(點選位置有子顯示物件)則在返回的 nextedHit 陣列內把 currentTarget 新增進隊尾, 並返回 nestedHit 陣列
if (nestedHit.length > 0 || isInteractive) nestedHit.push(currentTarget); return nestedHit;
-
最後 如果是 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;
}
主要進行了三個判斷
-
eventMode === 'passive' 直接不進行碰撞檢測,用於最佳化效能,比如在滾動區域內滾動時可以設定內部的元素為 passive
-
displayObject.hitArea 判斷 主要作用
- 自定義互動區域:你可以定義一個特定的區域來響應使用者互動,而不是使用顯示物件的整個邊界框。這在某些情況下非常有用,例如當你有一個複雜形狀的物件,但只希望某個部分響應互動。
- 提高效能:透過定義較小的互動區域,可以減少不必要的命中測試,從而提高效能。
- 精確控制:你可以精確控制哪些區域應該響應使用者互動,這在遊戲開發和複雜的使用者介面中非常有用。
-
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()
-
其它程式碼與
sprite-pointerdown.html
幾乎一樣,就是新增了兩個 sprite 且有一部分重疊在一起 -
分別給這兩個 sprite 分別新增了 _Name 屬性,方便除錯 sprite1 和 sprite2
-
sprite2 的 tint 屬性設定為紅色
-
修改了 sprite2 的x 值,使得 sprite2 只覆蓋一部分 sprite1
如圖:4-3
圖 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
圖 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)
...省略部分程式碼
}
圖 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)