Rendering Fast Graphics with PixiJS

Matt Karl
4 min readSep 29, 2022

As one of the maintainers of PixiJS, I see a variety of issues and posts from users. In general, these usually are from folks trying to learn and understand how best to render a project as fast as possible. One of the common questions, usually from developers trying to create map or data visualization application, is: I want to render [insert large number here] Graphics, what is the most efficient way to do that? Good question!

While PixiJS is very fast, it is not a magic-bullet for all rendering conditions. The more work PixiJS is given, the more helpful it is to understand what’s happening below the surface. In this post, I’ll highlight some of the fundamentals to keep in mind when rendering lots of Graphics.

Fundamentals About Graphics

PixiJS has a Graphics class, if you want a primer about Graphics API, strongly encourage you to read this guide. Simply put, Graphics takes a user’s instructions about what to draw (e.g., shapes, fill, strokes, style) and converts it into something WebGL can render: triangles. More specifically, we call this geometry. This process of converting draw instructions into geometry is called triangulation. PixiJS uses a library called earcut to do this step asynchronously before uploading the GPU. The more complicated the instructions, the more work to triangulate and the more work it takes to render.

Triangulation of a capital letter “A”

Batching is an important concept for how PixiJS renders so efficiently. For more information about batching fundamentals, checkout this video from PixiJS creator Mat Groves or read this technical article from PixiJS contributor Shukant Pal. For our purposes, we want to make sure our output geometry is under PixiJS’s arbitrary limit of 100 points (i.e., GraphicsGeometry.BATCHABLE_SIZE).

Graphics Gotchas

Curves are Adaptive By Default

Whenever you draw curves (bezier curves, circles, rounded rectangles, etc) the curves are adaptively drawn. That means the size of the curve determines how many vertices it is subdivided into. Here’s an example of the difference between a radius of 100 and a radius of 1. This optimization is designed to render as few triangles as necessary. Settings used for adaptive drawing can be changed using the GRAPHICS_CURVES API.

Demonstration between adaptive circle drawing, showing the number of points in each shape

Existing Shapes are Immutable

Once you draw a shape with Graphics, the shape cannot be changed. This means that changing the style (texture fill, color, stroke) all require clearing the Graphics first. Because regenerating geometry can be expensive, it is better to use tint, scale and position to manipulate the Graphics object, instead of redrawing.

How to Draw 1000 Circles

Attempt 1 — Flat Draw, Single Graphics

Let’s start off by first rendering 1000 circles to a single Graphics object. I’m going to use 1000 as an arbitrary large value. This approach is not practical for a number of reasons:

  1. You cannot manipulate the circles individually, requiring a complete redraw every time which means taking a few frames to re-generate the geometry.
  2. The total number of points is 370K, which will break batching in a scene with other sprites causing an overall slow-down.
  3. It takes several frames to generate the geometry and upload to the GPU, which will cause slow-downs even if done sporadically.
Single Graphics object use to render 1000 circles

Attempt 2 — Shared Geometry

Instead of a single Graphics object, we create multiple Graphics that all share the same geometry. The only optional constructor for Graphics is reference to another GraphicsGeometry instance. This approach is faster to update (less than a frame), and it reduces 370,000 points to 370 points. This approach still falls short in a few areas:

  1. Still above the 100 point threshold for optimal batching.
  2. Color fill is not dynamic.
Example of 1000 circles with shared GraphicsGeometry

Attempt 3 — Shared Geometry, Tinting & Scaling

This attempt gets our points count under 100, which means that these circles are batchable. Also, by making the color white and applying a tint, we can color these circles anything we want just by changing the color. The downsides of this approach:

  1. Unfortunately, you can start to see the triangulating and these look more like polygons than circles.
  2. Graphics by default do not have anti-aliasing, but enabling it in the Renderer is a huge performance killer.
Example of 1000 Graphics, dynamic color, but lower triangles

Attempt 4 — RenderTexture

Instead of using shared GraphicsGeometry, this approach takes a render of a single Graphics object using a RenderTexture. Then uses this like any Texture and share between Sprites. Essentially, it’s the same effect, but with no Graphics overhead and better antialiasing. If you need to scale your circles, simply render the RenderTexture at a larger initial size.

Example of 1000 circles being pre-rendered with RenderTexture

Summary

Graphics are a powerful tool but if you aim to create performant scene, make sure you pay attention to the complexity of your shapes relative to the size they will appear on screen. Adjusting PixiJS’s adaptive settings or using scale and drawing at a smaller size will get you a better performing result with minimal quality loss.

Bonus Performance Tip About Culling

When dealing with thousands of elements, it’s critically important that you only show the essential elements on screen. Not doing this will dramatically increase your fillrate and slow the performance of your application. Elements that are offscreen (outside the view area) should be hidden and ignored by the Renderer — called culling. Fortunately, PixiJS now has an easy, out-of-the-box solution for that: set the cullableproperty of each display object to true.

--

--

Matt Karl

Tools Architect, Open Source Developer — Senior Software Engineer @ Netflix, Co-owner of PixiJS