デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
柱(溝を彫った円柱・台座・柱頭)と梁を InstancedMesh で並べ、床の石のタイルも含めて、回廊の中身をすべて柱の間隔ごとの繰り返しにしています。回廊全体を「進んだ距離を柱の間隔で割った余り」だけ手前へずらすので、カメラを前へ動かさなくても、どこまでも進んで見えます。
12 秒でちょうど柱 4 本ぶん進むので、ループの継ぎ目が出ません。床のタイル 1 枚を柱の間隔の半分にしてあり、ずらしが間隔に達したとき模様もぴったり重なります。カメラの揺れも 1 周に整数回の周期にして、歩くようなわずかな上下と左右を足しています。
横から差す DirectionalLight の影が、柱と梁の縞を床に落とし、進む動きをよく見せます。影を撮る範囲は、見えている回廊に合わせて固定します。広すぎると影がぼやけ、狭いと奥の影が切れます。three.js r186 では PCFSoftShadowMap が廃止され、警告を出して PCFShadowMap に置き換わるので、はじめから後者を指定しています。
霧と背景を同じ暖かい白にして、奥を霞に溶かします。床は画面の左右の端より広くして、縁を見せません。回廊の奥が明るい霞になるので、文字は画面の中央に濃い色で置くと読みやすくなります。影の計算は重いので、画面の外では描画を止め、動きを減らす設定では 1 枚だけ描いて止めます。
柱の間隔・1 周で進む本数(速さ)・回廊の幅・柱の高さ・溝の数・色・霧の距離で調整できます。建築・不動産・ホテル・ブランドの世界観を見せるヒーローに向いています。
コード
<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>
.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; }
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();
};
});