Search code examples
three.jsglslshaderbuffer-geometry

Transitioning vertices between 3D models with three.js


I am trying to achieve polygon blowing and reassembling effect similar to:

In both of these examples, you can see how they are morphing / transitioning the vertices from one 3d model to another, resulting in a pretty cool effect. I have something similar working, but I can't wrap my head around how they are transitioning the vertices with velocity offsets (please refer to the first link and see how the particles don't simply map and ease to the new position, but rather do it with some angular offset):

enter image description here

So, I import my two models in Three.js, take the one that has bigger vert count and copy its geometry while attaching the second's model data as an attribute:

class CustomGeometry extends THREE.BufferGeometry {
  constructor (geometry1, geometry2) {
    super()

    let { count } = geometry1.attributes.position

    // this will hold
    let targetArr = new Float32Array(count * 3)
    let morphFactorArr = new Float32Array(count)

    for (let i = 0; i < count; i += 3) {
      targetArr[i + 0] = geometry2.attributes.position.array[i + 0] || 0
      targetArr[i + 1] = geometry2.attributes.position.array[i + 1] || 0
      targetArr[i + 2] = geometry2.attributes.position.array[i + 2] || 0

      let rand = Math.random()
      morphFactorArr[i + 0] = rand
      morphFactorArr[i + 1] = rand
      morphFactorArr[i + 2] = rand
    }
    this.addAttribute('a_target',      new THREE.BufferAttribute(targetArr, 3))
    this.addAttribute('a_morphFactor', new THREE.BufferAttribute(morphFactorArr, 1))
    this.addAttribute('position', geometry1.attributes.position)
  }
}

Then in my shaders I can simply transition between them like this:

vec3 new_position = mix(position, a_targetPosition, a_morphFactor);

This works, but is really dull and boring. The vertices simply map from one model to another, without any offsets, no gravity or whatever you want to throw into the mix.

Also, since I am attaching 0 to a position if there is a vert number mismatch, the unused positions simply scale to vec4(0.0, 0.0, 0.0, 1.0), which results in, again, a dull and boring effect (here morphing between a bunny and elephant models)

(notice how the unused bunny vertices simply scale down to 0)

morphing between a rabbit and elephant model

How does one approach a problem like this?

Also, in the League of Legends link, how do they manage to

  1. Animate the vertices internally to the model while it is active on the screen

  2. Apply different velocity and gravity to the particles when mapping them to the next model (when clicking on the arrows and transitioning)?

Is it by passing a boolean attribute? Are they changing the targetPositions array? Any help is more than appreciated


Solution

  • This works, but is really dull and boring. The vertices simply map from one model to another, without any offsets, no gravity or whatever you want to throw into the mix.

    So you can apply any effects you can imagine and code. This is not the exact answer to your question, but this is the simplest motivating example of what you can do with shaders. Spoiler: the link to a working example is at the end of this answer.

    Let's transform this

    Cube

    into this

    Sphere

    with funny swarming particles during transition

    Swarm

    We'll use THREE.BoxBufferGeometry() with some custom attributes:

    var sideLenght = 10;
    var sideDivision = 50;
    var cubeGeom = new THREE.BoxBufferGeometry(sideLenght, sideLenght, sideLenght, sideDivision, sideDivision, sideDivision);
    var attrPhi = new Float32Array( cubeGeom.attributes.position.count );
    var attrTheta = new Float32Array( cubeGeom.attributes.position.count );
    var attrSpeed = new Float32Array( cubeGeom.attributes.position.count );
    var attrAmplitude = new Float32Array( cubeGeom.attributes.position.count );
    var attrFrequency = new Float32Array( cubeGeom.attributes.position.count );
    for (var attr = 0; attr < cubeGeom.attributes.position.count; attr++){
        attrPhi[attr] = Math.random() * Math.PI * 2;
      attrTheta[attr] = Math.random() * Math.PI * 2;
      attrSpeed[attr] = THREE.Math.randFloatSpread(6);  
      attrAmplitude[attr] = Math.random() * 5;
      attrFrequency[attr] = Math.random() * 5;
    }
    cubeGeom.addAttribute( 'phi', new THREE.BufferAttribute( attrPhi, 1 ) );
    cubeGeom.addAttribute( 'theta', new THREE.BufferAttribute( attrTheta, 1 ) );
    cubeGeom.addAttribute( 'speed', new THREE.BufferAttribute( attrSpeed, 1 ) );
    cubeGeom.addAttribute( 'amplitude', new THREE.BufferAttribute( attrAmplitude, 1 ) );
    cubeGeom.addAttribute( 'frequency', new THREE.BufferAttribute( attrFrequency, 1 ) );
    

    and THREE.ShaderMaterial():

    var vertexShader = [
    "uniform float interpolation;",
    "uniform float radius;",
    "uniform float time;",
    "attribute float phi;",
    "attribute float theta;",
    "attribute float speed;",
    "attribute float amplitude;",
    "attribute float frequency;",
    
    "vec3 rtp2xyz(){ // the magic is here",
    " float tmpTheta = theta + time * speed;",
    " float tmpPhi = phi + time * speed;",
    " float r = sin(time * frequency) * amplitude * sin(interpolation * 3.1415926);",
    " float x = sin(tmpTheta) * cos(tmpPhi) * r;",
    " float y = sin(tmpTheta) * sin(tmpPhi) * r;",
    " float z = cos(tmpPhi) * r;",
    " return vec3(x, y, z);",
    "}",
    
    "void main(){",
    " vec3 newPosition = mix(position, normalize(position) * radius, interpolation);",
    " newPosition += rtp2xyz();",
    "   vec4 mvPosition = modelViewMatrix * vec4( newPosition, 1.0 );",
    "   gl_PointSize = 1. * ( 1. / length( mvPosition.xyz ) );",
    "   gl_Position = projectionMatrix * mvPosition;",
    "}"
    ].join("\n");
    
    var fragmentShader = [
    "uniform vec3 color;",
    "void main(){",
    "   gl_FragColor = vec4( color, 1.0 );",
    "}"
    ].join("\n");
    
    var uniforms = {
        interpolation: { value: slider.value},
      radius: { value: 7.5},
      color: { value: new THREE.Color(0x00ff00)},
      time: { value: 0 }
    }
    
    var shaderMat = new THREE.ShaderMaterial({
        uniforms: uniforms,
        vertexShader: vertexShader,
      fragmentShader: fragmentShader,
      //wireframe: true //just in case, if you want to use THREE.Mesh() instead of THREE.Points()
    });
    

    As you can see, all the magic happens in the vertex shader and its rtp2xyz() function.

    And in the end, the code of the function of animation:

    var clock = new THREE.Clock();
    var timeVal = 0;
    
    render();
    function render(){
        timeVal += clock.getDelta();
        requestAnimationFrame(render);
      uniforms.time.value = timeVal;
      uniforms.interpolation.value = slider.value;
      renderer.render(scene, camera);
    }
    

    Oh, and yes, we have a slider control in our page:

    <input id="slider" type="range" min="0" max="1" step="0.01" value="0.5" style="position:absolute;width:300px;">
    

    Here's a snippet

    body{
      margin: 0;
    }
    <script type="importmap">
      {
        "imports": {
          "three": "https://unpkg.com/[email protected]/build/three.module.js",
          "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
        }
      }
    </script>
    <input id="slider" type="range" min="0" max="1" step="0.01" value="0.5" style="position:absolute;width:300px;">
    <script type="module">
    import * as THREE from "three";
    import {OrbitControls} from "three/addons/controls/OrbitControls.js";
    
    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 1000);
    camera.position.set(10, 10, 20);
    var renderer = new THREE.WebGLRenderer({antialias: true});
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    
    var controls = new OrbitControls(camera, renderer.domElement);
    
    var vertexShader = [
    "uniform float interpolation;",
    "uniform float radius;",
    "uniform float time;",
    "attribute float phi;",
    "attribute float theta;",
    "attribute float speed;",
    "attribute float amplitude;",
    "attribute float frequency;",
    
    "vec3 rtp2xyz(){ // the magic is here",
    " float tmpTheta = theta + time * speed;",
    " float tmpPhi = phi + time * speed;",
    " float r = sin(time * frequency) * amplitude * sin(interpolation * 3.1415926);",
    " float x = sin(tmpTheta) * cos(tmpPhi) * r;",
    " float y = sin(tmpTheta) * sin(tmpPhi) * r;",
    " float z = cos(tmpPhi) * r;",
    " return vec3(x, y, z);",
    "}",
    
    "void main(){",
    " vec3 newPosition = mix(position, normalize(position) * radius, interpolation);",
    " newPosition += rtp2xyz();",
    "   vec4 mvPosition = modelViewMatrix * vec4( newPosition, 1.0 );",
    "   gl_PointSize = 1. * ( 1. / length( mvPosition.xyz ) );",
    "   gl_Position = projectionMatrix * mvPosition;",
    "}"
    ].join("\n");
    
    var fragmentShader = [
    "uniform vec3 color;",
    "void main(){",
    "   gl_FragColor = vec4( color, 1.0 );",
    "}"
    ].join("\n");
    
    var uniforms = {
        interpolation: { value: slider.value},
      radius: { value: 7.5},
      color: { value: new THREE.Color(0x00ff00)},
      time: { value: 0 }
    }
    
    var sideLenght = 10;
    var sideDivision = 50;
    var cubeGeom = new THREE.BoxGeometry(sideLenght, sideLenght, sideLenght, sideDivision, sideDivision, sideDivision);
    var attrPhi = new Float32Array( cubeGeom.attributes.position.count );
    var attrTheta = new Float32Array( cubeGeom.attributes.position.count );
    var attrSpeed = new Float32Array( cubeGeom.attributes.position.count );
    var attrAmplitude = new Float32Array( cubeGeom.attributes.position.count );
    var attrFrequency = new Float32Array( cubeGeom.attributes.position.count );
    for (var attr = 0; attr < cubeGeom.attributes.position.count; attr++){
        attrPhi[attr] = Math.random() * Math.PI * 2;
      attrTheta[attr] = Math.random() * Math.PI * 2;
      attrSpeed[attr] = THREE.MathUtils.randFloatSpread(6); 
      attrAmplitude[attr] = Math.random() * 5;
      attrFrequency[attr] = Math.random() * 5;
    }
    cubeGeom.setAttribute( 'phi', new THREE.BufferAttribute( attrPhi, 1 ) );
    cubeGeom.setAttribute( 'theta', new THREE.BufferAttribute( attrTheta, 1 ) );
    cubeGeom.setAttribute( 'speed', new THREE.BufferAttribute( attrSpeed, 1 ) );
    cubeGeom.setAttribute( 'amplitude', new THREE.BufferAttribute( attrAmplitude, 1 ) );
    cubeGeom.setAttribute( 'frequency', new THREE.BufferAttribute( attrFrequency, 1 ) );
    
    var shaderMat = new THREE.ShaderMaterial({
        uniforms: uniforms,
        vertexShader: vertexShader,
      fragmentShader: fragmentShader,
      //wireframe: true
    });
    var points = new THREE.Points(cubeGeom, shaderMat);
    scene.add(points);
    
    var clock = new THREE.Clock();
    var timeVal = 0;
    
    render();
    function render(){
        timeVal += clock.getDelta();
        requestAnimationFrame(render);
      uniforms.time.value = timeVal;
      uniforms.interpolation.value = slider.value;
      renderer.render(scene, camera);
    }
    </script>