-
Notifications
You must be signed in to change notification settings - Fork 5
Description
The objective of this issue is to simplify the Sprite API, making it easier to use and less likely to produce unexpeted or erroneous behavior.
Sections:
- Summary of proposed Sprite API
- Background: Issues with the current Sprite API
- Walkthrough of proposed Sprite API
- Discussion of callbacks vs Promises
Summary of proposed Sprite API
Set attributes directly on Sprite object without having to schedule a callback (replaces the .enter() method):
const sprite = scene.createSprite();
sprite.TransitionTimeMs = 250;
sprite.PositionWorld = [0, 0];
sprite.FillColor = [255, 127, 0, 1];Set a singleton callback to be invoked after the next time values are flashed to the GPU (supersedes .enter()/.update() dynamic):
sprite.nextSync(() => {
sprite.PositionWorld = getUpdatedPosition();
});Set a singleton callback to be invoked after the Sprite has finished its transition (supersedes .exit()):
sprite.transitionFinished(() => {
sprite.detatch();
});A detatched sprite can be attached to the scene by calling the .attach() method. Attaching an attached sprite or detatching a detatched sprite has no effect. User can check isAttached to see its current state.
If there is insufficient capacity to accommodate attachcing a sprite, then it waits in an intermediate state until space is freed up. During this time, isAttached will be false, but isWaiting will be true.
If one or more attributes have been changed, but the data has not been flashed to the GPU, the isDirty property will be true.
Background: Issues with the current Sprite API
In this section, we'll walk through a fairly basic usage of the current Sprite API. This will demonstrate the interplay between memory management and timing which lead to unexpected and unintuitive behavior.
The current Sprite API requires users to use the .enter(), .update() and .exit() methods to schedule callbacks. Each callback receives a SpriteView object through which the user can specify attributes like PositionWorld, FillColor, etc. Here's an example:
const sprite = scene.createSprite();
sprite.enter((s: SpriteView) => {
s.PositionWorldX = 0;
s.PositionWorldY = 0;
s.FillColorR = 255;
s.FillColorG = 127;
s.FillColorB = 0;
s.FillColorOpacity = 1;
});To reduce the amount of code needed to set attribute values, the attributes offer destructuring assignment. The above code can be rewritten as follows:
const sprite = scene.createSprite();
sprite.enter((s: SpriteView) => {
s.PositionWorld = [0, 0];
s.FillColor = [255, 127, 0, 1];
});The downside of this approach is that there are now two, short-lived array objects which are created: [0, 0] and [255, 127, 0, 1]. For a single sprite, this is not a problem, but at scale, creating these kinds of objects causes noticeable performance degradation, and precipitates more frequent garbage collection.
To reduce this memory thrashing, users can move their objects outside of the callbacks for reuse, like this:
const INITIAL_POSITION = [0, 0];
const INITIAL_COLOR = [255, 127, 0, 1];
const sprite = scene.createSprite();
sprite.enter((s: SpriteView) => {
s.PositionWorld = INITIAL_POSITION;
s.FillColor = INITIAL_COLOR;
});This works, but the next problem is that the values are often dynamic in a visualization context. So rather than constants, the position and color would change over time:
// Dynamic properties change with visualization state.
const properties = {
position: [0, 0],
color: [255, 127, 0, 1],
};
const sprite = scene.createSprite();
sprite.enter((s: SpriteView) => {
// Set initial position and color.
s.PositionWorld = properties.position;
s.FillColor = properties.color;
});
// Later, dynamically...
properties.position = getUpdatedPosition();
properties.color = getUpdatedColor();
sprite.update((s: SpriteView) => {
// Set updated position and color.
s.PositionWorld = properties.position;
s.FillColor = properties.color;
})Notice here that we're using .update() for the second set of changes. Megaplot guarantees that the .update() callback will be called after the .enter() callback. But both callbacks are scheduled and invoked asynchronously.
In our running example, this means that the .enter() callback may not have been run by the time later that the properties values are changed. That is, the sprite may never have had its PositionWorld set to [0, 0] and its FillColor set to [255, 127, 0, 1].
To solve this problem, the API user must explicitly capture the intended values for each callback. This can be done in any of several different ways. The following example promotes the initial state back to constants.
const INITIAL_POSITION = [0, 0];
const INITIAL_COLOR = [255, 127, 0, 1];
// Dynamic properties to change with visualization state.
const properties = {
position: INITIAL_POSITION,
color: INITIAL_COLOR,
};
const sprite = scene.createSprite();
sprite.enter((s: SpriteView) => {
// Set initial position and color.
s.PositionWorld = INITIAL_POSITION;
s.FillColor = INITIAL_COLOR;
});
// Later, dynamically...
properties.position = getUpdatedPosition();
properties.color = getUpdatedColor();
sprite.update((s: SpriteView) => {
// Set updated position and color.
s.PositionWorld = properties.position;
s.FillColor = properties.color;
})Overall, the behavior and subtle interaction between memory and timing makes the existing Sprite API difficult to work with and reason about.
Walkthrough of proposed Sprite API
The proposed Sprite API would be much simpler. Rather than requiring the API user to make changes inside of callbacks, the user sets properties directly on the sprite object:
const sprite = scene.createSprite();
sprite.PositionWorld = [0, 0];
sprite.FillColor = [255, 127, 0, 1];This solves the problem of unexpected late execution. As soon as the setter is called, the values are read and stashed for eventual rendering. This is illutrated by adding a second instance of setting the values:
// Dynamic properties change with visualization state.
const properties = {
position: [0, 0],
color: [255, 127, 0, 1],
};
const sprite = scene.createSprite();
// This runs immediately, capturing the attribute values.
sprite.PositionWorld = properties.position;
sprite.FillColor = properties.color;
// Later, dynamically...
properties.position = getUpdatedPosition();
properties.color = getUpdatedColor();
// Set updated position and color.
sprite.PositionWorld = properties.position;
sprite.FillColor = properties.color;While this solves the problem of unexpected late execution, there's another issue. Rendering is necessarily asynchronous, so it may be some time between when the attributes are set and the sprite is actually drawn to the screen. So while the sprite had its position and color set right away, it may have never rendered in that state before having its values updated.
To solve this issue, the API user needs to delay performing the update until after the values have been flashed to the GPU (at the earliest). We can make this possible by offering a nextSync() method, which takes a callback to invoke after the next time the values are flashed:
// Later, dynamically...
properties.position = getUpdatedPosition();
properties.color = getUpdatedColor();
sprite.nextSync(() => {
// Set updated position and color.
sprite.PositionWorld = properties.position;
sprite.FillColor = properties.color;
})NOTE: The callback provided to nextSync() could be invoked as soon as the next frame, but will not be called immediately in the same execution pass. As a general principle, an API that takes a callback should either always execute the callback immediately, or always wait for a future turn of the event loop. API methods that differentially delay execution are difficult to reason about and cause hard to predict behavior. For this reason, nextSync() must always delay invoking the callback, even if the sprite's isDirty flag currently reads false.
Rather than waiting simply for the next sync, which will often be the next frame, the API user may want to wait until the sprite has finished its transition. A method like transitionFinished() could make this easier:
const TRANSITION_TIME_MS = 250;
// Dynamic properties change with visualization state.
const properties = {
position: [0, 0],
color: [255, 127, 0, 1],
};
const sprite = scene.createSprite();
// This runs immediately, capturing the attribute values.
sprite.TransitionTimeMs = TRANSITION_TIME_MS;
sprite.PositionWorld = properties.position;
sprite.FillColor = properties.color;
// Later, dynamically...
properties.position = getUpdatedPosition();
properties.color = getUpdatedColor();
// Set updated position and color.
sprite.transitionFinished(() => {
sprite.PositionWorld = properties.position;
sprite.FillColor = properties.color;
});transitionFinished() enables chaining for more complex animations:
sprite.TransitionTimeMs = 250;
sprite.FillColor = d3.color('red');
sprite.transitionFinished(() => {
sprite.FillColor = d3.color('orange');
sprite.transitionFinished(() => {
sprite.FillColor = d3.color('yellow');
sprite.transitionFinished(() => {
sprite.FillColor = d3.color('green');
});
});
});One could be tempted to unroll these nested callbacks with Promises:
// NOT RECOMMENDED!
sprite.TransitionTimeMs = 250;
sprite.FillColor = d3.color('red');
await new Promise((r) => sprite.transitionFinished(r));
sprite.FillColor = d3.color('orange');
await new Promise((r) => sprite.transitionFinished(r));
sprite.FillColor = d3.color('yellow');
await new Promise((r) => sprite.transitionFinished(r));
sprite.FillColor = d3.color('green');While this approach does reduce the nesting observed in the nested callbacks, it introduces the possibility of forever-suspended functions. Each call to transitionFinished() supersedes the previous, overwriting the callback previously set (if any). So if, say, the above code was suspended on the first await, waiting for the sprite to finish transitioning to red, and some other callback calls transitionFinished(), then this function's execution context will hang forever.
Megaplot could overcome this by returning Promises which are rejected when superceded. In that case, the API user should surround the transition chain with try/catch to handle the rejected Promise:
// NOT RECOMMENDED!
try {
sprite.TransitionTimeMs = 250;
sprite.FillColor = d3.color('red');
await sprite.transitionFinished();
sprite.FillColor = d3.color('orange');
await sprite.transitionFinished();
sprite.FillColor = d3.color('yellow');
await sprite.transitionFinished();
sprite.FillColor = d3.color('green');
} catch (err) {
// Transition superceded by later call!
}The downside to providing this kind of aid is that it encourages writing async functions, which, in the interactive data visualization context, may make code more difficult to reason about. A lot can happen in between frames.
For more discussion of the tradeoffs between a callback-based and a Promise-based API, see the following section.
Discussion of callbacks vs Promises
As a design alternative, rather than taking callbacks (or perahps in addition), methods like nextSync() could return Promises. In the case of nextSync(), the returned Promise is fulfilled the next time values are flashed. For example:
// Later, dynamically...
properties.position = getUpdatedPosition();
properties.color = getUpdatedColor();
await sprite.nextSync();
// Set updated position and color.
sprite.PositionWorld = properties.position;
sprite.FillColor = properties.color;There are several downsides to this approach. The first is that creating a Promise requires allocating a potentially short-lived object.
Another downside is that promises encourage the recipient to write async functions, which, in this context, may have lingering unpredictable behavior. A lot can happen in between frames.
But perhaps the best reason to prefer a callback API to a Promise one is that only a single callback function will be saved, while any number of async functions could be hanging on the same Promise.
For example, consider this code, which attempts to update the visualzitaion based on an end-user triggered event:
containerElement.addEventListener('mousemove', async () => {
properties.position = getUpdatedPosition();
properties.color = getUpdatedColor();
await sprite.transitionFinished();
sprite.PositionWorld = properties.position;
sprite.FillColor = properties.color;
});Since mousemove events can be prolific, this implementation could spawn many hanging async functions all waiting for the sprite to finish its last transition. This style would exacerbate memory thrashing if the post-await code does any allocation. Consider:
containerElement.addEventListener('mousemove', async () => {
await sprite.transitionFinished();
sprite.FillColor = d3.color(getComputedColor());
});d3.color() allocates an object with r, g, b and opacity properties. So this seemingly innocuous, Promise-based code suspends a number of callback handles, each of which, when unsuspended, allocates a short-lived object.
To mitigate the potential for many hanging methods, it would be prudent to reject the previous Promise when a method like transitionFinished() is called again. In that case, the API user would want to surround the await with a try/catch block:
containerElement.addEventListener('mousemove', async () => {
try {
await sprite.transitionFinished();
sprite.FillColor = d3.color(getComputedColor());
} catch (err) {
// Probably superseded. Check err for details.
}
});Compare that to the equivalent, callback-based implementation:
containerElement.addEventListener('mousemove', () => {
sprite.transitionFinished(() => {
sprite.FillColor = d3.color(getComputedColor());
});
});Because of the additional complexity of the try/catch in the Promise-based code, the level of indentation here is identical. Plus, this code better handles a barrage of mousemove events. Each time sprite.transitionFinished() is called, the callback passed in supplants the previous callback, without triggering a try/catch flow.
Strictly speaking, these two APIs are not mutually exclusive. The transitionFinished() implementation could return a Promise only when called without a callback. This would mitigate the creation of short-lived objects.
However, since it's always possible to add the Promise-based features on later, for an initial implementation, the callback implementation is sufficient.