thecodingidiot.com

The InfiniteColour

Colour

Add two colour schemes: a linear gradient that reveals the set's structure, and smooth colouring that eliminates the banding you will see with the first one.


The pixel format

RGB — additive colour. A screen pixel emits light in three primaries: red, green, and blue. Each channel is a value from 0 (off) to 255 (full intensity). Mixing them produces any colour the display can show. This is the RGB model — it maps directly to the physical hardware, where each pixel on an LCD or OLED panel is made up of three coloured subpixels.

(255,   0,   0)  →  red
(  0, 255,   0)  →  green
(  0,   0, 255)  →  blue
(255, 255,   0)  →  yellow  (red + green)
(  0, 255, 255)  →  cyan    (green + blue)
(255, 255, 255)  →  white   (all channels full)
(  0,   0,   0)  →  black   (all channels off)
(128, 128, 128)  →  mid-grey

Alpha — transparency. The fourth channel, A, controls opacity: 0 means fully transparent, 255 means fully opaque. SDL2 uses alpha when compositing layers — sprites over backgrounds, UI over scenes. In this renderer there are no layers; every pixel writes directly to the screen texture, so alpha is always 0xFF.

Packing four bytes into one integer. SDL2 with SDL_PIXELFORMAT_ARGB8888 stores all four channels in a single 32-bit uint32_t, with alpha in the most significant byte:

0xAARRGGBB
  ↑↑↑↑↑↑↑↑
  ││││││└└─ blue   (bits  7–0)
  ││││└└─── green  (bits 15–8)
  ││└└───── red    (bits 23–16)
  └└─────── alpha  (bits 31–24)

You know the mechanics from the previous page. Each channel value (0–255, one byte) is shifted into its designated byte position, then OR'd together into one 32-bit integer:

uint32_t colour = (0xFF << 24) | (r << 16) | (g << 8) | b;

Alpha is fixed at 0xFF — this renderer has no compositing layers, so every pixel is fully opaque. r, g, and b are the colour values the scheme functions produce.


colour.h

#ifndef COLOUR_H
#define COLOUR_H
 
#include <stdint.h>
 
uint32_t colour_linear(int iter, int max_iter);
uint32_t colour_smooth(int iter, int max_iter, double sq_mag);
 
#endif

No SDL2. No game state. Pure functions: iteration count in, packed pixel value out. The SDL2 dependency stays in render.c.


Colour models

The problem with RGB for this task. The escape count is a single integer. We want to map it to a colour that changes smoothly across the range 0 to MAX_ITER. If we worked directly in RGB, we would need to write three separate mathematical functions — one for each channel — that rise and fall in coordination to produce a smooth colour progression. That is error-prone and unintuitive.

HSV[1] — a human-friendly model. HSV separates colour into three independent properties:

  • Hue — position on the colour wheel, expressed as an angle from 0° to 360°. Red is at 0°, yellow at 60°, green at 120°, cyan at 180°, blue at 240°, magenta at 300°, and red again at 360°. One number covers the full spectrum.
  • Saturation — how vivid the colour is. 0.0 is pure grey, 1.0 is the fully pure colour for that hue.
  • Value — brightness. 0.0 is black regardless of hue or saturation; 1.0 is full brightness.

To map an iteration count to a colour that cycles through the full spectrum, only the hue needs to change:

t=itermax_itert = \frac{\text{iter}}{\text{max\_iter}}

hue=t×360°\text{hue} = t \times 360°

A point that escapes on iteration 1 gets a hue near 0° (red). A point that barely escapes at iteration 99 out of 100 gets a hue near 360° (also red, having completed one full cycle through the spectrum). Everything between follows the rainbow in sequence — violet, blue, cyan, green, yellow, orange — with no arithmetic needed beyond a single multiplication.

Why convert to RGB? Screens do not have "hue" subpixels. They have red, green, and blue subpixels. HSV is a human-intuitive way to reason about colour; RGB is what the display hardware understands. hsv_to_rgb converts between the two: you think in HSV, the display receives RGB.


Scheme 1: linear gradient

Map the iteration count to a hue using HSV-to-RGB conversion. The ratio t = (double)iter / max_iter runs from 0 (escaped immediately) to just below 1 (barely escaped). Multiplying by 360 maps it to the full colour wheel.

#include <math.h>
#include "colour.h"
 
static uint32_t hsv_to_rgb(double h, double s, double v)
{
    double c;
    double x;
    double m;
    double r;
    double g;
    double b;
    int    sector;
 
    c = v * s;                                          /* chroma: colour intensity */
    x = c * (1.0 - fabs(fmod(h / 60.0, 2.0) - 1.0));    /* secondary component */
    m = v - c;                                          /* brightness offset for all channels */
    sector = (int)(h / 60.0);                           /* which 60° slice of the wheel */
    r = g = b = 0.0;
    if (sector == 0) {             /* 0–60°:   red → yellow */
        r = c;
        g = x;
    } else if (sector == 1) {     /* 60–120°: yellow → green */
        r = x;
        g = c;
    } else if (sector == 2) {     /* 120–180°: green → cyan */
        g = c;
        b = x;
    } else if (sector == 3) {     /* 180–240°: cyan → blue */
        g = x;
        b = c;
    } else if (sector == 4) {     /* 240–300°: blue → magenta */
        r = x;
        b = c;
    } else {                       /* 300–360°: magenta → red */
        r = c;
        b = x;
    }
    return ((uint32_t)(0xFF) << 24
        | (uint32_t)((r + m) * 255) << 16
        | (uint32_t)((g + m) * 255) << 8
        | (uint32_t)((b + m) * 255));
}
 
uint32_t colour_linear(int iter, int max_iter)
{
    double t;
    double hue;
 
    if (iter == max_iter)
        return (0xFF000000);                /* black — point never escaped */
    t = (double)iter / (double)max_iter;    /* normalise to 0.0–1.0 */
    hue = t * 360.0;                        /* map to full colour wheel */
    return (hsv_to_rgb(hue, 0.8, 1.0));     /* fixed saturation and brightness */
}

How hsv_to_rgb works. The colour wheel divides into six sectors of 60° each. Within each sector, one RGB channel is at its maximum (c), one is at zero, and one is transitioning (x). The sector variable identifies which slice the hue falls in; the if/else block assigns c and x to the correct channels for that slice:

c is the chroma — the product of brightness and saturation, giving the colour's intensity. x is the value of the rising or falling channel within the current sector, computed by fmod and fabs to find the position within the 60° slice. m is added to all three channels after the sector assignment; it lifts the minimum channel value to achieve the correct brightness without losing the hue.

The result is three floats in 0.0–1.0, multiplied by 255 and packed into the 0xAARRGGBB format.

colour_linear. Saturation is fixed at 0.8 (slightly desaturated — pure 1.0 can look garish) and value at 1.0 (full brightness). The only variable is the hue, driven by the iteration count. Points in the set return 0xFF000000 — opaque black — before any HSV computation.


Wire it up

In render.c, replace the black-or-white logic with a call to colour_linear:

#include "colour.h"
 
/* inside render_frame: */
i = mandelbrot(re, im, MAX_ITER);
colour = colour_linear(i, MAX_ITER);
s->pixels[y * WIDTH + x] = colour;
make re && ./infinite

The set renders in colour. Pan and zoom. Zoom into the boundary — the spirals and filaments appear. But zoom in far enough and you will see it: the colour bands. Sharp transitions where the iteration count increases by exactly one. The boundary between i = 47 and i = 48 is a visible line.

This is not a rendering artefact. The integer iteration count produces integer colour steps. The only fix is to make the escape value non-integer.


Scheme 2: smooth colouring

The smooth colouring formula computes a fractional escape value using the final magnitude of z when the point escaped:

t=iter+1log(logz)log2t = \text{iter} + 1 - \frac{\log\bigl(\log|z|\bigr)}{\log 2}

z|z| is the magnitude of z at the moment the escape condition was met. The formula shifts the integer count by a fraction derived from how far past the escape radius z travelled. The result is a continuous real number; the colour gradient is smooth.

To compute this, the escape loop must return the final z|z| in addition to the iteration count. Add a double * parameter to capture it:

int mandelbrot(double c_re, double c_im, int max_iter, double *mag_out)
{
    double re;
    double im;
    double new_re;
    double sq;
    int    i;
 
    re = 0.0;
    im = 0.0;
    i = 0;
    while (i < max_iter) {
        sq = re * re + im * im;
        if (sq > 4.0) {
            if (mag_out)
                *mag_out = sq;
            return (i);
        }
        new_re = re * re - im * im + c_re;
        im = 2.0 * re * im + c_im;
        re = new_re;
        i++;
    }
    if (mag_out)
        *mag_out = re * re + im * im;
    return (max_iter);
}

Storing sq (the squared magnitude) avoids recomputing it for the mag_out assignment. Pass NULL where the magnitude is not needed.

Update mandelbrot.h, all call sites in render.c, and colour.h:

uint32_t colour_smooth(int iter, int max_iter, double sq_mag);

colour_smooth

#include <math.h>
#include "colour.h"
 
uint32_t colour_smooth(int iter, int max_iter, double sq_mag)
{
    double t;
    double hue;
 
    if (iter == max_iter)
        return (0xFF000000);
    t = (double)iter + 1.0 - log(log(sqrt(sq_mag))) / log(2.0);
    t = t / (double)max_iter;
    if (t < 0.0)
        t = 0.0;
    if (t > 1.0)
        t = 1.0;
    hue = t * 360.0;
    return (hsv_to_rgb(hue, 0.8, 1.0));
}

Why this formula works. The escape condition fires when |z| > 2. At that moment, |z| is somewhere between 2 and a larger value depending on how far past the boundary z had already travelled. The logarithm term measures that overshoot and converts it into a fractional correction between 0 and 1. Adding it to iter produces a continuous real number instead of a discrete integer — the colour transitions smoothly rather than stepping.

sqrt(sq_mag) converts the stored squared magnitude back to |z| for the log computation. The clamp t = max(0, min(1, t)) prevents floating-point edge cases near the set boundary from producing out-of-range hues before the multiplication by 360.


Cycle the colour scheme

In render_frame, add double mag; to the variable declarations at the top of the function alongside i, colour, re, and im. Then replace the existing inner-loop code with:

i = mandelbrot(re, im, MAX_ITER, &mag);
if (s->colour_scheme == 0)
    colour = colour_linear(i, MAX_ITER);
else
    colour = colour_smooth(i, MAX_ITER, mag);
s->pixels[y * WIDTH + x] = colour;
make re && ./infinite

Press C to toggle between linear and smooth colouring. Zoom into a region with visible banding in linear mode, then switch to smooth. The bands disappear. The gradient runs continuously across the iteration boundaries.

The colour_smooth function also applies to Julia and Burning Ship mode — the iteration loop is the same, only c and the starting z differ.

Footnotes

  1. HSL and HSV - Wikipedia