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

本教程详细介绍了如何使用React Three Fiber构建交互式3D天气应用,包括太阳、月亮、雨雪粒子系统和风暴效果的实现,以及如何将WeatherAPI数据转换为动态3D场景。

使用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限制期间保持体验响应性
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计