Gen Art

Shattering Text Animation 제작기

개발가락 2024. 6. 6. 14:59

 

소스코드는 하단에 있습니다

 

 

 

Intro.

웹 사이트의 목적은 정보의 전달이 목적이라 생각하고, 텍스트는 가장 직접적인 정보 전달의 수단이기에 개인적으로 텍스트 애니메이션이 들어가면 유독 임팩트가 강하게 느껴집니다.

 

요즘 소소하게 awwwards 둘러보면서 인사이트를 얻고는 하는데, 사이트가 기억이 안나지만... 스크롤에 따라 파편 상태 -> 하나로 합쳐지는 이미지 애니메이션이 있엇는데 정말 재밌게 반복해서 스크롤링 했었습니다.

 

약간 요런 느낌...?? ㅎㅅㅎ

 

 

그 애니메이션은 2d 이미지를  단순하게 16등분하고 스크롤 위치에 따라 CSS::translate 속성 조절을 통해 하나로 합치는 애니메이션이었는데, 문득 다음과 같은 생각을 하게 되었습니다.

 

 

임의의 3d 객체를 임의의 평면으로 쪼갰다가 합치면 재밌겠다

 

 

 

 

Problem Definition.

생각을 구현하려면 먼저 다음의 주요 문제가 있습니다.

 

  1. (m1)임의의 3d 객체를 어떻게 쪼갤것인가?
  2. (m2)객체를 면(face)으로 쪼갰다면, 어떤식으로 각각 효과를 줄 것인가?
    1. (m2-1)각 면의 움직임의 자취는 어떻게 결정할 것인가?
    2. (m2-2)각 면이 움직임의 속도는 어떻게 결정할 것인가?

 

Intuition.

 

M1

m1을 해결하기 가장 직관적인 방법은 애시당초 3d 객체가 아니라 파편들을 전부 직접 하나하나 만드는 방법이지요.

 

단순 도형이라면 개인적으로는 효과적인 방법이라 생각합니다. 실제로 위 방법이 제가 본 애니메이션에서 사용한 방법이기도 했었습니다.

 

문제는 해당 방법은 일단 확장성에 위배되면서 동시에 3d객체가 복잡할수록 프로덕션으로는 사용할 수 없을 만큼 처참한 메모리 소비와 시간 자원을 소비합니다.  (예를 들어, 분할된 면의 개수가 100개만 되더라도 그것들 일일이 하나하나 위치맞추고 있을 순... 없자나요..?)

 

여기서 스스로에게 한 가지 제약을 줌으로써 문제를 해결했는데요, 다음과 같습니다.

 

모든 임의의 평면은 n각형이어야한다. (n은 모든 평면에 대해 동일)

 

위 전제를 통해 다음을 도출 할 수 있습니다.

 

하나의 3d 객체의 모든 정점(vertice)값들이 주어진다면 n개의 정점을 골라 하나의 평면으로 간주할 수 있다.

다행히 Threejs 에서 제공하는 BufferGeometry 클래스는 기본적으로 정점들의 좌표값 리스트를 제공해 주기에 해당 문제는 해결된듯 합니다.

M2

이 부분이 제일 어려웠는데요, 이유인즉슨, 2d와는 달리 3d 현상은 인간이 직관적으로 이해하기 쉽지가 않기 때문입니다.

 

만약 쪼개진다면, 일반적으로 폭발을 의미하는데 폭발의 물리적 방정식을 구할 수도 없고 구하더라도 그 식이 심히 복잡할 것이 예상되기 때문입니다.

 

여기서 이제 단순화를 잘 시켜야하는데요, 저희가 아는 레벨로 문제를 단순화 해보겠습니다.

 

일단 무언가 폭발한다면 해당 물체의 파편은 무슨 운동을 할까요?

 

 

 

바로 ... 포물선 운동입니다.

 

그리고 포물선 운동은 곡선의 움직임이고, 프론트엔드 개발자로써, 가장 친숙한 곡선은 바로 bezier curve 입니다.

 

그렇다면 모든 면에 대해서 bezier 곡선 움직임을 적용하되, 시작 시점이나 애니메이션의 진행 속도를 다르게 하므로써, 랜덤한 느낌을 살릴 수 있을것 같습니다. (m2-1)

그리고 각 면들에 대해서, 당연하지만 크기(질량)이 큰 면은 좀 빠르게 진행하고 크기가 작은 면은 천천히 진행시키면 더욱 자연스러운 애니메이션이 될듯합니다.(m2-2)

 

 

길고 긴 아이디어 서술이었지만... 정리하자면 다음과 같습니다.

 

  1. 3d객체의 정점들의 조합으로써 면을 정의한다.
  2. 운동 방정식은 bezier 곡선을 베이스로한다.
  3. 면의 질량에 따라 애니메이션 속도를 조절 및 최종 위치를 조절한다.

 

 

 

Implementation.

 

1. M1 ( ShatteredBufferGeometry 구현)


저는 먼저 면(face)는 삼각형으로 가정하였는데요, 삼각형이 일단 3차원 도형의 기본 단위고 threejs에서도 기본적으로 채용하는 면의 단위이기 때문입니다.

 

먼저 최종적으로 각 면에 움직임을 주기 위해서, 각 면의 움직임의 '축(pivot)' 이 필요한데요, 각 면마다 축이 각 면의 정점으로 정의되어 있으면, 면이 힘을 받을 때, 해당 정점을 잡고 뜯어내는 기괴한 효과가 연출되기 때문입니다.

(이해가 잘 안된다면 a4용지 하나를 두고 모서리를 잡고 들어올려보시면 됩니다. 제가 하려는건 a4 용지의 중심을 잡고 날리고 싶은 겁니다)

 

이를 위해 애니메이션을 위한 새로운 geometry가 필요하여 Three.js에서 제공하는 bufferGeometry를 상속하는 모든 geomtry 클래스들을 애니메이션화 시킬 수 있도록하기 위해 ShatteredBufferGeometry클래스를 만들었습니다.

 

해당 클래스는 기본 geometry에 새로운 centroid(무게 중심) 속성을 추가해주고 애니메이션을 위한 추가적인 기하학적 유틸리티를 제공합니다.

 

 

import { BufferGeometry, BufferAttribute } from "three";

export class ShatteredBufferGeometry extends BufferGeometry {
  constructor(baseGeometry) {
    super();
    this.baseGeometry = baseGeometry;
    this.centroids = this.updateCentroid();
    this.attributes = this.baseGeometry.attributes;
    this.faceCnt = this.getAttribute("position").count / 3;
    this.parameters = this.baseGeometry.parameters;
    this.groups = this.baseGeometry.groups;

    this.updateCentroid();
  }
  updateCentroid() {
    const centroids = [];
    const positions = this.getAttribute("position");

    for (let i = 0; i < this.faceCnt * 3 * 3; i += 9) {
      const f1x = positions.array[i + 0];
      const f1y = positions.array[i + 1];
      const f1z = positions.array[i + 2];

      const f2x = positions.array[i + 3];
      const f2y = positions.array[i + 4];
      const f2z = positions.array[i + 5];

      const f3x = positions.array[i + 6];
      const f3y = positions.array[i + 7];
      const f3z = positions.array[i + 8];

      const cx = (f1x + f2x + f3x) / 3;
      const cy = (f1y + f2y + f3y) / 3;
      const cz = (f1z + f2z + f3z) / 3;

      for (let v = 0; v < 9; v += 3) {
        centroids.push(...[cx, cy, cz]);
      }
    }

    const centroidAttribute = new BufferAttribute(new Float32Array(centroids), 3, false);
    this.setAttribute("centroid", centroidAttribute);
  }
  computeBoundingBox() {
    this.baseGeometry.computeBoundingBox();
    this.boundingBox = this.baseGeometry.boundingBox;
    this.boundingSphere = this.baseGeometry.boundingSphere;
  }
  applyMatrix4(matrix) {
    this.baseGeometry.applyMatrix4(matrix);
    this.updateCentroid();
  }
}

 

 

 

2. M2 ( ShatteringAnimation 구현)

 

일제가 'W'라는 텍스트 하나 만들고 면의 개수를 측정해보면 약 5000개정도 나옵니다. 일반적으로 렌더링은 16.7ms 마다 발생하는데, 제가 만약 'Hello world' 하나 입력하면 최소 50000개의 면이란 뜻이고 50000개에 면에 산술 연산까지 들어가면 16.7ms안에 완수하기엔 메인쓰레드에선 불가능합니다. 따라서 필연적으로 쉐이더의 힘을 빌려야하는데요, 이를 위해 쉐이더에 넘길 값들을 채워주기 위해 animation 클래스를 하나 만듭니다.

 

(글쓰면서 깨달았는데, 클래스를 잘못만들었네요... 속성값들 초기화 하는 부분을 ShatteredBufferGeometry로 옮기는게 맞습니다만.. 일단 계속 쓰겠습니다.)

 

해당 파트의 정수는,  쉐이더에 넘겨줄 각 면에 대한 속성들 (애니메이션 지속시간, 애니메이션 딜레이, 컨트롤 포인트(bezier curve 값들) , 최종 위치) 채우는 로직인데요.

 

중요한 부분만 서술하자면 다음과 같습니다.

 

  • 애니메이션 지속시간 - 최소 지속 ~ 최대 지속의 랜덤 사잇값으로 지정합니다.
  • 애니메이션 딜레이  - geometry의 최대 너비/높이에 대한 해당 정점의 x/y좌표값의 위치 비율 * x/y축이동 최대 딜레이값
  • 컨트롤 포인트 - 기본적으로 y,z 값은 자연스러움을 위해 어느정도 범위 구간에 있는 랜덤 값으로 잡되, 3차 함수형태의 움직임을 위해, x 축은 -1을 곱하여 반대 방향으로 설정합니다. (제가 x를 축으로 잡아서 그런겁니다. 도형의 기본 방향에 따라 충분히 달라질수 있는 부분입니다.)
  • 최종 위치 - 각 도형의 무게 중심을 기준으로 충분히 먼 거리를 랜덤으로 지정합니다.
const VERTEX_ITEM_SIZE = 3;
const FACE_ITEM_SIZE = 3;
const ANIMATION_ATTR_SIZE = 2;
const CONTROL_ATTR_SIZE = 3;
const DESTINATION_ATTR_SIZE = 3;


....

const geometry = this.geometry;
    const material = this.material;
    const zimbal = { x: 0.5, y: 0.5, z: 0.0 };
    const {
      maxDelayX,
      maxDelayY,
      maxDuration,
      minDuration,
      stretch,
      cp0X,
      cp0Y,
      cp0Z,
      cp1X,
      cp1Y,
      cp1Z,
      spreadX,
      spreadY,
      spreadZ,
    } = this.config;

    //set object dimension to center of i
    geometry.computeBoundingBox();
    geometry.userData = {};
    geometry.userData.size = {
      width: geometry.boundingBox.max.x - geometry.boundingBox.min.x,
      height: geometry.boundingBox.max.y - geometry.boundingBox.min.y,
      depth: geometry.boundingBox.max.z - geometry.boundingBox.min.z,
    };

    // fullfill default attribute values
    const centroids = geometry.getAttribute("centroid");

    let startingVertexIdx = 0;
    let faceIdx = 0;
    let animationIdx = 0;
    const verticesCnt = geometry.faceCnt * 3;
    const aAnimation = new Array(verticesCnt * 2).fill(0);
    const aCp0 = new Array(verticesCnt * 3).fill(0);
    const aCp1 = new Array(verticesCnt * 3).fill(0);
    const aDestination = new Array(verticesCnt * 3).fill(0);

    // iterate base per face
    while (faceIdx < geometry.faceCnt) {
      const cx = centroids[faceIdx * FACE_ITEM_SIZE + 0];
      const cy = centroids[faceIdx * FACE_ITEM_SIZE + 1];
      const cz = centroids[faceIdx * FACE_ITEM_SIZE + 2];

      const centroid = new Vector3(cx, cy, cz);
      const dimension = geometry.userData.size;

      const delayX = Math.abs((centroid.x / dimension.width) * maxDelayX);

      // will splash up if not inverse
      const delayY = Math.abs(centroid.y / dimension.height) * maxDelayY;
      const duration = MathUtils.randFloat(minDuration, maxDuration);

      for (let j = 0; j < VERTEX_ITEM_SIZE * ANIMATION_ATTR_SIZE; j += ANIMATION_ATTR_SIZE) {
        const delayIdx = animationIdx;
        const durationIdx = animationIdx;

        // for texture for tearing material, add stretch value.
        // per force, larger mass results low velocity. to make this obvious ,increase delay to let larger mass(higher stretch) looks much slower
        aAnimation[delayIdx + j + 0] = delayX + delayY + Math.random() * stretch;
        aAnimation[durationIdx + j + 1] = duration;
      }

      const c0x = centroid.x + MathUtils.randFloat(...cp0X);
      const c0y = centroid.y + dimension.height * MathUtils.randFloat(...cp0Y);
      const c0z = MathUtils.randFloatSpread(cp0Z);

      // each control point has symmetric relationship with line connecting start-end point
      // briefly set symmertry line to x-coord
      const c1x = centroid.x + MathUtils.randFloat(...cp1X) * -1;
      const c1y = centroid.y + dimension.height * MathUtils.randFloat(...cp1Y);
      const c1z = MathUtils.randFloatSpread(cp1Z);

      for (let j = 0; j < VERTEX_ITEM_SIZE * CONTROL_ATTR_SIZE; j += CONTROL_ATTR_SIZE) {
        // to spread bidirectionally.
        if (faceIdx % 2 === 0) {
          aCp0[startingVertexIdx + j + 0] = c0x;
          aCp0[startingVertexIdx + j + 1] = c0y;
          aCp0[startingVertexIdx + j + 2] = c0z;

          aCp1[startingVertexIdx + j + 0] = c1x;
          aCp1[startingVertexIdx + j + 1] = c1y;
          aCp1[startingVertexIdx + j + 2] = c1z;
        } else {
          aCp0[startingVertexIdx + j + 0] = c1x;
          aCp0[startingVertexIdx + j + 1] = c1y;
          aCp0[startingVertexIdx + j + 2] = c1z;

          aCp1[startingVertexIdx + j + 0] = c0x;
          aCp1[startingVertexIdx + j + 1] = c0y;
          aCp1[startingVertexIdx + j + 2] = c0z;
        }
      }

      // destinations for each vertices
      const desX = centroid.x + MathUtils.randFloatSpread(spreadX);
      const desY = centroid.y + dimension.height * MathUtils.randFloatSpread(spreadY);
      const desZ = MathUtils.randFloatSpread(spreadZ);

      for (let j = 0; j < VERTEX_ITEM_SIZE * DESTINATION_ATTR_SIZE; j += DESTINATION_ATTR_SIZE) {
        aDestination[startingVertexIdx + j + 0] = desX;
        aDestination[startingVertexIdx + j + 1] = desY;
        aDestination[startingVertexIdx + j + 2] = desZ;
      }

      faceIdx++;
      animationIdx += VERTEX_ITEM_SIZE * ANIMATION_ATTR_SIZE;
      startingVertexIdx += VERTEX_ITEM_SIZE * FACE_ITEM_SIZE;
    }
    geometry.setAttribute("aCentroid", centroids.clone());
    geometry.setAttribute("aAnimation", new BufferAttribute(new Float32Array(aAnimation), ANIMATION_ATTR_SIZE));
    geometry.setAttribute("aCp0", new BufferAttribute(new Float32Array(aCp0), CONTROL_ATTR_SIZE));
    geometry.setAttribute("aCp1", new BufferAttribute(new Float32Array(aCp1), CONTROL_ATTR_SIZE));
    geometry.setAttribute("aDestination", new BufferAttribute(new Float32Array(aDestination), DESTINATION_ATTR_SIZE));

 

 

 

2. M2 ( Vertex shader 구현)

 

이렇게 채워넣은 속성들을 이제 쉐이더로 보내주어서 bezier 움직임을 넣으면 끝입니다.

 

속성들을 받아와 cubicBezier 함수를 통해 위치값을 갱신시켜주면 끝입니다!

 

uniform float uTime;

attribute vec2 aAnimation;
attribute vec3 aCentroid;
attribute vec3 aCp0;
attribute vec3 aCp1;
attribute vec3 aDestination;



vec3 cubicBezier(vec3 p0, vec3 c0, vec3 c1, vec3 p1, float time)
{
    vec3 res;
    float next = 1.0 - time;
    res.xyz = next * next * next * p0.xyz + 3.0 * next * next * time * c0.xyz + 3.0 * next * time * time * c1.xyz + time * time * time * p1.xyz;
    return res;
}

void main() {
  float delay = aAnimation.x;
  float duration = aAnimation.y;
  float time = clamp(uTime - delay, 0.0, duration);
  // float progress =  ease(tTime, 0.0, 1.0, duration);
  float progress = time / duration;


  vec3 transformed = position;

  transformed *= 1.0 - progress;

  transformed += cubicBezier(transformed, aCp0, aCp1, aDestination, progress);

  vec4 mvPosition = modelViewMatrix * vec4( transformed, 1.0 );
  gl_Position = projectionMatrix * mvPosition;
}

 

 

 

 

 

 

 

https://github.com/dovigod/Sandbox/tree/master/workloads/vanila/src/FBOGeometry

 

Sandbox/workloads/vanila/src/FBOGeometry at master · dovigod/Sandbox

Testing, playing, studying. Contribute to dovigod/Sandbox development by creating an account on GitHub.

github.com