本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カーソル

カーソルの軌跡が絵の具のようににじんで広がる

デモ

操作:カーソルを動かす。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
カーソル
動作
DOM+WebGL
種類
シェーダー
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

全面の板に 1 枚のフラグメントシェーダーをかけて、紙と絵の具を描きます。流体の計算はせず、いまから少し前までのポインタの位置を 48 点、uniform の配列で渡します。隣り合う点を結ぶ線分への距離でガウスの場(メタボールのような場)を作り、ある濃さを越えた所に絵の具をのせます。線分ごとのガウスをそのまま足すと、つなぎ目で場が 2 倍になり、速く動いたときに縁が点の間隔で波打ちます。そこで線分ごとの値は足さずに最大を取り、各点の強さを線分に沿って均した線積分(誤差関数で解けます)の和と大きいほうを使うので、速く動いても一定の太さの線になり、ゆっくり動くと絵の具が溜まります。線分は前後の点で向きを決めた曲線(Catmull-Rom)に曲げて距離を測るので、速く動いて点の間隔が広がっても折れ線に見えません。古い点ほど太く弱くするので、軌跡がにじんで広がりながら乾いていくように見えます。

点の間隔は 0.055 秒で、約 2.6 秒前までの軌跡が残ります。太さは、頭の画面の高さの 1.8% から、にじみきると 8.8% まで広げます。場をノイズで揺らしてからしきい値で切るので、縁が紙の繊維に沿ってギザギザににじみます。境目のすぐ内側を濃くした水彩の縁の溜まり、外側の薄いにじみ、中のむらを足し、色は紙の色に掛け合わせて、重ねるほど暗くします。絵の具の色は、赤茶 → 砂色 → 緑 → 青みの墨 → 紫と 8 秒でめぐり、点ごとに置いた時刻の色なので、軌跡にグラデーションが残ります。

ポインタの位置は、何フレーム前かではなく時刻と一緒に記録し、0.055 秒ずつ前の位置を前後の記録から補間して取り出します。止まっている間は同じ位置が並ぶので、絵の具が溜まって丸く広がります。ヒーローの外へ出た所と入り直した所を線でつなぐと、太い帯が横切ってしまいます。出たら pointerleave で「筆を上げた」印を記録し、その区間の線分はシェーダーで描きません。軌跡を囲む矩形の外はガウスを計算しないので、全面のシェーダーでも軽く済みます。

調整できるのは、点の数と間隔(軌跡の長さ)、頭とにじみきったときの太さ、しきい値、縁のにじみと溜まりの強さ、絵の具の色の並びです。色は CSS 変数に書いて、シェーダーへ渡しています。本物のカーソルはヒーローの中だけ cursor: none で隠し、今の絵の具の色を入れた小さな丸を代わりに出します。指で触る端末と動きを減らす設定では、紙だけを描いて本物のカーソルのままにします。クリエイティブスタジオ・画材や文具・子ども向けのサイトのヒーローに向いています。

コード

HTML
<section class="ink-hero" aria-labelledby="ink-hero-title">
  <!-- 紙と絵の具は飾りなので、読み上げからは外す -->
  <canvas class="ink-hero__canvas" aria-hidden="true"></canvas>
  <div class="ink-hero__inner">
    <p class="ink-hero__eyebrow">Design studio</p>
    <h1 class="ink-hero__title" id="ink-hero-title">色で伝える、<br>ブランドづくり。</h1>
    <p class="ink-hero__lead">ここに説明文が入ります。</p>
    <div class="ink-hero__actions">
      <a class="ink-hero__button" href="/works/">実績を見る <span aria-hidden="true">→</span></a>
      <a class="ink-hero__link" href="/contact/">気軽に相談する</a>
    </div>
  </div>
  <p class="ink-hero__hint" aria-hidden="true">カーソルを動かしてみてください</p>
  <!-- 独自カーソルの丸。中は今の絵の具の色になる -->
  <span class="ink-hero__cursor" aria-hidden="true"></span>
</section>

CSS
.ink-hero {
  /* 紙と絵の具の色。script.js がここを読んでシェーダーへ渡すので、#rrggbb で書く */
  --paper: #f2f1ed;
  --pigments: #bd4436, #ae7532, #429b46, #2f6181, #6148a9; /* この順にめぐる */
  --ink: #262626;
  --on-ink: #ffffff;
  --mute-small: #6b6b6b;
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  display: grid;
  align-items: center;
  min-height: min(90vh, 720px);
  padding: 96px var(--gutter);
  box-sizing: border-box;
  overflow: hidden;
  color: var(--ink);
  /* 読み込み中と、WebGL が使えないときの地 */
  background-color: var(--paper);
}

.ink-hero__canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }

.ink-hero__inner { position: relative; } /* 紙の上に載せる */

.ink-hero__eyebrow {
  margin: 0;
  color: var(--mute-small);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.ink-hero__title {
  margin: 24px 0 0;
  font-size: clamp(40px, 8vw, 72px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  word-break: keep-all; /* 和文の見出しを語の途中で折らない */
}

.ink-hero__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

.ink-hero__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 24px 32px;
  margin-top: 48px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
}

.ink-hero__button {
  display: inline-flex;
  align-items: center;
  gap: 8px; /* inline-flex では文言と矢印の間の空白が消えるので、間は gap で取る */
  height: 48px;
  padding: 0 24px;
  border-radius: 999px;
  background-color: var(--ink);
  color: var(--on-ink);
  text-decoration: none;
}

.ink-hero__link { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.4em; }
.ink-hero a:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.ink-hero__hint {
  position: absolute;
  left: var(--gutter);
  bottom: 40px;
  margin: 0;
  color: var(--mute-small);
  font-size: 13px;
  letter-spacing: 0.04em;
}

/* 丸の中心をポインタに合わせる。位置は script.js が transform で入れる */
.ink-hero__cursor {
  position: absolute;
  left: -8px;
  top: -8px;
  width: 16px;
  height: 16px;
  box-sizing: border-box;
  border: 2px solid var(--ink);
  border-radius: 50%;
  pointer-events: none; /* 下のリンクを押せるようにする */
  visibility: hidden; /* ヒーローの中にポインタがあるときだけ出す */
}

/* 絵の具を描くときだけ、本物のカーソルを隠す。指で触る端末と、動きを減らす設定では描かない */
.ink-hero.is-painting,
.ink-hero.is-painting a { cursor: none; }
.ink-hero:not(.is-painting) .ink-hero__hint { display: none; }

JavaScript
import * as THREE from "three";

const N = 48, STEP = 0.055, DURATION = 8; // 軌跡の点の数と間隔(秒。N × STEP 秒前までが残る)、色がひとめぐりする秒数(にじみの模様も同じ周期)
const R0 = 0.018, R1 = 0.088; // 軌跡の頭の太さと、にじみきったときの太さ(ヒーローの高さに対する比)
const THRESHOLD = 0.45, BLEED = 0.85, EDGE = 0.55, TUBE = 1.5; // 絵の具がのる濃さ・縁のにじみ・縁の内側の溜まり・速く動いたときの線の濃さ(太さ)
const f = (v) => v.toFixed(4), vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }"; // f は GLSL の float 定数
const fragmentShader = /* glsl */ `
  uniform vec4 uTrail[${N}]; // xy: 位置(高さを 1 とした座標、左下原点)、z: 経過(0 = いま、1 = 消える)、w: 0 なら筆を上げている
  uniform vec3 uColor[${N}]; uniform vec4 uBox; uniform vec3 uPaper; uniform float uAspect; uniform float uPhase; varying vec2 vUv;
  vec2 hash2(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return -1.0 + 2.0 * fract(sin(p) * 43758.5453123); }
  float noise(vec2 p) { vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f); // グラディエントノイズ(-1..1)
    return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1, 0)), f - vec2(1, 0)), u.x), mix(dot(hash2(i + vec2(0, 1)), f - vec2(0, 1)), dot(hash2(i + 1.0), f - 1.0), u.x), u.y); }
  float erf(float x) { float x2 = x * x; return sign(x) * sqrt(1.0 - exp(-x2 * (1.2732395 + 0.147 * x2) / (1.0 + 0.147 * x2))); } // 誤差関数の近似
  void main() {
    vec2 p = vUv * vec2(uAspect, 1.0); float cap = 0.0, density = 0.0, weight = 0.0, ageSum = 0.0; vec3 tint = vec3(0.0);
    float fiber = noise(p * 220.0) * 0.6 + noise(p * vec2(70.0, 160.0)) * 0.4; // 紙の繊維。むらと四隅の陰も足す
    vec3 paper = uPaper * (0.985 + 0.02 * noise(p * 2.5) + 0.025 * fiber) * (1.0 - 0.1 * dot(vUv - 0.5, vUv - 0.5)), color = paper;
    if (all(greaterThan(p, uBox.xy)) && all(lessThan(p, uBox.zw))) for (int k = 0; k < ${N - 1}; k++) {
      vec4 a = uTrail[k], b = uTrail[k + 1], a0 = uTrail[max(k - 1, 0)], b1 = uTrail[min(k + 2, ${N - 1})]; if (a.w * b.w < 0.5) continue; // 筆を上げていた区間は描かない
      vec2 pa = p - a.xy, ba = b.xy - a.xy; float len = max(length(ba), 1e-6), x = dot(pa, ba) / len, h = clamp(x / len, 0.0, 1.0); // 線分に沿った位置
      vec2 ta = (b.xy - mix(a.xy, a0.xy, a0.w)) * 0.5 - ba, tb = (mix(b.xy, b1.xy, b1.w) - a.xy) * 0.5 - ba; // 前後の点で決めた両端の向き(筆を上げた点は使わない)
      if (length(pa - ba * h) > 0.1482 * (length(ta) + length(tb)) + 3.5 * mix(${f(R0)}, ${f(R1)}, sqrt(b.z))) continue; // 遠い線分は効かないので飛ばす(曲線は弦から少ししか離れない)
      vec2 q = pa - ba * h - h * (1.0 - h) * ((1.0 - h) * ta - h * tb); // 線分を Catmull-Rom 曲線に曲げる。つなぎ目で向きがそろい、速く動いても折れない
      for (int i = 0; i < 2; i++) { vec2 t = ba + (1.0 - h) * (1.0 - 3.0 * h) * ta - h * (2.0 - 3.0 * h) * tb; h = clamp(h + dot(q, t) / max(dot(t, t), 1e-8), 0.0, 1.0); q = pa - ba * h - h * (1.0 - h) * ((1.0 - h) * ta - h * tb); } // 接線の向きに寄せて、いちばん近い曲線の点を探す
      float y = (q.x * ba.y - q.y * ba.x) / len, age = mix(a.z, b.z, h), r = mix(${f(R0)}, ${f(R1)}, sqrt(age)), w = pow(1.0 - age, 1.3); // 古いほど太く(にじむ)、弱く(乾く)
      float g = w * exp(-dot(q, q) / (r * r)); cap = max(cap, ${f(TUBE)} * g); weight += g; tint += mix(uColor[k], uColor[k + 1], h) * g; ageSum += age * g;
      density += len > 0.05 * r ? w * exp(-y * y / (r * r)) * 0.8862269 * r * (erf(x / r) - erf((x - len) / r)) / len : w * exp(-dot(pa - ba * 0.5, pa - ba * 0.5) / (r * r)); // 強さを線分に沿って均した線積分
    }
    float field = max(cap, density); if (field > 0.02) { // 管は足さずに最大を取る(足すとつなぎ目で 2 倍になり、縁が点の間隔で波打つ)。ゆっくりだと均した和が効いて溜まる
      float age = ageSum / weight, fade = pow(1.0 - age, 0.6); vec2 drift = vec2(cos(uPhase), sin(uPhase)) * 0.25; // 場をノイズで揺らしてからしきい値で切ると、縁がにじむ
      float wet = field * (1.0 + ${f(BLEED)} * (noise(p * 5.0 + drift) * 0.5 + noise(p * 13.0 - drift) * 0.3 + noise(p * 34.0) * 0.2) * (0.25 + 0.75 * age));
      float cover = smoothstep(${f(THRESHOLD - 0.025)}, ${f(THRESHOLD + 0.025)}, wet), rim = 1.0 - smoothstep(${f(THRESHOLD)}, ${f(THRESHOLD * 2)}, wet); // 縁の内側ほど濃い
      float halo = smoothstep(${f(THRESHOLD * 0.12)}, ${f(THRESHOLD)}, wet) * (1.0 - cover) * (0.12 + 0.3 * age) * fade; // 縁の外へのにじみ
      float amount = (cover * (0.58 + ${f(EDGE)} * rim) * (0.82 + 0.36 * (noise(p * 9.0 + 3.1) * 0.5 + 0.5)) * fade + halo) * (0.86 + 0.28 * fiber); // 中のむらと粒
      color = paper * mix(vec3(1.0), tint / weight, clamp(amount, 0.0, 1.0)); // 紙の色に掛け合わせる(重ねるほど暗い)
    }
    gl_FragColor = vec4(color, 1.0);
  }
`;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches, canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches; // 指で触る端末では描かない

document.querySelectorAll(".ink-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name);
  // THREE.Color は線形に直すので、hex を 255 で割った値をそのまま渡す
  const rgb = (hex) => new THREE.Vector3(...[1, 3, 5].map((i) => Number.parseInt(hex.trim().slice(i, i + 2), 16) / 255));
  const pigments = css("--pigments").split(",").map(rgb), cursor = hero.querySelector(".ink-hero__cursor");
  const uniforms = { uTrail: { value: Array.from({ length: N }, () => new THREE.Vector4()) }, uColor: { value: Array.from({ length: N }, () => new THREE.Vector3()) },
    uBox: { value: new THREE.Vector4() }, uPaper: { value: rgb(css("--paper")) }, uAspect: { value: 1 }, uPhase: { value: 0 } };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".ink-hero__canvas") }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene(), camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader })); mesh.frustumCulled = false; scene.add(mesh);
  const history = []; // ポインタの記録(新しいものが先頭。ヒーローの幅・高さに対する比)。up は筆を上げた(ヒーローを出た)印
  const at = (time) => { // time 秒の位置。筆を上げていた間と、記録より前は ink: 0。止まっている間は同じ位置が並び、絵の具が溜まる
    const i = history.findIndex((e) => e.time <= time), e = history[i], next = history[i - 1];
    if (!e || e.up) return { x: 0, y: 0, ...(e ?? history.at(-1)), ink: 0 };
    const s = next ? (time - e.time) / (next.time - e.time) : 0;
    return { x: e.x + ((next?.x ?? e.x) - e.x) * s, y: e.y + ((next?.y ?? e.y) - e.y) * s, ink: 1 };
  };
  const pigmentAt = (time, out) => { // 隣の色へなめらかに移る
    const u = ((((time / DURATION) % 1) + 1) % 1) * pigments.length, i = Math.floor(u), s = (u - i) ** 2 * (3 - 2 * (u - i));
    return out.lerpVectors(pigments[i], pigments[(i + 1) % pigments.length], s);
  };
  const box = new THREE.Box2(), point = new THREE.Vector2();
  const draw = (now) => {
    box.makeEmpty(); while (history.length > 2 && history.at(-2).time < now - N * STEP) history.pop(); // 古い記録を捨てる(補間に要る 1 つは残す)
    for (let k = 0; k < N; k++) {
      const p = at(now - k * STEP), x = p.x * uniforms.uAspect.value;
      uniforms.uTrail.value[k].set(x, 1 - p.y, k / (N - 1), p.ink); pigmentAt(now - k * STEP, uniforms.uColor.value[k]);
      if (p.ink) box.expandByPoint(point.set(x, 1 - p.y));
    }
    // 軌跡の外の画素は計算しない(ガウスが十分小さくなる、太さの 3 倍まで)。絵の具が無ければ紙だけ
    if (box.isEmpty()) box.set(point.set(0, 0), point); else box.expandByScalar(R1 * 3);
    uniforms.uBox.value.set(box.min.x, box.min.y, box.max.x, box.max.y);
    uniforms.uPhase.value = ((now / DURATION) % 1) * Math.PI * 2;
    renderer.render(scene, camera);
    cursor.style.backgroundColor = `rgb(${uniforms.uColor.value[0].toArray().map((v) => Math.round(v * 255))})`; // 丸の中は今の絵の具の色
  };
  const painting = canHover && !reduceMotion; hero.classList.toggle("is-painting", painting);
  hero.addEventListener("pointermove", (e) => {
    if (!painting) return;
    const r = hero.getBoundingClientRect(), x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
    history.unshift({ x, y, time: performance.now() / 1000 });
    Object.assign(cursor.style, { transform: `translate(${x * hero.clientWidth}px, ${y * hero.clientHeight}px)`, visibility: "visible" });
  });
  hero.addEventListener("pointerleave", () => { // 出たら筆を上げる。入り直した所から、新しい線が始まる
    if (history[0] && !history[0].up) history.unshift({ ...history[0], time: performance.now() / 1000, up: true }); cursor.style.visibility = "";
  });
  const sizes = new ResizeObserver(() => {
    renderer.setSize(hero.clientWidth, hero.clientHeight, false); uniforms.uAspect.value = hero.clientWidth / hero.clientHeight; // style は書き換えない
    if (!painting) draw(0); // 描かないときは、紙を 1 枚だけ描いて止める
  });
  // 毎フレーム描き直すので、画面の外では回さない
  const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting && painting ? (ms) => draw(ms / 1000) : null));
  sizes.observe(hero); view.observe(hero);
  hero.destroy = () => { // PJAX で捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect();
    mesh.geometry.dispose(); mesh.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。