使用React Three Fiber创建沉浸式3D天气可视化
技术栈
我们的天气世界建立在我最喜欢的技术基础上:
- React Three Fiber:Three.js的React渲染器
- @react-three/drei:包含云层、天空、星星等必备助手
- R3F-Ultimate-Lens-Flare:Anderson Mancini开发的镜头光晕系统
- WeatherAPI.com:实时气象数据
天气组件
可视化的核心在于根据天气API返回的城市天气结果,有条件地显示逼真的太阳、月亮和/或云层,模拟雨或雪的粒子,日夜逻辑,以及在雷暴期间的一些有趣的灯光效果。
太阳+月亮实现
我们从简单的开始:创建一个太阳和月亮组件,只是一个包裹着逼真纹理的球体。我们还会给它一些旋转和光照。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// Sun.js和Moon.js组件,纹理包裹的球体
import React, { useRef } from 'react';
import { useFrame, useLoader } from '@react-three/fiber';
import { Sphere } from '@react-three/drei';
import * as THREE from 'three';
const Sun = () => {
const sunRef = useRef();
const sunTexture = useLoader(THREE.TextureLoader, '/textures/sun_2k.jpg');
useFrame((state) => {
if (sunRef.current) {
sunRef.current.rotation.y = state.clock.getElapsedTime() * 0.1;
}
});
const sunMaterial = new THREE.MeshBasicMaterial({
map: sunTexture,
});
return (
<group position={[0, 4.5, 0]}>
<Sphere ref={sunRef} args={[2, 32, 32]} material={sunMaterial} />
{/* 太阳光照 */}
<pointLight position={[0, 0, 0]} intensity={2.5} color="#FFD700" distance={25} />
</group>
);
};
export default Sun;
|
雨:实例化圆柱体
接下来,我们创建一个雨粒子效果。为了保持性能,我们将使用instancedMesh而不是为每个雨粒子创建单独的网格组件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// Rain.js - 实例化渲染
const Rain = ({ count = 1000 }) => {
const meshRef = useRef();
const dummy = useMemo(() => new THREE.Object3D(), []);
const particles = useMemo(() => {
const temp = [];
for (let i = 0; i < count; i++) {
temp.push({
x: (Math.random() - 0.5) * 20,
y: Math.random() * 20 + 10,
z: (Math.random() - 0.5) * 20,
speed: Math.random() * 0.1 + 0.05,
});
}
return temp;
}, [count]);
useFrame(() => {
particles.forEach((particle, i) => {
particle.y -= particle.speed;
if (particle.y < -1) {
particle.y = 20; // 重置到顶部
}
dummy.position.set(particle.x, particle.y, particle.z);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
});
meshRef.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[null, null, count]}>
<cylinderGeometry args={[0.01, 0.01, 0.5, 8]} />
<meshBasicMaterial color="#87CEEB" transparent opacity={0.6} />
</instancedMesh>
);
};
|
雪:基于物理的翻滚
我们将对雪效果使用相同的基本模板,但粒子不会直线下落,而是会有一些漂移。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// Snow.js - 具有时间基础旋转的逼真漂移和翻滚
useFrame((state) => {
particles.forEach((particle, i) => {
particle.y -= particle.speed;
particle.x += Math.sin(state.clock.elapsedTime + i) * particle.drift;
if (particle.y < -1) {
particle.y = 20;
particle.x = (Math.random() - 0.5) * 20;
}
dummy.position.set(particle.x, particle.y, particle.z);
// 基于时间的翻滚旋转,实现自然的雪花运动
dummy.rotation.x = state.clock.elapsedTime * 2;
dummy.rotation.y = state.clock.elapsedTime * 3;
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
});
meshRef.current.instanceMatrix.needsUpdate = true;
});
|
风暴系统:多组件天气事件
当风暴来临时,我想模拟黑暗阴沉的云层和闪电闪光。这种效果需要同时组合多个天气效果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
// Storm.js
const Storm = () => {
const cloudsRef = useRef();
const lightningLightRef = useRef();
const lightningActive = useRef(false);
useFrame((state) => {
// 带有环境光的闪电闪光
if (Math.random() < 0.003 && !lightningActive.current) {
lightningActive.current = true;
if (lightningLightRef.current) {
// 每次闪光的随机X位置
const randomX = (Math.random() - 0.5) * 10; // 范围:-5到5
lightningLightRef.current.position.x = randomX;
// 单次明亮闪光
lightningLightRef.current.intensity = 90;
setTimeout(() => {
if (lightningLightRef.current) lightningLightRef.current.intensity = 0;
lightningActive.current = false;
}, 400);
}
}
});
return (
<group>
<group ref={cloudsRef}>
<DreiClouds material={THREE.MeshLambertMaterial}>
<Cloud
segments={60}
bounds={[12, 3, 3]}
volume={10}
color="#8A8A8A"
fade={100}
speed={0.2}
opacity={0.8}
position={[-3, 4, -2]}
/>
{/* 额外的云配置... */}
</DreiClouds>
{/* 大雨 - 1500个粒子 */}
<Rain count={1500} />
<pointLight
ref={lightningLightRef}
position={[0, 6, -5.5]}
intensity={0}
color="#e6d8b3"
distance={30}
decay={0.8}
castShadow
/>
</group>
);
};
|
API驱动逻辑:整合所有组件
现在我们构建了天气组件,需要一个系统来根据真实的天气数据决定显示哪些组件。
将API条件转换为组件类型
我们系统的核心是一个简单的解析函数,将数百种可能的天气描述映射到我们可管理的一组视觉组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// weatherService.js - 将天气文本转换为可渲染类型
export const getWeatherConditionType = (condition) => {
const conditionLower = condition.toLowerCase();
if (conditionLower.includes('sunny') || conditionLower.includes('clear')) {
return 'sunny';
}
if (conditionLower.includes('thunder') || conditionLower.includes('storm')) {
return 'stormy';
}
if (conditionLower.includes('cloud') || conditionLower.includes('overcast')) {
return 'cloudy';
}
if (conditionLower.includes('rain') || conditionLower.includes('drizzle')) {
return 'rainy';
}
if (conditionLower.includes('snow') || conditionLower.includes('blizzard')) {
return 'snowy';
}
// ... 额外的雾和薄雾条件
return 'cloudy';
};
|
条件组件渲染
魔法发生在我们的WeatherVisualization组件中,解析后的天气类型决定了要渲染的确切3D组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// WeatherVisualization.js - 将天气数据变为现实
const renderWeatherEffect = () => {
if (weatherType === 'sunny') {
if (partlyCloudy) {
return (
<>
{isNight ? <Moon /> : <Sun />}
<Clouds intensity={0.5} speed={0.1} />
</>
);
}
return isNight ? <Moon /> : <Sun />;
} else if (weatherType === 'rainy') {
return (
<>
<Clouds intensity={0.8} speed={0.15} />
<Rain count={800} />
</>
);
} else if (weatherType === 'stormy') {
return <Storm />; // 包含自己的云、雨和闪电
}
// ... 额外的天气类型
};
|
动态时间系统
天气不仅仅是关于条件,还关乎时间。我们的天气组件需要知道是显示太阳还是月亮,我们需要配置Drei的Sky组件来为当前时间渲染适当的大气颜色。
动态天空配置
Drei的Sky组件非常棒,因为它模拟了实际的大气物理,我们只需要为每个时间段调整大气参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
// Scene3D.js - 时间响应式天空配置
{timeOfDay !== 'night' && (
<Sky
sunPosition={(() => {
if (timeOfDay === 'dawn') {
return [100, -5, 100]; // 太阳低于地平线,呈现较暗的黎明颜色
} else if (timeOfDay === 'dusk') {
return [-100, -5, 100]; // 太阳低于地平线,呈现日落颜色
} else { // 白天
return [100, 20, 100]; // 高太阳位置,呈现明亮的日光
}
})()}
inclination={(() => {
if (timeOfDay === 'dawn' || timeOfDay === 'dusk') {
return 0.6; // 中等倾角用于过渡期
} else { // 白天
return 0.9; // 高倾角用于晴朗的白天天空
}
})()}
turbidity={(() => {
if (timeOfDay === 'dawn' || timeOfDay === 'dusk') {
return 8; // 较高的浊度创造温暖的日出/日落颜色
} else { // 白天
return 2; // 较低的浊度用于晴朗的蓝天
}
})()}
/>
)}
|
夜间:简单的黑色背景+星星
对于夜间,我故意选择完全跳过Drei的Sky组件,而是使用简单的黑色背景。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Scene3D.js - 高效的夜间渲染
{!portalMode && isNight && <SceneBackground backgroundColor={'#000000'} />}
{/* 星星创造夜间氛围 */}
{isNight && (
<Stars
radius={100}
depth={50}
count={5000}
factor={4}
saturation={0}
fade
speed={1}
/>
)}
|
预测门户:展望未来天气的窗口
像任何优秀的天气应用一样,我们不仅要显示当前状况,还要显示接下来的情况。我们的API返回一个三日预测,我们将其转换为悬浮在3D场景中的三个交互式门户,每个门户显示该日天气状况的预览。
使用MeshPortalMaterial构建门户
门户使用Drei的MeshPortalMaterial,它将完整的3D场景渲染到映射到平面上的纹理。每个门户都成为进入其自身天气世界的窗口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
// ForecastPortals.js - 创建交互式天气门户
const ForecastPortal = ({ position, dayData, index, onEnter }) => {
const materialRef = useRef();
// 将预测API数据转换为我们天气组件的格式
const portalWeatherData = useMemo(() => ({
current: {
temp_f: dayData.day.maxtemp_f,
condition: dayData.day.condition,
is_day: 1, // 强制白天以获得一致的门户光照
humidity: dayData.day.avghumidity,
wind_mph: dayData.day.maxwind_mph,
},
location: {
localtime: dayData.date + 'T12:00' // 设置为中午以获得最佳光照
}
}), [dayData]);
return (
<group position={position}>
<mesh onClick={onEnter}>
<roundedPlaneGeometry args={[2, 2.5, 0.15]} />
<MeshPortalMaterial
ref={materialRef}
blur={0}
resolution={256}
worldUnits={false}
>
{/* 每个门户渲染完整的天气场景 */}
<color attach="background" args={['#87CEEB']} />
<ambientLight intensity={0.4} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<WeatherVisualization
weatherData={portalWeatherData}
isLoading={false}
portalMode={true}
/>
</MeshPortalMaterial>
</mesh>
{/* 天气信息叠加 */}
<Text position={[-0.8, 1.0, 0.1]} fontSize={0.18} color="#FFFFFF">
{formatDay(dayData.date, index)}
</Text>
<Text position={[0.8, 1.0, 0.1]} fontSize={0.15} color="#FFFFFF">
{Math.round(dayData.day.maxtemp_f)}° / {Math.round(dayData.day.mintemp_f)}°
</Text>
<Text position={[-0.8, -1.0, 0.1]} fontSize={0.13} color="#FFFFFF">
{dayData.day.condition.text}
</Text>
</group>
);
};
|
性能优化
由于我们正在渲染数千个粒子、多个云系统和交互式门户,有时是同时进行的,这可能会变得昂贵。我们所有的粒子系统都使用实例化渲染,在单个GPU调用中绘制数千个雨滴或雪花。条件渲染确保我们只加载实际需要的天气效果。
智能粒子缩放
1
2
3
|
// WeatherVisualization.js - 智能粒子缩放
{weatherType === 'rainy' && <Rain count={portalMode ? 100 : 800} />}
{weatherType === 'snowy' && <Snow count={portalMode ? 50 : 400} />}
|
API可靠性和缓存
除了3D性能,我们需要应用在天气API缓慢、宕机或受到速率限制时也能可靠工作。系统实现了智能缓存和优雅降级,以保持体验流畅。
智能缓存
我们不是为每个请求调用API,而是将天气响应缓存10分钟:
1
2
3
4
5
6
7
8
9
10
|
// api/weather.js - 简单但有效的缓存
const cache = new Map();
const CACHE_DURATION = 10 * 60 * 1000; // 10分钟
const cacheKey = `weather:${location.toLowerCase()}`;
const cachedData = cache.get(cacheKey);
if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
return res.json({ ...cachedData.data, cached: true });
}
|
结论
构建这个3D天气可视化将React Three Fiber与实时气象数据结合起来,创造出了超越传统天气应用的东西。通过利用Drei的现成组件和自定义粒子系统,我们将API响应转换为可探索的大气环境。
技术基础结合了几种关键方法:
- 实例化渲染粒子系统,在模拟数千个雨滴时保持60fps
- 条件组件加载,只渲染当前需要的天气效果
- 基于门户的场景组合,使用MeshPortalMaterial进行预测预览
- 时间感知的大气渲染,Drei的Sky组件响应本地日出和日落
- 智能缓存和回退系统,在API限制期间保持体验响应性