The HTML5 version of Rainbow features vibrantly colored Rainbows; when porting to C++, I traded off a bit of the brightness of the rainbows for softer intersection behavior. This weekend, I set out to brighten the bows in the C++ port, while retaining nice bow intersections.
As you can see, the C++ version has been improved to have bows as bright as the HTML5 version.
In Rainbow, the player can split the rainbow they control into multiple "fronts" and drive these fronts separately. Each front leaves a rainbow trail.
I would like several conditions to hold:
- Trails freshly produced by a front should draw over older trails.
- Fronts should always remain visible.
- No hard edges should be introduced.
To justify briefly: (1) arises because I want players to see what they are currently controlling; (2) is important because players should be able to see how much rainbow they have left; and (3) just seems like how magical rainbow substance should behave.
The Old Solutions
In the HTML5 version of Rainbow, the entire rainbow is redrawn each frame with each vertex's depth set based on the number of ticks from the beginning of the rainbow. This method actually fails all three of my ideal conditions in some cases, e.g. when drawing with an older front.
In the C++ port, the rainbow is accumulated into a framebuffer. This pretty much guarantees that condition (1) holds. Condition (2) is satisfied by always drawing (but not accumulating) the very front of the bow over the top of everything in a different rendering pass.
This leaves condition (3) -- no hard edges. This is solved by drawing the blow slowly instead of all at once. Specifically, every time a front moves, it draws over its last 10 positions with partially-transparent segments. This has the effect of slowly building up the bow color over 10 steps:
Unfortunately, because it draws over each of the old positions with the same opacity (alpha value) -- set, in an ad-hoc way to 1/8th -- the effective opacity of the bow is only about 73%.
The New Solution
Let me start by considering a simpler version where we only draw over the last 4 locations. Labeling the segments that get drawn as 1-4 and the locations traversed a-g (both from oldest to newest), one can sketch this diagram:
... ... frame 4: 1 2 3 4 frame 3: 1 2 3 4 frame 2: 1 2 3 4 frame 1: 1 2 3 4 --------------- location: a b c d e f g
a will be drawn into by segment 1, and then no others;
b gets drawn by 2 then 1, location
c by 3 then 2 then 1, and so on.
So what opacity value should we pick for segment 4 if we want locations
d and beyond to have opacity αtarget?
Well, it depends what the next passes (1-3) will draw over it.
Let's say that segments 1-3, when drawn, will cover fraction αnext of a pixel.
Then we can select opacity value x for segment 4 by writing down the blending equation:
αtarget = αnext + (1 - αnext)x
αtarget - αnext = (1 - αnext)x
x = (αtarget - αnext)/(1 - αnext)
This passes some basic sanity checks -- if we want 100% coverage after segments (1-3) are drawn (i.e. αtarget = 1), we need to draw segment 4 with 100% opacity (i.e. x = 1). Also, if αnext is greater than αtarget then we get negative values -- which makes sense, as segment 4 can't prevent the subsequently drawn segments from covering the location, so it needs to attempt to preemptively remove coverage.
Since I'd like the rainbow to smoothly fade in from a cleared start, I chose a "fade" opacity value, f to vary smoothly from 0% coverage (at the trailing edge of the oldest segment) to 100% (at the leading edge of the newest).
In the pixel shader, other factors that influence opacity -- band color, edge-of-band "antialiasing" texture -- are looked up. Letting their product be called m, the shader sets:
αtarget = f * m
αnext = min(1.0, f + 1/10) * m
These values are used to compute the final opacity value x as outlined above. This results in a variable-opacity front that produces a more vibrant bow:
I was a bit worried about having a divide in the pixel shader, but profiling on iOS shows negligible effect on frame time.
Since all of this math is actually done on 8-bit color values, some inaccuracy (and banding) does result. I could avoid this (and the above-mentioned divide) by doing the computation of x with a look-up-table that takes these errors into account. However, the artifacts are not so severe that this is a high priority.
Some Methods That Didn't Work
Before settling on the present solution, I tried all sorts of other ideas including multi-pass rendering and different blending modes. While none of them were what I wanted, they did produce some interesting pictures.