GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

メッシュ グラデーション 9パターン — 色点の補間からノイズのゆがみまで計算で描く

メッシュグラデーションは数色をぼかせば終わりに見えるが、そのまま混ぜると真ん中が濁り暗い面は帯に割れる — 05番と06番がその二か所を一つずつ塞ぐ。この記事の九つのメッシュ グラデーションは、キャンバスのピクセルをJavaScriptで一つずつ計算して描いたもので、ファイルを貼ればそのまま動きます。

メッシュ グラデーション九つの場面をこの画面で見る

下のグリッドは、ページを開いた時点から自動で動いています。色の点を置いて混ぜる方法が、場面ごとに一つずつ違います。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

メッシュ グラデーション 9パターン — ピクセルごとに計算した色が画面を埋める

九つの場面は同じ土台の上に立っています。キャンバスの内側は160×100マス(CALC_W・CALC_H)で、フレームごとに createImageData で作った ImageData へ1万6000マスの色を一つずつ書き込み、putImageData で載せてから、CSSが16:10の比率に引き伸ばして見せます。マス数を160×100に絞るのは計算量のためで、描くマスが増えればフレームごとの計算も同じ割合で増えます。

その上で、九つの場面は色の混ぜ方を一つずつ変えます。01は四隅の色の双線形補間が最小のメッシュだと示し、02は3×3の制御点でマスを増やし、03は格子を捨てて色の点だけを置きます。04は色を読む座標をノイズでゆがめ、05と06は混ぜ方の落とし穴を一つずつ直す比較画面です。07は明るさを高さとして読んで光沢をのせ、08は色相の角度を回し、09は03の色点の混ぜ方に周辺の暗さと粒を重ねて、見出しの背景にまとめます。

時間は requestAnimationFrame のタイムスタンプを16ミリ秒(FIXED_DT)単位でためて秒に直し、paint(t, d) に渡します。乱数は種を固定した seedRand だけなので、同じ時刻なら必ず同じ絵になります。メッシュグラデーション 作り方の話でいえば、ライブラリ・WebGL・外部アドレスは一つも使わず、貼れば動く一枚のスクリプトです。同じキャンバスの方法で水滴とブラックホールを描くhtml5 canvas アニメーション9種 — 水滴とブラックホールと、正弦波を重ねて色の帯を作るレインボーグラデーション 9パターン — キャンバスで作るプラズマ背景を並べると、計算で色を決める方法の幅が見えてきます。

技法別コードで見る色の混ぜ方

以下の断片はデモのソースから連続した行を順番どおりに切り出したもので、完結したファイルは暗号化zipに入っています。色の一覧を輪にして読む pick(pal, pos, out) は、pos の整数部で色を選び、小数部の分だけ次の色へ線形に補間する小さな道具で、01と02の色の巡りはこの関数から出ています(08は色相の角度を回すので使いません)。

01四隅の色を補間して混ぜる基本のメッシュ

CORNER_RATE(毎秒2)に合わせて四隅の色が、ピンク・ライラック・空色・ミントの一覧を時計回りに一つ隣の色へ移り、全ピクセルは lerp を横に二回、縦に一回呼んで四色の加重平均を受け取ります。中央は四色が同じ割合で混ざった値なので隅より濁りやすく、この双線形の枠が残りの場面の骨組みになります。この場面は累積面積60.9%・平均強度15.9・23/23フレームです。

四隅の色を補間する基本のメッシュ
新しいタブで開く

01 · 四隅の色を補間する基本のメッシュ — 計測 累積面積 60.93% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CORNER_RATECORNER_PALCALC_WCALC_H
      var CORNER_RATE = 2;
      var CORNER_PAL = [PAL[0], PAL[1], PAL[2], PAL[3]];
      var corners = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]];
      function paint(t, d) {
          // 左上 → 右上 → 右下 → 左下の順に、色の一覧の隣の升を一つずつ受け取る
          pick(CORNER_PAL, t * CORNER_RATE + 0, corners[0]);
          pick(CORNER_PAL, t * CORNER_RATE + 1, corners[1]);
          pick(CORNER_PAL, t * CORNER_RATE + 2, corners[3]);
          pick(CORNER_PAL, t * CORNER_RATE + 3, corners[2]);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var r = lerp(lerp(corners[0][0], corners[1][0], u), lerp(corners[2][0], corners[3][0], u), v);
                  var g = lerp(lerp(corners[0][1], corners[1][1], u), lerp(corners[2][1], corners[3][1], u), v);
                  var b = lerp(lerp(corners[0][2], corners[1][2], u), lerp(corners[2][2], corners[3][2], u), v);
                  put(d, x, y, r, g, b);
              }
          }

023×3の制御点がただようなめらかなメッシュ

制御点九つが NODE_RATE(毎秒1.2)で色の一覧を読んで色を変え、SWAY(0.12)だけ元の位置の近くで揺れます。マスの中の重みは smooth を通るのでマスの境目で傾きが0になって継ぎ目の折れが減り、制御点が動いた分だけ読む位置を逆に押して画面がついてたわみます。この場面は累積面積69.1%・平均強度16.5・23/23フレームです。

3×3の制御点が漂うやわらかいメッシュ
新しいタブで開く

02 · 3×3の制御点が漂うやわらかいメッシュ — 計測 累積面積 69.099% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWAYNODE_RATECALC_WCALC_H
      function meshAt(u, v, out) {
          var gx = clamp(u, 0, 1) * 2, gy = clamp(v, 0, 1) * 2;
          var ix = Math.min(1, Math.floor(gx)), iy = Math.min(1, Math.floor(gy));
          var fx = smooth(gx - ix), fy = smooth(gy - iy);
          var a = nodeCol[iy * 3 + ix], b = nodeCol[iy * 3 + ix + 1];
          var c = nodeCol[(iy + 1) * 3 + ix], e = nodeCol[(iy + 1) * 3 + ix + 1];
          out[0] = lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy);
          out[1] = lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy);
          out[2] = lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy);
      }
      function paint(t, d) {
          for (var n = 0; n < 9; n += 1) {
              pick(PAL, n * 0.7 + t * NODE_RATE, nodeCol[n]);
              nodeDx[n] = SWAY * Math.sin(6.2832 * (t * 1.2 + n * 0.37));
              nodeDy[n] = SWAY * Math.cos(6.2832 * (t * 1.05 + n * 0.51));
          }
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              var gy = v * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var gx = u * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
                  var k0 = iy * 3 + ix, k1 = k0 + 1, k2 = k0 + 3, k3 = k0 + 4;
                  // 制御点が動いた分だけ読む位置を逆に押す — 同じなめらかな重みでずれを混ぜる
                  var du = lerp(lerp(nodeDx[k0], nodeDx[k1], fx), lerp(nodeDx[k2], nodeDx[k3], fx), fy);
                  var dv = lerp(lerp(nodeDy[k0], nodeDy[k1], fx), lerp(nodeDy[k2], nodeDy[k3], fx), fy);
                  meshAt(u - du, v - dv, meshTmp);
                  put(d, x, y, meshTmp[0], meshTmp[1], meshTmp[2]);
              }
          }
      }

03距離の逆数で色を混ぜるにじみのメッシュ

六つの色の点がリサージュ軌道で泳ぎ、各ピクセルは点までの距離の POWER 乗に反比例する重み 1 / (d^POWER + EPS) で六色を加重平均します。格子なしで点だけ置けば済む代わりに、ピクセルごとに点六つの距離と Math.pow を呼ぶので、九つの中で計算量がいちばん多い場面です。この場面は累積面積70.2%・平均強度25.5・23/23フレームです。

距離の逆数で色を混ぜるにじみメッシュ
新しいタブで開く

03 · 距離の逆数で色を混ぜるにじみメッシュ — 計測 累積面積 70.224% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

POWEREPSASPECTCALC_W
      var POWER = 2;
      var EPS = 0.002;
      var ASPECT = CALC_W / CALC_H;
      var SEED_FX = [0.31, 0.43, 0.37, 0.5, 0.27, 0.41];
      var SEED_FY = [0.39, 0.29, 0.47, 0.33, 0.45, 0.35];
      var SEED_PH = [0.0, 0.17, 0.33, 0.5, 0.67, 0.83];
      var dotX = [0, 0, 0, 0, 0, 0];
      var dotY = [0, 0, 0, 0, 0, 0];
      function paint(t, d) {
          for (var k = 0; k < 6; k += 1) {
              dotX[k] = (0.5 + 0.4 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
              dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.7));
          }
          var halfP = POWER / 2;
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 6; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + EPS);
                      wr += w * PAL[j][0];
                      wg += w * PAL[j][1];
                      wb += w * PAL[j][2];
                      ws += w;
                  }
                  put(d, x, y, wr / ws, wg / ws, wb / ws);
              }
          }
      }

04ノイズで座標をゆがめて流れるメッシュ

色を読む前に座標 u・v を値ノイズの二層(大きなうねりと細かいさざ波)で WARP(0.3)だけ押し、ずらした位置で固定の3×3格子の色を読みます。色そのものは同じなのに縁が煙のようにたなびくので、止まったグラデーションを生きた背景に変えたいときに使います。この場面は累積面積65.6%・平均強度18.1・23/23フレームです。

ノイズで座標をゆがめて流れるメッシュ
新しいタブで開く

04 · ノイズで座標をゆがめて流れるメッシュ — 計測 累積面積 65.601% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WARPLATTICECALC_WCALC_H
      function lat(ix, iy) {
          return lattice[(((iy % LATTICE) + LATTICE) % LATTICE) * LATTICE + (((ix % LATTICE) + LATTICE) % LATTICE)];
      }
      function vnoise(x, y) {
          var ix = Math.floor(x), iy = Math.floor(y);
          var fx = smooth(x - ix), fy = smooth(y - iy);
          return lerp(lerp(lat(ix, iy), lat(ix + 1, iy), fx), lerp(lat(ix, iy + 1), lat(ix + 1, iy + 1), fx), fy);
      }
      var warpTmp = [0, 0, 0];
      function paint(t, d) {
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  // 大きなうねり + 細かいさざ波の二層
                  var wu = (vnoise(u * 2.2 + t * 0.7, v * 2.2 - t * 0.4) - 0.5) * 2 + (vnoise(u * 5 - t * 0.9 + 7.7, v * 5 + t * 0.6) - 0.5) * 0.7;
                  var wv = (vnoise(u * 2.2 - t * 0.5 + 3.1, v * 2.2 + t * 0.8 + 5.3) - 0.5) * 2 + (vnoise(u * 5 + t * 0.8 + 1.9, v * 5 - t * 0.7 + 9.1) - 0.5) * 0.7;
                  meshRead(u + WARP * wu, v + WARP * wv, warpTmp);
                  put(d, x, y, warpTmp[0], warpTmp[1], warpTmp[2]);
              }
          }
      }

05そのまま混ぜる方法と明るさを直して混ぜる方法を並べる

同じ紫とオレンジを同じ割合 s で混ぜますが、左半分は値のまま lerp し、右半分は GAMMA(2.2)乗で明るさを線形に直して混ぜてから戻します。0.9秒の時点で縦全体を平均すると、帯の中央の色は左が191,122,155(明るさ146.4)、右が201,127,179(明るさ155.1)で、右のほうが少し明るく青みが残ります。この場面は累積面積64.5%・平均強度19.2・23/23フレームです。

素の混合と明るさ補正の混合を並べる
新しいタブで開く

05 · 素の混合と明るさ補正の混合を並べる — 計測 累積面積 64.516% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GAMMACOLOR_ACOLOR_BHALF
      var COLOR_A = [130, 90, 245];
      var COLOR_B = [255, 156, 60];
      var GAMMA = 2.2;
      var linA = [Math.pow(COLOR_A[0] / 255, GAMMA), Math.pow(COLOR_A[1] / 255, GAMMA), Math.pow(COLOR_A[2] / 255, GAMMA)];
      var linB = [Math.pow(COLOR_B[0] / 255, GAMMA), Math.pow(COLOR_B[1] / 255, GAMMA), Math.pow(COLOR_B[2] / 255, GAMMA)];
      var HALF = CALC_W / 2;
      function paint(t, d) {
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var right = x >= HALF;
                  var lx = (right ? x - HALF : x) / (HALF - 1);
                  // 二色の間の割合 s — 両側の半分が同じ値 s を使う
                  var s = smooth(lx + 0.22 * Math.sin(6.2832 * (v * 1.1 + t * 0.5)) + 0.1 * Math.sin(6.2832 * (v * 2.3 - t * 0.35)));
                  var r, g, b;
                  if (!right) {
                      r = lerp(COLOR_A[0], COLOR_B[0], s);
                      g = lerp(COLOR_A[1], COLOR_B[1], s);
                      b = lerp(COLOR_A[2], COLOR_B[2], s);
                  }
                  else {
                      r = 255 * Math.pow(lerp(linA[0], linB[0], s), 1 / GAMMA);
                      g = 255 * Math.pow(lerp(linA[1], linB[1], s), 1 / GAMMA);
                      b = 255 * Math.pow(lerp(linA[2], linB[2], s), 1 / GAMMA);
                  }
                  if (x === Math.floor(HALF) || x === Math.floor(HALF) - 1) {
                      r = 255;
                      g = 255;
                      b = 255;
                  }
                  put(d, x, y, r, g, b);
              }
          }
      }

06しま模様を消す粒子まぜ

暗い放射状のグラデーションを STEP(10)刻みで丸めて、わざと帯を作ります。左半分は丸めの境目を0.5に固定し、右半分は種2026の乱数表で境目を GRAIN の分だけ揺らして帯の縁を散らします。この場面は累積面積62.0%・平均強度16.0・23/23フレームです。

縞を消す粒子の混ぜ込み
新しいタブで開く

06 · 縞を消す粒子の混ぜ込み — 計測 累積面積 61.95% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEPGRAINNOISE_NDARKLIGHT
      var STEP = 10;
      var GRAIN = 1;
      var DARK = [30, 34, 66];
      var LIGHT = [112, 100, 170];
      var NOISE_N = CALC_W * CALC_H;
      var noiseTab = [];
      var rnd = seedRand(2026);
      for (var ni = 0; ni < NOISE_N; ni += 1) {
          noiseTab.push(rnd());
      }
      function paint(t, d) {
          var cx = 0.5 + 0.28 * Math.sin(6.2832 * t * 0.4);
          var cy = 0.5 + 0.2 * Math.cos(6.2832 * t * 0.31);
          var frame = Math.floor(t * 30);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = x / (CALC_W - 1);
                  var dx = (u - cx) * 1.6, dy = v - cy;
                  var k = 1 - clamp(Math.sqrt(dx * dx + dy * dy) * 1.05, 0, 1);
                  var right = x >= CALC_W / 2;
                  var n = noiseTab[(y * CALC_W + x + frame * 997) % NOISE_N];
                  // 丸めの境目をピクセルごとに0.5 ± GRAIN/2段の中で揺らす(左は境目を0.5に固定)
                  var th = right ? 0.5 + (n - 0.5) * GRAIN : 0.5;
                  var ch = [0, 0, 0];
                  for (var c = 0; c < 3; c += 1) {
                      var val = lerp(DARK[c], LIGHT[c], k);
                      ch[c] = Math.floor(val / STEP + th) * STEP;
                  }
                  if (x === Math.floor(CALC_W / 2) || x === Math.floor(CALC_W / 2) - 1) {
                      ch = [236, 232, 255];
                  }
                  put(d, x, y, ch[0], ch[1], ch[2]);
              }
          }
      }

07明るさを高さとして読み、光が回るメッシュ

点四つの逆二乗加重メッシュの明るさを二乗して HEIGHT 分の高さとして読み、隣のピクセルとの高さの差から傾きと法線を求めて、2.86秒で一周する光源の拡散光と白い照り(SHINE 24)を加えます。ボタンやカードの表面に光沢をのせるとき、どの値がきらめきを作るのかを一画面で確かめられます。この場面は累積面積71.2%・平均強度28.9・23/23フレームです。

明るさを高さとして読み光が巡るメッシュ
新しいタブで開く

07 · 明るさを高さとして読み光が巡るメッシュ — 計測 累積面積 71.171% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HEIGHTSLOPE_GAINSHINESHEEN
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 4; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (dx * dx + dy * dy + 0.004);
                      wr += w * TONE[j][0];
                      wg += w * TONE[j][1];
                      wb += w * TONE[j][2];
                      ws += w;
                  }
                  var i = y * CALC_W + x;
                  colR[i] = wr / ws;
                  colG[i] = wg / ws;
                  colB[i] = wb / ws;
                  // 明るさ(0〜1)で近い点の加重の割合をもう一度二乗して高さに使う
                  var lum = (0.299 * colR[i] + 0.587 * colG[i] + 0.114 * colB[i]) / 255;
                  hgt[i] = lum * lum * HEIGHT * CALC_H;
              }
          }
          var ang = 6.2832 * t * 0.35;
          var lx = Math.cos(ang) * 0.7, ly = Math.sin(ang) * 0.7, lz = 0.6;
          var ln = Math.sqrt(lx * lx + ly * ly + lz * lz);
          lx /= ln;
          ly /= ln;
          lz /= ln;
          // 反射ベクトルの代わりにハーフベクトル(光源 + 視線)で照りを測る
          var hx = lx, hy = ly, hz = lz + 1;
          var hn = Math.sqrt(hx * hx + hy * hy + hz * hz);
          hx /= hn;
          hy /= hn;
          hz /= hn;
          for (var y2 = 0; y2 < CALC_H; y2 += 1) {
              var ya = Math.max(0, y2 - 1), yb = Math.min(CALC_H - 1, y2 + 1);
              for (var x2 = 0; x2 < CALC_W; x2 += 1) {
                  var xa = Math.max(0, x2 - 1), xb = Math.min(CALC_W - 1, x2 + 1);
                  var gx = (hgt[y2 * CALC_W + xa] - hgt[y2 * CALC_W + xb]) * SLOPE_GAIN * 10;
                  var gy = (hgt[ya * CALC_W + x2] - hgt[yb * CALC_W + x2]) * SLOPE_GAIN * 10;
                  var nn = Math.sqrt(gx * gx + gy * gy + 1);
                  var nx = gx / nn, ny = gy / nn, nz = 1 / nn;
                  var diff = clamp(nx * lx + ny * ly + nz * lz, 0, 1);
                  var spec = Math.pow(clamp(nx * hx + ny * hy + nz * hz, 0, 1), SHINE) * SHEEN;
                  var j2 = y2 * CALC_W + x2;
                  var shade = 0.72 + 0.4 * diff;
                  put(d, x2, y2, clamp(colR[j2] * shade + 255 * spec, 0, 255), clamp(colG[j2] * shade + 255 * spec, 0, 255), clamp(colB[j2] * shade + 255 * spec, 0, 255));
              }
          }

08色相の角度を回して雰囲気が変わるメッシュ

3×3の制御点九つの色相の角度が HUE_SPEED に従って毎秒66〜174度ずつ、三つは逆向きに回り、明るさは DAY_PERIOD(8秒)周期で0.74〜0.84の間を上下します。ソースには夜明けや夕焼けのような時間帯の名前はなく、明るさの曲線が一本あるだけです。この場面は累積面積62.6%・平均強度15.6・23/23フレームです。

色相の角度を回して雰囲気が移るメッシュ
新しいタブで開く

08 · 色相の角度を回して雰囲気が移るメッシュ — 計測 累積面積 62.622% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SATURATIONDAY_PERIODBASE_HUEHUE_SPEED
      function paint(t, d) {
          var phase = 0.5 - 0.5 * Math.cos(6.2832 * t / DAY_PERIOD);
          var light = 0.84 - 0.1 * phase;
          for (var n = 0; n < 9; n += 1) {
              hslToRgb(BASE_HUE[n] + HUE_SPEED[n] * t, SATURATION, light, hueCol[n]);
          }
          for (var y = 0; y < CALC_H; y += 1) {
              var gy = (y / (CALC_H - 1)) * 2, iy = Math.min(1, Math.floor(gy)), fy = smooth(gy - iy);
              for (var x = 0; x < CALC_W; x += 1) {
                  var gx = (x / (CALC_W - 1)) * 2, ix = Math.min(1, Math.floor(gx)), fx = smooth(gx - ix);
                  var a = hueCol[iy * 3 + ix], b = hueCol[iy * 3 + ix + 1], c = hueCol[(iy + 1) * 3 + ix], e = hueCol[(iy + 1) * 3 + ix + 1];
                  put(d, x, y, lerp(lerp(a[0], b[0], fx), lerp(c[0], e[0], fx), fy), lerp(lerp(a[1], b[1], fx), lerp(c[1], e[1], fx), fy), lerp(lerp(a[2], b[2], fx), lerp(c[2], e[2], fx), fy));
              }
          }
      }

09見出しの後ろに敷くメッシュのヒーロー

色の点五つの逆距離メッシュに、縁の暗がり(VIGNETTE 0.22)と種99の粒子(GRAIN_AMOUNT 5)を重ねて見出しの背面に敷き、見出しはキャンバスの上の文字として別に載せます。60秒を0.1秒間隔でたどった見出しの位置の背景と文字色(#17141a)の最悪のコントラスト比は7.55:1で、最悪でも7.55:1あり、4.5:1を下回りませんでした。この場面は累積面積67.2%・平均強度23.1・23/23フレームです。

見出しの後ろに敷くメッシュのヒーロー
新しいタブで開く

09 · 見出しの後ろに敷くメッシュのヒーロー — 計測 累積面積 67.238% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

VIGNETTEGRAIN_AMOUNTPOWERNOISE_N
      function paint(t, d) {
          for (var k = 0; k < 5; k += 1) {
              dotX[k] = (0.5 + 0.42 * Math.sin(6.2832 * (t * SEED_FX[k] + SEED_PH[k]))) * ASPECT;
              dotY[k] = 0.5 + 0.4 * Math.cos(6.2832 * (t * SEED_FY[k] + SEED_PH[k] * 1.6));
          }
          var halfP = POWER / 2;
          var frame = Math.floor(t * 30);
          for (var y = 0; y < CALC_H; y += 1) {
              var v = y / (CALC_H - 1);
              for (var x = 0; x < CALC_W; x += 1) {
                  var u = (x / (CALC_W - 1)) * ASPECT;
                  var wr = 0, wg = 0, wb = 0, ws = 0;
                  for (var j = 0; j < 5; j += 1) {
                      var dx = u - dotX[j], dy = v - dotY[j];
                      var w = 1 / (Math.pow(dx * dx + dy * dy, halfP) + 0.003);
                      wr += w * HERO_PAL[j][0];
                      wg += w * HERO_PAL[j][1];
                      wb += w * HERO_PAL[j][2];
                      ws += w;
                  }
                  // 縁の暗がり — 中心から遠いほど掛ける値が1から1 - VIGNETTEへ下がる
                  var ex = (x / (CALC_W - 1) - 0.5) * 2, ey = (y / (CALC_H - 1) - 0.5) * 2;
                  var vig = 1 - VIGNETTE * smooth((Math.sqrt(ex * ex * 0.6 + ey * ey) - 0.55) / 0.9);
                  var grain = (noiseTab[(y * CALC_W + x + frame * 991) % NOISE_N] - 0.5) * GRAIN_AMOUNT;
                  put(d, x, y, clamp((wr / ws) * vig + grain, 0, 255), clamp((wg / ws) * vig + grain, 0, 255), clamp((wb / ws) * vig + grain, 0, 255));
              }
          }
      }

値が場面を変えるところ — 制御点の数・にじみの指数・粒子の強さ

場面の印象を変える値は三つあります。制御点がどれだけ揺れるか(02の SWAY)、色の点の周りがどれだけにじむか(03の POWER)、帯を散らす粒子がどれだけ強いか(06の GRAIN)です。同じ瞬間の二つの画面をNodeで paint に定数だけ変えて比べると、SWAY を0.24に上げればピクセルがチャンネルあたり平均5.9〜6.6、0.30なら8.9〜9.8変わり、POWER が1なら画面の色のばらつきは16〜19に減って4なら34〜38に増えます。最後のばらつきは255段階の値の標準偏差です。

色が巡る速さの定数(CORNER_RATE の2・NODE_RATE の1.2・HUE_SPEED の66〜174度)は、わざと速く置いてあります。落ち着いた背景に使うなら、この値から下げてください。

場面ごとの測定値は次の表にまとめました。01・06・08の平均強度は15.9・16.0・15.6で基準の15にいちばん近く、九つの中でフレーム間の差が小さい側の場面です。九つとも23フレームすべてで動きました。

項目 累積面積 平均強度 動いたフレーム
01 四隅の双線形 60.9% 15.9 23/23
02 3×3の制御点 69.1% 16.5 23/23
03 逆距離のにじみ 70.2% 25.5 23/23
04 ノイズのゆがみ 65.6% 18.1 23/23
05 ガンマ混ぜの比較 64.5% 19.2 23/23
06 粒子まぜ 62.0% 16.0 23/23
07 高さと光沢 71.2% 28.9 23/23
08 色相の角度の巡り 62.6% 15.6 23/23
09 見出し裏のヒーロー 67.2% 23.1 23/23

どこで壊れるか — 解像度の増加・濁った中央・暗い面の帯

中央が濁る場合

01の中央は四色の平均なので隅より濁ります。05がその程度を数字で見せていて、同じ二色の帯の中央の明るさは、値のまま混ぜると146.4、明るさを線形に直して混ぜると155.1です。差は約9と控えめなので、大きな変化を期待すると肩すかしになります。05の紫とオレンジは濁りが目に入るように選んだ彩度の高い色で、この記事のパステルのパレットとは別物です。08では、反対向きに回る制御点の間が補色の近くを通るときに彩度が抜けます。色相を回しても混ぜるのはRGBの値なので、反対側の色どうしの中間は灰色に寄るためです。

暗い面に帯が出る場合

06の STEP の10は、実際の画面の一段(255のうち1)ではなく、帯が目に見えるようにわざと粗くした値です。粒子の GRAIN を1から1.5に上げると、8×8の箱の平均誤差は0.32から0.46へかえって大きくなります。境目が一段の中だけで揺れる仕組みなので、1を超える粒子はその範囲をはみ出すからです。

読む位置が端の外に出る場合

02の SWAY や04の WARP を大きくすると、読む位置が0〜1の外に出て、clamp が端の色をそのまま引き伸ばします。03は色の点の真上でその点の色がほぼそのまま残り、POWER が大きいほどとがります。07は明るさだけを高さとして読むので、色が違っても明るさが同じなら平らに見えます。

測定が動きを見逃す場合

ヘッドレスの描画でフレーム間の実際の間隔は約130ミリ秒でした。最初に色の巡りを毎秒0.6にしたところ、累積面積は58%出たのに動いたフレームが0/23でした。巡りを smooth で包んだときも色の升の端で止まってフレームが空いたため、pick を線形補間に変えて速さを上げました。

解像度を上げると計算が増える

CALC_W・CALC_H を320×200に広げるとマス数は4倍です。03はピクセルごとに Math.pow を六回呼ぶので、負担がいちばん大きくふくらみます。定数を自分で書き換えて解像度を比べたいときは、パスワード w432zjum でzipを開いてください。

変形3つとアクセシビリティ

名前 値 感じ
制御点の揺れ幅を広げる 02 SWAY 0.12 → 0.30 揺れ幅を0.12から0.30に広げると、同じ瞬間の画面と比べてピクセル値がチャンネルあたり平均8.9〜9.8(255中)、最大で56〜75変わり、色の境が大きくうねってマス目の継ぎ目付近の曲がりが目に入る。
にじみの指数を変える 03 POWER 2 → 4 指数を2から4に上げると画面全体の色のばらつきが約28から約35に増えて色の点の周りがくっきりした円に近づき、1に下げると約17に減って画面が一つの色調にぼやける。
粒子の強さを調整する 06 GRAIN 1 → 0 粒子の強さを1から0に下げると、右半分で同じ色が横に続く長さが平均1.6pxから5.6pxに伸び、粒子のない左半分の6.6pxに近い帯がまた現れる。

prefers-reduced-motionが有効なとき、九つの場面はどれも requestAnimationFrame のループを回さず、場面ごとに決めた時刻(REDUCE_MS、700〜1200ミリ秒)の一場面をすぐに描きます。このときボタンは「次の場面」になり、押すたびに REDUCE_STEP_MS の900ミリ秒だけ時刻を進めて描き直し、状態の行(aria-live="polite")が一場面ずつ進めている状態を知らせます。動きが有効な状態では、ボタンかキャンバスを押すか、キャンバスでEnterまたはSpaceを押すと一時停止と再生が切り替わり、状態の行が「実演中」と「一時停止中」を知らせます。canvasに tabindex の0を付け、停止ボタンを button 要素にしてあるので、マウスなしでキーボードだけでも止めて再開できます。

メッシュグラデーションは CSS だけで作れないのか?

近いものをまねることはできます。放射状のグラデーションを何枚か重ねて位置をずらす方法が代表的ですが、この記事の計算はマスごとに重みを決め、色を読む座標をゆがめ、明るさを補正する作業なので、その道具では一段足りません。メッシュグラデーション cssだけで足りる場面はCSS 背景 アニメーション 9選 — 一行でコピペOKにまとめてあり、重なった層が奥行きを作る方法はcss パララックス 背景 9選 — 奥行き3Dシーンで見られます。

使っている描画関数はCanvas APIのドキュメントとputImageDataのドキュメントで読めます。

メッシュグラデーションを使う前によくある質問

メッシュグラデーションの背景を自分のトップページに貼るには、まず何を変えますか?

09を開いて、HERO_PAL の色の点五つと VIGNETTE を変えてください。見出しの位置の下を暗い色が通るとコントラスト比が4.5:1を下回ることがあるので、変えたあとは60秒ほど回して文字が読めるか確かめます。

外部のライブラリや画像は必要ですか?

必要ありません。九つの場面はキャンバス2dと一つのファイルのスクリプトだけで動き、外部アドレスを呼ぶ行もないので、ファイル一つを移すだけで場面がそのまま再生されます。ジェネレーターのようにスライダーで色を決める道具とは違い、調整はファイルの先頭の定数を書き換えて行います。

場面がカクついたり中央が濁って見えたりしたら、どこを直しますか?

カクつくときは CALC_W・CALC_H を減らしてマス数を下げ、03のように重い場面は点の数から減らします。濁って見えるときは、05のように明るさを線形に直して混ぜるか、反対向きに回る色を減らします。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。