일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 파이썬
- es6
- 백엔드
- HTML
- docker
- 이슈
- 타입스크립트
- 웹
- 블록체인
- kubernetes
- 프론트엔드
- 백준
- k8s
- 리액트
- AWS
- 클라우드
- 솔리디티
- next.js
- 알고리즘
- 컴퓨터공학
- 가상화
- BFS
- 이더리움
- TypeScript
- VUE
- CSS
- react
- JavaScript
- 쿠버네티스
- 자바스크립트
- Today
- Total

즐겁게, 코드
블루 아카이브 팬이라면 꼭 봐야 할! Three.js로 만드는 3D 애니메이션 본문
우연히 블루 아카이브라는 게임을 깔아보고 있었는데, 설치 중 유난히 예쁜 애니메이션이 눈에 들어왔다.
별의 일주 운동처럼 축을 기준으로 고리가 공전하는 애니메이션인데 이게 상당히 예쁘게 느껴져 한번 비스무리하게 만들어 보았다.
이런 효과를 부르는 명칭이 따로 있으려나?
먼저 이런 애니메이션의 특성상 Three.js 를 사용하는 것이 적절해 보였고, 고리 형태를 화면에 그리기 위해 RingGeometry를 사용해야 할 것 같았다.
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도 꼭 명시해준다
},
});
}
그러나 아쉽게도 잘 되지는 않았다.
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);
'🎨 프론트엔드 > 뚝딱뚝딱!' 카테고리의 다른 글
Google Apps Script로 주식 주가 정보 스크래핑하기 (0) | 2025.01.19 |
---|