The Function Is A Lie
The relationship between software and its effects: "functions" vs. functions, multithreading, command buffers, and more.
I recently mentioned, in conversation, a technique I regularly use for user interface animation, which I’ve written about before. It’s not applicable for all animation curves in all scenarios, but it tightly fits design goals for a user interface, it’s simple, it’s low-cost to implement, and it produces robust animation behavior which gracefully adapts to mid-animation changes. Importantly, this technique introduces no new effective codepaths over the equivalent non-animated system, meaning it’s easy to write once and have a strong guarantee of correctness.
It looks like this:
value += (target - value) * rate
One of the responses I received was one with raised eyebrows—“isn’t that just linear interpolation, blending between value
and target
?”
In one sense, the answer is yes—looking at any “lerp” function will show an identical computation:
F32 Lerp(F32 v0, F32 v1, F32 t)
{
v0 += (v1-v0) * t;
return v0;
}
But in another sense, the answer is no. Let’s compare a main loop which produces a linear interpolation animation:
F32 start = 1.f;
F32 value = start;
F32 end = 10.f;
F32 time_in_seconds = 0.f;
F32 dt = 1.f / 60.f;
for(;;)
{
value = Lerp(start, end, time_in_seconds/4.f);
time_in_seconds += dt;
}
…with that which produces the exponential animation curve that I use my original snippet to produce:
F32 start = 1.f;
F32 value = start;
F32 end = 10.f;
F32 dt = 1.f / 60.f;
for(;;)
{
value = Lerp(value, end, 1 - pow(2.f, -4.f*dt));
}
Assuming value
is used to position something on the screen, the end results are dramatically different:
In both cases, I call Lerp
every frame. But to produce the linear animation curve, the t
used for Lerp
changed as time progressed. To produce the exponential animation curve, the t
used for Lerp
stayed the same, while v0
instead changed.
To understand why I claim the latter is “more robust”, and “does not introduce new effective codepaths”, consider a situation in which end
—the target value for the animation—changes during the animation. This commonly arises in user interfaces, where animation is merely intended to show a transition between two well-defined logical states.
In order to properly “reset” the animation in the case of linear animation, there must be a conscious decision on the part of the animation code to “reset”—start
, end
, and t
must be adjusted, and on every frame, that “reset” either happens or not.
In the case of the exponential animation, the codepath is written such that the same computation occurs for all value
s and all end
s. end
can simply be written over (perhaps every frame as part of the single animation codepath), rate
stays identical, and value
’s current state makes no difference—the codepath will operate correctly regardless.
So, in any case, was the original comment about it being “linear interpolation” wrong? Well, no, not really—but the same name being used to refer to the two above animation implementations makes that name not meaningful in this context.
What, then, is meaningful in this context?
Consider another set of functions—just mathematical functions which predict value
across frames for each animation style (sweeping along this function is visualized when using these animation styles in rendering).
One looks like this when plotted:
And the other looks like this when plotted:
These are the functions that are actually connected to the end effect of the software—they’re what a programmer makes decisions about. How is the thing on the screen actually going to move at the end of the day?
But these are not functions in code—in fact, each can be implemented with the same code function (called differently). Each can be implemented with no additional code functions at all. These are mathematical functions which model the effect of some code.
In the latter case, it’s hopefully clear that this function is produced by aggregating the effects of each individual Lerp
. On each frame, yes, it’s a linear interpolation—but the cross-frame effect is an exponential curve.
In the former case, the shape of the value modelling function closely matches Lerp
with respect to t
, with a fixed v0
and v1
. But to claim that therefore Lerp
is the same as this mathematical function is an error!
In both cases, the mathematical function which models the software’s effects “slices through” a set of frames. One line of code, in this case performing a single linear interpolation, is not representative of that mathematical function—the mathematical function describes the effect of that code across multiple frames.
I happen to think that—maybe—calling code functions “functions”, knowing that mathematical functions are relevant for programming and are also called “functions”, was a major slip up, bound to cause confusion for decades. (I’m sure that this is only clear in hindsight—I would’ve made the same mistake in a historical context and worse—but I think it’s time to correct the record!)
I’d like to use this example to introduce the point of this post: mathematical models are useful in reasoning about effects, but programming language constructs are just tools to produce physical effects with a computer. They are entirely distinct in nature. The physical effects can follow a track outlined by a mathematical model, but the two are not one in the same.
In my experience, even if that premise flatly stated is accepted, silently merging these two concepts in one’s mental model causes a number of problems. Forcing a mathematical function to be implemented in close relationship with a composition of code functions is often technically inappropriate. Code functions have specific technical constraints, and mathematical functions describing the desired high-level effects of software exist as mathematical entities disconnected from those constraints.
I’ll show this with some examples.
Command Buffers
One clear way in which this distinction between code functions and mathematical functions manifests is by looking at problems in which command buffers are used.
A graphical application implements an overarching mathematical function Program State → Frame Buffer. The application produces a changing grid of pixels as its internal state changes. But the possibilities in implementing that mathematical function concretely are endless, and many of those possibilities do not have obvious maps from code functions to mathematical functions.
Take the following code:
// implements state -> framebuffer
void Draw(ProgramState *)
{
// ...
// draw red rectangle from p0 to p1
DrawRect(R2F32(p0, p1), V4(1, 0, 0, 1));
// ...
}
It may be tempting to imagine that DrawRect
has a loop that goes and fills out a bunch of pixel values, or if it’s operating the GPU, kicking off some GPU code which does the same. But this is often not how such a function would be implemented in practice, because such an implementation would break technical constraints.
Perhaps doing one rectangle at a time is simply not reasonable for performance, because some work can be done per rectangle batch instead. This cannot be done if the entire per-rectangle drawing codepath is implemented in DrawRect
. An entire rectangle batch may eliminate entire swathes of work, if some of those rectangles overlap and occlude others.
It may be inappropriate to do (or organize GPU) pixel-fill work on this thread at all—it may be better to pull rendering work into its own thread(s), and allow logical work for the subsequent frame to begin as the previous frame’s framebuffer is being produced.
Nevertheless, the idea that the program needs some rectangle rendered with these parameters is still important information. But that’s really all this codepath needed to produce: information. A different codepath can consume that information and do further rendering work.
As a result, many practical implementations of DrawRect
(or equivalent, or similar) instead simply append to a command buffer—or, a data structure which records (and possibly batches) information pertaining to each draw command. Later, that command buffer is taken as input by a different codepath, which performs the next batch of work relevant to rendering.
That separate rendering codepath can then exist in a different layer within the codebase, be implemented for various backends, run on a different thread, take full advantage of knowledge of the entire frame (rather than having to do rendering work immediately), and so on.
This is another example of the high-level mathematical effect of the software being quite distinct from the shape of the code which programs the machine to produce that effect.
Code Editors and “Replace Range”
(The remainder of this post is for paid subscribers only)