Impressionantes esferas de pontos com WebGL

Nov 30 2022
Os belos e interativos globos WebGL tiveram um momento de destaque ultimamente, com o Stripe e o GitHub apresentando-os com destaque em suas páginas iniciais. Posteriormente, cada um escreveu uma postagem no blog sobre como eles fizeram isso (o do Stripe está aqui e o do GitHub aqui, se você estiver curioso).

Os belos e interativos globos WebGL tiveram um momento de destaque ultimamente, com o Stripe e o GitHub apresentando-os com destaque em suas páginas iniciais. Posteriormente, cada um escreveu uma postagem no blog sobre como eles fizeram isso (o do Stripe está aqui e o do GitHub aqui , se você estiver curioso).

Ambos os globos são compostos principalmente de pontos, o que me fez pensar nas várias maneiras pelas quais os pontos podem ser distribuídos pela superfície de uma esfera. O empacotamento de esferas é um quebra-cabeça complexo sob deliberação ativa de matemáticos, portanto, para os propósitos deste artigo, limitei-me a apresentar algumas abordagens básicas e como alcançá-las no WebGL.

Configurando a cena

Antes de prosseguir, precisamos estabelecer uma cena WebGL básica na qual construir a esfera. Estou usando o Three.js como a estrutura de escolha para interagir com a API WebGL. Tentarei manter os trechos de código neste artigo o mais concisos e relevantes possível; explore qualquer um dos Sandboxes incorporados para obter o código completo.

Depois de criar uma cena, estabelecemos uma dotGeometriesmatriz que eventualmente conterá as geometrias de todos os nossos pontos. Em seguida, criamos um vetor em branco, um ponto 3D no espaço dentro da cena, cuja posição será reatribuída cada vez que criarmos um ponto.

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

A abordagem básica

A maneira mais fácil de adicionar pontos a uma esfera é simplesmente definir o número de linhas de latitude e linhas de longitude que gostaríamos que a esfera tivesse e, em seguida, distribuir os pontos ao longo delas. Há algumas coisas importantes a serem observadas aqui.

Em primeiro lugar, estamos definindo os ângulos phie para cada ponto. thetaEsses ângulos fazem parte do sistema de coordenadas esféricas, um sistema para definir exatamente onde um ponto fica no espaço 3D em relação à sua origem (que no nosso caso é o centro da nossa esfera).

Em segundo lugar, phie thetaambos são medidos em radianos, não em graus. A chave para isso é lembrar que existem π ​​radianos em 180º . Então, para encontrar phiaqui, tudo o que precisamos fazer é dividir π pelo número de linhas de latitude. Mas para encontrar theta, precisamos dividir 2 * πpelo número de linhas de longitude porque queremos que nossas linhas de longitude continuem em torno de 360º da esfera.

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

Se você interagir com a esfera para girá-la, notará que os anéis na parte superior e inferior são muito mais compactados do que os do meio. Isso ocorre porque não variamos o número de pontos em cada linha de latitude. É aqui que entra o empacotamento de esferas.

A abordagem da filotaxia

Se você já olhou para a cabeça de um girassol ou para a base de uma pinha, notou um padrão incomum e distinto. Esse padrão, criado por um arranjo baseado na sequência de Fibonacci , é conhecido como filotaxia. Podemos usá-lo aqui para posicionar nossos pontos de forma que eles apareçam muito mais uniformemente espaçados sobre a superfície da esfera.

Desta vez, em vez de definir o número de linhas de latitude e longitude, simplesmente definimos o número total de pontos que queremos que apareçam na esfera. Em vez de percorrer as linhas de latitude, os pontos serão renderizados em uma espiral única e contínua de um pólo da esfera ao outro.

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

  ...

}

Isso é muito mais satisfatório. Mas e se quisermos agrupar os pontos o mais uniformemente possível, mas ainda tivermos a liberdade de definir o número de linhas de latitude?

A abordagem linear

Desta vez, definiremos o número de linhas de latitude necessárias, mas o número de pontos também será dimensionado com base na circunferência da linha de latitude em que estão posicionados. Para nos dar maior controle sobre o espaçamento, também definiremos um parâmetro de densidade de pontos.

A parte complicada aqui é calcular o raio de cada linha de latitude. Assim que tivermos isso, é relativamente simples descobrir quantos pontos exibir nele e, em seguida, encontrar phie thetapara cada um de maneira semelhante à primeira abordagem.

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

      ...

    }
  }

Então nós cobrimos como obter os pontos exibidos na esfera. Mas que tal conseguir efeitos mais complexos?

Máscara de forma

Descobrir como exibir os pontos em padrões cada vez mais complicados pode rapidamente se transformar em uma dor de cabeça matemática. No entanto, ao usar um dos arranjos de embalagem acima em combinação com uma imagem de máscara, podemos obter alguns efeitos extraordinários.

Para fazer isso, primeiro precisamos criar um elemento de tela HTML e desenhar nossa imagem de máscara nele. Esse elemento não será realmente renderizado na tela; é apenas um método conveniente para extrair os dados de pixel de uma imagem . Só precisamos fazer isso uma vez, então faremos isso antecipadamente e depois passaremos os dados da imagem extraída para nossa renderScenefunção.

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

Podemos usar imagens de máscara mais complexas para obter formas como este efeito de terra:

Ou até mesmo para renderizar texto:

Isso é um embrulho

Eu usei essas técnicas de mapeamento esférico em vários lugares como base para apresentações do WebGL. Espero que eles inspirem você a fazer o mesmo. Se você gostou deste artigo ou se ele te ajudou de alguma forma, por favor me avise! Meu site está aqui .