デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- 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つです。範囲を狭めて半径を上げるほど、小さな模型を間近で撮ったように見え、範囲を広げればふつうの空撮に近づきます。
コード
<!-- ヒーローの背景。見出しなどの中身は、この要素の中に重ねて置く -->
<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>
.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; } }
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();
};
});