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

ヒーローの背景でカメラが白い列柱の回廊を進み続ける

デモ

仕様

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

しくみと調整

柱(溝を彫った円柱・台座・柱頭)と梁を InstancedMesh で並べ、床の石のタイルも含めて、回廊の中身をすべて柱の間隔ごとの繰り返しにしています。回廊全体を「進んだ距離を柱の間隔で割った余り」だけ手前へずらすので、カメラを前へ動かさなくても、どこまでも進んで見えます。

12 秒でちょうど柱 4 本ぶん進むので、ループの継ぎ目が出ません。床のタイル 1 枚を柱の間隔の半分にしてあり、ずらしが間隔に達したとき模様もぴったり重なります。カメラの揺れも 1 周に整数回の周期にして、歩くようなわずかな上下と左右を足しています。

横から差す DirectionalLight の影が、柱と梁の縞を床に落とし、進む動きをよく見せます。影を撮る範囲は、見えている回廊に合わせて固定します。広すぎると影がぼやけ、狭いと奥の影が切れます。three.js r186 では PCFSoftShadowMap が廃止され、警告を出して PCFShadowMap に置き換わるので、はじめから後者を指定しています。

霧と背景を同じ暖かい白にして、奥を霞に溶かします。床は画面の左右の端より広くして、縁を見せません。回廊の奥が明るい霞になるので、文字は画面の中央に濃い色で置くと読みやすくなります。影の計算は重いので、画面の外では描画を止め、動きを減らす設定では 1 枚だけ描いて止めます。

柱の間隔・1 周で進む本数(速さ)・回廊の幅・柱の高さ・溝の数・色・霧の距離で調整できます。建築・不動産・ホテル・ブランドの世界観を見せるヒーローに向いています。

コード

HTML
<section class="colonnade-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="colonnade-hero__canvas" aria-hidden="true"></canvas>
  <div class="colonnade-hero__inner">
    <p class="colonnade-hero__eyebrow">Architecture</p>
    <h1 class="colonnade-hero__title">静けさを、<br>設計する。</h1>
    <p class="colonnade-hero__lead">ここに説明文が入ります。</p>
    <a class="colonnade-hero__button" href="/works/">作品を見る</a>
  </div>
</section>

CSS
.colonnade-hero {
  /* 回廊の色はここで決める。script.js がこの変数を読んでシーンへ渡す */
  --marble: #f2efeb;
  --floor: #e1dbd0;
  --grout: #c8c0b4; /* 床の目地 */
  --stain: #706458; /* 床の石のまだら */
  --fog: #ebe4da; /* 奥の霞と、背景の色 */
  --sun: #fff4e6; /* 横から差す日差し */
  --sky: #fff9f2; /* 上からの照り返し */
  --ground: #c2b6a7; /* 下からの照り返し */
  --ink: #262626;
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  display: grid;
  place-items: center;
  min-height: min(90vh, 720px);
  padding: 0 var(--gutter);
  overflow: hidden;
  color: var(--ink);
  text-align: center;
  /* 読み込み中と、WebGL が使えないときの地 */
  background-color: var(--fog);
}

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

/* 文字の後ろだけ、霞の色を薄く重ねて読みやすくする。回廊の奥は隠さない */
.colonnade-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse 46% 40% at center, rgba(235, 228, 218, 0.72), rgba(235, 228, 218, 0));
}

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

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

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

.colonnade-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す */
}

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

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

JavaScript
import * as THREE from "three";

const DURATION = 12; // 柱 4 本ぶん進んで、元の絵に戻るまで(秒)
const SPACING = 3.2; // 柱の間隔(床のタイル 2 枚ぶん)
const LAPS = 4; // 1 周で進む柱の本数。整数にすると継ぎ目なくつながる
const COLUMNS = 22; // 片側に並べる柱の数(霧の奥まで届く数)
const HALF_WIDTH = 2.7; // 回廊の半分の幅(柱の中心まで)
const HEIGHT = 5.2; // 柱の高さ
const FLUTES = 20; // 柱に彫る溝の数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 溝を彫った柱。円柱の頂点を、回りの角度に応じて少しだけ内側へ寄せる
function flutedColumn() {
  const geometry = new THREE.CylinderGeometry(0.3, 0.34, HEIGHT, 80, 1);
  const pos = geometry.attributes.position;
  for (let i = 0; i < pos.count; i++) {
    const x = pos.getX(i), z = pos.getZ(i);
    if (Math.hypot(x, z) < 1e-4) continue; // ふたの中心は動かさない
    const k = 1 - 0.045 * (0.5 + 0.5 * Math.cos(Math.atan2(z, x) * FLUTES));
    pos.setXYZ(i, x * k, pos.getY(i), z * k);
  }
  geometry.computeVertexNormals();
  return geometry.translate(0, HEIGHT / 2, 0);
}

// 床の石のタイル(2 × 2 枚)。薄いまだらと目地を canvas に描く
function floorTexture(css) {
  const size = 256;
  const canvas = Object.assign(document.createElement("canvas"), { width: size, height: size });
  const g = canvas.getContext("2d");
  g.fillStyle = css("--floor"); g.fillRect(0, 0, size, size); // 塗り残しがあると、そこが黒く写る
  g.fillStyle = css("--stain");
  for (let i = 0; i < 900; i++) {
    g.globalAlpha = 0.02 + Math.random() * 0.04;
    g.fillRect(Math.random() * size, Math.random() * size, 1 + Math.random() * 3, 1 + Math.random() * 3);
  }
  g.globalAlpha = 1; g.fillStyle = css("--grout");
  for (const p of [0, size / 2]) g.fillRect(p, 0, 2, size), g.fillRect(0, p, size, 2);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
  texture.repeat.set(48 / SPACING, 96 / SPACING); // タイル 1 枚 = 柱の間隔の半分。床(48 × 96)いっぱいに敷く
  return texture;
}

document.querySelectorAll(".colonnade-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".colonnade-hero__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.toneMapping = THREE.NeutralToneMapping; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap;
  const scene = new THREE.Scene(); scene.background = new THREE.Color(css("--fog"));
  scene.fog = new THREE.Fog(css("--fog"), 6, 56); // 背景と同じ色にして、奥を霞に溶かす
  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 120); // 縦横比は resize で入れる

  // 日差し: 右上から斜めに差し、柱と梁の影を床へ落とす。影を撮る範囲は、見えている回廊に合わせて固定する
  const sun = new THREE.DirectionalLight(css("--sun"), 3.2);
  sun.position.set(11, 13, -4); sun.target.position.set(0, 0, -16);
  sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.bias = -0.0004; sun.shadow.normalBias = 0.02;
  Object.assign(sun.shadow.camera, { left: -26, right: 26, top: 26, bottom: -26, near: 1, far: 60 });
  scene.add(sun, sun.target, new THREE.HemisphereLight(css("--sky"), css("--ground"), 1.3));

  // 回廊の中身は、すべて柱の間隔ごとの繰り返し。間隔より短い分だけ手前へずらし続けると、無限に進んで見える
  const hall = new THREE.Group(), marble = new THREE.MeshStandardMaterial({ color: css("--marble"), roughness: 0.5 });
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(48, 96).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ map: floorTexture(css), roughness: 0.6 }));
  floor.position.z = -36; floor.receiveShadow = true; // 床は画面の左右の端より広くして、縁を見せない
  scene.add(hall.add(floor));
  const place = (geometry, count, at) => {
    const mesh = new THREE.InstancedMesh(geometry, marble, count);
    for (let i = 0; i < count; i++) mesh.setMatrixAt(i, new THREE.Matrix4().makeTranslation(...at(i)));
    mesh.castShadow = mesh.receiveShadow = true; hall.add(mesh);
  };
  const column = (i) => [(i % 2 ? 1 : -1) * HALF_WIDTH, 0, 6 - Math.floor(i / 2) * SPACING]; // 左右交互に、手前から奥へ
  place(flutedColumn(), COLUMNS * 2, column);
  place(new THREE.BoxGeometry(0.95, 0.3, 0.95).translate(0, 0.15, 0), COLUMNS * 2, column); // 台座
  place(new THREE.BoxGeometry(1, 0.28, 1).translate(0, HEIGHT - 0.14, 0), COLUMNS * 2, column); // 柱頭
  place(new THREE.BoxGeometry(HALF_WIDTH * 2 + 1, 0.36, 0.5).translate(0, HEIGHT + 0.68, 0), COLUMNS, (i) => [0, 0, 6 - i * SPACING]); // 左右に渡す梁
  place(new THREE.BoxGeometry(0.9, 0.5, 96).translate(0, HEIGHT + 0.25, -36), 2, (i) => [(i ? 1 : -1) * HALF_WIDTH, 0, 0]); // 柱の上の長い梁

  const draw = (t) => {
    const a = (t / DURATION) * Math.PI * 2;
    hall.position.z = ((t / DURATION) * LAPS * SPACING) % SPACING;
    camera.position.set(0.22 * Math.sin(a), 1.75 + 0.05 * Math.sin(a * LAPS * 2), 6); // 歩くようにわずかに揺らす
    camera.lookAt(0.35 * Math.sin(a + 0.6), 2.05 + 0.1 * Math.sin(a), -30);
    renderer.render(scene, camera);
  };
  const sizes = new ResizeObserver(() => {
    camera.aspect = hero.clientWidth / hero.clientHeight; camera.updateProjectionMatrix();
    renderer.setSize(hero.clientWidth, hero.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
    if (reduceMotion) draw(0); // 動きを減らす設定では、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();
    hall.traverse((o) => o.isMesh && (o.geometry.dispose(), o.material.map?.dispose(), o.material.dispose(), o.dispose?.()));
    renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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