Maximising Performance: A Deep Dive into PixiJS Optimization

Ergin Turk - Sep 4 - - Dev Community

Take your PixiJS Applications to the Next Level with Advanced Strategies and Techniques

Foreword

This post goes over the different ways one can best optimize the rendering of multiple elements within pixiJS in terms of both CPU / Memory. For example, considering the difference between re-rendering every frame without any caching-which performs well in terms of CPU usage-or caching a rendered graphics in memory. This will increase the memory usage in proportion to the number of graphics in the scene.

There are a number of strategies for dealing with such optimizations. Of particular note is Data-Oriented Design, which presents a radically alternative set of approaches from the more traditionally common Object-Oriented way of programming.

Other major ways include: culling and utilizing far more structured formats - NativeArrays in C# and TypedArrays in TypeScript, for example. These will allow much greater management of memory buffers, which may limit cache-misses, but which also need significant engineering experience and/or customisation.

In this post, I will be focusing on one working method of optimization for a WebGL environment with PixiJS: The Object-Oriented Approach, inclusive of best practices. This shall provide you with a well-organized means to increase speed and efficiency in your PixiJS applications.

In my next article, I will be talking about another strong optimization approach: the Entity-Component-System Approach. The ECS approach is strikingly data-oriented and offers a fresh look when it comes to optimizing PixiJS in high-performance environments. Continue on Medium for this article where, in-depth, I go into the nitty-gritty of the ECS approach.

Always remember that there is always something that can be done better in an attempt to optimize and further enhance the performance of your Pixi application. By better, it does not mean most optimized or fastest. Best solution is a question of trade-off between amount of time you invest in an optimization and return on that investment to make sure you can meet the project deadlines but with enough optimization to hopefully satisfy any potential users without over-expanding your resources.

Object Oriented Approach

In this section, I'm going to guide you through the best ways of optimizing PixiJS applications.

This section is based on official tips, worth to check!

The rest of our discussion will revolve around Pixi Graphics, Sprites, Meshes and when to use a Particle Container instead of the default Pixi Container. This chapter should give you a clear view of how all can be used optimally in an Object-Oriented context so your PixiJS projects are functional and rendered with the greatest efficiency.

Understanding the Inner Workings of Pixi Graphics

In order to effectively use Pixi graphics, we need to understand how they function internally. So let's start by showing a very basic example of creating a graphics object in Pixi:

const graphics = new PIXI.Graphics();
graphics.beginFill(0xff0000);
graphics.drawRect(0, 0, 200, 100);
graphics.endFill();
Enter fullscreen mode Exit fullscreen mode

What is important in this simple implementation, however, is what happens 'under the hood'. In creating this kind of graphic, Pixi creates something called a GraphicsGeometry object. That object takes on the shape and size based on the dimensions and properties you specify for the shape you are drawing. The final Geometry object is then stored inside a GeometryList within the Graphics object.

Note that each time you are drawing something with the help of PIXI.Graphics, GeometryList gets updated. Sometimes, you just want to clear this list, but at the same time keep your Graphics object alive-that is where the .clear() method comes into play. Knowing how this process works will help you greatly when using Pixi, as it directly affects how Pixi will handle and render the graphics in your app.

Optimization Techniques for Pixi Graphics

Let's explore optimization strategies through a use case of creating 100 Graphics objects in PixiJS.

function createGraphics(x, y) {
    const graphic = new PIXI.Graphics();
    graphic.beginFill(0xDE3249);
    graphic.drawCircle(x, y, 10);
    graphic.endFill();
    return graphic;
}

for (let i = 0; i < 100; i++) {
    const x = Math.random() * app.screen.width;
    const y = Math.random() * app.screen.height;
    const graphics = createGraphics(x, y);
    app.stage.addChild(graphic);
}
Enter fullscreen mode Exit fullscreen mode

In this scenario, if all 100 Graphics objects share the same width and height, we can optimize by reusing the geometry.

Performance result for the example

Passing GraphicsGeometry as Reference

Create a single geometry for a circle and reuse it:

// Create a single geometry for a circle
const circleGeometry = new PIXI.Graphics();
circleGeometry.beginFill(0xDE3249);
circleGeometry.drawCircle(0, 0, 10); // Draw a circle at the origin
circleGeometry.endFill();
// Function to create a graphic using the circle geometry
function createCircle(x, y) {
    const circle = new PIXI.Graphics(circleGeometry.geometry);
    circle.x = x;
    circle.y = y;
    return circle;
}
// Create 100 circles using the same geometry
for (let i = 0; i < 100; i++) {
    const x = Math.random() * app.screen.width;
    const y = Math.random() * app.screen.height;
    const circle = createCircle(x, y);
    app.stage.addChild(circle);
}
Enter fullscreen mode Exit fullscreen mode

This method significantly reduces memory usage by referencing the same geometry instead of duplicating it for each object.

Performance result for the example

Draw All in One Graphics Object

For static graphics or complex structures, drawing all elements in a single Graphics object is another optimization technique:

const graphics = new PIXI.Graphics();
// Draw 100 circles using the same PIXI.Graphics instance
for (let i = 0; i < 100; i++) {
    const x = Math.random() * app.screen.width;
    const y = Math.random() * app.screen.height;
    graphics.beginFill(0xDE3249);
    graphics.drawCircle(x, y, 10);
    graphics.endFill();
}
// Add the graphics to the stage
app.stage.addChild(graphics);
Enter fullscreen mode Exit fullscreen mode

In this approach, instead of creating new Graphics objects, we add new geometries to the GeometryList of a single Graphics instance. This method is particularly efficient for more complex graphic structures.

Performance result for the example


Leveraging the Power of CacheAsBitmap in PixiJS

One of the most powerful features within PixiJS is CacheAsBitmap. Essentially, it lets the engine treat graphics like sprites. This can bring performance up substantially in certain cases.

  • Only use CacheAsBitmap if the object is not updated too often.

  • Big batch of Graphics can be cached as bitmap in container. Instead having 100 Graphics re-rendered, pixi will take a snapshot and pre-render it as a bitmap.

  • Always consider the memory usage, cached bitmaps are using a lot of memory.

When to Use CacheAsBitmap

One should use cacheAsBitmap judiciously. It will be most effective when applied to objects that need to update seldom. For instance, if one happens to have thousands of volume of Graphics that are static or have only a rare change, caching them as a bitmap radically reduces rendering overhead.

Instead of re-rendering 100 individual Graphics, PixiJS can take a 'snapshot' of these and render them as single bitmap. This is how you can implement:

const graphicsContainer = new PIXI.Container();
// Add your graphics to the container
// ...
// Cache the entire container as a bitmap
graphicsContainer.cacheAsBitmap = true;
Enter fullscreen mode Exit fullscreen mode

Memory Usage Consideration

However, it's important to be mindful of memory usage. Cached bitmaps can consume a significant amount of memory. Therefore, while cacheAsBitmap can drastically reduce the rendering load, it trades off by using more memory. This trade-off should be carefully considered based on the specific needs and constraints of your application.

In summary, cacheAsBitmap is an effective tool for optimizing performance in PixiJS, particularly for static or seldom-updated graphics. It simplifies rendering by treating complex graphics as single bitmaps, but it's essential to balance this with the memory footprint implications.

Why Sprites Are Often More Efficient than Graphics in PixiJS

When it comes to memory efficiency in PixiJS, sprites generally have the upper hand over graphics. This is particularly evident when dealing with multiple objects that share the same shape or texture. Let's revisit the example of creating 100 circle graphics, but this time using sprites.

Creating Sprites from a Single Texture

First, we create a texture from the geometry of a single circle graphic:

const circleGraphic = new PIXI.Graphics();
circleGraphic.beginFill(0xDE3249);
circleGraphic.drawCircle(0, 0, 10);
circleGraphic.endFill();
// Generate a texture from the graphic
const circleTexture = app.renderer.generateTexture(circleGraphic);
Next, we use this texture to create sprites:
// Function to create a sprite using the circle texture
function createCircleSprite(x, y) {
    const sprite = new PIXI.Sprite(circleTexture);
    sprite.x = x;
    sprite.y = y;
    return sprite;
}

// Create and add 100 circle sprites to the stage
for (let i = 0; i < 100; i++) {
    const x = Math.random() * app.screen.width;
    const y = Math.random() * app.screen.height;
    const circleSprite = createCircleSprite(x, y);
    app.stage.addChild(circleSprite);
}
Enter fullscreen mode Exit fullscreen mode

In this approach, instead of re-rendering graphics and managing a growing geometry list for each object, we create one texture and reuse it across multiple sprites. This significantly reduces the rendering load and memory usage.

Limitations and Creative Solutions

One limitation of this method is that you're constrained by the textures you've created. However, this is where creativity becomes key. You can generate various shaped textures using PIXI.Graphics and apply them to Sprites. An especially efficient approach is to create a baseTexture, like a 1x1 pixel bitmap, and reuse it for all rectangular sprites. By resizing the sprite to different dimensions, you can leverage the same baseTexture across multiple sprites without redundancy.
For instance:

// This creates a 16x16 white texture
const baseTexture = PIXI.Texture.WHITE;

// Use this baseTexture for all rectangular shapes
const sprite= new PIXI.Sprite(baseTexture);
sprite.tint = 0xDE3249; // Set the sprite color
sprite.position.set(x, y);
sprite.width = width;
sprite.height = height;

Enter fullscreen mode Exit fullscreen mode

Performance result for the example

With this method, .tint() allows you to color the sprite without triggering a full re-render, as the tint is applied as an additional shader effect directly on the GPU.

Using 100k Sprites in Particle Container

To illustrate the power of this technique, imagine running 100,000 individual sprites with random tints, each transforming on every frame, all while maintaining a smooth 60 FPS.

Performance result for the example

Performance result for the example

For further reading on optimizing PixiJS, I highly recommend an insightful article by one of the original creators of PixiJS, which delves deeply into the renderTexture technique. 

You can find it here

Wow! If you've made it this far, I want to sincerely thank you for sticking with me through this deep dive into PixiJS optimization. I hope you found the insights and techniques shared here valuable for your projects. Stay tuned for my next article, where I'll be exploring the Entity-Component-System (ECS) approach and the power of NativeArrays in even greater detail. These methods will take your PixiJS applications to new heights in performance and efficiency. Thanks for reading, and see you in the next one!

.