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

ヒーローの背景で浮かぶガラスの球が奥の模様を屈折させる

デモ

仕様

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

しくみと調整

MeshPhysicalMaterial の透過は使わず、自前のシェーダーで屈折を近似します。背景を canvas のテクスチャにしておき、球の各画素で屈折した向きの分だけ、背景を引く位置をずらします。

RGB で屈折率を少しずつずらすと、球の縁に虹色のにじみが出ます。フレネルの項で縁を白ませると、ガラスらしくなります。球の影と、光が集まって明るくなる部分(コースティクス)は、背景のシェーダーに球の画面上の位置を渡して描きます。下のコードは屈折の要点だけに絞った短い版なので、この 2 つは入れていません。

背景に線や模様が無いと、屈折が見えません。グリッドや文字を置いてください。

屈折率・屈折の強さ・色のずれ(分散)・球の並び・背景の絵で調整します。背景は写真にも差し替えられます。

コード

HTML
<section class="orb-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="orb-hero__canvas" aria-hidden="true"></canvas>
  <div class="orb-hero__inner">
    <p class="orb-hero__eyebrow">Skincare — Clean & clear</p>
    <h1 class="orb-hero__title">Water, and nothing to hide.</h1>
    <p class="orb-hero__lead">成分は 7 つだけ。肌にのせるものを、ぜんぶ見える形にしました。</p>
    <a class="orb-hero__button" href="/products/">成分と使い方を見る</a>
  </div>
</section>
CSS
.orb-hero {
  /* 背景の色はここで決める。script.js がこの変数を読んで、canvas に背景を描く */
  --tint-a: #f4effd;
  --tint-b: #e2edfd;
  /* 色だまり。script.js が末尾に 00 を足して透明へ向かわせるので、#rrggbb で書く */
  --spot-a: #ffb8d6;
  --spot-b: #9cc0ff;
  --spot-c: #ffd9a6;
  --grid: rgba(40, 36, 80, 0.13);
  --ink: #2a2640;
  --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-color: var(--tint-a);
}

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

.orb-hero__inner {
  position: relative; /* 背景の上に載せる */
  max-width: 32em;
}

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

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

.orb-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
}

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

.orb-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
JavaScript
import * as THREE from "three";

const DURATION = 10; // ひと回りして同じ絵に戻るまで(秒)
const IOR = 1.45; // 屈折率。上げるほど、奥の模様が大きくゆがむ
const STRENGTH = 0.085; // 屈折でずらす量(画面の高さに対する比。半径 1 の球あたり)
const DISPERSION = 0.05; // 色ごとの屈折率の差。球の縁の虹色になる
// x, y, z: 基準の位置 / r: 半径 / ax, ay: 浮遊の振れ幅 / fx, fy: 周波数(整数にするとループがつながる)
const ORBS = [
  { x: -2.7, y: 0.35, z: 0, r: 1.35, ax: 0.25, ay: 0.3, fx: 1, fy: 1, phase: 0 },
  { x: 2, y: 1.15, z: 0.4, r: 0.8, ax: 0.2, ay: 0.25, fx: 1, fy: 2, phase: 1.7 },
  { x: 3.3, y: -1.25, z: -0.6, r: 1.1, ax: 0.18, ay: 0.22, fx: 2, fy: 1, phase: 3.1 },
  { x: 0.1, y: -1.45, z: 0.8, r: 0.55, ax: 0.3, ay: 0.18, fx: 1, fy: 2, phase: 4.4 },
  { x: -0.55, y: 1.7, z: 0.2, r: 0.42, ax: 0.22, ay: 0.2, fx: 2, fy: 1, phase: 5.6 },
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 背景。屈折を見せるには線や模様が要る(無地だと、ゆがんでも動きが見えない)。写真に差し替えてもよい
function drawBackground(w, h, css) {
  const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h });
  const g = canvas.getContext("2d");
  const base = g.createLinearGradient(0, 0, w, h);
  base.addColorStop(0, css("--tint-a")); base.addColorStop(1, css("--tint-b")); g.fillStyle = base; g.fillRect(0, 0, w, h);
  for (const [x, y, name] of [[0.18, 0.22, "--spot-a"], [0.82, 0.78, "--spot-b"], [0.66, 0.12, "--spot-c"]]) {
    const spot = g.createRadialGradient(x * w, y * h, 0, x * w, y * h, 0.55 * h);
    spot.addColorStop(0, css(name)); spot.addColorStop(1, `${css(name)}00`); g.fillStyle = spot; g.fillRect(0, 0, w, h);
  }
  const cell = h / 12; // 細いグリッド。屈折で線が曲がると、ガラスだとわかる
  g.strokeStyle = css("--grid"); g.lineWidth = Math.max(1, Math.round(h / 720)); g.beginPath();
  for (let x = (w % cell) / 2; x <= w; x += cell) { g.moveTo(Math.round(x) + 0.5, 0); g.lineTo(Math.round(x) + 0.5, h); }
  for (let y = (h % cell) / 2; y <= h; y += cell) { g.moveTo(0, Math.round(y) + 0.5); g.lineTo(w, Math.round(y) + 0.5); }
  g.stroke();
  return canvas;
}

const orbVertex = /* glsl */ `
  varying vec3 vNormal; varying vec3 vViewPos; varying float vScale;
  void main() {
    vec4 mv = modelViewMatrix * vec4(position, 1.0);
    vViewPos = mv.xyz; vNormal = normalize(normalMatrix * normal);
    vScale = length(modelMatrix[0].xyz); // 球の半径。大きい球ほど強く屈折させる
    gl_Position = projectionMatrix * mv;
  }
`;
const orbFragment = /* glsl */ `
  uniform sampler2D uBg; uniform vec2 uResolution; uniform float uIor; uniform float uStrength; uniform float uDispersion;
  varying vec3 vNormal; varying vec3 vViewPos; varying float vScale;
  void main() {
    vec3 n = normalize(vNormal); vec3 v = normalize(-vViewPos);
    vec2 uv = gl_FragCoord.xy / uResolution;
    vec2 toUv = vec2(uResolution.y / uResolution.x, 1.0) * uStrength * vScale;
    // 屈折した向きのぶん、背景を引く位置をずらす。色ごとに屈折率を変えて、縁に虹色を出す
    vec3 rr = refract(-v, n, 1.0 / uIor);
    vec3 rg = refract(-v, n, 1.0 / (uIor + uDispersion));
    vec3 rb = refract(-v, n, 1.0 / (uIor + uDispersion * 2.0));
    vec3 color = vec3(texture2D(uBg, uv + rr.xy * toUv).r, texture2D(uBg, uv + rg.xy * toUv).g, texture2D(uBg, uv + rb.xy * toUv).b);
    float facing = clamp(dot(n, v), 0.0, 1.0);
    color = mix(color, vec3(0.97, 0.96, 1.0), pow(1.0 - facing, 3.0) * 0.6); // 縁ほど空を映す(フレネル)
    color += pow(max(dot(reflect(-normalize(vec3(-0.55, 0.65, 0.55)), n), v), 0.0), 80.0) * 0.9; // 左上からの光のハイライト
    gl_FragColor = vec4(color, 1.0);
  }
`;

document.querySelectorAll(".orb-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".orb-hero__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.outputColorSpace = THREE.LinearSRGBColorSpace; // 背景も球も、canvas に描いた色をそのまま出す
  const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100); camera.position.z = 10;
  const uniforms = { uBg: { value: null }, uResolution: { value: new THREE.Vector2(1, 1) },
    uIor: { value: IOR }, uStrength: { value: STRENGTH }, uDispersion: { value: DISPERSION } };
  const geometry = new THREE.SphereGeometry(1, 64, 48);
  const material = new THREE.ShaderMaterial({ uniforms, vertexShader: orbVertex, fragmentShader: orbFragment });
  const orbs = ORBS.map((o) => { const mesh = new THREE.Mesh(geometry, material); mesh.scale.setScalar(o.r); scene.add(mesh); return mesh; });

  const draw = (t) => {
    const a = (t / DURATION) * Math.PI * 2;
    ORBS.forEach((o, i) => orbs[i].position.set(o.x + o.ax * Math.sin(o.fx * a + o.phase), o.y + o.ay * Math.cos(o.fy * a + o.phase), o.z));
    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 は書き換えない
    const { x, y } = renderer.getDrawingBufferSize(uniforms.uResolution.value); scene.background?.dispose();
    const texture = new THREE.CanvasTexture(drawBackground(x, y, css)); // 球のシェーダーも、この同じ絵を画面の座標で引く
    texture.minFilter = THREE.LinearFilter; texture.generateMipmaps = false; // 画面と 1:1 なので、縮小用の段は作らない
    scene.background = uniforms.uBg.value = texture;
    if (reduceMotion) draw(2.5); // 動きを減らす設定では、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();
    geometry.dispose(); material.dispose(); scene.background?.dispose();
    renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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