Color Journey

You pick a color. It gets encoded, stored, decoded, and lands on the canvas — what happens in between?

Contents

  1. Color is a traveler
  2. 35+1 color palette: each color has an ID
  3. RGB: three lights
  4. HSB color space
  5. Tweaks: hueShift / satShift / briShift
  6. Stroke density: strokeLum and intensity
  7. Blend modes: Mix / Multiply / Darken
  8. White brush secret code (Historical)
  9. Alpha: pixel’s hidden ID (Historical)
  10. Decode: how composite.frag reads the codes (Historical)
  11. 10b TypeMapBuffer — Current Identity System ✦
  12. Color’s full journey in the pipeline
1

Color is a traveler

You pick a marker and draw on paper. In our code, that color passes through several "stations" before it appears on screen—like a letter going through sorting, transport, and delivery.

In InkField, the journey looks like this:

PaletteWhich color?
encode.fragEncode + mark
feedback.fragInk effects
finalBufferStored in memory
composite.fragDecode + compose
ScreenWhat you see

This tutorial walks through every station.

2

35+1 color palette: each color has an ID

Imagine a class of 36 students, each with a student ID. The teacher just calls the number to find someone. Our palette is the same—35 preset colors plus 1 custom slot, each with an ID.

Palette colors are defined in js/colors.js. Each color has:

All 36 colors

0Black
1White
2Dark gray
3Mid gray
4Light gray
5Green
6Orange
7Brown
8Teal
9Navy
10Purple
11Olive green
12Gray
13Sienna
14Ochre
15Olive
16Pink
17Wine red
18Gold
19Taupe
20Sage
21Brick
22Silver
23Beige
24Slate
25Camel
26Khaki
27Mist
28Lavender gray
29Canvas
30Red
31Yellow
32Blue
33Custom
34Coral
35Mint

Special IDs

IDNameNote
0BlackDefault; uses different blend logic (desaturate)
1WhiteUses the separate "white brush" encoding path
29CanvasSame as background; used to "erase"
33CustomUser-defined via slider, not in the fixed palette

When you pick a swatch, the app sets brushColorMode = id. The shader then uses that ID in a long if/else chain to get the RGB.

3

RGB: three lights

Imagine a pitch-black room with three lights—red, green, blue. Each has a dial (0–255). All at full = white; all off = black. That's RGB.
R
+
G
+
B
=
Color

In the shader, RGB is 0.0–1.0

0–255 is just 256 steps of 0.0–1.0. So:

  • Black #1A1A1A ≈ vec3(0.102)
  • Pure red #FF0000 = vec3(1.0, 0.0, 0.0)
  • Pure white = vec3(1.0, 1.0, 1.0)

Luminance is a weighted average of R, G, B:

float lum = dot(color, vec3(0.299, 0.587, 0.114)); // luminance = R×0.299 + G×0.587 + B×0.114 // Green has the largest weight because the eye is most sensitive to it.
More photoreceptors respond to green, so green "votes" more in the mix.
4

HSB color space

If RGB is "three lights," HSB describes color more intuitively:
H (Hue) = where on the rainbow (0–360°)
S (Saturation) = how vivid (0 = gray, 1 = full color)
B (Brightness) = how bright (0 = black, 1 = max)

Why HSB?

"Make it a bit brighter" in RGB means touching three values. In HSB you turn one knob (B). The code converts RGB→HSB, tweaks, then HSB→RGB:

vec3 hsb = rgb2hsb(brushColor); hsb.x = mod(hsb.x + hueShift, 1.0); hsb.y = clamp(hsb.y + satShift, 0.0, 1.0); hsb.z = clamp(hsb.z + maxBriShift, 0.0, 0.95); adjustedColor = hsb2rgb(hsb);

HSB gotcha

RGB ↔ HSB is not perfectly invertible. When saturation is very low (almost gray), hue becomes unstable. So encode.frag clamps saturation below 0.05 to 0 to avoid odd tints.

5

hueShift / satShift / briShift

The 36 palette colors are defaults. Three knobs let you tweak—like adjusting a garment to fit.

Three knobs

KnobHSBEffect
hueShiftHRotate around the hue wheel
satShiftSMore vivid / more gray
briShiftBBrighter / darker (capped at 0.95)

Some colors skip tweaks

Grays (id 2, 3, 4) have changeShift set to 0—no hue/saturation to tweak. ID 29 (canvas) is forced to HSB(0,0,1) and skips tweaks (used for erasing).

Brightness cap for light colors

If B > 0.6, max brightness shift is limited to 0.95 - current B so bright colors don't become indistinguishable from the white brush.

float maxBriShift = (hsb.z > 0.6) ? (0.95 - hsb.z) : briShift;
6

strokeLum and intensity

Like a brush loaded with ink: more ink = darker stroke. In code, that "amount of ink" is intensity.

encode.frag reads the stroke texture and computes luminance:

float strokeLum = dot(stroke, vec3(0.299, 0.587, 0.114)); float baseIntensity = clamp(1.0 - strokeLum, 0.0, 1.0); // darker stroke → higher intensity → more "ink"

Early-out threshold

If strokeLum > 0.9 (almost white = no stroke), the pixel is skipped and the previous color is kept.

baseIntensity is then run through a curve to get final intensity; each ink mode uses a different curve:

Intensity curves by mode

useSharpenFormulaEffect
0 (diffuse)1.02 × clamp(0.15 + 0.85×x0.6)Strong base, soft spread
1, 2, 3x0.7Medium lift, good for texture
4, 5 (watercolor)Black: x1.5×0.98; Color: x0.7Black darkened to avoid bright spots

x = baseIntensity. Exponent < 1 brightens; > 1 darkens.

7

Blend modes: Mix / Multiply / Darken

When new color is drawn over existing color, how do they combine? Three modes (keyBlendMode):

Mode 0 — Mix (linear blend)

Like mixing two paints. The ratio is set by intensity.
result = mix(oldColor, newColor, intensity); // mix(A, B, t) = A×(1-t) + B×t

Mode 1 — Multiply

Like stacking two colored filters. Light passes through both; result is always darker. Red × blue = dark purple.
vec3 colorFilter = mix(vec3(1.0), adjustedColor, intensity); result = oldColor * colorFilter;

Mode 2 — Darken

Per channel, take the smaller value. Result is never lighter.
vec3 darkenedColor = min(oldColor, adjustedColor); result = mix(oldColor, darkenedColor, intensity);
Mix
Old + new → blend
Multiply
Old × new → darker
Darken
Per-channel min

Black brush

When brushColorIdx == 0, a separate path is used: mix/min plus desaturation so black ink doesn't tint the result.

⚠ Historical Architecture (Ch8–Ch10)

Chapters 8–10 describe the G = R × 0.5 marker system used before 2026-03-03. It has been replaced by the TypeMapBuffer identity system (Ch10b).

These chapters are preserved because they document an important architectural evolution — from "hiding identity inside color channels" to "independent identity buffer" — and the "purple ghost" bug that motivated the change.

8

White brush secret code (Historical)

The white brush doesn't literally paint white—it adds light on top of dark ink. So how does the pipeline know "this bright pixel is white brush, not just a light color"? By encoding a secret.

White brush encoding

When brushCategory > 0.5, encode.frag does:

// White brush code float whiteLuminance = mix(1.0, 0.5, enhancedIntensity); result.r = whiteLuminance; // R = brightness (0.5~1.0) result.g = result.r * 0.5; // G = R/2 (the secret!) result.b = whiteMaxOpacity; // B = max opacity

G = R × 0.5 is the white-brush marker. Normal colors don't have G exactly half of R, so composite.frag can detect "this is white brush."

Channel roles

ChannelStoresRange
RStroke brightness0.5 (dense) ~ 1.0 (none)
GMarker = R×0.5Computed
BMax opacityFrom UI

Risk: purple ghost bug

If the GPU resamples the image (e.g. bilinear), the G=R×0.5 ratio can break. composite.frag then treats the pixel as normal color; high R and half G can look purple—the "purple ghost" bug. See Bug Stories.

9

Alpha: pixel's hidden ID (Historical)

Besides R, G, B, each pixel has an Alpha channel. In InkField it's used as a "brush type ID" instead of transparency.

brushTypeMarker

A = 0.99
Black / White brush
0.995~1.0
Color brush
AlphaMeaningWhen
0.99Black or white (id 0 or 1)brushColorIdx == 0 or == 1
0.995–1.0Color brushbrushColorIdx > 1; value = 0.995 + intensity×0.005
// encode.frag: set marker float brushTypeMarker; if(brushColorIdx == 0.0) { brushTypeMarker = 0.99; } else if(brushColorIdx > 1.0) { brushTypeMarker = 0.995 + intensity * 0.005; brushTypeMarker = clamp(brushTypeMarker, 0.995, 1.0); } else { brushTypeMarker = 0.99; // white (id=1) } gl_FragColor = vec4(result, brushTypeMarker);

Intensity in Alpha for color brush

Color brush Alpha = 0.995 + intensity×0.005. composite.frag can recover intensity from Alpha for blending.

10

Decode: how composite.frag reads the codes (Historical)

encode.frag packs and stamps; composite.frag unpacks, detects the codes, and outputs the final color to the screen.

Decoding is a decision tree:

Read pixel RGB + Alpha
strokeLum > 0.995 and colorDiff < 0.01? No stroke → use background
G ≈ R×0.5 and B > 0.4? White brush → Screen blend
Alpha < 0.995? Black brush → dark blend
Alpha ≥ 0.995? Color brush → color filter

White brush decode

When the marker is detected:

float encodedLum = stroke.r; float maxOpacity = stroke.b; float intensity = (1.0 - encodedLum) / 0.5; vec3 whiteColor = vec3(mix(0.3, 1.0, intensity)); vec3 screenResult = 1.0 - (1.0 - base) * (1.0 - whiteColor); result = mix(base, screenResult, maxOpacity);

Screen blend only makes the result brighter.

Color brush decode

float encodedIntensity = (brushTypeMarker - 0.995) / 0.005; float alpha = clamp(encodedIntensity, 0.0, 1.0); vec3 colorFilter = mix(vec3(1.0), safeStroke, alpha); result = base * colorFilter;

Black brush decode

float alpha; if(strokeLum > 0.5) { alpha = 0.95; } else { alpha = 1.0 - smoothstep(0.0, 0.95, strokeLum); } result = mix(base, safeStroke, alpha);

Safety: desaturate to avoid purple ghost

If a pixel looks like corrupted white encoding (G≈R×0.5, B>G, R>0.1), composite.frag forces it to gray to avoid purple artifacts.

10b

TypeMapBuffer — Current Identity System (Since 2026-03-03)

Why a new system?

The old G = R × 0.5 marker hid identity inside color channels. Any GPU interpolation (scaling, flow displacement) broke the ratio, causing "purple ghost" and other bugs (Cases 1–7 in bug-stories.html).

The fix is conceptually simple — move identity out of the color channels into a separate buffer.

The old system sewed a secret tag onto the student's shirt — the washing machine (GPU interpolation) destroyed it. The new system issues a separate student ID card that survives any washing.

Shader typeMapBuffer channels

ChannelStoresValues
R Brush type 0.0 = background (unpainted)
0.5 = color or black brush
1.0 = white brush
G White brush max opacity From UI slider (white brush only)

Three-phase rollout

PhaseWhatShaders affected
Phase 1 Create typeMapBuffer; typeMapEncode.frag writes identity per stroke typeMapEncode.frag (new), composite.frag (reads typeMapTex)
Phase 2 Flow displacement syncs typeMapBuffer with color movement flow.frag (new isTypeMapMode dual-pass)
Phase 3 Remove all G=R×0.5 legacy code encode.frag, flow.frag, feedback.frag, composite.frag

composite.frag new decode flow

No more "detecting secret codes" — just read typeMapTex directly:

vec4 typeInfo = texture2D(typeMapTex, uv); float brushType = typeInfo.r; float whiteMaxOpacity = typeInfo.g; if(brushType > 0.75) { // White brush → Screen blend (add light) } else if(brushType > 0.25) { // Color or black brush → color filter / dark blend } else { // Background → use paper texture }

No channel ratios, no GPU interpolation risk — the purple ghost problem is eliminated at the architectural level.

Flow sync

Flow effects displace pixels. If only color moves but identity stays behind, composite.frag will decode white brush pixels as colored ones.

Solution: flow.frag runs two passes — first with isTypeMapMode = 1 to move typeMapBuffer, then isTypeMapMode = 0 to move color. Both use identical noise offsets so identity follows color.

11

Color's full journey in the pipeline

End-to-end path for one color pixel:

You pick "Purple" (id=10) brushColorMode = 10 → RGB(140, 106, 172)
encode.frag 1. Look up purple RGB
2. HSB tweaks (hueShift, satShift, briShift)
3. strokeLum → intensity (curve)
4. mix(base, adjusted, intensity)
5. Alpha = 0.995 + intensity×0.005
feedback.frag Diffusion, texture, wet-in-wet
finalBuffer Encoded pixels stored in memory
composite.frag 1. Read Alpha → 0.997 = color brush
2. intensity = (0.997-0.995)/0.005 = 0.4
3. colorFilter = mix(white, purple, 0.4)
4. base × colorFilter → final color
Purple stroke on screen With shading and ink effect

White brush path

White brush uses a different path: encode stores R, G=R×0.5, B; composite detects the marker and applies Screen blend.

Summary

TypeencodecompositeBlend
Black (id=0)Desaturate + Alpha=0.99Alpha < 0.995mix / min + desaturate
White (id=1)G=R×0.5, B=opacityDetect markerScreen
Color (id>1)HSB + Alpha=0.995~1.0Alpha ≥ 0.995Color filter

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

InkField Tutorial Series — Color Journey