Потрясающие точечные сферы с WebGL

Nov 30 2022
В последнее время красивые интерактивные глобусы WebGL оказались в центре внимания, поскольку Stripe и GitHub размещают их на своих домашних страницах. Позже каждый из них написал сообщение в блоге о том, как они это сделали (Stripe здесь и GitHub здесь, если вам интересно).

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

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

Настройка сцены

Прежде чем идти дальше, нам нужно создать базовую сцену WebGL, в которой будет построена сфера. Я использую Three.js как фактический фреймворк для взаимодействия с WebGL API. Я постараюсь, чтобы фрагменты кода в этой статье были как можно более краткими и релевантными; изучите любую из встроенных песочниц, чтобы получить полный код.

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

// Set up the scene.
const scene = new THREE.Scene();

// Define an array to hold the geometries of all the dots.
const dotGeometries = [];

// Create a blank vector to be used by the dots.
const vector = new THREE.Vector3();

// We'll create and position the dots here!

// Merge all the dot geometries together into one buffer geometry.
const mergedDotGeometries = BufferGeometryUtils.mergeBufferGeometries(
  dotGeometries
);

// Define the material for the dots.
const dotMaterial = new THREE.MeshBasicMaterial({
  color: DOT_COLOR,
  side: THREE.DoubleSide
});

// Create the dot mesh from the dot geometries and material.
const dotMesh = new THREE.Mesh(mergedDotGeometries, dotMaterial);

// Add the dot mesh to the scene.
scene.add(dotMesh);

Основной подход

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

Во-первых, мы определяем углы phiи для каждой точки. thetaЭти углы составляют часть сферической системы координат, системы точного определения положения точки в трехмерном пространстве по отношению к ее началу (которое в нашем случае является центром нашей сферы).

Во- вторых, phiи то, и thetaдругое измеряется в радианах, а не в градусах. Ключ к этому — помнить, что в 180º содержится π радиан . Итак, чтобы найти phiздесь, все, что нам нужно сделать, это разделить π на количество линий широты. Но чтобы найти theta, нам нужно разделить 2 * πна количество линий долготы, потому что мы хотим, чтобы наши линии долготы продолжались вокруг полных 360º сферы.

// Loop across the latitudes.
for (let lat = 0; lat < LATITUDE_COUNT; lat += 1) {
  // Loop across the longitudes.
  for (let lng = 0; lng < LONGITUDE_COUNT; lng += 1) {
    // Create a geomtry for the dot.
    const dotGeometry = new THREE.CircleGeometry(DOT_SIZE, 5);
    // Defin the phi and theta angles for the dot.
    const phi = (Math.PI / LATITUDE_COUNT) * lat;
    const theta = ((2 * Math.PI) / LONGITUDE_COUNT) * lng;

    // Set the vector using the spherical coordinates generated from the sphere radius, phi and theta.
    vector.setFromSphericalCoords(SPHERE_RADIUS, phi, theta);

    // Make sure the dot is facing in the right direction.
    dotGeometry.lookAt(vector);

    // Move the dot geometry into position.
    dotGeometry.translate(vector.x, vector.y, vector.z);

    // Push the positioned geometry into the array.
    dotGeometries.push(dotGeometry);
  }
}

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

Филлотаксический подход

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

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

// Loop across the number of dots.
for (let dot = 0; dot < DOT_COUNT; dot += 1) {
  // Create a geomtry for the dot.
  const dotGeometry = new THREE.CircleGeometry(DOT_SIZE, 5);

  // Work out the spherical coordinates of each dot, in a phyllotaxis pattern.
  const phi = Math.acos(-1 + (2 * dot) / DOT_COUNT);
  const theta = Math.sqrt(DOT_COUNT * Math.PI) * phi;

  // Set the vector using the spherical coordinates generated from the sphere radius, phi and theta.
  vector.setFromSphericalCoords(SPHERE_RADIUS, phi, theta);

  ...

}

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

Линейный подход

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

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

// Loop across the latitude lines.
  for (let lat = 0; lat < LATITUDE_COUNT; lat += 1) {
    // Calculate the radius of the latitude line.
    const radius =
      Math.cos((-90 + (180 / LATITUDE_COUNT) * lat) * (Math.PI / 180)) *
      SPHERE_RADIUS;
    // Calculate the circumference of the latitude line.
    const latitudeCircumference = radius * Math.PI * 2 * 2;
    // Calculate the number of dots required for the latitude line.
    const latitudeDotCount = Math.ceil(latitudeCircumference * DOT_DENSITY);

    // Loop across the dot count for the latitude line.
    for (let dot = 0; dot < latitudeDotCount; dot += 1) {
      const dotGeometry = new THREE.CircleGeometry(DOT_SIZE, 5);
      // Calculate the phi and theta angles for the dot.
      const phi = (Math.PI / LATITUDE_COUNT) * lat;
      const theta = ((2 * Math.PI) / latitudeDotCount) * dot;

      ...

    }
  }

Итак, мы рассмотрели, как отобразить точки на сфере. А как насчет достижения более сложных эффектов?

Маскировка формы

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

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

// Initialise an image loader.
  const imageLoader = new THREE.ImageLoader();

  // Load the image used to determine where dots are displayed. The sphere
  // cannot be initialised until this is complete.
  imageLoader.load(MASK_IMAGE, (image) => {
    // Create an HTML canvas, get its context and draw the image on it.
    const tempCanvas = document.createElement("canvas");

    tempCanvas.width = image.width;
    tempCanvas.height = image.height;

    const ctx = tempCanvas.getContext("2d");

    ctx.drawImage(image, 0, 0);

    // Read the image data from the canvas context.
    const imageData = ctx.getImageData(0, 0, image.width, image.height);

    renderScene(imageData);
  });

// Utility function to convert a dot on a sphere into a UV point on a
// rectangular texture or image.
const spherePointToUV = (dotCenter, sphereCenter) => {
  // Create a new vector and give it a direction from the center of the sphere
  // to the center of the dot.
  const newVector = new THREE.Vector3();
  newVector.subVectors(sphereCenter, dotCenter).normalize();

  // Calculate the  UV coordinates of the dot and return them as a vector.
  const uvX = 1 - (0.5 + Math.atan2(newVector.z, newVector.x) / (2 * Math.PI));
  const uvY = 0.5 + Math.asin(newVector.y) / Math.PI;

  return new THREE.Vector2(uvX, uvY);
};

// Utility function to sample the data of an image at a given point. Requires
// an imageData object.
const sampleImage = (imageData, uv) => {
  // Calculate and return the data for the point, from the UV coordinates.
  const point =
    4 * Math.floor(uv.x * imageData.width) +
    Math.floor(uv.y * imageData.height) * (4 * imageData.width);

  return imageData.data.slice(point, point + 4);
};

// Move the dot geometry into position.
dotGeometry.translate(vector.x, vector.y, vector.z);

// Find the bounding sphere of the dot.
dotGeometry.computeBoundingSphere();

// Find the UV position of the dot on the land image.
const uv = spherePointToUV(
  dotGeometry.boundingSphere.center,
  new THREE.Vector3()
);

// Sample the pixel on the land image at the given UV position.
const sampledPixel = sampleImage(imageData, uv);

// If the pixel contains a color value (in other words, is not transparent),
// continue to create the dot. Otherwise don't bother.
if (sampledPixel[3]) {
  // Push the positioned geometry into the array.
  dotGeometries.push(dotGeometry);
}

Мы можем использовать более сложные изображения маски для создания таких форм, как этот эффект земли:

Или даже для рендеринга текста:

это обертка

Я использовал эти методы сферического отображения в различных местах в качестве основы для демонстрационных образцов WebGL. Надеюсь, они вдохновят вас сделать то же самое. Если вам понравилась эта статья или она вам как-то помогла, пожалуйста, дайте мне знать! Мой сайт здесь .