デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 使うもの
- three/addons/postprocessing/EffectComposer.js・three/addons/postprocessing/RenderPass.js・three/addons/postprocessing/UnrealBloomPass.js・three/addons/postprocessing/OutputPass.js
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
暗い壁の前で、円形や角を丸めた多角形のネオン管が光をにじませ、ときおり何本かが細かく点滅します。にじみは、ブルームというポストエフェクトで作っています。管の色を1より大きい値(HDR)で持たせ、EffectComposer で RenderPass、UnrealBloomPass、OutputPass の順に通すと、明るい所だけが周りへにじみます。トーンマッピングは最後の OutputPass にまかせているので、明るすぎる管の芯はなめらかに白へ寄ります。
にじませるかどうかのしきい値は、高めにすれば管だけを狙えそうに思えます。ところが、にじみはごく狭い幅で急に切り替わるので、0.8にしたときは、管が明るさのゆらぎで暗くなるたびにスイッチを切ったように消えました。管がいちばん暗くなるときより十分に低い、0.3まで下げてあります。
色ごとのにじみ方もそろえました。ローズカラーやラベンダーのように輝度の低い色はにじみが弱くなるので、色の値を「最大の成分と輝度の平均」で割って明るさをならしています。輝度だけで割ると赤や青の成分が強くなりすぎ、画面全体がにじみます。霞は広がるほど色が薄まってグレーがかるので、半径を低めにし、いちばん大きい2段のぼかしも弱めて、管と管のあいだに暗がりを残しています。
奥の壁は、管と同じ色の点光源を壁のすぐ手前に置いて照らし、照り返しを図形の真後ろに集めています。カメラをわずかに揺らしているので、図形と照り返しのあいだに視差が生まれます。点滅は、どの管をループのどの区間でまたたかせるかを先に決めておき、その区間だけ1/30秒ごとに明るさを切り替えるしくみです。
ブルームは重いので、にじみは解像度を半分に落として描いています。そのぶん最初の描画先は4回のマルチサンプルにして、管の縁のギザギザを消しました。
コード
<section class="neon-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="neon-hero__canvas" aria-hidden="true"></canvas>
<div class="neon-hero__inner">
<p class="neon-hero__eyebrow">Live</p>
<h1 class="neon-hero__title">今夜も、<br>ネオンが灯る。</h1>
<p class="neon-hero__lead">ここに説明文が入ります。</p>
<a class="neon-hero__button" href="/schedule/">今月の出演を見る</a>
</div>
</section>
.neon-hero {
/* 管と壁の色はここで決める。script.js がこの変数を読んでシーンへ渡す */
--neon-rose: #e48b85;
--neon-slate: #87b4d5;
--neon-greige: #e4bb87;
--neon-sage: #98d093;
--neon-lavender: #aa95da;
--wall: #1c2024; /* 奥の壁 */
--sky: #4a5b66; /* 上からのかすかな環境の光 */
--night: #090a0c;
--ink: #ffffff;
--soft: #eeece6; /* リード文 */
--mute: #dedcd6; /* 見出しに添える英大文字のラベル */
--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);
}
.neon-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
/* 文字の側だけ背景を暗くする。ネオンは明るいので、これが無いと読めない */
.neon-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, rgba(9, 10, 12, 0.92), rgba(9, 10, 12, 0.6) 42%, transparent 70%);
}
.neon-hero__inner {
position: relative;
z-index: 1; /* 暗くする層(::after)より手前に表示する */
max-width: 32em;
text-shadow: 0 2px 24px rgba(9, 10, 12, 0.85);
}
.neon-hero__eyebrow {
margin: 0;
color: var(--mute);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.neon-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.neon-hero__lead {
margin: 24px 0 0;
color: var(--soft);
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す */
}
.neon-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;
}
.neon-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 幅が狭いと、横向きのグラデーションでは画面ぜんぶが暗くなる。一様に暗くして、管は透かして見せる */
@media (max-width: 720px) {
.neon-hero::after { background: rgba(9, 10, 12, 0.66); }
}
import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
const DURATION = 8; // ひと回りして同じ絵に戻るまで(秒)
const TUBE = 0.05; // 管の太さ
const GLOW = 1.8; // 管の明るさ。1 を超えた分がにじみになる(くすんだ色は輝度が高いので、上げすぎると霞む)
const LIGHT = 1.1; // 壁を照らす点光源の強さ
const BLOOM_SCALE = 0.5; // にじみを描く解像度の比。下げるほど軽い
const FLICKER = { index: 4, until: 0.6, low: 0.3 }; // 何番目の管が・何秒まで・どこまで暗くなるか
const TUBES = [ // sides は 0 が円で 3 以上が多角形。spin は 1 ループの回転数(整数にするとループがつながる)
{ sides: 0, color: "--neon-rose", size: 1.15, x: -3.7, y: 1.25, z: 0, spin: 0, tilt: 0.45 },
{ sides: 3, color: "--neon-slate", size: 1.3, x: 3.8, y: 1, z: -0.4, spin: 1, tilt: 0.25 },
{ sides: 4, color: "--neon-greige", size: 0.9, x: -2.5, y: -1.75, z: 0.3, spin: -1, tilt: 0.3 },
{ sides: 6, color: "--neon-sage", size: 1.4, x: 2.6, y: -1.85, z: 0.2, spin: 0, tilt: 0.35 },
{ sides: 0, color: "--neon-lavender", size: 0.42, x: 0.9, y: 2.25, z: -0.6, spin: 0, tilt: 0.6 },
];
const hash = (n) => Math.abs(Math.sin(n * 127.1 + 311.7) * 43758.5453) % 1; // 0..1 の擬似乱数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function geometryOf({ sides, size }) {
if (sides === 0) return new THREE.TorusGeometry(size, TUBE, 12, 128);
const at = (i) => { const a = Math.PI / 2 + (i / sides) * Math.PI * 2; return new THREE.Vector3(Math.cos(a) * size, Math.sin(a) * size, 0); };
// 角の手前と先に点を足してカトマル–ロムで結ぶと、辺は直線のまま角だけが丸くなる
const points = Array.from({ length: sides }, (_, i) => [at(i).lerp(at(i - 1), 0.1), at(i).lerp(at(i + 1), 0.1)]).flat();
return new THREE.TubeGeometry(new THREE.CatmullRomCurve3(points, true, "centripetal"), 240, TUBE, 12, true);
}
document.querySelectorAll(".neon-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".neon-hero__canvas") });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping; // 最後の OutputPass がかける。1 を超えた芯が白へ寄る
const scene = new THREE.Scene(); scene.background = new THREE.Color(css("--night")); // CSS の背景色とそろえる
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100); // 縦横比は resize で入れる
const wall = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), new THREE.MeshStandardMaterial({ color: css("--wall"), roughness: 0.92 }));
wall.position.z = -2.2;
scene.add(wall, new THREE.HemisphereLight(css("--sky"), "#000000", 0.25));
const tubes = TUBES.map((def) => {
const base = new THREE.Color(css(def.color)); // にじみは輝度で判定されるので、暗い色(ローズカラー・ラベンダー)は持ち上げてそろえる
const lum = 0.2126 * base.r + 0.7152 * base.g + 0.0722 * base.b;
base.multiplyScalar(1 / (0.5 * Math.max(base.r, base.g, base.b) + 0.5 * lum));
const mesh = new THREE.Mesh(geometryOf(def), new THREE.MeshBasicMaterial());
mesh.position.set(def.x, def.y, def.z);
const light = new THREE.PointLight(css(def.color), LIGHT, 0, 2); // 壁のすぐ手前に置き、照り返しを管の真後ろに集める
light.position.set(def.x, def.y, -1.5);
scene.add(mesh, light);
return { def, base, mesh, light };
});
// 管の縁がぎざぎざにならないよう、最初の描画先はマルチサンプルにする
const composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 }));
// しきい値は輝度で見る。管が明るさのゆらぎで暗くなるところに近いと、にじみがスイッチのように点いたり消えたりする
const bloom = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.8, 0.08, 0.3); // 強さ・半径・しきい値
bloom.bloomTintColors[3].setScalar(0.4); bloom.bloomTintColors[4].setScalar(0.2); // 大きいぼかしを弱め、管のあいだの暗がりを残す
composer.addPass(new RenderPass(scene, camera));
composer.addPass(bloom);
composer.addPass(new OutputPass());
const draw = (t) => {
const a = (t / DURATION) * Math.PI * 2;
tubes.forEach(({ def, base, mesh, light }, i) => {
mesh.rotation.set(def.tilt * Math.sin(a + i), def.tilt * Math.cos(a + i * 2), a * def.spin);
mesh.position.y = def.y + 0.12 * Math.sin(a * 2 + i * 1.3);
let b = 0.93 + 0.07 * Math.sin(a * ((i % 3) + 1) + i * 1.7); // 管の明るさのゆらぎ
if (i === FLICKER.index && t % DURATION < FLICKER.until && hash(Math.floor(t * 30)) < 0.45) b = FLICKER.low; // またたき
mesh.material.color.copy(base).multiplyScalar(GLOW * b);
light.intensity = LIGHT * b; light.position.y = mesh.position.y;
});
camera.position.set(0.45 * Math.sin(a), 0.25 * Math.sin(a * 2), 9); camera.lookAt(0, 0, 0); // 壁の照り返しとの視差
composer.render();
};
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero;
camera.aspect = w / h;
camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); // 縦長の画面では引いて、図形を収める
camera.updateProjectionMatrix();
renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
composer.setSize(w, h);
const px = renderer.getPixelRatio() * BLOOM_SCALE;
bloom.setSize(w * px, h * px); // composer が全パスをそろえたあとで、にじみだけ解像度を落とす
if (reduceMotion) draw(2.4); // 動きを減らす設定では、管が全部点いた 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();
for (const { mesh } of tubes) { mesh.geometry.dispose(); mesh.material.dispose(); }
wall.geometry.dispose(); wall.material.dispose(); composer.dispose(); renderer.dispose();
renderer.forceContextLoss();
};
});