Blend & Flow

How colors merge, how strokes bend in the wind, and how ink begins to move

Contents

  1. Three blend modes: Mix / Multiply / Darken
  2. Comparing blend effects across colors
  3. Why background color matters
  4. Path Rotation: twisting stroke direction
  5. Flow Effect: eight blend types
  6. Last Stroke Only: affect only the last stroke
  7. Spectral: pigment-accurate color mixing
1

Three blend modes: Mix / Multiply / Darken

Imagine overlapping two crayons of different colors. "Mix" blends them linearly (like mixing on a palette). "Multiply" multiplies the two colors—more overlap gets darker (like two tinted glasses). "Darken" takes the darker of the two per channel (like keeping the deepest shadow).

In encode.frag, keyBlendMode controls how each new stroke blends with existing color:

ModekeyBlendModeShader logicVisual
Mix0 mix(existing, new, alpha) Linear blend by alpha; more overlap = more saturated
Multiply1 existing * new Color multiply; darker and calmer with overlap
Darken2 min(existing, new) Per-channel minimum; keeps the darkest tone

Below: orange + blue crossing, with each of the three blend modes:

Mix — orange and blue cross
Mix (linear blend)
Multiply — orange and blue cross
Multiply
Darken — orange and blue cross
Darken

Key shader code

// encode.frag — keyBlendMode decides blend if (keyBlendMode == 0) { blended = mix(existing, newColor, alpha); } else if (keyBlendMode == 1) { blended = existing * newColor; } else { blended = min(existing, newColor); }
2

Comparing blend effects across colors

The same blend mode with different color pairs gives very different results. Below: three color pairs.

Orange + Blue

Orange Blue Mix
Mix
Orange Blue Multiply
Multiply
Orange Blue Darken
Darken

Red + Teal

Red Teal Mix
Mix
Red Teal Multiply
Multiply
Red Teal Darken
Darken

Gray + Gray (reference)

Gray Gray Mix
Mix
Gray Gray Multiply
Multiply
Gray Gray Darken
Darken

What to notice

  • Complementary colors (orange/blue, red/teal) in Multiply get very dark where they overlap—RGB values multiply to small numbers.
  • Darken keeps the minimum per channel; overlap often shows a third hue.
  • Same color (gray/gray) shows how the three modes affect density without color distraction.
3

Why background color matters

Imagine placing tinted film on white paper—you see colored light. On black paper, you see almost nothing. Blend mode behavior depends entirely on the "base" color.

In encode.frag, isWhiteBase controls this:

isWhiteBase

The shader checks whether the current pixel base is "close to white." If so, it uses additive-style blending (for white backgrounds); otherwise it uses keyBlendMode. That’s why Multiply and Darken look similar on pure white or black—white base uses the additive path.

// encode.frag bool isWhiteBase = hasWhiteTag || isHighLuminanceGray || isVeryBright;

Best practice

To see clear blend-mode differences, use mid-tone backgrounds, e.g. beige [222, 212, 195], warm gray [180, 160, 140], or cool gray [150, 160, 170]. Avoid pure white or black.

4

Path Rotation: twisting stroke direction

A flat brush without rotation (Mode 1) follows your hand. Slight rotation (Mode 2) is like natural wrist turn. Strong rotation (Mode 3) is like a sharp flick—strokes twist wildly.

pathRotation controls how much noise perturbs stroke direction along the path:

ModepathRotationEffect
Mode 10No rotation; particles follow path
Mode 25–10Light perturbation, natural writing feel
Mode 310–25Strong twist; edges spread, wilder shape
Path Rotation Mode 1
Mode 1 (pathRotation = 0)
Path Rotation Mode 2
Mode 2 (pathRotation = 7)
Path Rotation Mode 3
Mode 3 (pathRotation = 17)

How it works

In the draw loop, each particle’s direction gets a Perlin-noise offset. pathRotation is the strength of that offset.

let noiseAngle = noise(x * scale, y * scale) * pathRotation; particleDir += noiseAngle;
5

Flow Effect: eight blend types

Flow is InkField’s strongest post effect (see Effects). Below, each blendType on the same strokes:

Flow Type 0
0: Basic
Flow Type 2
2: Concentric
Flow Type 3
3: Vertical
Flow Type 4
4: Horizontal
Flow Type 5
5: Crack Pattern
Flow Type 6
6: Mosaic
Flow Type 7
7: Vortex
Flow Type 8
8: Cellular

Per-type behavior

blendTypeNameDisplacement
0BasicSimplex noise + globalStyle strength
2ConcentricTwo random centers create radial ripples; uses mix() to override base noise—ripples dominate near centers, organic noise preserved at edges
3/4Vertical/Horizontalsin/cos/tan layers; directional texture
5Crack PatternDual-layer Voronoi/cellular noise; strong displacement at cell boundaries creates crack/fracture texture, base noise preserved inside cells
6MosaicCanvas split into random-sized tiles, each with independent random offset—like broken tiles shifting apart, sharp grid boundaries
7VortexTwo vortex centers with polar rotation from original coords, Gaussian falloff—vortex dominates near centers, transitions to noise farther out
8CellularVoronoi + Simplex; tissue-like
6

Last Stroke Only: affect only the last stroke

With ten strokes on paper, "Last Stroke Only" is like a sheet that covers the first nine—only the last stroke is moved by Flow. Off: all strokes are affected.
Last Stroke Only OFF
OFF — all strokes affected
Last Stroke Only ON
ON — only last stroke

Implementation

When flowEffectLastStrokeOnly is true, flow.frag uses flowEffectStrokeBounds so only pixels inside the last stroke’s bounds are displaced.

// flow.frag if (lastStrokeOnly) { bool inBounds = all(greaterThan(coord, bounds.xy)) && all(lessThan(coord, bounds.zw)); if (!inBounds) displace = vec2(0.0); }

When to use

  • OFF: Full-frame effect—apply Flow once after all strokes.
  • ON: Per-stroke control—each stroke gets its own flow while earlier strokes stay intact.
7

Spectral: pigment-accurate color mixing

Imagine squeezing red and yellow paint onto a palette and stirring them together—you expect orange. But Multiply (RGB multiplication) turns red × yellow into near-black, because RGB math doesn't understand "pigment." Spectral mode converts colors into 38-wavelength reflectance curves, mixes them in the physical domain, then converts back to RGB—so red + yellow genuinely becomes orange.

Why spectral mixing?

RGB is an additive light model (red light + green light = yellow light), but pigments are subtractive (red pigment absorbs green light, yellow pigment absorbs blue light, leaving only orange). Using RGB Multiply to simulate pigment mixing makes complementary colors (like yellow + blue) turn near-black instead of the physically correct green.

Spectral mode solves this by expanding each color into a reflectance curve across 38 wavelengths (380nm–730nm), mixing in that space, and converting back to screen colors.

Three iterations

v1: Original Kubelka-Munk (failed)

Used spectral.js's KM formula with luminance weighting. Problem: bright colors dominated dark ones (yellow concentration 13× blue), and A/B tests showed no visible difference from Multiply.

v2: Flat KM (partial success)

Removed luminance weighting, used equal 50/50 KS-weighted mixing. Complementary colors worked (yellow + blue = green, red + blue = purple), but analogous colors failed (red + yellow = "more red," not orange).

Root cause: The KS function (1-R)²/(2R) produces extreme absorption coefficients at low reflectance. Red at green wavelengths: R≈0.05 → KS≈9.0; yellow: R≈0.9 → KS≈0.006. After averaging (KS=4.5) and inverting, reflectance is only 0.026—red's absorption completely overwhelms yellow's reflectance.

v3: Weighted geometric mean (current)

Replaced KM's KS/KM transform with a weighted geometric mean in reflectance space:

sR[i] = pow(R_canvas[i], 0.35) * pow(R_brush[i], 0.65); // canvas = previously painted (35% weight) // brush = newly painted (65% weight)

Two design decisions:

  • Geometric mean over KM: Preserves subtractive character (wavelengths absorbed by both colors stay dark) without letting one color's extreme absorption dominate the other
  • 0.35/0.65 weighting: The later-painted color has more influence on brightness—bright yellow over dark red produces a bright orange; dark red over bright yellow produces a dark orange. This matches the physical intuition that the top layer dominates appearance

Mixing results comparison

Color pair Multiply Spectral
Yellow + Blue Near-black Green
Red + Blue Near-black Purple
Red + Yellow Dark red Orange

Technical pipeline

// encode.frag — Spectral path (when !isWhiteBase) // 1. sRGB → linear light vec3 lrgb1 = spectral_srgb_to_linear(oldColor); vec3 lrgb2 = spectral_srgb_to_linear(adjustedColor); // 2. Linear → 38-band reflectance curves float sR1[38], sR2[38]; spectral_linear_to_reflectance(lrgb1, sR1); spectral_linear_to_reflectance(lrgb2, sR2); // 3. Weighted geometric mean (brush gets 65%) float sR[38]; for (int i = 0; i < 38; i++) { sR[i] = pow(sR1[i], 0.35) * pow(sR2[i], 0.65); } // 4. Reflectance → XYZ → sRGB vec3 targetColor = spectral_xyz_to_srgb( spectral_reflectance_to_xyz(sR)); // 5. Saturation boost + blend with canvas targetColor = hsb_boost(targetColor, sat * 1.2); result = mix(oldColor, targetColor, intensity);

Acknowledgments

Spectral reflectance coefficients and XYZ color matching data from spectral.js by Ronald van Wijnen (MIT License). Shader integration architecture inspired by p5.brush by Alejandro Campos (MIT License).

← Previous: Effects Workshop   |   Next: Recording & Playback →

InkField Tutorial Series — Understanding digital ink, explained simply