React Three Fiber生成式WebGL落地页:当产品官网本身就是产品#
大多数应用落地页看起来都差不多:手机边框里的一张截图、三张功能卡片、一个「在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的reconciler处理。对生成式、数据驱动的图形而言,这带来了最重要的东西——「数据层」(React state、数据拉取、把天气映射成着色器参数)和「渲染层」(useFrame、直接修改uniform)之间清晰的组件边界。
meteohealth.pro的场景由两层叠加而成:
- 气压等值线——一个铺满全屏的平面,配合一段fragment着色器绘制出同心的波状线条,类似地形图。每三条线中有一条是强调色(绿色),其余是低透明度的「墨色」。
- GPU粒子——沿着同一批等值线分布,相对于场的梯度旋转了90°的细长轨迹(模拟真实气象学中的地转风),完全通过ping-pong FBO在GPU上模拟,CPU端没有一行逐粒子的JS循环。
关键的架构决策是:让这两套系统都由同一个标量值——「场的躁动程度」——驱动,而不是由任意的时间驱动。下面是一段简化但保留了本质逻辑的示例,展示这在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>
)
}注意,画布在分级(tier)确定之前不会出现。海报图不是「以防万一」的兜底方案,而是屏幕正当的第一状态。真正对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>
)
}下面是fragment着色器本身——真正绘制等值线那部分逻辑的简化版本:
// 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 simplex噪声,实现从略
void main() {
vec2 uv = vUv * 2.0 - 1.0;
// 场在两个噪声倍频上"呼吸";第二个倍频随躁动程度增强,
// 天气平静时几乎不可察觉
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);
// 每三条线中有一条是强调色,其余为"墨色"
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才值得选用:需要大量运动的图元(成百上千个粒子)、需要requestAnimationFrame级别的亚像素平滑,以及图形必须在不重建DOM的情况下响应持续变化的数据。
| 标准 | Canvas 2D | SVG | WebGL(R3F / Three.js) |
|---|---|---|---|
| 可动画对象数量 | 数百个,之后帧率下降 | 数十个——DOM开销大 | GPU上数千个粒子 |
| 对实时数据的响应 | 需要手动重绘 | 简单(属性/CSS变量) | 简单(着色器uniform) |
| 可访问性 / SEO | 屏幕阅读器不可见 | 原生可访问,可被索引 | 需要HTML文本副本 |
| 学习曲线 | 低 | 非常低 | 高(着色器、缓冲区) |
| 弱性能设备上的降级 | 良好 | 优秀 | 需要明确的降级分级 |
| 典型场景 | 图表、简单粒子 | 图标、示意图、dash-draw示意图 | 生成式场景、场模拟 |
对meteohealth.pro来说,选择WebGL是一次刻意的权衡:这个场景是整个网站上唯一「昂贵」的元素,而内容页面(博客、功能页、法律页面)的JS预算之所以能稳定在约100KB,正是因为three这个chunk只在落地页上加载,通过动态导入,而且只在idle之后才加载。
毫不妥协的降级:分级、海报图与prefers-reduced-motion#
生成式落地页最常见的错误,就是只在开发者自己的MacBook Pro上测试。真实访客往往用着三年前的廉价Android手机,开着省电模式,prefers-reduced-motion也是开启的。一套只在理想条件下好看的系统,还没有准备好上线。
在meteohealth.pro上,降级被构建成三个明确的分级,而不是一个「关闭动画」的开关:
- Tier A——完整场景:两个噪声倍频、密集的等值线、完整的粒子集合,
devicePixelRatio最高到1.75。 - Tier B——简化场景:绘制的粒子更少(
drawRange)、只有一个噪声倍频,devicePixelRatio限制在1.25。 - Tier C——从真实场景中截取的静态AVIF海报图。它同时也是OG图像的底图——不是随手凑数的占位图,而是产品诚实的一帧画面。
分级的选择不是一次性的:除了初始的启发式判断(deviceMemory、maxTouchPoints)之外,应用会实测最初20帧的真实帧时间,如果渲染持续超出预算就会降级。会话过程中也会实时发生同样的事——一个120帧的滑动窗口,如果平均帧时间超过约33毫秒,场景就会降一级,而不是继续压榨GPU直到手机发烫。
prefers-reduced-motion被单独处理,并且拥有最高优先级——它不是「又一个性能信号」,而是用户明确做出的决定。当这个标志位开启时,Three.js、GSAP和Lenis根本不会被加载:不仅仅是动画被关闭,相关的JS压根就不会向网络发起请求。画布会在第一帧准备就绪后,以0.7秒的交叉淡入叠加到海报图之上——这次切换本身不应该让人感觉是一次突兀的界面跳变。
设计系统作为契约:GSAP、Lenis与Liquid Signal#
WebGL场景只是众多元素中的一个,尽管是最引人注目的那个。它生活在自己的一整套设计系统「Liquid Signal」之中:沉静的编辑式排版,搭配HUD式的原语组件——比如PRESSURE_Δ24H、TIME_CODE、RISK这样的等宽字体读数,旁边永远是真实的测量值,而不是装饰性数字。这背后的理念是,网站应该表现得像一台校准过的仪器,而不是一块广告牌:屏幕上的每一个数字都是真的,包括驱动生成式场的那一个。
GSAP和Lenis只在落地页上加载,而且只做两件事:首次访问时一段简短的加载序列(线条被绘制出来,HUD标签逐字「打印」出来),以及其余内容的滚动揭示动画(translateY加淡入,约0.6秒,power2.out)。在博客、功能页、法律文本这类内容页面上,要么完全没有动画,要么只有纯CSS过渡:这些页面的JS预算不应该受落地页上发生了什么的影响。
最终结果对一个「生成式」项目来说出乎意料地严格:标志性元素越大胆,它周围的一切就必须越自律。所有的艺术大胆之处都由一个场景来承担,其余部分则保持安静、可预期,绝不闪烁。
结语#
一个生成式WebGL场景只有在它不是装饰、而是产品数据的直接呈现时,才配得上出现在落地页上——而且前提是降级方面的工程投入,和着色器本身一样用心。React Three Fiber为数据层和渲染层之间提供了一个方便的声明式边界,但真正的自律藏在细节里:用一个躁动参数取代一堆魔法数字,用明确的分级取代单一的动画开关,把prefers-reduced-motion当作用户的决定,而不是一个性能设置。
你可以在meteohealth.pro——MeteoHealth的营销官网——上亲眼看到这一切:在开启了prefers-reduced-motion的手机上打开它,海报图依然读起来像一次刻意的设计选择,而不是临时的占位图。



