使用React Three Fiber创建沉浸式3D天气可视化

本文详细介绍了如何使用React Three Fiber构建交互式3D天气应用,包括太阳、月亮、雨雪粒子和风暴系统的实现,以及基于API数据的条件渲染和性能优化技巧。

使用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>
  );
};

当粒子达到负Y轴水平时,它会立即回收到场景顶部,并具有新的随机水平位置,从而在不不断创建新对象的情况下产生连续降雨的错觉。

雪:基于物理的翻滚

我们对雪效果使用相同的基本模板,但粒子不是直线下落,而是给予它们一些漂移。

 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;
});

水平漂移使用Math.sin(state.clock.elapsedTime + i),其中state.clock.elapsedTime提供持续增加的时间值,i偏移每个粒子的时间。这创建了一个自然的摇摆运动,每个雪花都遵循自己的路径。

风暴系统:多组件天气事件

当风暴来临时,我想模拟黑暗阴沉的云层和闪电闪光。这种效果需要同时组合多个天气效果。

 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>
  );
};

闪电系统使用简单的基于引用的冷却机制来防止持续闪光。当闪电触发时,它会创建一个具有随机定位的单个明亮闪光。系统使用setTimeout在400毫秒后重置光强度,创建逼真的闪电效果,而无需复杂的多阶段序列。

API驱动逻辑:整合所有组件

现在我们构建了天气组件,我们需要一个系统来根据实时天气数据决定显示哪些组件。

1
2
3
4
5
// weatherService.js - 获取实时天气数据
const response = await axios.get(
  `${WEATHER_API_BASE}/forecast.json?key=${API_KEY}&q=${location}&days=3&aqi=no&alerts=no&tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}`,
  { timeout: 10000 }
);

API请求包括时区信息,以便我们可以准确确定太阳/月亮系统的白天或夜晚。days=3参数获取我们门户功能所需的预报数据,而aqi=no&alerts=no通过排除我们不需要的数据来保持负载轻量。

将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';
};

这种字符串匹配方法优雅地处理边缘情况——无论API返回"Light rain"、“Heavy rain"还是"Patchy light drizzle”,它们都映射到我们的rainy类型并触发适当的3D效果。

条件组件渲染

魔法发生在我们的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 />; // 包括自己的云、雨和闪电
  }
  // ... 额外的天气类型
};

这种条件系统确保我们只加载实际需要的粒子系统。晴天只渲染我们的Sun组件,而风暴加载我们完整的Storm系统,包括大雨、暗云和闪电效果。

性能优化

由于我们正在渲染数千个粒子、多个云系统和交互式门户——有时是同时进行的——这可能会变得昂贵。我们所有的粒子系统都使用实例化渲染,在单个GPU调用中绘制数千个雨滴或雪花。条件渲染确保我们只加载实际需要的天气效果。

最显著的改进来自我们门户系统的自适应渲染。当多个预报门户同时显示降水时,我们显著减少粒子数量:

1
2
3
// WeatherVisualization.js - 智能粒子缩放
{weatherType === 'rainy' && <Rain count={portalMode ? 100 : 800} />}
{weatherType === 'snowy' && <Snow count={portalMode ? 50 : 400} />}

这防止了在所有门户都显示雨时渲染4 × 800 = 3,200个雨粒子的不理想情况。相反,我们得到800 + (3 × 100) = 1,100个总粒子,同时保持视觉效果。

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 });
}

这为用户最近搜索的位置提供即时响应,并在API减速期间保持应用程序响应性。

速率限制和回退

当用户超过我们每小时15个请求的限制时,系统会平滑切换到演示数据,而不是显示错误:

1
2
3
4
5
// weatherService.js - 优雅降级
if (error.response?.status === 429) {
  console.log('Too many requests');
  return getDemoWeatherData(location);
}

演示数据包括时间感知的昼夜检测,因此即使是回退体验也基于用户的本地时间显示适当的照明和天空颜色。

结论

构建这个3D天气可视化将React Three Fiber与实时气象数据相结合,创造了超越传统天气应用程序的东西。通过利用Drei的现成组件和自定义粒子系统,我们将API响应转换为可探索的大气环境。

技术基础结合了几种关键方法:

  • 实例化渲染粒子系统,在模拟数千个雨滴时保持60fps
  • 条件组件加载,仅渲染当前需要的天气效果
  • 基于门户的场景组合,使用MeshPortalMaterial进行预报预览
  • 时间感知的大气渲染,Drei的Sky组件响应本地日出和日落
  • 智能缓存和回退系统,在API限制期间保持体验响应性
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计