How do I draw a ball? It seems that JS and CSS don’t provide this capability, and it’s certainly not possible to introduce Threejs just to draw a ball. This article will introduce 4 methods of drawing balls. Each method has different characteristics. The data generated by balls can be rendered in any way, from canvas to DOM to achieve some of the tabbed balls in blogs. At the end of the article, I will draw more complex and cool 3D shapes based on the previous knowledge.

Click on woopen.github. IO /sphere/ for an online preview.

The standard ball

The standard Sphere, also known as the UV Sphere, is the most common method of drawing the Sphere. To understand it, take a look at a common plan of the earth.

As you can see from this diagram, the longitudes are columns going from 180 degrees east to 180 degrees west, and the parallels are rows going from 90 degrees north to 90 degrees south. The longitude and latitude lines cross each other to form a grid, and you just need to take the vertices on the grid and turn them into 3d Spherical coordinates using the Spherical Coordinate System. Open this article and you will find the following formula.

Where R is radius, Theta is latitude, and Phi is longitude. Now that you know the formula above, you can draw a sphere.

function createSphere(total = 10) {
	const points = []
    let lat, lon, x, y, z
    for (let i = 0; i <= total; ++i) {
    	lat = i * Math.PI / total / / latitude
    	for (let j = 0; j <= total; ++j) {
          lon = j * 2 * Math.PI / total / / longitude
          x = Math.sin(lat) * Math.cos(lon)
          y = Math.sin(lat) * Math.sin(lon)
          z = Math.cos(lat)
          // In order to facilitate the corresponding view, put some formulas that can be evaluated in the first layer into this layer
          points.push([x, y, z])
        }
    }
    return points
}
Copy the code

Use total to indicate the number of longitude and latitude lines, and use the formula to find the position of each point on the grid. I’m missing the r here, because I want to return the unit sphere, so I’m ignoring the r equals 1 here. The default is 10 warp and weft lines, and as the warp and weft lines get larger, the resulting ball will have a smoother surface.

With this data we can render in a variety of ways.

With canvas rendering

const canvas = document.createElement('canvas')
canvas.width = 800; canvas.height = 800
document.body.appendChild(canvas)

const ctx = canvas.getContext('2d')
const points = createSphere()

ctx.clearRect(0.0, canvas.width, canvas.height)
ctx.save();
ctx.lineWidth = 5 / canvas.width
ctx.translate(canvas.width / 2, canvas.height / 2) // Move to the middle
ctx.scale(canvas.width / 5, canvas.height / 5) Enlarge / /
ctx.beginPath()
ctx.moveTo(xys[0] [0], xys[0] [1])
for (let i = 1, l = xys.length; i < l; i++) {
  ctx.lineTo(xys[i][0], xys[i][1])
}
ctx.stroke(); ctx.restore()
Copy the code

You can see that the poles of the ball are pointing at us, but we want the poles to be on the Y-axis.

function createSphere(total = 10) {
/ /...
          x = Math.sin(lat) * Math.cos(lon)
          y = Math.sin(lat) * Math.sin(lon)
          z = Math.cos(lat)
          points.push([y, z, x]) // Change the position
/ /...
}
Copy the code

I put the poles on the Y-axis by replacing x, y, and z. But now there is no perspective effect, it doesn’t look like a sphere, let’s add perspective effect.

const xys = points.map((point) = > {
  const x = point[0], y = point[1], z = point[2]
  const zToD = 2 - z
  return [x / zToD, y / zToD]
})
/ /...
ctx.moveTo(xys[0] [0], xys[0] [1])
for (let i = 1, l = xys.length; i < l; i++) {
  ctx.lineTo(xys[i][0], xys[i][1])}Copy the code

The perspective effect is larger near and smaller far away, so we divide z by x and y. Because it’s a unit circle and z is minus 1 to 1. 2-z changes the value of z to 1 to 3. So this 2 here is actually how far we are from the ball, and as this value gets bigger we see that the ball gets further and further away from us.

Now let’s spin the ball. How do you spin the ball? Also go to Wikipedia and find the Rotation matrix, we want the ball to rotate around the Y-axis, and you can find the following Rotation matrix.

let rotate = 0
function draw() {
  ctx.clearRect(0.0, canvas.width, canvas.height)
  ctx.save()
  ctx.lineWidth = 5 / canvas.width
  ctx.translate(canvas.width / 2, canvas.height / 2) // Move to the middle
  ctx.scale(canvas.width / 5, canvas.height / 5) Enlarge / /
  const xys = points.map((point) = > {
    const x = point[0] * Math.cos(rotate) + point[2] * Math.sin(rotate)
    const y = point[1]
    const z = -1 * point[0] * Math.sin(rotate) + point[2] * Math.cos(rotate)
    const zToD = 2 - z
    return [x / zToD, y / zToD]
  })
  ctx.beginPath()
  ctx.moveTo(xys[0] [0], xys[0] [1])
  for (let i = 1, l = xys.length; i < l; i++) {
    ctx.lineTo(xys[i][0], xys[i][1])
  }
  ctx.stroke()
  ctx.restore()
  rotate += 0.05
  requestAnimationFrame(draw)
}
Copy the code

Here we increase the total value, and you can see that the ball is more rounded.

With CSS 3 rendering

We can also use CSS to render, using CSS3 perspective and animation properties can be very convenient to render out.

body {
  font-size: 12px;
  perspective: 500px;
  overflow: hidden;
}

#app {
  width: 100vw;
  height: 100vh;
  transform-style: preserve-3d;
  animation: rot 5s linear infinite normal;
}

#app div {
  position: absolute;
  left: 50%;
  top: 50%;
}

@keyframes rot {
  100% { transform: rotateY(1turn); }}Copy the code
const points = createSphere()
const app = document.querySelector('#app')
const html = points.map((p, i) = > {
  return `<div style="transform: translate3d(${p[0] * 100}px, ${p[1] * 100}px, ${p[2] * 100}px)">${i}</div>`
})
app.innerHTML = html.join(' ')
Copy the code

It only takes a few lines of code to render. But you can see that the poles of the ball have a lot of overlap, and there are two meridians that overlap.

The poles repeat because the first row and the last row are at the poles, and the two longitudes repeat because the range of longitudes in the formula above is [0, 2PI] and it doesn’t include 2PI.

function createSphere(meridians = 10, parallels = 10) {
  const points = []
  points.push([0.1.0]) // Manually fill in the first line
  let lat, lon, x, y, z
  for (let i = 1; i < parallels; ++i) { / / two lines
      lat = i * Math.PI / parallels
      for (let j = 0; j < meridians; ++j) { / / a column
        lon = j * 2 * Math.PI / meridians
        x = Math.sin(lat) * Math.cos(lon)
        y = Math.sin(lat) * Math.sin(lon)
        z = Math.cos(lat)
        points.push([y, z, x])
    }
  }
  points.push([0, -1.0]) // Manually fill in the last line
  return points
}
Copy the code

This gives you a ball with no points repeating vertices.

Using webgl rendering

With WebGL rendering, we also need to modify the createSphere method, because webGL uses triangles and we need to connect every 3 related points into a triangle.

function createSphere(meridians = 10, parallels = 10) {
  let vertices = [], points = [];

  vertices.push([0.1.0])
  let lat, lon, x, y, z;
  for (let i = 1; i < parallels; ++i) {
    lat = i * Math.PI / parallels
    for (let j = 0; j < meridians; ++j) {
      lon = j * 2 * Math.PI / meridians
      x = Math.sin(lat) * Math.cos(lon)
      y = Math.sin(lat) * Math.sin(lon)
      z = Math.cos(lat)
      vertices.push([y, z, x])
    }
  }
  vertices.push([0, -1.0])

  function tri(a, b, c) { // Add trianglespoints.push(... vertices[a], ... vertices[b], ... vertices[c]) }function quad(a, b, c, d) { // Divide a grid into two triangles
    tri(b, a, c)
    tri(b, c, d)
  }

  for (let i = meridians; i > 0; --i) { // Calculate the top triangle
    tri(0, i - 1 || meridians, i) // Connect to the first when it is the last
  }
  let aStart, bStart
  for (let i = 0; i < parallels - 2; ++i) { // Calculate the middle mesh triangle
    aStart = i * meridians + 1
    bStart = (i + 1) * meridians + 1
    for (let j = 0; j < meridians; ++j) {
      quad(aStart + j, aStart + (j + 1) % meridians, bStart + j, bStart + (j + 1) % meridians)
      // when the last column is connected to the first column}}for (let l = vertices.length - 1, i = l - meridians; i < l; ++i) { // Calculate the bottom triangle
    tri(l, i + 1 === l ? l - meridians : i + 1, i)
  }

  return new Float32Array(points)
}
Copy the code

Above we handled the first and last lines manually. Note that you need to concatenate the last column to the first column when it is the most important column.

const vs = ` attribute vec4 a_position; uniform mat4 u_mvp; void main() { gl_Position = u_mvp * a_position; } `
const fs = ` precision mediump float; Void main() {gl_FragColor = vec4(0.1, 0.8, 0.5, 1.); } `
/ / to omit
let rotate = 0
const sphere = createSphere(15.15)
const count = sphere.length / 3
function draw() {
  / / to omit
  gl.drawArrays(gl.LINE_STRIP, 0, count);
  requestAnimationFrame(() = > { rotate += 0.03; draw() })
}
draw()
Copy the code

Because the WebGL code is verbose, a lot of the relevant code is omitted here, except for the two shaders and the final rendering method, which can be seen here.

cube

You can also get a sphere from a cube, which is similar to a Rubik’s cube, where each face is a grid.

First you generate this cube, and then you normalize the top points, and that gives you the unit sphere.

To make one of these cubes, we need three cycles, each face, each row, and each column of the cube. I can define the starting point of each face, the end point of each face to the right and the end point of each face, and then multiply by the corresponding step size in each cycle, and I get this cube.

function createNormalizedCubeSphere(divisions = 5) {
  const vertices = [], points = []

  const origins = [
    [-1, -1, -1], [1, -1, -1], [1, -1.1], [...1, -1.1], [...1.1, -1], [...1, -1.1]]const rights = [
    [2.0.0], [0.0.2], [...2.0.0],
    [0.0, -2], [2.0.0], [2.0.0]]const ups = [
    [0.2.0], [0.2.0], [0.2.0],
    [0.2.0], [0.0.2], [0.0, -2]]const step = 1 / divisions // The length of each step
  let origin, right, up
  for (let i = 0 ; i < 6; ++i) {
    origin = origins[i]
    right = rights[i]
    up = ups[i]
    for (let j = 0; j <= divisions; ++j) {
      for (let k = 0; k <= divisions; ++ k) {
        const ur = vec3.add([], vec3.scale([], up, j * step), vec3.scale([], right, k * step)) // The distance to go right and to go up
        vertices.push(
          vec3.normalize([], vec3.add([], origin, ur)) // Add the distance to the starting point and normalize)}}}// All the vertices of the sphere have been generated above, but need to be triangulated to render in WebGL

  function tri(a, b, c) { points.push(... vertices[a], ... vertices[b], ... vertices[c]) }function quad(a, b, c, d) {
    tri(b, a, c)
    tri(b, c, d)
  }

  const row = divisions + 1
  let a, c
  for (let i = 0; i < 6; ++i) {
    for (let j = 0; j < divisions; ++j) {
      for (let k = 0; k < divisions; ++k) {
      	// Get each small mesh and generate two triangles
        a = (i * row + j) * row + k
        c = (i * row + j + 1) * row + k
         quad(a, a + 1, c, c + 1)}}}return new Float32Array(points)
}
Copy the code

Vec3 in the above function represents a three-dimensional vector, and glMatrix is used here to help us carry out vector operations.

You can also render using WebGL. Below is the result after 5 subdivisions.

Regular tetrahedral subdivision

The sphericity can also be approximated by subdivision of the regular tetrahedron. A regular tetrahedron has four faces, each of which is a triangle.

By subdividing the triangles on each face. By taking the midpoints of each side of a triangle and connecting them, you can generate four small triangles, and then you repeat this process and subdivide them, and finally you can normalize the subdivided points and get a unit sphere.

You can find out from this article the coordinates of the four vertices of Tetrahedron.

function createTetrahedronSphere(count = 0) {
  const points = []

  const ta = [0.0, -1]
  const tb = [Math.sqrt(8 / 9), 0.1 / 3]
  const tc = [-1 * Math.sqrt(2 / 9), Math.sqrt(2/ 3), 1 / 3]
  const td = [-1 * Math.sqrt(2 / 9), -1 * Math.sqrt(2/ 3), 1 / 3]

  function tri(a, b, c) { points.push(... a, ... b, ... c) }function divide(a, b, c, count) {
    if (count > 0) {
      const ab = vec3.normalize([], vec3.scale([], vec3.add([], a, b), 0.5))
      const ac = vec3.normalize([], vec3.scale([], vec3.add([], a, c), 0.5))
      const bc = vec3.normalize([], vec3.scale([], vec3.add([], b, c), 0.5))
	  // Get the midpoint of each edge and subdivide the four smaller triangles
      divide(a, ab, ac, count - 1)
      divide(ab, b, bc, count - 1)
      divide(bc, c, ac, count - 1)
      divide(ab, bc, ac, count - 1)}else {
      tri(a, b, c)
    }
  }

  // Subdivide 4 surfaces
  divide(ta, tc, tb, count)
  divide(ta, td, tc, count)
  divide(ta, tb, td, count)
  divide(tb, tc, td, count)

  return new Float32Array(points)
}
Copy the code

Below is the result after subdividing 3 times.

Regular icosahedral subdivision

Regular icosahedral subdivision is very similar to regular tetrahedral subdivision. A regular tetrahedron we find its four vertices and subdivide its four faces, a regular icosahedron we find 12 vertices and subdivide its 20 faces. Why don’t you just subdivide it a few times if it’s so similar to a regular tetrahedron? This is because regular icosahedral subdivision produces triangular spheres of the same size.

Regular Icosahedron can be known from this article.

function createIcosahedronSphere(count = 0) {
  const points = []

  const t = (1 + Math.sqrt(5)) / 2
  const v1 = vec3.normalize([], [-1, t, 0])
  const v2 = vec3.normalize([], [1, t, 0])
  const v3 = vec3.normalize([], [-1, -t, 0])
  const v4 = vec3.normalize([], [1, -t, 0])
  const v5 = vec3.normalize([], [0, -1, t])
  const v6 = vec3.normalize([], [0.1, t])
  const v7 = vec3.normalize([], [0, -1, -t])
  const v8 = vec3.normalize([], [0.1, -t])
  const v9 = vec3.normalize([], [t, 0, -1])
  const v10 = vec3.normalize([], [t, 0.1])
  const v11 = vec3.normalize([], [-t, 0, -1])
  const v12 = vec3.normalize([], [-t, 0.1])

  function tri(a, b, c) { points.push(... a, ... b, ... c) }function divide(a, b, c, count) {
    if (count > 0) {
      const ab = vec3.normalize([], vec3.scale([], vec3.add([], a, b), 0.5))
      const ac = vec3.normalize([], vec3.scale([], vec3.add([], a, c), 0.5))
      const bc = vec3.normalize([], vec3.scale([], vec3.add([], b, c), 0.5))

      divide(a, ab, ac, count - 1)
      divide(ab, b, bc, count - 1)
      divide(bc, c, ac, count - 1)
      divide(ab, bc, ac, count - 1)}else {
      tri(a, b, c)
    }
  }

  divide(v1, v12, v6, count)
  divide(v1, v6, v2, count)
  divide(v1, v2, v8, count)
  divide(v1, v8, v11, count)
  divide(v1, v11, v12, count)
  divide(v2, v6, v10, count)
  divide(v6, v12, v5, count)
  divide(v12, v11, v3, count)
  divide(v11, v8, v7, count)
  divide(v8, v2, v9, count)
  divide(v4, v10, v5, count)
  divide(v4, v5, v3, count)
  divide(v4, v3, v7, count)
  divide(v4, v7, v9, count)
  divide(v4, v9, v10, count)
  divide(v5, v10, v6, count)
  divide(v3, v5, v12, count)
  divide(v7, v3, v11, count)
  divide(v9, v7, v8, count)
  divide(v10, v9, v2, count)

  return new Float32Array(points)
}
Copy the code

The figure below is the result of subdivision 2 times. You can see that all triangles are the same size.

SuperShapes

With all this knowledge, we can actually draw more than spheres, and with just a few modifications to the first ball drawing method we can draw a lot of cool shapes!

The first thing we need to know is the formula of superShape, which is available on this website.

Turn this formula into a function.

function superShape(theta, m, n1, n2, n3, a = 1, b = 1) {
  return (Math.abs((1 / a) * Math.cos(m * theta / 4)) ** n2 + Math.abs((1 / b) * Math.sin(m * theta / 4)) ** n3) ** (-1 / n1)
}
Copy the code

And then I’m going to modify the first way I drew the ball.

function createSuperShape(meridians = 90, parallels = 90) {
  const vertices = [], points = []

  let lat, lon, x, y, z, r1, r2;
  for (let i = 0; i <= parallels; ++i) {
    lat = i * Math.PI / parallels - (Math.PI / 2)
    r2 = superShape(lat, 10.3.0.2.1)
    for (let j = 0; j <= meridians; ++j) {
      lon = j * 2 * Math.PI / meridians - Math.PI
      r1 = superShape(lon, 5.7.0.5.1.2.5)
      x = r1 * Math.cos(lon) * r2 * Math.cos(lat)
      y = r1 * Math.sin(lon) * r2 * Math.cos(lat)
      z = r2 * Math.sin(lat)
      vertices.push([x, y, z])
    }
  }

  function tri(a, b, c) { points.push(... vertices[a], ... vertices[b], ... vertices[c]) }function quad(a, b, c, d) {
    tri(a, d, c)
    tri(a, b, d)
  }

  const row = parallels + 1
  let p1, p2
  for (let i = 0; i < parallels; ++i) {
    for (let j = 0; j < meridians; ++j) {
      p1 = i * row + j
      p2 = p1 + row
      quad(p1, p1 + 1, p2, p2 + 1)}}return new Float32Array(points)
}
Copy the code

Below is the result rendered in WebGL. You can change the parameters of superShape to create a variety of shapes, and of course you can dynamically transition from one shape to another.

conclusion

This article introduces a total of four methods to draw a sphere, each sphere has different characteristics and different application scenarios, the standard sphere at the poles of the triangle is small, the triangle near the equator is large. Cube subdivision and regular tetrahedron subdivision of the sphere, face to face where the triangle is small. The icosahedral subdivision of the sphere all the triangles are the same size.

Online preview: woopen.github. IO /sphere/

Source: github.com/woopen/sphe…

reference

  • Four Ways to Create a Mesh for a Sphere
  • Spherical coordinate system
  • Mapping a Cube to a Sphere
  • Tetrahedron
  • Regular icosahedron
  • 3d supershapes
  • Supershapes (Superformula)
  • Interactive Computer Graphics