How Bloom mixes colors

Put yellow and blue paint next to each other, then work a knife through the middle. You expect to find green along the way, not just an average of two screen colors. That's the idea behind Bloom's oil color mixing. It uses a Metal port of Spectral.js 3.0.0, developed by Ronald van Wijnen, to mix reconstructed reflectance spectra rather than simply average the sRGB values.

Four ways to mix the same colors

Let's start with the colors themselves. Every bar below begins and ends with the same pair of colors; only the way we mix them changes. The middle is always a 50 / 50 input blend. Notice how the yellow and blue take a brighter green route through the spectral mixer.

The canvas uses Bloom's Metal port of Spectral.js, which evaluates the 38-band model directly. The last row is a Bézier experiment from the color-mixing visualization. It's here for comparison, not because it's running under your brush.

A quick note on the name: “full KM” just means evaluating the 38-band, single-constant KM model directly, rather than using the Bézier approximation. It isn't a complete physical paint simulation. Spectral.js and Bloom's Metal port are two implementations of the same underlying approach.

Cadmium Red + Yellow Pale

sRGB interpolation
A straight interpolation of the same red and yellow sRGB values, shown for comparison with Bloom's spectral result.
Spectral.js — upstream JavaScript
Upstream Spectral.js 38-band KM gradient from Cadmium Red to Cadmium Yellow Pale, using reconstructed spectra.
Spectral.js — Bloom's Metal port (canvas mixer)
Bloom's Metal port of Spectral.js: a 38-band KM gradient from Cadmium Red through orange to Cadmium Yellow Pale.
Bézier fit of Bloom's Spectral.js port (experimental)
Bloom's experimental visualization-only cubic Bézier approximation from Cadmium Red to Cadmium Yellow Pale.

Red50 / 50Yellow

Cadmium Yellow Pale + Cerulean Blue

sRGB interpolation
A straight interpolation of the same yellow and blue sRGB values, with a more muted middle than Bloom's spectral result.
Spectral.js — upstream JavaScript
Upstream Spectral.js 38-band KM gradient from Cadmium Yellow Pale to Cerulean Blue, using reconstructed spectra.
Spectral.js — Bloom's Metal port (canvas mixer)
Bloom's Metal port of Spectral.js: a 38-band KM gradient from Cadmium Yellow Pale through greens to Cerulean Blue.
Bézier fit of Bloom's Spectral.js port (experimental)
Bloom's experimental visualization-only cubic Bézier approximation from Cadmium Yellow Pale to Cerulean Blue.

Yellow50 / 50Blue

For a fair comparison, each bar has 257 samples and uses the same unrounded catalog sRGB colors as the videos below. The sRGB, Metal-port, and Bézier-fit bars come from Bloom's native renderer; the upstream bars come from the JavaScript library. All keep the input endpoints and clip out-of-gamut channels instead of using perceptual gamut mapping. These are static SVGs, so no mixing library runs in this page.

  • sRGB interpolation: the simplest option. Blend each encoded red, green, and blue channel separately. There's no idea of paint here, and it's not linear-light blending either.
  • Spectral.js — upstream JavaScript: instead of stopping at RGB, it reconstructs a 38-band reflectance spectrum and mixes with the single-constant Kubelka–Munk model. These bars use revision bb2b05c, with tinting strengths of 1 and channel clipping. The library is MIT-licensed.
  • Spectral.js — Bloom's Metal port: this is the version behind the oil knife in the videos, and it also appears in watercolor mixing paths. It comes from Spectral.js 3.0.0, so the resemblance to the upstream bar is expected. Bloom uses 32-bit floats and a reflectance floor of 0.0001 to keep the math stable; upstream JavaScript uses 64-bit numbers and a much smaller floor.
  • Bézier fit of Bloom's Spectral.js port: what if a curve could follow that same color path? This experimental visualization-only row fits a cubic through two spectral mixes, at 1/3 and 2/3. It keeps the endpoints and those two samples, but the colors between them can drift. It isn't an active canvas optimization.

These bars show how the colors travel between their endpoints. They don't tell us which method best matches a tube of real paint, or which runs fastest. Both versions of Spectral.js work with reconstructed spectra here, not the original measured paint curves.

If you'd like the short version in math, a plain sRGB blend looks like this, applied to each encoded channel:

C(t)=(1−t)CA+tCB

Bloom takes a different route: it combines modeled absorption-to-scattering ratios across wavelengths. That extra step is what lets the color path bend away from a straight RGB blend. You can see it in motion below, as the knife pulls the two patches of paint together.

Native Bloom Sketches recording at four times speed. Circular Oil Flat brush strokes lay down Cadmium Red and Cadmium Yellow Pale on linen. A large, clean Diamond oil knife rocks ten degrees each way through twenty left-right sweeps, mixing the adjoining halves while leaving the outer colors mostly untouched.
Native Bloom Sketches recording at four times speed. Circular Oil Flat brush strokes lay down Cadmium Yellow Pale and Cerulean Blue on linen. A large, clean Diamond oil knife rocks ten degrees each way through twenty left-right sweeps, mixing the adjoining halves while leaving the outer colors mostly untouched.

There's more to a paint color than RGB

A screen gives us red, green, and blue values. A paint has a whole pattern of wavelengths it reflects and absorbs. We call its reflected fraction R(λ): 0 means none of the light at that wavelength comes back; 1 means all of it does. Two paints can look alike on screen and still mix differently because those patterns aren't the same.

The colors in these examples are Cadmium Red, Cadmium Yellow Pale, and Cerulean Blue from Bloom's Chartpak Grumbacher PTO catalog. Each has 36 measured reflectance samples, from 380 to 730 nanometers in 10 nm steps. Picking just three wavelengths makes the differences easier to see:

Selected catalog reflectance values, from 0 to 1
Paint450 nm550 nm650 nm
Cadmium Red0.02330.03050.7971
Cadmium Yellow Pale0.03330.74950.9048
Cerulean Blue0.12600.03600.0428

The catalog data comes from rubenwiersma/painting_tools, the Chartpak Grumbacher PTO set, with Centore/RIT lineage.

There's a catch worth keeping in mind. The recordings start with the catalog's sRGB display colors. Bloom reconstructs 38-band spectra from those colors; it doesn't keep the original measured curves at every canvas pixel. So if two paints have identical sRGB inputs, they'll get identical reconstructed spectra, even if the real paints would mix differently.

A little math behind the mix

Here's where Kubelka–Munk comes in. At each wavelength, it turns reflectance into a ratio of absorption K to scattering S. Let's call that ratio Q:

Q(λ)=KS=[1−R(λ)]22R(λ)

That relation assumes infinite optical thickness: a paint layer thick enough that what's underneath no longer affects its reflectance. You'll often see it written R∞; we're shortening that to R. This part of the model doesn't describe film thickness or the canvas underneath.

Now choose how much of each color to mix. Let t be the input blend proportion for color B, leaving 1 − t for color A. The mixer squares those proportions and weights them by luminance, Y — the CIE XYZ Y component of each reconstructed spectrum. These are model weights, not measured pigment concentrations or tinting strengths. With the default tinting strengths of 1 for both colors, we get:

cA=(1−t)2YA,cB=t2YB Qmix=cAQA+cBQBcA+cB

Once those ratios are combined, this brings us back to reflectance:

Rmix=1+Qmix−Qmix2+2Qmix

Let's try it with two numbers

Suppose one paint reflects 80% of the light at a particular wavelength and another reflects 20%. Give them equal effective weights, cA = cB, and follow the calculation below. These are illustrative values, not measurements of the paints above.

  1. QA = (1 − 0.8)² / (2 × 0.8) = 0.025
  2. QB = (1 − 0.2)² / (2 × 0.2) = 1.6
  3. Qmix = (0.025 + 1.6) / 2 = 0.8125
  4. Rmix = 1 + 0.8125 − √(0.8125² + 2 × 0.8125) ≈ 0.3008

We end up at about 30% reflectance, not the 50% we'd get by averaging. That's one wavelength. Repeat across the spectrum, combine the results with CIE color-matching weights to get XYZ, and convert to sRGB: now there's a color to put on screen.

Could a Bézier curve take a shortcut?

That's the idea behind the “Spectral-Curve” mode in Bloom's color-mixing visualization. It's still an experiment, not the canvas mixer. Take the spectral result at t = 1/3 and t = 2/3, then fit a cubic Bézier curve in encoded sRGB space through those two samples and the original endpoints:

B(t)=(1−t)3CA+3(1−t)2tP1+3(1−t)t2P2+t3CB

The input colors are CA and CB; P1 and P2 are the control points we solve for. We're fitting the route from one color to another, not a reflectance spectrum across wavelengths. The curve hits the chosen samples, but it can differ visibly for some color pairs in between. That's why both versions are in the comparison above.

So why not use it on the canvas? An earlier evaluation across eight color pairs found a mean CIELAB ΔE76 error of about 2.3, but a worst-case error of about 10.3 compared with the direct spectral calculation. The proposed maximum-error target was roughly 3. Two nice-looking gradients aren't enough to say the shortcut works for every color.

There's also a practical wrinkle: the current visualization computes two direct spectral samples and the fit for every output pixel. It doesn't cache a curve and reuse it. To save work, we'd need to fit once and use that curve many times for the same pair of colors. On the canvas, the paint under the knife changes as it moves, so the pair doesn't stay fixed. Swapping in the visualization code as it stands would mean two spectral evaluations instead of one, plus the curve math — not a speedup.

For now, the knife uses the direct spectral mixer. It carries paint into neighboring areas and mixes what meets there, leaving streaks and little pockets of the starting colors along the way. The model doesn't follow every pigment particle, but those local differences are what keep the result from becoming one flat, uniform color.

Try Bloom Sketches on TestFlight

Available on macOS and iPad