React Three Fiberで作る生成的WebGLランディング:プロダクトサイト自体がプロダクトになるとき#
多くのアプリのランディングページは似たり寄ったりだ。スマホの枠に入ったスクリーンショット、3つの機能カード、「App Storeでダウンロード」ボタン。これで一応機能はするが、記憶には残らない。iOSアプリMeteoHealthのマーケティングサイトmeteohealth.proを作ったとき、私は別の仮説を試したかった。サイト自体がプロダクトの「説明」ではなく、プロダクトの「実演」になったらどうなるか、と。
アイデア自体はシンプルだが、実装は簡単ではない。ヒーローセクションには生成的な「気圧場」が存在し、選択した都市の実際の気象データにリアルタイムで反応する波状のパターンが React Three Fiber 上に描かれる。気圧の低下幅が大きくなるほど、パターンは密になり、揺らぎを増す。天候が穏やかなときは、フィールドは均一に「呼吸」する。ここには意味のないノイズは一切ない。シェーダーのすべての変形は、サーバーから届く実際の数値に対応している。
本稿では、これがどう構築されているかを技術的に見ていく。React Three Fiber のアーキテクチャ、シェーダー自体の分解、そしてフラッグシップの iPhone でも、prefers-reduced-motion を有効にした安価な Android でも同じように快適でなければならない劣化システムまでを扱う。
プロダクトサイト自体がプロダクトになるとき#
ランディングページの WebGL シーンを「単なるきれいな背景」として扱いたくなる誘惑がある。しかし、これはこの手の実験を台無しにしがちな典型的な誤りだ。装飾的なアニメーションはテキストと注目を奪い合い、LCP を悪化させ、そのページが本来売っているものとは何の関係もない。
もう一つのアプローチは、ビジュアライゼーションをプロダクトの機能の文字通りの証拠にすることだ。MeteoHealth は大気圧の低下と体調の関係を分析する。したがって、ヒーローシーンは抽象的なパターンではなく、その気圧の低下そのものの直接的な表現になる。気象サーバーからの pressureDelta24h が、フィールド全体を動かす唯一の「神経」になる。これは機能のイラストではなく、アプリが実行しているのと同じロジックの、数字ではなく形として見える動作するスライスなのだ。
ここから一つのアーキテクチャ上のルールが導かれる。シーンには装飾的な変数があってはならない。シェーダーのパラメータが実データに対応していないなら、それは存在すべきではない。この制約は、どんなクリエイティブブリーフよりもデザインを厳しく律する。
生成的フィールドの解剖:R3F、Three.js、ノイズの代わりにデータ#
React Three Fiber は Three.js の代替ではなく、その上に乗る宣言的レイヤーだ。シーンは JSX ツリーとして記述され、レンダーループ、リソースの破棄、React の state との同期は R3F のリコンサイラが引き受ける。生成的でデータ駆動のグラフィックスにとって、これが重要な意味を持つ。「データ層」(React state、フェッチ、気象データをシェーダーパラメータへマッピングする処理)と「レンダー層」(useFrame、uniform への直接的な変更)の間に、コンポーネントとしての明確な境界ができるのだ。
meteohealth.pro のシーンは、重なり合う2つのレイヤーで構成されている。
- 気圧の等圧線 — 地形図のような同心円状の波状の線を描くフラグメントシェーダーを持つ、フルスクリーンのプレーン。3本に1本はアクセント(緑)で、残りは低アルファの「インク」色。
- GPU パーティクル — 同じ等圧線に沿って、フィールドの勾配に対して90°回転した細い軌跡(実際の気象学における地衡風を模している)。CPU 側のパーティクルごとの JS ループを一切使わず、GPU 上で完結する ping-pong FBO によってシミュレーションされる。
重要なアーキテクチャ上の決定は、任意の時間ではなく、両方のシステムを同じ一つのスカラー値「フィールドの揺らぎ」から駆動することだ。以下は、React Three Fiber でこれがどう組み立てられているかを簡略化しつつも本質を保ったスケッチである。
// components/field/PressureField.tsx
'use client'
import { Suspense, lazy, useEffect, useState } from 'react'
import { useReducedMotion } from '@/lib/hooks/use-reduced-motion'
const FieldScene = lazy(() => import('./FieldScene'))
type Tier = 'A' | 'B' | 'C'
function detectTier(prefersReducedMotion: boolean): Tier {
if (prefersReducedMotion) return 'C'
if (!('WebGLRenderingContext' in window)) return 'C'
const memory = (navigator as any).deviceMemory ?? 4
const isTouchHeavy = navigator.maxTouchPoints > 4
if (memory <= 2 || isTouchHeavy) return 'B'
return 'A'
}
export function PressureField({ posterSrc }: { posterSrc: string }) {
const prefersReducedMotion = useReducedMotion()
const [tier, setTier] = useState<Tier | null>(null)
useEffect(() => {
setTier(detectTier(prefersReducedMotion))
}, [prefersReducedMotion])
if (tier === null || tier === 'C') {
return <img src={posterSrc} alt="" aria-hidden className="field-poster" />
}
return (
<Suspense fallback={<img src={posterSrc} alt="" aria-hidden className="field-poster" />}>
<FieldScene tier={tier} />
</Suspense>
)
}キャンバスはティアが決定するまで表示されないことに注目してほしい。ポスターは「念のため」のフォールバックではなく、画面の正当な最初の状態そのものだ。LCP を担うのは WebGL キャンバスではなく、このポスターである。
コード:Canvas からシェーダーへ#
次は、@react-three/drei の shaderMaterial を通じて気象データとシェーダーを結びつけるマテリアルコンポーネントだ。気圧の低下を「フィールドの揺らぎ」に変換する式は、意図的に非線形になっている。7hPa の低下は目に見える反応を生むが急激ではなく、その後カーブは飽和し、極端な値でもフィールドが視覚的なカオスに陥らないようにする。
// components/field/FieldMaterial.tsx
import { shaderMaterial } from '@react-three/drei'
import { extend, useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import vertexShader from './field.vert.glsl'
import fragmentShader from './field.frag.glsl'
export const FieldMaterial = shaderMaterial(
{
uTime: 0,
uAgitation: 0, // 0..~0.97、24時間の気圧低下で増加
uIsoDensity: 14,
uInk: [0.06, 0.07, 0.07],
uGreen: [0.12, 0.48, 0.35],
},
vertexShader,
fragmentShader
)
extend({ FieldMaterial })
export function agitationFromPressureDelta(deltaHpa24h: number): number {
// 気圧の低下がフィールドを動かす。上昇または安定していれば穏やかなまま
const drop = Math.max(0, -deltaHpa24h)
return 1 - Math.exp(-drop / 7)
}
export function FieldMesh({ pressureDelta24h }: { pressureDelta24h: number }) {
const materialRef = useRef<any>(null)
const targetAgitation = agitationFromPressureDelta(pressureDelta24h)
useFrame((state, delta) => {
if (!materialRef.current) return
materialRef.current.uTime = state.clock.elapsedTime
// 目標値へなめらかにlerp — データ更新時にカクつかない
materialRef.current.uAgitation +=
(targetAgitation - materialRef.current.uAgitation) * Math.min(1, delta * 2)
})
return (
<mesh scale={[2, 2, 1]}>
<planeGeometry args={[1, 1]} />
{/* @ts-expect-error – extended material via shaderMaterial */}
<fieldMaterial ref={materialRef} transparent />
</mesh>
)
}そして、実際に等圧線を描くロジックを簡略化したフラグメントシェーダー本体はこうなる。
// field.frag.glsl
uniform float uTime;
uniform float uAgitation; // 0(凪)..~0.97(強い気圧低下)
uniform float uIsoDensity; // 等圧線の基準密度
uniform vec3 uInk;
uniform vec3 uGreen;
varying vec2 vUv;
float snoise(vec2 v); // 2Dシンプレックスノイズ、実装は省略
void main() {
vec2 uv = vUv * 2.0 - 1.0;
// フィールドは2つのノイズオクターブで"呼吸"する。2つ目は揺らぎとともに
// 強まり、穏やかな天候ではほとんど目立たない
float breathing =
snoise(uv * 1.6 + uTime * 0.075) * 0.5 +
snoise(uv * 3.7 - uTime * 0.028) * (0.2 + uAgitation * 0.3);
float density = uIsoDensity * (0.82 + uAgitation * 0.55);
float field = length(uv) * density + breathing * (0.055 + uAgitation * 0.05);
// 等圧線:フィールドの剰余における最も近い"線"までの距離
float line = abs(fract(field) - 0.5) * 2.0;
float iso = 1.0 - smoothstep(0.0, fwidth(field) * 1.5, line);
// 3本に1本がアクセント、残りは"インク"トーン
bool isAccent = mod(floor(field), 3.0) < 1.0;
vec3 color = isAccent ? uGreen : uInk;
float alpha = iso * (isAccent ? 0.55 : 0.24);
gl_FragColor = vec4(color, alpha);
}線のアンチエイリアスに使われている fwidth(field) に注目してほしい。これがあることで、シェーダーは「ギザギザのモザイク」から、どんな解像度・devicePixelRatio でもきれいな細い線へと変わる。追加のポストプロセスは一切必要ない。
Canvas 2D、SVG、それとも WebGL——どれを選ぶべきか#
シェーダーを一行でも書く前に、そもそも WebGL が本当に必要なのかを正直に問う価値がある。ほとんどの「動きのある」ランディングページには、Canvas 2D、あるいは CSS アニメーション付きの SVG で十分だ。WebGL が正当化されるのは、次の3条件が同時に成り立つときだけだ。多数の動く要素(数百〜数千のパーティクル)が必要であること、requestAnimationFrame でのサブピクセル単位の滑らかさが必要であること、そしてグラフィックが DOM を再構築せずに絶えず変化するデータへ反応する必要があること。
| 基準 | Canvas 2D | SVG | WebGL(R3F / Three.js) |
|---|---|---|---|
| アニメーション対象の数 | 数百、それ以上は FPS が落ちる | 数十——DOM は高コスト | GPU上で数千パーティクル |
| ライブデータへの反応 | 手動での再描画が必要 | シンプル(属性/CSS変数) | シンプル(シェーダーuniform) |
| アクセシビリティ / SEO | スクリーンリーダーには見えないコンテンツ | ネイティブにアクセシブル、インデックス可能 | テキストのHTML複製が必要 |
| 学習コスト | 低い | 非常に低い | 高い(シェーダー、バッファ) |
| 低スペック端末での劣化対応 | 良好 | 優秀 | 明示的なフォールバックティアが必要 |
| 典型的なユースケース | グラフ、シンプルなパーティクル | アイコン、図表、dash-draw図式 | 生成的シーン、フィールドシミュレーション |
meteohealth.pro にとって WebGL を選ぶことは意図的なトレードオフだった。このシーンはサイト全体で唯一の「重い」要素であり、ブログ・機能ページ・法的ページといったコンテンツページの JS 予算がおよそ 100KB に収まっているのは、まさに three チャンクがランディングページでのみ、動的インポートで、しかも idle の後にだけ読み込まれるからだ。
妥協のない劣化:ティア、ポスター、prefers-reduced-motion#
生成的なランディングページで最もよくある失敗は、開発者の MacBook Pro だけでテストすることだ。実際の訪問者は、3年前の安価な Android から、省電力モードで、prefers-reduced-motion を有効にした状態でやってくる。理想的な条件でしか良く見えないシステムは、本番運用の準備ができていない。
meteohealth.pro では、劣化は単一の「アニメーションをオフにする」フラグではなく、3つの明示的なティアとして構築されている。
- Tier A — フルシーン:両方のノイズオクターブ、密な等圧線、フルセットのパーティクル、
devicePixelRatioは最大1.75まで。 - Tier B — 簡略化されたシーン:描画するパーティクルを減らし(
drawRange)、ノイズオクターブは1つ、devicePixelRatioは1.25までに制限。 - Tier C — 実際のシーンから撮影された静的な AVIF ポスター。OG 画像のベースとしても使われ、単なる使い捨てのプレースホルダーではなく、プロダクトの正直な一コマになっている。
ティアの選択は一度きりのものではない。初期のヒューリスティック(deviceMemory、maxTouchPoints)に加えて、アプリは最初の20フレームの実際のフレーム時間を計測し、レンダリングが継続的に予算を超える場合はティアを下げる。同じことがセッション中もリアルタイムで起こる。120フレームの移動窓を使い、平均フレーム時間が約33msを超えると、GPUを酷使してスマホを過熱させ続けるのではなく、シーンは一段階下のティアへ落ちる。
prefers-reduced-motion は別扱いで、優先度が高い。これは「もう一つのパフォーマンスシグナル」ではなく、明示的なユーザーの意思決定だからだ。このフラグが有効な場合、Three.js、GSAP、Lenis はそもそも読み込まれない。アニメーションが無効になるだけでなく、関連する JS がネットワークにリクエストされることすらない。キャンバスは、最初のフレームが準備できた後、0.7秒のクロスフェードでポスターの上に現れる。この切り替え自体が、目立つ UI のジャンプに見えてはならない。
デザインシステムという契約:GSAP、Lenis、そして Liquid Signal#
WebGL シーンは、たとえ最も目を引く要素であっても、あくまで一つの要素に過ぎない。それは「Liquid Signal」という独自のデザインシステムの中に存在している。落ち着いたエディトリアルなタイポグラフィと、PRESSURE_Δ24H、TIME_CODE、RISK のようなモノスペースの読み取り値——装飾的な数字ではなく、本物の測定値——を並べる HUD プリミティブの組み合わせだ。サイトは広告バナーではなく、校正済みの計器のように振る舞うべきだという考え方であり、生成的フィールドを動かしている数値を含め、画面上のすべての数字は本物である。
GSAP と Lenis はランディングページでのみ読み込まれ、行うことはちょうど2つだけだ。初回訪問時の短いロードシーケンス(線が描かれ、HUD ラベルが「タイプされる」)と、残りのコンテンツに対するスクロールリビール(translateY とフェード、約0.6秒、power2.out)。ブログ、機能ページ、法的ページといったコンテンツページでは、アニメーションが全くないか、CSS のみのトランジションにとどまる。これらのページの JS 予算は、ランディングページで何が起きているかに左右されるべきではないからだ。
結果として、「生成的」なプロジェクトにしては驚くほど厳格なものになった。シグネチャ要素が大胆であればあるほど、その周りのすべてはより規律正しくなければならない。芸術的な大胆さのすべてを一つのシーンが担い、残りは静かで、予測可能で、決してちらつかない。
まとめ#
生成的な WebGL シーンがランディングページにふさわしい存在になるのは、それが装飾ではなくプロダクトのデータを直接表現しているときだけであり、かつ劣化のエンジニアリングがシェーダー自体と同じくらい練られているときだけだ。React Three Fiber はデータとレンダーの間に便利な宣言的境界を与えてくれるが、規律は細部に宿る。一握りのマジックナンバーの代わりに一つの揺らぎパラメータを、単一のアニメーションのオン/オフフラグの代わりに明示的なティアを、そしてパフォーマンス設定ではなくユーザーの意思決定として扱われる prefers-reduced-motion を。
実際の例は meteohealth.pro、すなわち MeteoHealth のマーケティングサイトで見ることができる。prefers-reduced-motion を有効にしたスマホで開いてみてほしい。ポスターが一時的な代替物ではなく、意図的なデザイン上の選択として読めるはずだ。



