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

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

デモ

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

仕様

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

しくみと調整

黒い金属の立体は、TorusKnotGeometry に、金属の度合い(metalness)を 1 にしてクリアコートを重ねた MeshPhysicalMaterial を当てて作ります。金属は自分の色をほとんど持たず、周りを映すだけなので、本物らしさは映り込むスタジオで決まります。商品写真を撮るスタジオと同じく、暗い灰色の部屋に縁のぼけた細長いソフトボックスを 5 枚(真上の広い面・左右手前の縦長・奥の左右の縦長)と明るい床を置いたシーンを作り、PMREM でぼかして環境マップにします。部屋の暗さが黒い地に、ソフトボックスが管に沿った白い筋に、奥の 2 枚が縁の明るい線に、床が下側の照り返しになります。

粗さは 0.3 に、指でなでたような大きなむらと細かい粒を描いた地図(roughnessMap)を掛け、場所によって 0.17〜0.3 に揺らします。映り込みの筋がわずかに乱れ、CG らしい均一なつやが写真の金属に近づきます。地図は管の長さ方向に 7 回・周りに 1 回繰り返し、どちらも整数にして継ぎ目を消しています。ソフトボックスの板は中ほどを平らに明るく、縁をなめらかに暗くしています。縁の硬い板や点の光源を映すと、筋の端や光の点がくっきり切れて CG に見えるので、光源は置かず映り込みだけで照らします。細い映り込みの筋や縁の明るい線は、画素の中で明暗が急に変わるので、そのまま描くと拡大したときにギザギザします。描く解像度を画面の 2 倍にして縮めて表示し、粗さの下限も 0.17 に上げて、筋の切り替えをなだらかにしています(輪郭にしか効かない MSAA では消えません)。

回転の順序を YXZ にして、傾けたまま床と平行に回します(ろくろのような回り方)。1 周は 12 秒で、傾きと上下の揺れもその整数分の 1 の周期にしているので、ひと回りで元の姿勢に戻ります。床の影はシャドウマップを使わず、真下の濃く小さい芯と、外へ広く消えていくにじみの 2 層を 1 枚の板に描き、立体が浮き上がるほど薄く広げます。

立体はドラッグで回せます。横のドラッグは縦の軸まわりの回転に足し、縦のドラッグは画面の横軸まわりの傾きとして、回転の前から掛けます(premultiply)。傾きも回転の値に足すだけにすると、立体が回った向きによって、縦のドラッグで傾く向きが変わってしまいます。つかんでいる間は自動の回転を止め、放すと勢いが残って減っていき、傾きはゆっくり 0 に戻ります。

canvas は透明にして CSS の無彩色のグラデーションに重ねているので、地の色は CSS だけで変えられます。金属の反射の色・映り込む部屋と床の明るさ・影の色も CSS の変数で、部屋を暗くするほど黒く、床を明るくするほど下側が銀に近づきます。文字は左、立体は右半分の canvas に描くので、文字の下に幕を敷かずに済みます。動きを減らす設定では自動では回さず、ドラッグしている間だけ描きます。立体は商品の 3D モデル(glTF)にも差し替えられ、商品・ブランド・3D 制作のヒーローに向いています。

コード

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; // 枠の高さ 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; // 枠の高さに対する比で回す(立体の大きさは高さで決まる)
    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); // 縦長の枠では引いて、立体を幅に収める
    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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。