NOTE SPACE

STORES EXPERIMENT AND TRICK NOTES ABOUT COMPUTER GRAPHIC
Voronoi in ThreeJS GLSL
This note use ThreeJS version 157
Concept
The concept of Voronoi noise is to generate points randomly.
And, pixel colors depend on the distance to their nearest point. Let’s see below.
This image’s generated from Blender
The left image shows you the generated point position.
The right shows you how color is painted.
You can notice that the more pixels near a point, the darker it is.
You can assume that the black color is 0 and the white is 1.
So, the distance near points is near 0.
Set up scene
Create a basic ThreeJS scene with this HTML first. And, serve it as a static web.
Shader interface
Before implementation, I want to determine the interface of our shader first.
If you have some experience with GLSL texture, you would be similar with accessing texture with 2D position.
And, the position is between (0, 0) to (1, 1). For Voronoi noise, we will use the same concept.
Check out below.
float voronoi(vec2 position, vec2 cellNumber)
As you already see, I received the position of Voronoi noise texture.
And, cellNumber is the number of random points on the texture.
For the reason why cellNumber is vector 2D, I will talk about it in the next section.
Step 1: Generate cell point
From the
Concept section,
the first step of Voronoi noise generation is to generate random points first.
You can do it by generating points on the cell grid first like this.
<!-- ... -->
<!-- add this vertex shader tag -->
<script type="x-shader/x-vertex" id="vertex-shader">
precision mediump float;
out vec3 vPosition;
void main()
{
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
vPosition = position;
}
</script>
<!-- ... -->
<script type="module">
// ...
const vertexShader = document.getElementById('vertex-shader').innerHTML
const material = new THREE.ShaderMaterial({
fragmentShader: fragShader,
vertexShader: vertexShader, // add vertex shader
glslVersion: THREE.GLSL3,
})
// ...
</script>
Add vertex shader to the file and pass the position of the model as a varying to the fragment shader.
// fragment shader
precision mediump float;
in vec3 vPosition;
out vec4 fragColor;
float voronoi(vec2 position, vec2 cellNumber){
vec2 cellSize = 1. / cellNumber;
vec2 cellPosition = floor(position / cellSize) * cellSize + cellSize / 2.;
if(distance(position, cellPosition) < 0.01){
return 0.;
}
return 1.;
}
void main()
{
float result = voronoi(vPosition.xy / 1.5 * 0.5 + 0.5, vec2(10, 10));
fragColor = vec4(vec3(result), 1);
}
In the fragment shader, I set up the Voronoi noise function. It consists of 3 steps.
Cell size calculation - I divide 1 by cell number
Cell position calculation - I divide the position with cell size, and floor it to get the number of a current column and row of this pixel. And, multiply it with cell size to get the bottom-left edge position of the cell.
Cell center position - I plus cell position with cell size divided by 2.
Then, I render every pixel whose distance to the center point is less than 0.01.
Then, add a random offset to each point position.
// add random function
float random(in vec2 _st) {
return fract(sin(dot(_st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float voronoi(vec2 position, vec2 cellNumber){
vec2 cellSize = 1. / cellNumber;
vec2 cellPosition = floor(position / cellSize) * cellSize + cellSize / 2.;
// add offset
vec2 offsettedCellPosition = cellPosition + (random(cellPosition) * 2. - 1.) * cellSize / 2.;
if(distance(position, offsettedCellPosition) < 0.01){
return 0.;
}
return 1.;
}
I just call the random function which returns 0 to almost 1 and normalize it to -1 almost 1.
Then, I multiply it by half of the cell size and add it to the cell position.
Now, I got a new offsetted position.
Step 2: Find distance from the pixel to the nearest point
From the previous section, you may notice that the offset won’t push the point out of its cell.
So, to find the nearest point, All you should do is check the distance of all cells around your current cell and the current cell itself.
You can see that if the red point is the current pixel. The nearest point can
only be in the blue square area.
Let’s see the code.
float voronoi(vec2 position, vec2 cellNumber){
vec2 cellSize = 1. / cellNumber;
vec2 cellPosition = floor(position / cellSize) * cellSize + cellSize / 2.;
float maxDistance = distance(vec2(0.), cellSize);
float minDistance = maxDistance;
for(float offsetX = -1.; offsetX < 2.; offsetX++){
for(float offsetY = -1.; offsetY < 2.; offsetY++){
vec2 currentCellPosition = cellPosition + vec2(offsetX, offsetY) * cellSize;
vec2 offsettedCurrentCellPosition = currentCellPosition + (random(currentCellPosition) * 2. - 1.) * cellSize / 2.;
float currentDistance = distance(position, offsettedCurrentCellPosition);
if(minDistance > currentDistance){
minDistance = currentDistance;
}
}
}
return minDistance / maxDistance;
}
I loop over the grid and check if it has less distance than the current minimum distance.
Then, I return the distance normalized by max distance division so its value isn’t over 1.
For max distance, I use the distance from the cell’s bottom-left edge to its top-right edge because there’s no way that the nearest point for a pixel will be out of its cell.
Step 3: Tiling
I want you to look at the image below.
Do you see the wrinkle? I combined the rendering from the previous section into a single image. You can observe that the border of each tile isn’t seamless when they’re placed together.
This is because the next cell point that is out of texture isn’t in the same position as the cell point at the start of its axis.
// make value to restart if it's over 1 and less that 0
float wrap(float value){
if(value >= 1.){
return mod(value, 1.);
}
if(value < 0.){
return 1. - mod(value * -1., 1.);
}
return value;
}
vec2 wrap(vec2 value){
return vec2(wrap(value.x), wrap(value.y));
}
float voronoi(vec2 position, vec2 cellNumber){
vec2 cellSize = 1. / cellNumber;
vec2 cellPosition = floor(position / cellSize) * cellSize + cellSize / 2.;
float maxDistance = distance(vec2(0.), cellSize);
float minDistance = maxDistance;
for(float offsetX = -1.; offsetX < 2.; offsetX++){
for(float offsetY = -1.; offsetY < 2.; offsetY++){
vec2 currentCellPosition = cellPosition + vec2(offsetX, offsetY) * cellSize;
// get the position of the start of axis
vec2 wrapCellPosition = wrap(currentCellPosition);
// use wrap position to generate offset
vec2 offsettedCurrentCellPosition = currentCellPosition + (random(wrapCellPosition) * 2. - 1.) * cellSize / 2.;
float currentDistance = distance(position, offsettedCurrentCellPosition);
if(minDistance > currentDistance){
minDistance = currentDistance;
}
}
}
return minDistance / maxDistance;
}
To reset the position value going out of the texture, I create a wrap function that keeps the value in the 0 to 1 range.
And, wrap the position of each grid center before using it as a random seed for point offset.