Render tasks for Nodes (updated demo!)

Continuing the discussion from How to decide what updates?:

I finally got around to implementing a mechanism by which nodes register updates in v5.2.0 of my prototype API. The API is simple:

node.addRenderTask(function(timestamp) {
    // `this` is the node the task was added to.
    this.rotation = [0, rotation++, 0]
})

Try it out by checking out commit 5c87435 of trusktr/site then running the following (requires Node.js with NPM 3 and Meteor, but I’ll start posting live demos soon so you won’t need to install anything!):

git clone git@github.com:trusktr/site.git
cd site
git checkout 5c87435f
npm install -g 'npm@^3.0.0' # make sure to use NPM 3
npm install
meteor

Then visit http://localhost:3000. The demo looks the same as the one at Marrying the Scene Graph with the DOM, but it is using node.addRenderTask() now instead of manual requestAnimationFrame loops. This reduces the number of animation frames fired from multiple to a single frame per browser paint, manage by a single Motor instance.

What’s interesting:

1) Adding a render task to the node causes the Motor to start an animation loop if one isn’t already started. When no render tasks exist (removed with node.removeRenderTask()), the Motor stops the animation loop so if there are no animations then the app is completely idle (see screenshot in the release notes).

2) This API is opt-out, so when a render task is added with node.addRenderTask(fn), the task will be fired every animation frame until it is removed with node.removeRenderTask(fn).

3) Every frame, the Motor updates only the Node instance that addRenderTask was called on, by calling node.render() on that Node. The call to node.render() does not traverse to the children of the node that .render() is called on. There is a downside to this: we need to add one render task per node that we wish to animate. For example, one might write the following code expecting for two nodes to rotate, but will be surprised when only one of the nodes rotates:

let rotation = 0
node1.addRenderTask(function(timestamp) {
    this.rotation = [0, rotation++, 0]
    node2.rotation = [rotation, 0, 0]
})

What happens here is that node1 (referenced as this inside the render task) will appear to rotate, but node2 will not, even if node2 is a child or grandchild of node1. This is because Motor is only calling .render() on node1 since the render task belongs to that node, and as mentioned, the Node#render method does not traverse to children of the Node that it is called on. In order to make the example work, we’d have to do the following:

let rotation = 0
node1.addRenderTask(function(timestamp) {
    this.rotation = [0, rotation++, 0]
})
node2.addRenderTask(function(timestamp) {
    this.rotation = [rotation, 0, 0]
})

The upside of updating only the node that a render task is added to is that only that node gets updated, which means that the render tree doesn’t have to be traversed, therefore we may have better performance.

The reason we can avoid traversing the tree ourselves is because we know that when we use nested DOM elements (and therefore CSS transform caching) that the HTML engine itself will do the traversal of the DOM tree in order to perform transform multiplications (we don’t have to do it ourselves, so therefore we can avoid the extra traversal). The WebGL renderer will work similarly: nodes won’t be traversed directly by this part of the engine, only by the WebGL renderer in order to calculate world transforms (just like what the HTML engine does).

So yeah, that’s the first version update management in the engine. But, I already don’t like the burden of having to add a render task to each and every node that needs to be animated. For the next update, I’ll be adding a mechanism by which any node can be updated from a single render task, and I’d also like to move the addRenderTask method to the Motor. It will look like this:

// ...create node1 and node2 and add them to the scene, then:
let rotation = 0
Motor.addRenderTask(timestamp => {
  rotation += 1
  node1.rotation = [0,rotation,0]
  node2.rotation = [0,rotation,rotation]
})

When the update fires, the Motor will detect that node1 and node2 were updated, and will call .render()on those nodes. I have a feeling this API will be easier and more convenient to use. For backwards compatibility, I might keep the .addRenderTask on the Node class, but it will work the same way, just proxying to the Motor method of the same name.

Looks good. What if you define a setter function that also marks the node as needing an update?

Meteor.addRenderTask(() => {
  rotation++;
  node1.setRotation([0, rotation, 0]);
  node2.setRotation([0, rotation, rotation]);
});

Or is node.rotation= already using a setter?

You must be eating, breathing, and sleeping Meteor. :laughing: :stuck_out_tongue:

That’s the same idea that I had, although in the above demos that isn’t the case yet: only the node that the render task is attached to gets updated (no traversal of the scene graph).

I just made an update, so now in that last demo,

let rotation = 0
Motor.addRenderTask(timestamp => {
  rotation += 1
  node1.rotation = [0,rotation,0]
  node2.rotation = [0,rotation,rotation]
})

both node1 and node2 are re-rendered since they are both modified inside the render task. It’s interesting, because not all nodes in a render task are necessarily updated, for example:

let rotation = 0
Motor.addRenderTask(timestamp => {
  rotation += 1
  node1.rotation = [0,rotation,0]

  if (rotation > 200)
      node2.rotation = [0,rotation,rotation]
})

In this last one, node2 will start being modified after rotation is greater than 200, so it won’t start being re-rendered until that point.

In this new approach a Map is populated during each tick of the Motor whenever a setter on a Node is used, while firing all the render tasks, in order to keep track of which nodes need rendering. After all tasks are fired, then the Map is iterated over, all nodes rendered, and finally the Map cleared. This means that each animation frame a Map is populated then cleared. The Map keys are the nodes, and currently the values are undefined because I just need a list a nodes and don’t actually care what the Map values are. I want to avoid Garabage Collection (GC), so when the Map is cleared, the nodes are released, which should be fine because the render tasks contain references to the nodes in their scopes, so the nodes won’t be GCed until at least the render tasks manipulating the nodes removed (i.e. animations finished). I’m curious to know if having undefined for the Map values will cause those undefined values to be GCed? I’m curious to know how undefined is treated: is each undefined Map value an object that the JS engine keeps track of and eventually GCs when I release them? If so (I’ll wait for an answer on es-discuss) then we can just set the Map values to some const value stored inside Motor.

The API also guards against people who might use their own (possibly naive) animation loop. When a Node setter is used outside of the Motor’s animation loop, the Node automatically places itself into the Motor’s animation loop, so it’s possible to do:

let rotation = 0
window.setInterval(function() {
    rotation += 1
    node1.rotation = [0,rotation,0]

    if (rotation > 200)
        node2.rotation = [0,rotation,rotation]
}, 16.66)

These nodes that are being modified will not render outside of the Motor’s animation loop, but note that there is now a little extra overhead from running two loops (each tick of this setInterval loop causes a render task for the modified Nodes to be added and removed in the next Motor tick).

@dmvaldman @Steveblue @oldschooljarvis @talves @andrewreedy Thoughts/ideas?