Jump to content

Performance advice and basic concepts


Recommended Posts

Hello, I'm brand new to the world of graphics programming and just starting to jump into Pixi.js for a personal hobby project I'm starting. The documentation for Pixi however seems very scarce though, especially for v5, so I'm kind of having trouble knowing where to start. 

To keep it simple, my project is essentially Conway's Game of Life, though I'll be adding more onto it after I get my performance sorted out. Basically it's a grid with squares, and the squares can either be black or white depending on their state (alive or dead, respectively). The game runs fine at a full 60 fps for smaller grids, but if I try a 150x200 square grid, the performance degrades quite a bit. And I actually want to go quite further than that, with maybe 1000 x 1000 if that's even possible. I'm using Graphics to draw the squares using the same instance and calling clear() on each frame, but honestly my implementation isn't really the point of this post, I'm more interested in what you would do.

So that said, I'll be asking two things:

  • What are the best options for rendering a large number of squares that are all the same, but with different colours depending on their state? Any common performance pitfalls to watch out for?
    • Considerations:
      • They will very often switch back and forth on each frame, but a large portion of the grid might stay static half or more of the time
      • I would ideally not write a custom shader, because I don't know how to do that or what a shader is

 

  • Can you give a rundown of when to use Graphics, Sprites, Textures, and Meshes? And maybe what they are?

If I were to try and explain what I think they are, I would say:

  • Graphics: Draws shapes and lines
  • Sprites: Represents an "actor" on the stage, textures are used to represent them
  • Textures: Basically images that sprites use for visuals?
  • Mesh: Combines many textures? I'm not actually sure

Am I close? Probably not. It's fine. At least I tried. You don't need to go into tons of detail because I'm sure books have probably been written about this, but I just want a basic idea of what they are and when I should use them.

 

Hopefully I'm not asking for too much, thanks a lot!

 

Link to post
Share on other sites

Very simple optimization. Instead of graphics use sprites with single white rectangle as their basetexture. Then apply tint to them to color the sprite. That way the squares can be rendered as a batch. That should be good enough for 150*200 squares (30k sprites). But for 1000 x 1000  (1M squares) you need to go deep into webgl rendering or have some other optimization strategy. Or would those squares be all visible at the same time? If not, then that would be doable by separating logic from rendering and only rendering a subsection of the whole area.

And here's a little rundown about different graphic objects:

- Graphics: Dynamically drawn content. Use when you need to draw lines, shapes etc. Be aware that updating graphics every frame might be costly depending on the complexity.
- Sprites: Sprites are basically just to tell what texture to draw and where, with this rotation, tint and scale. Sprites are among the cheapest objects in pixi.
- Textures: Textures are a region of baseTexture. They tell you what part of a baseTexture should be drawn. When using spritesheets the difference between texture and baseTexture is very noticable. When using just regular images then usually textures point just to a baseTexture and say that I want to render that whole thing.
- Basetexture: Basetextures represent a single image in memory.
- Mesh: Meshes are renderables that have customizable vertices. You could think that sprite is also a mesh that has 4 vertex points (topleft, topright, bottomright and bottomleft). With Meshes you can control how your polygons get formed. There are some premade mesh classes that provide premade useful meshes: SimpleRope, SimpleMesh and SimplePlane. Those abstract some of the complexity away.

And when to use them:
Graphics: Dynamic drawn content.
Sprites: Images with basic affine transformations (scale, rotation, position) and basic color transformation (tint, alpha).
Textures & BaseTexture: Pretty much always if you have some images to use. Very often these get handled automatically.
Mesh: When you need deformations.

Also here's a short instruction on shaders:

Modern computer graphics cards have a pipeline where you tell what program (vertex + fragment shader) you want to use, what vertices it gets as input and what uniforms (+ other stuff that I wont go into at this point). Then for each vertex it runs the vertex shader program. This basically calculates where on the screen should the point be. Then for the polygons formed by these vertices it runs the fragment shader for each pixel that is inside the polygon. The fragment shader returns the color value for that pixel. The uniforms mentioned beforehand are values that stay the same for both vertex and fragment shader on all vertex & pixel values. They are used to give values that are needed to calculate the output values. In sprite fragment shader "tint" is an uniform that is multiplied with the texture value.

So basically your gpu renders wegbl like this (simplified): list of points to draw -> vertex shader -> find out what pixels are affected -> fragment shader -> pixel to screen.

Link to post
Share on other sites

Thanks so much for the detailed answer! Shaders sound like they would be pretty hard to write for someone who doesn't know WebGL, so I'll start smaller for now with your suggested solution.

Also if you don't mind a few more questions:

If the squares would be rendered in batches, what do you think would be the main bottlenecks in scaling from 30k to 1M squares? And would RenderTexture be better/worse? I've seen that suggested in other threads for similar questions.

And also, do Pixels (or maybe pixi in general) have built-in caching? Like if half of my squares don't change, do they need to be re-painted?

I appreciate the help 🙂

Link to post
Share on other sites

The bottleneck when rendering squares is just the amount of squares you would need to render if you used basic sprites.

Rendering the squares to rendertexture and then rendering that texture to screen would make the frames where rendering doesnt change faster. But when the rendertexture needs to be rerendered then it would still take some time.

And in webgl the whole frame gets repainted every time.

If you are sufficient with having pixels be the squares and dont need anything fancy like borders / textures to squares then you could use additional 2d canvas and do the game of life operations on that and then render that canvas inside pixi. That way you could have the game of life update only the canvas data and still have smooth scrolling/zooming if needed.

Link to post
Share on other sites

So Sprites definitely helped, but I'm trying to push it a bit farther, and there seems to be way more render calls than there should be. Here's one of my better frames:

image.thumb.png.8243d3b1ee2573aaf695641f5f55debc.png

Looks like there's 9 calls to AbstractBatchRenderer.flush(), even though they're all using the same Texture/BaseTexture. The only thing changing in this frame is that drawCells() is changing the tint of a bunch of the Sprites. Does the default batch renderer not automatically know how to handle this or am I doing something wrong here?

Here's my code for drawCells(): (I'm running the game logic with wasm in a web worker)

const drawCells = async () => {
  const cells = await worker.tick();

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const idx = getIndex(row, col);

      if (cells[idx] === Cell.Alive) {
        cellSprites[idx].tint = ALIVE_COLOR;
      } else if (cellSprites[idx].tint !== DEAD_COLOR) {
        cellSprites[idx].tint = DEAD_COLOR;
      }
    }
  }
};

And my ticker is literally just this:

app.ticker.add(async () => await drawCells());

 

Link to post
Share on other sites
28 minutes ago, ShoemakerSteve said:

So Sprites definitely helped, but I'm trying to push it a bit farther, and there seems to be way more render calls than there should be. Here's one of my better frames:

image.thumb.png.8243d3b1ee2573aaf695641f5f55debc.png

Looks like there's 9 calls to AbstractBatchRenderer.flush(), even though they're all using the same Texture/BaseTexture. The only thing changing in this frame is that drawCells() is changing the tint of a bunch of the Sprites. Does the default batch renderer not automatically know how to handle this or am I doing something wrong here?

Here's my code for drawCells(): (I'm running the game logic with wasm in a web worker)


const drawCells = async () => {
  const cells = await worker.tick();

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const idx = getIndex(row, col);

      if (cells[idx] === Cell.Alive) {
        cellSprites[idx].tint = ALIVE_COLOR;
      } else if (cellSprites[idx].tint !== DEAD_COLOR) {
        cellSprites[idx].tint = DEAD_COLOR;
      }
    }
  }
};

And my ticker is literally just this:


app.ticker.add(async () => await drawCells());

 

Why do you iterate over row and col to get an index, why not iterate directly over cells? 

Otherwise I think that a shader is the most performant way to do it. Here's an example.

Link to post
Share on other sites
2 hours ago, ShoemakerSteve said:

So Sprites definitely helped, but I'm trying to push it a bit farther, and there seems to be way more render calls than there should be. Here's one of my better frames:

image.thumb.png.8243d3b1ee2573aaf695641f5f55debc.png

Looks like there's 9 calls to AbstractBatchRenderer.flush(), even though they're all using the same Texture/BaseTexture. The only thing changing in this frame is that drawCells() is changing the tint of a bunch of the Sprites. Does the default batch renderer not automatically know how to handle this or am I doing something wrong here?

Here's my code for drawCells(): (I'm running the game logic with wasm in a web worker)


const drawCells = async () => {
  const cells = await worker.tick();

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const idx = getIndex(row, col);

      if (cells[idx] === Cell.Alive) {
        cellSprites[idx].tint = ALIVE_COLOR;
      } else if (cellSprites[idx].tint !== DEAD_COLOR) {
        cellSprites[idx].tint = DEAD_COLOR;
      }
    }
  }
};

And my ticker is literally just this:


app.ticker.add(async () => await drawCells());

 

How many sprites you have? There's a batchsize limit. When that is reached the current batch is rendered and new one is starting. You can change that size with PIXI.Settings.SPRITE_BATCH_SIZE. Default is 4096.

Link to post
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Loading...
  • Recently Browsing   0 members

    No registered users viewing this page.

×
×
  • Create New...