From Shader to Metaball, Part Ⅰ

Shu,
Back

I started getting into creative coding since COVID-19 (you can find some of them here). Two months ago I did some research on visualizing randomly generated metaballs, and here’s what I got as the result:

metaballs.vercel.app

In the meantime a lot of people have reached out to me about the tech details, and I think it might be fun to write about it. So in my following blog posts I’ll share how you can create the same thing, without too much knowledge about computer graphics.

In this first part let’s talk about shaders.

Shaders

What are shaders? You probably already knew the tixy.land website created by @aemkei. From Martin’s own words:

Control the size and color of a 16x16 dot matrix with a single JavaScript function.

The tixy function, is almost a fragment shader. It takes the coordinates of a screen pixel and returns the corresponding color of that pixel. In short, it’s a “(x, y) => color” function.

Of course you can also pass other parameters to the shader, such as mouse position, time, window size, etc. But overall this is a simple and powerful concept. Hundreds of beautiful animations were created with it, you can find them from that Twitter thread (and here is mine).

A Simple Example

For example, we have the following simplified shader function that returns the brightness of that coordinate:

// `x` and `y` range from 0 to 1, representing the coordinate of a pixel.
// A number between 0 and 1 is returned, representing the brightness of it.
function myShader(x, y) {
  return x
}

Say we’re running this shader function for each pixel of a 9×9 screen, it will return the following values for each pixel:

yx
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8
0/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
1/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
2/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
3/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
4/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
5/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
6/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
7/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1
8/8
0
0.125
0.25
0.375
0.5
0.625
0.75
0.875
1

Since the return number represents the brightness of that pixel, the entire screen will look like this:

yx
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8

So, we’ve created a simple gradient shader. Now if we change the shader function to return (x + y) / 2 instead of just x, you will get this (think about it!):

yx
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8

Disk

To draw 3-dimentianal metaballs, we need to think about 2d first. What’s the 2d reference of a 3d ball ? Some might think it should be a circle, but actually a ball in 2d should be a disk, and that’s fundamentally different from a circle.

In mathematics, a ball is different from a sphere: a ball is the volume space bounded by a sphere. Similarily, a disk is the region in a plane bounded by a circle. So, we will be using the term disk instead of circle here.

Why is this important? Because in shaders, something to keep in mind is we’re usually tackling shapes with the “space” inside them, not just the “border” (will get this covered in future parts). Let’s assume that there is a disk centered at (0.5, 0.5) on the screen. And this time we define each pixel’s brightness with the distance from the center of the disk to that pixel:

function myShader(x, y) {
  return Math.sqrt((x - 0.5) * (x - 0.5) + (y - 0.5) * (y - 0.5))
}

The value table should be:

yx
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8
0/8
0.71
0.63
0.56
0.52
0.50
0.52
0.56
0.63
0.71
1/8
0.63
0.53
0.45
0.40
0.38
0.40
0.45
0.53
0.63
2/8
0.56
0.45
0.35
0.28
0.25
0.28
0.35
0.45
0.56
3/8
0.52
0.40
0.28
0.18
0.13
0.18
0.28
0.40
0.52
4/8
0.50
0.38
0.25
0.13
0.00
0.13
0.25
0.38
0.50
5/8
0.52
0.40
0.28
0.18
0.13
0.18
0.28
0.40
0.52
6/8
0.56
0.45
0.35
0.28
0.25
0.28
0.35
0.45
0.56
7/8
0.63
0.53
0.45
0.40
0.38
0.40
0.45
0.53
0.63
8/8
0.71
0.63
0.56
0.52
0.50
0.52
0.56
0.63
0.71

And it looks like this:

yx
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8

Since we use the distance to (0.5, 0.5) as the brightness value, the pixels nearer to the screen center get darker.

How can we draw a disk? It isn’t hard to think about. If we choose 0.5 as the radius of our disk, we can simply return 0 as the brightness (black) if the distance is less or equal than 0.5. Otherwise we will return 1 (white):

function myShader(x, y) {
  const d = Math.sqrt((x - 0.5) * (x - 0.5) + (y - 0.5) * (y - 0.5)) - 0.5
  return d > 0 ? 1 : 0
}

Also we can refactor the code a bit:

function distanceToDisk(x, y) {
  return Math.sqrt((x - 0.5) * (x - 0.5) + (y - 0.5) * (y - 0.5)) - 0.5
}

function myShader(x, y) {
  return distanceToDisk(x, y) > 0 ? 1 : 0
}

And we can see the shape of a disk (the numbers represent the distanceToDisk value):

yx
0/8
1/8
2/8
3/8
4/8
5/8
6/8
7/8
8/8
0/8
0.21
0.13
0.06
0.02
0
0.02
0.06
0.13
0.21
1/8
0.13
0.03
-0.05
-0.1
-0.12
-0.1
-0.05
0.03
0.13
2/8
0.06
-0.05
-0.15
-0.22
-0.25
-0.22
-0.15
-0.05
0.06
3/8
0.02
-0.1
-0.22
-0.32
-0.37
-0.32
-0.22
-0.1
0.02
4/8
0
-0.12
-0.25
-0.37
-0.5
-0.37
-0.25
-0.12
0
5/8
0.02
-0.1
-0.22
-0.32
-0.37
-0.32
-0.22
-0.1
0.02
6/8
0.06
-0.05
-0.15
-0.22
-0.25
-0.22
-0.15
-0.05
0.06
7/8
0.13
0.03
-0.05
-0.1
-0.12
-0.1
-0.05
0.03
0.13
8/8
0.21
0.13
0.06
0.02
0
0.02
0.06
0.13
0.21

And if we make the screen larger, the shape will be smoother. This is how we draw a “2d ball” with shaders:

Signed Distance Field (SDF)

The distanceToDisk function above, is a SDF function:

function distanceToDisk(x, y) {
  return Math.sqrt((x - 0.5) * (x - 0.5) + (y - 0.5) * (y - 0.5)) - 0.5
}

It simply returns the signed distance from a coordinate to the border of a given disk. Sometimes, we can write it as the following to make it reusable:

function distanceToDisk(x, y, ox, oy, r) {
  return Math.sqrt((x - ox) * (x - ox) + (y - oy) * (y - oy)) - r
}

SDF is another simple and powerful concept that we will write about it in Part Ⅱ. But if you write JavaScript, I think you are already familiar with the idea:

// Sort an array with numbers.
arr.sort((a, b) => {
  return a - b
})

And we are calling it “signed distance from b to a”.

:-)

References


Twitter · GitHub · Instagram · g@shud.inCC BY-NC 4.0 © Shu Ding.RSS