A Probably Terrible Way to Render Gradients

freerangepixels profile image Crystal Schuller ・4 min read

My most recent project at 42 Silicon Valley has me creating a 3-D wireframe rendering engine in C. One of the required capabilities is to smoothly change the color of rendered lines according to their elevation, like this:

So I needed to build a C function that can increment colors in a smooth gradient. It needed to work with Olivier Crouzet's miniLibX, which interprets RGB colors as a single hexadecimal int (which is probably what every graphics library does? I did not research this).

So we have one of these bois:


who needs to turn into this boi:


And it needs to look all pretty, like this:

Having not done math since 2002, I had my work cut out for me.

I was floored to discover that these random jargon digits have actually been RGB number values the whole time, with each two-digit pair representing the value of red, green or blue light. That means hex codes can be broken down like this:

Armed with this revelation, it was clear I needed to somehow isolate digit pairs in a hexadecimal number, manipulate them, and re-combine them.

Oh no, I realized, I have to do math with base-16. God, please, no...

No you don't, answered Beyoncé from on high via sunbeam, This will actually be super easy because bitshifting exists.

Adventures in Bitshifting

In decimal notation, 255 looks like this:

and in hexadecimal notation, it looks like this:

but in binary, it looks like this:
0000 0000 0000 0000 0000 0000 1111 1111

Using the magic of bitwise operators, we can scooch this value over like this:
FF << 8

which results in this:
0000 0000 0000 0000 1111 1111 0000 0000

which in hexadecimal looks like this:

and that means we can do the same in reverse, which allows us to isolate the first two digits of a hexadecimal number like this:
0x2FA8F9 >> 16 = 0x2F
We have our R value!

Isolating the G and B values requires using a bitmask with the bitwise and, which Neso Academy can explain better than I can, but which looks like this:
0x2FA8F9 & 0xFF = 0xF9

In brief, it works like this:

0x2FA8F9 in binary is:
0000 0000 0010 1111 1010 1000 1111 1001
and 0xFF in binary is:
0000 0000 0000 0000 0000 0000 1111 1111

The bitwise and returns an integer containing the bits that both numbers have in common:
0000 0000 0000 0000 0000 0000 1111 1001
Which is 0xF9 in hexadecimal!

We can use a combination of these techniques to isolate the G value, so what we're left with is this:

R = color >> 16
G = color >> 8 & 0xFF
B = color & 0xFF

Now that we have all the color values isolated into separate variables, the math can begin.

The Math Begins

To create a gradient, we need to increment each color value by even increments, like this:

The increment value is nice and easy to calculate:

increment = (end_color - start_color) / steps

Steps here are just pixels. Since my line-drawing algorithm is Not Fancy™, the number of pixels is just the larger of either x-distance or y-distance. We need the absolute (unsigned) values of these -- an x-change of -16 will evaluate as less than a y-change of 5, but 16 is the greater distance. I made a macro that finds the absolute max like this:

steps = ABS_MAX((x1-x0), (y1-y0))

Now that we have that, we can describe the color of each pixel as a function of three values: the starting color, the increment, and the position in the line.

color = (position * increment) + start_color

If we store position as a counter in our loop, we can find that piece easily, too.

int position = 0;

A Gradient Is Born

Now that we have all the pieces, we can create our gradient!

If we moved from our example colors 0xFFFFFF and 0x2FA8F9 over 5 steps, our increments would be:

Here's what it looks like when we apply our color formula to the individual color channels at each step.

You'll notice all these values are rounded to the nearest whole number, since RGB values can't handle decimals.

Once we have the individual values for R, G, and B, we can do the bitwise operations in reverse and add the values together to get the final color.

RGB = (R << 16) + (G << 8) + B
RGB = (0x2F << 16) + (0xA8 << 8) + (F9)
RGB = 0x2F0000 + 0x00A800 + 0x0000F9
RGB = 0x2FA8F9

Here's what that looks like:

All of this has been done before, and probably a lot more elegantly and efficiently. But this function is my function and I made it all by myself, so I get a popsicle*. If you want to see more of this for some reason, you can view the rest of the project (still in progress) on github.
Here's the function in it's entirety.

# define R(a) (a) >> 16
# define G(a) ((a) >> 8) & 0xFF
# define B(a) (a) & 0xFF
# define RGB(a, b, c) ((a) << 16) + ((b) << 8) + (c)

int gradient(int startcolor, int endcolor, int len, int pix)
    double increment[3];
    int new[3];
    int newcolor;

    increment[0] = (double)((R(endcolor)) - (R(startcolor))) / (double)len;
    increment[1] = (double)((G(endcolor)) - (G(startcolor))) / (double)len;
    increment[2] = (double)((B(endcolor)) - (B(startcolor))) / (double)len;

    new[0] = (R(startcolor)) + ft_round(pix * increment[0]);
    new[1] = (G(startcolor)) + ft_round(pix * increment[1]);
    new[2] = (B(startcolor)) + ft_round(pix * increment[2]);

    newcolor = RGB(new[0], new[1], new[2]);

    return (newcolor);

* they do not actually give us popsicles.

Posted on by:


Editor guide

This article gets an instant follow from me! I love the way you break things down


thank you, very nice