3D Particles Animation on Hume ai with Next.js

Hi everyone :waving_hand:

I’m trying to build a 3D particles animation that i just saw at the end of the hume.ai contact page (with Next.js + Three.js; optionally with Framer Motion or gsap for UI). My goal is a smooth particle field with sine-like motion and some interaction on hover/scroll, exactly like the one on Hume.

Honestly, I’m new to both Three.js and Next.js, so I really need help. If you’ve seen similar examples or have any tips for animation and styling, I would be glad to hear them.Thank you🌸

Here is the code I’ve written so far:

'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';

// Resource Tracker Class
class ResourceTracker {
  constructor() {
    this.resources = new Set();
  }

  track(resource) {
    if (!resource) return resource;

    if (Array.isArray(resource)) {
      resource.forEach(r => this.track(r));
      return resource;
    }

    if (resource.dispose || resource instanceof THREE.Object3D) {
      this.resources.add(resource);
    }

    if (resource instanceof THREE.Object3D) {
      this.track(resource.geometry);
      this.track(resource.material);
      this.track(resource.children);
    } else if (resource instanceof THREE.Material) {
      for (let value of Object.values(resource)) {
        if (value instanceof THREE.Texture) {
          this.track(value);
        }
      }
      if (resource.uniforms) {
        for (let uniform of Object.values(resource.uniforms)) {
          if (uniform?.value) {
            let value = uniform.value;
            if (value instanceof THREE.Texture || Array.isArray(value)) {
              this.track(value);
            }
          }
        }
      }
    }

    return resource;
  }

  dispose() {
    for (let resource of this.resources) {
      if (resource instanceof THREE.Object3D && resource.parent) {
        resource.parent.remove(resource);
      }
      if (resource.dispose) {
        resource.dispose();
      }
    }
    this.resources.clear();
  }
}

const ContactCanvas = () => {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    let animationId: number;
    const resTracker = new ResourceTracker();
    const track = resTracker.track.bind(resTracker);

    const config = {
      useWindow: true,
      dark: false,
      fade: true,
      gap: 5.0,
      scale: 15,
      zDepth: 75,
      xTarget: 0,
      yTarget: 7.53,
      y: 5.1,
      z: 27
    };

    const scene = new THREE.Scene();
    const sceneTwo = new THREE.Scene();

    const sceneBackgroundColor = config.dark ? '#FFF4E8' : '#353535';
    scene.background = new THREE.Color(sceneBackgroundColor);

    const particleColors = config.dark ? [
      new THREE.Color(0xd9110c),
      new THREE.Color(0x1e0a68),
      new THREE.Color(0x4caf50),
      new THREE.Color(0xa036f4),
      new THREE.Color(0xf44336),
      new THREE.Color(0x3f51b5),
      new THREE.Color(0x009688),
      new THREE.Color(0x673ab7),
      new THREE.Color(0xff9800),
      new THREE.Color(0xe91e63),
      new THREE.Color(0xffc1b4),
      new THREE.Color(0x5c6bc0)
    ] : [
      new THREE.Color(0xd9110c),
      new THREE.Color(0x1e0a68),
      new THREE.Color(0x254c26),
      new THREE.Color(0x0065e7),
      new THREE.Color(0xff8874),
      new THREE.Color(0xc83f00),
      new THREE.Color(0xff8801),
      new THREE.Color(0x5208d2),
      new THREE.Color(0xbdd4b0),
      new THREE.Color(0xc2ad70),
      new THREE.Color(0xd0c696),
      new THREE.Color(0xe8cfae)
    ];

    const particlesAmountX = 45;
    const particlesAmountZ = config.zDepth;
    const cameraLookAt = {
      xTarget: config.xTarget,
      yTarget: config.yTarget,
      y: config.y,
      z: config.z
    };
    const mouseMovement = { scale: 0.028, speed: 0.021 };

    let mouseX = 0;
    let mouseY = 0;
    let count = 0;

    const wrapper = config.useWindow ? window : wrapperRef.current!;
    const sizes = {
      width: wrapperRef.current?.offsetWidth || window.innerWidth,
      height: 223
    };

    const camera = new THREE.PerspectiveCamera(
      50,
      sizes.width / sizes.height,
      1,
      200
    );
    camera.position.x = (particlesAmountX * config.gap) / 2;
    camera.position.y = cameraLookAt.y;
    camera.position.z = cameraLookAt.z;
    scene.add(camera);

    const ambientLight = new THREE.AmbientLight(0xffffff, 1);
    sceneTwo.add(ambientLight);

    // Create particles
    const particleCount = particlesAmountX * particlesAmountZ;
    const positions = new Float32Array(particleCount * 3);
    const scales = new Float32Array(particleCount);
    const randomPositions = new Float32Array(particleCount);
    const randomColorIndices = new Float32Array(particleCount);
    const baseYPositions = new Float32Array(particleCount);

    const totalWidth = particlesAmountX * config.gap;
    const totalDepth = particlesAmountZ * config.gap;

    let vertexIndex = 0;
    let particleIndex = 0;

    for (let x = 0; x < particlesAmountX; x++) {
      for (let z = 0; z < particlesAmountZ; z++) {
        const randomPos = Math.random();
        const randomColorIndex = Math.floor(Math.random() * particleColors.length);

        randomPositions[particleIndex] = randomPos;
        randomColorIndices[particleIndex] = randomColorIndex;

        positions[vertexIndex] =
          x * config.gap + config.gap * (2 * randomPos - 1) - totalWidth / 2;

        positions[vertexIndex + 1] = (Math.random() - 0.5) * 223;
        baseYPositions[particleIndex] = positions[vertexIndex + 1];

        positions[vertexIndex + 2] =
          z * config.gap +
          config.gap * (Math.random() * 2 - 1) -
          totalDepth +
          20 * config.gap;

        scales[particleIndex] = 1 * Math.min(window.devicePixelRatio || 1, 2);

        vertexIndex += 3;
        particleIndex++;
      }
    }

    const geometry = track(new THREE.BufferGeometry());
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('scale', new THREE.BufferAttribute(scales, 1));
    geometry.setAttribute('randomPosition', new THREE.BufferAttribute(randomPositions, 1));
    geometry.setAttribute('randomColorIndex', new THREE.BufferAttribute(randomColorIndices, 1));
    geometry.setAttribute('baseY', new THREE.BufferAttribute(baseYPositions, 1));

    const material = track(
      new THREE.ShaderMaterial({
        uniforms: {
          colors: { value: particleColors },
          zWidth: { value: totalDepth }
        },
        transparent: true,
        vertexShader: `
          attribute float scale;
          attribute float randomColorIndex;
          varying float vColorIndex;
          varying vec4 pos;
          
          void main() {
            vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
            gl_PointSize = scale * (3.0 / -mvPosition.z);
            gl_Position = projectionMatrix * mvPosition;
            pos = gl_Position;
            vColorIndex = randomColorIndex;
          }
        `,
        fragmentShader: `
          uniform vec3 colors[12];
          uniform float zWidth;
          varying float vColorIndex;
          varying vec4 pos;
          
          void main() {
            if(length(gl_PointCoord - vec2(0.5, 0.5)) > 0.475) discard;
            
            float zScale = pos.z / zWidth;
            int colorIndex = int(vColorIndex);
            vec3 color;
            
            if(colorIndex == 0) color = colors[0];
            else if(colorIndex == 1) color = colors[1];
            else if(colorIndex == 2) color = colors[2];
            else if(colorIndex == 3) color = colors[3];
            else if(colorIndex == 4) color = colors[4];
            else if(colorIndex == 5) color = colors[5];
            else if(colorIndex == 6) color = colors[6];
            else if(colorIndex == 7) color = colors[7];
            else if(colorIndex == 8) color = colors[8];
            else if(colorIndex == 9) color = colors[9];
            else if(colorIndex == 10) color = colors[10];
            else if(colorIndex == 11) color = colors[11];
            
            gl_FragColor = vec4(color, zScale * 6.0);
          }
        `
      })
    );

    const particles = track(new THREE.Points(geometry, material));
    scene.add(particles);

    const renderer = new THREE.WebGLRenderer({
      canvas: canvasRef.current!,
      antialias: !(window.devicePixelRatio > 1)
    });
    renderer.autoClear = false;
    renderer.setSize(sizes.width, sizes.height);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));

    const handlePointerMove = (event: PointerEvent) => {
      if (event.isPrimary === false) return;

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

    const handleResize = () => {
      sizes.width = wrapperRef.current?.offsetWidth || window.innerWidth;
      sizes.height = 223;

      camera.aspect = sizes.width / sizes.height;
      camera.updateProjectionMatrix();

      renderer.setSize(sizes.width, sizes.height);
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    };

    const updateParticles = () => {
      const positionsArray = particles.geometry.attributes.position.array as Float32Array;
      const scalesArray = particles.geometry.attributes.scale.array as Float32Array;
      const randomPosArray = particles.geometry.attributes.randomPosition.array as Float32Array;
      const baseYArray = particles.geometry.attributes.baseY.array as Float32Array;
      const particleScale = config.scale * Math.min(window.devicePixelRatio || 1, 2);

      let vertexIndex = 0;
      let particleIndex = 0;

      for (let x = 0; x < particlesAmountX; x++) {
        for (let z = 0; z < particlesAmountZ; z++) {
          const random = randomPosArray[particleIndex];

          positionsArray[vertexIndex + 1] =
            baseYArray[particleIndex] +
            (0.6 *
              Math.sin((random * particlesAmountX + count) * 0.3) *
              random +
              0.6 *
              Math.sin((random * particlesAmountZ + count) * 0.5) *
              random);

          scalesArray[particleIndex] =
            (Math.sin((x + count) * 0.3) + 2) *
              particleScale *
              (random / 2 + 0.5) +
            (Math.sin((z + count) * 0.5) + 2.5) *
              particleScale *
              (random / 2 + 0.5);

          vertexIndex += 3;
          particleIndex++;
        }
      }

      particles.geometry.attributes.position.needsUpdate = true;
      particles.geometry.attributes.scale.needsUpdate = true;
    };

    const updateCamera = () => {
      const targetX =
        mouseX * (particlesAmountX * config.gap * mouseMovement.scale);
      const targetY = -mouseY * (50 * mouseMovement.scale) + cameraLookAt.y;

      camera.position.x += (targetX - camera.position.x) * mouseMovement.speed;
      camera.position.y += (targetY - camera.position.y) * mouseMovement.speed;

      camera.lookAt(cameraLookAt.xTarget, cameraLookAt.yTarget, 0);
    };

    const animate = () => {
      updateParticles();
      updateCamera();

      renderer.clear();
      renderer.render(scene, camera);
      renderer.clearDepth();
      renderer.render(sceneTwo, camera);

      count += 0.04;
      animationId = requestAnimationFrame(animate);
    };

    wrapper.addEventListener('pointermove', handlePointerMove);
    window.addEventListener('resize', handleResize);

    animate();

    return () => {
      if (animationId) {
        cancelAnimationFrame(animationId);
      }

      wrapper.removeEventListener('pointermove', handlePointerMove);
      window.removeEventListener('resize', handleResize);

      resTracker.dispose();
      renderer.dispose();
    };
  }, []);

  return (
    <div
      className="bottom-0 absolute w-full h-[223px] overflow-hidden"
      ref={wrapperRef}
    >
      <canvas
        className="top-0 bottom-0 left-0 absolute w-full h-[223px]"
        data-engine="three.js r167"
        ref={canvasRef}
        style={{ width: '100%', height: '223px' }}
      />
    </div>
  );
};

export default ContactCanvas;

Your current shader is far from best practice, branching 12 times in the fragment… Pick the color once per particle. Either pass a per particle color attribute from js, or pick a random color at init and store it in an attribute. If you want particles to change color over time, drive it with a time uniform in the shader instead of rebuilding attributes on the cpu.

You update position and scale on the cpu every frame. Push randomness as attributes once, then animate in the vertex shader with a uTime uniform to cut cpu and bandwidth.

Rendering a second scene with only an AmbientLight does nothing for an unlit ShaderMaterial…

If you are not doing SSR, ISR, or API routes, Next.js adds complexity for no gain here. Vite + Three.js is simpler, especially since you are not using React Three Fiber and don’t know how to use Next.js…

But before any of that, learn the GLSL and three.js fundamentals. Otherwise, ready samples won’t really help at this stage, and there’s a lot of wrong usage out there, which I don’t dive into it…

This one is not free but great for zero to hero:

3 Likes

Oh, I didn’t pay attention to that point — I don’t want the particles to change color over time..
So I’ll start with learning GLSL first. Thanks a lot for taking the time to write such a detailed reply :folded_hands: I really appreciate it :cherry_blossom:

1 Like

If it needs the switching between different palettes, then I would go with DataTextures and texelFetch :thinking:
Picture:


Demo: https://codepen.io/prisoner849/full/ZYbrdga

4 Likes

Yeah, I actually need it to switch for Dark mode. Thanks a lot, I didn’t know about this :cherry_blossom:

1 Like