pwmarcz.pl

Blog » Making Grass, part 3: Scene graph and performance

Making Grass, part 3: Scene graph and performance


Part of a series about Grass, a real-time roguelike engine with pretty graphics.

Updating scene graph

As I said, the PixiJS graphics library makes it easy to draw things on a screen. It maintains a scene graph pretty similar to DOM, with Containers containing smaller objects, Sprites, as well as Graphics and Text elements. In the beginning, this was pretty convenient: I could just create a sprite for every map square, a sprite for every mob, and keep updating them when the world changed.

But then more and more changes came. The mobs (monsters) are moving, so I want to update their position in every frame. This is simple:

update() {
  for (const mob of world.mobs) {
    const sprite = this.mobSprites[mob.id];
    sprite.x = mob.x * TILE_SIZE;
    sprite.y = mob.y * TILE_SIZE;
    // (Do something more clever if the mob is currently moving,
    // i.e. between two positions.)
  }
}

But then, what if I want to allow opening a door? The terrain tile will also change. Should I loop over all the tiles?

for (let y = 0; y < h; y++) {
  for (let x= 0; x < w; x++) {
    const terrain = world.map[y][x];
    const sprite = this.terrainSprites[y][x];
    sprite.texture = textureFor(terrain);
  }
}

But it's terribly inefficient to loop over a large map in each frame, even if only one tile has changed. So maybe I should listen for changes by adding a callback?

changeTerrain(x, y, terrain) { ... }

world.onTerrainChanged(this.changeTerrain.bind(this));

Also, remember the pretty alpha-blending effect? I need to fade in and out the terrain tiles someone walks over:

Walking animation

So I added some more callbacks from world back to the renderer code. And fixed some bugs related to them. But it wasn't very fun. And I hadn't even covered creating and destroying mobs and items, which would require more creating/destroying sprites.

Then I wanted to add a marker for the "highlighted" square under the mouse cursor. I created a Graphics object, showed it when it was necessary, and hid it when the mouse was not over the cursor:

// init:
highlightPos = new Graphics();
highlightPos.rectangle(...);

// update:
if (highlightPos) {
  highlight.visible = true;
  highlight.x = highlightPos.x * TILE_SIZE;
  highlight.y = highlightPos.y * TILE_SIZE;
} else {
  highlight.visible = false;
}

I had a bunch of code like this for many objects on the screen.

Things started to look really familiar…

Making it declarative

I recognized where I saw this pattern and all these problems. Having a data model, and manually updating the view when it changes? Hiding and showing objects? Sounds like bad old days of jQuery soup!

And what weapon do I have against jQuery soup? React? Well… maybe not React directly, but the pattern of one-way updates and specifying the components declaratively.

I decided that while I don't want to recreate the whole scene every frame, I'm going to act like I am recreating the whole scene every frame. So the above examples become:

// draw mob:
const sprite = renderer.make(`mob.${mob.id}`, PIXI.Sprite);
sprite.x = ...
sprite.y = ...
sprite.texture = ...

// draw terrain:
for x, y in "visible portion of the map" {
  if (map[y][x] != 'EMPTY') {
    const sprite = renderer.make(`map.${x}.${y}`, PIXI.Sprite);
    sprite.x = ...
    sprite.y = ...
    sprite.texture = ...
  }
}

// draw highlight:
if (highlightPos) {
  const highlight = renderer.make('highlight', PIXI.Graphics);
  ...
}

// remove all the objects we didn't ask for in this frame
renderer.flush();

What's happening here?

  • The renderer is a class I wrote that keeps a cache of all objects in the scene, referred to by name like 'mob.goblin2' or 'map.5.7'.

  • When I ask for the object using renderer.make(), it either retrieves existing object from the cache, or creates a new one if I haven't yet. (The function even has an optional init callback for all the things that we only need to specify once).

  • Finally, renderer.flush() removes all the "stale" objects that we didn't ask for in this frame.

This solution does have an obvious cost. I have to iterate over the entire scene in every frame, and keep the mapping between names and objects. However, once you accept the performance hit, the upsides are enormous:

  • No more two-way binding, "on changed" callbacks and horrors of cache invalidation! No more manually adding or removing objects when something changes! The code is simpler and I'm free to make the scene as complicated as I want. If I want to draw something, or not draw it, I just write an if statement, not think about all the places where it might change.

  • The scene graph only contains the objects I want to draw. PixiJS doesn't spend time trying to render something outside of screen (even if it does recognize that immediately, this adds some overhead). So the full map can be as big as I want.

  • I do create and destroy objects, but only where I need to. For instance, if I move around the map, there will be some tiles coming into view, and some going out of view. However, most of the objects will stay on screen, so I will just update their properties.

I will be keeping a close eye on the performance, but for now the overhead isn't so big. This is definitely worth the price.

By the way, TypeScript generics are pretty nice. The above function is actually make<T> where T is any kind of PIXI.DisplayObject (sprite, text, "graphics", container…)

Profiling

Speaking of performance, both Chrome and Firefox have excellent profilers in their developer tools. I'm able to check how many frames per second are rendered, how much time is spent on graphics vs. any particular function.

A few times, the profiler "called my bluff" and forced me to replace a naive O(n**2) solution with a better one, or add some caching where I always computed something on the fly. Sometimes I did the opposite and decided I could afford to have simpler code.

Strangely enough, of the few machines I'm testing on, my desktop computer is the fastest, laptop is the slowest, and my phone falls in the middle.

Part of a series about Grass, a real-time roguelike engine with pretty graphics.