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
Red50 / 50Yellow
Cadmium Yellow Pale + 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:
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.
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:
| Paint | 450 nm | 550 nm | 650 nm |
|---|---|---|---|
| Cadmium Red | 0.0233 | 0.0305 | 0.7971 |
| Cadmium Yellow Pale | 0.0333 | 0.7495 | 0.9048 |
| Cerulean Blue | 0.1260 | 0.0360 | 0.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:
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:
Once those ratios are combined, this brings us back to reflectance:
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.
- QA = (1 − 0.8)² / (2 × 0.8) = 0.025
- QB = (1 − 0.2)² / (2 × 0.2) = 1.6
- Qmix = (0.025 + 1.6) / 2 = 0.8125
- 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:
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.
Available on macOS and iPad