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?

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:

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.