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.

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

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.

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

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...

... or a sand garden ...

Checkout this old github repo for more references.