<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@v0.171.0/build/three.webgpu.js",
      "three/webgpu": "https://cdn.jsdelivr.net/npm/three@v0.171.0/build/three.webgpu.js",
      "three/tsl": "https://cdn.jsdelivr.net/npm/three@v0.171.0/build/three.tsl.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@v0.171.0/examples/jsm/"
    }
  }
</script>

<div id="courses"><a href="https://niklever.com/courses" target="_blank">niklever.com/courses</a></div>
body {
  padding: 0;
  margin: 0;
}

#courses {
  font: bold 30px "Arial";
  position: fixed;
  left: 20px;
  top: 20px;
  color: #ffffff;
  text-decoration: none;
}

a:link {
  color: white;
  text-decoration: none;
}

a:hover {
  color: #dddd33;
  text-decoration: underline;
}

a:visited {
  color: white;
  text-decoration: none;
}
import * as THREE from "three";
import {
  positionLocal,
  normalLocal,
  normalize,
  modelWorldMatrix,
  cameraProjectionMatrix,
  cameraViewMatrix,
  mix,
  attributeArray,
  clamp,
  time,
  mx_noise_float,
  Fn,
  uint,
  float,
  cross,
  If,
  Continue,
  distance,
  length,
  attribute,
  max,
  exp,
  mat3,
  vec3,
  select,
  Loop,
  instanceIndex,
  uniform
} from "three/tsl";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

class FlockGeometry extends THREE.BufferGeometry {
  constructor(geo) {
    super();

    //const scale = 0.2;
    //geo.scale( scale, scale, scale );
    const geometry = geo;//.toNonIndexed();
    
    const srcPosAttr = geometry.getAttribute( "position" );
    const srcNormAttr = geometry.getAttribute( "normal" );
    const srcUVAttr = geometry.getAttribute( "uv" );
    
    let count = srcPosAttr.count;
    const total = count * BOIDS;
    
    const posAttr = new THREE.BufferAttribute(new Float32Array(total * 3), 3); 
    const normAttr = new THREE.BufferAttribute(new Float32Array(total * 3), 3); 
    const uvAttr = new THREE.BufferAttribute(new Float32Array(total * 2), 2); 
    const instanceIDAttr = new THREE.BufferAttribute(new Uint32Array(total), 1);
    const vertexIDAttr = new THREE.BufferAttribute(new Uint32Array(total), 1);

   this.setAttribute("instanceID", instanceIDAttr);
    this.setAttribute("vertexID", vertexIDAttr);
    this.setAttribute("position", posAttr);
    this.setAttribute("normal", normAttr);
    this.setAttribute("uv", uvAttr);

    let index = 0;

    for (let b = 0; b < BOIDS; b++) {
      let offset = b * count * 3;
      for (let i = 0; i < count * 3; i++) {
        posAttr.array[offset + i] = srcPosAttr.array[i];
        normAttr.array[offset + i] = srcNormAttr.array[i];
      }
      
      offset = b * count * 2;
      for (let i = 0; i < count * 2; i++) {
        uvAttr.array[offset + i] = srcUVAttr.array[i];
      }
    
      offset = b * count;
      for (let i = 0; i < count; i++) {
        instanceIDAttr.array[offset + i] = b;
        vertexIDAttr.array[offset + i] = i;
      }
    }
  }
}

let container;

let camera,
  scene,
  renderer,
  options,
  material,
  assetPath,
  clock,
  boid,
  flock,
  mixer,
  animInfo,
  deltaTime,
  computeVelocity,
  computePosition,
  computeTime,
  computeTest;

const BOIDS = 2000;
const BOUNDS = 20,
  BOUNDS_HALF = BOUNDS / 2;

init();

function init() {
  container = document.createElement("div");
  document.body.appendChild(container);

  camera = new THREE.PerspectiveCamera(
    40,
    window.innerWidth / window.innerHeight,
    1,
    100
  );
  camera.position.set(0.0, 1, 12);

  //

  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x444488);

  //

  renderer = new THREE.WebGPURenderer({ antialias: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setAnimationLoop(render);
  container.appendChild(renderer.domElement);

  //

  //content

  const ambient = new THREE.HemisphereLight(0xaaaaaa, 0x333333);
  const light = new THREE.DirectionalLight(0xffffff, 3);
  light.position.set(3, 3, 1);
  scene.add(ambient);
  scene.add(light);

  loadSkybox();
  
  clock = new THREE.Clock();
  
  const controls = new OrbitControls(camera, renderer.domElement);

  assetPath = "https://assets.codepen.io/2666677/";

  loadGLB("sparrow");

  window.addEventListener("resize", onWindowResize);
}

function loadSkybox(){
  scene.background = new THREE.CubeTextureLoader()
    .setPath( 'https://assets.codepen.io/2666677/skybox4_' )
    .load( [
       'px.jpg',
       'nx.jpg',
       'py.jpg',
       'ny.jpg',
       'pz.jpg',
       'nz.jpg'
      ], (tex) => {              
      scene.environment = tex;
    });
}


function loadGLB(name) {
  const loader = new GLTFLoader().setPath(assetPath);
  const dracoLoader = new DRACOLoader();
  dracoLoader.setDecoderPath(
    "https://cdn.jsdelivr.net/npm/three@v0.170.0/examples/jsm/libs/draco/gltf/"
  );
  loader.setDRACOLoader(dracoLoader);

  loader.load(`${name}.glb`, (gltf) => {
    boid = gltf;

    tsl();
  });
}

function initStorage() {
  const positionArray = new Float32Array(BOIDS * 3);
  const directionArray = new Float32Array(BOIDS * 3);
  const noiseArray = new Float32Array(BOIDS); 
  const timeArray = new Float32Array(BOIDS);

  const q = new THREE.Quaternion();
  const v = new THREE.Euler();

  for (let i = 0; i < BOIDS; i++) {
    const offset = i * 3;

    for( let j=0; j<3; j++){
      positionArray[offset + j] = Math.random() * BOUNDS - BOUNDS_HALF;
    }

    q.random();
    v.setFromQuaternion(q);

    directionArray[offset + 0] = v.x;
    directionArray[offset + 1] = v.y;
    directionArray[offset + 2] = v.z;
    
    noiseArray[i] = Math.random() * 1000.0;
    
    timeArray[i] = Math.random() * animInfo.duration; 
  }

  const positionStorage = attributeArray(positionArray, "vec3").label(
    "positionStorage"
  );
  const directionStorage = attributeArray(directionArray, "vec3").label(
    "directionStorage"
  );
  const noiseStorage = attributeArray(noiseArray, "float").label(
    "noiseStorage"
  );
  
  const timeStorage = attributeArray(timeArray, "float").label(
    "timeStorage"
  );
  
  // The Pixel Buffer Object (PBO) is required to get the GPU computed data in the WebGL2 fallback.
  positionStorage.setPBO(true);
  directionStorage.setPBO(true);
  noiseStorage.setPBO(true);
  timeStorage.setPBO(true);

  return [ positionStorage, directionStorage, noiseStorage, timeStorage ];
}

function bakeAnimation( gltf ){
  const skinnedMesh = gltf.scene.children[0].children[0];
  
  const geometry = skinnedMesh.geometry.toNonIndexed();
  skinnedMesh.geometry = geometry;
  
  gltf.scene.visible = false;
  scene.add( gltf.scene );
  
  mixer = new THREE.AnimationMixer( gltf.scene );
  const clip = gltf.animations[0];
  const action = mixer.clipAction( clip );
  action.play();
  const interval = 1/25;
  const frameCount = ~~(clip.duration/interval) + 1;
  
  let posAttr = geometry.getAttribute( "position" );
  const vertexCount = posAttr.count;
  const vertexArray = new Float32Array( vertexCount * frameCount * 3);
  animInfo = { duration:clip.duration, interval, vertexCount, frameCount };
  
  const skinned = new THREE.Vector3(); 
  
  for (let f = 0; f<frameCount; f++ ){
    mixer.setTime( f * interval );
    renderer.render( scene, camera );
    const offset = f * vertexCount * 3;
    for (let i=0; i<vertexCount; i++){
       skinned.set( 
         posAttr.getX(i), 
         posAttr.getY(i), 
         posAttr.getZ(i)
       );
      skinnedMesh.applyBoneTransform(i,  skinned);
      skinned.applyMatrix4( skinnedMesh.matrixWorld );
      skinned.multiplyScalar( 0.2 );
      vertexArray[offset + i*3 + 0] = skinned.x;
      vertexArray[offset + i*3 + 1] = skinned.y;
      vertexArray[offset + i*3 + 2] = skinned.z;
    }
  }
  
  scene.remove( gltf.scene );
  
  const vertexStorage = attributeArray(vertexArray, "vec3").label(
    "vertexStorage"
  );
  vertexStorage.setPBO(true);
  
  return vertexStorage;
}

function tsl() {
  const vertexStorage = bakeAnimation( boid );
  const [positionStorage, directionStorage, noiseStorage, timeStorage] = initStorage();

  deltaTime = uniform(float());
  const boidSpeed = uniform(2);
  const flockPosition = uniform(vec3());
  const neighbourDistance = uniform(4);
  const rotationSpeed = uniform(1);
  const duration = uniform( animInfo.duration );
  const interval = uniform( animInfo.interval );
  const vertexCount = uniform( animInfo.vertexCount );
  const useStorage = uniform( uint(1) );

  const flockVertexTSL = Fn(() => {
    const instanceID = attribute("instanceID").toVar();
    const vertexID = attribute("vertexID").toVar();
    const frameIndex = uint( timeStorage.element( instanceID ).div( interval )).toVar();
    vertexID.addAssign( vertexCount.mul( frameIndex ));
    const normal = normalLocal.toVar();
    const dir = normalize(directionStorage.element(instanceID)).toVar();
    
    //Create matrix
    //float4x4 create_matrix(float3 pos, float3 dir, float3 up) {
    const zaxis = dir.negate().normalize().toVar();
    const xaxis = cross(vec3(0, 1, 0), zaxis).normalize().toVar();
    const yaxis = cross(zaxis, xaxis).toVar();
    const mat = mat3( 
      xaxis.x,
      yaxis.x,
      zaxis.x,
      xaxis.y,
      yaxis.y,
      zaxis.y,
      xaxis.z,
      yaxis.z,
      zaxis.z
    );
    
    const position = select( useStorage.greaterThan(0), vertexStorage.element(vertexID), positionLocal ); 
    
    const finalVert = modelWorldMatrix.mul(mat.mul(position)).add(positionStorage.element(instanceID));

    return cameraProjectionMatrix.mul(cameraViewMatrix).mul(finalVert);
  });

  computeVelocity = Fn(() => {
    const boid_pos = positionStorage.element(instanceIndex).toVar();
    const boid_dir = directionStorage.element(instanceIndex).toVar();
    
    const separation = vec3(0).toVar();
    const alignment = vec3(0).toVar();
    const cohesion = vec3(flockPosition).toVar();

    const nearbyCount = uint(1).toVar(); // Add self that is ignored in loop
    
    Loop(
      { start: uint(0), end: uint(BOIDS), type: "uint", condition: "<" },
      ({ i }) => {
        If(i == instanceIndex, () => {
          Continue();
        });

        const tempBoid_pos = positionStorage.element(i).toVar();
        const tempBoid_dir = directionStorage.element(i).toVar();

        const offset = boid_pos.sub(tempBoid_pos).toVar();
        const dist = length(offset).toVar();
        
        If( dist.lessThan(neighbourDistance), () => {
          If( dist.lessThan( 0.0001 ), () => {
							Continue();
						} ); 
          
          //separation += offset * (1.0/dist - 1.0/neighbourDistance);
          const s = offset.mul(float(1.0).div(dist).sub(float(1.0).div(neighbourDistance))).toVar();
          separation.addAssign( s );
          alignment.addAssign(tempBoid_dir);
          cohesion.addAssign(tempBoid_pos);

          nearbyCount.addAssign(1);
        }); //If
      }); //Loop
    
    const avg = float(1.0).div(nearbyCount).toVar();
    alignment.mulAssign(avg);
    cohesion.mulAssign(avg);
    cohesion.assign(cohesion.normalize().sub(boid_pos));

    const direction = alignment.add(separation).add(cohesion).toVar();

    const ip = exp(rotationSpeed.mul(-1).mul(deltaTime));
    boid_dir.assign(mix(direction, boid_dir.normalize(), ip));
    directionStorage.element(instanceIndex).assign(boid_dir);
  })().compute(BOIDS);

  computePosition = Fn( () => {
    const boid_pos = positionStorage.element(instanceIndex).toVar();
    const boid_dir = directionStorage.element(instanceIndex).toVar();
    const noise_offset = noiseStorage.element(instanceIndex).toVar();
    const noise = mx_noise_float( boid_pos.mul( time.div(100.0).add(noise_offset))).add(1).div(2.0).toVar();
	const velocity = boidSpeed.mul(float(1.0).add(noise)).toVar();// * boidSpeedVariation);
    
    boid_pos.addAssign( boid_dir.mul( velocity ).mul(deltaTime));
    positionStorage.element(instanceIndex).assign(boid_pos);
  })().compute(BOIDS);
  
  computeTest = Fn( () => {
    const position = positionStorage.element( instanceIndex );
    position.addAssign( vec3( 0, deltaTime.mul(100), 0) );
  })().compute(BOIDS);
  
  computeTime = Fn( () => {
    const instanceTime = timeStorage.element( instanceIndex );
    const boid_pos = positionStorage.element(instanceIndex).toVar();
    const boid_dir = directionStorage.element(instanceIndex).toVar();
    const noise_offset = noiseStorage.element(instanceIndex).toVar();
    const noise = mx_noise_float( boid_pos.mul( time.div(100.0).add(noise_offset))).add(1).div(2.0).toVar();
	const velocity = boidSpeed.mul(float(1.0).add(noise)).toVar();
  const speed = length( velocity );
    instanceTime.addAssign( deltaTime.mul(speed).mul(boidSpeed).mul(0.25) );
    If( instanceTime.greaterThan( duration ), () => {
      instanceTime.subAssign( duration );
    })
  })().compute(BOIDS);
  
  const geometry = new FlockGeometry(boid.scene.children[0].children[0].geometry);
  const material = new THREE.MeshStandardNodeMaterial( { map: boid.scene.children[0].children[0].material.map });

  flock = new THREE.Mesh(geometry, material);
  scene.add(flock);

  material.vertexNode = flockVertexTSL();
  
  const options = {
    useStorage: true
  }
  
  const gui = new GUI();
  gui.add( neighbourDistance, 'value', 1, 8 ).name("Neighbour Distance");
  gui.add( boidSpeed, 'value', 0.5, 6 ).name( "boid speed");
  gui.add( rotationSpeed, 'value', 0.5, 6 ).name( "rotation speed");
}

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

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

//

function render() {
  if (deltaTime && clock) deltaTime.value = clock.getDelta();
  if (computeTime) renderer.compute(computeTime);
  if (computeVelocity) renderer.compute(computeVelocity);
  if (computePosition) renderer.compute(computePosition);
  renderer.render(scene, camera);
}

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.