DEV NOTE · JJ

줄이 숨을 쉬게: PixiJS 발광 렌더와 장력 합성음

JJ에서 플레이어가 가장 자주 읽어야 하는 정보는 줄의 장력입니다. 이 숫자가 0.6을 넘으면 게이트를 자를 수 있고, 1.0 이상이 0.5초 이어지면 줄이 끊어집니다. HUD에 막대 하나를 그려 두는 것으로는 부족하다고 보고, 줄 자체의 색과 굵기, 그리고 줄이 내는 소리가 모두 장력을 따라가게 만들었습니다. 그 과정에서 Bloom 때문에 줄이 하얗게 날아가는 문제와, 적응형 음악이 한 번도 들어오지 않는 문제를 만났습니다.

왜 PixiJS였나

ROGA GAME의 다른 2D 게임, 예를 들어 ORBIT RUSH는 바닐라 Canvas 2D로 그립니다. JJ도 첫 버전은 Canvas 2D 렌더로 시작했고, 발광은 화면을 1/4과 1/16로 줄여 흐리게 겹치는 방식으로 흉내 냈습니다. 그런데 이 게임의 비주얼 방향은 "깊은 유리 저장고 안의 두 불빛"이었고, 어두운 무광 공간에서 코어와 줄과 장치의 활성 부위만 빛나야 했습니다. 발광과 후처리를 싸게 쓰려면 GPU가 필요했습니다.

그래서 0.4.0에서 PixiJS v8.22와 pixi-filters 6.1.5를 들였습니다. 둘 다 MIT 라이선스이고, 저장소 규칙대로 CDN 대신 게임 폴더의 vendor 디렉터리에 라이선스 파일과 함께 복사했습니다. 번들 크기는 압축 전 841KB로 작지 않지만, 1년 캐시와 서비스 워커 프리캐시 덕분에 두 번째 방문부터는 바로 뜹니다. 물리 엔진은 쓰지 않았습니다. Matter.js 같은 엔진은 결정성을 보장하지 않아 리플레이와 온라인 동기화에 맞지 않기 때문입니다. WebGL 초기화에 실패하면 원래의 Canvas 2D 렌더가 그대로 동작합니다.

화면은 레이어를 쌓아 만듭니다. 뒤에서부터 성운과 별, 패럴랙스로 움직이는 원경 유리 기둥, 벽과 장치, 줄, 코어, 입자, 가산 혼합 발광 레이어, 후처리, 비네트 순서입니다. 줄이 끊기거나 게이트를 자르면 충격파, 낙하에는 짧은 색수차, 클리어에는 빛줄기 필터가 걸립니다. 품질은 LOW(필터 없음), MID(Bloom만), HIGH(전부)와 AUTO가 있고, AUTO는 처음 3초 동안 프레임레이트를 재서 프리셋을 고릅니다. HIGH에서 CPU 쪽 렌더 시간은 프레임당 중앙값 0.2ms, 95백분위 0.6ms로 측정됐습니다.

줄이 끊어지는 순간 충격파와 섬광이 퍼지는 WebGL 렌더 장면
줄이 끊어지는 순간. 충격파 필터와 섬광, 양 끝이 코어 쪽으로 튕겨 들어간다.

느슨한 줄이 하얗게 날아가던 문제

설계 문서에서 줄은 폭 3픽셀 코어선과 폭 10픽셀 발광선 두 겹으로, 색만 장력에 따라 흰색에서 청록, 마젠타로 바뀌는 계획이었습니다. 막상 가산 혼합과 Bloom을 켜자 느슨한 상태의 흰 줄이 화면에서 가장 밝은 물체가 되어 버렸습니다. 아무 일도 일어나지 않는 상태가 가장 눈에 띄는 셈이었습니다.

두 가지를 고쳤습니다. 첫째, 굵기와 밝기도 장력에 비례하게 했습니다. 코어선은 느슨할 때 0.045칸에 밝기 0.85, 팽팽할 때 0.075칸에 밝기 1.0이고, 발광선은 0.08칸에서 0.24칸까지 굵어집니다. 둘째, Bloom 임계값을 0.25에서 0.4로 올려 흰 줄의 번짐을 줄였습니다. 이제 줄은 느슨할 때 가늘고 차분하다가, 당길수록 굵어지고 색이 바뀌며 빛을 냅니다. 팽팽해지면 줄을 따라 파트너 쪽으로 흐르는 빛 펄스가 생기고, 한계 구간에서는 미세하게 떨립니다.

장력을 듣게 하는 줄 험

소리는 샘플 파일 없이 전부 Web Audio로 합성합니다. 사이트 전체가 "음악과 효과음도 자체 제작"이라고 밝히고 있어서, 효과음 설계 보조 도구로 파라미터를 잡더라도 최종 음은 자체 합성기로 다시 만드는 것이 원칙입니다.

이 게임의 중심 소리는 줄 험(hum)입니다. 톱니파와 사인파 두 오실레이터를 겹치고, 피치는 110Hz × (1 + 1.5 × 장력), 로우패스 필터 컷오프는 180Hz + 2600Hz × 장력입니다. 비브라토의 속도와 깊이도 장력을 따라 커지고, 느슨할 때는 40ms 시정수로 조용히 사라집니다. 실제로 측정해 보면 느슨할 때 게인 0, 팽팽할 때 0.14~0.25(148~200Hz), 한계 구간에서 0.28~0.32(210~229Hz)로 장력에 단조롭게 따라갑니다. 당기기를 누르는 동안에는 장력에 비례해 빨라지는 래칫 소리가 따로 겹칩니다.

두 코어의 목소리도 구분했습니다. EMBER는 근음 A2의 FM 금속 타격음, AZURE는 근음 E3의 유리 종소리(배음 비 1, 2.76, 5.4)입니다. 두 근음이 5도 관계라서 둘이 동시에 점프하거나 착지하면 소리가 화음이 됩니다. 협동이 맞았다는 피드백을 귀로도 받게 하려는 의도입니다.

한 번도 들어오지 않던 아르페지오

음악은 박자가 없습니다. 퍼즐 게임에서 BPM은 플레이어를 재촉하기 때문입니다. 대신 다섯 성부의 드론 패드가 8초 주기로 숨 쉬듯 밝아졌다 어두워지고, 체크포인트를 지날 때마다 Dm9, B♭maj7, Fmaj9, Cadd9 네 코드를 한 단계씩 진행합니다. 협동이 잘 되고 있으면 아르페지오 레이어가, 줄이 한계에 가까우면 리드 톤이 얹힙니다.

0.5.0 당시에는 SYNC 게이지가 아직 없어서, 줄이 팽팽한 시간의 비율을 4초로 평활해 협동 지표로 대신 썼고 0.5를 넘으면 아르페지오가 들어오게 했습니다. 그런데 수제 스테이지 대부분이 7~10초 안에 끝나다 보니 이 값이 최대 0.26에 그쳐 레이어가 한 번도 들어오지 않았습니다. 그래서 올라갈 때는 1.5초, 내려갈 때는 6초로 비대칭 평활을 하고 임계값을 0.35로 낮췄습니다. 그 뒤 새총 스테이지에서 4.1초 만에 아르페지오가 들어오는 것을 확인했습니다. 1.2.0에서 SYNC 게이지가 생긴 뒤로는 대체값 대신 실제 SYNC를 써서 35% 이상이면 아르페지오, RESONANCE에서 리드가 들어옵니다.

SWAP, HARDCORE, PHOTO를 넣은 기준

부가 모드는 기존 시스템을 거의 건드리지 않고 얹을 수 있는 것만 골랐습니다. SWAP은 체크포인트마다 두 사람이 조종하는 코어를 자동으로 맞바꿉니다. 로컬에서는 장치 슬롯을 교환하고, SOLO에서는 조종 중인 슬롯을 바꾸는 것뿐이라 시뮬레이션은 변하지 않습니다. 앵커만 하던 사람이 날아가는 쪽을 해 보게 만드는 것이 목적입니다.

HARDCORE는 체크포인트를 없애고 끊김이나 낙하가 한 번만 나와도 실패로 끝냅니다. 기록과 고스트는 일반 기록과 섞이지 않도록 스테이지 ID 뒤에 별도 표시를 붙여 저장합니다. 슬로우 어시스트는 게임 속도를 0.75배로 늦추고 기록에 ASSIST 표시를 남깁니다.

PHOTO는 일시정지 화면에서 들어가며 HUD를 숨기고 카메라를 자유롭게 움직입니다. 드래그로 이동하고, 휠이나 + / − 로 확대하고, S로 저장합니다. WebGL 렌더에서는 PixiJS의 추출 기능으로 현재 화면을 이미지로 뽑는데, 검증 때 4626×1775 크기의 스냅샷이 저장되는 것을 확인했습니다. 결과 화면의 공유 카드는 별도로 1080×1080 PNG를 캔버스로 그리며, 레벨 미니맵 위에 6스텝마다 기록한 두 코어의 궤적을 겹쳐 그 판의 움직임이 한눈에 보이게 했습니다.

정리

장력이라는 숫자 하나를 색, 굵기, 빛, 피치, 음악 레이어로 동시에 내보내는 것이 JJ 연출의 뼈대입니다. 설계 문서에는 "장력 막대를 끄고도 끊김률 차이가 10%포인트 이내면 줄 험이 제 역할을 한다"는 가설이 있습니다. 이건 아직 실제 플레이테스트로 확인하지 못했고, 실패하면 한계 구간 진입 시 짧은 경고음을 더할 계획입니다. GPU 프레임레이트도 테스트 창의 화면 갱신 제한 때문에 측정하지 못해, 실기기 성능 표를 채우는 일이 남아 있습니다.