Projection mapping has lengthy fascinated audiences within the bodily world, turning buildings, sculptures, and whole cityscapes into transferring canvases. What in the event you might recreate that very same sense of spectacle instantly contained in the browser? With WebGL and Three.js, you possibly can mission video not onto partitions or monuments however onto dynamic 3D grids product of lots of of cubes, each carrying a fraction of the video like a digital mosaic.
On this tutorial we’ll discover methods to simulate video projection mapping in a purely digital surroundings, from constructing a grid of cubes, to UV-mapping video textures, to making use of masks that decide which cubes seem. The result’s a mesmerizing impact that feels each sculptural and cinematic, good for interactive installations, portfolio showcases, or just as a playground to push your inventive coding expertise additional.
What’s Video Projection Mapping within the Actual World?
When describing video projection mapping, it’s best to consider big buildings lit up with animations throughout festivals, or artwork installations the place a transferring picture is “painted” onto sculptures.
Listed here are some examples of real-world video projections:
Bringing it to our 3D World
In 3D graphics, we are able to do one thing comparable: as an alternative of shining a bodily projector, we map a video texture onto objects in a scene.
Due to this fact, let’s construct a grid of cubes utilizing a masks picture that can decide which cubes are seen. A video texture is UV-mapped so every dice reveals the precise video fragment that corresponds to its grid cell—collectively they reconstruct the video, however solely the place the masks is darkish.
Prerequesites:
- Three.js r155+
- A small, high-contrast masks picture (e.g. a coronary heart silhouette).
- A video URL with CORS enabled.
Our Boilerplate and Beginning Level
Here’s a primary starter setup, i.e. the minimal quantity of code and construction it’s worthwhile to get a scene rendering within the browser, with out worrying concerning the particular inventive content material but.
export default class Fashions {
constructor(gl_app) {
...
this.createGrid()
}
createGrid() {
const geometry = new THREE.BoxGeometry( 1, 1, 1 );
this.materials = new THREE.MeshStandardMaterial( { shade: 0xff0000 } );
const dice = new THREE.Mesh( geometry, this.materials );
this.group.add( dice );
this.is_ready = true
}
...
}
The result’s a spinning purple dice:
Creating the Grid
A centered grid of cubes (10×10 by default). Each dice has the identical dimension and materials. The grid spacing and total scale are configurable.
export default class Fashions {
constructor(gl_app) {
...
this.gridSize = 10;
this.spacing = 0.75;
this.createGrid()
}
createGrid() {
this.materials = new THREE.MeshStandardMaterial( { shade: 0xff0000 } );
// Grid parameters
for (let x = 0; x < this.gridSize; x++) {
for (let y = 0; y < this.gridSize; y++) {
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const mesh = new THREE.Mesh(geometry, this.materials);
mesh.place.x = (x - (this.gridSize - 1) / 2) * this.spacing;
mesh.place.y = (y - (this.gridSize - 1) / 2) * this.spacing;
mesh.place.z = 0;
this.group.add(mesh);
}
}
this.group.scale.setScalar(0.5)
...
}
...
}
Key parameters
World-space distance between dice facilities. Improve for bigger gaps, lower to pack tighter.
What number of cells per aspect. A ten×10 grid ⇒ 100 cubes


Creating the Video Texture
This operate creates a video texture in Three.js so you need to use a taking part in HTML <video>
as the feel on 3D objects.
- Creates an HTML
<video>
aspect totally in JavaScript (not added to the DOM). - We’ll feed this aspect to Three.js to make use of its frames as a texture.
loop = true
→ restarts routinely when it reaches the tip.muted = true
→ most browsers block autoplay for unmuted movies, so muting ensures it performs with out consumer interplay..play()
→ begins playback.- ⚠️ Some browsers nonetheless want a click on/contact earlier than autoplay works — you possibly can add a fallback listener if wanted.
export default class Fashions {
constructor(gl_app) {
...
this.createGrid()
}
createVideoTexture() {
this.video = doc.createElement('video')
this.video.src = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/pattern/BigBuckBunny.mp4'
this.video.crossOrigin = 'nameless'
this.video.loop = true
this.video.muted = true
this.video.play()
// Create video texture
this.videoTexture = new THREE.VideoTexture(this.video)
this.videoTexture.minFilter = THREE.LinearFilter
this.videoTexture.magFilter = THREE.LinearFilter
this.videoTexture.colorSpace = THREE.SRGBColorSpace
this.videoTexture.wrapS = THREE.ClampToEdgeWrap
this.videoTexture.wrapT = THREE.ClampToEdgeWrap
// Create materials with video texture
this.materials = new THREE.MeshBasicMaterial({
map: this.videoTexture,
aspect: THREE.FrontSide
})
}
createGrid() {
this.createVideoTexture()
...
}
...
}
That is the video we’re utilizing: Huge Buck Bunny (with out CORS)
All of the meshes have the identical texture utilized:
Attributing Projection to the Grid
We will probably be turning the video right into a texture atlas cut up right into a gridSize × gridSize
lattice.
Every dice within the grid will get its personal little UV window (sub-rectangle) of the video so, collectively, all cubes reconstruct the total body.
Why per-cube geometry? As a result of we are able to create a brand new BoxGeometry
for every dice because the UVs should be distinctive per dice. If all cubes shared one geometry, they’d additionally share the identical UVs and present the identical a part of the video.
export default class Fashions {
constructor(gl_app) {
...
this.createGrid()
}
createGrid() {
...
// Grid parameters
for (let x = 0; x < this.gridSize; x++) {
for (let y = 0; y < this.gridSize; y++) {
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
// Create particular person geometry for every field to have distinctive UV mapping
// Calculate UV coordinates for this particular field
const uvX = x / this.gridSize
const uvY = y / this.gridSize // Take away the flip to match appropriate orientation
const uvWidth = 1 / this.gridSize
const uvHeight = 1 / this.gridSize
// Get the UV attribute
const uvAttribute = geometry.attributes.uv
const uvArray = uvAttribute.array
// Map every face of the field to point out the identical portion of video
// We'll give attention to the entrance face (face 4) for the principle projection
for (let i = 0; i < uvArray.size; i += 2) {
// Map all faces to the identical UV area for consistency
uvArray[i] = uvX + (uvArray[i] * uvWidth) // U coordinate
uvArray[i + 1] = uvY + (uvArray[i + 1] * uvHeight) // V coordinate
}
// Mark the attribute as needing replace
uvAttribute.needsUpdate = true
...
}
}
...
}
...
}
The UV window for cell (x, y)
For a grid of dimension N = gridSize
:
- UV origin of this cell:
– uvX = x / N
– uvY = y / N - UV dimension of every cell:
– uvWidth = 1 / N
– uvHeight = 1 / N


Outcome: each face of the field now samples the identical sub-region of the video (and we famous “give attention to the entrance face”; this method maps all faces to that area for consistency).
Creating Masks
We have to create a canvas utilizing a masks that determines which cubes are seen within the grid.
- Black (darkish) pixels → dice is created.
- White (gentle) pixels → dice is skipped.
To do that, we have to:
- Load the masks picture.
- Scale it all the way down to match our grid dimension.
- Learn its pixel shade information.
- Move that information into the grid-building step.
export default class Fashions {
constructor(gl_app) {
...
this.createMask()
}
createMask() {
// Create a canvas to learn masks pixel information
const canvas = doc.createElement('canvas')
const ctx = canvas.getContext('2nd')
const maskImage = new Picture()
maskImage.crossOrigin = 'nameless'
maskImage.onload = () => {
// Get unique picture dimensions to protect side ratio
const originalWidth = maskImage.width
const originalHeight = maskImage.top
const aspectRatio = originalWidth / originalHeight
// Calculate grid dimensions based mostly on side ratio
this.gridWidth
this.gridHeight
if (aspectRatio > 1) {
// Picture is wider than tall
this.gridWidth = this.gridSize
this.gridHeight = Math.spherical(this.gridSize / aspectRatio)
} else {
// Picture is taller than extensive or sq.
this.gridHeight = this.gridSize
this.gridWidth = Math.spherical(this.gridSize * aspectRatio)
}
canvas.width = this.gridWidth
canvas.top = this.gridHeight
ctx.drawImage(maskImage, 0, 0, this.gridWidth, this.gridHeight)
const imageData = ctx.getImageData(0, 0, this.gridWidth, this.gridHeight)
this.information = imageData.information
this.createGrid()
}
maskImage.src = '../photographs/coronary heart.jpg'
}
...
}
Match masks decision to grid
- We don’t wish to stretch the masks — this retains it proportional to the grid.
gridWidth
andgridHeight
are what number of masks pixels we’ll pattern horizontally and vertically.- This matches the logical dice grid, so every dice can correspond to at least one pixel within the masks.

Making use of the Masks to the Grid
Let’s combines mask-based filtering with customized UV mapping to resolve the place within the grid packing containers ought to seem, and how every field maps to a bit of the projected video.
Right here’s the idea step-by-step:
- Loops by means of each potential
(x, y)
place in a digital grid. - At every grid cell, it can resolve whether or not to position a field and, in that case, methods to texture it.
flippedY
: Flips the Y-axis as a result of picture coordinates begin from the top-left, whereas the grid’s origin begins from the bottom-left.pixelIndex
: Locates the pixel within thethis.information
array.- Every pixel shops 4 values: purple, inexperienced, blue, alpha.
- Extracts the
R
,G
, andB
values for that masks pixel. - Brightness is calculated as the typical of R, G, B.
- If the pixel is darkish sufficient (brightness < 128), a dice will probably be created.
- White pixels are ignored → these positions keep empty.
export default class Fashions {
constructor(gl_app) {
...
this.createMask()
}
createMask() {
...
}
createGrid() {
...
for (let x = 0; x < this.gridSize; x++) {
for (let y = 0; y < this.gridSize; y++) {
const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
// Get pixel shade from masks (pattern at grid place)
// Flip Y coordinate to match picture orientation
const flippedY = this.gridHeight - 1 - y
const pixelIndex = (flippedY * this.gridWidth + x) * 4
const r = this.information[pixelIndex]
const g = this.information[pixelIndex + 1]
const b = this.information[pixelIndex + 2]
// Calculate brightness (0 = black, 255 = white)
const brightness = (r + g + b) / 3
// Solely create field if pixel is darkish (black reveals, white hides)
if (brightness < 128) { // Threshold for black vs white
// Create particular person geometry for every field to have distinctive UV mapping
// Calculate UV coordinates for this particular field
const uvX = x / this.gridSize
const uvY = y / this.gridSize // Take away the flip to match appropriate orientation
const uvWidth = 1 / this.gridSize
const uvHeight = 1 / this.gridSize
// Get the UV attribute
const uvAttribute = geometry.attributes.uv
const uvArray = uvAttribute.array
// Map every face of the field to point out the identical portion of video
// We'll give attention to the entrance face (face 4) for the principle projection
for (let i = 0; i < uvArray.size; i += 2) {
// Map all faces to the identical UV area for consistency
uvArray[i] = uvX + (uvArray[i] * uvWidth) // U coordinate
uvArray[i + 1] = uvY + (uvArray[i + 1] * uvHeight) // V coordinate
}
// Mark the attribute as needing replace
uvAttribute.needsUpdate = true
const mesh = new THREE.Mesh(geometry, this.materials);
mesh.place.x = (x - (this.gridSize - 1) / 2) * this.spacing;
mesh.place.y = (y - (this.gridSize - 1) / 2) * this.spacing;
mesh.place.z = 0;
this.group.add(mesh);
}
}
}
...
}
...
}
Additional steps
- UV mapping is the method of mapping 2D video pixels onto 3D geometry.
- Every dice will get its personal distinctive UV coordinates equivalent to its place within the grid.
uvWidth
anduvHeight
are how a lot of the video texture every dice covers.- Modifies the dice’s
uv
attribute so all faces show the very same portion of the video.


Right here is the end result with the masks utilized:
Including Some Depth and Movement to the Grid
Including refined movement alongside the Z-axis brings the in any other case static grid to life, making the projection really feel extra dynamic and dimensional.
replace() {
if (this.is_ready) {
this.group.kids.forEach((mannequin, index) => {
mannequin.place.z = Math.sin(Date.now() * 0.005 + index * 0.1) * 0.6
})
}
}
It’s the time for A number of Grids
Up till now we’ve been working with a single masks and a single video, however the actual enjoyable begins once we begin layering a number of projections collectively. By combining totally different masks photographs with their very own video sources, we are able to create a set of unbiased grids that coexist in the identical scene. Every grid can carry its personal identification and movement, opening the door to richer compositions, transitions, and storytelling results.
1. A Playlist of Masks and Movies
export default class Fashions {
constructor(gl_app) {
...
this.grids_config = [
{
id: 'heart',
mask: `heart.jpg`,
video: `fruits_trail_squared-transcode.mp4`
},
{
id: 'codrops',
mask: `codrops.jpg`,
video: `KinectCube_1350-transcode.mp4`
},
{
id: 'smile',
mask: `smile.jpg`,
video: `infinte-grid_squared-transcode.mp4`
},
]
this.grids_config.forEach((config, index) => this.createMask(config, index))
this.grids = []
}
...
}
As an alternative of 1 masks and one video, we now have a listing of mask-video pairs.
Every object defines:
id
→ identify/id for every grid.masks
→ the black/white picture that controls which cubes seem.video
→ the feel that will probably be mapped onto these cubes.
This lets you have a number of totally different projections in the identical scene.
2. Looping Over All Grids
As soon as we now have our playlist of masks–video pairs outlined, the subsequent step is to undergo every merchandise and put together it for rendering.
For each configuration within the listing we name createMask(config, index)
, which takes care of loading the masks picture, studying its pixels, after which passing the info alongside to construct the corresponding grid.
On the similar time, we hold monitor of all of the grids by storing them in a this.grids
array, so afterward we are able to animate them, present or disguise them, and change between them interactively.
3. createMask(config, index)
createMask(config, index) {
...
maskImage.onload = () => {
...
this.createGrid(config, index)
}
maskImage.src = `../photographs/${config.masks}`
}
- Hundreds the masks picture for the present grid.
- When the picture is loaded, runs the masks pixel-reading logic (as defined earlier than) after which calls
createGrid()
with the identicalconfig
andindex
. - The masks determines which cubes are seen for this particular grid.
4. createVideoTexture(config, index)
createVideoTexture(config, index) {
this.video = doc.createElement('video')
this.video.src = `../movies/${config.video}`
...
}
- Creates a
<video>
aspect utilizing the particular video file for this grid. - The video is then transformed to a THREE.VideoTexture and assigned as the fabric for the cubes on this grid.
- Every grid can have its personal unbiased video taking part in.
5. createGrid(config, index)
createGrid(config, index) {
this.createVideoTexture(config, index)
const grid_group = new THREE.Group()
this.group.add(grid_group)
for (let x = 0; x < this.gridSize; x++) {
for (let y = 0; y < this.gridSize; y++) {
...
grid_group.add(mesh);
}
}
grid_group.identify = config.id
this.grids.push(grid_group);
grid_group.place.z = - 2 * index
...
}
- Creates a brand new THREE.Group for this grid so all its cubes will be moved collectively.
- This retains every masks/video projection remoted.
grid_group.identify
: Assigns a reputation (you would possibly later useconfig.id
right here).this.grids.push(grid_group)
: Shops this grid in an array so you possibly can management it later (e.g., present/disguise, animate, change movies).grid_group.place.z
: Offsets every grid additional again in Z-space in order that they don’t overlap visually.
And right here is the end result for the a number of grids:
And eventually: Interplay & Animations
Let’s begin by making a easy UI with some buttons on our HTML:
<ul class="btns">
<li class="btns__item">
<button class="lively" data-id="coronary heart">
...
</button>
</li>
<li class="btns__item">
<button data-id="codrops">
...
</button>
</li>
<li class="btns__item">
<button data-id="smile">
...
</button>
</li>
</ul>
We’ll additionally create a data-current="coronary heart"
to our canvas aspect, will probably be vital to vary its background-color relying on which button was clicked.
<canvas id="sketch" data-current="coronary heart"></canvas>
Let’s not create some colours for every grid utilizing CSS:
[data-current="heart"] {
background-color: #e19800;
}
[data-current="codrops"] {
background-color: #00a00b
}
[data-current="smile"] {
background-color: #b90000;
}
Time to use to create the interactions:
createGrid(config, index) {
...
this.initInteractions()
}
1. this.initInteractions()
initInteractions() {
this.present = 'coronary heart'
this.previous = null
this.is_animating = false
this.length = 1
this.DOM = {
$btns: doc.querySelectorAll('.btns__item button'),
$canvas: doc.querySelector('canvas')
}
this.grids.forEach(grid => {
if(grid.identify != this.present) {
grid.kids.forEach(mesh => mesh.scale.setScalar(0))
}
})
this.bindEvents()
}
this.present
→ The at present lively grid ID. Begins as"coronary heart"
so the"coronary heart"
grid will probably be seen by default.this.previous
→ Used to retailer the earlier grid ID when switching between grids.this.is_animating
→ Boolean flag to forestall triggering a brand new transition whereas one remains to be working.this.length
→ How lengthy the animation takes (in seconds).$btns
→ Selects all of the buttons inside.btns__item
. Every button seemingly corresponds to a grid you possibly can change to.$canvas
→ Selects the principle<canvas>
aspect the place the Three.js scene is rendered.
Loops by means of all of the grids within the scene.
- If the grid is not the present one (
grid.identify != this.present
), - → It units all of that grid’s cubes (
mesh
) to scale = 0 so they’re invisible firstly. - This implies solely the
"coronary heart"
grid will probably be seen when the scene first hundreds.
2. bindEvents()
bindEvents() {
this.DOM.$btns.forEach(($btn, index) => {
$btn.addEventListener('click on', () => {
if (this.is_animating) return
this.is_animating = true
this.DOM.$btns.forEach(($btn, btnIndex) => {
btnIndex === index ? $btn.classList.add('lively') : $btn.classList.take away('lively')
})
this.previous = this.present
this.present = `${$btn.dataset.id}`
this.revealGrid()
this.hideGrid()
})
})
}
This bindEvents()
technique wires up the UI buttons in order that clicking one will set off switching between grids within the 3D scene.
- For every button, connect a click on occasion handler.
- If an animation is already working, do nothing — this prevents beginning a number of transitions on the similar time.
- Units
is_animating
totrue
so no different clicks are processed till the present change finishes.
Loops by means of all buttons once more:
- If that is the clicked button → add the
lively
CSS class (spotlight it). - In any other case → take away the
lively
class (unhighlight). this.previous
→ retains monitor of which grid was seen earlier than the clicking.this.present
→ updates to the brand new grid’s ID based mostly on the button’sdata-id
attribute.- Instance: if the button has
data-id="coronary heart"
,this.present
turns into"coronary heart"
.
- Instance: if the button has
Calls two separate strategies:
revealGrid()
→ makes the newly chosen grid seem (by scaling its cubes from 0 to full dimension).hideGrid()
→ hides the earlier grid (by scaling its cubes again all the way down to 0).
3. revealGrid() & hideGrid()
revealGrid() {
// Filter the present grid based mostly on this.present worth
const grid = this.grids.discover(merchandise => merchandise.identify === this.present);
this.DOM.$canvas.dataset.present = `${this.present}`
const tl = gsap.timeline({ delay: this.length * 0.25, defaults: { ease: 'power1.out', length: this.length } })
grid.kids.forEach((youngster, index) => {
tl
.to(youngster.scale, { x: 1, y: 1, z: 1, ease: 'power3.inOut' }, index * 0.001)
.to(youngster.place, { z: 0 }, '<')
})
}
hideGrid() {
// Filter the present grid based mostly on this.previous worth
const grid = this.grids.discover(merchandise => merchandise.identify === this.previous);
const tl = gsap.timeline({
defaults: { ease: 'power1.out', length: this.length },
onComplete: () => { this.is_animating = false }
})
grid.kids.forEach((youngster, index) => {
tl
.to(youngster.scale, { x: 0, y: 0, z: 0, ease: 'power3.inOut' }, index * 0.001)
.to(youngster.place, {
z: 6, onComplete: () => {
gsap.set(youngster.scale, { x: 0, y: 0, z: 0 })
gsap.set(youngster.place, { z: - 6 })
}
}, '<')
})
}
And that’s it! A full animated and interactive Video Projection Slider, made with lots of of small cubes (meshes).
⚠️ Perfomance issues
The method used on this tutorial, is the best and extra digestable technique to apply the projection idea; Nonetheless, it will possibly create too many draw calls: 100–1,000 cubes would possibly fantastic; tens of hundreds will be sluggish. In the event you want extra detailed grid or extra meshes on it, think about InstancedMesh
and Shaders.
Going additional
This a totally practical and versatile idea; Due to this fact, it opens so many prospects.
Which will be utilized in some actually cool methods, like scrollable story-telling, exhibition simulation, intro animations, portfolio showcase and and so forth.
Listed here are some hyperlinks so that you can get impressed:
Last Phrases
I hope you’ve loved this tutorial, and provides a attempt in your tasks or simply discover the probabilities by altering the grid parameters, masks and movies.
And speaking concerning the movies, these used on this instance are screen-recording of the Inventive Code classes contained in my Net Animations platform vwlab.io, the place you possibly can discover ways to create extra interactions and animations like this one.
Come be a part of us, you’ll be greater than welcome! ☺️❤️