本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ヒーロー

ヒーローの背景でネオン管の図形が発光してまたたく

デモ

仕様

用途
ヒーロー
動作
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
公開日

しくみと調整

ネオン管は、TorusGeometryTubeGeometry に、光を受けない素材(MeshBasicMaterial)を当てて作ります。多角形の管は、角の手前と先に点を足した曲線でなぞり、角だけを丸めます。色を 1 より大きい値(HDR)にしておき、EffectComposer で RenderPass → UnrealBloomPass → OutputPass の順に通すと、明るいところだけがにじみます。トーンマッピングは最後のパスがかけるので、管の芯がなめらかに白へ寄ります。

ピンクや紫のように輝度の低い色は、にじみが弱くなります。そこで、色の最大成分と輝度の平均で割って、明るさをそろえます。輝度だけで割ると、赤や青が強くなりすぎて、画面全体がにじみます。にじみの半径を上げると、大きいぼかしの段が効いて画面全体が色の霞になるので、半径は低めにし、いちばん大きい 2 段は色を弱めます。

にじませるかどうかのしきい値は輝度で判定され、0.01 ほどの幅で急に切り替わります。しきい値を 0.8 にすると、管がうなりで暗くなるたびに、にじみがスイッチのように消えました。管がいちばん暗くなるときの明るさより十分に下げる必要があるので、このデモでは 0.3 にしています。

壁は図形と同じ色の点光源で照らし、光源を壁のすぐ手前に置いて、照り返しを図形の真後ろに集めます。カメラをわずかに揺らすと、図形と壁の照り返しのあいだに視差が出ます。またたきは、どの管が・いつから・何秒またたくかを決めておき、その区間だけ 1/30 秒ごとに点滅させます。

にじみは解像度を半分にして描き、最初の描画先を 4 回のマルチサンプルにして、管の縁のぎざぎざを消します。終わるときは、コンポーザー・各パス・ジオメトリ・マテリアルを解放します。にじむ色の面で見せる背景と違い、形のある発光体とポストエフェクトで作る背景です。ナイトイベント・音楽・ゲームのヒーローに向いています。

コード

HTML
<section class="neon-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="neon-hero__canvas" aria-hidden="true"></canvas>
  <div class="neon-hero__inner">
    <p class="neon-hero__eyebrow">Live & Late — Every Friday</p>
    <h1 class="neon-hero__title">Loud nights, bright signs.</h1>
    <p class="neon-hero__lead">福岡・大名の小さなライブハウス。今月の出演、チケット、フロアの様子をまとめています。</p>
    <a class="neon-hero__button" href="/schedule/">今月の出演を見る</a>
  </div>
</section>
CSS
.neon-hero {
  /* 管と壁の色はここで決める。script.js がこの変数を読んでシーンへ渡す */
  --neon-pink: #ff2e97;
  --neon-cyan: #2ee6ff;
  --neon-amber: #ffb13b;
  --neon-lime: #a6ff4d;
  --neon-violet: #9a6bff;
  --wall: #241832;
  --night: #07040c;
  --paper: #f7f1ff;
  --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(--paper);
  /* 読み込み中と、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(7, 4, 12, 0.92), rgba(7, 4, 12, 0.6) 42%, transparent 70%);
}

.neon-hero__inner {
  position: relative;
  z-index: 1; /* 幕(::after)より前に出す */
  max-width: 32em;
  text-shadow: 0 2px 24px rgba(7, 4, 12, 0.85);
}

.neon-hero__eyebrow {
  margin: 0;
  color: var(--neon-cyan);
  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;
  font-size: 18px;
  line-height: 1.8;
}

.neon-hero__button {
  display: inline-grid;
  place-items: center;
  height: 48px;
  margin-top: 32px;
  padding: 0 32px;
  border-radius: 999px;
  background-color: var(--paper);
  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(--neon-cyan); outline-offset: 4px; }

/* 幅が狭いと、横に流す幕では画面ぜんぶが暗くなる。一様に落として、管は透かして見せる */
@media (max-width: 720px) {
  .neon-hero::after { background: rgba(7, 4, 12, 0.66); }
}
JavaScript
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 = 2.2; // 管の明るさ。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-pink", size: 1.15, x: -3.7, y: 1.25, z: 0, spin: 0, tilt: 0.45 },
  { sides: 3, color: "--neon-cyan", size: 1.3, x: 3.8, y: 1, z: -0.4, spin: 1, tilt: 0.25 },
  { sides: 4, color: "--neon-amber", size: 0.9, x: -2.5, y: -1.75, z: 0.3, spin: -1, tilt: 0.3 },
  { sides: 6, color: "--neon-lime", size: 1.4, x: 2.6, y: -1.85, z: 0.2, spin: 0, tilt: 0.35 },
  { sides: 0, color: "--neon-violet", 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("#6a4a8a", "#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.7); bloom.bloomTintColors[4].setScalar(0.45); // 大きいぼかしを弱め、色の霞を抑える
  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();
  };
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。