Color is a traveler
In InkField, the journey looks like this:
This tutorial walks through every station.
35+1 color palette: each color has an ID
Palette colors are defined in js/colors.js. Each color has:
- id: the slot number (0–35) used by the program to identify it
- name: e.g.
black,wine_red - rgb: [R, G, B] in 0–255
- hex: hex code like
#1A1A1A
All 36 colors
Special IDs
| ID | Name | Note |
|---|---|---|
| 0 | Black | Default; uses different blend logic (desaturate) |
| 1 | White | Uses the separate "white brush" encoding path |
| 29 | Canvas | Same as background; used to "erase" |
| 33 | Custom | User-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.
RGB: three lights
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:
HSB color space
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:
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.
hueShift / satShift / briShift
Three knobs
| Knob | HSB | Effect |
|---|---|---|
| hueShift | H | Rotate around the hue wheel |
| satShift | S | More vivid / more gray |
| briShift | B | Brighter / 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.
strokeLum and intensity
encode.frag reads the stroke texture and computes luminance:
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
| useSharpen | Formula | Effect |
|---|---|---|
| 0 (diffuse) | 1.02 × clamp(0.15 + 0.85×x0.6) | Strong base, soft spread |
| 1, 2, 3 | x0.7 | Medium lift, good for texture |
| 4, 5 (watercolor) | Black: x1.5×0.98; Color: x0.7 | Black darkened to avoid bright spots |
x = baseIntensity. Exponent < 1 brightens; > 1 darkens.
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)
Mode 1 — Multiply
Mode 2 — Darken
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.
White brush secret code (Historical)
White brush encoding
When brushCategory > 0.5, encode.frag does:
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
| Channel | Stores | Range |
|---|---|---|
| R | Stroke brightness | 0.5 (dense) ~ 1.0 (none) |
| G | Marker = R×0.5 | Computed |
| B | Max opacity | From 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.
Alpha: pixel's hidden ID (Historical)
brushTypeMarker
| Alpha | Meaning | When |
|---|---|---|
| 0.99 | Black or white (id 0 or 1) | brushColorIdx == 0 or == 1 |
| 0.995–1.0 | Color brush | brushColorIdx > 1; value = 0.995 + intensity×0.005 |
Intensity in Alpha for color brush
Color brush Alpha = 0.995 + intensity×0.005. composite.frag can recover intensity from Alpha for blending.
Decode: how composite.frag reads the codes (Historical)
Decoding is a decision tree:
White brush decode
When the marker is detected:
Screen blend only makes the result brighter.
Color brush decode
Black brush decode
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.
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.
Shader typeMapBuffer channels
| Channel | Stores | Values |
|---|---|---|
| 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
| Phase | What | Shaders 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:
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.
Color's full journey in the pipeline
End-to-end path for one color pixel:
2. HSB tweaks (hueShift, satShift, briShift)
3. strokeLum → intensity (curve)
4. mix(base, adjusted, intensity)
5. Alpha = 0.995 + intensity×0.005
2. intensity = (0.997-0.995)/0.005 = 0.4
3. colorFilter = mix(white, purple, 0.4)
4. base × colorFilter → final color
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
| Type | encode | composite | Blend |
|---|---|---|---|
| Black (id=0) | Desaturate + Alpha=0.99 | Alpha < 0.995 | mix / min + desaturate |
| White (id=1) | G=R×0.5, B=opacity | Detect marker | Screen |
| Color (id>1) | HSB + Alpha=0.995~1.0 | Alpha ≥ 0.995 | Color filter |