Trying to simulate a 3D effect via Three.js

Viewed 210

I'm trying to achieve something akin to this amazing effect : https://www.cobosrl.co/

Here's what I have so far : https://codepen.io/routsou/pen/ZEGWJgR?editors=0010

/*--------------------
Setup
--------------------*/
console.clear();
const canvas = document.querySelector('#bubble');

//wobble
let mouseDown = false;
let howMuch = 0;
let howMuchLimit = 0.25;

//ripple
let rippleAmount = 0;
let rippleRatio = 5;
let step = 0;
let sphereVerticesArray = [];
let sphereVerticesNormArray = [];

//raycaster
let raycaster;
let INTERSECTED = null;

let width = canvas.offsetWidth,
    height = canvas.offsetHeight;
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
  antialias: true,
  alpha: true
});
const scene = new THREE.Scene();

const setup = () => {
  renderer.setPixelRatio( window.devicePixelRatio );
  renderer.setSize(width, height);
  renderer.setClearColor(0xebebeb, 0);
  renderer.shadowMap.enabled = true;
  renderer.shadowMapSoft = true;

  scene.fog = new THREE.Fog(0x000000, 10, 950);

  const aspectRatio = width / height;
  const fieldOfView = 100;
  const nearPlane = 0.1;
  const farPlane = 10000;
  camera = new THREE.PerspectiveCamera(
    fieldOfView,
    aspectRatio,
    nearPlane,
    farPlane
  );
  
  raycaster = new THREE.Raycaster();
   
  camera.position.x = 0;
  camera.position.y = 0;
  camera.position.z = 300;
}
setup();


/*--------------------
Lights
--------------------*/
let hemispshereLight, shadowLight, light2;
const createLights = () => {
    hemisphereLight = new THREE.HemisphereLight(0xffffff,0x000000, .5)
  
    shadowLight = new THREE.DirectionalLight(0x666666, .4);
    shadowLight.position.set(0, 450, 350);
    shadowLight.castShadow = true;

    shadowLight.shadow.camera.left = -650;
    shadowLight.shadow.camera.right = 650;
    shadowLight.shadow.camera.top = 650;
    shadowLight.shadow.camera.bottom = -650;
    shadowLight.shadow.camera.near = 1;
    shadowLight.shadow.camera.far = 1000;

    shadowLight.shadow.mapSize.width = 4096;
    shadowLight.shadow.mapSize.height = 4096;
  
  light2 = new THREE.DirectionalLight(0x666666, .25);
    light2.position.set(-600, 350, 350);
  
  light3 = new THREE.DirectionalLight(0x666666, .15);
    light3.position.set(0, -250, 300);

    scene.add(hemisphereLight);  
    scene.add(shadowLight);
  scene.add(light2);
  scene.add(light3);
}
createLights();


/*--------------------
Bubble
--------------------*/
const vertex = width > 575 ? 80 : 40;
const bubbleGeometry = new THREE.SphereGeometry( 150, vertex, vertex );

let bubble;
const createBubble = () => {
  for(let i = 0; i < bubbleGeometry.vertices.length; i++) {
    let vector = bubbleGeometry.vertices[i];
    vector.original = vector.clone();  
  }
  
  const bubbleMaterial = new THREE.MeshStandardMaterial({
    emissive: 0x91176b,
    emissiveIntensity: 0.85,
    roughness: 0.55,
    metalness: 0.51,
    side: THREE.FrontSide,
  });
  
  // save points for later calculation
  for (var i = 0; i < bubbleGeometry.vertices.length; i += 1) {
    var vertex = bubbleGeometry.vertices[i];
    var vec = new THREE.Vector3(vertex.x, vertex.y, vertex.z);
    sphereVerticesArray.push(vec);
    var mag = vec.x * vec.x + vec.y * vec.y + vec.z * vec.z;
    mag = Math.sqrt(mag);
    var norm = new THREE.Vector3(vertex.x / mag, vertex.y / mag, vertex.z / mag);
    sphereVerticesNormArray.push(norm);
  }
  
  bubble = new THREE.Mesh(bubbleGeometry, bubbleMaterial);
  bubble.castShadow = true;
  bubble.receiveShadow = false;
  bubble.rotation.y = -90;
  
  scene.add(bubble);
}
createBubble();


/*--------------------
Plane
--------------------*/
const createPlane = () => {
  const planeGeometry = new THREE.PlaneBufferGeometry( 2000, 2000 );
  const planeMaterial = new THREE.ShadowMaterial({
    opacity: 0.15
  });
  const plane = new THREE.Mesh( planeGeometry, planeMaterial );
  plane.position.y = -150;
  plane.position.x = 0;
  plane.position.z = 0;
  plane.rotation.x = Math.PI / 180 * -90;
  plane.receiveShadow = true;
  scene.add(plane);
}
createPlane();


/*--------------------
Map
--------------------*/
const map = (num, in_min, in_max, out_min, out_max) => {
  return (num - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}


/*--------------------
Distance
--------------------*/
const distance = (a, b) => {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  const d = Math.sqrt( dx * dx + dy * dy );
  return d;
}


/*--------------------
Mouse
--------------------*/
let mouse = new THREE.Vector2(0, 0);
const onMouseMove = (e) => {
  TweenMax.to(mouse, 0.8, {
    x : ( e.clientX / window.innerWidth ) * 2 - 1,
    y: - ( e.clientY / window.innerHeight ) * 2 + 1,
    ease: Power2.easeOut
  });
  
  raycaster.setFromCamera( mouse, camera );
  let intersects = raycaster.intersectObjects( scene.children );

  try{
    if ( intersects.length > 0 ) {
      if ( INTERSECTED != intersects[ 0 ].object ) {

        if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );

        INTERSECTED = intersects[ 0 ].object;
        INTERSECTED.currentHex = INTERSECTED.material.emissive.getHex();
        INTERSECTED.material.emissive.setHex( 0x000000 );
        document.body.style.cursor = 'pointer';
      }
    } else {
      if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
      INTERSECTED = null;
      document.body.style.cursor = 'auto';
    }
  }catch(e){
    
  }
};
['mousemove', 'touchmove'].forEach(event => {
  window.addEventListener(event, onMouseMove);  
});


/*--------------------
Spring
--------------------*/
let spring = {
  scale: 1
};
const clicking = {
  down: () => {
    mouseDown = true;
  },
  up: () => {
    mouseDown = false;
  }
};
['mousedown', 'touchstart'].forEach(event => {
  window.addEventListener(event, clicking.down);
});
['mouseup', 'touchend'].forEach(event => {
  window.addEventListener(event, clicking.up);
});


/*--------------------
Resize
--------------------*/
const onResize = () => {
  canvas.style.width = '';
  canvas.style.height = '';
  width = canvas.offsetWidth;
  height = canvas.offsetHeight;
  camera.aspect = width / height;
  camera.updateProjectionMatrix(); 
  maxDist = distance(mouse, {x: width / 2, y: height / 2});
  renderer.setSize(width, height);
}
let resizeTm;
window.addEventListener('resize', function(){
  resizeTm = clearTimeout(resizeTm);
  resizeTm = setTimeout(onResize, 200);
});


/*--------------------
Noise
--------------------*/
let dist = new THREE.Vector2(0, 0);
let maxDist = distance(mouse, {x: width / 2, y: height / 2});
const updateVertices = (time) => {
  dist = distance(mouse, {x: width / 2, y: height / 2});
  dist /= maxDist;
  dist = map(dist, 1, 0, 0, 1);
  for(let i = 0; i < bubbleGeometry.vertices.length; i++) {
    let vector = bubbleGeometry.vertices[i];
    vector.copy(vector.original);
    let perlin = noise.simplex3(
      (vector.x * 0.006) + (time * 0.0005),
      (vector.y * 0.006) + (time * 0.0005),
      (vector.z * 0.006)
    );
    
    let ratio = ((perlin * 0.3 * (howMuch + 0.1)) + 0.9);
    vector.multiplyScalar(ratio);
  }
  bubbleGeometry.verticesNeedUpdate = true;
}

/*--------------------
Animate
--------------------*/
const render = (a) => {
  step +=1;
  requestAnimationFrame(render);
  
  //bubble.scale.set(spring.scale, spring.scale, spring.scale);
  updateVertices(a);
  renderer.clear();
  renderer.render(scene, camera);
  
  //Activate on mouse down
  if(mouseDown && howMuch < howMuchLimit)
    howMuch += 0.01;
  else if (howMuch > 0)
    howMuch -= 0.01;
  
  if(INTERSECTED){
    if(rippleAmount < 10)
      rippleAmount += 0.05;
  }else if(rippleAmount > 0)
      rippleAmount -= 0.05;
  
  doRipple();

}
requestAnimationFrame(render);
renderer.render(scene, camera);

/*--------------------
Helpers
--------------------*/

function fbm(p) {
  var result = noise.simplex3(p._x, p._y, p._z);
  return result;
}

function addPoint(arr) {
  var r = new Point(0, 0, 0);
  var len = arr.length;
  for (var i = 0; i < len; i += 1) {
    r._x += arr[i]._x;
    r._y += arr[i]._y;
    r._z += arr[i]._z;
  }

  return r;
}

function Point(_x=0, _y=0, _z=0) {
  this._x = _x;
  this._y = _y;
  this._z = _z;
}

function ripple(p) {
  var q = new Point(fbm(addPoint([p, new Point(0, 0, 0)])), 
                    fbm(addPoint([p, new Point(0, 1, 0)])),
                    fbm(addPoint([p, new Point(0, 0, 1)])));

  return fbm(addPoint([p, new Point(0.5 * q._x, 0.5 * q._y, 0.5 * q._z)]));
}

function doRipple(){
  //ripple
  for (var i = 0; i < bubbleGeometry.vertices.length; i += 1) {
    var vertex = bubbleGeometry.vertices[i];

    // var value = pn.noise((vertex.x + step)/ 10, vertex.y / 10, vertex.z / 10);

    var value = ripple(new Point((vertex.x + step) / 100.0), vertex.y / 100.0, vertex.z / 100.0);

    vertex.x = sphereVerticesArray[i].x + sphereVerticesNormArray[i].x * value * rippleAmount;
    vertex.y = sphereVerticesArray[i].y + sphereVerticesNormArray[i].y * value * rippleAmount;
    vertex.z = sphereVerticesArray[i].z + sphereVerticesNormArray[i].z * value * rippleAmount;
  }
  bubbleGeometry.computeFaceNormals();
  bubbleGeometry.computeVertexNormals();

  bubbleGeometry.verticesNeedUpdate = true;
  bubbleGeometry.normalsNeedUpdate = true;
}

Any help, particularly about the mouse pointer "sculpting the geometry", and the waves being more natural and from the pointer?

Thank you very much in advance

1 Answers

I've investigated and found you're intersecting with all children (6) in the scene, including the bubble shadow and the lights. The shadow seems to also intersect with the mouse triggering a false contact.

About "sculpting the geometry" I noticed you hardcode the ripple effect from one specific point of the bubble during initial construction and that's why the sculpting effect is always from that same point. This is my recommendation:

  • Remove the hard-coded sphereVerticesArray and sphereVerticesNormArray.
  • After computing the intersection with the mouse, find out the face of the bubble getting hit: intersections[0].point provides the point of intersection, in world coordinates. Use this to find out the face of contact.
  • During ripple effect use the normal of the contact face as starting point and orientation of the ripple.

This is the code to fix the shadow intersection issue including some comments:

/*--------------------
Setup
--------------------*/
console.clear();
const canvas = document.querySelector('#bubble');

//wobble
let mouseDown = false;
let howMuch = 0;
let howMuchLimit = 0.25;

//ripple
let rippleAmount = 0;
let rippleRatio = 5;
let step = 0;
let sphereVerticesArray = [];
let sphereVerticesNormArray = [];

//raycaster
let raycaster;
let isIntersectingWithBubble = false;

let width = canvas.offsetWidth,
    height = canvas.offsetHeight;
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
  antialias: true,
  alpha: true
});
const scene = new THREE.Scene();

const setup = () => {
  renderer.setPixelRatio( window.devicePixelRatio );
  renderer.setSize(width, height);
  renderer.setClearColor(0xebebeb, 0);
  renderer.shadowMap.enabled = true;
  renderer.shadowMapSoft = true;

  scene.fog = new THREE.Fog(0x000000, 10, 950);

  const aspectRatio = width / height;
  const fieldOfView = 100;
  const nearPlane = 0.1;
  const farPlane = 10000;
  camera = new THREE.PerspectiveCamera(
    fieldOfView,
    aspectRatio,
    nearPlane,
    farPlane
  );
  
  raycaster = new THREE.Raycaster();
   
  camera.position.x = 0;
  camera.position.y = 0;
  camera.position.z = 300;
}
setup();


/*--------------------
Lights
--------------------*/
let hemispshereLight, shadowLight, light2;
const createLights = () => {
    hemisphereLight = new THREE.HemisphereLight(0xffffff,0x000000, .5)
  
    shadowLight = new THREE.DirectionalLight(0x666666, .4);
    shadowLight.position.set(0, 450, 350);
    shadowLight.castShadow = true;

    shadowLight.shadow.camera.left = -650;
    shadowLight.shadow.camera.right = 650;
    shadowLight.shadow.camera.top = 650;
    shadowLight.shadow.camera.bottom = -650;
    shadowLight.shadow.camera.near = 1;
    shadowLight.shadow.camera.far = 1000;

    shadowLight.shadow.mapSize.width = 4096;
    shadowLight.shadow.mapSize.height = 4096;
  
  light2 = new THREE.DirectionalLight(0x666666, .25);
    light2.position.set(-600, 350, 350);
  
  light3 = new THREE.DirectionalLight(0x666666, .15);
    light3.position.set(0, -250, 300);

    scene.add(hemisphereLight);  
    scene.add(shadowLight);
  scene.add(light2);
  scene.add(light3);
}
createLights();


/*--------------------
Bubble
--------------------*/
const vertex = width > 575 ? 80 : 40;
const bubbleGeometry = new THREE.SphereGeometry( 150, vertex, vertex );
const bubbleEmissive = 0x91176b;
const bubbleEmissiveOnContact = 0x000000;

const createBubble = () => {
  for(let i = 0; i < bubbleGeometry.vertices.length; i++) {
    let vector = bubbleGeometry.vertices[i];
    vector.original = vector.clone();  
  }
  
  const bubbleMaterial = new THREE.MeshStandardMaterial({
    emissive: bubbleEmissive,
    emissiveIntensity: 0.85,
    roughness: 0.55,
    metalness: 0.51,
    side: THREE.FrontSide,
  });
  
  // save points for later calculation
  for (var i = 0; i < bubbleGeometry.vertices.length; i += 1) {
    var vertex = bubbleGeometry.vertices[i];
    var vec = new THREE.Vector3(vertex.x, vertex.y, vertex.z);
    sphereVerticesArray.push(vec);
    var mag = vec.x * vec.x + vec.y * vec.y + vec.z * vec.z;
    mag = Math.sqrt(mag);
    var norm = new THREE.Vector3(vertex.x / mag, vertex.y / mag, vertex.z / mag);
    sphereVerticesNormArray.push(norm);
  }
  
  const _bubble = new THREE.Mesh(bubbleGeometry, bubbleMaterial);
  _bubble.castShadow = true;
  _bubble.receiveShadow = false;
  _bubble.rotation.y = -90;
  
  scene.add(_bubble);

  return _bubble;
}
const bubble = createBubble();


/*--------------------
Plane
--------------------*/
const createPlane = () => {
  const planeGeometry = new THREE.PlaneBufferGeometry( 2000, 2000 );
  const planeMaterial = new THREE.ShadowMaterial({
    opacity: 0.15
  });
  const plane = new THREE.Mesh( planeGeometry, planeMaterial );
  plane.position.y = -150;
  plane.position.x = 0;
  plane.position.z = 0;
  plane.rotation.x = Math.PI / 180 * -90;
  plane.receiveShadow = true;
  scene.add(plane);
}
createPlane();


/*--------------------
Map
--------------------*/
const map = (num, in_min, in_max, out_min, out_max) => {
  return (num - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}


/*--------------------
Distance
--------------------*/
const distance = (a, b) => {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  const d = Math.sqrt( dx * dx + dy * dy );
  return d;
}


/*--------------------
Mouse
--------------------*/
let mouse = new THREE.Vector2(0, 0);
const onMouseMove = (e) => {
  TweenMax.to(mouse, 0.8, {
    x : ( e.clientX / window.innerWidth ) * 2 - 1,
    y: - ( e.clientY / window.innerHeight ) * 2 + 1,
    ease: Power2.easeOut
  });
  
  raycaster.setFromCamera( mouse, camera );
  isIntersectingWithBubble = raycaster.intersectObject( bubble ).length > 0; // we are only interested in intersections with the bubble object

  try {
      if (isIntersectingWithBubble) {
          // is intersecting: change color, change pointer, change point of contact
          bubble.material.emissive.setHex(bubbleEmissiveOnContact);
          document.body.style.cursor = 'pointer';
      } else {
          // is not intersecting: restore color, restore pointer, remove point of contact
          bubble.material.emissive.setHex(bubbleEmissive);
          document.body.style.cursor = 'auto';
      }
  } catch (e) {

  }
};
['mousemove', 'touchmove'].forEach(event => {
  window.addEventListener(event, onMouseMove);  
});


/*--------------------
Spring
--------------------*/
let spring = {
  scale: 1
};
const clicking = {
  down: () => {
    mouseDown = true;
  },
  up: () => {
    mouseDown = false;
  }
};
['mousedown', 'touchstart'].forEach(event => {
  window.addEventListener(event, clicking.down);
});
['mouseup', 'touchend'].forEach(event => {
  window.addEventListener(event, clicking.up);
});


/*--------------------
Resize
--------------------*/
const onResize = () => {
  canvas.style.width = '';
  canvas.style.height = '';
  width = canvas.offsetWidth;
  height = canvas.offsetHeight;
  camera.aspect = width / height;
  camera.updateProjectionMatrix(); 
  maxDist = distance(mouse, {x: width / 2, y: height / 2});
  renderer.setSize(width, height);
}
let resizeTm;
window.addEventListener('resize', function(){
  resizeTm = clearTimeout(resizeTm);
  resizeTm = setTimeout(onResize, 200);
});


/*--------------------
Noise
--------------------*/
let dist = new THREE.Vector2(0, 0);
let maxDist = distance(mouse, {x: width / 2, y: height / 2});
const updateVertices = (time) => {
  dist = distance(mouse, {x: width / 2, y: height / 2});
  dist /= maxDist;
  dist = map(dist, 1, 0, 0, 1);
  for(let i = 0; i < bubbleGeometry.vertices.length; i++) {
    let vector = bubbleGeometry.vertices[i];
    vector.copy(vector.original);
    let perlin = noise.simplex3(
      (vector.x * 0.006) + (time * 0.0005),
      (vector.y * 0.006) + (time * 0.0005),
      (vector.z * 0.006)
    );
    
    let ratio = ((perlin * 0.3 * (howMuch + 0.1)) + 0.9);
    vector.multiplyScalar(ratio);
  }
  bubbleGeometry.verticesNeedUpdate = true;
}

/*--------------------
Animate
--------------------*/
const render = (a) => {
  step +=1;
  requestAnimationFrame(render);
  
  //bubble.scale.set(spring.scale, spring.scale, spring.scale);
  updateVertices(a);
  renderer.clear();
  renderer.render(scene, camera);
  
  //Activate on mouse down
  if(mouseDown && howMuch < howMuchLimit)
    howMuch += 0.01;
  else if (howMuch > 0)
    howMuch -= 0.01;
  
  if(isIntersectingWithBubble){
    if(rippleAmount < 10)
      rippleAmount += 0.05;
  }else if(rippleAmount > 0)
      rippleAmount -= 0.05;
  
  doRipple();

}
requestAnimationFrame(render);
renderer.render(scene, camera);

/*--------------------
Helpers
--------------------*/

function fbm(p) {
  var result = noise.simplex3(p._x, p._y, p._z);
  return result;
}

function addPoint(arr) {
  var r = new Point(0, 0, 0);
  var len = arr.length;
  for (var i = 0; i < len; i += 1) {
    r._x += arr[i]._x;
    r._y += arr[i]._y;
    r._z += arr[i]._z;
  }

  return r;
}

function Point(_x=0, _y=0, _z=0) {
  this._x = _x;
  this._y = _y;
  this._z = _z;
}

function ripple(p) {
  var q = new Point(fbm(addPoint([p, new Point(0, 0, 0)])), 
                    fbm(addPoint([p, new Point(0, 1, 0)])),
                    fbm(addPoint([p, new Point(0, 0, 1)])));

  return fbm(addPoint([p, new Point(0.5 * q._x, 0.5 * q._y, 0.5 * q._z)]));
}

function doRipple(){
  //ripple
  for (var i = 0; i < bubbleGeometry.vertices.length; i += 1) {
    var vertex = bubbleGeometry.vertices[i];

    // var value = pn.noise((vertex.x + step)/ 10, vertex.y / 10, vertex.z / 10);

    var value = ripple(new Point((vertex.x + step) / 100.0), vertex.y / 100.0, vertex.z / 100.0);

    vertex.x = sphereVerticesArray[i].x + sphereVerticesNormArray[i].x * value * rippleAmount;
    vertex.y = sphereVerticesArray[i].y + sphereVerticesNormArray[i].y * value * rippleAmount;
    vertex.z = sphereVerticesArray[i].z + sphereVerticesNormArray[i].z * value * rippleAmount;
  }

  bubbleGeometry.computeFaceNormals();
  bubbleGeometry.computeVertexNormals();

  bubbleGeometry.verticesNeedUpdate = true;
  bubbleGeometry.normalsNeedUpdate = true;
}

Related