🕹️

Pixi.js 도입과 React에서 사용하기

Created
May 22, 2024 08:40 AM
설명
html canvas로 제작된 Snack Game에 한계를 느껴 Pixi.js를 도입하고 많은 발전을 이루었는데요. 어떤 한계점이 있었고 Pixi.js를 도입하고 장점은 무엇이 있었을까요? React에서는 어떻게 사용해야할까요?
Status
작성 완료
Tags
pixi
Snack Game
game
Select
Dev Log
👀
팀과 함께 개발하고 운영하는 Snack Game에서 기존 게임 제작 도구인 canvas의 한계를 느껴 Pixi.js 로 변경하게 되었는데요. 어떤 한계점이 있었고 도입하며 어떤 장점이 있었는지 정리했습니다. Pixi.js의 도입을 고려하거나 Pixi.js로 게임을 만드려고 하시는 분에게 도움이 되었으면 좋겠습니다! 아래 움짤은 Pixi.js를 도입한 Snack Game입니다.
notion image

Canvas(2D Context)에서 느낀 한계

notion image

성능적인 한계

HTML Canvas란 무엇인지 Canvas API의 성능상 한계점과 최적화 방법은 무엇이 있는지를 정리한 글을 읽어 보시는걸 추천합니다! →
🎨
HTML5 Canvas와 최적화
기존의 스낵게임은 HTML Canvas가 제공하는 2D Context를 사용해 게임을 렌더링 했습니다.
HTML Canvas 가 제공하는 2D Context나 WebGL API 는 저 수준의 그래픽스 연산을 제공하는데요.
이로 인해 굉장히 세밀하게 제어하고 별도의 프레임워크나 라이브러리 없이 제작할 수 있다는 장점이 있습니다.
다시말해 개발자의 책임이 굉장히 커지게 됩니다.
사용하는 에셋 최적화, 텍스쳐 관리, 렌더링, 이벤트 등등 신경쓸 부분이 많고 특히나 Canvas 가 익숙하지 않은 사람에게는 많은 휴먼에러가 발생할 수 있습니다.
Snack Game에서도 이런일이 발생했는데요. 다음의 코드가 프레임 드랍을 유발했습니다.
class Snack(){ public apple = new Image(); // 이미지 객체 public goldenApple = new Image(); // 이미지 객체 ... constructor() { this.apple.src = AppleImage; // 생성자에서 사용할 이미지 src 할당 this.goldenApple.src = GoldenAppleImage; } ... // requestAnimationFrame에서 1초에 60번 호출되는 함수 drawApple(ctx: CanvasRenderingContext2D, Apple: Apple) { ctx.drawImage( // 3항 연산으로 어떤 이미지를 사용할 이미지를 정함 Apple.isGolden ? this.goldenApple : this.apple, Apple.position.x, Apple.position.y, Apple.radius * 2,
코드를 보면 3항 연산으로 Apple객체에 렌더링할 이미지를 정하고 있는데요.
문제는 저 객체가 100개 정도가 화면에 렌더링 되는데 각각 이미지 객체를 가지고 있고 3항 연산으로 사용할 객체를 선택하니 캐시 미스가 발생한 것 으로 추측하고 있습니다.

화려한 애니메이션

Pixi의 도입을 결정하게된 가장 큰 이유가 아닐까 싶습니다.
다음 움짤은 Snack Game의 애니메이션 인데요.
notion image
굉장히 캐주얼한 게임이라 이정도면 충분한가 싶지만 개인적인 생각으론 매우 아쉬웠습니다.
그렇다고 화려한 애니메이션을 위해서 직접 시스템을 만들어야 하는것과, 복잡한 애니메이션을 개발하면서 발생할 성능 문제를 직접 해결해야 하는건 상당히 부담스러웠습니다.
결국은 적절한 도구를 선택해 쉽고 빠르게 개선해보자는 결론을 내렸습니다.
물론 다른 도구를 사용하더라도 성능 문제는 발생할 수 있지만 직접 만드는 것 보다는 나을것으로 예상합니다.

Pixi.js을 선택한 이유

Pixi.js는 게임, 데이터 시각화 등 다양한 부분에서 사용될 수 있는 2D 그래픽을 위한 오픈소스 WebGL 기반 렌더링 시스템 입니다.
2D 그래픽 렌더링을 위한 시스템이라는 말처럼 게임 엔진은 아닙니다.
웹 게임을 위해선 Phaser와 같은 게임 엔진을 사용할 수 있는데요.
이런 게임엔진은 게임을 위한 추상화가 되어 비교적 러닝커브가 적고 게임 개발을 위한 다양한 기능을 제공합니다.
카메라, 충돌 감지와 같은 게임 물리학을 지원하고, Pixi.js는 이런 기능을 직접 구현해야 합니다.
그럼에도 Pixi.js를 선택한 이유는 Snack Game는 가벼운 게임이기에 Phaser가 지원하는 많은 기능이 필요로 하지 않습니다.
맨 처음의 움짤을 보면 아시겠지만 충돌 감지나 카메라 기능이 필요하지 않았습니다.
그리고 Pixi.js는 Phaser와 비교해 가벼운 번들 사이즈를 가지고 있는것도 Snack Game에 적합했습니다.
Phaser
PixiJS
minified된 번들 사이즈
1 MB
478.6 kB
minified 및 GZipped 후 번들 사이즈
274.1 kB
125.4 kB
minified된 Flappy Bird 게임 사이즈
1.02 MB
424 kB
가볍다는 장점과 Snack Game의 특징을 생각해 Pixi.js를 선택했습니다.
또 , Pixi.js는 2D 그래픽 렌더링에 집중한 라이브러리다 보니 렌더링 성능 하나는 👍 이라고 합니다.

Pixi.js의 생태계

Pixi.js는 다양한 기능을 추가적으로 지원하는 여러 플러그인들을 제공합니다.
Snack Game에서 사용한 플러그인만 정리해 보겠습니다.

Pixi/UI

Pixi/UI는 Canvas 상에 CheckBox, Button, Input 등 UI 요소들을 지원합니다.
onHover, onPress 등 이벤트들을 쉽게 사용할 수 있게 UI 동작과 관련된 메서드를 제공합니다.
notion image

Pixi/assetpack

정말 편리하고 유용한 플러그인 입니다.
원본 assets 폴더 기반으로 이미지와 오디오를 최적화 하고 이미지들은 sprite 형식으로 자동으로 합쳐줍니다.
원본 assets
원본 assets
최적화 및 sprite 형식으로 합쳐진 모습
최적화 및 sprite 형식으로 합쳐진 모습
하나로 합쳐진 png
하나로 합쳐진 png
 
어플리케이션에서 자동으로 수행하는 플러그인은 아니고 아래처럼 파일을 하나 만들고 assets 변경시 한번씩 실행시켜주면 됩니다.
Pixi/assets 코드
export default { entry: './raw-assets', output: './public/assets/', cache: false, plugins: { webfont: webfont(), compressJpg: compressJpg(), compressPng: compressPng(), audio: audio(), json: json(), texture: pixiTexturePacker({ texturePacker: { removeFileExtension: true, }, }), manifest: pixiManifest({ output: './public/assets/assets-manifest.json', }), }, };

GSAP

Tween 애니메이션을 위한 여러가지 강력한 기능을 지원하는 GSAP입니다.
Pixi.js의 플러그인은 아니며 Javascript 객체 속성도 강력한 easing function으로 제어가 가능하기 때문에 Pixi.js와 궁합이 좋습니다.
다음과 같은 애니메이션도 상당히 쉽게 구현할 수 있는데요.
notion image
타겟 위치와 애니메이션 재생 시간, 사용할 easing function만 대충 제공하면 애니메이션을 쉽게 적용할 수 있습니다.
이런 작업들은 비동기로 작동하기에 위의 움짤처럼 여러 Snack의 애니메이션이 한번에 작동하는걸 볼 수 있습니다.
gsap.to, from, fromTo는 애니메이션 인터페이스를 반환하고 .then 메서드를 지원하기에 await로 동기적인 애니메이션도 쉽게 작성할 수 있습니다.
public async playFlyToTarget(snack: Snack, to: { x: number; y: number }) { const distance = this.getDistance(snack.x, snack.y, to.x, to.y); gsap.killTweensOf(snack); gsap.killTweensOf(snack.scale); const duration = distance * 0.001 + randomRange(0.2, 0.8); gsap.to(snack, { x: to.x, duration: duration, ease: easeJumpToCauldronX, }); gsap.to(snack, { y: to.y, duration: duration, ease: easeJumpToCauldronY, }); await gsap.to(snack.scale, { x: 0.5, y: 0.5, duration: duration, ease: easeJumpToCauldronScale, }); sfx.play('common/sfx-buble.mp3', { volume: 0.3 }); }

Pixi/Sound

오디오 재생을 위한 플러그인 입니다.
오디오 로딩 및 재생, 볼륨 조절, 클립 등 오디오와 관련한 여러 기능을 지원합니다.
오디오 객체로 만들어 관리하면 gsap와 연동해 페이드 인/아웃을 자연스럽게 적용할 수 있습니다.
(gasp는 신이야!)
gsap.to(current, { volume: 0, duration: 1, ease: 'linear' }).then(() => { current.stop(); });

React를 위한 pixi-react 사용 해야할까?

Pixi 플러그인 중 React를 위한 pixi-react 를 지원하는데요. (Snack Game에서는 사용 안함)
PixiJS의 고성능 2D 그래픽 렌더링을 React의 컴포넌트 기반 아키텍처와 결합할 수 있도록 지원합니다.
<Stage width={300} height={300} options={{ backgroundColor: 0xeef1f5 }}> <Container position={[150, 150]}> <Sprite anchor={0.5} x={-75} y={-75} image="/pixi-react/img/bunny.png" /> <Sprite anchor={0.5} x={0} y={0} image="/pixi-react/img/bunny.png" /> <Sprite anchor={0.5} x={75} y={75} image="/pixi-react/img/bunny.png" /> </Container> </Stage>
하지만 게임 개발에 있어서 컴포넌트 기반 아키텍처는 어색한 느낌이 들었습니다.
기존에 canvas api 2D Context로 개발할 때도 class를 통해 객체지향적으로 풀어보았는데,
컴포넌트 기반 아키텍처 보다 객체지항적인 구조가 익숙하기도 하고 게임에 더 적합하다고 생각합니다.
(추상화, 조합, 상속 등 유연한 여러가지 장점으로 인해)
이런 이유로 일반 Pixi.js를 사용하기로 결정했습니다.

Pixi 초기 설정

Pixi.js를 React 에서 사용하기 위해 필요한 설정을 몇가지 해주어야 합니다.
우선적으로 Pixi 어플리케이션을 만들기 위해 Pixi Application을 전역으로 생성해 줍니다.
이 클래스는 렌더러, 애니메이션을 위한 티커 및 루트 컨테이너를 자동으로 생성합니다.
굳이 전역으로 생성할 필요는 없지만 페이지 이동시에도 게임의 상태를 유지하고 매번 Pixi 어플리케이션을 초기화 하는 불필요한 동작을 예방하기 위함입니다.
const app = new Application();

Pixi 초기화

그 다음에 Pixi Application을 초기화 해야합니다.
그런데 이 초기화는 한 번만 해주어야 하는데요. 중복으로 초기화를 하게되면 콘솔에 에러를 뱉습니다.
저는 useEffect에서 초기화를 했습니다.
Pixi Application을 전역에서 생성한것처럼 초기화도 전역에서 하면 되지않나? 라는 의문이 들 수 있는데 아래에서 설명할 Pixi Application Canvas에서 설명해보겠습니다.
useEffect에서 초기화를 진행하기 때문에 게임 페이지가 마운트될 때 마다 Pixi Application을 초기화 하려고 시도하기 때문에 이미 초기화를 했다고 알려주어야 합니다.
저는 전역 상태관리 라이브러리를 사용해 해결했는데요.
다른 페이지를 갔다가 와도 상태가 유지되어야 하기 때문입니다.
const CanvasBase = () => { const [pixiValue, setPixiValue] = useRecoilState(pixiState); useEffect(() => { initCanvas(); }, []) /** pixi 초기화 */ const initCanvas = async () => { // pixi canvas를 초기화 합니다. if (!pixiValue.pixiInit) { await app.init({ resolution: Math.max(window.devicePixelRatio, 2), backgroundColor: 0xffedd5, }); setPixiValue((pre) => ({ ...pre, pixiInit: true, // 초기화 성공 })); } }; return ( <div className={'mx-auto h-full w-full max-w-xl'}></div> ); };

Pixi Application Canvas

Pixi Application이 렌더러를 생성할 때 렌더링할 Canvas요소를 빌드해야 합니다.
Pixi가 그리는 그림을 보려면 이 Canvas 요소를 DOM에 추가해야 하는데요.
React 에서는 Ref로 Dom요소를 가져와 추가할 수 있습니다.
const CanvasBase = () => { const canvasBaseRef = useRef<HTMLDivElement>(null); useEffect(() => { initCanvas(); return () => { app.stop; // CanvasBase 컴포넌트가 언마운트 되면 렌더링을 중지합닉다. // 어플리케이션을 완전히 폐기하는 destroy와는 다릅니다! } }, []) /** pixi 초기화 */ const initCanvas = async () => { // pixi canvas를 초기화 합니다. ... canvasBaseRef.current?.appendChild(app.canvas); }; return ( <div ref={canvasBaseRef} className={'mx-auto h-full w-full max-w-xl'}></div> ); };
컴포넌트 언마운트시 clean-up 함수에서 렌더링을 중지합니다.
만약 다시 게임 페이지로 돌아오면 CanvasBase 컴포넌트가 마운트 되고 다시 Pixi Application Canvas를 DOM에 추가합니다.
Pixi Application은 autoStart 옵션이 기본적으로 True이기 때문에 Canvas를 추가하면 렌더링이 다시 시작됩니다.
Pixi Application을 전역으로 생성했기 때문에 게임의 상태를 유지하면서 페이지를 이동할 수 있습니다!!
앞서 Pixi Application을 전역으로 초기화 하지 않은 이유도 이런 렌더링 제어를 위함인데요.
다른 페이지를 들렸을 때 게임의 렌더링에 계속 진행되고 있다면 불필요한 성능적인 비용을 지불해야하기 때문입니다.
전역에서 초기화를 하더라도 이런 부분을 구현할 수 있지만 useEffect의 clean-up함수를 이용하면 쉽게 사용할 수 있습니다.

게임을 만들기 위해..

앞서 React에서 Pixi를 어떻게 초기화 하고 설정하는지 알아보았는데요.
게임을 만들기 위해서는 여러가지 시스템이 필요로 하고 상당히 복잡할 수 있습니다.
특히 Pixi를 처음 접했는데 게임 시스템까지 직접 만드려고 하면 숨이 턱 막히는데요.
이런 시스템을 처음부터 직접 만드는 것 보다는 아래 레포를 참고하는걸 추천합니다.
open-games
pixijsUpdated Jun 20, 2024
Pixi.js로 게임을 만드는 방법을 배우는 데 사용할 수 있는 게임 모음을 제공하는 레포입니다.
Snack Game도 상당히 도움을 많이 받았고 주석이 상세하게 적혀있어 게임을 위한 시스템들의 작동 방식을 이해하기 쉽습니다.
화면을 보여주고 교체하는 navigation 객체, 객체를 재활용 하기 위한 pool 객체등 굉장히 유용하고 공부가 될 코드가 많습니다.

마무리와

여기까지 Pixi.js는 무엇인지, 장점은 어떤게 있는지, React에서 사용하려면 어떤 방식이 좋은지 알아보았습니다.
기존에 열심히 제작했던 게임 코드를 전부 버리고 새롭게 만들었는데 이게 아깝지 않을정도의 발전이 있었습니다.
성능은 물론이고 원하던 만큼의 풍푸한 애니메이션을 제공할 수 있게되어 정말 만족스럽네요.
이상으로 긴 글 마치겠습니다.