Create a Photo Normal Map for Three.js

Last Updated On 11 Oct 2019 by

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.

Take 4 identical photo with light source coming from north, east, west, east, as well as a 5th photo with ambient lighting.

Image Tiled 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 );
}

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 - 0.5) *
      2;
    this.mousePosition.y =
      -((event.clientY + document.body.scrollTop) / window.innerHeight - 0.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);
  }
}

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);
  }
}

You can try various textures such as paper folds…

Image Tiled Paper Textures

… or a sand garden …

Image Tiled Sand Textures

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

Image Toothpaste Textures

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

About The Author

Headshot of Michael Iriarte aka Mika

Hi, I'm Michael aka Mika. I'm a software engineer with years of experience in frontend development. Thank you for visiting tips4devs.com I hope you learned something fun today! You can follow me on Twitter, see some of my work on GitHub, or read more about me on my website.