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

ヒーローの背景で液体金属のような反射がうねる

デモ

仕様

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

しくみと調整

全画面の板に、フラグメントシェーダーを 1 枚かけた背景です。ノイズでゆがめた座標でもう一度ノイズを引く「ドメインワーピング」で液面の高さを作り、隣の画素との高さの差から法線を求めます。正面から見て反射した向きを reflect で出し、その向きにある環境の色を引くと、面全体が鏡として景色を映します。ゆがみを引く位置を円周に沿って回すので、10 秒でひと回りしてループがつながります。

映す環境は画像ではなく、式で作ります。地平線の白からスレートへ移る空に横長の照明を 2 本置き、地平線より下は暗い床にします。効いているのは、反射した向きの高さに 0.15 を足すかさ上げです。平らな所は地平線の少し上の明るい空を映し、傾いた所だけが暗い床を映すので、面の大半が明るく、うねりの縁が黒く締まって金属らしく見えます。

地平線のすぐ下に暗い帯を置くと、映り込みの縁取りに見えて、アニメの塗りのようになります。床は地平線の直下をいちばん明るくし、足元へ向かってなめらかに暗くします。傾きの急な所には、薄膜のような色みを差します。色相を回す虹色ではなく、ラベンダーグレーとグレージュの 2 色の間を傾きと高さで行き来させるので、金属の無彩色を崩さずに、淡い色みだけが縁に乗ります。明るい映り込みにだけ乗せ、暗い所には差しません。暗い所に差すと、色が濁って見えます。

1 画素ごとに液面の高さを 3 回引く、重めのシェーダーです。ハッシュには sin を使わない軽い形を選び、ノイズを重ねる数も 2〜3 段に抑えています。重い端末では setPixelRatio を 1 に下げてください。反射はもともと柔らかいので、解像度を落としても目立ちません。

空の上・地平線・床の手前・床の奥の 4 色と、薄膜の 2 色は CSS 変数で変えられます。模様の大きさ・傾きの強さ(上げると反射の帯が細かく割れる)・ゆがみの強さ・薄膜の色みの強さで質感を調整します。金属は空を映して明るいので、白い文字の側には黒の幕をかけています。動きを減らす設定では 1 枚だけ描いて止め、画面の外にあるあいだは描き直しを止めます。ファッション・音楽・プロダクトのヒーローに向いています。

コード

HTML
<section class="chrome-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="chrome-hero__canvas" aria-hidden="true"></canvas>
  <div class="chrome-hero__inner">
    <p class="chrome-hero__eyebrow">Collection</p>
    <h1 class="chrome-hero__title">光をまとう、<br>秋冬の新作。</h1>
    <p class="chrome-hero__lead">ここに説明文が入ります。</p>
    <a class="chrome-hero__button" href="/collection/">コレクションを見る</a>
  </div>
</section>

CSS
.chrome-hero {
  /* 金属に映す空と床の色と、縁に差す薄膜の 2 色。script.js がこの 6 つを読んでシェーダーへ渡すので、#rrggbb で書く */
  --sky-top: #98b1c4; /* スレート */
  --sky-horizon: #f4f3ef;
  --floor-near: #101213;
  --floor-far: #6b7880; /* 墨のスレートを明るくした色 */
  --film-a: #b0a5ca; /* ラベンダーグレー */
  --film-b: #d2b999; /* グレージュ */
  --ink: #ffffff;
  --soft: #eeece6;
  --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(180deg, var(--sky-top), var(--sky-horizon) 55%, var(--floor-far) 56%, var(--floor-near));
}

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

/* 文字の側ほど濃い黒の幕。金属は空を映して明るいので、これが無いと白い文字が読めない */
.chrome-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, rgba(0, 0, 0, 0.66), rgba(0, 0, 0, 0.6) 55%, rgba(0, 0, 0, 0.3));
  pointer-events: none;
}

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

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

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

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

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

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

/* 幅が狭いと、横に流す幕では文字の下が薄くなる。一様にかける */
@media (max-width: 720px) {
  .chrome-hero::after { background: rgba(0, 0, 0, 0.62); }
}

JavaScript
import * as THREE from "three";

const DURATION = 10; // うねりがひと回りして、同じ模様に戻るまで(秒)
const SCALE = 0.8; // 模様の大きさ。小さいほど大きなうねりになる
const RELIEF = 0.42; // 傾きの強さ。上げると反射の帯が細かく割れる
const WARP = 1.15; // ゆがみの強さ。上げると流体らしい渦が増える
const IRIDESCENCE = 0.32; // 急な傾きに差す薄膜の色みの強さ
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, uScale, uRelief, uWarp, uIridescence;
  uniform vec3 uSkyTop, uSkyHorizon, uFloorNear, uFloorFar, uFilmA, uFilmB;
  varying vec2 vUv;
  // sin を使わない軽いハッシュ。1 画素で高さを 3 回引くので、軽さが効く
  vec2 hash2(vec2 p) {
    vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.xx + p3.yz) * p3.zy) * 2.0 - 1.0;
  }
  // グラディエントノイズ(-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);
  }
  const mat2 ROT = mat2(1.6, 1.2, -1.2, 1.6);
  float fbm2(vec2 p) { return 0.62 * noise(p) + 0.3 * noise(ROT * p + 3.7); }
  float fbm3(vec2 p) { float sum = 0.0, amp = 0.6; for (int i = 0; i < 3; i++) { sum += amp * noise(p); p = ROT * p + 11.7; amp *= 0.42; } return sum; }
  // 液面の高さ。ノイズでゆがめた座標で、もう一度ノイズを引く。ゆがみの引き位置 a, b は円周上を回る
  float height(vec2 p, vec2 a, vec2 b) { return fbm3(p + vec2(fbm2(p + a), fbm2(p + vec2(5.2, 1.3) + b)) * uWarp); }
  // 映す環境。空(地平線の白 → スレート)に横長の照明が 2 本。床は地平線の直下がいちばん明るく、足元ほど暗い
  vec3 environment(vec3 r) {
    float y = r.y + 0.15; // 平らなところは、地平線より少し上の空を映す
    vec3 sky = mix(uSkyHorizon, uSkyTop, smoothstep(0.0, 0.3, y));
    sky = mix(sky, uSkyTop * 0.62, smoothstep(0.35, 1.1, y));
    float lamp = 1.0 - smoothstep(0.2, 0.95, abs(r.x + 0.1));
    sky += (1.0 - smoothstep(0.0, 0.06, abs(y - 0.42))) * lamp * 0.55;
    sky += (1.0 - smoothstep(0.0, 0.04, abs(y - 0.78))) * lamp * 0.3;
    vec3 ground = mix(uFloorFar, uFloorNear, smoothstep(0.0, 0.55, -y));
    ground += (1.0 - smoothstep(0.0, 0.1, abs(-y - 0.32))) * lamp * 0.2; // 床に置いた照明の照り返し
    vec3 color = mix(ground, sky, smoothstep(-0.008, 0.008, y)); // 地平線でくっきり切り替える
    return color * (1.0 - 0.22 * smoothstep(-0.3, 0.9, r.x)); // 右を向いた面ほど少し暗く
  }
  void main() {
    vec2 p = (vUv - 0.5) * vec2(uResolution.x / uResolution.y * 0.7, 1.0) * uScale * 2.0; // 横に引き伸ばして、流れる向きを付ける
    vec2 a = vec2(cos(uPhase), sin(uPhase)) * 0.9, b = vec2(sin(uPhase * 2.0 + 1.0), cos(uPhase + 0.4)) * 0.6;
    // 隣の画素との高さの差から法線を作る
    float e = 1.5 * uScale * 2.0 / uResolution.y;
    float h0 = height(p, a, b), hx = height(p + vec2(e, 0.0), a, b), hy = height(p + vec2(0.0, e), a, b);
    vec2 slope = vec2(hx - h0, hy - h0) / e;
    vec3 n = normalize(vec3(-slope * uRelief, 1.0));
    vec3 r = reflect(vec3(0.0, 0.0, -1.0), n); // 正面から見て、反射した向きの環境を映す
    vec3 color = environment(r);
    color += pow(max(dot(r, normalize(vec3(-0.35, 0.45, 0.82))), 0.0), 200.0) * 0.6; // 左上の小さな光源のハイライト
    // 急な傾き(縁)に薄膜のような色みを差す。色相は回さず 2 色の間を行き来させる。暗い映り込みに差すと濁るので、明るいところだけ
    float edge = 1.0 - n.z, light = dot(color, vec3(0.299, 0.587, 0.114));
    vec3 film = mix(uFilmA, uFilmB, 0.5 + 0.5 * cos(6.2831853 * (edge * 2.4 + h0 * 0.8)));
    color = mix(color, color * (0.4 + 0.9 * film), clamp(edge * 4.0, 0.0, 1.0) * smoothstep(0.35, 0.75, light) * uIridescence);
    color += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0; // 8bit の段差(バンディング)を散らす
    gl_FragColor = vec4(color, 1.0);
  }
`;

document.querySelectorAll(".chrome-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 }, uResolution: { value: new THREE.Vector2(1, 1) },
    uScale: { value: SCALE }, uRelief: { value: RELIEF }, uWarp: { value: WARP }, uIridescence: { value: IRIDESCENCE },
    uSkyTop: { value: rgb("--sky-top") }, uSkyHorizon: { value: rgb("--sky-horizon") }, uFloorNear: { value: rgb("--floor-near") }, uFloorFar: { value: rgb("--floor-far") },
    uFilmA: { value: rgb("--film-a") }, uFilmB: { value: rgb("--film-b") },
  };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".chrome-hero__canvas") });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 重いときは 1 に下げる。反射はもともと柔らかいので目立たない
  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; renderer.render(scene, camera); };
  const sizes = new ResizeObserver(() => {
    renderer.setSize(hero.clientWidth, hero.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
    renderer.getDrawingBufferSize(uniforms.uResolution.value); // 法線を取る隣の画素までの距離は、実際の画素で決める
    if (reduceMotion) draw(7.9); // 動きを減らす設定では、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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。