デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
星の尾 1 本を四角形 1 枚にして、インスタンス描画(InstancedBufferGeometry)で 1600 本をまとめて描きます。星ごとの乱数(角度・筒の中の半径・奥行きの始まり・速さ)は最初に 1 度だけ決め、あとは頂点シェーダーが時間の式だけで位置を出します。
奥行きは「始まりの位置 + 時間 × 速さ」を範囲で割った余り(mod)です。速さを「範囲 × 整数 ÷ 周期」にしてあるので、6 秒のひと巡りでどの星も元の奥行きに戻り、ループの継ぎ目が出ません。星によって 1 周か 2 周進むので、速さにむらが出ます。生まれる奥と折り返す手前ではフェードさせ、星が急に現れたり消えたりするのを隠します。
WebGL の線は 1px より太くできないので、頭と尾をそれぞれ画面に投影し、画面の上で尾に直交する向きへ太さを付けています。太さを画素で決めているため、そのままでは高解像度の画面や背の高い画面で尾が細く見えます。描画の倍率と画面の高さから倍率を出し、高さ 720px の画面と同じ見え方にそろえます。
星の数・尾の長さ・筒の半径と、3 色の星と宇宙の地の色(CSS の変数)で調整します。星は淡いスレート・ラベンダーグレー・グレージュで、色相より明るさの差で見分けられるようにし、地は青みを抜いた墨色にしています。カメラをわずかに漂わせると、消失点がゆっくり動いて奥行きが増します。見出しを重ねる側には宇宙の色の幕を敷き、尾が文字をまたいでも読めるようにしています。宇宙・テック・新しいサービスの告知のヒーローに向いています。
コード
<section class="warp-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="warp-hero__canvas" aria-hidden="true"></canvas>
<div class="warp-hero__inner">
<p class="warp-hero__eyebrow">VERSION 2.0</p>
<h1 class="warp-hero__title">もっと速く、<br>もっと遠くへ。</h1>
<p class="warp-hero__lead">ここに説明文が入ります。</p>
<a class="warp-hero__button" href="/signup/">無料ではじめる</a>
</div>
</section>
.warp-hero {
/* 宇宙の地と星の色。script.js がこの変数を読んでシェーダーへ渡すので、#rrggbb で書く */
--space-center: #15191d; /* 消失点のまわり(青みを抜いた墨に、スレートをわずかに差す) */
--space-edge: #030405; /* 画面の端 */
--space-glow: #2c3640; /* 消失点のにじみ */
--star-a: #d3dde5; /* 7 割の星。淡いスレート(色相より明るさで 3 色を分ける) */
--star-b: #b0a5ca; /* 2 割の星。ラベンダーグレー */
--star-c: #d2b999; /* 残りの星。グレージュ */
--ink: #ffffff;
--soft: #eeece6;
--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: radial-gradient(ellipse at center, var(--space-center), var(--space-edge) 70%);
}
.warp-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
/* 文字の側だけ、宇宙の色の幕で星を沈める。尾が文字をまたぐと、本文が読みにくい */
.warp-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, color-mix(in srgb, var(--space-edge) 82%, transparent), color-mix(in srgb, var(--space-edge) 55%, transparent) 45%, transparent 72%);
pointer-events: none;
}
.warp-hero__inner {
position: relative;
z-index: 1; /* 幕(::after)より前に出す */
max-width: 32em;
}
.warp-hero__eyebrow {
margin: 0;
color: var(--soft);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.warp-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
text-wrap: balance; /* 2 行に折り返すとき、行の長さをそろえる */
}
.warp-hero__lead {
margin: 24px 0 0;
color: var(--soft);
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
line-break: strict; /* 小さい「ョ」や「ー」から行を始めない */
}
.warp-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(--space-edge);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.warp-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 幅が狭いと、横に流す幕では文字の下に尾が濃く残る。一様にかける */
@media (max-width: 720px) {
.warp-hero::after { background: color-mix(in srgb, var(--space-edge) 66%, transparent); }
}
import * as THREE from "three";
const DURATION = 6; // 星がひと巡りして、同じ並びに戻るまで(秒)
const COUNT = 1600; // 星(尾)の数
const FAR = -60, NEAR = -1.2; // 星が生まれる奥行きと、奥へ戻す手前の位置
const TRAIL = 0.12, SPREAD = 9; // 尾の長さ(何秒ぶんの移動を尾に引くか)と、星を並べる筒の半径
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const streakVertex = /* glsl */ `
uniform float uTime, uDuration, uFar, uRange, uTrail, uSpread, uScale;
uniform vec2 uResolution; uniform vec3 uStarA, uStarB, uStarC;
attribute vec4 aSeed;
varying float vAlong, vSide, vAlpha; varying vec3 vColor;
void main() {
// 1 周期で範囲の 1 倍か 2 倍進む。整数倍なので、周期の終わりで元の奥行きに戻る
float laps = 1.0 + floor(aSeed.w * 2.0), speed = uRange * laps / uDuration;
float travel = mod(aSeed.z * uRange + uTime * speed, uRange), nearness = travel / uRange;
float angle = aSeed.x * 6.2831853, radius = mix(0.5, uSpread, sqrt(aSeed.y));
vec3 head = vec3(cos(angle) * radius, sin(angle) * radius, uFar + travel), tail = head - vec3(0.0, 0.0, speed * uTrail);
// 頭と尾をそれぞれ投影し、画面上で尾に直交する向きへ太さを付ける(WebGL の線は 1px より太くできない)
vec4 h = projectionMatrix * modelViewMatrix * vec4(head, 1.0);
vec4 tl = projectionMatrix * modelViewMatrix * vec4(tail, 1.0);
vec2 dir = (h.xy / h.w - tl.xy / tl.w) * uResolution;
float len = length(dir); dir = len > 0.001 ? dir / len : vec2(0.0, 1.0);
vec2 normal = vec2(-dir.y, dir.x);
vec4 pos = mix(h, tl, position.x); // position.x: 0 = 頭, 1 = 尾 / position.y: -1..1 = 太さの向き
float halfWidth = mix(0.9, 2.2, nearness * nearness) * uScale; // 手前ほど太く(高さ 720px の画面での px)
pos.xy += normal * position.y * halfWidth * 2.0 / uResolution * pos.w; gl_Position = pos;
vAlong = position.x; vSide = position.y;
// 奥で生まれるときはフェードイン、折り返す直前にフェードアウト
vAlpha = smoothstep(0.0, 0.3, nearness) * (1.0 - smoothstep(0.9, 1.0, nearness)) * (0.45 + 0.55 * aSeed.w);
float tone = fract(aSeed.x * 17.0 + aSeed.y * 31.0);
vColor = tone < 0.7 ? uStarA : (tone < 0.88 ? uStarB : uStarC);
}
`;
const streakFragment = /* glsl */ `
varying float vAlong, vSide, vAlpha; varying vec3 vColor;
void main() {
float side = 1.0 - abs(vSide), fade = pow(1.0 - vAlong, 1.5); // 頭が明るく、尾へ向かって消える
gl_FragColor = vec4(vColor, side * fade * vAlpha);
}
`;
const backgroundVertex = `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const backgroundFragment = /* glsl */ `
uniform vec2 uResolution; uniform vec3 uCenter, uEdge, uGlow; varying vec2 vUv;
void main() { // 奥(消失点)がわずかに明るい、墨色の宇宙
float r = length((vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0));
vec3 color = mix(uCenter, uEdge, smoothstep(0.0, 0.8, r));
color += uGlow * exp(-r * r / 0.02) * 0.5;
color += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0; // 8bit の段差(バンディング)を散らす
gl_FragColor = vec4(color, 1.0);
}
`;
document.querySelectorAll(".warp-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
// 色は CSS 変数から読む。THREE.Color は線形に直すので、hex を 255 で割った値をそのまま渡す
const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return { value: new THREE.Vector3(hex >> 16, (hex >> 8) & 255, hex & 255).divideScalar(255) }; };
const resolution = { value: new THREE.Vector2(1, 1) }; // 2 つのシェーダーで共有する(実際の画素の数)
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".warp-hero__canvas"), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); // 縦横比は resize で入れる
const background = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ vertexShader: backgroundVertex, fragmentShader: backgroundFragment,
uniforms: { uResolution: resolution, uCenter: rgb("--space-center"), uEdge: rgb("--space-edge"), uGlow: rgb("--space-glow") }, depthTest: false, depthWrite: false }));
background.frustumCulled = false; background.renderOrder = -1; // カメラに関係なく、画面いっぱいに先に描く
// 尾 1 本 = 四角形 1 枚を、インスタンスで COUNT 本。星ごとの乱数は最初に決め、あとは時間の式だけで動かす
const geometry = new THREE.InstancedBufferGeometry().setIndex([0, 2, 1, 1, 2, 3]);
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0]), 3));
geometry.setAttribute("aSeed", new THREE.InstancedBufferAttribute(Float32Array.from({ length: COUNT * 4 }, () => Math.random()), 4));
geometry.instanceCount = COUNT;
const uniforms = { uTime: { value: 0 }, uDuration: { value: DURATION }, uFar: { value: FAR }, uRange: { value: NEAR - FAR }, uTrail: { value: TRAIL }, uSpread: { value: SPREAD },
uScale: { value: 1 }, uResolution: resolution, uStarA: rgb("--star-a"), uStarB: rgb("--star-b"), uStarC: rgb("--star-c") };
const streaks = new THREE.Mesh(geometry, new THREE.ShaderMaterial({ uniforms, vertexShader: streakVertex, fragmentShader: streakFragment,
transparent: true, depthWrite: false, side: THREE.DoubleSide, blending: THREE.AdditiveBlending }));
streaks.frustumCulled = false; // 位置はシェーダーで決めるので、視錐台カリングで消えないようにする
scene.add(background, streaks);
const draw = (t) => {
const a = (t / DURATION) * Math.PI * 2;
uniforms.uTime.value = t % DURATION;
camera.position.set(0.35 * Math.sin(a), 0.2 * Math.sin(a * 2), 0); // 消失点をゆっくり漂わせる(周波数は整数)
renderer.render(scene, camera);
};
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
uniforms.uScale.value = renderer.getDrawingBufferSize(resolution.value).y / 720; // 尾の太さを、高さ 720px の画面と同じ見え方にそろえる
if (reduceMotion) draw(5.75); // 動きを減らす設定では、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, streaks.material, background.geometry, background.material, renderer].forEach((item) => item.dispose()); renderer.forceContextLoss();
};
});