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

ヒーロー

ヒーローの背景でスタジオ照明に照らされた黒い金属の立体が回り、ドラッグでも回せる

デモ

操作:ドラッグ。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

右半分で黒い金属の結び目がゆっくり回り、管に沿って白い光の筋が流れていきます。素材は metalness を最大にしてクリアコートを重ねた MeshPhysicalMaterial。金属は自分の色をほとんど持たず周りを映すだけなので、本物に見えるかどうかは、映り込む部屋の作り方で決まります。

そこで、商品撮影のスタジオをシーンとして組み、PMREM でぼかして環境マップにしました。暗いグレーの部屋に縁のぼけたソフトボックスを5枚と明るい床を置くと、部屋の暗さが金属の黒に、ソフトボックスが白い筋や縁の明るい線に、床が下側の照り返しになります。ライトは置いていません。縁のくっきりした光源や点光源が映ると、筋の端がすぱっと切れて CG らしく見えるからです。

粗さは、指でなでたようなむらと細かい粒を描いた roughnessMap で、0.17〜0.3の間で揺らしています。均一なつやがわずかに乱れて、写真の金属に近づきます。細い筋は画素の中で明暗が急に変わるのでギザギザになりやすく、MSAA は輪郭にしか効きません。下限を0.17まで上げたのはそのためで、描画の解像度も表示サイズの2倍に固定しました。

回転の順序は YXZ で、傾いたまま床と平行に、ろくろに載せたように回ります。ひと回りは12秒。傾きと上下の揺れもその周期を整数で割った長さなので、ひと回りで元の姿勢に戻ります。床の影はシャドウマップを使わずに平面へ描き、立体が浮くほど薄く広げています。

ドラッグの横の動きは縦軸まわりの回転に足し、縦の動きは premultiply で、画面の横軸まわりの傾きとして掛け合わせます。傾きも回転の値に足すだけだと、立体の向きしだいで、縦のドラッグで傾く向きが変わってしまいます。放すと勢いが残って少しずつ弱まり、傾きはゆっくり0に戻ります。

立体は商品の glTF モデルにも差し替えられます。背景は CSS のグラデーションで、反射の色や部屋と床の明るさも CSS 変数で変えられます。文字は左、立体は右半分の canvas に分けたので、文字の下にオーバーレイは要りません。

コード

HTML
<section class="object-hero">
  <!-- 立体は飾りなので、読み上げからは外す。ドラッグで回せる -->
  <canvas class="object-hero__canvas" aria-hidden="true"></canvas>
  <div class="object-hero__inner">
    <p class="object-hero__eyebrow">3D STUDIO</p>
    <h1 class="object-hero__title">質感まで、<br>伝える3D。</h1>
    <p class="object-hero__lead">ここに説明文が入ります。</p>
    <a class="object-hero__button" href="/works/">制作事例を見る</a>
  </div>
</section>

CSS
.object-hero {
  /* 立体と映り込みの色。script.js がこの 4 つを読んでシーンへ渡す */
  --object: #b4b7bb; /* 金属の反射の色。映り込みに乗算される */
  --room: #262626; /* 映り込む部屋の色。金属のベースの明るさになる(暗いほど黒い) */
  --floor: #999999; /* 映り込む床の色。下を向いた面の照り返しになる(明るいほどシルバーに近づく) */
  --shadow: #1c1c1c; /* 床の影。#rrggbb で書く */
  /* 背景のグラデーション(無彩色のグレー)。立体の後ろ(--light の位置)がいちばん明るい */
  --bg-center: #f2f2f2;
  --bg-middle: #d9d9d9;
  --bg-edge: #bfbfbf;
  --light: 75% 40%;
  --ink: #262626;
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); /* 左に文字、右に立体 */
  align-items: center;
  min-height: min(90vh, 720px);
  padding: 0 var(--gutter);
  overflow: hidden;
  color: var(--ink);
  background: radial-gradient(ellipse at var(--light), var(--bg-center) 0%, var(--bg-middle) 55%, var(--bg-edge) 100%);
}

/* 立体は右半分に描く。文字の上に重ねないので、暗くするオーバーレイは要らない */
.object-hero__canvas {
  position: absolute;
  top: 0;
  left: 50%;
  display: block;
  width: 50%;
  height: 100%;
  cursor: grab;
  touch-action: pan-y; /* 縦のスワイプはページのスクロールに残し、横のドラッグで回す */
}

.object-hero__canvas.is-grabbing { cursor: grabbing; }

.object-hero__inner {
  position: relative;
  max-width: 32em;
}

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

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

.object-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
  line-break: strict; /* 小さい「ョ」や「ー」から行を始めない */
}

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

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

/* 幅が狭いときは、立体を上に置き、文字をその下に並べる */
@media (max-width: 720px) {
  .object-hero { --light: 50% 20%; grid-template-columns: minmax(0, 1fr); align-content: start; padding-bottom: 64px; }
  .object-hero__canvas { position: relative; left: auto; width: calc(100% + 2 * var(--gutter)); height: auto; aspect-ratio: 1; margin: 0 calc(-1 * var(--gutter)); }
}

JavaScript
import * as THREE from "three";

const DURATION = 12; // 1 周して、同じ姿勢に戻るまで(秒)
const DRAG = 4, TILT = 0.7; // canvas の高さ 1 つ分ドラッグしたときに回す角度と、縦のドラッグで傾ける上限(ラジアン)
const SPIN_DAMPING = 2.5, TILT_RETURN = 4; // 放したあと、勢いが減る速さと、傾きが 0 へ戻る速さ(大きいほど早い)
const ROUGHNESS = 0.3; // 粗さの上限。むらのマップ(0.55〜1)を乗算するので、場所によって 0.17〜0.3 になる(下げすぎると、細い映り込みの筋がギザギザする)
// 映り込みのソフトボックス [幅, 高さ, 明るさ, x, y, z]: 真上の広い平面・左手前の縦長・右手前の縦長(弱め)・奥の左右の縦長(縁の明るい線)
const LIGHTS = [[16, 4, 7, 0, 10, 2], [3, 16, 5, -9, 1, 4], [2.4, 16, 3.2, 9, 1, 3], [1.6, 16, 9, -6, 1, -9], [1.6, 16, 9, 6, 1, -9]];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const shadowVertex = `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
// 床の影は、真下の濃く小さい芯と、外へ広く消えていくにじみの 2 層(シャドウマップを使わない)。平面の縁で 0 にする
const shadowFragment = /* glsl */ `uniform vec3 uColor; uniform float uOpacity, uCore; varying vec2 vUv; void main() {
  float d = length(vUv - 0.5) * 2.0, soft = exp(-d * d * 3.5) * uOpacity, core = exp(-d * d * 22.0) * uCore;
  gl_FragColor = vec4(uColor, (1.0 - (1.0 - soft) * (1.0 - core)) * (1.0 - smoothstep(0.7, 1.0, d))); }`;
// グレースケールの DataTexture を作る。value(u, v) が 0..1 を返す
const grayTexture = (size, value) => {
  const data = new Uint8Array(size * size * 4).fill(255);
  for (let i = 0; i < size * size; i++) data.fill(Math.round(255 * value(((i % size) + 0.5) / size, (Math.floor(i / size) + 0.5) / size)), i * 4, i * 4 + 3);
  const texture = new THREE.DataTexture(data, size, size); texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; return texture;
};
const { smoothstep, smootherstep, lerp } = THREE.MathUtils;
const fall = (v) => smoothstep(v, 0, 0.3) * smoothstep(1 - v, 0, 0.3); // ソフトボックスの縁を、なめらかに暗くする
document.querySelectorAll(".object-hero").forEach((hero) => {
  const canvas = hero.querySelector(".object-hero__canvas"), css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); // 背景は CSS のグラデーション。canvas は透明にして重ねる
  renderer.setPixelRatio(2); renderer.toneMapping = THREE.NeutralToneMapping; // 2 倍で描いて縮め、細い映り込みの筋のギザギザを消す。トーンマップは素材の色を崩しにくいもの
  // 映り込み用のスタジオ(画面には描かない)。暗い部屋が黒いベースの色に、ソフトボックスが白い筋に、奥の 2 本が縁の線に、床が下側の照り返しになる
  const studio = new THREE.Scene(), box = grayTexture(32, (u, v) => fall(u) * fall(v)), parts = [box], pmrem = new THREE.PMREMGenerator(renderer);
  const add = (geometry, material, [x, y, z], mesh = new THREE.Mesh(geometry, material)) => {
    mesh.position.set(x, y, z); mesh.lookAt(0, 0, 0); studio.add(mesh); parts.push(geometry, material); };
  const panel = ([w, h, power, ...at], color = new THREE.Color(power, power, power)) =>
    add(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color, map: box, side: THREE.DoubleSide }), at);
  add(new THREE.SphereGeometry(20, 32, 16), new THREE.MeshBasicMaterial({ color: css("--room"), side: THREE.BackSide }), [0, 0, 0]);
  panel([40, 40, 1, 0, -5, 0], new THREE.Color(css("--floor"))); LIGHTS.forEach((light) => panel(light));
  const scene = new THREE.Scene(), env = pmrem.fromScene(studio, 0.01); // PMREM でぼかした映り込みのテクスチャ
  scene.environment = env.texture; parts.forEach((part) => part.dispose()); pmrem.dispose();
  // 粗さのむら: 指でなでたような大きなむら(8×8 の格子をなめらかにつなぐ)と細かい粒。映り込みの筋をわずかに乱す
  const grid = Array.from({ length: 64 }, Math.random), at = (i, j) => grid[(j % 8) * 8 + (i % 8)];
  const roughnessMap = grayTexture(128, (u, v, x = u * 8, y = v * 8, i = Math.floor(x), j = Math.floor(y)) => {
    const fx = smootherstep(x - i, 0, 1), fy = smootherstep(y - j, 0, 1); return 0.55 + 0.25 * lerp(lerp(at(i, j), at(i + 1, j), fx), lerp(at(i, j + 1), at(i + 1, j + 1), fx), fy) + 0.2 * Math.random();
  });
  roughnessMap.wrapS = roughnessMap.wrapT = THREE.RepeatWrapping; roughnessMap.repeat.set(7, 1); // 管の長さ方向に 7 回・周りに 1 回(整数で継ぎ目を消す)
  roughnessMap.minFilter = THREE.LinearMipmapLinearFilter; roughnessMap.generateMipmaps = true;
  const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100); // 縦横比は resize で入れる
  camera.position.set(0, 0.6, 9); camera.lookAt(0, 0, 0);
  const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(1, 0.34, 320, 48, 2, 3),
    new THREE.MeshPhysicalMaterial({ color: css("--object"), roughness: ROUGHNESS, roughnessMap, metalness: 1, clearcoat: 1, clearcoatRoughness: 0.07 }));
  knot.scale.setScalar(0.9); knot.rotation.order = "YXZ"; // Y(ろくろの回転)を最後に適用して、傾けたまま水平に回す
  const hex = Number.parseInt(css("--shadow").slice(1), 16); // シェーダーには、hex を 255 で割った値をそのまま渡す
  const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({ vertexShader: shadowVertex, fragmentShader: shadowFragment, transparent: true, depthWrite: false,
    uniforms: { uColor: { value: new THREE.Vector3(hex >> 16, (hex >> 8) & 255, hex & 255).divideScalar(255) }, uOpacity: { value: 0.3 }, uCore: { value: 0.4 } } }));
  shadow.rotation.x = -Math.PI / 2; shadow.position.y = -2.1; scene.add(knot, shadow);
  const turn = new THREE.Quaternion(), xAxis = new THREE.Vector3(1, 0, 0), uniforms = shadow.material.uniforms;
  const draw = (t, yaw, pitch) => {
    const a = (t / DURATION) * Math.PI * 2, lift = Math.sin(a * 2); // lift: 上下の揺れ(-1..1)
    knot.rotation.set(0.55 + 0.12 * Math.sin(a), a + yaw, 0.1 * Math.sin(a * 2));
    knot.quaternion.premultiply(turn.setFromAxisAngle(xAxis, pitch)); // 縦のドラッグは、画面の横軸まわりに傾ける
    knot.position.y = 0.12 * lift; // 浮き上がるほど影は薄く、広く
    uniforms.uOpacity.value = 0.3 - 0.07 * lift; uniforms.uCore.value = 0.42 - 0.14 * lift; shadow.scale.set(4.4 + 0.4 * lift, 2.6 + 0.25 * lift, 1);
    renderer.render(scene, camera);
  };
  // ドラッグで回す。つかんでいる間は時計を止め、放すと勢いが残って、傾きはゆっくり戻る
  let clock = 2.5, last = 0, yaw = 0, pitch = 0, spin = 0, grab = null, visible = false;
  const frame = (ms) => {
    const dt = Math.min(ms - last, 100) / 1000; last = ms;
    if (!grab && !reduceMotion) clock += dt; // 動きを減らす設定では、自動では回さない
    if (!grab) { yaw += spin * dt; spin *= Math.exp(-SPIN_DAMPING * dt); pitch *= Math.exp(-TILT_RETURN * dt); }
    draw(clock, yaw, pitch);
  };
  // 画面の外では回さない。動きを減らす設定では、ドラッグしている間だけ描く
  const loop = () => renderer.setAnimationLoop(visible && (!reduceMotion || grab) ? frame : null);
  canvas.addEventListener("pointerdown", (e) => {
    grab = { id: e.pointerId, x: e.clientX, y: e.clientY, time: e.timeStamp }; spin = 0;
    canvas.setPointerCapture(e.pointerId); canvas.classList.add("is-grabbing"); loop();
  });
  canvas.addEventListener("pointermove", (e) => {
    if (!grab) return;
    const dx = ((e.clientX - grab.x) / canvas.clientHeight) * DRAG; // canvas の高さに対する比で回す(立体の大きさは高さで決まる)
    yaw += dx; pitch = THREE.MathUtils.clamp(pitch + ((e.clientY - grab.y) / canvas.clientHeight) * DRAG, -TILT, TILT);
    spin = reduceMotion ? 0 : dx / Math.max((e.timeStamp - grab.time) / 1000, 0.008); // 放したときに残す勢い(ラジアン毎秒)
    Object.assign(grab, { x: e.clientX, y: e.clientY, time: e.timeStamp });
  });
  const release = (e) => {
    if (grab && e.timeStamp - grab.time > 80) spin = 0; // 止めてから放したら、勢いは残さない
    grab = null; canvas.classList.remove("is-grabbing"); loop();
  };
  canvas.addEventListener("pointerup", release); canvas.addEventListener("pointercancel", release);
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = canvas; camera.aspect = w / h; camera.zoom = Math.min(1, camera.aspect / 0.75); // 縦長の canvas では引いて、立体を幅に収める
    camera.updateProjectionMatrix(); renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    draw(clock, yaw, pitch);
  });
  const view = new IntersectionObserver(([e]) => { visible = e.isIntersecting; loop(); });
  sizes.observe(canvas); view.observe(canvas);
  hero.destroy = () => { // PJAX でセクションごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect();
    [knot.geometry, knot.material, roughnessMap, shadow.geometry, shadow.material, env, renderer].forEach((item) => item.dispose()); renderer.forceContextLoss();
  };
});

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

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