こんにちは、デザイナーだけどFigmaよりTypeScriptを触っている時間のほうが長い
id:ymrl です。
この記事はfreee Developers Advent Calendarの2日目です。

CSSの mix-blend-mode 、なんだかカッコいい見た目のものを作るのに便利そうとずっと思っていたものの、 mix-blend-mode に何を指定したらどうなるのか想像しづらく、私は「当てずっぽうでいろんな値を入れてみていい感じになったのを使う」みたいな状態でずっと誤魔化していました。
なにせ MDNのmix-blend-modeのページに載っているサンプルがこれですよ。

これだけ見てもよくわからない!
ということで、それぞれの blend-mode の値のときの色の計算方法を調べて、自分で計算してみれば mix-blend-mode と和解できるんじゃないかと思ったのです。
mix-blend-mode計算機を作った
mix-blend-modeで何が起きるかを理解するために、Compositing and Blending Level 2のEditor's Draftを参考に、いきなりコードを書きました。
https://codepen.io/ymrl/pen/qBKyBrR
See the Pen Untitled by ymrl (@ymrl) on CodePen.
指定したblend-mode での backdrop color 上で source color が何色になるかを計算して、CSSで指定した色と並べてくれます。

こういうツールを自作したことで、mix-blend-modeについてそこそこ理解できるようになってきました。
mix-blend-modeはディスプレイのカラープロファイルの影響を受ける
ツールの作成中にいきなり悩まされたのが、mix-blend-modeで作られる色はディスプレイのカラープロファイルの影響を受けるということです。
以下の画像は極端な例で、mix-blend-mode: multiply と #ff0000 と #00ffff を指定したとき、計算上は #000000 になりますが、MacBookでの表示は赤茶色になります。
(追記2022/12/02 13:20: 記事公開時、#ff0000と #0000ff と書いていましたが、正しくは #ff0000 と #00ffff でした)

この状況はディスプレイのカラープロファイルをsRGBにすると解消しますが、つまりカラープロファイルの違いによって望みどおりの色にならないという点に注意が必要そうです。
mix-blend-mode によって作られる色
ツールと、MDNのblend-modeの解説を読んで、なんとなく各modeにどんな効果があるのかわかってきました。
ツールでの実装では、入力された上の色 (source) と下の色 (backdrop) の16進数のカラーコードをR,G,Bにバラし、255で割って0〜1の値にして、そこに mix-blend-mode ごとの処理をして、255を掛けてカラーコードに戻すようなことをやっています。 HSL色空間での計算を伴う hue saturation color luminosity以外では R,G,Bそれぞれのチャネルの数値に対する処理をします。
// 下の色(0〜1の数字の配列にする) const b = "#e9e7e7".match( /^#([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})$/i).slice(1).map((e) => parseInt(e, 16) / 255); // 上の色(0〜1の数字の配列にする) const s = "#2864f0".match( /^#([0-9A-F]{2})([0-9A-F]{2})([0-9A-F]{2})$/i).slice(1).map((e) => parseInt(e, 16) / 255); // R,G,Bの各チャンネルにmix-blend-modeの処理をする ( blend(b:number, s:number) => number な関数を用意しておく) const colorArray = [...Array(3)].map((_, i) => blend(b[i], s[i])); // 配列をカラーコードに戻す const colorCode = colorArray.reduce((p, c) => `${p}${`00${Math.round(c * 255).toString(16)}`.slice(-2)}`, "#");
normal
デフォルトのモードで、単純に上の色がそのまま使われます。
const normal = (b, s) => s;

multiply と screen
multiply では色同士が乗算され、1以下の数字同士の乗算となるので暗い色になります。screen はそれぞれの色を反転させて(つまり1から引いて)乗算し、それをさらに反転した色になります。明るい色になります。
という説明だとわかりにくいですが、multiply は「色の三原色」の重なり方、screenは「光の三原色」の重なり方だと理解するのがよさそうです。
const multiply = (b, s) => b * s; const screen = (b, s) => 1 - (1 - b) * (1 - s);

darken と lighten
darken は R,G,Bをそれぞれ比較して暗いほう(小さいほう)の数字が採用されます。これも暗くなります。lighten は明るいほう(大きいほうの数字が採用され、明るくなります。
R,G, Bそれぞれがバラバラに上の色や下の色から取った数字が採用されるので、出来上がりの色が予測しづらいかもしれません。
const darken = (b, s) => Math.min(b, s); const lighten = (b, s) => Math.max(b, s);

hard-light と overlay と soft-light
hard-light は上の色が暗ければ multiply 明るければ screen の効果になりますが、上の色を2倍したり1を引いたりするので、multiply や screen より効果が弱くなっています。
overlay は hard-light の上の色と下の色を入れ替えたものです。
soft-light は計算式がややこしいですが、hard-light よりさらに弱い効果になるようです。
const hardLight = (b, s) => s <= 0.5 ? multiply(b, s * 2) : screen(b, 2 * s - 1);const overlay = (b, s) => hardLight(s, b); const overlay = (b, s) => hardLight(s, b); const softLight = (b, s) => s < 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (b < 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b) - b);

color-dodge と color-burn
color-dodge は screen に、color-burn は multiply に似た効果になります。multiply や screenよりも強い効果が得られるようです。
const colorDodge = (b, s) => b === 0 ? 0 : s === 1 ? 1 : Math.min(1, b / (1 - s)); const colorBurn = (b, s) => b === 1 ? 1 : s === 0 ? 0 : 1 - Math.min(1, (1 - b) / s);

difference と exclusion
difference はR,G,Bそれぞれの差の絶対値、exclusionは difference よりも変化の小さな効果があります。
const difference = (b, s) => Math.abs(b - s); const exclusion = (b, s) => b + s - 2 * b * s;

hue と saturation と color と luminosity
ここまでのblend-modeはRGBに対する計算でしたが、 hue と saturation と color と luminosityの4つは、HSLに対する計算になります。カラーフィルタみたいな使い方ができそう。
hueは上の色の色相(H)と、下の色の彩度(S)と明度(L)を持つ色になります。saturationは上の色の彩度(L)と、下の色の色相(H)と明度(L)を持つ色になります。colorは上の色の色相(H)と彩度(S)と、下の色の明度(L)を持つ色になります。luminosityは上の色の明度(L)と、下の色の色相(H)と彩度(S)を持つ色になります。
HSLを扱う部分が複雑なのでコード例は省略します。

まとめ
なるほどわからん。
明日は Takeru Ichii さんです。お楽しみに。
