I’ve all the time seen myself as a visible learner, so I have a tendency to visualise an idea or course of in my thoughts each time I study a brand new matter. I additionally like to share my learnings as a visible article on my weblog, since I believe it may be helpful for others who additionally like to be taught issues visually.
In one of many posts, I discuss dithering, which is a course of to cut back the variety of colours in a picture. To visualise the mechanism behind dithering, I mapped a picture to a grid of 400 x 400 cubes and animate the colour, place, and measurement of these 160,000 cubes concurrently.
On this article, I’ll break down how I achieved this utilizing Three.js customized shaders. By the tip of this text, you’ll have the ability to animate 1000’s of objects in Three.js and hopefully have the ability to apply the identical strategy to a variety of use circumstances.
However first, let’s focus on the background and motivation behind the visualization.
Background
For my part, animation is a superb software for visualizing a course of. With animation, the reader can observe how an object’s state adjustments over time, making it clear precisely what a course of does to that object.
For instance, in one of many animations in my article, I attempt to illustrate what dithering does to a pixel: you evaluate a pixel’s shade with a threshold, then change the colour accordingly.
Utilizing animation, readers can observe how the colour of pixels adjustments as they undergo the brink map. It offers them visible steering on how dithering works and lets them see the way it impacts the picture pixels’ shade.
The animation above solely includes the modification of three properties: place, shade, and scale. Nevertheless, the problem arises when we have now to do that for all 160,000 cubes on the identical time.
For every animation body, we have now to calculate and replace 160,000 (cubes) x 3 (properties) = 480,000 properties.
And that is the place the customized shader in Three.js actually helps!
As a substitute of looping by way of every dice’s properties on the CPU and updating them one after the other, we are able to create a single set of directions in a customized shader that defines the colour, place, and scale for each dice concurrently. These directions run immediately on the GPU, calculating the state of all 160,000 cubes on the identical time. That is what retains the animation fluid and responsive.
Now, let’s transfer on to the implementation half.
Implementation
1. Setup Three.js
First, let’s arrange our Three.js scene and digital camera. This can be a fairly customary Three.js setup, so I can’t clarify it an excessive amount of. The entire code for this step is offered within the repository on the setup-three-js department.
All through this tutorial, you will discover the whole code for every step by testing its respective department.
End result
If you run the code at this level, you will note this clean scene.

Subsequent, let’s begin including our objects (the cubes) to the scene.
2. Draw the Cubes
The code for this step is offered on the draw-cubes department.
I created a Grid class to deal with the logic of drawing the cubes and place them in a grid association. It additionally has a helper operate to assist us present and conceal the grid from the scene.
Listed below are what we’re going to do at this step:
- Calculate the place of every dice within the grid.
- Draw the cubes utilizing Three.js
InstancedMesh. - Write a easy
vertexShaderandfragmentShaderfor the cubes. - Add the cubes to our scene.
Be aware: All through the code, I’ll check with the person items that kind the grid (in our case, the cubes) as “cells.” So, while you see
cellwithin the code, simply understand it refers to a dice.
Calculate the cubes’ place
Earlier than we create our cubes, first we have to put together their positions within the grid. The calculateCellProperties operate on the Grid class is dealing with this:
calculateCellProperties(gridProperties) {
// ...
// Calculate place and heart the grid round heart
const x = (columnId - (columnCount - 1) / 2) * cellSpacing;
const y = (-rowId + (rowCount - 1) / 2) * cellSpacing;
const z = 0;
// ...
}
Draw the cubes
Subsequent, we are able to begin creating our cubes. Right here I’m utilizing InstancedMesh with a easy BoxGeometry and a easy ShaderMaterial. Don’t neglect to replace every occasion’s place primarily based on the positions we calculated within the earlier step.
// ...
const geometry = new THREE.BoxGeometry(cellSize, cellSize, cellThickness);
const materials = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
});
const mesh = new THREE.InstancedMesh(
geometry,
materials,
this.cellProperties.size // Variety of situations
);
//Replace Cell Place for every occasion
for (let i = 0; i < this.cellProperties.size; i++) {
const { x, y, z } = this.cellProperties[i];
const objectRef = new THREE.Object3D();
objectRef.place.set(x, y, z);
objectRef.updateMatrix();
mesh.setMatrixAt(i, objectRef.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
// ...
Write the vertexShader and fragmentShader
Let’s now create a easy shader to attract the cubes with a single shade.
vertexShader.glsl
void fundamental() {
vec3 cellLocalPosition = vec3(place);
vec4 cellWorldPosition = modelMatrix * instanceMatrix * vec4(cellLocalPosition, 1.0);
gl_Position = projectionMatrix * viewMatrix * cellWorldPosition;
}
fragmentShader.glsl
void fundamental() {
vec3 shade = vec3(0.7); // Set a default shade for now
gl_FragColor = vec4(shade, 1.0);
}
Provoke the grid and add it to the scene
Lastly, let’s provoke our grid and add it to the scene. You are able to do this within the index.js:
//Init grid and present it on the scene
const grid = new Grid({
title: "grid",
rowCount: 400,
columnCount: 400,
cellSize: 1,
cellThickness: 0.5,
});
grid.showAt(scene);
End result
At this stage it is best to see an enormous gray sq. in your display. It might seem like an enormous sq. for now, however they’re really shaped of 400×400 cubes!

3. Animating Cubes’ Z-Place
The code for this step is offered on the animate-z-position department.
Proper now, the entire cubes in our grid have a hard and fast z-position. On this step, we’re going to replace our codes in order that the cubes can transfer throughout z-axis dynamically.
Right here’s an inventory of issues we’ll do:
- Outline variables for storing the vary for cubes’ z-positions and animation progress.
- Calculate the cubes’ positions primarily based on the animation progress worth.
- Add a Tweakpane panel to vary the worth with a slider.
Outline z-position vary and animation progress variables
Uniforms are variables that we are able to use to ship values from our JavaScript code to our shaders. Right here, we are going to want two uniforms:
uZPositionRange, which is able to retailer the beginning and ending factors of our cubes.uAnimationProgress, which is able to retailer the animation progress and will probably be used to calculate the place of our cubes at z-axis.
First, outline these two uniforms in Grid.js
const materials = new THREE.ShaderMaterial({
// ...
// Outline uniforms for the shader
uniforms: {
uZPositionRange: { worth: this.gridProperties.zPositionRange ?? new THREE.Vector2(0, 0) },
uAnimationProgress: { worth: 0 },
},
});
Calculate the cubes place
Subsequent, use these two uniforms in our vertexShader to calculate the ultimate z-position for every dice.
uniform vec2 uZPositionRange; // Vary for z place animation (begin and finish)
uniform float uAnimationProgress; // Animation progress (0.0 to 1.0) to regulate the z place animation
void fundamental() {
// ...
// Calculate the z place begin and finish place primarily based on the uniform values
float zPositionStart = uZPositionRange.x;
float zPositionEnd = uZPositionRange.y;
// Smoothen the z place animation progress utilizing smoothstep
float zPositionAnimationProgress = smoothstep(0.0, 1.0, uAnimationProgress);
// Replace the world z place of the cell primarily based on the zPositionAnimationProgress worth
cellWorldPosition.z += combine(zPositionStart, zPositionEnd, zPositionAnimationProgress);
// ...
}
Be aware that we apply a smoothstep operate to smoothen the cubes’ motion. It will make the cubes transfer slower at the start and the tip of animation.
Lastly, add the default worth the cubes’ z-position vary in index.js:
const grid = new Grid({
// ...
// New properties: zPositionRange
zPositionRange: new THREE.Vector2(20, -20),
});
It will first place our cubes at when uAnimationProgress is 0. As the worth of uAnimationProgress adjustments to 1, the cubes will regularly transfer to .
To animate the cubes, we simply have to replace the worth of our uAnimationProgress utilizing an animation library like GSAP. For this tutorial, nevertheless, I simply arrange a slider utilizing Tweakpane in order that we are able to play with the animation progress freely.
Add animation panel
Now let’s add a debug panel to permit us to vary the animation progress and instantly observe the end result. We’re going to make use of Tweakpane library right here. Within the index.js, add this following code:
// Init Tweakpane
const pane = new Pane({ title: 'Settings', expanded: true });
pane.registerPlugin(EssentialsPlugin);
// ...
// Create Animation Folder
const animationFolder = pane.addFolder({ title: 'Animation' });
// Add Progress Slider to regulate animation progress
const progressSlider = animationFolder.addBlade({
view: 'slider',
label: 'Progress',
worth: 0,
min: 0,
max: 1,
step: 0.01,
});
progressSlider.on('change', (ev) => {
// Replace the shader uniform with the brand new animation progress worth
grid.materials.uniforms.uAnimationProgress.worth = ev.worth;
});
End result
Now we have now animatable cubes which we are able to management utilizing the Tweakpane slider. See how the grid strikes as the worth of animation progress adjustments.
At this level our animation doesn’t look actually spectacular. It appears as if we’re simply transferring an enormous sq., despite the fact that we really simply animated 160,000 cubes on the identical time! Let’s now change this by including slight delay for every dice.
4. Including Per-Dice Animation Delay
The code for this step is offered on the add-per-cube-animation-delay department.
The thought right here is so as to add a little bit of animation delay for every dice primarily based on its normalized cell index (starting from 0 to 1), in order that they transfer at barely completely different occasions.
The primary dice will transfer as quickly because the animation progress > 0. The following dice will transfer a bit later, when the animation progress > (cell index * max delay worth). The delay will regularly improve till the final dice, whose cell index is 1, strikes after the animation progress > max delay worth. This, in flip, will create a gradual motion like a wavy impact in our animation.
To implement this we’re going to:
- Calculate the normalized cell index for every dice.
- Create an
InstancedBufferAttributeto carry every dice’s cell index. - Use the cell index attribute to calculate a delay issue for every dice within the
vertexShader. - Add a “max delay” slider in Tweakpane.
Calculate the cell index
To do that, we are able to first calculate the cell index (normalized from 0 to 1) within the calculateCellProperties in Grid.js.
calculateCellProperties(gridProperties) {
// ...
for (let i = 0; i < objectCount; i++) {
// ...
properties[i].cellIdNormalized = i / (objectCount - 1); // Normalize cellId to [0, 1] vary
// ...
}
// ...
}
Assign cell index to InstancedBufferAttribute
Subsequent, create a Float32Array model of our cellIdNormalized variable, and assigned it to an InstancedBufferAttribute object. Then add the attribute to the geometry utilizing setAttribute operate.
const attributes = {
aCellIdNormalized: new THREE.InstancedBufferAttribute(
new Float32Array(this.cellProperties.map((prop) => prop.cellIdNormalized)),
1
)
};
geometry.setAttribute("aCellIdNormalized", attributes.aCellIdNormalized);
Calculate the delay for every dice
The delay for every dice is calculated as cell index * most delay. The final dice will wait till the animation progress > most delay earlier than transferring, and it’ll end when the animation progress is 1. This implies the transferring length for the final dice is (1 - most delay). We are going to then apply this identical transferring length to all cubes.
For instance, if we set our most delay to 0.9, the primary dice will begin transferring at animation progress > 0, and arrive at its ultimate place at animation progress = 0.1.
The delay will regularly improve for the next cubes, and the final dice (cell index = 1) could have delay equal to the utmost delay (0.9). It begins transferring at animation progress > 0.9 and finishes at animation progress = 1.
To implement this in our vertexShader:
// ...
// New uniform to retailer animation max delay
uniform float uAnimationMaxDelay;
// New attribute (InstancedBufferAttribute) to retailer the normalized cell index
attribute float aCellIdNormalized;
void fundamental() {
//Calculate delay and length for every dice animation
float delayFactor = aCellIdNormalized;
float animationStart = delayFactor * uAnimationMaxDelay;
float animationDuration = 1.0 - uAnimationMaxDelay;
float animationEnd = animationStart + animationDuration;
// ...
// Replace the zPositionAnimationProgress
// Animations will begin at animationStart and finish at animationEnd worth for every dice
float zPositionAnimationProgress = smoothstep(animationStart, animationEnd, uAnimationProgress);
// ...
}
Add max delay variable to tweakpane
Lastly, let’s additionally add the max delay variable to Tweakpane in order that we are able to change them simply.
// Add Progress Slider to regulate animation progress
const animationDelay = animationFolder.addBlade({
view: 'slider',
label: 'Max Delay',
worth: grid.materials.uniforms.uAnimationMaxDelay.worth,
min: 0.05,
max: 1,
step: 0.01,
});
animationDelay.on('change', (ev) => {
grid.materials.uniforms.uAnimationMaxDelay.worth = ev.worth;
});
End result
See how we now have a wavy impact in our grid animation. Attempt taking part in with the max delay variable and see the way it impacts the form of the wave.
5. Including Extra Delay Kind Variations
The code for this step is offered on the add-delay-variation department.
Subsequent, let’s strive including completely different results to our grid animation. We are able to do that through the use of completely different delay components to calculate our ultimate delay. On this step we’re going to:
- Create
InstancedBufferAttributeto retailer normalized row and column indices. - Use the row and column indices to make various kinds of delay components.
- Add choices to decide on delay kind within the Tweakpane panel
Retailer normalized row and column index
Identical to earlier than, we are able to calculate the normalized row and column index within the calculateCellProperties operate, then assign them to the geometry by way of InstancedBufferAttribute.
calculateCellProperties(gridProperties) {
// ...
for (let i = 0; i < objectCount; i++) {
// ...
// Calculate normalized row and column index (0 to 1)
properties[i].rowIdNormalized = rowId / (rowCount - 1);
properties[i].columnIdNormalized = columnId / (columnCount - 1);
}
// ...
}
// ...
const attributes = {
// ...
// Create InstancedBufferAttribute to retailer normalized row index
aRowIdNormalized: new THREE.InstancedBufferAttribute(
new Float32Array(this.cellProperties.map((prop) => prop.rowIdNormalized)),
1
),
// Create InstancedBufferAttribute to retailer normalized column index
aColumnIdNormalized: new THREE.InstancedBufferAttribute(
new Float32Array(this.cellProperties.map((prop) => prop.columnIdNormalized)),
1
),
};
// ...
geometry.setAttribute("aColumnIdNormalized", attributes.aColumnIdNormalized);
Outline delay varieties
Within the vertexShader, create a number of choices of delay issue and set the one used primarily based on the worth of DELAY_TYPE fixed.
#ifdef DELAY_TYPE
#if DELAY_TYPE == 1
// Cell Index - primarily based delay
float delayFactor = aCellIdNormalized;
#elif DELAY_TYPE == 2
// Row-based delay
float delayFactor = aRowIdNormalized;
#elif DELAY_TYPE == 3
// Column-based delay
float delayFactor = aColumnIdNormalized;
#elif DELAY_TYPE == 4
// random-based delay
float delayFactor = random(vec2(aColumnIdNormalized, aRowIdNormalized));
#elif DELAY_TYPE == 5
// delay primarily based on distance from the top-left nook;
float delayFactor = distance(vec2(aRowIdNormalized, aColumnIdNormalized), vec2(0, 0));
delayFactor = smoothstep(0.0, 1.42, delayFactor);
#else
// No delay
float delayFactor = 0.0;
#endif
#else
// Default to no delay if DELAY_TYPE isn't outlined
float delayFactor = 0.0;
#endif
In Grid.js, assign the default worth for the DELAY_TYPE fixed:
const materials = new THREE.ShaderMaterial({
// ...
// Set DELAY_TYPE worth in materials defines
defines: {
DELAY_TYPE: 1,
},
// ...
});
Add delay kind choices in Tweakpane
Lastly, add choices to decide on the delay kind in our Tweakpane panel. Keep in mind that we have to recompile the shader each time we alter the defines worth after the fabric is initiated. We are able to do that by updating the materials.needsUpdate flag to true.
//Add Dropdown to pick out delay kind
const delayTypeController = animationFolder.addBlade({
view: 'record',
label: 'Delay Kind',
choices: {
'Cell by Cell': 1,
'Row by Row': 2,
'Column by Column': 3,
'Random': 4,
'Nook to Nook': 5,
},
worth: grid.materials.defines.DELAY_TYPE,
});
delayTypeController.on('change', (ev) => {
grid.materials.defines.DELAY_TYPE = ev.worth;
grid.materials.needsUpdate = true;
});
End result
We now have completely different animation impact that we are able to select for our grid! Play with completely different delay kind and see how the animation impact adjustments.
6. Including Picture Texture
The code for this step is offered on the add-image-texture department.
Now it’s time so as to add a picture onto our grid. Right here’s what we’re going to do at this stage:
- Load a picture texture and assign it to a shader uniform.
- Pattern the feel to paint the cubes primarily based on their row and cell index.
- Add a border to the picture grid.
Load picture texture
In Grid.js, add a texture loader to load a picture and add it to the feel uniform as soon as it’s loaded.
// ...
const materials = new THREE.ShaderMaterial({
// ...
uniforms: {
// ...
uTexture: { worth: null }, // Placeholder for texture uniform
},
});
// Load picture to materials.uniforms.uTexture if the picture path is offered
if (this.gridProperties.picture) {
const textureLoader = new THREE.TextureLoader();
textureLoader.load(
this.gridProperties.picture,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
materials.uniforms.uTexture.worth = texture;
materials.needsUpdate = true;
}
);
}
// ...
Set the trail to the picture we need to use within the index.js:
import imageUrl from './picture/dithering_object.jpg';
// ...
const grid = new Grid({
// ...
picture: imageUrl, // Path to the picture for use within the grid
});
// ...
Now we’re able to learn the picture in our shader.
Pattern the feel to paint the dice
Normally, a picture texture is sampled within the fragment shader primarily based on the mesh UV coordinates. However on this case, we’re drawing the picture over our grid, not over a single mesh. For that reason, we’ll pattern the feel utilizing the dice’s row and column index. The ensuing shade is then handed to the fragment shader to paint the dice.
within the vertexShader:
// ...
// Pattern the feel to get the colour for the present cell
float imageColor = texture2D(uTexture, vec2(aColumnIdNormalized, 1.0 - aRowIdNormalized)).r;
float finalColor = imageColor;
// ...
vColor = vec3(finalColor); //Ship the ultimate shade to the fragment shader
within the fragmentShader:
void fundamental() {
vec3 shade = vColor; // Use the colour handed from the vertex shader
// ...
}
Add picture border
Subsequent let’s add a border to our picture grid. We are able to do that by setting the dice’s shade to black if it’s on the grid’s edge.
// ...
//Add border
float borderThreshold = 0.005; // Modify this worth to regulate the thickness of the border
// Examine if the dice is on the grid's edge
float borderX = step(aColumnIdNormalized, borderThreshold) + step(1.0 - borderThreshold, aColumnIdNormalized);
float borderY = step(aRowIdNormalized, borderThreshold) + step(1.0 - borderThreshold, aRowIdNormalized);
float isBorder = clamp(borderX + borderY, 0.0, 1.0);
// replace shade to black if it is on the grid's edge
finalColor = combine(finalColor, 0.0, isBorder);
// ...
End result
Now you will note the picture is drawn over our grid! See how the picture waves as we alter the animation progress.
7 – Including The Dithering Impact
The code for this step is offered on the add-dithering-effect department.
Lastly, we enter our fundamental operate: including the dithering impact. Dithering is completed by evaluating the pixel worth with a threshold out there in a threshold map. I can’t focus on the logic intimately right here; you may verify my visible article if you wish to perceive the way it work in additional element.
Right here’s what we’re going to do on this step:
- Create a variable to carry the brink map choices.
- Calculate the brink for a dice by wanting up the brink map primarily based on the dice’s row and column index.
- Assign the brink to an
InstancedBufferAttribute. - Evaluate the ultimate shade of the dice towards the brink; flip the dice white if it’s brighter than the brink, and black in any other case.
Create a threshold maps variable
In Grid.js, create a variable to carry the brink maps. Right here I create a number of forms of threshold maps, which is able to create completely different dithering results.
class Grid {
constructor(gridProperties) {
// ...
this.thresholdMaps = [
{
id: "bayer4x4",
name: "Bayer 4x4",
rows: 4,
columns: 4,
data: [
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5
]
},
{
id: "halftone",
title: "Halftone",
rows: 8,
columns: 8,
information: [
24, 10, 12, 26, 35, 47, 49, 37,
8, 0, 2, 14, 45, 59, 61, 51,
22, 6, 4, 16, 43, 57, 63, 53,
30, 20, 18, 28, 33, 41, 55, 39,
34, 46, 48, 36, 25, 11, 13, 27,
44, 58, 60, 50, 9, 1, 3, 15,
42, 56, 62, 52, 23, 7, 5, 17,
32, 40, 54, 38, 31, 21, 19, 29
]
},
// ... different threshold maps goes right here ...
// ...
Calculate the brink for every dice
In calculateCellProperties, calculate the brink for every dice and retailer it in a brand new properties. Totally different threshold maps will return completely different thresholds, so we are going to retailer every threshold in their very own threshold map key.
calculateCellProperties(gridProperties) {
// ...
for (let i = 0; i < objectCount; i++) {
// ...
properties[i].thresholdMaps = {}; // Put together an object to carry threshold map values for this cell
// Retailer threshold worth for all threshold maps variant
this.thresholdMaps.forEach(config => {
const { information, rows: matrixRowSize, columns: matrixColumnSize } = config;
const matrixSize = information.size;
const matrixRow = rowId % matrixRowSize;
const matrixColumn = columnId % matrixColumnSize;
const index = matrixColumn + matrixRow * matrixColumnSize;
const thresholdValue = information[index] / matrixSize; // Normalize threshold to [0, 1]
properties[i].thresholdMaps[config.id] = thresholdValue;
});
}
Assign the brink to InstancedBufferAttribute
In Grid.js, assign the thresholdMaps properties to their very own InstancedBufferAttribute, then assign them to aDitheringThreshold geometry attribute.
There’ll solely be one threshold map that can be utilized at a time, so let’s select a bayer4x4 threshold map as a default.
init() {
// ...
const attributes = {
// ...
aDitheringThresholds: {} // Put together an object to carry threshold map attributes
};
this.thresholdMaps.forEach(config => {
attributes.aDitheringThresholds[config.id] = new THREE.InstancedBufferAttribute(
new Float32Array(this.cellProperties.map((prop) => prop.thresholdMaps[config.id])),
1
);
});
// ...
geometry.setAttribute("aDitheringThreshold", attributes.aDitheringThresholds.bayer4x4); // Utilizing bayer4x4 because the default threshold map for now
Evaluate dice’s unique shade to the brink
Within the vertexShader, add logic to check the unique shade of the dice towards the assigned threshold, then turns the dice white if it’s brighter than the brink, and black if in any other case. Management the transition from unique to dithered shade by the animation progress, so it occurs because the dice strikes throughout the z-axis.
// ...
// Pattern the feel to get the colour for the present cell
float imageColor = texture2D(uTexture, vec2(aColumnIdNormalized, 1.0 - aRowIdNormalized)).r;
// Evaluate the picture shade with the dithering threshold to find out if the cell ought to be "white" or "black"
float ditheringThreshold = aDitheringThreshold;
float ditheredColor = step(ditheringThreshold, imageColor);
// Calculate the progress of the colour animation for every cell
float colorAnimationProgress = smoothstep(animationStart, animationEnd, uAnimationProgress);
// Change the colour of the cell primarily based on the calculated animation progress,
float finalColor = combine(imageColor, ditheredColor, colorAnimationProgress);
// ...
Add threshold map choices on Tweakpane
Lastly, let’s add the brink map choices on Tweakpane so we are able to change between completely different maps simply.
// Create Dithering Folder
const ditheringFolder = pane.addFolder({ title: 'Dithering' });
const activeThresholdMaps = {
worth: 'bayer4x4',
};
const ditheringThresholdController = ditheringFolder.addBinding(activeThresholdMaps, 'worth', {
view: 'radiogrid',
groupName: 'ditheringThreshold',
measurement: [2, 2],
cells: (x, y) => ({
title: `${grid.thresholdMaps[y * 2 + x].title}`,
worth: grid.thresholdMaps[y * 2 + x].id,
}),
label: 'Threshold Map',
})
ditheringThresholdController.on('change', (ev) => {
grid.geometry.setAttribute("aDitheringThreshold", grid.attributes.aDitheringThresholds[ev.value]);
});
End result
Transfer the animation slider and observe how now the cubes transitions to the dithered model because it strikes. Play with various kinds of the brink map to see the completely different dithering results.
8. Including a Threshold Map Grid
The code for this step is offered on the add-threshold-map-grid department.
At this level, we have now a working visualization exhibiting the transition from the unique to the dithered picture. Subsequent let’s add a threshold map and make the cubes move by way of it as they bear the dithering course of.
On this stage we are going to:
- Replace the
vertexShaderso as to add a brand new grid kind: threshold map. - Add a threshold map grid to the scene.
- Add Tweakpane management to point out and conceal the picture and the brink map grids.
Add new gridType
Within the vertexShader, add new #if blocks for 2 grid kind choices. If GRID_TYPE == 1, we’ll use our present picture logic. If GRID_TYPE == 2, we are going to shade it primarily based on the aDitherThreshold attribute.
// ...
#ifdef GRID_TYPE
#if GRID_TYPE == 1
// ... (present logic for picture grid)
#elif GRID_TYPE == 2
// New logic for Threshold Map grid
float finalColor = aDitheringThreshold;
// ...
In Grid.js, add the default defines worth for GRID_TYPE:
const materials = new THREE.ShaderMaterial({
// ...
defines: {
// ...
GRID_TYPE: this.gridProperties.gridType ?? 1,
},
// ...
});
Add threshold map grid to the scene
In index.js, provoke the brink map grid and add it to the scene. Place it within the center by setting its zPositionRange to (0,0).
const thresholdMapGrid = new Grid({
title: "thresholdMapGrid",
rowCount: 400,
columnCount: 400,
cellSize: 1,
cellThickness: 0.1,
gridType: 2,
zPositionRange: new THREE.Vector2(0, 0),
});
thresholdMapGrid.showAt(scene);
Add Tweakpane management to point out and conceal grid
// Create Picture Grid Settings Folder
const imageGridFolder = pane.addFolder({ title: 'Picture Grid' });
const showImageGrid = imageGridFolder.addBinding({present: true}, 'present', {
label: 'Present',
});
showImageGrid.on('change', (ev) => {
if (ev.worth) {
grid.showAt(scene);
} else {
grid.hideFrom(scene);
}
});
// Create Threshold Map Grid Settings Folder
const thresholdMapGridFolder = pane.addFolder({ title: 'Threshold Map Grid' });
const showThresholdMapGrid = thresholdMapGridFolder.addBinding({present: true}, 'present', {
label: 'Present',
});
showThresholdMapGrid.on('change', (ev) => {
if (ev.worth) {
thresholdMapGrid.showAt(scene);
} else {
thresholdMapGrid.hideFrom(scene);
}
});
Repair Picture Animation Timing
Should you open the demo at this level, you might discover a flaw: the dice’s shade begins altering as quickly because it strikes. We would like the colour to vary solely after the dice passes by way of the brink map.
To repair this, replace the operate of colorAnimationProgress within the vertexShader:
#ifdef GRID_TYPE
#if GRID_TYPE == 1
// ...
float colorAnimationStart = animationStart + animationDuration * 0.5; // Begin shade animation midway by way of the z-position animation, when it attain the brink map
float colorAnimationEnd = colorAnimationStart + 0.01; // Finish shade animation as quickly because it move the brink map
float colorAnimationProgress = smoothstep(colorAnimationStart, colorAnimationEnd, uAnimationProgress);
// ...
End result
Now you will note a threshold map within the center, which the cubes move by way of as they transfer.
9. Add Scale Animation
The code for this step is offered on the add-scale-animation department.
Now we have now another downside: the brink map hides the output picture. To repair this, we’ll make the brink map disappear because the cubes move by way of. Here’s what we’ll do at this step:
- Add dice scale animation within the
vertexShader. - Set threshold map scale to 1 at the start and 0 on the finish of the animation.
- Sync the animation progress slider with the brink map animation progress.
Including dice scale animation
Related with how we animate the z-position and shade, we are able to create a brand new uniform uCellScaleRange to retailer the beginning and ending scale. Use the uniform to calculate the dice’s ultimate scale In vertexShader:
// ...
uniform vec2 uCellScaleRange; // Vary for cell scale animation (begin and finish)
// ...
void fundamental() {
float cellScaleStart = uCellScaleRange.x;
float cellScaleEnd = uCellScaleRange.y;
float cellScaleAnimationProgress = smoothstep(animationStart, animationEnd, uAnimationProgress);
float cellScale = combine(cellScaleStart, cellScaleEnd, cellScaleAnimationProgress);
vec3 cellLocalPosition = vec3(place);
cellLocalPosition *= cellScale;
// ...
then add the default worth in Grid.js:
const materials = new THREE.ShaderMaterial({
// ...
uniforms: {
// ...
uCellScaleRange: { worth: this.gridProperties.cellScaleRange ?? new THREE.Vector2(1, 1) },
// ...
},
});
Outline the beginning and finish scale
In index.js, set the size vary for each picture grid and threshold map grid. Since we don’t need to change the size of the picture grid, we set it as (1, 1). For the brink map grid, we set it to (1,0) so it vanishes by the tip of the animation.
const grid = new Grid({
// ...
cellScaleRange: new THREE.Vector2(1, 1), // property to regulate cell scale animation
});
// ...
const thresholdMapGrid = new Grid({
// ...
zPositionRange: new THREE.Vector2(0, -20),
cellScaleRange: new THREE.Vector2(1, 0), // property to regulate cell scale animation
});
// ...
Sync threshold map animation progress on Tweakpane
Replace the all of the animation-related sliders (delay kind, progress, max delay) to additionally change the animation variables for thresholdMapGrid. It will sync the animation for each picture grid and threshold map grid collectively.
// ...
delayTypeController.on('change', (ev) => {
grid.materials.defines.DELAY_TYPE = ev.worth;
grid.materials.needsUpdate = true;
thresholdMapGrid.materials.defines.DELAY_TYPE = ev.worth;
thresholdMapGrid.materials.needsUpdate = true;
});
// ...
animationDelay.on('change', (ev) => {
grid.materials.uniforms.uAnimationMaxDelay.worth = ev.worth;
thresholdMapGrid.materials.uniforms.uAnimationMaxDelay.worth = ev.worth;
});
// ...
progressSlider.on('change', (ev) => {
grid.materials.uniforms.uAnimationProgress.worth = ev.worth;
thresholdMapGrid.materials.uniforms.uAnimationProgress.worth = ev.worth;
});
// ...
End result
Now see how the brink map disappears because the cubes move by way of.
10. Add Min Delay Variable
The code for this step is offered on the add-min-delay-variable department.
There’s nonetheless one factor to repair: the brink map really begins transferring similtaneously the picture grid. We would like the brink map grid to maneuver solely when the cubes are about to move by way of it.
To repair this, we’re going so as to add an preliminary delay to the brink map grid, in order that they don’t transfer instantly because the animation progress will increase.
We’re going to implement this logic:
- Add preliminary delay variable to the animation begin within the
vertexShader. - Replace min and max delay values for the picture and threshold map grids.
- Sync Tweakpane delay slider with each grids’ delay variables
Add preliminary delay variable within the vertexShader
Within the vertexShader, replace the animation begin to issue the preliminary delay.
// ...
uniform float uAnimationMinDelay; // Minimal delay for the animation.
// ...
float animationStart = combine(uAnimationMinDelay, uAnimationMaxDelay, delayFactor);
// ...
Subsequent replace the default worth for the min and max delay uniforms within the Grid.js:
const materials = new THREE.ShaderMaterial({
// ...
uniforms: {
// ...
uAnimationMinDelay: { worth: this.gridProperties.animationMinDelay ?? 0 }, // Minimal delay for the animation in % of length.
uAnimationMaxDelay: { worth: this.gridProperties.animationMaxDelay ?? 0.9 }, // Most delay for the animation in % of length.
// ...
},
});
Set min and max delay for the picture and threshold map grid
Replace the min and max delay values in index.js:
//Init grid and present it on the scene
const grid = new Grid({
// ...
animationMinDelay: 0, // property for minimal animation delay
animationMaxDelay: 0.9, // property for optimum animation delay
});
// ...
const thresholdMapGrid = new Grid({
// ...
animationMinDelay: 0.05, // property for minimal animation delay
animationMaxDelay: 0.95, // property for optimum animation delay
});
// ...
Right here’s how I arrived at these numbers:
- We would like the
thresholdMapGridto maneuver solely when the cubes are about to move by way of it. - The cubes will move the brink map on the midway level of their path.
- Due to this fact, we are able to set the brink map grid’s preliminary delay to half of a dice’s animation length.
- The dice’s animation length = (1 – Max Delay) = (1 – 0.9) = 0.1.
- The edge map grid’s min delay equals to half of the dice’s animation length = 0.05
- The entire threshold map grid’s delay ought to be offset by 0.05, together with its max delay, which ends up in 0.95.
Sync delay slider on Tweakpane
Lastly, apply the above logic on Tweakpane:
animationDelay.on('change', (ev) => {
grid.materials.uniforms.uAnimationMaxDelay.worth = ev.worth;
const animationDuration = 1.0 - ev.worth;
thresholdMapGrid.materials.uniforms.uAnimationMinDelay.worth = animationDuration * 0.5;
thresholdMapGrid.materials.uniforms.uAnimationMaxDelay.worth = ev.worth + animationDuration * 0.5;
});
This is able to preserve the picture grid and threshold map grid animation in sync when the max delay worth adjustments.
End result
That’s all! That was our ultimate step.
Now it’s time to play with the demo: strive a unique threshold map or play with the animation parameters to vary the dithering end result and the animation impact!
Wrapping Up
On this article, we mentioned how Three.js is usually a highly effective software to animate 1000’s of objects with ease. On this case, I take advantage of it to visualise the mechanism behind dithering. Nevertheless, I consider we are able to use this strategy to visualise many different ideas.
The precise implementation for different use circumstances would possibly differ, however in precept, it should contain defining the preliminary and ending states for an object, then calculating the present state primarily based on the animation progress. The essential factor is to dump these calculations to the GPU utilizing customized shaders if the animation includes a lot of objects.
This is identical strategy I’ve taken for different visible articles at my weblog, visualrambling.house, the place I attempt to clarify numerous technical ideas utilizing visualizations made with Three.js.
Whereas Three.js is often used for touchdown web page visuals or web-based video games, I believe it may also be a fantastic software for creating interactive web-based visible explainers like this. So I hope this text is beneficial and conjures up you to make your personal.
Thanks for studying!








![Does AI content material rank nicely in search? [Survey + Data study]](https://blog.aimactgrow.com/wp-content/uploads/2026/04/does-ai-content-rank-well-in-search-survey-data-study-120x86.png)
