本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ヒーロー

ヒーローの背景で、フィルムグレインの乗った夕暮れのグラデーションがうねる

デモ

仕様

用途
ヒーロー
動作
WebGL
種類
シェーダー
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

全画面の板に、フラグメントシェーダーを 1 枚かけた背景です。上から淡いグレージュ・くすみローズ・ラベンダーグレー・墨のスレートの 4 色へ移る縦のグラデーションを少し斜めに倒し、色の境目を 2 つのノイズでうねらせます。ノイズを引く座標を円周に沿って回すので、10 秒でひと回りして元の模様に戻り、ループの継ぎ目が出ません。左上には、ゆっくり漂うやわらかい光だまりを足しています。

フィルムグレインは、乱数ではなく、画素の位置とコマ番号から作ったハッシュで決めます。gl_FragCoord を整数に切り捨てて使うので、粒はいつも 1 画素の大きさです。コマ番号は JS で整数にしてから渡し、1 秒に 12 回だけ入れ替えます。描き直すたびに変えると、ざらつきがせわしなく見えます。

粒は中間調に強く、明るい所と暗い所には弱く乗せると、フィルムらしくなります。明るさを L として、粒の振れ幅に 0.5 + 2L(1 − L) を掛けています。粒は、なめらかなグラデーションに出やすい色の段差(バンディング)を散らす役目も兼ねるので、粒の強さを 0 にすると縞が見えることがあります。

粒のハッシュには sin を使わない形を選んでいます。sin を使うハッシュに画素の座標のような大きな値を入れると、GPU によっては精度が足りず、粒に縞や格子の模様が出ます。4 色はどれも彩度を抑えたアッシュ寄りの色で、上ほど明るく下ほど暗く並べ、色相ではなく明るさの差で奥行きを出しています。中ほどのラベンダーグレーは、濃い文字にも白い文字にも明るさが足りないので、文字の側だけ、いちばん明るいグレージュを薄くかけて持ち上げています。

4 色と光だまりの色は CSS 変数で変えられます。粒の強さと入れ替わる速さ、うねりがひと回りする長さで質感を調整します。動きを減らす設定では 1 枚だけ描いて止め、画面の外にあるあいだは描き直しを止めます。ブランドサイトやポートフォリオ、写真や映像のスタジオのやわらかいヒーローに向いています。

コード

HTML
<section class="grain-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="grain-hero__canvas" aria-hidden="true"></canvas>
  <div class="grain-hero__inner">
    <p class="grain-hero__eyebrow">Photography</p>
    <h1 class="grain-hero__title">その一瞬を、<br>美しく残す。</h1>
    <p class="grain-hero__lead">ここに説明文が入ります。</p>
    <a class="grain-hero__button" href="/works/">作品を見る</a>
  </div>
</section>

CSS
.grain-hero {
  /* 上から下へ移る 4 色と、左上の光だまりの色。script.js がこの 5 つを読んでシェーダーへ渡すので、#rrggbb で書く */
  --greige: #e6d6bf; /* 淡いグレージュ */
  --rose: #d99590; /* くすみローズ */
  --lavender: #857ba5; /* ラベンダーグレー */
  --slate: #475c6a; /* 墨のスレート。上から下へ明るさを落として奥行きを出す */
  --glow: #f3e7d5;
  --ink: #262626;
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  display: grid;
  align-items: center;
  min-height: min(90vh, 720px);
  padding: 0 var(--gutter);
  overflow: hidden;
  color: var(--ink);
  /* 読み込み中と、WebGL が使えないときの地 */
  background: linear-gradient(170deg, var(--greige), var(--rose) 35%, var(--lavender) 65%, var(--slate));
}

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

/* 文字の側だけ、いちばん明るい色の薄い幕で持ち上げる。ラベンダーグレーの上では、濃い文字が読みにくい */
.grain-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, color-mix(in srgb, var(--greige) 40%, transparent), color-mix(in srgb, var(--greige) 30%, transparent) 50%, transparent 75%);
  pointer-events: none;
}

.grain-hero__inner {
  position: relative;
  z-index: 1; /* 幕(::after)より前に出す */
  max-width: 32em;
}

.grain-hero__eyebrow {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.grain-hero__title {
  margin: 24px 0 0;
  font-size: clamp(40px, 8vw, 72px);
  line-height: 1.05;
  letter-spacing: -0.02em;
  text-wrap: balance; /* 2 行に折り返すとき、行の長さをそろえる */
}

.grain-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}

.grain-hero__button {
  display: inline-grid;
  place-items: center;
  height: 48px;
  margin-top: 32px;
  padding: 0 32px;
  border-radius: 999px;
  background-color: var(--ink);
  color: #ffffff;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

.grain-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 幅が狭いと、横に流す幕では文字の下が薄くなる。一様にかける */
@media (max-width: 720px) {
  .grain-hero::after { background: color-mix(in srgb, var(--greige) 45%, transparent); }
}

JavaScript
import * as THREE from "three";

const DURATION = 10; // 色の境目のうねりがひと回りして、同じ模様に戻るまで(秒)
const GRAIN_FPS = 12; // 粒が入れ替わる速さ(コマ/秒)。上げるほど、ざらつきがせわしなくなる
const GRAIN = 0.1; // 粒の強さ(0 で消える)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const vertexShader = /* glsl */ `
  varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;

const fragmentShader = /* glsl */ `
  precision highp float;
  uniform vec2 uResolution;
  uniform float uPhase, uGrainFrame, uGrain;
  uniform vec3 uStops[4];
  uniform vec3 uGlow;
  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);
  }
  // グラディエントノイズ(-1..1)
  float noise(vec2 p) {
    vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
    return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
      mix(dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y);
  }
  // 粒のハッシュ(0..1)。sin を使わないので、画素の座標が大きくても模様が崩れにくい
  float hash12(vec2 p) {
    vec3 p3 = fract(vec3(p.xyx) * 0.1031);
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.x + p3.y) * p3.z);
  }
  // s = 0(上)→ 1(下)で 4 色をなめらかにつなぐ
  vec3 palette(float s) {
    s = clamp(s, 0.0, 1.0) * 3.0;
    vec3 c = mix(uStops[0], uStops[1], smoothstep(0.0, 1.0, s));
    c = mix(c, uStops[2], smoothstep(1.0, 2.0, s));
    return mix(c, uStops[3], smoothstep(2.0, 3.0, s));
  }
  void main() {
    vec2 p = (vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0);
    // 時間は円周上の座標としてノイズに足す。1 周すると同じ模様に戻る
    vec2 loop = vec2(cos(uPhase), sin(uPhase));
    float n1 = noise(p * 0.9 + loop * 0.6);
    float n2 = noise(p * 1.8 - loop.yx * 0.45 + 3.1);
    // 縦のグラデーションを少し斜めに倒し、境目をノイズでうねらせる
    float s = (1.0 - vUv.y) * 0.8 + vUv.x * 0.18 + n1 * 0.34 + n2 * 0.12;
    vec3 color = palette(s);
    // 左上にやわらかい光だまり
    vec2 glow = vec2(-0.38 + 0.12 * cos(uPhase), 0.26 + 0.08 * sin(uPhase * 2.0));
    color += uGlow * exp(-dot(p - glow, p - glow) / 0.09) * 0.16;
    // フィルムグレイン。(画素の位置, コマ番号)のハッシュで決め、中間調に強く、明部・暗部には弱く乗せる
    float g = hash12(floor(gl_FragCoord.xy) + uGrainFrame * vec2(37.0, 17.0));
    float lum = dot(color, vec3(0.299, 0.587, 0.114));
    color += (g - 0.5) * uGrain * (0.5 + 2.0 * lum * (1.0 - lum));
    gl_FragColor = vec4(color, 1.0);
  }
`;

document.querySelectorAll(".grain-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  // 色は CSS 変数から読む。THREE.Color は線形に直すので、hex を 255 で割った値をそのまま渡す
  const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return new THREE.Vector3((hex >> 16) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255); };
  const uniforms = {
    uPhase: { value: 0 }, uGrainFrame: { value: 0 }, uGrain: { value: GRAIN }, uResolution: { value: new THREE.Vector2(1, 1) },
    uStops: { value: ["--greige", "--rose", "--lavender", "--slate"].map(rgb) }, uGlow: { value: rgb("--glow") },
  };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".grain-hero__canvas") });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene();
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); // 板は画面ぴったりの 2 × 2。頂点シェーダーがそのまま座標に使う
  const plane = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
  scene.add(plane);

  const draw = (t) => {
    uniforms.uPhase.value = ((t % DURATION) / DURATION) * Math.PI * 2;
    uniforms.uGrainFrame.value = Math.floor(t * GRAIN_FPS) % (DURATION * GRAIN_FPS); // 粒のコマ番号は、JS で整数にしてから渡す
    renderer.render(scene, camera);
  };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    uniforms.uResolution.value.set(w, h);
    if (reduceMotion) draw(5.8); // 動きを減らす設定では、1 枚だけ描いて止める
  });
  const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting && !reduceMotion ? (ms) => draw(ms / 1000) : null)); // 画面の外では回さない
  sizes.observe(hero); view.observe(hero);
  hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect();
    plane.geometry.dispose(); plane.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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