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.jsthe main class that sets up Three.js scene, camera and renderer.RTT.jsa 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…

… or a sand garden …

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à!