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 at any time when I study a brand new matter. I additionally like to share my learnings as a visible article on my weblog, since I feel 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 scale 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 top of this text, you’ll be capable of animate 1000’s of objects in Three.js and hopefully be capable of apply the identical strategy to a variety of use circumstances.
However first, let’s focus on the background and motivation behind the visualization.
Background
In my view, animation is a superb instrument for visualizing a course of. With animation, the reader can observe how an object’s state modifications 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 colour with a threshold, then change the colour accordingly.
Utilizing animation, readers can observe how the colour of pixels modifications as they undergo the brink map. It provides them visible steerage on how dithering works and lets them see the way it impacts the picture pixels’ colour.
The animation above solely includes the modification of three properties: place, colour, and scale. Nevertheless, the problem arises when we have now to do that for all 160,000 cubes on the similar 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 an alternative of looping via every dice’s properties on the CPU and updating them one after the other, we will create a single set of directions in a customized shader that defines the colour, place, and scale for each dice concurrently. These directions run straight on the GPU, calculating the state of all 160,000 cubes on the similar 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 normal Three.js setup, so I cannot clarify it an excessive amount of. The entire code for this step is accessible within the repository on the setup-three-js department.
All through this tutorial, you could find the entire code for every step by trying out its respective department.
Outcome
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 accessible 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 here 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.
Notice: All through the code, I’ll check with the person items that type 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 middle the grid round middle
const x = (columnId - (columnCount - 1) / 2) * cellSpacing;
const y = (-rowId + (rowCount - 1) / 2) * cellSpacing;
const z = 0;
// ...
}
Draw the cubes
Subsequent, we will begin creating our cubes. Right here I’m utilizing InstancedMesh with a easy BoxGeometry and a easy ShaderMaterial. Don’t overlook to replace every occasion’s place based mostly 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 cases
);
//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 colour.
vertexShader.glsl
void predominant() {
vec3 cellLocalPosition = vec3(place);
vec4 cellWorldPosition = modelMatrix * instanceMatrix * vec4(cellLocalPosition, 1.0);
gl_Position = projectionMatrix * viewMatrix * cellWorldPosition;
}
fragmentShader.glsl
void predominant() {
vec3 colour = vec3(0.7); // Set a default colour for now
gl_FragColor = vec4(colour, 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({
identify: "grid",
rowCount: 400,
columnCount: 400,
cellSize: 1,
cellThickness: 0.5,
});
grid.showAt(scene);
Outcome
At this stage it is best to see an enormous gray sq. in your display screen. It could appear to be 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 accessible 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 based mostly 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 will use to ship values from our JavaScript code to our shaders. Right here, we’ll 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 might 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 predominant() {
// ...
// Calculate the z place begin and finish place based mostly 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 based mostly on the zPositionAnimationProgress worth
cellWorldPosition.z += combine(zPositionStart, zPositionEnd, zPositionAnimationProgress);
// ...
}
Notice that we apply a smoothstep operate to smoothen the cubes’ motion. This may make the cubes transfer slower firstly and the top 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),
});
This may first place our cubes at when uAnimationProgress is 0. As the worth of uAnimationProgress modifications to 1, the cubes will regularly transfer to .
To animate the cubes, we simply must replace the worth of our uAnimationProgress utilizing an animation library like GSAP. For this tutorial, nonetheless, I simply arrange a slider utilizing Tweakpane in order that we will 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 outcome. 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;
});
Outcome
Now we have now animatable cubes which we will management utilizing the Tweakpane slider. See how the grid strikes as the worth of animation progress modifications.
At this level our animation doesn’t look actually spectacular. It appears to be like as if we’re simply shifting an enormous sq., regardless that we really simply animated 160,000 cubes on the similar 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 accessible on the add-per-cube-animation-delay department.
The concept right here is so as to add a little bit of animation delay for every dice based mostly on its normalized cell index (starting from 0 to 1), in order that they transfer at barely totally different occasions.
The primary dice will transfer as quickly because the animation progress > 0. The subsequent dice will transfer a bit later, when the animation progress > (cell index * max delay worth). The delay will regularly enhance 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 will 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 shifting, and it’ll end when the animation progress is 1. This implies the shifting length for the final dice is (1 - most delay). We are going to then apply this similar shifting length to all cubes.
For instance, if we set our most delay to 0.9, the primary dice will begin shifting at animation progress > 0, and arrive at its ultimate place at animation progress = 0.1.
The delay will regularly enhance for the next cubes, and the final dice (cell index = 1) may have delay equal to the utmost delay (0.9). It begins shifting 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 predominant() {
//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 will 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;
});
Outcome
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 Sort Variations
The code for this step is accessible on the add-delay-variation department.
Subsequent, let’s attempt including totally different results to our grid animation. We will do that through the use of totally 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 sort within the Tweakpane panel
Retailer normalized row and column index
Similar to earlier than, we will calculate the normalized row and column index within the calculateCellProperties operate, then assign them to the geometry through 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 based mostly on the worth of DELAY_TYPE fixed.
#ifdef DELAY_TYPE
#if DELAY_TYPE == 1
// Cell Index - based mostly 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 based mostly 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 shouldn't be 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 sort choices in Tweakpane
Lastly, add choices to decide on the delay sort in our Tweakpane panel. Keep in mind that we have to recompile the shader each time we modify the defines worth after the fabric is initiated. We will do that by updating the materials.needsUpdate flag to true.
//Add Dropdown to pick out delay sort
const delayTypeController = animationFolder.addBlade({
view: 'checklist',
label: 'Delay Sort',
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;
});
Outcome
We now have totally different animation impact that we will select for our grid! Play with totally different delay sort and see how the animation impact modifications.
6. Including Picture Texture
The code for this step is accessible 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 based mostly 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 wish 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
Often, a picture texture is sampled within the fragment shader based mostly 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 colour 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 colour to the fragment shader
within the fragmentShader:
void predominant() {
vec3 colour = vColor; // Use the colour handed from the vertex shader
// ...
}
Add picture border
Subsequent let’s add a border to our picture grid. We will do that by setting the dice’s colour to black if it’s on the grid’s edge.
// ...
//Add border
float borderThreshold = 0.005; // Alter this worth to regulate the thickness of the border
// Verify 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 colour to black if it is on the grid's edge
finalColor = combine(finalColor, 0.0, isBorder);
// ...
Outcome
Now you will note the picture is drawn over our grid! See how the picture waves as we modify the animation progress.
7 – Including The Dithering Impact
The code for this step is accessible on the add-dithering-effect department.
Lastly, we enter our predominant operate: including the dithering impact. Dithering is completed by evaluating the pixel worth with a threshold obtainable in a threshold map. I cannot focus on the logic intimately right here; you possibly can test 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 based mostly on the dice’s row and column index.
- Assign the brink to an
InstancedBufferAttribute. - Evaluate the ultimate colour 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 sorts of threshold maps, which is able to create totally 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",
identify: "Halftone",
rows: 8,
columns: 8,
knowledge: [
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 totally different thresholds, so we’ll 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 { knowledge, rows: matrixRowSize, columns: matrixColumnSize } = config;
const matrixSize = knowledge.size;
const matrixRow = rowId % matrixRowSize;
const matrixColumn = columnId % matrixColumnSize;
const index = matrixColumn + matrixRow * matrixColumnSize;
const thresholdValue = knowledge[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 colour to the brink
Within the vertexShader, add logic to check the unique colour 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 colour 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 colour with the dithering threshold to find out if the cell needs 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 based mostly 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 will change between totally 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].identify}`,
worth: grid.thresholdMaps[y * 2 + x].id,
}),
label: 'Threshold Map',
})
ditheringThresholdController.on('change', (ev) => {
grid.geometry.setAttribute("aDitheringThreshold", grid.attributes.aDitheringThresholds[ev.value]);
});
Outcome
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 totally different dithering results.
8. Including a Threshold Map Grid
The code for this step is accessible on the add-threshold-map-grid department.
At this level, we have now a working visualization displaying the transition from the unique to the dithered picture. Subsequent let’s add a threshold map and make the cubes cross via it as they endure the dithering course of.
On this stage we’ll:
- Replace the
vertexShaderso as to add a brand new grid sort: 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 sort choices. If GRID_TYPE == 1, we’ll use our present picture logic. If GRID_TYPE == 2, we’ll colour it based mostly 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({
identify: "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
In the event you open the demo at this level, you could discover a flaw: the dice’s colour begins altering as quickly because it strikes. We would like the colour to vary solely after the dice passes via 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 colour animation midway via the z-position animation, when it attain the brink map
float colorAnimationEnd = colorAnimationStart + 0.01; // Finish colour animation as quickly because it cross the brink map
float colorAnimationProgress = smoothstep(colorAnimationStart, colorAnimationEnd, uAnimationProgress);
// ...
Outcome
Now you will note a threshold map within the center, which the cubes cross via as they transfer.
9. Add Scale Animation
The code for this step is accessible on the add-scale-animation department.
Now we have now yet another downside: the brink map hides the output picture. To repair this, we’ll make the brink map disappear because the cubes cross via. Here’s what we’ll do at this step:
- Add dice scale animation within the
vertexShader. - Set threshold map scale to 1 firstly 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 colour, we will 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 predominant() {
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 dimensions vary for each picture grid and threshold map grid. Since we don’t wish to change the dimensions 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 top 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 sort, progress, max delay) to additionally change the animation variables for thresholdMapGrid. This may 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;
});
// ...
Outcome
Now see how the brink map disappears because the cubes cross via.
10. Add Min Delay Variable
The code for this step is accessible on the add-min-delay-variable department.
There’s nonetheless one factor to repair: the brink map really begins shifting concurrently the picture grid. We would like the brink map grid to maneuver solely when the cubes are about to cross via 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 cross via it. - The cubes will cross the brink map on the midway level of their path.
- Due to this fact, we will 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 brink map grid’s min delay equals to half of the dice’s animation length = 0.05
- All the threshold map grid’s delay needs to be offset by 0.05, together with its max delay, which leads to 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 might hold the picture grid and threshold map grid animation in sync when the max delay worth modifications.
Outcome
That’s all! That was our ultimate step.
Now it’s time to play with the demo: attempt a distinct threshold map or play with the animation parameters to vary the dithering outcome and the animation impact!
Wrapping Up
On this article, we mentioned how Three.js is usually a highly effective instrument 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 will use this strategy to visualise many different ideas.
The precise implementation for different use circumstances may differ, however in precept, it is going to contain defining the preliminary and ending states for an object, then calculating the present state based mostly 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 usually used for touchdown web page visuals or web-based video games, I feel it will also be an ideal instrument for creating interactive web-based visible explainers like this. So I hope this text is helpful and evokes you to make your individual.
Thanks for studying!


