顏色是旅行者
在 InkField 裡,顏色的旅程大致是這樣的:
這篇教學會帶你走過每一個車站,看看顏色到底經歷了什麼。
35+1 色調色盤:每個顏色都有身份證
調色盤的顏色定義在 js/colors.js 裡,每個顏色都有:
- id:學號(0~35),程式用它來辨認
- name:英文名,像是
black、wine_red - rgb:三原色數值 [R, G, B],0~255
- hex:十六進位色碼,像
#1A1A1A
全部 36 色一覽
幾個特殊學號
| ID | 名稱 | 特殊之處 |
|---|---|---|
| 0 | 黑色 | 預設色,使用完全不同的混合邏輯(去飽和度) |
| 1 | 白色 | 走完全不同的「白色筆刷」編碼路線 |
| 29 | 畫布色 | 等於背景色,用來「擦除」 |
| 33 | 自訂色 | 不在調色盤裡,由使用者用滑桿自選 |
當你在面板上點一個色票,程式會設定 brushColorMode = id,告訴 Shader「嘿,現在要用學號 X 的顏色畫畫」。Shader 收到學號後,用一長串 if/else 找到對應的 RGB 值。
RGB 三原色光:三盞燈的魔法
在 Shader 裡,RGB 值是 0.0~1.0
日常用的 0~255 其實是把 0.0~1.0 切成 256 格。所以:
- 黑色 #1A1A1A =
vec3(26/255, 26/255, 26/255)≈vec3(0.102) - 純紅 #FF0000 =
vec3(1.0, 0.0, 0.0) - 純白 #FFFFFF =
vec3(1.0, 1.0, 1.0)
Shader 裡有一個很常用的數字叫亮度(Luminance),它把 RGB 三個值加權平均:
HSB 色彩空間:另一種描述顏色的方式
H(色相)= 你在彩虹上挑哪個位置(0~360°)
S(飽和度)= 顏色有多鮮豔?0 = 灰灰的,1 = 超鮮豔
B(亮度)= 顏色有多亮?0 = 全黑,1 = 最亮
為什麼需要 HSB?
因為「把顏色調亮一點」用 RGB 很難做——你要同時改三個值。但用 HSB 只要調一個旋鈕(B)就好。程式裡需要微調顏色的時候,會先把 RGB 轉換成 HSB,調完再轉回來:
HSB 轉換的小陷阱
RGB ↔ HSB 的轉換不是百分之百精確的。特別是當飽和度很低的時候(幾乎是灰色),色相(H)值會變得不穩定——就像問一片灰色牆壁「你是什麼顏色」,答案可能每次都不同。
這就是為什麼 encode.frag 裡有一段保護:當飽和度低於 0.05 時,直接設成 0,避免產生奇怪的偏色。
色彩微調:hueShift / satShift / briShift
三個調整旋鈕
| 旋鈕 | 對應 HSB | 效果 |
|---|---|---|
| hueShift | H(色相) | 整個色環上旋轉,紅變橙、橙變黃… |
| satShift | S(飽和度) | +鮮豔 / −灰暗 |
| briShift | B(亮度) | +更亮 / −更暗(上限 0.95,不會純白) |
有些顏色不讓你調
灰階色(id 2、3、4)的 changeShift 被設成 0,三個旋鈕完全不生效。為什麼?因為灰色沒有色相和飽和度可以調,硬調反而會出現奇怪的偏色。
另外,id 29(畫布色)也走特殊路線——直接強制設成 HSB(0, 0, 1)(純白),跳過所有微調。因為它的功能是「擦除」,不需要偏色。
亮色的亮度上限
如果顏色本身已經很亮(B > 0.6),亮度微調的上限會被壓縮到 0.95 − 目前亮度。這是為了避免亮色調到幾乎全白,跟白色筆刷搞混。
筆觸濃度:strokeLum 與 intensity
encode.frag 先讀取筆觸圖層(strokeTex),算出每個點的亮度:
「快速出局」門檻
如果 strokeLum > 0.9(幾乎純白 = 沒畫到),整個像素直接跳過,保留原本的顏色。這就像掃過紙面但沒碰到的部分,不用浪費時間處理。
接下來,baseIntensity 會經過一條「曲線」變成最終的 intensity,不同墨水效果模式用不同曲線:
各模式的 intensity 曲線
| useSharpen | 公式 | 效果 |
|---|---|---|
| 0(墨汁擴散) | 1.02 × clamp(0.15 + 0.85 × x0.6) | 起始就有 15% 底色,擴散感強 |
| 1, 2, 3 | x0.7 | 中等提亮,適合紋理效果 |
| 4, 5(水彩) | 黑筆 x1.5 × 0.98彩筆 x0.7 | 黑筆壓暗避免水彩白點過亮 |
x = baseIntensity。次方 < 1 = 提亮(淡墨區域變明顯),次方 > 1 = 壓暗(只有濃墨才明顯)。
三種混合模式:Mix / Multiply / Darken
當彩色筆畫在已有顏色的地方時,新舊顏色要怎麼「合在一起」?這裡有三種模式可以選(由 keyBlendMode 控制):
Mode 0 — Mix(線性混合)
Mode 1 — Multiply(相乘)
Mode 2 — Darken(取暗)
黑色筆刷的特殊處理
當 brushColorIdx == 0(黑色)時,不走上面三種模式,而是走專屬邏輯:
- 灰色底 + 白色底 →
mix混合後去飽和度(避免染色) - 灰色底 + 非白 →
min取暗 - 彩色底 →
min取暗後去飽和度(只留明暗,不留色彩)
「去飽和度」就是把 HSB 的 S 設成 0——把彩色變成灰色。因為黑色墨水蓋上去,不應該讓底色的彩度透出來。
⚠ 歷史架構(Ch8–Ch10)
以下 Ch8–Ch10 描述的是 2026-03-03 之前使用的 G = R × 0.5 暗號系統。該系統已被 TypeMapBuffer 獨立身份緩衝區(Ch10b)取代。
保留這些章節是因為它們記錄了一段重要的架構演化過程——從「在顏色通道內藏暗號」到「獨立身份緩衝區」的轉變,以及這個過程中產生的「紫色幽靈」Bug。
白色筆刷的秘密暗號 (歷史架構)
白色筆刷的編碼公式
當 brushCategory > 0.5(白色筆刷模式)時,encode.frag 不走正常混色,而是:
看到了嗎?G = R × 0.5 就是白色筆刷的秘密暗號。正常的顏色不會有這種巧合(綠色通道剛好等於紅色通道的一半),所以之後 composite.frag 看到這個比例,就知道「啊,這是白色筆刷!」
三個通道各自的任務
| 通道 | 儲存的資訊 | 範圍 |
|---|---|---|
| R(紅) | 筆觸亮度(越亮=越淡) | 0.5(最濃)~ 1.0(沒畫) |
| G(綠) | 暗號 = R × 0.5 | 自動計算 |
| B(藍) | 最大不透明度 | 由 UI 滑桿決定 |
暗號的風險:紫色幽靈 Bug
這個暗號系統有一個弱點——如果 GPU 在縮放圖像時做了「雙線性插值」(把旁邊的像素混在一起),暗號比例會被打破。這時 G ≠ R×0.5,composite.frag 認不出白色筆刷,就會把這個像素當作一般彩色顯示。而 R 值高、G 值是 R 的一半附近、B 值也不低——這組數值看起來就像紫色!
這就是著名的「紫色幽靈」Bug,詳見除錯偵探故事集。
Alpha 通道:像素的隱藏身份證 (歷史架構)
brushTypeMarker 的三種身份
| Alpha 值 | 身份 | 什麼時候? |
|---|---|---|
| 0.99 | 黑色筆刷 / 白色(id=1) | brushColorIdx == 0 或 brushColorIdx == 1 |
| 0.995 ~ 1.0 | 彩色筆刷 | brushColorIdx > 1,數值 = 0.995 + intensity × 0.005 |
彩色筆刷的 intensity 也藏在 Alpha 裡
注意到了嗎?彩色筆刷的 Alpha 不是固定值,而是 0.995 + intensity × 0.005。這意味著:
- intensity = 0(沒畫到)→ Alpha = 0.995
- intensity = 1(最濃)→ Alpha = 1.0
稍後 composite.frag 就能從 Alpha 值反推出 intensity,用來決定合成的透明度!
解碼還原:composite.frag 怎麼讀懂這些暗號 (歷史架構)
composite.frag 的解碼流程就像一棵決策樹:
白色筆刷的解碼
偵測到暗號(G ≈ R×0.5 且 B > 0.4)後,composite.frag 這樣還原:
Screen 混合的結果一定比原本更亮——就像在暗處打手電筒,只會更亮不會更暗。
彩色筆刷的解碼
從 Alpha 反推 intensity:
黑色筆刷的解碼
黑色筆刷比較直接——根據 strokeLum 計算透明度,然後混合:
安全防護:desaturate 去紫
還記得白色筆刷的暗號可能被 GPU 插值破壞嗎?composite.frag 有一個安全網——如果發現像素看起來「有點像被破壞的白色編碼」(G ≈ R×0.5、B 比 G 高、R > 0.1),就把它強制轉成灰色,避免出現紫色幽靈:
退化白色的拯救
還有一種情況:白色筆刷的像素經過 Flow 力場處理後,B 通道的值被 min() 壓低到 0.4 以下,暗號中的「B > 0.4」條件失敗了。這時候程式用「退化白色偵測」把它救回來:
TypeMapBuffer — 現行身份識別系統 (2026-03-03 起)
為什麼需要新系統?
舊的 G = R × 0.5 暗號系統有致命弱點:它把身份資訊藏在顏色通道裡。只要 GPU 對像素做插值(縮放、Flow 力場位移),暗號比例就會被破壞,產生「紫色幽靈」等一系列 Bug(Cases 1–7,見 bug-stories.html)。
解法很簡單——把身份資訊從顏色通道裡搬出來,存進一張獨立的 Buffer。
Shader typeMapBuffer 的兩個通道
| 通道 | 儲存的資訊 | 數值 |
|---|---|---|
| R(紅) | 筆刷類型 | 0.0 = 背景(沒畫過) 0.5 = 彩色或黑色筆刷 1.0 = 白色筆刷 |
| G(綠) | 白色筆刷最大不透明度 | 由 UI 滑桿決定(僅白色筆刷使用) |
三階段實施
| 階段 | 內容 | 影響的 Shader |
|---|---|---|
| Phase 1 | 建立 typeMapBuffer,每筆繪製時由 typeMapEncode.frag 寫入身份 | typeMapEncode.frag(新增)、composite.frag(改讀 typeMapTex) |
| Phase 2 | Flow 力場位移時,typeMapBuffer 同步跟隨顏色移動 | flow.frag(新增 isTypeMapMode 雙 pass) |
| Phase 3 | 移除所有 G=R×0.5 殘留代碼 | encode.frag、flow.frag、feedback.frag、composite.frag |
composite.frag 新的解碼流程
不再需要「偵測暗號」——直接讀 typeMapTex:
不再依賴通道比例,不再怕 GPU 插值破壞——從架構層面消除了紫色幽靈問題。
Flow 力場的同步
Flow 效果會把像素位移到新位置。如果只移動顏色、不移動身份,composite.frag 就會把白色筆刷的像素當成彩色來解碼。
解法:flow.frag 執行 兩次 pass——先用 isTypeMapMode = 1 移動 typeMapBuffer,再用 isTypeMapMode = 0 移動顏色。兩次使用相同的噪聲偏移量,確保身份跟著顏色走。
顏色在管線中的完整旅程
讓我們把所有車站串起來,看看一個彩色像素從頭到尾的完整旅程:
2. HSB 微調(hueShift, satShift, briShift)
3. strokeLum → intensity(曲線處理)
4. mix(淺灰, 調整色, intensity) → 帶有濃淡的紫色
5. Alpha = 0.995 + intensity×0.005 → 身份證
2. 反推 intensity = (0.997-0.995)/0.005 = 0.4
3. 用色彩濾鏡 mix(白, 紫色, 0.4) 處理
4. base × colorFilter → 最終顏色
白色筆刷的不同旅程
白色筆刷走的是完全不同的路線:
總結對照表
| 筆刷類型 | encode 怎麼標記 | composite 怎麼解碼 | 混合方式 |
|---|---|---|---|
| 黑色(id=0) | 去飽和度 + Alpha=0.99 | Alpha < 0.995 → 黑色 | mix / min + desaturate |
| 白色(id=1) | G=R×0.5, B=opacity | 偵測 G≈R×0.5 暗號 | Screen(疊光) |
| 彩色(id>1) | HSB 微調 + Alpha=0.995~1.0 | Alpha ≥ 0.995 → 反推 intensity | Color filter(Multiply 風格) |
高彩度底色的混合難題 (2026-03-15)
問題:互補色 Multiply 必然變黑
Ch7 介紹的 Multiply 模式 base × stroke 在低彩度底色上效果很好(像真實墨水疊加),但在高彩度底色遇到互補色時會崩潰:
RGB 三通道沒有共同的高值,相乘後全部趨近零。這不是 bug,是 Multiply 的數學本質。
解法:底色彩度驅動的混合漸變
核心思路:低彩度底色保持 Multiply(墨色疊加感),高彩度底色漸變為 Normal Mix(筆刷色彩可見)。
進階:筆刷亮度歸一化(realtime.frag)
即時繪畫(realtime.frag)中需要額外處理筆刷邊緣。邊緣的墨量少、亮度高,需要保持 Multiply 才能自然消融。但亮色筆刷(米色、粉紅)本身就亮,不能把「亮」都當「邊緣」處理。
歸一化讓 strokeDensity 判斷的是「相對於筆刷原色的濃淡」,而非「絕對亮度」。
兩個 Shader 的策略差異
| Shader | 有無筆刷色資訊 | 策略 |
|---|---|---|
| realtime.frag | 有(adjustedColor) |
satBlend × strokeDensity(brushLum 歸一化) |
| composite.frag | 無 | 直接用 satBlend(不乘 strokeDensity) |