일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
- 컴퓨터공학
- 알고리즘
- 백준
- HTML
- VUE
- kubernetes
- 이슈
- AWS
- react
- BFS
- 백엔드
- 이더리움
- es6
- 쿠버네티스
- 클라우드
- 타입스크립트
- 파이썬
- 자바스크립트
- 블록체인
- next.js
- 프론트엔드
- JavaScript
- docker
- 솔리디티
- k8s
- 웹
- 리액트
- TypeScript
- CSS
- 가상화
- 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 |
---|