관리 메뉴

즐겁게, 코드

블루 아카이브 팬이라면 꼭 봐야 할! Three.js로 만드는 3D 애니메이션 본문

🎨 프론트엔드/뚝딱뚝딱!

블루 아카이브 팬이라면 꼭 봐야 할! Three.js로 만드는 3D 애니메이션

Chamming2 2025. 1. 19. 02:35

ㅎㅎ;

 

우연히 블루 아카이브라는 게임을 깔아보고 있었는데, 설치 중 유난히 예쁜 애니메이션이 눈에 들어왔다.

별의 일주 운동처럼 축을 기준으로 고리가 공전하는 애니메이션인데 이게 상당히 예쁘게 느껴져 한번 비스무리하게 만들어 보았다.

급 떠오른건데 펄스건 이즈리얼 스킨에도 비슷한 효과가 있다.
이런 효과를 부르는 명칭이 따로 있으려나?

먼저 이런 애니메이션의 특성상 Three.js 를 사용하는 것이 적절해 보였고, 고리 형태를 화면에 그리기 위해 RingGeometry를 사용해야 할 것 같았다.

thetaLength 값을 조절해 'C' 형태의 고리를 만든 모습

RingGeometry는 기본적으로는 완전한 고리의 형태를 띠고 있는데, 생성자의 6번째 인자인 thetaLength 값을 조절하면 우측처럼 완전하지 않은 고리를 만들 수도 있다.

/**
** RingGeometry 생성자의 인자 목록 (순서대로) **

@param innerRadius — Expects a Float. Default 0.5.
@param outerRadius — Expects a Float. Default 1.
@param thetaSegments — Number of segments. A higher number means the ring will be more round. Minimum is 3. Expects a Integer. Default 32.
@param phiSegments — Number of segments per ring segment. Minimum is 1. Expects a Integer. Default 1.
@param thetaStart — Starting angle. Expects a Float. Default 0.
@param thetaLength — Central angle. Expects a Float. Default Math.PI * 2.
*/
const geometry = new THREE.RingGeometry(5, 4.8, 100, 1, 2, 5);

고리 하나를 만든 모습이다.

이제 이 코드를 적당히 수정하고 복사해, 다음과 같이 고리의 그룹을 만들어 줬다.

만약 메시의 해상도가 낮아 상이 자글거려 보인다면, 다음 코드를 추가해 개선할 수 있다.

const pixelRatio = window.devicePixelRatio;
renderer.setPixelRatio(pixelRatio * 2); // 해상도 개선

이제 고리를 움직이게 해보자.

function animate() {
  mesh1.rotateZ(0.005);
  mesh2.rotateZ(-0.005);
  mesh3.rotateZ(-0.0075);
  mesh4.rotateZ(0.005);
  mesh5.rotateZ(-0.002);
  mesh6.rotateZ(-0.005);
  renderer.render(scene, camera);
}

// webXR을 대상으로 하지 않는다면 네이티브 RequestAnimationFrame을 사용해도 상관없다.
renderer.setAnimationLoop(animate);

색감은 다르지만 고리의 움직임이 어느 정도 그럴 듯 해졌다.

도형을 마우스로 회전시킬 수 있는 기능도 추가해 보자.

let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };

function onMouseDown() {
  isDragging = true;
}

function onMouseUp() {
  isDragging = false;
}

function onMouseMove(event) {
  if (isDragging) {
    const deltaMove = {
      x: event.movementX || event.mozMovementX || event.webkitMovementX || 0,
      y: event.movementY || event.mozMovementY || event.webkitMovementY || 0,
    };

    mesh1.rotation.y += deltaMove.x * 0.01;
    mesh1.rotation.x += deltaMove.y * 0.01;
    mesh2.rotation.y += deltaMove.x * 0.01;
    mesh2.rotation.x += deltaMove.y * 0.01;
    mesh3.rotation.y += deltaMove.x * 0.01;
    mesh3.rotation.x += deltaMove.y * 0.01;
    mesh4.rotation.y += deltaMove.x * 0.01;
    mesh4.rotation.x += deltaMove.y * 0.01;
    mesh5.rotation.y += deltaMove.x * 0.01;
    mesh5.rotation.x += deltaMove.y * 0.01;
    mesh6.rotation.y += deltaMove.x * 0.01;
    mesh6.rotation.x += deltaMove.y * 0.01;
  }

  previousMousePosition = {
    x: event.clientX,
    y: event.clientY,
  };
}

function onMouseWheel(event) {
  const delta = Math.sign(event.deltaY) * 0.01;

  mesh1.rotation.z += delta;
  mesh2.rotation.z += delta;
  mesh3.rotation.z += delta;
  mesh4.rotation.z += delta;
  mesh5.rotation.z += delta;
  mesh6.rotation.z += delta;
}

renderer.domElement.addEventListener("mousedown", onMouseDown, false);
renderer.domElement.addEventListener("mouseup", onMouseUp, false);
renderer.domElement.addEventListener("mousemove", onMouseMove, false);

이걸로 내가 처음에 생각했던 애니메이션 효과는 구현해 보았다.

 

번외. 특정 URL의 응답이 Three.js 씬을 SVG로 만들게 할 수는 없을까?

욕심이 하나 있었다면 아래 링크처럼 특정 URL이 SVG를 리턴하게 해, 깃허브 프로필 영역에 이 멋진 고리 효과를 넣고 싶었다.

https://github-readme-stats.vercel.app/api?username=c17an&show_icons=true&theme=dracula

import { JSDOM } from "jsdom";
import * as THREE from "three";
import { SVGRenderer } from "three/addons/renderers/SVGRenderer.js";

// Next.js API Route를 사용할 것이라, 서버 사이드 환경을 위한 RAF 함수 모킹
function requestAnimationFrame(f) {
  setImmediate(() => f(Date.now()));
}

export async function GET() {
  // Next.js API Route를 사용할 것이라, 서버 사이드 환경을 위한 DOM 모킹
  const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
  global.document = dom.window.document;
  global.window = dom.window;

  // 고리 만드는 애니메이션은 생략
  // renderer만 WebGLRenderer에서 SVGRenderer로 교체
  const renderer = new SVGRenderer();

  const svgOutput = renderer.domElement.outerHTML;

  // SVG를 image/svg+xml로 반환
  return new Response(svgOutput, {
    headers: {
      "Content-Type": "image/svg+xml", // Response Content-type도 꼭 명시해준다
    },
  });
}

API Route에서 이미지가 떨어져야 하는데, <svg> 문자열이 떨어지는 모습

그러나 아쉽게도 잘 되지는 않았다.

github-readme-stats 프로젝트는 URL에 진입하면 SVG가 이미지 형식으로 떨어지는데, SVGRenderer가 씬을 SVG로 치환해준 결과물은 보통의 SVG와 뭔가 다른 느낌이다.

 

처음에는 응답의 Content-Type 속성의 문제를 강하게 의심했는데, 다른 SVG 파일로 테스트했을 때는 잘 나오는 것으로 보아 렌더러의 문제가 맞는 것으로 보인다.

 

급하게 후다닥 만든 코드여서 탄생과 동시에 낡은 코드지만, 관심이 있는 분도 있을까봐 애니메이션 코드를 남겨둔다.

import * as THREE from "three";

const scene = new THREE.Scene();
// scene.background = new THREE.Color();
const camera = new THREE.PerspectiveCamera(
  50,
  window.innerWidth / window.innerHeight,
  0.1,
  100
);
// const axesHelper = new THREE.AxesHelper(5);
// scene.add(axesHelper);

const geometry1 = new THREE.RingGeometry(5, 4.8, 100, 1, 2, 5);
const material1 = new THREE.MeshBasicMaterial({
  color: "#789DBC",
  side: THREE.DoubleSide,
});
const mesh1 = new THREE.Mesh(geometry1, material1);

const geometry2 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 4);
const material2 = new THREE.MeshBasicMaterial({
  color: "#cdcfd1",
  side: THREE.DoubleSide,
});

const mesh2 = new THREE.Mesh(geometry2, material2);

const geometry3 = new THREE.RingGeometry(7, 6.7, 100, 1, 4, 4);
const material3 = new THREE.MeshBasicMaterial({
  color: "#BCCCDC",
  side: THREE.DoubleSide,
});
const mesh3 = new THREE.Mesh(geometry3, material3);

const geometry4 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 1);
const material4 = new THREE.MeshBasicMaterial({
  color: "#3483eb",
  side: THREE.DoubleSide,
});
const mesh4 = new THREE.Mesh(geometry4, material4);

const geometry5 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 5);
const material5 = new THREE.MeshBasicMaterial({
  color: "#0b3975",
  side: THREE.DoubleSide,
});
const mesh5 = new THREE.Mesh(geometry5, material5);

const geometry6 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 5);
const material6 = new THREE.MeshBasicMaterial({
  color: "#afcfe3",
  side: THREE.DoubleSide,
});
const mesh6 = new THREE.Mesh(geometry6, material6);

const renderer = new THREE.WebGLRenderer({ antialias: true });

// 화면 크기 및 devicePixelRatio 반영
const width = window.innerWidth;
const height = window.innerHeight;
const pixelRatio = window.devicePixelRatio;

renderer.setSize(width, height);
renderer.setPixelRatio(pixelRatio * 2); // 화면 해상도에 맞게 렌더링 크기 조정

camera.position.z = 50;

mesh1.position.z = -5;

mesh2.position.z = 0;

mesh3.position.z = 12;

mesh4.position.z = 20;

mesh5.position.z = 24;

mesh6.position.z = 16;

scene.add(mesh1);
scene.add(mesh2);
scene.add(mesh3);
scene.add(mesh4);
scene.add(mesh5);
scene.add(mesh6);

function animate() {
  mesh1.rotateZ(0.005);
  mesh2.rotateZ(-0.005);
  mesh3.rotateZ(-0.0075);
  mesh4.rotateZ(0.005);
  mesh5.rotateZ(-0.002);
  mesh6.rotateZ(-0.005);

  renderer.render(scene, camera);
}

let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };

function onMouseDown() {
  isDragging = true;
}

function onMouseUp() {
  isDragging = false;
}

function onMouseMove(event) {
  if (isDragging) {
    const deltaMove = {
      x: event.movementX || event.mozMovementX || event.webkitMovementX || 0,
      y: event.movementY || event.mozMovementY || event.webkitMovementY || 0,
    };

    mesh1.rotation.y += deltaMove.x * 0.01;
    mesh1.rotation.x += deltaMove.y * 0.01;
    mesh2.rotation.y += deltaMove.x * 0.01;
    mesh2.rotation.x += deltaMove.y * 0.01;
    mesh3.rotation.y += deltaMove.x * 0.01;
    mesh3.rotation.x += deltaMove.y * 0.01;
    mesh4.rotation.y += deltaMove.x * 0.01;
    mesh4.rotation.x += deltaMove.y * 0.01;
    mesh5.rotation.y += deltaMove.x * 0.01;
    mesh5.rotation.x += deltaMove.y * 0.01;
    mesh6.rotation.y += deltaMove.x * 0.01;
    mesh6.rotation.x += deltaMove.y * 0.01;
  }

  previousMousePosition = {
    x: event.clientX,
    y: event.clientY,
  };
}

function onMouseWheel(event) {
  const delta = Math.sign(event.deltaY) * 0.01;

  mesh1.rotation.z += delta;
  mesh2.rotation.z += delta;
  mesh3.rotation.z += delta;
  mesh4.rotation.z += delta;
  mesh5.rotation.z += delta;
  mesh6.rotation.z += delta;
}

renderer.domElement.addEventListener("mousedown", onMouseDown, false);
renderer.domElement.addEventListener("mouseup", onMouseUp, false);
renderer.domElement.addEventListener("mousemove", onMouseMove, false);
renderer.domElement.addEventListener("wheel", onMouseWheel, false); // 마우스 휠 이벤트

renderer.setAnimationLoop(animate);

document.body.appendChild(renderer.domElement);

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}

window.addEventListener("resize", onWindowResize);
반응형
Comments
소소한 팁 : 광고를 눌러주시면, 제가 뮤지컬을 마음껏 보러다닐 수 있어요!
와!! 바로 눌러야겠네요! 😆