How I Faked 3D Depth from a Single Image with Three.js

What keeps you up at night?

You wanna know what keeps me up at night? It's the sheer size of the universe.

Each galaxy hosts billions of stars like our sun, and there are trillions of galaxies.

The observable universe alone spans some 93 billion light years, and yet it is still only the portion we can detect. Space continues to expand far beyond what we will ever be able to observe, and beyond that limit, who knows what's out there?

It's terrifying.

Now, one image of space that has always stayed with me is the Hubble Ultra Deep Field.

Hubble telescope ultra deep field

Captured by the Hubble Space Telescope, it was once the deepest image of the universe ever taken, revealing approximately 10,000 galaxies in what appeared to be a tiny, empty patch of sky.

I first came across it through a YouTube video as an inspired teen in the platform's early days.

Hubble, as seen from Discovery during its second servicing mission Hubble, as seen from Discovery during its second servicing mission, source: NASA

Recently, my colleagues and I geeked out about space, having watched the incredible film Project Hail Mary. Amusingly, in the aftermath of that conversation, one colleague managed to unintentionally recreate the Hubble Deep Field on his napkin, composed entirely of croissant crumbs.

Hubble deep field crumbs on a napkin

Anyway, that was a weird digression, but bear with me.

I've always been captivated by those interactive 3D effects you occasionally stumble across on social media sites. You know, the ones where a static image you've uploaded can somehow be made to feel alive when you move your mouse.

An example of the sort of parallax effect users are able to generate for their photos on Facebook Source: Facebook Help Centre "Create a 3D photo on Facebook"

Recently, while coding an interactive slideshow component for e-learning, I realised it could be really cool to bring this kind of 3D effect into slides and use it to captivate learners.

However, there is a fundamental limitation: to the browser, an image is simply a flat rectangle of pixels.

So where does one therefore actually start? How do you extract genuine 3D behavior from a single, flat image?

The Key Challenge: Images Are Flat, duh!

When you snap a photo, whether it's on an iPhone or the Hubble Telescope, you take a sprawling, three-dimensional universe and… VIOLENTLY SQUASHING it onto a two-dimensional plane.

The moment that shutter clicks, all the physical Z-axis data, whether it's the distance between a tree and the mountain behind it, or even a far flung galaxy, is instantly thrown into the trash.

A standard JPEG only saves the X, Y coordinates and the colour of the light hitting that exact spot, so needless to say, it does not contain geometric depth.

So, how do they do it?

A popular starting point in faking depth is by relying on a glitch in human perception: Parallax.

Crikey, that's a fancy word.

But the concept is quite simple.

It starts by understanding how we perceive depth in the world around us.

When you move your head, your eyes, or your body, the image reaching your eyes changes. Nearby objects change position quickly across your field of view, while distant objects change position much more slowly. Your brain has learned that this difference usually means distance. Fast visual movement usually means "close" and slow visual movement usually means "far away".

A simple everyday example is holding your finger in front of your face and alternating which eye is open. Your finger will appear to jump from side to side, even though it has not actually moved.

That happens because each eye sees your finger from a slightly different angle.

And as your finger is close, that small change in viewpoint creates a noticeable shift against the background.

A side-by-side optical illusion image shows nearly identical café table scenes. Each panel features a bitten croissant on a white plate, a coffee cup, and an open laptop displaying code. A hand points upward in the foreground, with the finger shifted slightly between the two panels to create a “close one eye, then the other” effect. Oh no, not the croissant!

Another simple example is what happens when you travel in a car. The trees beside the road seem to rush past you, while the hills in the distance barely appear to move at all.

That difference in visual speed based on distance is called parallax. It is the primary way our brains calculate depth.

Remarkably, a small of amount of CSS can help us fake some of the depth cues our brain expects to see.

Instead of changing an image itself, we change how it is presented on the page.

It’s a fairly common way to create depth, and one many web developers reach for first, not least because CSS has an easy, built-in way to do it.

If you wrap an image in a container and give that container a CSS perspective value, you are telling the browser to draw the image as if it sits inside a small 3D space instead of flat on the page. The image is still only a flat rectangle, but the browser now knows how it should look when it tilts towards or away from the viewer.

Then, with JavaScript, you can track the user’s mouse position and slightly rotate the image using rotateX and rotateY. rotateX tilts the image forward or backward, while rotateY tilts it left or right. As the image tilts, one side appears to come closer and the other side appears to move farther away. That changing angle creates a small depth illusion in your perception of it.

It looks cool, but I wasn't totally satisfied.

While the container looks like it's rotating in 3D space, the content of the photo is completely unaffected. Nothing truly 3D is really happening inside the photo. The browser just treats the image like a tilting piece of cardboard.

I wanted to explore the subject further!

Faking Geometry

When it comes to 3D on the web, Three.js is often what developers reach for first.

Three.js is a popular JavaScript library that lets you create and render interactive 3D graphics directly in the browser without needing to deal with low-level graphics code.

I'm always struck by how powerful it is, and have really enjoyed working with it in the past. Indeed, this site's very own landing page already contains a 3D augmented view of space as the hero background.

So equipped with a precious, albeit vague idea, I took the first step of my quest by googling queries like “3D effect image three.js”.

Ninety nine percent of them told me the same thing: you need to generate a depth map, you need to generate a depth map, you need to generate a depth map.

It played in my mind like a gramophone stuck on repeat.

Depth mapdepth map… the words lingered in my mind. Wait, what the heck is a depth map?

A depth map is a grayscale version of an image where brightness is used to describe depth. Lighter areas are treated as closer, and darker areas are treated as farther.

Side-by-side comparison of a calico cat perched on a radiator, with the original photo on the left and a blurred grayscale depth-map version on the right. I got my sweet cat to act as a model

The tutorials I found usually followed the same pattern.

You take your original image and either run it through an AI depth estimation tool like MiDaS, or spend an hour in Photoshop painting a secondary grayscale image. I've even had luck with ChatGPT.

Once you've got your depth map, you load both into the browser, the original for visuals and the depth map as a sort of instruction layer.

From there, a library like Three.js can use the depth map as a set of instructions. Bright pixels tell it which parts of the image should appear closer to the camera, while dark pixels instruct on which parts should sit farther back.

In the 3D graphics world, this technique is called Displacement Mapping.

You can then make this depth respond to user input, such as mouse movement, scrolling, or device tilt. As the user moves, the scene can shift slightly based on the depth map. The closer parts of the image move more noticeably, while the farther parts move more subtly. This difference in movement creates a much more complex depth map.

The "Aha!" Moment

I was gearing up to implement the aforementioned grayscale displacement mapping when I set the cat among the pigeons.

That's because despite the journey so far, the web developer in me wasn't completely satisfied.

I was building a 3D slideshow component, and with ten slides, using depth maps would force the browser to download twenty separate image files. Plus, I’d have to manually generate a map in Photoshop, or rely on AI tools, every time someone uploaded a new photo.

Great.. another thing to keep me up at night..

Searching for inspiration from the universe, I found myself looking back at that wondrous NASA image.

The longer I stared at it, the more it seemed to pull me in. Space is endless, immense and oh so dark.. and each galaxy stands out like a distant point of light.

futurama Fry Meme Generator

Wait a moment!

The things I wanted to push into the foreground were already the brightest pixels on the screen.

Woah.

Why on earth, or in this galaxy, was I going to force the user's browser to download a second image just to tell the browser what was bright and what was dark?

The journey had taken a new and unexpected turn.

Calculating brightness

To understand brightness, we need to look at what each pixel actually contains.

In a normal digital image, each pixel stores three colour values: red, green, and blue.

Each value usually ranges from 0 to 255, so a pure red pixel might be rgb(255, 0, 0), a pure green pixel might be rgb(0, 255, 0), and a pure blue pixel might be rgb(0, 0, 255). The colour you see in the pixel is created by mixing those three channels together.

Now, in case you didn't know, RGB is an additive model. More red, green, and blue combined gives you more light, and when all three channels are maxed out, you get white. rgb(255, 255, 255)

But this is where it gets interesting.

Brightness is not just a matter of how much red, green, and blue you have, it's also a matter of how brightness is perceived by the human eye.

Color scientist Charles Poynton explains this clearly in his widely cited writing on digital color spaces.

"If three sources appear red, green and blue, and have the same radiance in the visible spectrum, then the green will appear the brightest of the three because the luminous efficiency function peaks in the green region of the spectrum". [2]

It's all to do with how our eyes detect light. At the back of the eye, the retina contains tiny light-sensitive cells called cones. Some cones respond most strongly to reddish light, some to greenish light, and some to bluish light. The red and green-sensitive cones are much more common in the central part of our vision, so they play a bigger role in how bright something appears. Blue-sensitive cones are far less common, so blue contributes less to perceived brightness.

Diagram showing the three types of cone cells involved in colour vision. Image adapted from Charlotte Nickerson’s “The Trichromatic Theory of Color Vision” on Simply Psychology, updated May 11, 2026. Diagram showing the three types of cone cells involved in colour vision. Image adapted from Charlotte Nickerson’s “The Trichromatic Theory of Color Vision” on Simply Psychology, updated May 11, 2026.

So any calculation of brightness has to lean toward green, and we even know by how much.

Y = 0.2126R + 0.7152G + 0.0722B

This bad boy is the Rec. 709 relative luminance formula, Y represents how bright a pixel appears after its red, green, and blue values have been weighted according to human vision.

Those are not random weights. These numbers were defined in 1990 by the International Telecommunication Union (ITU) as part of the global standard for High-Definition Television. To make sure broadcasts appeared equally bright on every screen, they had to calculate how human eyes perceive red, green, and blue light.

I was staring at that math when the dots finally connected.

If I could extract the pixels from my image and run this exact calculation in real time, I would get a biologically accurate value between 0.0 (pure black void) and 1.0 (bright white galaxy).

Then, I could theoretically plug it straight into some basic 3D displacement logic to physically push the bright pixels forward and parallax the X and Y axes.

I quickly jotted down this pseudo code.

// 1. Grab a single pixel from our Hubble space photograph
Pixel currentPixel = getPixelFromImage(x, y)

// 2. Extract its Red, Green, and Blue values 
// (Assuming these are normalised on a scale from 0.0 to 1.0)
Float red   = currentPixel.R
Float green = currentPixel.G
Float blue  = currentPixel.B

// 3. Apply the Rec. 709 Biological Math
// We multiply each color channel by how sensitive human eyes are to it
Float weightedRed   = red   * 0.2126  // ~21%
Float weightedGreen = green * 0.7152  // ~72%
Float weightedBlue  = blue  * 0.0722  // ~7%

// 4. Add them together to get the final perceived brightness (Luminance)
// This gives us a single number between 0.0 (pure black) and 1.0 (pure white)
Float luminance = weightedRed + weightedGreen + weightedBlue

// 5. Use that single number to physically move the geometry in 3D space!
// (Z just means depth: toward or away from you)
// luminance (0 to 1) controls how far this point moves forward in 3D space.
// depthScale controls how tall the peaks can get.
Vertex.Z_Position = luminance * depthScale

Subdivision of the Image

It was time to put theory into action.

If we want to push parts of an image forward, we need to carve our canvas into a grid where each square can move independently of one another.

In Three.js, this is exactly what the class PlaneGeometry does.

const geometry = new THREE.PlaneGeometry(1, 1, 120, 120);

Looking at the arguments, the first two numbers set the size of the plane: 1, 1.

PlaneGeometry creates a flat rectangular surface. You can think of it a bit like adding a flat sheet of paper into the 3D scene.

In this case, the first 1 sets the width, and the second 1 sets the height. So this creates a plane that is 1 unit wide and 1 unit tall.

That does not mean it will be 1 pixel wide or 1 pixel tall on the screen. Three.js uses its own 3D world units, not pixels. The size you see on screen depends on where the camera is, how close it is to the plane, and how the scene is being viewed.

But wait, doesn't the image we're applying the effect to define the plane?

In Three.js, the plane is the 3D surface, and the image is used as a texture on top of it. If the plane is 1 unit wide and 1 unit tall, Three.js stretches the image across that square.

Moving on, the next two numbers control how many times that plane is divided: 120, 120.

This means the plane is split into 120 segments across and 120 segments down.

So this line:

const geometry = new THREE.PlaneGeometry(1, 1, 120, 120);

creates a flat square, but with a lot of tiny sections inside it.

By subdividing the plane into lots of smaller sections, we give the image a dense grid of vertices. Each vertex can move slightly on its own. That is what allows our surface to behave more like a flexible sheet that can bend, warp, and create the illusion of depth.

The 120 by 120 value is not magic, it's just a compromise.

If the number is too low, the image can look blocky because there are not enough points to move smoothly.

If the number is too high, the GPU has to do more work every frame, which can slow things down.

A 120 by 120 plane gives us enough detail for a smooth effect without making the browser work too hard.

Rise of the Pixels

Now things get spicy.

We're going to physically push parts of new grid forward forward or backward in 3D space.

To do that, we need to ask every part of our grid a simple question: “How bright are you? Damnit!”

Ok, the damnit might be unnecessary.. But based on that answer, bright pixels rise, dark pixels sink.

In order to do that, we need to use Three.js' ShaderMaterial class.

Essentially, Three.js treats every visible object as being made from two main parts: geometry which defines the shape and material which defines how that shape looks.

So where the PlaneGeometry class gives us the geometry, meaning the grid, ShaderMaterial gives us control over what happens to that grid.

A “shader” is not a Three.js-specific term. It is a general computer graphics term for a small program that runs on the GPU. Shaders are used in many graphics systems, not just Three.js, and they are responsible for controlling how objects are drawn to the screen.

ShaderMaterial writes its instructions not in JavaScript, but in GLSL, which stands for OpenGL Shading Language.

GLSL is not used for normal app logic, like handling clicks, updating state, or fetching data. JavaScript still handles that side of the application. GLSL is designed for graphics, and its job is to run lots of small calculations directly on the GPU extremely quickly.

A normal JavaScript function might loop through each pixel at a time, but that would quickly become computationally expensive, and ultimately impractical, for 3D rending. The GPU, on the other hand, is built for this kind of work. It can run the same kind of calculation across thousands of points at once.

This is why shaders are useful for effects like waves, distortion, gradients, particles, water, fire, and animated surfaces.

A shader is often described as one thing, but in practice the work is usually split into separate shader programs that run at different stages of the rendering process. The two most important ones, for our purposes, are the vertex shader and the fragment shader.

The vertex shader is the part that works on the shape of the object. Before anything is coloured in, it looks at the points that make up the geometry and can change where those points sit in 3D space. In our case, the flat plane has been divided into lots of points across a grid. The vertex shader can move each of those points forward or backward. That is what lets a flat image start to rise and dip, creating the illusion of depth.

The fragment shader comes afterwards. Once the shape has been positioned, the fragment shader decides what colour each visible part of that shape should be.

A simple way to think about it is that the vertex shader shapes the surface, and the fragment shader paints it.

Before looking at the full code, it helps to understand the basic shape of a ShaderMaterial.

const material = new THREE.ShaderMaterial({
  uniforms: {
    // Values from JavaScript go here
  },

  vertexShader: `
    // GLSL code for moving the geometry goes here
  `,

  fragmentShader: `
    // GLSL code for colouring the surface goes here
  `
});

Even though this sits inside a JavaScript file, the shader code itself is written as text inside backticks. That is because the code inside the backticks is GLSL, not JavaScript. Three.js takes those strings and passes them to WebGL, which compiles them so they can run on the GPU.

The uniforms object is where we pass values from JavaScript into the shader code. In our case, we want to pass in two things: the image texture and the strength of the depth effect.

Now let’s fill in the full setup for our image depth effect.

// ImageDepthPlane.js
import * as THREE from "three";

export class ImageDepthPlane {
  constructor({ imagePath, depth = 0.1 }) {
    // Store the values passed into the class.
    this.imagePath = imagePath;
    this.depth = depth;

    // Create the mesh as soon as the class is created.
    this.mesh = this.createMesh();
  }

  createMesh() {
    // Load the image file and convert it into a Three.js texture.
    // A texture is the graphics-friendly version of an image.
    const textureLoader = new THREE.TextureLoader();
    const texture = textureLoader.load(this.imagePath);

    // Create a flat square plane and divide it into a fine grid.
    // The 1, 1 sets the overall size of the plane in the 3D scene.
    // The 120, 120 splits the plane into 120 segments across and 120 segments down,
    // giving the shader lots of points to move for the depth effect.
    const geometry = new THREE.PlaneGeometry(1, 1, 120, 120);

    const material = new THREE.ShaderMaterial({
      // uniforms lets JavaScript pass values into the shader code.
      // Here, we pass in the loaded image texture and the depth strength.
      uniforms: {
        // The image texture the shader will sample.
        // This came from the imagePath passed into the class.
        uTexture: { value: texture },

        // The strength of the 3D depth effect.
        // Larger values create more noticeable peaks and dips.
        uDepth: { value: this.depth }
      },

      // This property holds the vertex shader code.
      // This is GLSL written as a JavaScript string.
      // It runs once for each point in the plane's grid.
      vertexShader: `
        // A varying is a value passed from the vertex shader to the fragment shader.
        // Here, we use it to pass the UV coordinates along so the fragment shader
        // knows which part of the image to draw on each pixel.
        varying vec2 vUv;
        
        // A uniform is a value passed into the shader from JavaScript.
        // This one holds the image texture loaded with THREE.TextureLoader.
        uniform sampler2D uTexture;

        // This uniform holds the strength of the depth effect.
        // JavaScript controls this value through the ShaderMaterial uniforms object.
        uniform float uDepth;

        void main() {
          // Save the UV coordinates so the fragment shader
          // knows which part of the image belongs to this point.
          vUv = uv;

          // Sample the image colour at this point on the plane.
          vec4 texel = texture2D(uTexture, uv);

          // Turn the RGB colour into one brightness value.
          // These weighted values better match how human vision perceives brightness.
          float brightness = dot(texel.rgb, vec3(0.2126, 0.7152, 0.0722));

          // Convert brightness into depth.
          // Darker areas move back, brighter areas move forward.
          float depth = (brightness - 0.5) * uDepth;

          // Start with the vertex's original position,
          // then move it along the Z axis.
          vec3 displacedPosition = position;
          displacedPosition.z += depth;

          // Output the final screen position of this vertex.
          gl_Position = projectionMatrix * modelViewMatrix * vec4(displacedPosition, 1.0);
        }
      `,

      // This property holds the fragment shader code.
      // This is also GLSL written as a JavaScript string.
      // It controls the colour of the visible surface.
      fragmentShader: `
				// This receives the UV coordinates that were passed from the vertex shader.
        // The fragment shader uses them to look up the correct part of the image.
        varying vec2 vUv;
        
        // This receives the same image texture that JavaScript passed into the shader.
        // We use it to colour the surface with the original image.
        uniform sampler2D uTexture;

        void main() {
          // Colour the displaced surface using the original image.
          gl_FragColor = texture2D(uTexture, vUv);
        }
      `
    });

    // A mesh combines geometry and material into one visible object.
    return new THREE.Mesh(geometry, material);
  }
}

It's seems like a lot to digest at first glance, but the core logic is actually quite simple.

The important part starts here:

vec4 texel = texture2D(uTexture, uv);

The uTexture value is the image we passed into the shader from JavaScript. The uv value tells the shader which part of the image to read. You can think of UV coordinates as image coordinates. They tell the shader where it is on the surface.

Then this line converts that colour into brightness:

float brightness = dot(texel.rgb, vec3(0.2126, 0.7152, 0.0722));

This is the same luminance calculation from earlier. Red, green, and blue are not treated equally. Green contributes the most, red contributes some, and blue contributes the least.

Once we have brightness, we turn it into depth:

float depth = (brightness - 0.5) * uDepth;

The brightness - 0.5 part is important. Think of brightness as a scale from dark to bright:

0.0           0.5           1.0
dark          middle        bright

If we use that number directly for depth, the smallest value is 0.0, so nothing can move backwards. Dark areas would move 0, middle areas would move 0.5, and bright areas would move 1.0. Everything either stays where it is or moves forward.

Subtracting 0.5 changes the scale:

brightness:        0.0        0.5        1.0
after - 0.5:      -0.5        0.0        0.5
meaning:          back        flat       forward

This lets the image rise and dip around the original flat plane, instead of only being pushed out in one direction.

Then we apply that movement to the vertex:

displacedPosition.z += depth;

The z axis controls depth. In simple terms, changing the z value moves a point forward or backward in 3D space.

Using our class above elsewhere in the code would look like this:

// main.js
import * as THREE from "three";
import { ImageDepthPlane } from "./ImageDepthPlane.js";

const scene = new THREE.Scene();

const imageDepthPlane = new ImageDepthPlane({
  imagePath: "/images/hubble.jpg",
  depth: 0.1
});

scene.add(imageDepthPlane.mesh);

Cinematic Polish

With the geometry physically warping in 3D space based on the image's own brightness, the illusion was already working.

But since we are already running code on the GPU, I wanted to see if I could go further still.

The fragment shader gave me a place to add some extra visual polish. Instead of only drawing the original image back onto the warped surface, I could slightly adjust how the final pixels were coloured.

One effect I added was chromatic aberration. This means the red, green, and blue channels are sampled from slightly different positions, creating a subtle prism-like distortion around the image.

float aberration = 0.0008 + abs(vDepth) * 0.002;

float r = texture2D(uTexture, sampleUv + vec2(aberration, 0.0)).r;
float g = texture2D(uTexture, sampleUv).g;
float b = texture2D(uTexture, sampleUv - vec2(aberration, 0.0)).b;

vec3 color = vec3(r, g, b);

Because I was working with a space image, I also added a small twinkle effect to the brightest pixels. This helped the stars feel a little more alive without needing another texture or animation file.

float brightest = max(max(color.r, color.g), color.b);
float starMask = smoothstep(0.8, 1.0, brightest);
float twinkle = hash(vUv * 140.0 + floor(uTime * 6.0));

color += starMask * twinkle * 0.03;

gl_FragColor = vec4(color, 1.0);

The bigger idea is that once the image is inside the shader, we can do more than simply display it. We can distort it, recolour it, animate parts of it, or make certain areas react differently, all while still using a single image file!

The Mission Debrief

Ready for the final result.. 🥁🥁🥁🥁

Hubble ultra deep field with a 3D effect

This approach works beautifully when brightness matches the important shapes in the image.

In the Hubble photo, that relationship is almost perfect. The galaxies are bright, the surrounding space is dark, and the contrast gives the shader a natural signal to work with.

But this is not true for every photograph.

A bright area does not always mean “closer”, and a dark area does not always mean "farther". Sometimes something is bright simply because a light is shining on it. Sometimes something is dark because it is in shadow, because it is wearing a dark colour, or because that part of the image has less contrast.

So this technique is not a universal depth detector. It is better understood as brightness-based displacement. It turns light into height.

On this note, it's probably worth mentioning that real cosmic distance is not measured by brightness alone. Astronomers often use redshift, where light from distant galaxies is stretched toward longer, redder wavelengths as the universe expands.

But I was not trying to build a scientific distance calculator. I was building a visual effect using brightness as an artistic depth cue!

References

[1] Cook, R.L. (1984). Shade trees. ACM SIGGRAPH Computer Graphics, 18(3), 223–231. Available at: https://dl.acm.org/doi/10.1145/964965.808602

[2] Poynton, C. (1997). Frequently asked questions about color. Available at: https://poynton.ca/PDFs/ColorFAQ.pdf

[3] Fairman, H.S., Brill, M.H. and Hemmendinger, H. (1997). How the CIE 1931 color-matching functions were derived from Wright-Guild data. Color Research & Application, 22(1), 11–23. Available at: https://doi.org/10.1002/(SICI)1520-6378(199702)22:1%3C11::AID-COL4%3E3.0.CO;2-7

[4] Nickerson, C. (2026). The trichromatic theory of color vision. Simply Psychology. Updated May 11, 2026. Available at: https://www.simplypsychology.org/what-is-the-trichromatic-theory-of-color-vision.html