デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
900 個の光の粒を Points 1 つで描き、位置はすべて頂点シェーダーで時刻から計算します。粒ごとに持たせるのは 4 つの乱数(横の位置・高さ・奥行き・大きさと速さ)だけで、CPU で毎フレーム座標を書き換えることはしません。粒は中心ほど明るいぼやけた丸にし、加算合成で重ねて光らせます。
上昇の速さは、1 ループ(12 秒)で縦の範囲の 1 倍か 2 倍を進む値にしています。高さを mod で折り返すので、ひと回りでちょうど元の高さに戻り、継ぎ目が出ません。上下の端に近づくと薄くしてから折り返すので、粒は上でふっと消え、下から現れます。奥の粒ほど小さく薄くして、奥行きを出しています。
頂点の位置は 0 のまま渡してシェーダーで動かすので、three.js が測る形の範囲(境界球)は原点の 1 点になり、実際の粒の広がりと合いません。カメラを動かして原点が画面から外れると、粒がまとめて消えるので、frustumCulled を false にしておきます。また、加算合成は重なるほど明るくなるので、粒を多く・大きくしすぎると画面が白く飛びます。
粒の大きさは画面の高さと描画の倍率に合わせて掛けているので、どの画面でも高さ 720px のときと同じ見え方になります。横長の画面ではカメラを寄せて、左右の端まで粒を行き渡らせます。地と粒の色は CSS の変数で変えられ、粒の数や縦の範囲を変えれば、雪や蛍、舞い上がる火の粉のようにも見せられます。宿泊・リラクゼーション・夜のイベントなど、落ち着いた暗いヒーローに向いています。
コード
<section class="drift-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="drift-hero__canvas" aria-hidden="true"></canvas>
<div class="drift-hero__inner">
<p class="drift-hero__eyebrow">NIGHT RETREAT</p>
<h1 class="drift-hero__title">灯りのそばで、<br>静かな夜を。</h1>
<p class="drift-hero__lead">ここに説明文が入ります。</p>
<a class="drift-hero__button" href="/reserve/">空き状況を見る</a>
</div>
</section>
.drift-hero {
/* 地と粒の色はここで決める。script.js がこの 2 つを読んで WebGL へ渡すので、#rrggbb で書く */
--night: #111111;
--glow: #e0d3c0; /* 粒の光。グレージュを文字の色と半々に混ぜた、彩度の低い暖かい色 */
--ink: #eeece6;
--mute: #a8a6a0;
--gutter: clamp(24px, 7vw, 96px);
position: relative;
display: grid;
align-items: center;
min-height: min(90vh, 720px);
padding: 0 var(--gutter);
overflow: hidden;
color: var(--ink);
/* 読み込み中と、WebGL が使えないときの地 */
background-color: var(--night);
}
.drift-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.drift-hero__inner {
position: relative; /* 背景の上に載せる */
max-width: 32em;
}
.drift-hero__eyebrow {
margin: 0;
color: var(--mute);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.drift-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.drift-hero__lead {
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}
.drift-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background-color: var(--ink);
color: var(--night);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.drift-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import * as THREE from "three";
const DURATION = 12; // 粒がひと回りして、同じ高さに戻るまで(秒)
const COUNT = 900; // 粒の数。加算合成なので、多く・大きくしすぎると重なって白く飛ぶ
const RANGE = 7; // 縦にループする範囲(カメラからの距離は 5)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const vertexShader = /* glsl */ `
uniform float uTime;
uniform float uDuration;
uniform float uRange;
uniform float uScale;
attribute vec4 aSeed;
varying float vAlpha;
void main() {
// 上昇の速さは、1 ループで範囲の 1 倍か 2 倍。ひと回りでちょうど元の高さに戻る
float laps = 1.0 + floor(aSeed.w * 2.0);
float y = mod(aSeed.y * uRange + uTime / uDuration * uRange * laps, uRange) - uRange * 0.5;
float sway = sin(uTime / uDuration * 6.2831853 * laps + aSeed.x * 20.0) * 0.15;
vec3 pos = vec3((aSeed.x - 0.5) * 9.0 + sway, y, -3.0 + aSeed.z * 5.0);
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
// 奥の粒ほど小さく(遠近)。uScale は画面の大きさに合わせる倍率
gl_PointSize = (5.0 + 14.0 * aSeed.w) * uScale * (4.0 / -mv.z);
gl_Position = projectionMatrix * mv;
// 上下の端で消えてから折り返す
vAlpha = (0.3 + 0.7 * aSeed.z) * smoothstep(0.0, 0.8, uRange * 0.5 - abs(y));
}
`;
// 中心ほど明るい、ぼやけた丸
const fragmentShader = /* glsl */ `
uniform vec3 uColor;
varying float vAlpha;
void main() {
float d = length(gl_PointCoord - 0.5);
float glow = smoothstep(0.5, 0.0, d);
gl_FragColor = vec4(uColor * glow, glow * vAlpha);
}
`;
document.querySelectorAll(".drift-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".drift-hero__canvas"), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(css("--night")); // CSS の地とそろえる
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50); // 縦横比は resize で入れる
camera.position.z = 5;
// 粒ごとに 4 つの乱数(横の位置と揺れ・高さ・奥行きと濃さ・大きさと速さ)
const seeds = new Float32Array(COUNT * 4);
for (let i = 0; i < seeds.length; i++) seeds[i] = Math.random();
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3)); // 位置はシェーダーが決める
geometry.setAttribute("aSeed", new THREE.BufferAttribute(seeds, 4));
// THREE.Color は線形の値に直すので、シェーダーには hex を 255 で割った値をそのまま渡す
const hex = Number.parseInt(css("--glow").slice(1), 16);
const uniforms = { uTime: { value: 0 }, uDuration: { value: DURATION }, uRange: { value: RANGE }, uScale: { value: 1 },
uColor: { value: new THREE.Vector3(hex >> 16, (hex >> 8) & 255, hex & 255).divideScalar(255) } };
const material = new THREE.ShaderMaterial({
uniforms, vertexShader, fragmentShader, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false; // 頂点の位置が 0 のままなので、カリングで消えないようにする
scene.add(points);
const draw = (t) => {
uniforms.uTime.value = t % DURATION;
renderer.render(scene, camera);
};
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero;
camera.aspect = w / h;
camera.zoom = Math.max(1, camera.aspect / (16 / 9)); // 横長の画面では寄って、左右の端まで粒を行き渡らせる
camera.updateProjectionMatrix();
renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
uniforms.uScale.value = (renderer.getPixelRatio() * h * camera.zoom) / 720; // 高さ 720px の画面で 1
if (reduceMotion) draw(2.5); // 動きを減らす設定では、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();
geometry.dispose(); material.dispose(); renderer.dispose(); renderer.forceContextLoss();
};
});