Вы когда-нибудь играли в Outer Wilds? Планеты там невероятно красивы. Это собственно стало основной мотивацией создать свою простую модель планеты, используя реальные географические данные о высотах и немножко магии Wolfram Language

Финальная анимация
Финальная анимация

Как разбросать точки на сфере

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

Как один из вариантов - мы пойдем от обратного - сначала создадим пробные точки на сфере, а затем уже запросим по ним из гео-данных значения высот.

Итак, нам нужно равномерно распределить N точек по сфере. Не нужно изобретать велосипед, если есть богатая стандартная библиотека

Graphics3D[{
  Sphere[],
  Red, PointSize[0.01], SpherePoints[1000]//Point
}]
Разбросали...
Разбросали...

Географические данные

К счастью или сожалению, в стандартной библиотеке WL уже лежит грубая карта всей Земли (на всякий случай). Если нужно поточнее, она пойдет в интернет и добудет больше. Собственно, к делу

GeoElevationData[GeoPosition[Here]]
Quantity[490, "Meters"]

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

points = SpherePoints[5000];
latLon = (180/Pi) {90Degree - ArcCos[#[[3]]], ArcTan[#[[1]], #[[2]]]} &/@ points;

elevation = GeoElevationData[GeoPosition[latLon]];
elevation = (elevation + Min[elevation])/Max[elevation];

Здесь мы переходим из декартовой системы координат в сферическую (геодезическую точнее)

\begin{matrix} lat =& 90^\circ - arccos(z/r) \\ lon =& arctan(x/y)\end{matrix}

получаем высоты и нормируем их.

Остается связать их с исходными точками на сфере, используя нормированную высоту как расстояние по нормали

surface = MapThread[(#1 (0.8 + 0.1 #2))&, {points, elevation}];

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

rainbow = ColorData["DarkRainbow"];

ListSurfacePlot3D[
  surface, 
  Mesh->None, MaxPlotPoints->100, 
  ColorFunction -> Function[{x,y,z}, rainbow[1.5(2 Norm[{x,y,z}]-1)]], 
  ColorFunctionScaling -> False
]
Действительно похоже на Землю!
Действительно похоже на Землю!

Генерируем облака

Какая же Земля без облаков? Какие же облака без Шума Перлина? Следующий кусок я честно украл на одном из форумов (никаких GPT вам!)

n = 128;
k2 = Outer[Plus, #, #] &[RotateRight[N@Range[-n, n - 1, 2]/n, n/2]^2];

spectrum = With[{d := RandomReal[NormalDistribution[], {n, n}]},
   (1/n) (d + I d)/(0.002 + k2)]; 

spectrum[[1, 1]] *= 0;

im[p_] := Clip[Re[InverseFourier[spectrum Exp[I p]]], {0, ∞}]^0.5

p0 = p = Sqrt[k2];

Image[im[p0 += p]]
То, что доктор прописал
То, что доктор прописал

Остается другая проблема - как сделать их трехмерными? Я не придумал ничего лучше, как использовать технику Marching Cubes и сгенерить low-poly облака. Однако для начала "растянем" двумерное изображение в трехмерное с затуханием по краям

With[{plain = im[p0+=p]}, Table[plain Exp[-( i)^2/200.], {i, -20,20}]]

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

Воспользуемся внешней библиотекой wl-marchingcubes

PacletRepositories[{
    Github -> "https://github.com/JerryI/wl-marching-cubes" -> "master"
}]

<<JerryI`MarchingCubes`
With[{plain = im[p0+=p]}, Table[plain Exp[-( i)^2/200.], {i, -20,20}]];

{vertices, normals} = CMarchingCubes[%, 0.2, "CalculateNormals" -> False];

Результат записывается в vertices. Библиотека генерирует по умолчанию неиндексированный набор вершин треугольников. Это означает, что можно напрямую интерпретировать их как

Polygon[{
  {1,2,3}, //треугольник 1
  {4,5,6}, //треугольник 2
  ...
}]

где ни одна из вершин не переиспользуется другим треугольником. Такой формат особенно прост для GPU, так как нужно отправить лишь один такой список в один из буферов WebGL. К счастью примитив Polygon поддерживает такой вариант

GraphicsComplex[vertices, Polygon[1, Length[vertices]]] // Graphics3D
Напоминает облака
Напоминает облака

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

Натягиваем на сферу

Здесь мы сталкиваемся с такой же проблемой, что и ранее. Как натянуть это на сферу? Для иллюстративных целей попробуем Такое преобразование в лоб, как будто наши x,y координаты это сферические углы

\begin{matrix}x\prime & \rightarrow \rho ~cos(2\pi ~x/width)~cos(\theta_{offset} + \pi ~y/height)\\y\prime &\rightarrow \rho ~sin(2\pi~x/width)~cos(\theta_{offset} + \pi ~y/height)\\z\prime &\rightarrow  \rho~sin(\theta_{offset} + \pi ~y/height)\end{matrix}

где \rho = \rho_0 + \alpha ~ z/depth. Вопрос зачем здесь некий угол сдвига \theta - так как такое преобразование создает серьезные артефакты на полюсах сферы, можно попытаться косметически поправить их сдвинув полюс в область, где меньше геометрии.

Если кто-то из хабровчан знает способ получше - прошу в комментарии ?

Итак, попробуем этот вариант

vertices = Map[Function[v,
    With[{
      \[Rho] = 50.0 + 0.25 (v[[3]] - 10), 
      \[Phi] = 2.0 Pi v[[1]]/127.0, 
      \[Theta] =  Pi/2 + Pi v[[2]]/127.0
    },
      {
        \[Rho] Cos[\[Phi]] Cos[\[Theta]], 
        \[Rho] Sin[\[Phi]] Cos[\[Theta]], 
        \[Rho] Sin[\[Theta]]
      }
    ]
  ]
, vertices];

{
  clouds = GraphicsComplex[0.017 vertices, Polygon[1, Length[vertices]]]
} // Graphics3D
Вроде бы даже неплохо
Вроде бы даже неплохо

Собираем все вместе

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

rainbow = ColorData["DarkRainbow"];

ListSurfacePlot3D[
  MapThread[(#1 (0.8 + 0.1 #2))&, {points, elevation}], 
  Mesh->None, MaxPlotPoints->100, 
  ColorFunction -> Function[{x,y,z}, rainbow[1.5(2 Norm[{x,y,z}]-1)]], 
  ColorFunctionScaling -> False, 
  Lighting->None,
  PlotStyle->Directive["Shadows"->True, "CastShadow"->True],

  Prolog -> {
    Directive["Shadows"->True, "CastShadow"->True],
    clouds,
    HemisphereLight[LightBlue, Orange // Darker // Darker],
    SpotLight[Orange, {-2.4909, 4.069, 3.024}]
  },
  
  Background->Black
, ImageSize->800]
Почти финальный результат
Почти финальный результат

Можно поиграться с освещением в реальном времени, добавив обработчик на свойство transform мелкой сферы на месте источника света

rainbow = ColorData["DarkRainbow"];
lightPos = {-2.4909, 4.069, 3.024};


ListSurfacePlot3D[
  MapThread[(#1 (0.8 + 0.1 #2))&, {points, elevation}], 
  Mesh->None, MaxPlotPoints->100, 
  ColorFunction -> Function[{x,y,z}, rainbow[1.5(2 Norm[{x,y,z}]-1)]], 
  ColorFunctionScaling -> False, 
  Lighting->None,
  PlotStyle->Directive["Shadows"->True, "CastShadow"->True],

  Prolog -> {
    Directive["Shadows"->True, "CastShadow"->True],
    clouds,
    HemisphereLight[LightBlue, Orange // Darker // Darker],
    SpotLight[Orange, lightPos // Offload],
    EventHandler[Sphere[lightPos, 0.001], {"transform" -> Function[t,
      lightPos = t["position"]
    ]}]
  },
  
  Background->Black
, ImageSize->800]

Анимация

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

rainbow = ColorData["DarkRainbow"];
lightPos = {-2.4909, 4.069, 3.024};

rotationMatrix = RotationMatrix[0., {0,0,1}];
angle = 0.;

animation = CreateUUID[];

EventHandler[animation, Function[Null,
  lightPos = RotationMatrix[1 Degree, {1,1,1}].lightPos;
  rotationMatrix = RotationMatrix[angle, {0,0,1}];
  angle += 0.5 Degree;
]];

ListSurfacePlot3D[
  MapThread[(#1 (0.8 + 0.1 #2))&, {points, elevation}], 
  Mesh->None, MaxPlotPoints->100, 
  ColorFunction -> Function[{x,y,z}, rainbow[1.5(2 Norm[{x,y,z}]-1)]], 
  ColorFunctionScaling -> False, 
  Lighting->"Default",
  PlotStyle->Directive["Shadows"->True, "CastShadow"->True],

  Prolog -> {
    Directive["Shadows"->True, "CastShadow"->True],
    GeometricTransformation[clouds, rotationMatrix // Offload],
    HemisphereLight[LightBlue, Orange // Darker // Darker],
    SpotLight[Orange, lightPos // Offload]
  },

  Epilog -> AnimationFrameListener[lightPos // Offload, "Event"->animation],
  Background->Black
]

Чтобы запустить движение нужно "пнуть" обработчик, тогда обновление позиции источника света вызовет прерывание, которое снова обновит позицию светового тела и так далее синхронно с циклом отрисовки браузера

Посмотреть в живую

Среда разработки

Все примеры были показаны в открытой среде-разработки-блокноте WLJS Notebook

Спасибо за внимание ??‍♂️

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


  1. KirillBelovTest
    09.01.2025 16:10

    Хабру нужно больше таких статей!


  1. karmael
    09.01.2025 16:10

    все прекрасно сударь, и не принимайте близко к сердцу, но!

    1. Ну Солнце то казалось бы физику, бог велел по орбите крутить, ну пускай даже вокруг "земли"

    2. Нуба в ДЗЗ выдают полюса =)


    1. JerryI Автор
      09.01.2025 16:10

      ;D спасибо

      1. Так и выходит, солнце матрицей поворота крутит по орбите (система отсчета на Земле)

      2. Блин, пытался раскодировать, так и не понял про Д33. ;(


      1. JerryI Автор
        09.01.2025 16:10

        А понял, вы про полюса облаков. Я так кстати и не понял, как можно меш квадратный натянуть на сферу. Вычислительная геометрия подсказывает, что без искажений - никак. А marching cubes на большом поле делать довольно дорого (если сразу шум генерить на сфере)

        Так что - если знаете - пишите!


        1. karmael
          09.01.2025 16:10

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

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

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


          1. JerryI Автор
            09.01.2025 16:10

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

            Спасибо! Попробую


  1. iushakov
    09.01.2025 16:10

    Другой подход к созданию планеты показан тут - https://m.youtube.com/watch?v=lctXaT9pxA0

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


    1. JerryI Автор
      09.01.2025 16:10

      Верно! Тоже видел, и как я понял, облака у него шейдером сделаны. Довольно физично


  1. JerryI Автор
    09.01.2025 16:10

    Попробовал улучшить ситуацию. Берем текстуру шума (на этот раз 256х128)

    n = 256;
    k2 = Outer[Plus, #, #] &[RotateRight[N@Range[-n, n - 1, 2]/n, n/2]^2];
    
    spectrum = With[{d := RandomReal[NormalDistribution[], {n, n}]},
       (1/n) (d + I d)/(0.002 + k2)]; 
    spectrum[[1, 1]] *= 0;
    
    im[p_] := Clip[Re[InverseFourier[spectrum Exp[I p]]], {0, ∞}]^0.5
    
    p0 = p = Sqrt[k2];
    
    testImg = im[p0 += p][[128;;, All]] // Image 

    преобразуем

    lat[y_, rad_:1] := ArcSin[(2 theta[y, rad] + Sin[2 theta[y, rad]])/Pi];
    lon[x_, y_, rad_:1, lonzero_: 0] := lonzero + Pi x/(2 rad Sqrt[2] Cos[theta[y, rad]]);
    theta[y_, rad_:1] := ArcSin[y/(rad Sqrt[2])];
    mollweidetoequirect[{x_, y_}] := {lon[x, y], lat[y]};
    
    testImg = ImageForwardTransformation[
      testImg,
      mollweidetoequirect,
      DataRange -> {{-2 Sqrt[2], 2 Sqrt[2]}, {-Sqrt[2], Sqrt[2]}},
      PlotRange -> All
    ]

    Получаем следующее изображение

    Затем, как обычно Marching Cubes

    With[{plain = ImageData[testImg]}, Table[plain Exp[-( i)^2/200.], {i, -20,20}]];
    
    {vertices, normals} = CMarchingCubes[%, 0.2, "CalculateNormals" -> False];
    vertices = With[{
      offset = {Min[vertices[[All,1]]], Min[vertices[[All,2]]], 0},
      maxX = Max[vertices[[All,1]]] - Min[vertices[[All,1]]],
      maxY = Max[vertices[[All,2]]] - Min[vertices[[All,2]]]
    }, Map[Function[v, With[{p = v - offset}, {p[[1]]/maxX, p[[2]]/maxY, p[[3]]}]], vertices]];

    Теперь проецируем эти вершины на сферу

    pvertices = Map[Function[v,
        With[{\[Rho] = 50.0 + 0.25 (v[[3]] - 10), \[Phi] =  2 Pi Clip[v[[1]], {0,1}], \[Theta] =  Pi Clip[v[[2]], {0,1}]},
          {\[Rho] Cos[\[Phi]] Sin[\[Theta]], \[Rho] Sin[\[Phi]] Sin[\[Theta]], \[Rho] Cos[\[Theta]]}
        ]
      ]
    , vertices];
    
    {
      clouds = GraphicsComplex[0.017 pvertices, Polygon[1, Length[vertices]]]
    } // Graphics3D

    Видно другие артефакты, однако, на этот раз из-за того, что текстуру нельзя замостить, как я предполагаю...

    И это тоже интересная задача!


  1. olegat19653
    09.01.2025 16:10

    Это что ИИ?