Location>code7788 >text

PixiJS Source Code Analysis Series: Chapter 4 Responding to Pointer Interaction Events (Part 1)

Popularity:277 ℃/2024-08-02 14:31:17

Responding to Pointer Interaction Events (Previous)

In the previous chapter we analyzed the rendering of sprite on the canvasRenderer, so it's time to take a look at the most important event system in terms of interaction.

image

Simplest demo

Let's go with the simplest demoexample/

Add a pointerdown event to the sprite, i.e. a click event, which is a touch event on mobile devices and a click event on desktop devices.

const app = new ({ width: 800, height: 600, autoStart: false });  
();  

const sprite = ('');

('pointerdown', ()=> {
    ('clicked')
})

(sprite);  
()

Try clicking on the sprite with your mouse and you'll see that the console doesn't output the expected 'clicked'.

Strange... Look at the official site for an example, you need to add the = 'static';;

Run it again, and you'll see that the console outputs 'clicked' as normal.

Display objects do not have their own events

Unlike the DOM, Canvas does not have its own system of events for each element to respond to.

You need to implement your own event system, and all interactable elements should be DisplayObjects and their subclasses.

/packages/display/src/ Row 210

export abstract class DisplayObject extends <DisplayObjectEvents>

Note that DisplayObject inherits the EventEmitter class and thus has a customized event system with all the corresponding APIs.

eventemitter3: /primus/eventemitter3

eventemitter3's REAMDME is too simple.

You have to look at its test cases./primus/eventemitter3/blob/master/test/

As you can see, listening to an event can be done with on, and triggering an event can be done with emit.

So an instance of the DisplayObject class in PixiJS can be used to listen to events with on and trigger events with emit.With the ability to customize events

When a display object has the ability to customize events, an event management system is needed to manage the triggering, listening, and removal of events for the display object.

Let's take a look at the EventSystem class

/packages/events/src/ 204-238 lines

constructor(renderer: IRenderer)
  {
     = renderer;
     = new EventBoundary(null);
    (this);

     = true;
     = false;

     = new FederatedPointerEvent(null);
     = new FederatedWheelEvent(null);

     = {
        default: 'inherit',
        pointer: 'pointer',
    };

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

            return true;
        }
    });
     = (this);
     = (this);
     = (this);
     = (this);
     = (this);
  }

(EventSystem); which integrates it into pixiJS as an extension plugin.

You can see that the constructor is simple within the

  1. Renderer instances are passed in

  2. rootBoudary "Root Boundary" This object is important and will be described later.

  3. Create a separate ticker to manage events and ensure that collision detection events for objects are displayed in the running state

  4. Two event objects are instantiated for passing when triggered, and the data structure within the event objects

  5. onPointerDown/onPointerMove/onPointerUp/onPointerOverOut/onWheel etc. are bound to this.

When EventSystem is added to PixiJS management, the Runner 'init' is triggered, which means that the init lifecycle function is triggered.

In lines 245 - 254:

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

    (view as HTMLCanvasElement);
     = resolution;
    EventSystem._defaultEventMode =  ?? 'auto';
    (,  ?? {});
     = ;
}

As you can see, setTargetElement is used to set the target element of the event, which is the view that corresponds to the renderer. You can think of this view as the canvas itself, which is able to respond to DOM events from the browser, including, of course, mouse clicks, movements, and so on.

The setTargetElement function will eventually call addEvents().

In lines 483 - 546:

private addEvents(): void
    {
      ... Omit part of the source code
        if ()
        {
            ('pointermove', , true);
            ('pointerdown', , true);
            ... Omit part of the source code
            ('pointerup', , true);
        }
        else
        {
            ('mousemove', , true);
            ('mousedown', , true);
            ... Omit part of the source code

            if ()
            {
                ('touchstart', , true);
               ... Omit part of the source code
            }
        }

        ('wheel', , {
            passive: true,
            capture: true,
        });

         = true;
    }

This function is where the event listener is actually added for the root element (or rather, the element within the entire canvas where the custom event initiates the event).

Use the pointer event if it is supported.

If the pointer event is not supported then the mouse event is used.

Add the touch event if it is supported.

Note that move-related events are added to the document element.

Up to this point, when the user clicks on the canvas element, the relevant callback functions are executed, such as the registered , etc.

The custom event of eventemitter3 is triggered within the callback function.

Using our example in the same way, we've registered the callback function in ('pointerdown', function() {}), which is triggered when the user clicks on the canvas element.

In the onPointerDown

In lines 343 - 377:

private onPointerDown(nativeEvent: MouseEvent | PointerEvent | TouchEvent): void
{
... Omitting some of the source code
= as DisplayObject;

const events = (nativeEvent);
... Omit part of the source code

for (let i = 0, j = ; i < j; i++)
{
    const nativeEvent = events[i];
    const federatedEvent = (, nativeEvent);

    (federatedEvent);
}

... an omission

}

  1. First specify the current = i.e., the target object that responds to the event "as a display object in the topmost level of the renderer".

  2. After adapting the browser native event nativeEvent, call (federatedEvent), federatedEvent will be normalized to PixiJS custom event.

EventBoundary

In order for elements drawn inside a canvas to respond accurately to user clicks, you must first determine the scope of the user's click, and then trigger the user-bound click event callbacks for the DisplayObject display elements within the scope.

/packages/events/src/

Constructor lines 149 - 172:

constructor(rootTarget?: DisplayObject)
  {
       = rootTarget;

       = (this);
       = (this);
       = (this);
       = (this);
       = (this);
       = (this);
       = (this);
       = (this);
       = (this);

       = {};
      ('pointerdown', );
      ('pointermove', );
      ('pointerout', );
      ('pointerleave', );
      ('pointerover', );
      ('pointerup', );
      ('pointerupoutside', );
      ('wheel', );
  }

After the constructor indicates that it is instantiated, the addEventMapping method saves the callback mapping functions for eight types of events, including pointerdown, pointermove, pointerout ...., etc., in the addEventMapping method. The addEventMapping method saves the callback mappings for 8 types of events in themappingTable in-object

Interaction events added by the user to the display object during subsequent use will be stored in the corresponding list of 8 types of events

This mapPointerDown is triggered when the mouse clicks on the sprite display object in the example.

Constructor lines 672 - 701:

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

        return;
    }

    const e = (from);
    ()
    (e, 'pointerdown');

    if ( === 'touch')
    {
        (e, 'touchstart');
    }
    else if ( === 'mouse' ||  === 'pen')
    {
        const isRightButton =  === 2;

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

    const trackingData = ();

    [] = ();

    (e);
}

When the view is clicked, the event object is created, then an event is sent to the target object, and the next step is to find that target object.

Find the target object, i.e. the object to click on

Put the mapPointerDown function'sconst e = (from); to see if the event object of the

image

Figure 4-1

Sure enough, the current click is on the sprite, which is obviously confirmed at this step.Currently clicked object (from); Method calls are very important

createPointerEvent should be on lines 1181 - 1205 of the file:

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

    (from, event);
    (from, event);
    (from, event);

     = ;
     = from;
     = target
        ?? (, ) as FederatedEventTarget
        ?? this._hitElements[0];

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

    return event;
}

It can be seen that it is in thiscreatePointerEvent Call hitTest or _hitElements inside a method.

Note that the mapPointerDown method calls within theconst e = (from); When passing only one parameter from

So here the target is determined by the(, ) as FederatedEventTarget inseparable

Passes in the global x, y coordinates of the current event to the hitTest method.

At lines 247 - 265 of the document:

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

    return invertedPath && invertedPath[0];
}

Because of the special handling of the move event, it is necessary to determine if hitTestMoveRecursive || hitTestRecursive was called within the hitTest function.

We're using the click event in the current demo, so the call will be hitTestRecursive.

Here, print it out.

image

Figure 4-2

You can see in Figure 4-2 that the current rootTarget is a container object.

When the click event occurs, you need to judge not only the current object, but all the children under the current container, that's why you need to use hitTestRecursive, which is a recursive judgment.

recursive traversal

The two most recent arguments to the hitTestRecursive function are the hitTestFn function, which is used for specific collision detection, and the hitPruneFn function, which is used to determine whether or not a collision can be rejected.

At lines 407 - 539 of the document:

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 ( === 'dynamic' || eventMode === 'dynamic')
    {
         = false;
    }

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

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

            const nestedHit = (
                child,
                this._isInteractive(eventMode) ? 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 ( > 0 && !nestedHit[ - 1].parent)
                {
                    continue;
                }

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

                if ( > 0 || isInteractive) (currentTarget);

                return nestedHit;
            }
        }
    }

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

    // 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;
}

Rough Flow of Functions

  1. Come in and determine if collision detection is needed firstif (this._interactivePrune(currentTarget) || pruneFn(currentTarget, location))

    Exclude objects that require collision detection, such as masked, invisible, non-interactive, and non-renderable objects

  2. If there are subdisplay objects, you need to loop through all subdisplay objects and recursively detect the subdisplay objects

  3. If the collision detection is successful (the clicked position has a child object), add currentTarget to the end of the queue in the returned nextedHit array, and return the nestedHit array.

    if ( > 0 || isInteractive) (currentTarget);
    return nestedHit;
    
  4. Finally, if isInteractiveMode and testFn collision detection was successful, put the current collision object into the array and return it.

Notice the line inside this functionconst isInteractiveMode = this._isInteractive(eventMode);

At lines 541-544 of the document:

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

Now we know why our demo doesn't respond to clicks when we don't specify eventMode as static or dynamic.

This detects the rootTarget's parent, so if the parent, such as a container, doesn't support interaction, you don't need to collide with its children.

there areconst isInteractiveTarget = (); This line, which determines whether the element itself is interactive, also determines the eventMode, which detects the current target's

exist/packages/events/src/ Lines 657 - 660 within the event definition:

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

The next step is to use collision detection to detect whether it is a clicked object or not

Find the collision detection function

Look at the last testFn, the incoming hitTestFn collision detection function.

In lines 615 - 637 of the document:

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

    // If the display object failed pruning with a hitArea, then it must pass it.
    if ()
    {
        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;
}

Three main judgments were made

  1. eventMode === 'passive' Directly without collision detection, used to optimize performance, such as scrolling in the scroll area can be set inside the element passive

  2. Judgement Main role

    • Custom interaction area: Instead of using the entire bounding box of the display object, you can define a specific area to respond to user interaction. This can be useful in certain situations, such as when you have a complex shaped object, but only want a certain part to respond to interaction.
    • Improved performance: By defining smaller interaction areas, unnecessary hit tests can be reduced, thus improving performance.
    • Precise control: You can precisely control which areas should respond to user interaction, which is useful in game development and complex user interfaces.
  3. detection, it can be seen that thecontainsPoint are methods that are implemented by the display object itself

Attention: method, it can be seen, if you are drawing a straight line, Bezier curves and other lines to add mouse events will not work, because these are just paths, not shapes, you need to add a hitArea for these to interact with the event will work!

Implemented as a sprite classcontainsPoint give an example

/packages/sprite/src/ Lines 439 - 459:

public containsPoint(point: IPointData): boolean
{
    (point, tempPoint);

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

    if ( >= x1 &&  < x1 + width)
    {
        y1 = -height * ;

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

    return false;
}

sprite's containsPoint determines whether a coordinate point is inside the rectangle of the displayed object. It is easier to convert the global coordinate point to the local coordinate point of the sprite, and then determine whether it is inside the rectangle.

method, pass in a coordinate point and return a new coordinate point converted from world coordinates to local coordinates, which is the sprite local coordinate point.

How to handle collision detection on sprite overlays

If it's just simple collision detection of points and shapes, then if two display objects are stacked on top of each other, clicking on the upper display pair, if left untreated, will result in the object stacked underneath also responding to the click event

Create a new demoexample/

const app = new ({ width: 800, height: 600, autoStart: false });  
();  

const sprite = ('');  
 = 'static';
sprite._Name = 'sprite1';
('pointerdown', ()=> {
    ('clicked')
})

const sprite2 = ('');
 = 'red';
 = 'static';
sprite2._Name = 'sprite2';
 = 100
('pointerdown', ()=> {
    ('clicked2')
})

(sprite);  
(sprite2);  
()
  1. Other codes are the same as It's almost the same, except that two sprites are added and partially overlapped.

  2. The _Name attribute is added to each of the two sprites to make it easier to debug sprite1 and sprite2.

  3. The tint attribute of sprite2 is set to red.

  4. Changed the x-value of sprite2 so that sprite2 only covers part of sprite1.

As shown in Figure: 4-3

image

Figure 4-3

Print out the nestedHit inside the for loop in the hitTestRecursive function.

At lines 407 - 539 of the document:

protected hitTestRecursive(
    currentTarget: DisplayObject,
    eventMode: EventMode,
    location: Point,
    testFn: (object: DisplayObject, pt: Point) => boolean,
    pruneFn?: (object: DisplayObject, pt: Point) => boolean
): DisplayObject[]
{
    ...Omit part of the code
    if ( && )
    {
        const children = ;

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

            const nestedHit = (
                child,
                this._isInteractive(eventMode) ? eventMode : ,
                location,
                testFn,
                pruneFn
            );
            (nestedHit)
            ...Omit part of the code
        }
    }
    ...Omit part of the code
}

To test, click on the red pixijs logo on the right side where it overlaps with the logo on the left.

image

Figure 4-4

The output is indeed correct, and does not output sprite1. A closer look at the for loop reveals that it is traversed in reverse order, i.e., the display object added at the end responds to the collision first.

Because objects added later are theoretically overlaying the upper layer, they should respond to collisions first

If you change the traversal order of the for loop to positive, then you output sprite1

...Omit part of the code
for (let i = 0; i < ; i++)
{
    const child = children[i] as DisplayObject;

    const nestedHit = (
        child,
        this._isInteractive(eventMode) ? eventMode : ,
        location,
        testFn,
        pruneFn
    );
    (nestedHit)
    ...Omit part of the code
}

image

Figure 4-5

As you can see, by traversing in reverse order through the for loop, you can prioritize the response of the display objects that are on top.

subsection (of a chapter)

Rough flow of collision detection:

(used form a nominal expression) init -> pointerdown -> onPointerDown -> mapPointerDown -> createPointerEvent -> hitTest -> hitTestRecursive -> hitTestFn -> containsPoint

Why the detection of events is not pixel-wise: the

Unlike the EaselJS library's pixel-level collision detection, PixiJS uses point-and-shape collision detection

/pixijs/pixijs/wiki/v5-Hacks#pixel-perfect-interaction

It took a round-robin of function calls to successfully implement collision detection, so we'll focus on that in the next post, and after successful collision detection, thedispatch