"Spotlight" effect for an animation

I’m creating an animation which resembles the GameBoy Advance startup and its working so far but I cant seem to get the “spotlight” animation to work.

// Define colors for each of the 23 frames
const colors = [
  "#bd8dbd", "#bd8dbd", "#bd8dbd", "#bd8dbd", "#bd8dbd", "#bd8dbd",
  "#bd8dbd", "#bd8dbd", "#bd8dbd", "#bd5cbd", "#be2cbd", "#bd00be",
  "#bc008d", "#bd005c", "#bd002a", "#bc0001", "#bd2c00", "#bb5c00",
  "#bd8e00", "#bcbf00", "#8ebd00", "#59be00", "#2bbd00", "#00bd00",
  "#00be2a", "#01bd5f", "#00be8b", "#00bdbf", "#008ebc", "#005dbd",
  "#0029b9", "#0100be",
];

const interpolateColor = (color1, color2, factor) => {
  const c1 = new THREE.Color(color1);
  const c2 = new THREE.Color(color2);
  return c1.lerp(c2, factor);
};

const animatedLetters = [];

// Function to animate a letter
const animateLetter = (letter, initialPosition, pathPoints, delay) => {
  const loader = new FontLoader();
  loader.load("src/assets/Futura_Bold Italic.json", function (font) {
    const textMaterial = new THREE.MeshPhongMaterial({ color: new THREE.Color(colors[0]) });
    const textGeometry = new TextGeometry(letter, {
      font: font,
      size: 1,
      depth: 0.2,
      curveSegments: 4,
      bevelEnabled: false,
      bevelThickness: 0.03,
      bevelSize: 0.02,
      bevelOffset: 0,
      bevelSegments: 5,
    });
    const textMesh = new THREE.Mesh(textGeometry, textMaterial);
    textMesh.castShadow = true;
    textMesh.receiveShadow = true;
    textMesh.position.copy(initialPosition);
    scene.add(textMesh);

    const curve = new THREE.CatmullRomCurve3(pathPoints);
    const animationDuration = 783;
    const animationTween = new TWEEN.Tween({ progress: 0 })
      .to({ progress: 1 }, animationDuration)
      .onUpdate((obj) => {
        const pointOnCurve = curve.getPointAt(obj.progress);
        textMesh.position.copy(pointOnCurve);

        const frameProgress = obj.progress * (colors.length - 1);
        const currentFrame = Math.floor(frameProgress);
        const nextFrame = Math.ceil(frameProgress);
        const frameFactor = frameProgress - currentFrame;
        const interpolatedColor = interpolateColor(colors[currentFrame], colors[nextFrame], frameFactor);
        textMaterial.color.set(interpolatedColor);
      })
      .onComplete(() => {
        textMaterial.color.set(colors[colors.length - 1]);

        const bounceDuration = 350;
        const bounceHeight = 0.4;

        const bounceTween1 = new TWEEN.Tween({ y: textMesh.position.y })
          .to({ y: textMesh.position.y + bounceHeight }, bounceDuration / 2)
          .easing(TWEEN.Easing.Quadratic.Out)
          .onUpdate((obj) => { textMesh.position.y = obj.y; })
          .yoyo(true)
          .repeat(1);

        const bounceTween2 = new TWEEN.Tween({ y: textMesh.position.y })
          .to({ y: textMesh.position.y + bounceHeight * 0.25 }, bounceDuration / 4)
          .easing(TWEEN.Easing.Quadratic.Out)
          .onUpdate((obj) => { textMesh.position.y = obj.y; })
          .yoyo(true)
          .repeat(1);

        const bounceTween3 = new TWEEN.Tween({ y: textMesh.position.y })
          .to({ y: textMesh.position.y + bounceHeight * 0.0625 }, bounceDuration / 8)
          .easing(TWEEN.Easing.Quadratic.Out)
          .onUpdate((obj) => { textMesh.position.y = obj.y; })
          .yoyo(true)
          .repeat(1);

        const bounceTween4 = new TWEEN.Tween({ y: textMesh.position.y })
          .to({ y: textMesh.position.y + bounceHeight * 0.015625 }, bounceDuration / 16)
          .easing(TWEEN.Easing.Quadratic.Out)
          .onUpdate((obj) => { textMesh.position.y = obj.y; })
          .yoyo(true)
          .repeat(1);

        bounceTween1.chain(bounceTween2);
        bounceTween2.chain(bounceTween3);
        bounceTween3.chain(bounceTween4);
        bounceTween1.start();
      });

    setTimeout(() => {
      animationTween.start();
    }, delay);
    // Store the modified letter object in the new array
    animatedLetters.push({ letter, initialPosition, pathPoints, delay, mesh: textMesh });
  });
};

// Letters with their initial positions, paths and delays
const letters = [
  { letter: "S", initialPosition: new THREE.Vector3(-1.9, -2, 10), pathPoints: [new THREE.Vector3(-1.9, -2, 10), new THREE.Vector3(-1.8, 0.3, 8), new THREE.Vector3(-2.15, 0.75, 7), new THREE.Vector3(-3.1, 0.3, 6), new THREE.Vector3(-3.8, 0.3, 6)], delay: 0 },
  { letter: "i", initialPosition: new THREE.Vector3(-1, -2, 10), pathPoints: [new THREE.Vector3(-1.2, -2, 10), new THREE.Vector3(-1.5, 0.3, 8), new THREE.Vector3(-1.75, 0.75, 7), new THREE.Vector3(-2.3, 0.3, 6), new THREE.Vector3(-3, 0.3, 6)], delay: 200 },
  { letter: "L", initialPosition: new THREE.Vector3(-1, -2, 10), pathPoints: [new THREE.Vector3(-1, -2, 10), new THREE.Vector3(-1.3, 0.3, 8), new THREE.Vector3(-1.5, 0.75, 7), new THREE.Vector3(-1.95, 0.3, 6), new THREE.Vector3(-2.65, 0.3, 6)], delay: 250 },
  { letter: "L", initialPosition: new THREE.Vector3(-0.8, -2, 10), pathPoints: [new THREE.Vector3(-0.8, -2, 10), new THREE.Vector3(-1, 0.3, 8), new THREE.Vector3(-1.3, 0.75, 7), new THREE.Vector3(-1.75, 0.3, 6), new THREE.Vector3(-1.95, 0.3, 6)], delay: 300 },
  { letter: "Y", initialPosition: new THREE.Vector3(-0.6, -2, 10), pathPoints: [new THREE.Vector3(-0.6, -2, 10), new THREE.Vector3(-0.7, 0.3, 8), new THREE.Vector3(-0.8, 0.75, 7), new THREE.Vector3(-1.1, 0.3, 6), new THREE.Vector3(-1.4, 0.3, 6)], delay: 350 },
  { letter: "D", initialPosition: new THREE.Vector3(-0.4, -2, 10), pathPoints: [new THREE.Vector3(-0.4, -2, 10), new THREE.Vector3(-0.5, 0.3, 8), new THREE.Vector3(-0.6, 0.75, 7), new THREE.Vector3(-0.8, 0.3, 6), new THREE.Vector3(-0.4, 0.3, 6)], delay: 400 },
  { letter: "O", initialPosition: new THREE.Vector3(0.95, -2, 10), pathPoints: [new THREE.Vector3(-0.25, -2, 10), new THREE.Vector3(-0.15, 0.3, 8), new THREE.Vector3(-0.05, 0.75, 7), new THREE.Vector3(0.15, 0.3, 6), new THREE.Vector3(0.55, 0.3, 6)], delay: 450 },
  { letter: "G", initialPosition: new THREE.Vector3(0.96, -2, 10), pathPoints: [new THREE.Vector3(0.25, -2, 10), new THREE.Vector3(0.45, 0.3, 8), new THREE.Vector3(0.65, 0.75, 7), new THREE.Vector3(1.25, 0.3, 6), new THREE.Vector3(1.65, 0.3, 6)], delay: 500 },
  { letter: "S", initialPosition: new THREE.Vector3(3.1, -2, 10), pathPoints: [new THREE.Vector3(1, -2, 10), new THREE.Vector3(0.8, 0.3, 8), new THREE.Vector3(1.3, 0.75, 7), new THREE.Vector3(2.1, 0.3, 6), new THREE.Vector3(2.7, 0.3, 6)], delay: 550 },
];

// Animate all letters
letters.forEach(({ letter, initialPosition, pathPoints, delay }) => {
  animateLetter(letter, initialPosition, pathPoints, delay);
});

the code above is the main animation which puts the letters into place. I have tried multiple things to simulate the spotlight effect but the closest I’ve gotten is changing the colour of the entire letter depending on the position of another object:

// Calculate the total animation duration of all letters
const totalAnimationDuration = letters.reduce((max, { delay }) => Math.max(max, delay), 0) + 1000; // Add extra time for the spotlight to start after all letters finish animating

// Start the color transition effect after the bounce is completed
setTimeout(startColorTransition, totalAnimationDuration);

// Modify the startColorTransition function to create a spotlight effect with a localized circle
function startColorTransition() {
  const finalPositions = animatedLetters.map(letterObj => letterObj.pathPoints[letterObj.pathPoints.length - 1]);
  const spotlightRadius = 0.5; // Define the radius of the spotlight circle
  const spotlightColor = "#bd8dbd"; // Spotlight color
  const darkBlueColor = "#0100be"; // Dark blue color
  
  const spotlightTween = new TWEEN.Tween({ x: -5 })
    .to({ x: 5 }, 1500) // Move circle horizontally
    .onUpdate((obj) => {
      finalPositions.forEach((finalPos, index) => {
        const distance = Math.abs(finalPos.x - obj.x);
        let color;
        
        // Calculate the color based on the distance from the spotlight center
        if (distance <= spotlightRadius) {
          // Inside the spotlight circle, use the spotlight color
          color = spotlightColor;
        } else {
          // Outside the spotlight circle, gradually transition back to the darker blue color
          const maxDistance = spotlightRadius * 2; // Adjust for a smooth transition
          const factor = Math.min((distance - spotlightRadius) / maxDistance, 1);
          color = interpolateColor(spotlightColor, darkBlueColor, factor);
        }
        
        animatedLetters[index].mesh.material.color.set(color); // Access the textMesh via letterObj.mesh
      });
    })
    .start();
}

Does anyone know a better way to achieve this?

1 Like

It would be useful to get a video reference of what you’re trying to achieve. Why are you not using a THREE.Spotlight to do this? Can you put your code in a codepen so other people can run it?

A few notes on unrelated stuff in the code:

  • You’re loading the font every time you create a letter, that’s unecessary
  • for cleaner code, instead of using callbacks to your loader.load function, you can write it with async / await instead:
async function initLetters(){
  const font = await new Promise( (resolve, reject) =>{
    loader.load("src/assets/Futura_Bold Italic.json", resolve, reject )
  })
  letters.forEach(({ letter, initialPosition, pathPoints, delay }) => {
    animateLetter(letter, initialPosition, pathPoints, delay);
  });
   
}
  • instead of using the Tween library, I’d use GSAP instead so you can create timelines and avoid creating tweens in the onComplete callback of a previous tween - it will be simpler to write and understand
1 Like

Ah sorry I’m quite new to Three.js and forums in general. Here is the animation I’m trying to recreate:

and this codepen should work:

I don’t know enough about Three.js so I dont know if a spotlight can achieve the desired effect (the moving light across the text in the video)

It depends on how close you want to get to the original video.

A spotlight can be useful, but it’s gonna look more like a real light than what we see here.

I would start by creating two transparent sprites with a simple white circle as texture, set their opacity to something like 0.5 for the smaller one and 0.3 for the larger one, and animate their positions. That should give you the spotlight circle effect.

Then if you want to properly replicate the color effect, I would do it with some shaders. You would need to write a custom shader that gets the spotlight’s position, and based on each pixel’s distance to the spotlight position, mixes between the blue unlit color and the purplish spotlight color. (TBH this is very similar to what the spotlight does, so you could potentially use that )

Yea that’s the problem I ran into, I want to have that retro feel as well. I have one last question, I tried to use a shader for that earlier but I honestly don’t know much about it so I didn’t get it to work.

Got any tips for this?
Thanks for the help

There are three main things the shader should do:

  • pass the spotlight’s world position as a uniform to the shader
  • pass vertex world coordinates as a varying from the vertex shader to the fragment shader
  • in the fragment shader, calculate the distance between the pixels’s world coordinates (obtained in the previous step) and the spotlight’s position, and use that to do your color calculation

Typically you’d start with an existing material (in this case, MeshBasicMaterial seems like a good choice).
Check its shader code, figure out where you need to insert your code (you can check what all the #include <something> statements refer to here).

Then use material.onBeforeCompile or a helper library to modify the material by adding your shader code to it.