Happy pride friends~


Skirt go Spinny


see this fullscreen

Ah yes, skirt go spinny, lmao.
But how you doin that?

I'm glad you asked. Here's my best short answer:

Use ThreeJS for loading in a low-poly, vertex colored, & textured GLTF file exported out of Blender

Then using Ammo.js, which comes neatly packaged with ThreeJS, to simulate that model as a soft body (such as cloth or jello)

Anchoring that soft body to an invisible Ammo kinematic rigid body hovering above the soft body, in this case a cube

Finally, handling mouse/touch input from you, the user, and calculating the physics for the invisible kinematic cube.


Cool, but wut.

I'm glad you asked. Here's my best long answer:

Ammo.js

Ammo.js is a phyics engine and direct port of an api called Bullet Physics. And because it's a pretty smart port, the api used for JavaScript is virtually the same as Bullet but replaces any Bullet functions with 'Ammo do thing' instead of 'Bullet do thing'. With Ammo.js, like in a game engine, we can simulate physics using rigid and soft bodies handling collisions and interation.

Import

I am using Ammo.js to take a GLTF model that I modelled and textured in Blender, load it in with ThreeJS and convert it to a soft body. I also painted vertex colors on the rim of the skirt which are imported right along with the mesh. We can then search for the vertex colors convert and normalize them as weighted influences to be used as anchors for the soft body.

Glue

With those achors, we can glue the model to an invisible kinematic rigid body above the model that suspends it in space.

Kinematic rigid bodies are useful because the bodies them selves can receive and give physics information but aren't affected by the rest of the simulation, (i.e gravity, projectiles, other rigid & soft bodies in motion). And since it can recieve information we can attach our soft body to it while it is suspended above and apply user input.

Simulate

Using MATH, the attached soft body spins around by taking input from user's mouse/touch speed and calculating angular momentum for the kinematic rigid body.


Make sense yet? Kinda?

Here. Let's peel behind the curtain a little bit. (LOOK AT THE GITHUB CODE!)

Behind the Scenes


see this fullscreen


All of the code:

// Here's everything minus the external scene files such as the GLTF, textures, and imported classes which can be found on github


<script src='../examples/js/libs/ammo.wasm.js'></script>

<script type='module'>
  import * as THREE from '../build/three.module.js';

  import Stats from '../examples/jsm/libs/stats.module.js';
  import { GUI } from '../examples/jsm/libs/dat.gui.module.js';

  import { OrbitControls } from '../examples/jsm/controls/OrbitControls.js';
  import { BufferGeometryUtils } from '../examples/jsm/utils/BufferGeometryUtils.js';
        
  import { GLTFLoader } from '../examples/jsm/loaders/GLTFLoader.js';
  import { DRACOLoader } from '../examples/jsm/loaders/DRACOLoader.js';

  import { MStylizedFabric } from '../materials/MStylizedFabric.js';

  // SCENE VARIABLES
  let container, gui, stats, params, camera, controls, scene, renderer, mixer, meshScene, skirt, krbRotator;
  const clock = new THREE.Clock();

  // STAT COUNTERS
  let spins = 0;
  let statSpins = 0;
  let statSpeed = 0;

  // MOUSE VARIABLES
  let clickRequest = false;
  let interactDown = false;
  let mouseX, mouseY;
  let mTimestamp = null;
  let lastMouseX = null;
  let lastMouseY = null;
  let mSpeedX = 0;
  let mSpeedY = 0;
  let lastClientX = 0;
  let distanceX = 0;

  // krbRotator VARIABLES
  let r_current_rotY = 0;
  let r_momentum_rotY = 0;
  let r_momentum_rotY_dir = 0;
  let r_momentum_rotY_max = 100.1;
  let r_tmp_m_rotY;

  let r_tmpTransform, r_ammoTmpPos, r_ammoTmpQuat;
  let r_tmpPos = new THREE.Vector3()
  let r_tmpQuat = new THREE.Quaternion();


  // BALL VARIABLES for testing
  const mouseCoords = new THREE.Vector2();
  const raycaster = new THREE.Raycaster();
  const pos = new THREE.Vector3();
  const quat = new THREE.Quaternion();

  // PHYSICS VARIABLES
  const gravityConstant = -9.8;
  let physicsWorld, transformAux1, softBodyHelpers;
  const rigidBodies = [];
  const softBodies = [];
  const margin = 0.05;
  const FLAGS = { CF_KINEMATIC_OBJECT: 2 };
  const STATE = { DISABLE_DEACTIVATION : 4 }

  // MATERIALS
  let M_invisible, M_skirt_fabric, M_skirt_fresnel;

  Ammo().then( function ( AmmoLib ) {

    Ammo = AmmoLib;

    init();
    // animate(); // normally this would be where we want to start our animate() loop, but we are loading in a custom model

  } );

  ////////////////////
  // Initialization //
  ////////////////////
      //#region 
  function init() {

    params = {
      opacity: 0,
      showVertexColors: false,
      projectiles: false,
      simulate: false,
    };

    r_tmpTransform = new Ammo.btTransform();
    r_ammoTmpPos = new Ammo.btVector3();
    r_ammoTmpQuat = new Ammo.btQuaternion();

    initScene(); // setup three camera, renderer, scene, & lights

    initPhysics(); // ammo world physics

    initCreateObjects(); // three and ammo object creation

    initInput();  // user input

    //initGUI(); // testing

    initStats(); // tracking physics elements

  }

  function initScene() {

    // CAMERA
    camera = new THREE.PerspectiveCamera( 30, window.innerWidth / window.innerHeight, 0.2, 2000 );
    camera.position.set( 0, 2, 10 );
    camera.rotation.set(-0.2, 0, 0);

    // RENDERER
    renderer = new THREE.WebGLRenderer();
    renderer.setPixelRatio( window.devicePixelRatio );
    renderer.setSize( window.innerWidth, window.innerHeight );
    renderer.shadowMap.enabled = true;
    container = document.getElementById( 'container' );
    container.oncontextmenu = contextSelect;  
    function contextSelect(e){e.preventDefault();}
    container.appendChild( renderer.domElement );

    // SCENE
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xF7A8B8);

    // DEFINE LIGHTS
    const hemisphereLight = new THREE.HemisphereLight( 0xfceafc, 0x000000, .8 );
    scene.add( hemisphereLight );

    const dirLight = new THREE.DirectionalLight( 0xffffff, .5 );
    dirLight.position.set( 150, 75, 150 );
    scene.add( dirLight );

    const dirLight2 = new THREE.DirectionalLight( 0xffffff, .2 );
    dirLight2.position.set( - 150, 75, - 150 );
    scene.add( dirLight2 );

    const dirLight3 = new THREE.DirectionalLight( 0xffffff, .1 );
    dirLight3.position.set( 125, 125, 0 );
    scene.add( dirLight3 );
  }

  function initPhysics() {

    // Physics configuration

    const collisionConfiguration = new Ammo.btSoftBodyRigidBodyCollisionConfiguration();
    const dispatcher = new Ammo.btCollisionDispatcher( collisionConfiguration );
    const broadphase = new Ammo.btDbvtBroadphase();
    const solver = new Ammo.btSequentialImpulseConstraintSolver();
    const softBodySolver = new Ammo.btDefaultSoftBodySolver();
    physicsWorld = new Ammo.btSoftRigidDynamicsWorld( dispatcher, broadphase, solver, collisionConfiguration, softBodySolver );
    physicsWorld.setGravity( new Ammo.btVector3( 0, gravityConstant, 0 ) );
    physicsWorld.getWorldInfo().set_m_gravity( new Ammo.btVector3( 0, gravityConstant, 0 ) );

    transformAux1 = new Ammo.btTransform();
    softBodyHelpers = new Ammo.btSoftBodyHelpers();

  }

  function initCreateObjects() {

    // DEFINE TEXTURES
    let fabricPattern = new THREE.TextureLoader().load( '../models/textures/transPattern.jpg');
    fabricPattern.wrapS = THREE.RepeatWrapping;
    fabricPattern.wrapT = THREE.RepeatWrapping;
    fabricPattern.repeat.set( 1,1);

    const gradientMap = createGradientMap(4,4);

    // DEFINE MATERIALS
    M_skirt_fabric = new THREE.MeshToonMaterial( {
      // color: new THREE.Color(0xd3678d),
      color: new THREE.Color(0xaaaaaa),
      map: fabricPattern,
      gradientMap: gradientMap,
      transparent: true,
      opacity: 1,
      side: THREE.DoubleSide,
      // vertexColors: true, // helpful to visualize your vertex colors
    } );

    M_skirt_fresnel = new THREE.ShaderMaterial( {

      uniforms: MStylizedFabric.uniforms,
      vertexShader: MStylizedFabric.vertexShader,
      fragmentShader: MStylizedFabric.fragmentShader,
      // side: THREE.DoubleSide,

    } );
    M_skirt_fresnel.uniforms[ 'baseColor' ].value = fabricPattern;

    M_invisible = new THREE.MeshToonMaterial( {
      color: new THREE.Color(0xffffff),
      gradientMap: gradientMap,
      transparent: true,
      opacity: params.opacity,
    } );
            

    // DEFINE GEOMETRIES
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath( '../examples/js/libs/draco/gltf/' );

    // Ground
    pos.set( 0, - 2.5, 0 );
    quat.set( 0, 0, 0, 1 );
    const ground = createRigidBodyBoxShape( 40, 1, 40, 0, pos, quat, M_invisible);
    ground.castShadow = true;
    ground.receiveShadow = true;
            
    // Create kinematic rigid body called krbRotator 
    pos.set( 0, 2.2, 0 );
    quat.setFromAxisAngle( new THREE.Vector3( 0, 0, 0 ), 0 );
    krbRotator = createRigidBodyBoxShape( 4, 1, 4, 1, pos, quat, M_invisible, true);
    krbRotator.castShadow = false;
    krbRotator.receiveShadow = false;

    // ADD LOADER AND GEO
    const loader = new GLTFLoader();
    loader.setDRACOLoader( dracoLoader );
    loader.load( '../models/skirtgospinnyLP.gltf', function ( gltf ) {

      / / IMPORT THE SCENE
      const meshScene = gltf.scene;
      meshScene.position.set( - 5, 25, 0 );

      // ADD SOFTBODIES
      meshScene.traverse( c => {
        if ( c.isMesh ) {
          let mesh = c;
          let count = 0;
          const volumeMass = 15;
          const volumePressure = 0; // 0 for flat open geometries, 1+ for closed geometries
                        
          mesh.translate( - 2, 5, 0 );
          skirt = createSoftBody( mesh.geometry, volumeMass, volumePressure);
          skirt.material = M_skirt_fresnel;

          // VERTEX WEIGHTS from Vertex Colors						
          let anchors = getVertexWeights(mesh.geometry, 0.05, 0.5); // max influence 0.5 for performance

          // Glue the cloth to the arm							
          count = 0;

          let ammoVertAssociation = skirt.geometry.ammoIndexInOrder;
          for(let i=0; i < ammoVertAssociation.length; i++) {
              for(let j=0; j < anchors.length; j++) {                                
                  if(ammoVertAssociation[i] == anchors[j].index) {
                      let influence = anchors[j].weight;
                      count++;
                      skirt.userData.physicsBody.appendAnchor( ammoVertAssociation[i], krbRotator.userData.physicsBody, false, influence);
                  }
              }
          }                         
        }
      });
        
      // ANIMATION CLIPS
      // Add an animation mixer to take care of mesh animations
      mixer = new THREE.AnimationMixer( meshScene );

      let i;
      for(i=0; i<gltf.animations.length; i++){
        const clip = gltf.animations[i];

        if ( clip ) {

          const action = mixer.clipAction( clip );
          action.play();
            
        }
      }

      // HERE'S THE ANIMTE LOOP!
      animate();					

    }, function() {
      console.log('LOADED SKIRT!');
    }, function ( e ) {

      console.error( e );

    } );

  }

  function initInput() {

    document.getElementById('start').addEventListener('pointerdown', function ( event ) {
      document.getElementById('start').setAttribute('style','display: none;');
      document.getElementById('startblur').setAttribute('style','filter: blur(0rem);');
      params.simulate = true;
      if(gui){
        gui.updateDisplay();
      }
    });
    document.getElementById('pause').addEventListener('pointerdown', function ( event ) {
      document.getElementById('start').setAttribute('style','display: flex;');
      document.getElementById('startblur').setAttribute('style','filter: blur(1.5rem);');
      params.simulate = false;
      if(gui){
        gui.updateDisplay();
      }
    });

    window.addEventListener( 'pointerdown', function ( event ) {
        if ( ! clickRequest ) {
          mouseCoords.set(
              ( event.clientX / window.innerWidth ) * 2 - 1,
              - ( event.clientY / window.innerHeight ) * 2 + 1
          );
          clickRequest = true;
        }
    } );    
    
    function handleInputDown(e) {
      // console.log('down');
                interactDown = true;
      r_tmp_m_rotY = r_momentum_rotY;
      setTimeout(function() {
        if(interactDown){
          r_momentum_rotY = 0;
        } 
      }, 200); 
      

      if(e.touches) {
        lastClientX = e.touches[0].clientX;
      } else {
        lastClientX = e.clientX;
      }
      
    }

    function handleInputMove(e) {

      if(interactDown) {
        let maxX = 0;
        let maxY = 0;
        
        if (mTimestamp === null) {
          mTimestamp = Date.now();
          if(e.touches) {
            lastMouseX = e.touches[0].screenX;
            lastMouseY = e.touches[0].screenY;
          } else {
            lastMouseX = e.screenX;
            lastMouseY = e.screenY;
          }
          return;
        }

        let now = Date.now();
        let dt =  now - mTimestamp;
        let dx = 0;
        let dy = 0;
        if(e.touches) {
          maxX = 120;
          maxY = maxX;
          // console.log('touch moving!');
          dx = e.touches[0].screenX - lastMouseX;
          dy = e.touches[0].screenY - lastMouseY;
          mSpeedX = Math.round( dx / dt * 100 );
          mSpeedY = Math.round( dy / dt * 100 );

          distanceX = ((Math.abs((e.touches[0].clientX - lastClientX)/dt) + 1)/100);
        } else {
          maxX = 100;
          maxY = maxX;
          dx = e.screenX - lastMouseX;
          dy = e.screenY - lastMouseY;
          mSpeedX = Math.round( dx / dt * 100 );
          mSpeedY = Math.round( dy / dt * 100 );

          distanceX = (Math.abs((e.clientX - lastClientX)/dt) + 1)/100;
        }
        
        // console.log(distanceX);
        
        let tmpCenterX = Math.abs((1-(Math.abs(distanceX*100))));
        tmpCenterX = tmpCenterX<=1?1:tmpCenterX;
        mSpeedX = mSpeedX * tmpCenterX;
        
        if(Math.abs(mSpeedX) >= maxX || Math.abs(mSpeedX) === Infinity) {
          mSpeedX = Math.sign(mSpeedX)*maxX;
        }
        if(Math.abs(mSpeedY) >= maxY || Math.abs(mSpeedY) === Infinity) {
          mSpeedY = Math.sign(mSpeedY)*maxY;
        }
        if(isNaN(mSpeedX)) {
          mSpeedX = 0;
        }
        if(isNaN(mSpeedY)) {
          mSpeedY = 0;
        }

        // console.log(mSpeedX);
        mTimestamp = now;
        if(e.touches) {
          lastMouseX = e.touches[0].screenX;
          lastMouseY = e.touches[0].screenY;
        } else {
          lastMouseX = e.screenX;
          lastMouseY = e.screenY;
        }

        let angularMomentumY = krbRotator.userData.physicsBody.getAngularVelocity().y();
        r_momentum_rotY = (Math.abs(angularMomentumY)+(r_tmp_m_rotY));
        r_momentum_rotY_dir = Math.sign(angularMomentumY);
      }

                // mouseX = ( event.clientX / window.innerWidth ) * 2 - 1;
                // mouseY =  - ( event.clientY / window.innerHeight ) * 2 + 1;

      // console.log('Screen X: '+e.screenX+'\nClient X: '+e.clientX);
    }

    function handleInputUp(e) {
      // console.log('up')
      interactDown = false;
      mSpeedX = 0;
      mSpeedY = 0;
    }

    window.addEventListener( 'mousedown', handleInputDown);
            window.addEventListener( 'mousemove', handleInputMove);
    window.addEventListener( 'mouseup', handleInputUp);
    window.addEventListener( 'touchstart', function ( e ) { e.preventDefault(); handleInputDown(e);},false );
            window.addEventListener( 'touchmove', function ( e ) { e.preventDefault(); handleInputMove(e);},false );
    window.addEventListener( 'touchend', function ( e ) { e.preventDefault(); handleInputUp(e);},false );
    

    window.addEventListener( 'resize', onWindowResize );

  }

  function initGUI() {
    gui = new GUI();

    // GEO OUTLINE GUI
    gui.add( params, 'opacity' ).min( 0.0 ).max( 1.0 ).step( 0.1 ).onChange( function(value)
    {
      M_invisible.opacity = value;
    });	
    gui.add( params, 'projectiles');	
    gui.add( params, 'simulate' );
  }

  function initStats() {
    statSpins = document.getElementById('stat-spins');
    statSpeed = document.getElementById('stat-speed');
  }
      //#endregion

  ////////////////////////////
  // Utility Ammo Functions //
  ////////////////////////////
      //#region 
  function processGeometry( bufGeometry ) {

    // Ony consider the position values when merging the vertices
    const posOnlyBufGeometry = new THREE.BufferGeometry();
    posOnlyBufGeometry.setAttribute( 'position', bufGeometry.getAttribute( 'position' ) );
    posOnlyBufGeometry.setIndex( bufGeometry.getIndex() );

    // Merge the vertices so the triangle soup is converted to indexed triangles
    const indexedBufferGeom = BufferGeometryUtils.mergeVertices( posOnlyBufGeometry );

    // Create index arrays mapping the indexed vertices to bufGeometry vertices
    mapIndices( bufGeometry, indexedBufferGeom );

  }
        
  function mapIndices( bufGeometry, indexedBufferGeom ) {

    // Creates ammoVertices, ammoIndices and ammoIndexAssociation in bufGeometry

    const vertices = bufGeometry.attributes.position.array;
    const idxVertices = indexedBufferGeom.attributes.position.array;
    const indices = indexedBufferGeom.index.array;

    const numIdxVertices = idxVertices.length / 3;
    const numVertices = vertices.length / 3;

    bufGeometry.ammoVertices = idxVertices;
    bufGeometry.ammoIndices = indices;
    bufGeometry.ammoIndexAssociation = [];
            bufGeometry.ammoIndexInOrder = [];

    for ( let i = 0; i < numIdxVertices; i ++ ) {

      const association = [];
      bufGeometry.ammoIndexAssociation.push( association );

      const i3 = i * 3;

      for ( let j = 0; j < numVertices; j ++ ) {

        const j3 = j * 3;
        if ( isEqual( idxVertices[ i3 ], idxVertices[ i3 + 1 ], idxVertices[ i3 + 2 ],
          vertices[ j3 ], vertices[ j3 + 1 ], vertices[ j3 + 2 ] ) ) {

          association.push( j3 );
                        bufGeometry.ammoIndexInOrder.push(j);
        }

      }

    }

    for( let i = 0; i < bufGeometry.ammoIndexInOrder.length; i++) {
      if(bufGeometry.ammoIndexInOrder[i] >= numIdxVertices) {
        // console.log(bufGeometry.ammoIndexInOrder[i] +' compare to '+ numIdxVertices);
        bufGeometry.ammoIndexInOrder.splice(i,1);
        i--;
      }
    }

  }
        
  function isEqual( x1, y1, z1, x2, y2, z2 ) {

    const delta = 0.000001;
    return Math.abs( x2 - x1 ) < delta &&
        Math.abs( y2 - y1 ) < delta &&
        Math.abs( z2 - z1 ) < delta;

  }

  function createSoftBody( bufferGeom, mass, pressure ) {

    processGeometry( bufferGeom );

    const sbMesh = new THREE.Mesh( bufferGeom, new THREE.MeshPhongMaterial( { color: 0xFFFFFF } ) );
    sbMesh.castShadow = true;
    sbMesh.receiveShadow = true;
    sbMesh.frustumCulled = false;
    scene.add( sbMesh );

    // sbMesh physics object
    const volumeSoftBody = softBodyHelpers.CreateFromTriMesh(
    physicsWorld.getWorldInfo(),
    bufferGeom.ammoVertices,
    bufferGeom.ammoIndices,
    bufferGeom.ammoIndices.length / 3, true );

    const sbConfig = volumeSoftBody.get_m_cfg();
    sbConfig.set_viterations( 40 );
    sbConfig.set_piterations( 40 );

    // Soft-soft and soft-rigid collisions
    sbConfig.set_collisions( 0x11 );

    // Friction
    sbConfig.set_kDF( 0.1 );
    // Damping
    sbConfig.set_kDP( 0.01 );
    // Pressure
    sbConfig.set_kPR( pressure );
    // Stiffness
    volumeSoftBody.get_m_materials().at( 0 ).set_m_kLST( 0.9 );
    volumeSoftBody.get_m_materials().at( 0 ).set_m_kAST( 0.9 );

    volumeSoftBody.setTotalMass( mass, false );
    Ammo.castObject( volumeSoftBody, Ammo.btCollisionObject ).getCollisionShape().setMargin( margin );
    physicsWorld.addSoftBody( volumeSoftBody, 1, - 1 );
    sbMesh.userData.physicsBody = volumeSoftBody;
    // Disable deactivation
    volumeSoftBody.setActivationState( 4 );

    softBodies.push( sbMesh );

    return sbMesh;
  }

  function createRigidBody( threeObject, physicsShape, mass, pos, quat, isKinematic=false ) {
    
    threeObject.position.copy( pos );
    threeObject.quaternion.copy( quat );

    const transform = new Ammo.btTransform();
    transform.setIdentity();
    transform.setOrigin( new Ammo.btVector3( pos.x, pos.y, pos.z ) );
    transform.setRotation( new Ammo.btQuaternion( quat.x, quat.y, quat.z, quat.w ) );
    const motionState = new Ammo.btDefaultMotionState( transform );

    const localInertia = new Ammo.btVector3( 0, 0, 0 );
    physicsShape.calculateLocalInertia( mass, localInertia );

    const rbInfo = new Ammo.btRigidBodyConstructionInfo( mass, motionState, physicsShape, localInertia );
    const body = new Ammo.btRigidBody( rbInfo );

    threeObject.userData.physicsBody = body;

    scene.add( threeObject );

    if ( mass > 0 ) {

      rigidBodies.push( threeObject );

      // Disable deactivation
      body.setActivationState( 4 );

    }
    
    if(isKinematic){
      body.setActivationState( STATE.DISABLE_DEACTIVATION );
      body.setCollisionFlags( FLAGS.CF_KINEMATIC_OBJECT );				
    }

    physicsWorld.addRigidBody( body );

    return threeObject;

  }
            
  function getVertexWeights( bufferGeom, toleranceMin = 0.0, toleranceMax = 1.0) {
    const m = new THREE.BufferGeometry();
    m.setAttribute( 'position', bufferGeom.getAttribute( 'position' ) );
    m.setAttribute( 'color', bufferGeom.getAttribute( 'color' ) );
    m.setIndex(bufferGeom.getIndex());
    const indexedMesh = BufferGeometryUtils.mergeVertices( m );

    let vertColors = indexedMesh.attributes.color.array
    let weights = [];
    let wCount = 0;
    for(let i=0; i<(vertColors.length/4); i++) {                                
      if((vertColors[i*4]/65535) >= toleranceMin && (vertColors[i*4]/65535) <= toleranceMax){
        // Divide by 65535 (max allowed verts) to get a normalied vertex weight [0.0 - 1.0]
        // console.log(vertColors[i*4]/65535);
        wCount++;
        weights.push({
          index: i,
          weight: (vertColors[i*4]/65535)
        });
      }
    }
    console.log('You painted '+wCount+' weights with more than '+toleranceMin+' and less than '+toleranceMax);  
    return weights;
  }
    //#endregion
        
  ///////////////////////////////////
  // Scene Specific Ammo Functions //
  ///////////////////////////////////
      //#region 
  function createRigidBodyBoxShape( sx, sy, sz, mass, pos, quat, material, isKinematic=false) {

    const threeObject = new THREE.Mesh( new THREE.BoxGeometry( sx, sy, sz, 1, 1, 1 ), material );
    const shape = new Ammo.btBoxShape( new Ammo.btVector3( sx * 0.5, sy * 0.5, sz * 0.5 ) );
    shape.setMargin( margin );

    createRigidBody( threeObject, shape, mass, pos, quat, isKinematic );

    return threeObject;

  }
      //#endregion

  /////////////////////////////
  // Utility Three Functions //
  /////////////////////////////
      //#region 
  function createGradientMap(grad_width = 4, grad_height = 4, grad_color = new THREE.Color( 0xffffff )) {

    const grad_size = grad_width * grad_height;
    const grad_data = new Uint8Array(grad_size);

    const grad_r = Math.floor( grad_color.r * 255 );
    const grad_g = Math.floor( grad_color.g * 255 );
    const grad_b = Math.floor( grad_color.b * 255 );

    for ( let i = 0; i < grad_size; i ++ ) {

      const grad_stride = i * 3;

      grad_data[ grad_stride ] = grad_r;
      grad_data[ grad_stride + 1 ] = grad_g;
      grad_data[ grad_stride + 2 ] = grad_b;

    }

    let gradientMap = new THREE.DataTexture(grad_data, grad_width, grad_height, THREE.LuminanceFormat );
    gradientMap.minFilter = THREE.NearestFilter;
    gradientMap.magFilter = THREE.NearestFilter;
    gradientMap.generateMipmaps = false;
            
    return gradientMap;
  }
      //#endregion

  /////////////////
  // Interaction //
  /////////////////
      //#region 
  function testingBall() {

    if ( clickRequest ) {

      // Cast a ray from the camera to where the mouse is
      raycaster.setFromCamera( mouseCoords, camera );

      // Create a ball
      const ballMass = 3;
      const ballRadius = 0.4;

      const ball = new THREE.Mesh(
        new THREE.SphereGeometry( ballRadius, 18, 16 ), 
        new THREE.MeshPhongMaterial( { color: 0x55CDFC })
      );
      ball.castShadow = true;
      ball.receiveShadow = true;

      // Ammo physics shape to apply physics to
      const ballShape = new Ammo.btSphereShape( ballRadius );
      ballShape.setMargin( margin );
      pos.copy( raycaster.ray.direction );
      pos.add( raycaster.ray.origin );
      quat.set( 0, 0, 0, 1 );
      const ballBody = createRigidBody( ball, ballShape, ballMass, pos, quat ).userData.physicsBody;
      ballBody.setFriction( 0.5 );

      // Apply a vector in the direction of a the ray we casted at the start here
      pos.copy( raycaster.ray.direction );
      pos.multiplyScalar( 14 );
      ballBody.setLinearVelocity( new Ammo.btVector3( pos.x, pos.y, pos.z ) );

      clickRequest = false;
    }

  }
            
  function onWindowResize() {

    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();

    renderer.setSize( window.innerWidth, window.innerHeight );

  }
      //#endregion

  //////////////////
  // UPDATE LOOPS //
  //////////////////
      //#region 
  function animate() {

    requestAnimationFrame( animate );

    render();
    if(stats)
      stats.update();

  }

  function render() {

    if(params.simulate){
      const deltaTime = clock.getDelta();
      updatePhysics( deltaTime );
      updateRotatorPhysics( deltaTime );
      mixer.update( deltaTime );
    }

    if(params.projectiles){
        testingBall();                    
    }

    renderer.render( scene, camera );

  }

  function updatePhysics( deltaTime ) {

    // Step world
    physicsWorld.stepSimulation( deltaTime, 10 );

    // Update soft volumes
    for ( let i = 0, il = softBodies.length; i < il; i ++ ) {

      const sbMesh = softBodies[ i ];
      const geometry = sbMesh.geometry;
      const softBody = sbMesh.userData.physicsBody;
      const volumePositions = geometry.attributes.position.array;
      const volumeNormals = geometry.attributes.normal.array;
      const association = geometry.ammoIndexAssociation;
      const numVerts = association.length;
      const nodes = softBody.get_m_nodes();
      for ( let j = 0; j < numVerts; j ++ ) {

        const node = nodes.at( j );
        const nodePos = node.get_m_x();
        const x = nodePos.x();
        const y = nodePos.y();
        const z = nodePos.z();
        const nodeNormal = node.get_m_n();
        const nx = nodeNormal.x();
        const ny = nodeNormal.y();
        const nz = nodeNormal.z();

        const assocVertex = association[ j ];

        for ( let k = 0, kl = assocVertex.length; k < kl; k ++ ) {

          let indexVertex = assocVertex[ k ];
          volumePositions[ indexVertex ] = x;
          volumeNormals[ indexVertex ] = nx;
          indexVertex ++;
          volumePositions[ indexVertex ] = y;
          volumeNormals[ indexVertex ] = ny;
          indexVertex ++;
          volumePositions[ indexVertex ] = z;
          volumeNormals[ indexVertex ] = nz;

        }

      }

      geometry.attributes.position.needsUpdate = true;
      geometry.attributes.normal.needsUpdate = true;

    }

    // Update rigid bodies
    for ( let i = 0, il = rigidBodies.length; i < il; i ++ ) {

      const objThree = rigidBodies[ i ];
      const objPhys = objThree.userData.physicsBody;
      const ms = objPhys.getMotionState();
      if ( ms ) {

        ms.getWorldTransform( transformAux1 );
        const p = transformAux1.getOrigin();
        const q = transformAux1.getRotation();
        objThree.position.set( p.x(), p.y(), p.z() );
        objThree.quaternion.set( q.x(), q.y(), q.z(), q.w() );

      }

    }

  }
        
  // Calculate the physics for Kinematic Rigid Body called krbRotator
  function updateRotatorPhysics(deltaTime){

    // console.log(r_momentum_rotY)
    if(interactDown || r_momentum_rotY >=0)
    {
      let scalingFactor = 0.0005;

      spins += ((Math.abs(mSpeedX)*scalingFactor)+(r_momentum_rotY)*(0.01))/(2*Math.PI);
      r_current_rotY += (mSpeedX*scalingFactor)+(r_momentum_rotY*r_momentum_rotY_dir)*(0.01);

      if(krbRotator && !isNaN(r_current_rotY)) {

        r_tmpPos.set(krbRotator.position.x,krbRotator.position.y,krbRotator.position.z);
        let tmpEuler = new THREE.Euler( 0, r_current_rotY, 0, 'XYZ' );

        r_tmpQuat.setFromEuler(tmpEuler);

        let physicsBody = krbRotator.userData.physicsBody;					
        let ms = physicsBody.getMotionState();
        if(ms) {

            r_ammoTmpPos.setValue(r_tmpPos.x, r_tmpPos.y, r_tmpPos.z);
            r_ammoTmpQuat.setValue( r_tmpQuat.x, r_tmpQuat.y, r_tmpQuat.z, r_tmpQuat.w);

            r_tmpTransform.setIdentity();
            r_tmpTransform.setOrigin( r_ammoTmpPos ); 
            r_tmpTransform.setRotation( r_ammoTmpQuat ); 

            ms.setWorldTransform(r_tmpTransform);							
        }

      }
      
      mSpeedX = 0;
      mSpeedY = 0;
      
    }
    
    // Prevent momentum from going beyond the max
    if(r_momentum_rotY > r_momentum_rotY_max) {
      r_momentum_rotY = r_momentum_rotY_max;
    }

    // Decrease the momentum over time
    if(r_momentum_rotY >=0) {
      r_momentum_rotY-=Math.sqrt(deltaTime);
    }


    // If momentum is less than 0.2, set the tmp momentum to 0 to stop spinning
    if(Math.abs(krbRotator.userData.physicsBody.getAngularVelocity().y()) <= 0.2 && interactDown) {
      r_tmp_m_rotY = 0;
    }

    statSpeed.innerHTML = (Math.abs(Math.round(krbRotator.userData.physicsBody.getAngularVelocity().y()*10)/10)) + '<span class='statsLabel'> twirls/s</span>';

    statSpins.innerHTML = Math.trunc(spins) + '<span class='statsLabel'> spinnies</span>';
  }
</script>

Code Breakdown:


import

// First, at the end of the 'body' tag, we need to import Ammo.js first as a separate script tag then everything else.
// Then as a new script module, start the import process of:


<script src='../examples/js/libs/ammo.wasm.js'></script>

<script type='module'>
  import * as THREE from '../build/three.module.js';

  import Stats from '../examples/jsm/libs/stats.module.js';
  import { GUI } from '../examples/jsm/libs/dat.gui.module.js';

  import { OrbitControls } from '../examples/jsm/controls/OrbitControls.js';
  import { BufferGeometryUtils } from '../examples/jsm/utils/BufferGeometryUtils.js';
        
  import { GLTFLoader } from '../examples/jsm/loaders/GLTFLoader.js';
  import { DRACOLoader } from '../examples/jsm/loaders/DRACOLoader.js';

  import { MStylizedFabric } from '../materials/MStylizedFabric.js';

variables

// Then following the variables continuing inside the same script tag we need to setup all the variables we'll use:


  //...imports..
  
  // SCENE VARIABLES
  let container, gui, stats, params, camera, controls, scene, renderer, mixer, meshScene, skirt, krbRotator;
  const clock = new THREE.Clock();

  // STAT COUNTERS
  let spins = 0;
  let statSpins = 0;
  let statSpeed = 0;

  // MOUSE VARIABLES
  let clickRequest = false;
  let interactDown = false;
  let mouseX, mouseY;
  let mTimestamp = null;
  let lastMouseX = null;
  let lastMouseY = null;
  let mSpeedX = 0;
  let mSpeedY = 0;
  let lastClientX = 0;
  let distanceX = 0;

  // krbRotator VARIABLES
  let r_current_rotY = 0;
  let r_momentum_rotY = 0;
  let r_momentum_rotY_dir = 0;
  let r_momentum_rotY_max = 100.1;
  let r_tmp_m_rotY;

  let r_tmpTransform, r_ammoTmpPos, r_ammoTmpQuat;
  let r_tmpPos = new THREE.Vector3()
  let r_tmpQuat = new THREE.Quaternion();


  // BALL VARIABLES for testing
  const mouseCoords = new THREE.Vector2();
  const raycaster = new THREE.Raycaster();
  const pos = new THREE.Vector3();
  const quat = new THREE.Quaternion();

  // PHYSICS VARIABLES
  const gravityConstant = -9.8;
  let physicsWorld, transformAux1, softBodyHelpers;
  const rigidBodies = [];
  const softBodies = [];
  const margin = 0.05;
  const FLAGS = { CF_KINEMATIC_OBJECT: 2 };
  const STATE = { DISABLE_DEACTIVATION : 4 }

  // MATERIALS
  let M_invisible, M_skirt_fresnel, M_skirt_fabric;

Ammo()

// Next after all that, setup Ammo.js
// Normally the animate() is started here, but we are importing a custom object, so animate() happens at the end of the object loading


  //...variables...
  
  Ammo().then( function ( AmmoLib ) {

    Ammo = AmmoLib;

    init();
    // animate();

  } );

init()

// Initialize init functions


  //...Ammo()...
  
  function init() {

    params = {
      opacity: 0,
      showVertexColors: false,
      projectiles: false,
      simulate: false,
    };

    r_tmpTransform = new Ammo.btTransform();
    r_ammoTmpPos = new Ammo.btVector3();
    r_ammoTmpQuat = new Ammo.btQuaternion();

    initScene(); // setup three camera, renderer, scene, & lights

    initPhysics(); // ammo world physics

    initCreateObjects(); // three and ammo object creation

    initInput();  // user input

    //initGUI(); // testing

    initStats(); // tracking physics elements

  }

initScene()

// Initialize the Three.js environment:


  //...init...

  function initScene() {

    // CAMERA
    camera = new THREE.PerspectiveCamera( 30, window.innerWidth / window.innerHeight, 0.2, 2000 );
    camera.position.set( 0, 2, 10 );
    camera.rotation.set(-0.2, 0, 0);

    // RENDERER
    renderer = new THREE.WebGLRenderer();
    renderer.setPixelRatio( window.devicePixelRatio );
    renderer.setSize( window.innerWidth, window.innerHeight );
    renderer.shadowMap.enabled = true;
    container = document.getElementById( 'container' );
    container.oncontextmenu = contextSelect;  
    function contextSelect(e){e.preventDefault();}
    container.appendChild( renderer.domElement );

    // SCENE
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xF7A8B8);

    // DEFINE LIGHTS
    const hemisphereLight = new THREE.HemisphereLight( 0xfceafc, 0x000000, .8 );
    scene.add( hemisphereLight );

    const dirLight = new THREE.DirectionalLight( 0xffffff, .5 );
    dirLight.position.set( 150, 75, 150 );
    scene.add( dirLight );

    const dirLight2 = new THREE.DirectionalLight( 0xffffff, .2 );
    dirLight2.position.set( - 150, 75, - 150 );
    scene.add( dirLight2 );

    const dirLight3 = new THREE.DirectionalLight( 0xffffff, .1 );
    dirLight3.position.set( 125, 125, 0 );
    scene.add( dirLight3 );
  }

initPhysics()

// Init Physics, these was copied from other ThreeJS Ammo demos.
// You won't really need to understand how this works. I don't ¯ \_ (ツ)_/¯

// However, these two are useful during the updatePhysics() loop to modify object transformations on the fly.

transformAux1 = new Ammo.btTransform();
softBodyHelpers = new Ammo.btSoftBodyHelpers();


  //...initScene...

  function initPhysics() {

    // Physics configuration
    const collisionConfiguration = new Ammo.btSoftBodyRigidBodyCollisionConfiguration();
    const dispatcher = new Ammo.btCollisionDispatcher( collisionConfiguration );
    const broadphase = new Ammo.btDbvtBroadphase();
    const solver = new Ammo.btSequentialImpulseConstraintSolver();
    const softBodySolver = new Ammo.btDefaultSoftBodySolver();

    physicsWorld = new Ammo.btSoftRigidDynamicsWorld( dispatcher, broadphase, solver, collisionConfiguration, softBodySolver );
    physicsWorld.setGravity( new Ammo.btVector3( 0, gravityConstant, 0 ) );
    physicsWorld.getWorldInfo().set_m_gravity( new Ammo.btVector3( 0, gravityConstant, 0 ) );

    transformAux1 = new Ammo.btTransform();
    softBodyHelpers = new Ammo.btSoftBodyHelpers();

  }

initCreateObjects()

// Define:

// After the imported model is loaded, start the animate() loop


  //...initPhysics...

  function initCreateObjects() {

    // DEFINE TEXTURES
    let fabricPattern = new THREE.TextureLoader().load( '../models/textures/transPattern.jpg');
    fabricPattern.wrapS = THREE.RepeatWrapping;
    fabricPattern.wrapT = THREE.RepeatWrapping;
    fabricPattern.repeat.set( 1,1);

    const gradientMap = createGradientMap(4,4);

    // DEFINE MATERIALS
    M_skirt_fabric = new THREE.MeshToonMaterial( {
      color: new THREE.Color(0xaaaaaa),
      map: fabricPattern,
      gradientMap: gradientMap,
      transparent: true,
      opacity: 1,
      side: THREE.DoubleSide,
      vertexColors: true, // helpful to visualize your vertex colors
    } );

    M_skirt_fresnel = new THREE.ShaderMaterial( {

      uniforms: MStylizedFabric.uniforms,
      vertexShader: MStylizedFabric.vertexShader,
      fragmentShader: MStylizedFabric.fragmentShader,
      // side: THREE.DoubleSide,

    } );
    M_skirt_fresnel.uniforms[ 'baseColor' ].value = fabricPattern;

    M_invisible = new THREE.MeshToonMaterial( {
      color: new THREE.Color(0xffffff),
      gradientMap: gradientMap,
      transparent: true,
      opacity: params.opacity,
    } );
            

    // DEFINE GEOMETRIES
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath( '../examples/js/libs/draco/gltf/' );

    // Ground
    pos.set( 0, - 2.5, 0 );
    quat.set( 0, 0, 0, 1 );
    const ground = createRigidBodyBoxShape( 40, 1, 40, 0, pos, quat, M_invisible);
    ground.castShadow = true;
    ground.receiveShadow = true;
            
    // Create kinematic rigid body called krbRotator 
    pos.set( 0, 2.2, 0 );
    quat.setFromAxisAngle( new THREE.Vector3( 0, 0, 0 ), 0 );
    krbRotator = createRigidBodyBoxShape( 4, 1, 4, 1, pos, quat, M_invisible, true);
    krbRotator.castShadow = false;
    krbRotator.receiveShadow = false;

    // ADD LOADER AND GEO
    const loader = new GLTFLoader();
    loader.setDRACOLoader( dracoLoader );
    loader.load( '../models/skirtgospinnyLP.gltf', function ( gltf ) {

      // IMPORT THE SCENE
      const meshScene = gltf.scene;
      meshScene.position.set( - 5, 25, 0 );

      // ADD SOFTBODIES
      meshScene.traverse( c => {
        if ( c.isMesh ) {
          let mesh = c;
          let count = 0;
          const volumeMass = 15;
          const volumePressure = 0; // 0 for flat open geometries, 1+ for closed geometries
                        
          mesh.translate( - 2, 5, 0 );
          skirt = createSoftBody( mesh.geometry, volumeMass, volumePressure);
          if(params.showVertexColors) {
            skirt.material = M_skirt_fabric;								
          }
          else {
            skirt.material = M_skirt_fresnel;
          }

          // VERTEX WEIGHTS from Vertex Colors						
          let anchors = getVertexWeights(mesh.geometry, 0.05, 0.5); // max influence 0.5 for performance

          // Glue the cloth to the arm							
          count = 0;

          let ammoVertAssociation = skirt.geometry.ammoIndexInOrder;
          for(let i=0; i < ammoVertAssociation.length; i++) {
              for(let j=0; j < anchors.length; j++) {                                
                  if(ammoVertAssociation[i] == anchors[j].index) {
                      let influence = anchors[j].weight;
                      count++;
                      skirt.userData.physicsBody.appendAnchor( ammoVertAssociation[i], krbRotator.userData.physicsBody, false, influence);
                  }
              }
          }                         
        }
      });
        
      // ANIMATION CLIPS
      // Add an animation mixer to take care of mesh animations
      mixer = new THREE.AnimationMixer( meshScene );

      let i;
      for(i=0; i<gltf.animations.length; i++){
        const clip = gltf.animations[i];

        if ( clip ) {

          const action = mixer.clipAction( clip );
          action.play();
            
        }
      }

      // HERE'S THE ANIMATE LOOP! 
      animate();

    }, function() {
      console.log('LOADED SKIRT!');
    }, function ( e ) {

      console.error( e );

    } );

  }

initInput()

// Initialize input, this is where the mouse/touch input speeds are calculated

// We are mainly using these:


  //...initCreateObjects...

  function initInput() {

    document.getElementById('start').addEventListener('pointerdown', function ( event ) {
      document.getElementById('start').setAttribute('style','display: none;');
      document.getElementById('startblur').setAttribute('style','filter: blur(0rem);');
      params.simulate = true;
      if(gui){
        gui.updateDisplay();
      }
    });
    document.getElementById('pause').addEventListener('pointerdown', function ( event ) {
      document.getElementById('start').setAttribute('style','display: flex;');
      document.getElementById('startblur').setAttribute('style','filter: blur(1.5rem);');
      params.simulate = false;
      if(gui){
        gui.updateDisplay();
      }
    });

    window.addEventListener( 'pointerdown', function ( event ) {
        if ( ! clickRequest ) {
          mouseCoords.set(
              ( event.clientX / window.innerWidth ) * 2 - 1,
              - ( event.clientY / window.innerHeight ) * 2 + 1
          );
          clickRequest = true;
        }
    } );
    
    // user starts interating, setup/reset momentum and mouse/touch location
    function handleInputDown(e) {
      interactDown = true;

      // if our mouse/touch is still down for 200ms, stop momentum
      r_tmp_m_rotY = r_momentum_rotY;
      setTimeout(function() {
        if(interactDown){
          r_momentum_rotY = 0;
        } 
      }, 200);     

      if(e.touches) {
        lastClientX = e.touches[0].clientX;
      } else {
        lastClientX = e.clientX;
      }
      
    }

    // user moves interation, calculate mouse/touch speed & momentum
    function handleInputMove(e) {

      if(interactDown) {
        let maxX = 0;
        let maxY = 0;
        
        if (mTimestamp === null) {
          mTimestamp = Date.now();
          if(e.touches) {
            lastMouseX = e.touches[0].screenX;
            lastMouseY = e.touches[0].screenY;
          } else {
            lastMouseX = e.screenX;
            lastMouseY = e.screenY;
          }
          return;
        }

        let now = Date.now();
        let dt =  now - mTimestamp;
        let dx = 0;
        let dy = 0;
        if(e.touches) {
          maxX = 120;
          maxY = maxX;
          // console.log('touch moving!');
          dx = e.touches[0].screenX - lastMouseX;
          dy = e.touches[0].screenY - lastMouseY;
          mSpeedX = Math.round( dx / dt * 100 );
          mSpeedY = Math.round( dy / dt * 100 );

          distanceX = ((Math.abs((e.touches[0].clientX - lastClientX)/dt) + 1)/100);
        } else {
          maxX = 100;
          maxY = maxX;
          dx = e.screenX - lastMouseX;
          dy = e.screenY - lastMouseY;
          mSpeedX = Math.round( dx / dt * 100 );
          mSpeedY = Math.round( dy / dt * 100 );

          distanceX = (Math.abs((e.clientX - lastClientX)/dt) + 1)/100;
        }
        
        // console.log(distanceX);
        
        let tmpCenterX = Math.abs((1-(Math.abs(distanceX*100))));
        tmpCenterX = tmpCenterX<=1?1:tmpCenterX;
        mSpeedX = mSpeedX * tmpCenterX;
        
        if(Math.abs(mSpeedX) >= maxX || Math.abs(mSpeedX) === Infinity) {
          mSpeedX = Math.sign(mSpeedX)*maxX;
        }
        if(Math.abs(mSpeedY) >= maxY || Math.abs(mSpeedY) === Infinity) {
          mSpeedY = Math.sign(mSpeedY)*maxY;
        }
        if(isNaN(mSpeedX)) {
          mSpeedX = 0;
        }
        if(isNaN(mSpeedY)) {
          mSpeedY = 0;
        }

        // console.log(mSpeedX);
        mTimestamp = now;
        if(e.touches) {
          lastMouseX = e.touches[0].screenX;
          lastMouseY = e.touches[0].screenY;
        } else {
          lastMouseX = e.screenX;
          lastMouseY = e.screenY;
        }

        let angularMomentumY = krbRotator.userData.physicsBody.getAngularVelocity().y();
        r_momentum_rotY = (Math.abs(angularMomentumY)+(r_tmp_m_rotY));
        r_momentum_rotY_dir = Math.sign(angularMomentumY);
      }
    }

    // user is done interacting, reset mouse/touch speed
    function handleInputUp(e) {
      // console.log('up')
      interactDown = false;
      mSpeedX = 0;
      mSpeedY = 0;
    }

    // add these functions as events to the web document window
    window.addEventListener( 'mousedown', handleInputDown);
    window.addEventListener( 'mousemove', handleInputMove);
    window.addEventListener( 'mouseup', handleInputUp);
    window.addEventListener( 'touchstart', function ( e ) { e.preventDefault(); handleInputDown(e);},false );
    window.addEventListener( 'touchmove', function ( e ) { e.preventDefault(); handleInputMove(e);},false );
    window.addEventListener( 'touchend', function ( e ) { e.preventDefault(); handleInputUp(e);},false );
    
    // window resize event binding of function onWindowResize()
    window.addEventListener( 'resize', onWindowResize );

  }

initGUI()

// Initialize GUI, which is the neat little thing in the corner that helps show variables we can change and update.
// And also initialize Stats, the live trackers for spinnies & twirls per second


  //...initInput...

  function initGUI() {
    gui = new GUI();

    // GEO OUTLINE GUI
    gui.add( params, 'opacity' ).min( 0.0 ).max( 1.0 ).step( 0.1 ).onChange( function(value)
    {
      M_invisible.opacity = value;
    });	
    gui.add( params, 'showVertexColors' ).onChange( function(value)
    {
      skirt.material = value?M_skirt_fabric:M_skirt_fresnel;
    });	
    gui.add( params, 'projectiles');	
    gui.add( params, 'simulate' );

    gui.domElement.setAttribute('style','margin-top: 35px; margin-right: -15px;')
  }

  function initStats() {
    statSpins = document.getElementById('stat-spins');
    statSpeed = document.getElementById('stat-speed');
  }

utility functions

// Here are all the utilies that handle do tedious code

// Ammo's utility functions are as follows:

// Three's utility functions are as follows:


  //...initGUI...

  ////////////////////////////
  // Utility Ammo Functions //
  ////////////////////////////

  function processGeometry( bufGeometry ) {

    // Ony consider the position values when merging the vertices
    const posOnlyBufGeometry = new THREE.BufferGeometry();
    posOnlyBufGeometry.setAttribute( 'position', bufGeometry.getAttribute( 'position' ) );
    posOnlyBufGeometry.setIndex( bufGeometry.getIndex() );

    // Merge the vertices so the triangle soup is converted to indexed triangles
    const indexedBufferGeom = BufferGeometryUtils.mergeVertices( posOnlyBufGeometry );

    // Create index arrays mapping the indexed vertices to bufGeometry vertices
    mapIndices( bufGeometry, indexedBufferGeom );

  }
        
  function mapIndices( bufGeometry, indexedBufferGeom ) {

    // Creates ammoVertices, ammoIndices and ammoIndexAssociation in bufGeometry

    const vertices = bufGeometry.attributes.position.array;
    const idxVertices = indexedBufferGeom.attributes.position.array;
    const indices = indexedBufferGeom.index.array;

    const numIdxVertices = idxVertices.length / 3;
    const numVertices = vertices.length / 3;

    bufGeometry.ammoVertices = idxVertices;
    bufGeometry.ammoIndices = indices;
    bufGeometry.ammoIndexAssociation = [];
            bufGeometry.ammoIndexInOrder = [];

    for ( let i = 0; i < numIdxVertices; i ++ ) {

      const association = [];
      bufGeometry.ammoIndexAssociation.push( association );

      const i3 = i * 3;

      for ( let j = 0; j < numVertices; j ++ ) {

        const j3 = j * 3;
        if ( isEqual( idxVertices[ i3 ], idxVertices[ i3 + 1 ], idxVertices[ i3 + 2 ],
          vertices[ j3 ], vertices[ j3 + 1 ], vertices[ j3 + 2 ] ) ) {

          association.push( j3 );
                        bufGeometry.ammoIndexInOrder.push(j);
        }

      }

    }

    for( let i = 0; i < bufGeometry.ammoIndexInOrder.length; i++) {
      if(bufGeometry.ammoIndexInOrder[i] >= numIdxVertices) {
        // console.log(bufGeometry.ammoIndexInOrder[i] +' compare to '+ numIdxVertices);
        bufGeometry.ammoIndexInOrder.splice(i,1);
        i--;
      }
    }

  }
        
  function isEqual( x1, y1, z1, x2, y2, z2 ) {

    const delta = 0.000001;
    return Math.abs( x2 - x1 ) < delta &&
          Math.abs( y2 - y1 ) < delta &&
          Math.abs( z2 - z1 ) < delta;

  }

  function createSoftBody( bufferGeom, mass, pressure ) {

    processGeometry( bufferGeom );

    const sbMesh = new THREE.Mesh( bufferGeom, new THREE.MeshPhongMaterial( { color: 0xFFFFFF } ) );
    sbMesh.castShadow = true;
    sbMesh.receiveShadow = true;
    sbMesh.frustumCulled = false;
    scene.add( sbMesh );

    // sbMesh physics object
    const volumeSoftBody = softBodyHelpers.CreateFromTriMesh(
    physicsWorld.getWorldInfo(),
    bufferGeom.ammoVertices,
    bufferGeom.ammoIndices,
    bufferGeom.ammoIndices.length / 3, true );

    const sbConfig = volumeSoftBody.get_m_cfg();
    sbConfig.set_viterations( 40 );
    sbConfig.set_piterations( 40 );

    // Soft-soft and soft-rigid collisions
    sbConfig.set_collisions( 0x11 );

    // Friction
    sbConfig.set_kDF( 0.1 );
    // Damping
    sbConfig.set_kDP( 0.01 );
    // Pressure
    sbConfig.set_kPR( pressure );
    // Stiffness
    volumeSoftBody.get_m_materials().at( 0 ).set_m_kLST( 0.9 );
    volumeSoftBody.get_m_materials().at( 0 ).set_m_kAST( 0.9 );

    volumeSoftBody.setTotalMass( mass, false );
    Ammo.castObject( volumeSoftBody, Ammo.btCollisionObject ).getCollisionShape().setMargin( margin );
    physicsWorld.addSoftBody( volumeSoftBody, 1, - 1 );
    sbMesh.userData.physicsBody = volumeSoftBody;
    // Disable deactivation
    volumeSoftBody.setActivationState( 4 );

    softBodies.push( sbMesh );

    return sbMesh;
  }

  function createRigidBody( threeObject, physicsShape, mass, pos, quat, isKinematic=false ) {
    
    threeObject.position.copy( pos );
    threeObject.quaternion.copy( quat );

    const transform = new Ammo.btTransform();
    transform.setIdentity();
    transform.setOrigin( new Ammo.btVector3( pos.x, pos.y, pos.z ) );
    transform.setRotation( new Ammo.btQuaternion( quat.x, quat.y, quat.z, quat.w ) );
    const motionState = new Ammo.btDefaultMotionState( transform );

    const localInertia = new Ammo.btVector3( 0, 0, 0 );
    physicsShape.calculateLocalInertia( mass, localInertia );

    const rbInfo = new Ammo.btRigidBodyConstructionInfo( mass, motionState, physicsShape, localInertia );
    const body = new Ammo.btRigidBody( rbInfo );

    threeObject.userData.physicsBody = body;

    scene.add( threeObject );

    if ( mass > 0 ) {

      rigidBodies.push( threeObject );

      // Disable deactivation
      body.setActivationState( 4 );

    }
    
    if(isKinematic){
      body.setActivationState( STATE.DISABLE_DEACTIVATION );
      body.setCollisionFlags( FLAGS.CF_KINEMATIC_OBJECT );				
    }

    physicsWorld.addRigidBody( body );

    return threeObject;

  }
        
  ///////////////////////////////////
  // Scene Specific Ammo Functions //
  ///////////////////////////////////

  function createRigidBodyBoxShape( sx, sy, sz, mass, pos, quat, material, isKinematic=false) {

    const threeObject = new THREE.Mesh( new THREE.BoxGeometry( sx, sy, sz, 1, 1, 1 ), material );
    const shape = new Ammo.btBoxShape( new Ammo.btVector3( sx * 0.5, sy * 0.5, sz * 0.5 ) );
    shape.setMargin( margin );

    createRigidBody( threeObject, shape, mass, pos, quat, isKinematic );

    return threeObject;

  }

  /////////////////////////////
  // Utility Three Functions //
  /////////////////////////////

  function createGradientMap(grad_width = 4, grad_height = 4, grad_color = new THREE.Color( 0xffffff )) {

    const grad_size = grad_width * grad_height;
    const grad_data = new Uint8Array(grad_size);

    const grad_r = Math.floor( grad_color.r * 255 );
    const grad_g = Math.floor( grad_color.g * 255 );
    const grad_b = Math.floor( grad_color.b * 255 );

    for ( let i = 0; i < grad_size; i ++ ) {

      const grad_stride = i * 3;

      grad_data[ grad_stride ] = grad_r;
      grad_data[ grad_stride + 1 ] = grad_g;
      grad_data[ grad_stride + 2 ] = grad_b;

    }

    let gradientMap = new THREE.DataTexture(grad_data, grad_width, grad_height, THREE.LuminanceFormat );
    gradientMap.minFilter = THREE.NearestFilter;
    gradientMap.magFilter = THREE.NearestFilter;
    gradientMap.generateMipmaps = false;
            
    return gradientMap;
  }

  function getVertexWeights( bufferGeom, toleranceMin = 0.0, toleranceMax = 1.0) {
    const m = new THREE.BufferGeometry();
    m.setAttribute( 'position', bufferGeom.getAttribute( 'position' ) );
    m.setAttribute( 'color', bufferGeom.getAttribute( 'color' ) );
    m.setIndex(bufferGeom.getIndex());
    const indexedMesh = BufferGeometryUtils.mergeVertices( m );

    let vertColors = indexedMesh.attributes.color.array
    let weights = [];
    let wCount = 0;
    for(let i=0; i<(vertColors.length/4); i++) {                                
      if((vertColors[i*4]/65535) >= toleranceMin && (vertColors[i*4]/65535) <= toleranceMax){
        // Divide by 65535 (max allowed verts) to get a normalied vertex weight [0.0 - 1.0]
        // console.log(vertColors[i*4]/65535);
        wCount++;
        weights.push({
          index: i,
          weight: (vertColors[i*4]/65535)
        });
      }
    }
    console.log('You painted '+wCount+' weights with more than '+toleranceMin+' and less than '+toleranceMax);  
    return weights;
  }


interaction

// Very simple and straigh forward here. Just some functions that handle a projectile 'ball' if we are testing and another for when the window is resized.


  //...utility functions...

  /////////////////
  // Interaction //
  /////////////////
  
  function testingBall() {

    if ( clickRequest ) {

      // Cast a ray from the camera to where the mouse is
      raycaster.setFromCamera( mouseCoords, camera );

      // Create a ball
      const ballMass = 3;
      const ballRadius = 0.4;

      const ball = new THREE.Mesh(
        new THREE.SphereGeometry( ballRadius, 18, 16 ), 
        new THREE.MeshPhongMaterial( { color: 0x55CDFC })
      );
      ball.castShadow = true;
      ball.receiveShadow = true;

      // Ammo physics shape to apply physics to
      const ballShape = new Ammo.btSphereShape( ballRadius );
      ballShape.setMargin( margin );
      pos.copy( raycaster.ray.direction );
      pos.add( raycaster.ray.origin );
      quat.set( 0, 0, 0, 1 );
      const ballBody = createRigidBody( ball, ballShape, ballMass, pos, quat ).userData.physicsBody;
      ballBody.setFriction( 0.5 );

      // Apply a vector in the direction of a the ray we casted at the start here
      pos.copy( raycaster.ray.direction );
      pos.multiplyScalar( 14 );
      ballBody.setLinearVelocity( new Ammo.btVector3( pos.x, pos.y, pos.z ) );

      clickRequest = false;
    }

  }
            
  function onWindowResize() {

    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();

    renderer.setSize( window.innerWidth, window.innerHeight );

  }

update loops

// Finally, to end this script, we have a few loops that handle animation, rendering, and Ammo.js physics.


  //...interaction code...

  //////////////////
  // UPDATE LOOPS //
  //////////////////
      //#region 
  function animate() {

    requestAnimationFrame( animate );

    render();
    if(stats)
      stats.update();

  }

  function render() {

    if(params.simulate){
      const deltaTime = clock.getDelta();
      updatePhysics( deltaTime );
      updateRotatorPhysics( deltaTime );
      mixer.update( deltaTime );
    }

    if(params.projectiles){
        testingBall();                    
    }

    renderer.render( scene, camera );

  }

  function updatePhysics( deltaTime ) {

    // Step world
    physicsWorld.stepSimulation( deltaTime, 10 );

    // Update soft volumes
    for ( let i = 0, il = softBodies.length; i < il; i ++ ) {

      const sbMesh = softBodies[ i ];
      const geometry = sbMesh.geometry;
      const softBody = sbMesh.userData.physicsBody;
      const volumePositions = geometry.attributes.position.array;
      const volumeNormals = geometry.attributes.normal.array;
      const association = geometry.ammoIndexAssociation;
      const numVerts = association.length;
      const nodes = softBody.get_m_nodes();
      for ( let j = 0; j < numVerts; j ++ ) {

        const node = nodes.at( j );
        const nodePos = node.get_m_x();
        const x = nodePos.x();
        const y = nodePos.y();
        const z = nodePos.z();
        const nodeNormal = node.get_m_n();
        const nx = nodeNormal.x();
        const ny = nodeNormal.y();
        const nz = nodeNormal.z();

        const assocVertex = association[ j ];

        for ( let k = 0, kl = assocVertex.length; k < kl; k ++ ) {

          let indexVertex = assocVertex[ k ];
          volumePositions[ indexVertex ] = x;
          volumeNormals[ indexVertex ] = nx;
          indexVertex ++;
          volumePositions[ indexVertex ] = y;
          volumeNormals[ indexVertex ] = ny;
          indexVertex ++;
          volumePositions[ indexVertex ] = z;
          volumeNormals[ indexVertex ] = nz;

        }

      }

      geometry.attributes.position.needsUpdate = true;
      geometry.attributes.normal.needsUpdate = true;

    }

    // Update rigid bodies
    for ( let i = 0, il = rigidBodies.length; i < il; i ++ ) {

      const objThree = rigidBodies[ i ];
      const objPhys = objThree.userData.physicsBody;
      const ms = objPhys.getMotionState();
      if ( ms ) {

        ms.getWorldTransform( transformAux1 );
        const p = transformAux1.getOrigin();
        const q = transformAux1.getRotation();
        objThree.position.set( p.x(), p.y(), p.z() );
        objThree.quaternion.set( q.x(), q.y(), q.z(), q.w() );

      }

    }

  }
        
  // Calculate the physics for Kinematic Rigid Body called krbRotator
  function updateRotatorPhysics(deltaTime){

    // console.log(r_momentum_rotY)
    if(interactDown || r_momentum_rotY >=0)
    {
      let scalingFactor = 0.0005;

      spins += ((Math.abs(mSpeedX)*scalingFactor)+(r_momentum_rotY)*(0.01))/(2*Math.PI);
      r_current_rotY += (mSpeedX*scalingFactor)+(r_momentum_rotY*r_momentum_rotY_dir)*(0.01);

      if(krbRotator && !isNaN(r_current_rotY)) {

        r_tmpPos.set(krbRotator.position.x,krbRotator.position.y,krbRotator.position.z);
        let tmpEuler = new THREE.Euler( 0, r_current_rotY, 0, 'XYZ' );

        r_tmpQuat.setFromEuler(tmpEuler);

        let physicsBody = krbRotator.userData.physicsBody;					
        let ms = physicsBody.getMotionState();
        if(ms) {

            r_ammoTmpPos.setValue(r_tmpPos.x, r_tmpPos.y, r_tmpPos.z);
            r_ammoTmpQuat.setValue( r_tmpQuat.x, r_tmpQuat.y, r_tmpQuat.z, r_tmpQuat.w);

            r_tmpTransform.setIdentity();
            r_tmpTransform.setOrigin( r_ammoTmpPos ); 
            r_tmpTransform.setRotation( r_ammoTmpQuat ); 

            ms.setWorldTransform(r_tmpTransform);							
        }

      }
      
      mSpeedX = 0;
      mSpeedY = 0;
      
    }
    
    // Prevent momentum from going beyond the max
    if(r_momentum_rotY > r_momentum_rotY_max) {
      r_momentum_rotY = r_momentum_rotY_max;
    }

    // Decrease the momentum over time
    if(r_momentum_rotY >=0) {
      r_momentum_rotY-=Math.sqrt(deltaTime);
    }


    // If momentum is less than 0.2, set the tmp momentum to 0 to stop spinning
    if(Math.abs(krbRotator.userData.physicsBody.getAngularVelocity().y()) <= 0.2 && interactDown) {
      r_tmp_m_rotY = 0;
    }

    // Update the innerHTML for our stats
    statSpeed.innerHTML = (Math.abs(Math.round(krbRotator.userData.physicsBody.getAngularVelocity().y()*10)/10)) + '<span class='statsLabel'> twirls/s</span>';
    statSpins.innerHTML = Math.trunc(spins) + '<span class='statsLabel'> spinnies</span>';
  }
</script>

Yikes, that was a lot. But you made it! Or you scrolled all the way to the bottom hoping for find an easier way to make skirt go spinny. But it doesn't seem I can be of any further help. Making cloth physics for the web browser isn't that easy yet! Some of us don't have 3080RTXMILLION cards to handle this junk so why not make everything work on the web? Oh because reasons, my bad, I forgot. Anyway, I hope this somewhatofa tutorial helped you in some way or taught you something! Check out my github for more complete code and DM or @me on tweeter if you have any questions about this, if something is broke, or you want more skirts to go spinny

Thanks for stopping by friend! Happy Pride!! <3

With love,

April Jane