Пошаговое руководство по симуляции солнца, дождя, снега и грозы в интерактивном трехмерном приложении для прогноза погоды.

Меня всегда интересовала визуализация данных с помощью Three.js / R3F и я подумал, что приложение для прогноза погоды будет отличным началом. Одна из моих любимых открытых библиотек, @react-three/drei, содержит множество прекрасных инструментов, вроде облаков, неба и звезд, которые отлично подходят для визуализации погоды в 3D.

В этом туториале мы научимся преобразовывать данные из API в трехмерный опыт, добавив немного изюминки и веселья в визуализацию погоды.

Технологический стек

Наше приложение будет построено на некоторых из моих любимых технологий:

  • React Three Fiber - рендерер React для Three.js

  • @react-three/drei - плюшки, вроде упомянутых выше

  • R3F-Ultimate-Lens-Flare - система бликов объектива (lens flare) от одного из моих любимых разработчиков Anderson Mancini

  • WeatherAPI.com - метеорологические данные в реальном времени

Компоненты приложения

Сердцем нашего приложения будет условное отображение реалистичного солнца, луны и/или облаков в зависимости от погоды в вашем или выбранном городе, частиц (particles) для симуляции дождя или снега, логики смены дня/ночи и некоторых прикольных световых эффектов для грозы. Мы начнем с разработки этих компонентов и закончим их рендерингом на основе результатов обращения к WeatherAPI.

Реализация солнца и луны

Начнем с простого: создадим компоненты солнца и луны, представляющие собой сферы, обернутые в реалистичные текстуры. Добавим им небольшое вращение и немного света.

// Солнце (Sun.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;

Я взял текстуру солнца отсюда. Компонент луны аналогичен; я использовал это изображение. Интенсивность точечного света (pointLight) низкая, поскольку большая часть света будет исходить от неба.

Дождь: инстансные цилиндры

Далее, создадим частицы дождя. В целях производительности мы используем instancedMesh вместо создания отдельного компонента сетки (mesh) для каждой частицы дождя. Мы рендерим простую геометрию (cylinderGeometry) несколько раз с разными преобразованиями (положение, вращение, масштаб). Также вместо создания нового THREE.Object3D для каждой частицы в каждом кадре (frame), мы используем одну болванку (dummy object). Это сохраняет память и предотвращает лишнюю работу по созданию и уничтожению (сборке мусора) большого числа временных объектов в цикле анимации. Также мы используем хук useMemo для создания и инициализации массива частиц один раз при монтировании компонента.

// Дождь (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 становится отрицательным, она перемещается наверх сцены с новым произвольным положением по горизонтали, что создает эффект бесконечного дождя без постоянного создания новых объектов.

Снег: основанное на физике движение

Мы используем такой же основной шаблон для эффекта снега, но частницы будут не просто падают вниз, а немного смещаются в стороны:

// Снег (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 "смещает" время каждой частицы. Это создает натуралистичный плавающий эффект, когда каждая снежинка следует своему собственному пути. Обновления rotation небольшими увеличениями осей X и Y создают эффект вращения.

Гроза

Для грозы мы имитируем темные нависшие облака и вспышки яркого света. Это требует комбинации нескольких последовательных погодных эффектов. Мы импортируем наш компонент дождя, добавляем несколько облаков и реализуем световой эффект с помощью pointLight, имитирующего вспышки света за облаками.

// Шторм (Storm.js)
const Storm = () => {
  const cloudsRef = useRef();
  const lightningLightRef = useRef();
  const lightningActive = useRef(false);

  useFrame((state) => {
    // Вспышка молнии при рассеянном (ambient) свете
    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>
  );
};

Система использует простой механизм на основе ref (ссылки на значения, модификация которых не приводит к повторному рендерингу компонента) для предотвращения постоянного мигания. При запуске механизма создается одиночная яркая вспышка с произвольным положением по оси Х. Система использует setTimeout для сброса интенсивности света через 400 мс, что создает реалистичный световой эффект без сложной многоэтапной последовательности.

Облака

Для таких типов погоды, как облачно, переменная облачность, пасмурно, туман, дождь, снег и дымка (misty), мы используем компонент облаков. Я хочу, чтобы компонент грозы имел собственные облака, поскольку они должны быть более специфичными, чем обычные. Компонент облаков просто рендерит Clouds от Drei. Мы объединим его с солнцем и луной в следующем разделе.

const Clouds = ({ intensity = 0.7, speed = 0.1 }) => {
  // Цвет облаков зависит от погодных условий
  const getCloudColors = () => {
      return {
        primary: '#FFFFFF',
        secondary: '#F8F8F8',
        tertiary: '#F0F0F0',
        light: '#FAFAFA',
        intensity: intensity
      };
  };

  const colors = getCloudColors();
  return (
    <group>
      <DreiClouds material={THREE.MeshLambertMaterial}>
        {/* Большой кластер пушистых облаков */}
        <Cloud
          segments={80}
          bounds={[12, 4, 4]}
          volume={15}
          color={colors.primary}
          fade={50}
          speed={speed}
          opacity={colors.intensity}
          position={[-5, 4, -2]}
        />
        {/* Другие облака... */}
      </DreiClouds>
    </group>
  );
};

Логика работы с API

Мы создали компоненты погоды. Теперь нам нужна система для определения отображаемых компонентов на основе данных о погоде. Сервис WeatherAPI предоставляет подробные текущие условия, которые нужно преобразовывать в параметры трехмерной сцены. API возвращает текст, вроде "Party cloud" (переменная облачность), "Thunderstorm" (гроза) или "Light snow" (легкий снег), которые необходимо конвертировать в типы компонентов.

// 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 включает информацию о временной зоне (timeZone) для точного определения времени суток для системы "Солнце/Луна". Параметр days=3 запрашивает данные для функционала порталов (об этом позже), а параметры aqi=no&alerts=no исключают данные, которые нам не нужны, для уменьшения размера полезной нагрузки.

Преобразование данных из API в типы компонентов

Сердцем нашей системы является простая функция парсинга, которая связывает множество возможных описаний погоды с нашим небольшим набором визуальных компонентов:

// 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' и запускаются соответствующие трехмерные эффекты. Это позволяет повторно использовать основные компоненты без создания отдельного компонента для каждого погодного условия.

Условный рендеринг компонентов

Магия происходит в компоненте WeatherVisualization, где тип погоды определяет, какой трехмерный компонент рендерить:

// 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 />; // Включает собственные облака, дождь и свет
  }
  // Другие типы погоды...
};

Условный рендеринг обеспечивает загрузку только необходимых систем. Солнечный день приводит к рендерингу только компонента солнца, гроза загружает целую систему с сильным дождем, темные облаками и вспышками молний. Каждый тип погоды имеют собственную комбинацию основных компонентов, что обеспечивает уникальный визуальный опыт, соответствующий реальным погодным условиям.

Динамичная система времени суток

Погода - это не только про условия, но также про время суток. Нам нужно знать, показывать солнце или луну. Нам также необходимо настроить компонент Sky от Drei для рендеринга соответствующих атмосферных цветов для текущего времени дня. К счастью, ответ WeatherAPI содержит локальное время для любой локации, которое можно использовать для реализации логики смены дня/ночи.

API предоставляет локальное время в простом формате, который мы можем разобрать для определения текущего периода времени:

// Scene3D.js - разбираем время из ответа WeatherAPI
const getTimeOfDay = () => {
  if (!weatherData?.location?.localtime) return 'day';
  const localTime = weatherData.location.localtime;
  const currentHour = new Date(localTime).getHours();

  if (currentHour >= 19 || currentHour <= 6) return 'night';
  if (currentHour >= 6 && currentHour < 8) return 'dawn';
  if (currentHour >= 17 && currentHour < 19) return 'dusk';
  return 'day';
};

Это дает нам разные периоды времени с разными настройками света и неба. Теперь мы можем использовать эти периоды для настройки компонента Sky от Drei, который обрабатывает атмосферное рассеяние и генерирует реалистичные цвета неба.

Динамичная настройка неба

Компонент Sky от Drei просто фантастический, поскольку позволяет имитировать актуальную физику атмосферы - нам всего лишь нужно настроить параметры окружения для каждого периода:

// 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; // Низкая мутность для чистого голубого неба
      }
    })()}
  />
)}

Магия в позиционировании. Во время восхода или заката мы помещаем солнце ниже горизонта (-5 по оси Y), чтобы компонент Sky от Drei генерировал теплые оранжевые и розовые цвета, которые ассоциируются с этими периодами. Параметр turbidity управляет атмосферным рассеянием, более высокие значения создают более драматичные цветовые эффекты в переходные периоды.

Ночь: простой черный фон и звезды

Для ночи, вместо компонента Sky от Drei, я решил использовать простой черный фон. Sky является избыточным для ночных сцен, чистый черный фон выглядит лучше и гораздо производительнее. Для создания аутентичной ночной атмосферы используется компонент Stars от Drei:

// Scene3D.js - эффективный рендеринг ночного времени
{!portalMode && isNight && <SceneBackground backgroundColor={'#000000'} />}

{/* Stars создает ночную атмосферу */}
{isNight && (
  <Stars
    radius={100}
    depth={50}
    count={5000}
    factor={4}
    saturation={0}
    fade
    speed={1}
  />
)}

Компонент Stars от Drei создает 5 000 звезд, рассеянных по сфере размером 100 единиц (units), с реалистичными вариациями глубины. saturation={0} отключает их насыщенность для аутентичной ночной видимости, а speed={1} создает едва заметное движение, имитирующее естественное движение небесных тел. Звезды появляются только в ночные часы (с 19 до 6) и автоматически исчезают на рассвете, создавая плавный переход к компоненту Sky.

Такой подход дает нам четыре разных состояния окружения: яркий дневной свет, теплые цвета рассвета, золотистые тона сумерек и звездные ночи — все они автоматически определяются реальным локальным временем на основе наших метеорологических данных.

Порталы прогноза: окна в будущую погоду

Как в любом хорошем приложении для прогноза погоды, мы хотим показывать не только текущую погоду, но и прогноз на будущее. Наш API возвращает трехдневный прогноз, который мы преобразуем в три интерактивных портала, парящих в трехмерной сцене. Каждый портал демонстрирует предварительный просмотр погодных условий на этот день. Нажав на портал, мы переносимся в атмосферу соответствующего дня.

Создание порталов с помощью MeshPortalMaterial

Порталы используют MeshPortalMaterial от Drei, который визуализирует полную трехмерную сцену в текстуру, накладываемую на плоскость. Каждый портал становится окном в свой собственный погодный мир:

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

roundedPlaneGeometry из библиотеки maath делает границы наших порталов плавными и органичными, вместо острых прямоугольников. Параметры [2, 2.5, 0.15] создают портал размером 2х2,5 единиц с угловым радиусом 0,15, обеспечивая достаточную степень скругления для визуально привлекательного вида.

Интерактивные состояния и анимации

Порталы реагируют на взаимодействие пользователя плавными переходами состояний. Система отслеживает два основных состояния: неактивное и полноэкранное.

// ForecastPortals.js - управление состояние и смешивание (blend) анимаций
const ForecastPortal = ({ position, dayData, isActive, isFullscreen, onEnter }) => {
  const materialRef = useRef();

  useFrame(() => {
    if (materialRef.current) {
      // Плавное смешивание анимации - только неактивное (0) или полноэкранное (1)
      const targetBlend = isFullscreen ? 1 : 0;
      materialRef.current.blend = THREE.MathUtils.lerp(
        materialRef.current.blend || 0,
        targetBlend,
        0.1
      );
    }
  });

  // Содержимое портала и элементы UI скрыты в полноэкранном режиме
  return (
    <group position={position}>
      <mesh onClick={onEnter}>
        <roundedPlaneGeometry args={[2, 2.5, 0.15]} />
        <MeshPortalMaterial ref={materialRef}>
          <PortalScene />
        </MeshPortalMaterial>
      </mesh>

      {!isFullscreen && (
        <>
          {/* Температура и текст погодных условий показываются только в режиме превью */}
          <Text position={[-0.8, 1.0, 0.1]} fontSize={0.18} color="#FFFFFF">
            {formatDay(dayData.date, index)}
          </Text>
        </>
      )}
    </group>
  );
};

Свойство blend управляет тем, сколько пространства на экране занимает портал. При 0 (неактивный) портал отображается как отдельное окно на сцене погоды. При 1 (полноэкранный) мы переносимся в среду погоды конкретного дня. Функция THREE.MathUtils.lerp создает плавные переходы между этими двумя состояниями при клике внутри и за пределами порталов.

Полноэкранный портал

Когда мы нажимаем на портал, он заполняет все наше поле зрения погодой на выбранный день:

// Scene3D.js - обработка перехода портала в полноэкранный режим
const handlePortalStateChange = (isPortalActive, dayData) => {
  setPortalMode(isPortalActive);
  if (isPortalActive && dayData) {
    // Создаем иммерсивную погодную среду для выбранного дня
    const portalData = {
      current: {
        temp_f: dayData.day.maxtemp_f,
        condition: dayData.day.condition,
        is_day: 1,
        humidity: dayData.day.avghumidity,
      },
      location: { localtime: dayData.date + 'T12:00' }
    };
    setPortalWeatherData(portalData);
  }
};

В полноэкранном режиме данные о погоде портала управляют всей сценой: компонент Sky, свет и другие эффекты представляют выбранный день. Мы можем перемещаться внутри завтрашней грозы или наслаждаться мягким солнечным светом послезавтрашнего дня. При выходе (клике за пределами портала) система плавно возвращается к текущим погодным условиям.

Ключевой момент заключается в том, что каждый портал использует один и тот же компонент WeatherVisualization, но с прогнозными данными вместо текущих условий. Параметр portalMode={true} оптимизирует рендеринг — меньше частиц, более простые облака, но с той же условной логикой, которую мы определили ранее.

После добавления порталов нужно обновить компоненты для поддержки оптимизации. Добавляем в них проп portalMode:

// WeatherVisualization.js - поддержка порталов
if (weatherType === 'rainy') {
  return (
    <>
      <Clouds intensity={0.8} speed={0.15} portalMode={portalMode} />
      <Rain count={portalMode ? 100 : 800} />
    </>
  );
} else if (weatherType === 'snowy') {
  return (
    <>
      <Clouds intensity={0.6} speed={0.05} portalMode={portalMode} />
      <Snow count={portalMode ? 50 : 400} />
    </>
  );
}

Также обновляем компонент Clouds для отображения меньшего количества и более простых облаков в портале:

// Clouds.js - оптимизация для порталов
const Clouds = ({ intensity = 0.7, speed = 0.1, portalMode = false }) => {
  if (portalMode) {
    return (
      <DreiClouds material={THREE.MeshLambertMaterial}>
        {/* Только 2 облака по центру для превью портала */}
        <Cloud segments={40} bounds={[8, 3, 3]} volume={8} position={[0, 4, -2]} />
        <Cloud segments={35} bounds={[6, 2.5, 2.5]} volume={6} position={[2, 3, -3]} />
      </DreiClouds>
    );
  }
  // Полная система облаков для основной сцены
  return <group>{/* ... */}</group>;
};

Это значительно снижает как количество частиц (на 87,5% меньше частиц дождя), так и сложность облаков (на 67% с 6 детализированных до 2 центрированных облаков), что обеспечивает плавную работу приложения, когда несколько порталов одновременно демонстрируют погодные условия.

Интеграция с Scene3D

Порталы размещаются и управляются в нашем основном компоненте Scene3D, где они дополняют текущую визуализацию погоды:

// Scene3D.js - интеграция порталов
<>
  {/* Текущая погода на основной сцене */}
  <WeatherVisualization
    weatherData={weatherData}
    isLoading={isLoading}
  />

  {/* Порталы трехдневного прогноза */}
  <ForecastPortals
    weatherData={weatherData}
    isLoading={isLoading}
    onPortalStateChange={handlePortalStateChange}
  />
</>

При нажатии на портал вся сцена переходит в полноэкранный режим, отображая погоду на выбранный день во всех подробностях. Система портала отслеживает активные состояния и обеспечивает плавные переходы между режимами предварительного просмотра и полного погружения, предоставляя удобный способ исследовать будущие погодные условия, наряду с текущими.

Порталы преобразуют статические прогнозы погоды в трехмерные объекты, доступные для изучения. Вместо того, чтобы читать "Завтра: 75° (по Фаренгейту), переменная облачность", вы видите и чувствуете, как медленно плывут кучевые облака, сквозь которые пробивается теплый солнечный свет.

Добавление кинематографических бликов объектива

Наш компонент Sun выглядит великолепно, но я решил добавить ему легкий эффект бликов для создания кинематографического эффекта. Для этого я использую библиотеку R3F-Ultimate-Lens-Flare, которую установил вручную, следуя инструкциям из репозитория. Хотя блики обычно лучше выглядят с удаленными солнечными объектами, чем с крупным планом, как в нашем случае, я все же считаю, что они добавляют сцене приятный кинематографический штрих.

Система бликов должна быть умной в плане времени появления. Как и наши погодные компоненты, она должна появляться только тогда, когда это имеет "метеорологический смысл":

// Scene3D.js - условный рендеринг бликов объектива
const PostProcessingEffects = ({ showLensFlare }) => {
  if (!showLensFlare) return null;

  return (
    <EffectComposer>
      <UltimateLensFlare
        position={[0, 5, 0]} // Располагается рядом с солнцем, находящимся на [0, 4.5, 0]
        opacity={1.00}
        glareSize={1.68}
        starPoints={2}
        animated={false}
        flareShape={0.81}
        flareSize={1.68}
        secondaryGhosts={true}
        ghostScale={0.03}
        additionalStreaks={true}
        haloScale={3.88}
      />
      <Bloom intensity={0.3} threshold={0.9} />
    </EffectComposer>
  );
};

Ключевые параметры, отвечающие за реалистичный эффект бликов: glareSize и flareSize со значениями 1,68 создают заметные, но не слишком яркие блики, а ghostScale={0.03} добавляет едва заметные артефакты отражения объектива. В свою очередь, haloScale={3.88} создает мощное атмосферное свечение вокруг солнца.

Блики связываются с системой погоды через функцию видимости, которая определяет, когда должно отображаться солнце:

// weatherService.js - когда отображаются блики?
export const shouldShowSun = (weatherData) => {
  if (!weatherData?.current?.condition) return true;
  const condition = weatherData.current.condition.text.toLowerCase();

  // Скрываем блики, когда из-за погоды солнца не видно
  if (condition.includes('overcast') ||
      condition.includes('rain') ||
      condition.includes('storm') ||
      condition.includes('snow')) {
    return false;
  }

  return true; // Показываем блики в случае чистой, солнечной погоды и переменной облачности
};

// Scene3D.js - объединение погоды и времени
const showLensFlare = useMemo(() => {
  if (isNight || !weatherData) return false;
  return shouldShowSun(weatherData);
}, [isNight, weatherData]);

Это создает реалистичное поведение, когда блики появляются только в ясную погоду днем. Во время грозы солнце (и его блики) скрываются за облаками, как в реальной жизни.

Оптимизация производительности

Поскольку мы рендерим тысячи частиц, несколько облачных систем и интерактивных порталов, иногда одновременно, это может негативно сказаться на производительности. Как упоминалось выше, все наши системы частиц используют инстансный рендеринг для отрисовки тысяч капель дождя или снежинок за один вызов GPU. Условный рендеринг гарантирует отображение только тех погодных эффектов, которые нам нужны: никакого дождя в солнечную погоду, никаких бликов во время грозы. Тем не менее, существует еще много возможностей для оптимизации. Наиболее значительное улучшение достигается за счет адаптивного рендеринга нашей системы порталов. Мы уже обсуждали уменьшение количества облаков в порталах выше, но когда несколько порталов одновременно показывают осадки, мы еще больше сокращаем количество частиц:

// WeatherVisualization.js - умное масштабирование частиц
{weatherType === 'rainy' && <Rain count={portalMode ? 100 : 800} />}
{weatherType === 'snowy' && <Snow count={portalMode ? 50 : 400} />}

Это предотвращает неидеальный сценарий рендеринга 4 × 800 = 3200 частиц дождя, когда все порталы показывают дождь. Вместо этого мы получаем 800 + (3 × 100) = 1100 частиц, сохраняя хороший визуальный эффект.

Надежность API и кэширование

Помимо высокой производительности, нам необходимо, чтобы приложение работало стабильно даже при медленном, нестабильном соединении или ограничениях API. Система реализует интеллектуальное кэширование и мягкое снижение производительности для обеспечения плавной работы.

Интеллектуальное кэширование

Вместо того чтобы обращаться к API при каждом запросе, мы кэшируем ответы на 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 запросов в час, система плавно переключается на демонстрационные данные вместо отображения ошибок:

// weatherService.js - мягкая деградация
if (error.response?.status === 429) {
  console.log('Too many requests');
  return getDemoWeatherData(location);
}

Демонстрационные данные включают функцию определения времени суток, поэтому даже в резервном режиме освещение и цвета неба отображаются правильно в зависимости от локального времени пользователя.

Будущие улучшения

Возможности для расширения нашего приложения огромны. Добавление точных фаз Луны придаст ночным сценам больше реализма — сейчас наша Луна всегда полная. Эффекты ветра могут анимировать растительность или создавать дрейфующие узоры тумана, используя данные о скорости ветра, которые мы получаем, но не визуализируем. С точки зрения производительности текущие оптимизации хорошо справляются с большинством сценариев, но есть возможности для улучшения, особенно когда все порталы показывают осадки одновременно.

Заключение

Создавая эту трехмерную визуализацию погоды, мы объединили React Three Fiber с метеорологическими данными в режиме реального времени, создав нечто большее, чем традиционное приложение для прогноза погоды. Используя готовые компоненты Drei и кастомные системы частиц, мы преобразовали ответы API в исследуемые атмосферные окружения.

Техническая основа приложения получилась следующей:

  • инстанс-рендеринг систем частиц, поддерживающих 60 кадров в секунду при имитации тысяч капель дождя

  • условный рендеринг компонентов - отображения только необходимых в данный момент погодных эффектов

  • композиция сцен на основе порталов с использованием MeshPortalMaterial для предварительного просмотра прогнозов

  • рендеринг окружения с учетом времени суток с помощью компонента Sky от Drei

  • интеллектуальное кэширование и резервная система, которые поддерживают отзывчивость интерфейса даже при ограничениях API

Комментарии (2)


  1. trentclainor
    06.10.2025 08:46

    на моем айфоне такая белиберда выводится, что пришлось закрыть страницу (


    1. trentclainor
      06.10.2025 08:46

      уточню, в демо