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

ヒーローの背景で、銀色の金属の球体がノイズで形を変え続ける

デモ

仕様

用途
ヒーロー
動作
WebGL
種類
シーン
使うもの
three/addons/environments/RoomEnvironment.js
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

正二十面体を細かく分けた球に、光や映り込みを計算する標準の MeshPhysicalMaterial をそのまま使い、onBeforeCompile で頂点シェーダーの一部だけを差し替えます。各頂点を、3 次元のノイズの値だけ外へ押し出すので、光と映り込みの計算は素材に任せたまま、形だけが変わり続けます。ノイズを引く位置を円周に沿って回し、回転も 10 秒でちょうど 1 周させるので、ループの継ぎ目が出ません。

頂点を動かしただけでは、法線は元の球のままです。陰影と映り込みが丸い球のように付き、こぶが平らに見えます。そこで、接線の向きへ少しずらした 2 点も同じ式で押し出し、3 点が作る面の向き(外積)を新しい法線にして objectNormal に入れます。ノイズの補間を 5 次式にしているのも、映り込みの筋に折れ目を出さないためです。

ノイズの細かさを上げるとこぶが増えますが、上げすぎると、へこみが折れ目のように見えます。0.8 前後がなめらかです。押し出しの強さは半径の 0.42 倍、球は 40 分割で頂点が約 10 万個です。頂点をシェーダーで押し出すと、表示の判定に使う境界の球が実際より小さいまま残り、端が欠けることがあるので、frustumCulled を切っておきます。

銀の質感は、部屋のシーン(RoomEnvironment)を PMREM でぼかした映り込みで出します。素材は金属の度合い(metalness)を 1 にした明るい灰で、金属は自分の色をほとんど持たず周りを映すだけなので、明るい部屋の壁と照明が、そのまま銀の地と白いつやになります。粗さは 0.2 で、下げるほど鏡のようにくっきり映り、上げるとつや消しの銀に近づきます。地は CSS の無彩色のグラデーションで、canvas は透明です。床の影はシャドウマップを使わず、中心の濃い丸いにじみの板 1 枚で描き、球が浮くほど薄く広げます。

球・光・影の色と地のグラデーションは、CSS 変数で変えられます。広い画面では canvas ごと右へずらして見出しの横に置き、狭い画面では中央に戻して、文字の下に地の色の薄い幕をかけます。動きを減らす設定では 1 枚だけ描いて止め、画面の外にあるあいだは描き直しを止めます。飲料・コスメ・プロダクトの紹介など、印象に残るヒーローに向いています。

コード

HTML
<section class="blob-hero">
  <!-- 球は飾りなので、読み上げからは外す -->
  <canvas class="blob-hero__canvas" aria-hidden="true"></canvas>
  <div class="blob-hero__inner">
    <p class="blob-hero__eyebrow">SPARKLING SODA</p>
    <h1 class="blob-hero__title">はじける、<br>果実の炭酸。</h1>
    <p class="blob-hero__lead">ここに説明文が入ります。</p>
    <a class="blob-hero__button" href="/stores/">取り扱い店を探す</a>
  </div>
</section>

CSS
.blob-hero {
  /* 球と光と影の色。script.js がこの 4 つを読んでシーンへ渡す */
  --blob: #d6d8db;   /* 銀の金属の反射の色。映り込みに掛かる */
  --key: #ffffff;    /* 左上からの主光源 */
  --rim: #e4e4e4;    /* 奥から縁を照らす光 */
  --shadow: #383838; /* 床の影(灰) */
  /* 地のグラデーション(無彩色の灰)。canvas は透明なので、この上に球が載る */
  --bg-center: #f2f2f2;
  --bg-middle: #dcdcdc;
  --bg-edge: #c4c4c4;
  /* 球を右へずらす量(幅に対する %)。見出しの横に置く。0% で中央 */
  --shift: 18%;
  --ink: #262626;
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  display: grid;
  align-items: center;
  min-height: min(90vh, 720px);
  padding: 0 var(--gutter);
  overflow: clip; /* ずらした canvas がはみ出す。hidden だと、焦点や scrollIntoView で節ごと横にスクロールされる */
  color: var(--ink);
  background: radial-gradient(ellipse at calc(50% + var(--shift)) 42%, var(--bg-center) 0%, var(--bg-middle) 55%, var(--bg-edge) 100%);
}

.blob-hero__canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  translate: var(--shift) 0; /* 絵ごと動かすので、球の形も影も変わらない */
}

.blob-hero__inner {
  position: relative; /* 球の上に載せる */
  max-width: 30em;
}

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

.blob-hero__title {
  margin: 24px 0 0;
  font-size: clamp(40px, 8vw, 72px);
  line-height: 1.05;
  letter-spacing: -0.02em;
}

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

.blob-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;
}

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

/* 画面の高さに比べて幅が詰まると、球が本文にかかる。もう少し右へ寄せる */
@media (max-width: 1200px) {
  .blob-hero { --shift: 26%; }
}

/* 幅が狭いと横に並べる余地が無い。球を中央に戻し、文字の下に地の色の薄い幕をかける */
@media (max-width: 960px) {
  .blob-hero { --shift: 0%; }
  .blob-hero::after {
    content: "";
    position: absolute;
    inset: 0;
    background: color-mix(in srgb, var(--bg-center) 55%, transparent);
    pointer-events: none;
  }
  .blob-hero__inner { z-index: 1; } /* 幕(::after)より前に出す */
}

JavaScript
import * as THREE from "three";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";

const DURATION = 10; // 形の変化と回転がひと回りして、同じ絵に戻るまで(秒)
const RADIUS = 1.45; // 球の半径
const DETAIL = 40; // 球の細かさ。上げるほど滑らか(頂点数は 60 ×(DETAIL + 1)² 個)
const FREQUENCY = 0.8; // ノイズの細かさ。上げるとこぶが増え、上げすぎると、へこみが折れ目に見える
const AMPLITUDE = 0.42; // 押し出しの強さ(半径に対する比)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// MeshPhysicalMaterial の頂点シェーダーに足す関数
const blobChunk = /* glsl */ `
  uniform float uPhase, uFrequency, uAmplitude;
  vec3 blobHash(vec3 p) {
    p = fract(p * vec3(0.1031, 0.1030, 0.0973)); p += dot(p, p.yxz + 33.33);
    return fract((p.xxy + p.yxx) * p.zyx) * 2.0 - 1.0; }
  // 3 次元のグラディエントノイズ。補間を 5 次式にして、映り込みに折れ目が出ないようにする
  float blobNoise(vec3 p) {
    vec3 i = floor(p), f = fract(p), u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
    return mix(
      mix(mix(dot(blobHash(i), f), dot(blobHash(i + vec3(1.0, 0.0, 0.0)), f - vec3(1.0, 0.0, 0.0)), u.x),
        mix(dot(blobHash(i + vec3(0.0, 1.0, 0.0)), f - vec3(0.0, 1.0, 0.0)), dot(blobHash(i + vec3(1.0, 1.0, 0.0)), f - vec3(1.0, 1.0, 0.0)), u.x), u.y),
      mix(mix(dot(blobHash(i + vec3(0.0, 0.0, 1.0)), f - vec3(0.0, 0.0, 1.0)), dot(blobHash(i + vec3(1.0, 0.0, 1.0)), f - vec3(1.0, 0.0, 1.0)), u.x),
        mix(dot(blobHash(i + vec3(0.0, 1.0, 1.0)), f - vec3(0.0, 1.0, 1.0)), dot(blobHash(i + vec3(1.0, 1.0, 1.0)), f - vec3(1.0, 1.0, 1.0)), u.x), u.y), u.z);
  }
  // 単位球上の方向 dir を、ノイズのぶんだけ外へ押し出した点。ノイズを引く位置は円周上を回る(ループ用)
  vec3 blobPoint(vec3 dir) {
    vec3 a = vec3(cos(uPhase), sin(uPhase), 0.0) * 0.8, b = vec3(0.0, cos(uPhase * 2.0 + 1.3), sin(uPhase * 2.0 + 1.3)) * 0.45;
    float n = blobNoise(dir * uFrequency + a) * 0.9 + blobNoise(dir * uFrequency * 2.0 + b + 4.7) * 0.1;
    return dir * (1.0 + uAmplitude * n);
  }
`;
// 法線: 押し出した点と、接線方向へ少しずらした 2 点も押し出し、その外積を新しい法線にする(しないと陰影が元の球のまま)
const blobNormal = /* glsl */ `
  vec3 blobDir = normalize(position);
  vec3 blobT = normalize(cross(blobDir, abs(blobDir.y) < 0.95 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
  vec3 blobB = cross(blobDir, blobT);
  vec3 blobP = blobPoint(blobDir);
  vec3 blobPA = blobPoint(normalize(blobDir + blobT * 0.01)), blobPB = blobPoint(normalize(blobDir + blobB * 0.01));
  vec3 objectNormal = normalize(cross(blobPA - blobP, blobPB - blobP));
  #ifdef USE_TANGENT
    vec3 objectTangent = vec3(tangent.xyz);
  #endif
`;
// 床の影は、中心が濃く外へ消えていく丸いにじみ 1 枚(シャドウマップを使わない)
const shadowVertex = /* glsl */ `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
const shadowFragment = /* glsl */ `
  uniform vec3 uColor; uniform float uOpacity; varying vec2 vUv;
  void main() { float d = length(vUv - 0.5) * 2.0; gl_FragColor = vec4(uColor, exp(-d * d * 4.0) * uOpacity); }
`;

document.querySelectorAll(".blob-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".blob-hero__canvas"), antialias: true, alpha: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setClearColor(0x000000, 0); renderer.toneMapping = THREE.NeutralToneMapping; // 地は CSS のグラデーション。canvas は透かす
  const scene = new THREE.Scene(), pmrem = new THREE.PMREMGenerator(renderer), room = new RoomEnvironment(), env = pmrem.fromScene(room, 0.04);
  scene.environment = env.texture; room.dispose(); pmrem.dispose(); // 映り込み用に、部屋のシーンを PMREM でぼかしたもの(画面には描かない)
  const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100); // 縦横比は resize で入れる
  camera.position.set(0, 0.4, 9.2); camera.lookAt(0, 0, 0);
  const key = new THREE.DirectionalLight(css("--key"), 1.8), rim = new THREE.DirectionalLight(css("--rim"), 3.5); // 左上からの主光源と、奥から縁を照らす光
  key.position.set(-4, 5, 6); rim.position.set(5, 1.5, -5); scene.add(key, rim);
  const uniforms = { uPhase: { value: 0 }, uFrequency: { value: FREQUENCY }, uAmplitude: { value: AMPLITUDE } };
  // 銀の金属。金属は周りを映すだけなので、明るい部屋の映り込みが明るい灰の地と白いつやになる
  const material = new THREE.MeshPhysicalMaterial({ color: css("--blob"), roughness: 0.2, metalness: 1 });
  material.onBeforeCompile = (shader) => { // 頂点の位置と法線を、ノイズで押し出したものに差し替える
    Object.assign(shader.uniforms, uniforms);
    shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>\n${blobChunk}`)
      .replace("#include <beginnormal_vertex>", blobNormal).replace("#include <begin_vertex>", "vec3 transformed = blobP;");
  };
  const blob = new THREE.Mesh(new THREE.IcosahedronGeometry(1, DETAIL), material);
  blob.scale.setScalar(RADIUS); blob.frustumCulled = false; // 頂点をシェーダーで押し出すので、境界球が実際より小さい。カリングで欠けないようにする
  const shade = new THREE.Color().setStyle(css("--shadow"), THREE.LinearSRGBColorSpace); // シェーダーがそのまま書くので、線形に直さない
  const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({ uniforms: { uColor: { value: shade }, uOpacity: { value: 0.3 } },
    vertexShader: shadowVertex, fragmentShader: shadowFragment, transparent: true, depthWrite: false }));
  shadow.rotation.x = -Math.PI / 2; shadow.position.y = -2.25;
  scene.add(blob, shadow);
  const draw = (t) => {
    const a = ((t % DURATION) / DURATION) * Math.PI * 2, lift = Math.sin(a * 2); // lift は上下の揺れ(-1..1)
    uniforms.uPhase.value = a; blob.position.y = 0.1 * lift;
    blob.rotation.set(0.3 * Math.sin(a), a, 0.12 * Math.sin(a * 2));
    shadow.material.uniforms.uOpacity.value = 0.34 - 0.06 * lift; // 浮き上がるほど影は薄く、広く
    shadow.scale.set(4.6 + 0.4 * lift, 2.4 + 0.2 * lift, 1);
    renderer.render(scene, camera);
  };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    camera.aspect = w / h; camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); // 縦長の画面では引いて、球を収める
    camera.updateProjectionMatrix(); renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    if (reduceMotion) draw(3); // 動きを減らす設定では、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(); env.dispose();
    blob.geometry.dispose(); material.dispose(); shadow.geometry.dispose(); shadow.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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