この記事はZennに掲載した記事の転載に加筆したものです。

上からsRGB補間、リニアRGB補間、Oklab補間。
多くのイラストソフトでは、通常のsRGBでの合成や補間が、非線形に符号化されたRGB値に対して行われる(注1)。 中間が暗く濁るのはバグではなく、その符号化された値をそのまま平均した結果である。
PhotonMixerというイラストソフトを個人で作っている。 構成はElectron、TypeScript、WebGPU(raw API)、Lit(Web Components)。
名前はそのまま光を混ぜるという意味。 絵の具ではなく光の量として色を持ち、光として混ぜるといったコンセプトから名前をつけた。
動機の半分は、イラストソフトの色処理を3DCG寄りのパイプラインで組んだら何が変わるかという興味にある。 モニターは光を発する媒体なので、デジタルネイティブなソフトなら色を混ぜるのではなく光を混ぜるという発想で作るべきではないか、という問いからきている。
もう半分は、SAIの書き味が好きで離れられないことにある。 CLIP STUDIO PAINTも持っているし機能面では不足はないが、結局SAIに戻ってしまう。 SAIのペンの滑らかさや色を混ぜながら描く書き味がどうしても手に合っていて、離れられないでいる。
なぜOklabで混色するか
SAIの混色モデル
SAIの水彩筆には「混色」「水分量」「色延び」という三つのパラメータがある。 ブラシがキャンバスに触れたとき、その位置のピクセル色を拾い、ブラシ色と混ぜながらストロークに沿って広げていく。 この「キャンバスの色を拾って混ぜる」機能は、イラストソフトの普通のブラシが持つ標準的な動作ではない。 Photoshopのミキサーブラシ、Kritaのカラースマッジブラシなど、特定のブラシ種別に搭載される機能である。 通常のペンや鉛筆ツールはブラシ色をα合成するだけで、キャンバス色を拾わない。
PhotonMixerはこのSAI的な混色モデルを再現している。 変えたのは補間の計算空間で、Oklabという色空間を使っている。
この記事で扱う「混色」は、レイヤーのα合成そのものではなく、ブラシが拾ったキャンバス色とブラシ色の間をどの色空間で補間するか、という処理を指す。
sRGBで補間する場合
sRGBの伝達関数は非線形であり、色の演算用には設計されていない。 この空間で二色の中間値を取ると、知覚的な中間にならない。 要は感覚的な中間色にならないのである。 冒頭の画像の1行目がその例で、赤とシアンの間に暗く彩度の落ちた領域が生まれる。
ではリニアRGBで補間すればよいかというと、別の問題が出る。 2行目のリニアRGB補間は物理的な光のエネルギーとしては正しい色の配分になる。 しかし人間の目には中間色が明るすぎるように見える。 リニアRGBは物理量であって、人間の感覚的なものとは関係ないからである。
Oklabの選定
sRGBでもリニアRGBでも、人間の感覚的な色を扱う方法としては課題がある。 色空間の仕組みや種類を調べ回っていたとき、AIにOklabを教えてもらった。 Björn Ottosson氏が設計した空間で(注2)、以下の性質がある。
- L軸:知覚的な明度に対応する。物理的な光量ではなく、人間が「同じ明るさ」と感じる尺度
- a/b軸上の距離:知覚的な色差に近い
- 色相の回転:補間中に意図しない色相を通過しにくい
色彩工学では、こうした性質を**知覚均等性(perceptual uniformity)**と呼ぶらしい。 知覚均等な空間で補間すれば、二色の間の色がどこも同じくらいの「違い」に見えるようになる。
3行目のOklab補間では、sRGB補間のように単純に暗い灰色へ落ち込まず、知覚的に比較的滑らかな経路を通る。 ただし、Oklabの線形補間が万能というわけではなく、明度やクロマを常に一定に保つわけではない。 反対色同士ではOklab上でも補間途中でクロマが低下する(注3)。 sRGB補間との違いは「濁らない」というより「濁り方の性質が違う」で、人間の感覚的な色の滑らかさではOklabが優位になる(注4)。
実装の構造
PhotonMixerの描画パイプラインはWebGPUのraw API(@webgpu/types)で組んでいる。
フレームワークやラッパーライブラリは使わず、シェーダーはWGSLで直接書いている。
筆者がフロントエンドエンジニアで、ブラウザベースの環境で作ってみたいという動機からこういった構成になっている。 ネイティブのほうがパフォーマンス的には有利かもしれないが、WebGPUやシェーダーが直接使えるようになった昨今ではある程度戦えると思って、この選定にしてみた。
作業色空間はlinear sRGB。 混色の計算はブラシのフラグメントシェーダー内で完結させた。 CPU側にテクスチャを読み戻す方式も考えられるが、毎フレームのGPU↔CPU往復はレイテンシが大きいと思い、この仕組みにしている。 シェーダーが確定済みキャンバスのテクスチャをサンプリングし、プリマルチプライドαをストレートに戻してからOklab空間で補間し、リニアRGBに戻して書き込む。
以下は実際のシェーダーから簡略化した擬似コードである。
// 係数は掲載用に丸めている。実装ではOklab参照実装の10桁精度の値を使用
fn linear_to_oklab(c: vec3f) -> vec3f {
let lms = vec3f(
0.4122 * c.r + 0.5363 * c.g + 0.0514 * c.b,
0.2119 * c.r + 0.6807 * c.g + 0.1074 * c.b,
0.0883 * c.r + 0.2817 * c.g + 0.6300 * c.b
);
// WGSLのpowは負値を扱えないため、符号を保存した立方根を取る
let lms_ = sign(lms) * pow(abs(lms), vec3f(1.0 / 3.0));
return vec3f(
0.2105 * lms_.x + 0.7936 * lms_.y - 0.0041 * lms_.z,
1.9780 * lms_.x - 2.4286 * lms_.y + 0.4506 * lms_.z,
0.0259 * lms_.x + 0.7828 * lms_.y - 0.8087 * lms_.z
);
}
var target_color = brush_color; // 透明な領域ではブラシ色がそのまま乗る
if (canvas_alpha > 0.001) {
let canvas_oklab = linear_to_oklab(canvas_color / canvas_alpha);
let brush_oklab = linear_to_oklab(brush_color);
let mixed_oklab = mix(brush_oklab, canvas_oklab, wet_ratio * canvas_alpha);
target_color = oklab_to_linear(mixed_oklab);
}
混ぜる比率はwet_ratio(にじみのスライダー)にcanvas_alphaを掛けたものになる。
キャンバスに色が乗っているほど混色が強く効き、透明な領域ではブラシ色がそのまま乗る。
canvas_alpha > 0.001のガードはアンプリマルチプライの除算のためのもので、ここで見た目の段差は生じない。
混色の比率自体にcanvas_alphaが掛かっているため、αが0に近づくにつれて混色率も0へ収束するからである。
なおクランプは入力側にのみ入っている。
linear_to_oklab内の処理はWGSLのpowが負値を扱えないことへの対処であり、符号を保存する形にしてある(Math.cbrtを使うTypeScript側では不要になる)。
一方、Oklab補間後のリニアRGB出力にはクランプをかけておらず、1.0を超える値や負値もそのままrgba16floatに保存される。
表示時にトーンマップとsRGB変換が適用される。
冒頭の画像で見たsRGB補間の濁りは、実際にストロークを引くとより体感的にわかる。 色を重ねるたびに彩度が落ちていく挙動と、Oklabで補間したときの滑らかさの違いは、手で触って確かめられる差になる。
各軸4倍のスーパーサンプリング描画
なぜSSAAか
ブラシストロークのアンチエイリアスにはいくつかの方式がある。 分析的AA、ハードウェア支援のMSAA、描画解像度を上げてから縮小するSSAA(スーパーサンプリング)などがある。
PhotonMixerはSSAAを選んだ。 分析的AAはブラシ形状ごとに被覆率計算を実装する必要があり、テクスチャブラシへの拡張が面倒になる。そもそも筆者が仕組みをよく把握していない。 WebGPUのMSAAは標準仕様上4サンプルに限られ、またMSAAはポリゴン境界の被覆率を扱う仕組みなので、テクスチャブラシ内部のアルファまでは面倒を見てくれない。 高解像度バッファに描いて縮小するSSAAが、一番素直で汎用的だろうということでSSAAで処理している。
パイプラインの流れ
描画は3ステップで行う。
- 各軸4倍の一時バッファにブラシスタンプを描画(max blend)
- Compute Shaderで4×4ピクセルを1ピクセルに畳み込み(Box Filter)
- 1xの隔離バッファからキャンバスにOver合成で焼き込み
各軸4倍なので1ピクセルあたり16サンプルを取り、その平均から被覆率を得る。
ブラシのフラグメントシェーダーはsmoothstepで連続的なアルファを出力するため、単純な二値被覆の離散段階ではなく、16サンプルの連続値の平均になる。
ステップ1のmax blendは、同一ストローク内でスタンプが重なったときのα累積を防ぐためのものである。 通常のOver合成では、30%不透明のブラシを引くとスタンプの重なり部分で不透明度が累積し、ほぼ不透明になる。 max blendは各ピクセルの最大値だけを残すため、1ストローク内では不透明度が頭打ちになる。
このmax blendはRGBA全チャンネルに適用される。 PhotonMixerの混色モードでは、同一ストローク内の全スタンプが同じ確定済みキャンバスをサンプリングするため、同じ位置では同じ色が出力される。 そのためRGBに対するmaxも実質的に同一値のmaxとなり、問題は起きない(注4)。

左が通常描画、右が各軸4倍のSSAA。 8倍に拡大すると、通常描画では階段状のエッジが目立つのに対し、SSAAでは中間輝度のピクセルが被覆率に応じた濃淡を作っている。
この実装は特に細い線の描画で違いが出る。 SAIほどではないが、1px程度でもある程度滑らかで繊細な線を引けるようになった。 今後さらに改良していくことになると思う。
SSAAのコスト
問題もいくつかある。 大きいのはメモリと帯域である。 一時バッファ1枚がキャンバス全体の各軸4倍になるため、キャンバスサイズに対してコストが急速に増大する。
| 論理解像度 | 各軸4倍バッファ(rgba16float 1枚) |
|---|---|
| 800×600 | 約60MB |
| 1920×1080 | 約265MB |
| 4K(3840×2160) | 約1GB |
さらにWebGPUのmaxTextureDimension2Dの標準的な下限値は8192なので、各軸4倍では論理キャンバスの一辺が2048までに制限される環境がある。
現在はキャンバス全体の各軸4倍バッファを1枚確保している。 このバッファは毎フレーム使い回す一時テクスチャ1枚で、レイヤーごとには持たない。 高解像度キャンバスではさらに増えることになるので、このメモリ消費は改善が必要だと思っている。 ストロークのバウンディングボックスだけを高解像度化するか、タイル分割するなどの工夫が今後必要になる。
浮動小数点リニアで色を持つ
PhotonMixerは内部キャンバスをrgba16float(16bit浮動小数点)のlinear sRGBで保持している。 KritaのScene Linear PaintingやPhotoshopの32bit/channel HDRなど、既存ソフトにも浮動小数点リニアのワークフローは存在する。 一方で、一般的なイラスト制作ソフトで標準的な動作として浮動小数点リニアを採用するものはほぼないと思う。 PhotonMixerではこれを基本の内部表現として採用した。
色を光の量として持っているのは、光を混ぜたいからである。 絵の具の混色をモデル化するなら別の設計になるが、デジタルのイラストを作成するなら、発光する側の色をコントロールする設計のソフトがあってもいいのではないかという発想である。
浮動小数点リニアで保持することで、内部的に1.0を超える値をそのまま保持できる。 ただし、rgba16floatで1.0超を保存できることと、それがHDRモニターに発光色として表示されることは別の話である。 表示経路(WebGPUのcanvas設定、OS、ディスプレイ)が揃う必要がある。 HDR表示に対応したモニターは増えていて、スマートフォンではほぼ標準になっている。 出力側をどう作るかは、これから手を付けるところになる。
現状
レイヤーシステム、基本ブラシ、エフェクトレイヤー(ぼかし、グロー、シャープ)などが動いている。

仕組みや設計は自分で決めているが、実際の実装はほとんどAIに書いてもらっている。
できていないことのほうが多い。 ブラシの種類とUIは途中で、SSAAの都合で高解像度キャンバスにも対応できていない。 HDR出力も、内部表現は用意できているが表示経路がまだない。
配布
v0.1.0をポータブル版で公開している。インストール不要で、そのまま起動できる。
- プロジェクトページ: PhotonMixer
- ソースコード: https://github.com/iwabuchi404/photon-mixer (MIT)
まだ開発途中だが、独自のパイプラインでイラストソフトを組むのは発見が多い。 気になる点や「こういう使い方がしたい」などがあれば、Xで教えてほしい。
注
- PhotoshopではGamma 1.00によるブレンドを明示的に選択できる(Color Settings内)。Kritaでは線形浮動小数点のドキュメントを作成でき、Scene Linear Paintingとして公式にサポートされている。ここでの記述は各ソフトの標準設定での挙動を指している。
- https://bottosson.github.io/posts/oklab/ 。Photoshopではグラデーションツールの補間方法としてOklab(Perceptual)が選択可能になっているが、ブラシの混色にOklabを使うイラストソフトは、調査した範囲では見つからなかった。
- Oklabのクロマ(彩度に対応)はC = √(a² + b²)で計算される。a/bを線形補間すると色空間上の二点を直線で結ぶため、反対方向の色同士では中心に近づきCが低下する。
- ストローク中にブラシ色が変化するモード(progressive)では、隣接スタンプ間で色が異なる場合にチャンネル単位のmaxが意図しない色を生む可能性がある。現在は隣接スタンプ間の色差が小さいため実用上の問題は出ていないが、設計上の制約として認識している。