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-greyAlpha — 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);
#endifNo 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:
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 && ./infiniteThe 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:
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 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 && ./infinitePress 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.