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

ヒーロー

ティルトシフトでミニチュアに見える街並みが、背景をゆっくり流れる

デモ

操作:カーソルを動かす。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

斜め上から見下ろした街並みが、右から左へゆっくり流れていきます。ピントが合っているのは画面の中ほどの横長の範囲だけで、上下へ離れるほどぼけていくので、ミニチュアの模型を撮った写真のように見えます。ビルや家、木、車は、立方体と三角柱と多面体を InstancedMesh で並べたもので、描画は形ごとに1回で済みます。

ぼかしは、街を描いたあとにかける自前のシェーダーです。EffectComposer に横と縦の2回のぼかしを足し、ピントの線から離れるほど半径を広げて、いちばん外では9pxになります。線の近くにはぼけない範囲を少し残し、そこからなめらかに半径を増やしているので、ぼけ始めに境目ができません。2回目のぼかしでは彩度も1.18倍に上げ、模型らしい鮮やかさを足しました。

継ぎ目なく流せるかどうかは、街区の中身の決め方にかかっています。カメラを斜めに回してあるので、画面の真横は、街区の格子でいうと斜めの向きです。その斜めに4街区進むと同じ中身に戻るように並べておき、24秒で街全体をちょうどその分だけずらしました。奥行きの向きには列ごとに違う中身を置いているので、同じ並びが上下にくり返して見えることもありません。

車は道に沿って、1周で4街区ぶん進みます。縦の道と横の道の車が交差点で重なるとすり抜けて見えるので、交差点にいる時刻を縦と横で半周期ずらし、同じ交差点に同時に入らないようにしました。マウスを使っているときは、ピントの線がポインタの高さに付いてきます。公開しているコードでは右下に動きを止めるボタンも用意しました。背景が休まず動き続けるからです。

ぼけ方を決めるのは、ぼけの最大の半径と、ぼけない範囲の広さの2つです。範囲を狭めて半径を上げるほど、小さな模型を間近で撮ったように見え、範囲を広げればふつうの空撮に近づきます。

コード

HTML
<!-- ヒーローの背景。見出しなどの中身は、この要素の中に重ねて置く -->
<div class="town-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="town-hero__canvas" aria-hidden="true"></canvas>
  <!-- 背景がずっと動き続けるので、止める手段を置く -->
  <button class="town-hero__toggle" type="button" aria-pressed="false" aria-label="背景の動きを止める"></button>
  <!-- 上下をぼかすシェーダー。uDir の向き(横・縦の 2 回)に、ピントの線から離れるほど大きな半径でぼかす -->
  <script type="x-shader/x-fragment" class="town-hero__blur">
    uniform sampler2D tDiffuse;
    uniform vec2 uDir, uRes;
    uniform float uFocus, uBlur, uSat;
    varying vec2 vUv;
    void main() {
      float r = uBlur * smoothstep(0.07, 0.39, abs(vUv.y - uFocus)); // ピントの線の近くは 0。離れるほど大きくする
      vec4 sum = vec4(0.0);
      float total = 0.0;
      for (int i = -8; i <= 8; i++) {
        float x = float(i) / 8.0, w = exp(-x * x * 2.0);
        sum += texture2D(tDiffuse, vUv + uDir * x * r / uRes) * w;
        total += w;
      }
      vec4 c = sum / total;
      float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
      gl_FragColor = vec4(mix(vec3(l), c.rgb, uSat), c.a); // uSat が 1 より大きいと彩度が上がる
    }
  </script>
</div>

CSS
.town-hero {
  /* 街の色はここで決める。script.js がこの変数を読んでシーンへ渡す。空白で区切った色からは、1 つずつ選んで塗る */
  --road: #9a9ca0;
  --line: #f4f2ec; /* 道の破線と横断歩道 */
  --walk: #ddd8cf; /* 歩道 */
  --grass: #a3c48c;
  --water: #8db4cf;
  --trunk: #8a6e55;
  --unit: #a7a9ad; /* 屋上の室外機 */
  --walls: #f2eee7 #ead9c4 #ebc9c3 #c9d8e7 #d3e4cb #e9e1c6;
  --roofs: #c77a6e #6c90b4 #b48c5e #7fa677 #9286b5;
  --leaves: #6fa15e #86b26a #5e9468 #9db866;
  --cars: #d9766c #6894c0 #e1bf63 #f6f4ef #4e6370 #8eb57e;
  --sun: #fff2df;
  --sky: #f6f2ea; /* 空からの光 */
  --ground: #b7aea0; /* 地面からの照り返し */
  position: relative;
  min-height: min(90vh, 720px);
  overflow: hidden;
  /* 読み込み中と、WebGL が使えないときの背景色 */
  background-color: var(--road);
}

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

/* 止めるボタン。右下に小さく置き、押すと一時停止の 2 本線が再生の三角に変わる */
.town-hero__toggle {
  position: absolute;
  right: 24px;
  bottom: 24px;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.6);
  border-radius: 999px;
  background-color: rgba(38, 38, 38, 0.5);
  color: #ffffff;
  cursor: pointer;
}

.town-hero__toggle::before {
  content: "";
  width: 10px;
  height: 12px;
  border-inline: 3px solid currentColor;
}

.town-hero__toggle[aria-pressed="true"]::before {
  width: 0;
  height: 0;
  margin-left: 4px;
  border-block: 7px solid transparent;
  border-inline: 11px solid currentColor;
  border-right: 0;
}

.town-hero__toggle:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 3px;
}

/* 動きを減らす設定では、はじめから止めて描くので、ボタンは要らない */
@media (prefers-reduced-motion: reduce) { .town-hero__toggle { display: none; } }

JavaScript
import * as THREE from "three";
import { gsap } from "gsap";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { CopyShader } from "three/addons/shaders/CopyShader.js";
const DURATION = 24; // 区画(4 × 4 街区)1 つぶん流れるまでの秒数
const BLOCK = 8, N = 4, TILE = N * BLOCK, ROAD = 1.8; // 街区の間隔・同じ並びに戻るまでの街区の数・道の幅
const FOCUS = 0.48, BLUR = 9; // ピントの線の高さ(下が 0・上が 1)と、いちばんぼけたときの半径(高さ 720px のときの px)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 画面の横向き(a)と奥向き(b)は、x と z を 45° 回した座標。流す分も含めて、見える範囲にかかる所だけ作る
const inView = (x, z, a = (x - z) / Math.SQRT2, b = -(x + z) / Math.SQRT2) => a > -56 && a < 92 && b > -34 && b < 46;
document.querySelectorAll(".town-hero").forEach((hero) => {
  const cache = {}, css = (name) => (cache[name] ??= getComputedStyle(hero).getPropertyValue(name).trim());
  const pick = (name, list = css(name).split(/\s+/)) => list[Math.floor(Math.random() * list.length)];
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".town-hero__canvas") }); 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(), camera = new THREE.PerspectiveCamera(30, 1, 1, 200); // 画角を狭くして、遠近の差を小さくする
  camera.position.set(23.7, 37.2, 23.7); camera.lookAt(0, 0, 0); // 45° 回した向きから、48° 見下ろす
  const sun = new THREE.DirectionalLight(css("--sun"), 2.9); // 左奥から差して、建物の影を右へ落とす
  sun.position.set(-22, 40, 14); sun.target.position.set(-3, 0, -3); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);
  sun.shadow.bias = -0.0005; sun.shadow.normalBias = 0.03; Object.assign(sun.shadow.camera, { left: -46, right: 46, top: 46, bottom: -46, far: 120 });
  const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ color: css("--road"), roughness: 0.95 }));
  ground.receiveShadow = true; scene.background = new THREE.Color(css("--road")); scene.add(sun, sun.target, ground, new THREE.HemisphereLight(css("--sky"), css("--ground"), 1.35));
  // 1 街区ぶんの中身(街区の中心からの位置)。公園・ビル 4 棟・家 9 軒・ビルと家 のどれかにする
  const makeBlock = (r = Math.random, inner = BLOCK - ROAD, out = []) => {
    const put = (kind, x, y, z, sx, sy, sz, color, rot = 0) => out.push({ kind, x, y, z, sx, sy, sz, color, rot });
    const tree = (x, z, s) => { put("box", x, 0.1, z, 0.1, 0.32 * s, 0.1, css("--trunk")); put("crown", x, 0.1 + 0.62 * s, z, 0.48 * s, 0.56 * s, 0.48 * s, pick("--leaves"), r() * 6.3); };
    const house = (x, z, rot, h = 0.62 + r() * 0.18) => { put("box", x, 0.1, z, 1.25, h, 1, pick("--walls"), rot); put("roof", x, 0.1 + h, z, 1.39, 0.42, 1.12, pick("--roofs"), rot); };
    const tower = (x, z, w, d, h = 1.6 + r() * 3.2) => { put("box", x, 0.1, z, w, h, d, pick("--walls")); put("box", x + (r() - 0.5) * w * 0.4, 0.1 + h, z + (r() - 0.5) * d * 0.4, 0.5, 0.26, 0.4, css("--unit")); };
    put("box", 0, 0, 0, inner, 0.1, inner, css("--walk")); // 歩道の高さの台
    const type = r(), px = r() < 0.5 ? 0.9 : -0.9, pz = r() < 0.5 ? 0.7 : -0.7; if (type < 0.17) { // 公園と、角に寄せた池
      put("box", 0, 0.1, 0, inner - 0.6, 0.04, inner - 0.6, css("--grass")); put("box", px, 0.14, pz, 2.2, 0.02, 1.5, css("--water"));
      for (let n = 0; n < 14; n++) { const x = (r() - 0.5) * (inner - 1.4), z = (r() - 0.5) * (inner - 1.4); if (Math.abs(x - px) > 1.5 || Math.abs(z - pz) > 1.1) tree(x, z, 0.8 + r() * 0.6); }
    } else if (type < 0.45) [[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => tower(a * 1.4, b * 1.4, 2.1 + r() * 0.4, 2.1 + r() * 0.4));
    else if (type < 0.78) for (let n = 0; n < 9; n++) { const x = ((n % 3) - 1) * 1.85, z = (Math.floor(n / 3) - 1) * 1.85; if (r() < 0.3) tree(x, z, 0.9 + r() * 0.4); else house(x, z, r() < 0.5 ? 0 : Math.PI / 2); }
    else { const s = r() < 0.5 ? 1 : -1; tower(-1.4 * s, 0, 2.3, 5); [-1, 0, 1].forEach((b) => house(1.5 * s, b * 1.85, Math.PI / 2)); }
    return out; };
  // 街区 (i, j) の中身は「i + j」と「i を N で割った余り」で決める。流れる向き(i + N, j − N)には N 街区ごとに同じ中身が来るので、継ぎ目なく流せる
  const items = { box: [], roof: [], crown: [], car: [] }, designs = new Map(), line = (x, z, sx, sz) => items.box.push({ x, y: 0, z, sx, sy: 0.02, sz, rot: 0, color: css("--line") });
  for (let i = -16; i <= 16; i++) for (let j = -16; j <= 16; j++) {
    const x0 = i * BLOCK, z0 = j * BLOCK, key = `${i + j},${((i % N) + N) % N}`;
    if (!inView(x0 + BLOCK / 2, z0 + BLOCK / 2)) continue;
    if (!designs.has(key)) designs.set(key, makeBlock());
    for (const it of designs.get(key)) items[it.kind].push({ ...it, x: it.x + x0 + BLOCK / 2, z: it.z + z0 + BLOCK / 2 });
    for (let s = 1.9; s <= BLOCK - 1.9; s += 1.05) { line(x0 + s, z0, 0.5, 0.07); line(x0, z0 + s, 0.07, 0.5); } // 道の中央の破線
    for (let n = -2; n <= 2; n++) for (const k of [-1.35, 1.35]) { line(x0 + k, z0 + n * 0.32, 0.6, 0.16); line(x0 + n * 0.32, z0 + k, 0.16, 0.6); } // 横断歩道
  }
  // 車は区画ごとに同じ並びで、1 周で区画 1 つぶん進む。横の道と縦の道の車は、交差点に入る時刻を半周期ずらして重ならないようにする
  const cars = [], tiles = [], jitter = () => (Math.random() - 0.5) * 0.6;
  for (let r = 0; r < N; r++) for (const dir of [1, -1]) for (let s = 0; s < N; s++) {
    if (Math.random() < 0.55) cars.push({ x: true, lane: r * BLOCK - dir * 0.45, dir, start: s * BLOCK + jitter(), color: pick("--cars") }); // 左側通行
    if (Math.random() < 0.45) cars.push({ x: false, lane: r * BLOCK + dir * 0.45, dir, start: (s + 0.5) * BLOCK + jitter(), color: pick("--cars") });
  }
  for (let i = -6; i <= 6; i++) for (let j = -6; j <= 6; j++) if (inView((i + 0.5) * TILE, (j + 0.5) * TILE)) { tiles.push([i * TILE, j * TILE]); for (const { color } of cars) items.car.push({ color }, { color }); }
  const matrix = new THREE.Matrix4(), turn = new THREE.Quaternion(), Y = new THREE.Vector3(0, 1, 0), color = new THREE.Color();
  const place = (mesh, i, { x = 0, y = 0, z = 0, sx = 1, sy = 1, sz = 1, rot = 0 }) => mesh.setMatrixAt(i, matrix.compose(new THREE.Vector3(x, y, z), turn.setFromAxisAngle(Y, rot), new THREE.Vector3(sx, sy, sz)));
  const material = new THREE.MeshStandardMaterial({ roughness: 0.82, flatShading: true }); // 色はインスタンスごとに付ける。面ごとの陰影で、模型らしい角を立てる
  const box = new THREE.BoxGeometry().translate(0, 0.5, 0), geometries = { box, car: box, crown: new THREE.IcosahedronGeometry(1, 0),
    roof: new THREE.CylinderGeometry(1, 1, 1, 3).rotateZ(Math.PI / 2).rotateX(-Math.PI / 2).translate(0, 0.5, 0).scale(1, 1 / 1.5, 1 / Math.sqrt(3)) }; // 三角柱の切妻屋根
  const town = new THREE.Group(), meshes = {}; scene.add(town);
  for (const kind in items) { // 種類ごとに InstancedMesh を 1 つ作り、1 回の描画で描く。車の分は毎回書き換える
    const mesh = (meshes[kind] = new THREE.InstancedMesh(geometries[kind], material, items[kind].length)); mesh.castShadow = mesh.receiveShadow = true;
    items[kind].forEach((it, i) => { place(mesh, i, it); mesh.setColorAt(i, color.set(it.color)); }); town.add(mesh); }
  meshes.car.frustumCulled = false; // 行列を毎回書き換えるので、最初に計算した境界の球に頼らない
  const composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 })); // 縁のギザギザを消す
  const uniforms = { tDiffuse: { value: null }, uDir: { value: new THREE.Vector2(1, 0) }, uRes: { value: new THREE.Vector2() }, uFocus: { value: FOCUS }, uBlur: { value: 0 }, uSat: { value: 1 } };
  const blur = [0, 1].map(() => new ShaderPass({ uniforms, vertexShader: CopyShader.vertexShader, fragmentShader: hero.querySelector(".town-hero__blur").textContent }));
  blur[1].uniforms.uDir.value.set(0, 1); blur[1].uniforms.uSat.value = 1.18; // 横・縦の順にぼかし、2 回目で彩度を少し上げる
  [new RenderPass(scene, camera), ...blur, new OutputPass()].forEach((pass) => composer.addPass(pass));
  const focus = { y: FOCUS }, draw = (t, p = (t / DURATION) % 1, i = 0) => {
    town.position.set(-TILE * p, 0, TILE * p); // カメラの右向きに、区画 1 つぶん斜めのベクトルだけ左へ流す
    for (const [ox, oz] of tiles) for (const car of cars) {
      const s = (((car.start + car.dir * p * TILE) % TILE) + TILE) % TILE, back = -car.dir * 0.08, rot = car.x ? 0 : Math.PI / 2;
      const x = (car.x ? s : car.lane) + ox, z = (car.x ? car.lane : s) + oz;
      place(meshes.car, i++, { x, y: 0.06, z, sx: 0.8, sy: 0.2, sz: 0.38, rot });
      place(meshes.car, i++, { x: x + (car.x ? back : 0), y: 0.26, z: z + (car.x ? 0 : back), sx: 0.44, sy: 0.15, sz: 0.32, rot }); } // 屋根は少し後ろ
    meshes.car.instanceMatrix.needsUpdate = true; for (const pass of blur) pass.uniforms.uFocus.value = focus.y;
    composer.render(); };
  let time = 0, visible = false, toggle = hero.querySelector(".town-hero__toggle");
  const running = () => visible && !reduceMotion && toggle.getAttribute("aria-pressed") === "false";
  const tick = (now, delta) => running() && draw((time += Math.min(delta, 100) / 1000)); // タブを戻したときに飛ばないよう、1 コマの進みに上限を付ける
  const sizes = new ResizeObserver(() => {
    const w = hero.clientWidth, h = hero.clientHeight, px = renderer.getPixelRatio();
    camera.aspect = w / h; camera.zoom = Math.max(0.7, camera.aspect / (16 / 9)); camera.updateProjectionMatrix(); // 横長では寄せて街の端を隠し、縦長では少し引く
    renderer.setSize(w, h, false); composer.setSize(w, h); // 大きさは CSS が決めるので、style は書き換えない
    for (const pass of blur) { pass.uniforms.uRes.value.set(w * px, h * px); pass.uniforms.uBlur.value = BLUR * (h / 720) * px; }
    draw(time); }); // 動きを減らす設定と一時停止の間は、この 1 枚のまま止まる
  const view = new IntersectionObserver(([e]) => (visible = e.isIntersecting)); // 画面の外では描かない
  toggle.addEventListener("click", () => toggle.setAttribute("aria-pressed", String(toggle.getAttribute("aria-pressed") === "false")));
  const follow = gsap.quickTo(focus, "y", { duration: 0.6, ease: "power3.out", onUpdate: () => !running() && draw(time) }); // マウスでは、ピントの線がポインタの高さに付いてくる
  hero.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse" && !reduceMotion) { const r = hero.getBoundingClientRect(); follow(gsap.utils.clamp(0.08, 0.92, 1 - (e.clientY - r.top) / r.height)); } });
  hero.addEventListener("pointerleave", () => follow(FOCUS)); gsap.ticker.add(tick); sizes.observe(hero); view.observe(hero);
  hero.destroy = () => { // PJAX でページを差し替えて要素ごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
    gsap.ticker.remove(tick); follow.tween.kill(); sizes.disconnect(); view.disconnect(); Object.values(meshes).forEach((mesh) => mesh.dispose());
    [box, geometries.crown, geometries.roof, ground.geometry].forEach((g) => g.dispose()); material.dispose(); ground.material.dispose(); composer.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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