Create a Photo Normal Map for Three.js

In this tutorial, we create a volume effect by overlapping several images taken at different light direction.

Fix your camera on a tripod and take a photo of your scene with regular lighting.

Without moving the camera, use another source of light such as your phone flashlight, and take 4 additional shots from the same position but changing the direction of the light for each photo.

Image Tileds Sand Textures

The magic essentially occurs in your shader:

float cheapLuma ( vec3 rgb ) {
  return ( rgb.r + rgb.r + rgb.b + rgb.g + rgb.g + rgb.g ) / 6.0;
}

void main () {
  vec4 pixelTop = texture2D(imageTop, vUv);
  float lTop = cheapLuma( pixelTop.rgb );
  
  vec4 pixelBottom = texture2D(imageBottom, vUv);
  float lBottom = cheapLuma( pixelBottom.rgb );

  vec4 pixelRight = texture2D(imageRight, vUv);
  float lRight = cheapLuma( pixelRight.rgb );
  
  vec4 pixelLeft = texture2D(imageLeft, vUv);
  float lLeft = cheapLuma( pixelLeft.rgb );

  float distX = (1.0-lLeft + lRight)/2.0; 
  float distY = (1.0-lBottom + lTop)/2.0; 
  gl_FragColor = vec4( distX, distY, 1.0, 1.0 );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

In this example two files are needed:

  • Main.js the main class that sets up Three.js scene, camera and renderer.
  • RTT.js a render texture class for merging the textures into its own buffer for better reusability.

Main.js

import THREE from 'three';
import RTT from './RTT';

const frameSize = { x:512, y:512 };

export default class Main {
  constructor () {

    this.loader = new THREE.TextureLoader();

    this.mousePosition = new THREE.Vector2();

    this.clock = new THREE.Clock();
    this.scene = new THREE.Scene();

    this.camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 1, 10000 );
    this.camera.position.z = 1000;
    
    this.renderer = new THREE.WebGLRenderer({alpha:true});
    this.renderer.setSize( window.innerWidth, window.innerHeight );
    this.renderer.setClearColor( 0x000000, 0 );
    
    document.body.appendChild( this.renderer.domElement );

    this.rtt = new RTT(512, 512, this.mousePosition);
    // this.rtt.render( this.renderer, this.clock.getDelta());

    this.mesh = this.newMesh();
    this.scene.add( this.mesh );

    // let ambientLight = new THREE.AmbientLight( 0x888888 );
    let ambientLight = new THREE.AmbientLight( 0xcccccc );
    this.scene.add( ambientLight );

    this.light = new THREE.PointLight( 0x777777, 0.7, 2500 );
    this.scene.add( this.light );
    this.light.position.z = 1500;

    document.addEventListener( 'mousemove', this.onMouseMove.bind(this), false );
    window.addEventListener( 'resize', this.onWindowResize.bind(this), false );

    this.onWindowResize();
    this.onMouseMove( {clientX:window.innerWidth/2, clientY:window.innerHeight/2} );
    this.animate();
  }

  onMouseMove(event) {
    this.mousePosition.x = (( event.clientX + document.body.scrollLeft ) / window.innerWidth - .5 ) * 2;
    this.mousePosition.y = -(( event.clientY + document.body.scrollTop ) / window.innerHeight - .5 ) * 2;
    this.light.position.x = window.innerWidth/2 * this.mousePosition.x;
    this.light.position.y = window.innerHeight/2 * this.mousePosition.y;
    this.light.position.z = this.camera.position.z/2;
  }

  onWindowResize() {  
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize( window.innerWidth, window.innerHeight );
    let scaleWidth = window.innerWidth/frameSize.x;
    let scaleHeight = window.innerHeight/frameSize.y;
    if ( scaleWidth < scaleHeight )
      this.camera.position.z = frameSize.x / this.camera.aspect / (2 * Math.tan(this.camera.fov / 2 * (Math.PI / 180)));
    else
      this.camera.position.z = frameSize.y / (2 * Math.tan(this.camera.fov / 2 * (Math.PI / 180)));     
  }

  newMesh () {
    this.paperTexture = this.loader.load('img/colors-center-512-neon-2.png');
    let geometry = new THREE.PlaneBufferGeometry( frameSize.x, frameSize.y );
    let material = new THREE.MeshPhongMaterial( { 
      map: this.paperTexture,
      shininess: 5,
      specular: 0x222222,
      specularMap: this.rtt.texture.texture,
      normalMap: this.rtt.texture.texture,
      normalScale: new THREE.Vector2( 1, 1 ),
      side: THREE.DoubleSide
    });
    return new THREE.Mesh( geometry, material );
  }

  animate() {
    requestAnimationFrame( this.animate.bind(this) );
    this.rtt.render( this.renderer, this.clock.getDelta());
    this.renderer.render( this.scene, this.camera );
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

Then you need to setup the render buffer that merges the photos:

RTT.js

import THREE from 'three';

const vertexShader = `
  precision mediump float;
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
  }
`;

const fragmentShader = `
  precision mediump float;
  uniform float time;
  uniform sampler2D imageTop;
  uniform sampler2D imageBottom;
  uniform sampler2D imageRight;
  uniform sampler2D imageLeft;
  uniform vec3 color;
  varying vec2 vUv;
  float cheapLuma ( vec3 rgb ) {
    return ( rgb.r + rgb.r + rgb.b + rgb.g + rgb.g + rgb.g ) / 6.0;
  }

  void main () {
    vec4 pixelTop = texture2D(imageTop, vUv);
    float lTop = cheapLuma( pixelTop.rgb );
    vec4 pixelBottom = texture2D(imageBottom, vUv);
    float lBottom = cheapLuma( pixelBottom.rgb );
    vec4 pixelRight = texture2D(imageRight, vUv);
    float lRight = cheapLuma( pixelRight.rgb );
    vec4 pixelLeft = texture2D(imageLeft, vUv);
    float lLeft = cheapLuma( pixelLeft.rgb );
    float distX = (1.0-lLeft + lRight)/2.0; 
    float distY = (1.0-lBottom + lTop)/2.0; 
    gl_FragColor = vec4( distX, distY, 1.0, 1.0 );
  }
`;

export default class RTT {
  constructor (width, height, mousePosition) {
    this.loader = new THREE.TextureLoader();
    this.lifetime = 0;
    this.camera = new THREE.OrthographicCamera( 
        width / -2,   width / 2,
        height / 2,    height / - 2, -10000, 10000 );
    this.scene = new THREE.Scene();
    this.texture = new THREE.WebGLRenderTarget( window.innerWidth, window.innerHeight, 
      { 
        minFilter: THREE.LinearFilter, 
        magFilter: THREE.LinearFilter, 
        format: THREE.RGBFormat 
      });
    let geometry = new THREE.PlaneBufferGeometry( width, height );
    let material = new THREE.ShaderMaterial( {
      uniforms: { 
        imageTop    : { type: "t",  value:  this.loader.load('img/colors-top-512.png') },
        imageBottom : { type: "t",  value:  this.loader.load('img/colors-bottom-512.png') },
        imageRight  : { type: "t",  value:  this.loader.load('img/colors-right-512.png') },
        imageLeft   : { type: "t",  value:  this.loader.load('img/colors-left-512.png') },
        lightPos    : { type: "2f", value:  mousePosition },
        time        : { type: "f",  value:  0 },
      },
      vertexShader: vertexShader,
      fragmentShader: fragmentShader,
      blending: THREE.AdditiveBlending
    });
    this.quad = new THREE.Mesh( geometry, material );
    this.scene.add( this.quad );
  }

  render (renderer, delta) {
    this.lifetime += delta*3;
    this.quad.material.uniforms.time.value = this.lifetime;
    renderer.render( this.scene, this.camera, this.texture, true );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

You can try various textures such as paper folds...

Image Tileds Sand Textures

... or a sand garden ...

Image Tileds Sand Textures

After deep research and further experimentation, toothpaste turned out to be a fun candidate for this project.

Checkout the github repo for more references. ¡And Voilà!

Join The Conversation