우수사례 - Inside World Wide Maze

World Wide Maze는 목표 지점에 도달하기 위해 스마트폰에서 만든 3D 미로를 통과하며 공을 이동하는 게임입니다.

월드 와이드 메이즈

게임에서 HTML5 기능을 다양하게 사용합니다. 예를 들어 DeviceOrientation 이벤트는 스마트폰에서 기울기 데이터를 가져온 다음 WebSocket을 통해 PC로 전송됩니다. 여기서 플레이어는 WebGL웹 작업자가 빌드한 3D 공간을 통해 길을 찾을 수 있습니다.

이 도움말에서는 이러한 기능이 사용되는 방법, 전체 개발 프로세스, 최적화의 핵심 사항에 대해 설명합니다.

DeviceOrientation

DeviceOrientation 이벤트 ()는 스마트폰에서 기울기 데이터를 검색하는 데 사용됩니다. addEventListenerDeviceOrientation 이벤트와 함께 사용되면 DeviceOrientationEvent 객체가 포함된 콜백이 일정한 간격으로 인수로 호출됩니다. 인터벌은 사용하는 기기에 따라 다릅니다. 예를 들어 iOS + Chrome, iOS + Safari에서는 약 1/20초마다 콜백이 호출되지만 Android 4 + Chrome에서는 약 1/10초마다 호출됩니다.

window.addEventListener('deviceorientation', function (e) {
  // do something here..
});

DeviceOrientationEvent 객체에는 각 X, Y, Z 축 (라디안 아님)의 기울기 데이터가 도 단위로 포함됩니다(HTML5Rocks 자세히 알아보기). 그러나 반환 값은 사용되는 기기와 브라우저의 조합에 따라 달라집니다. 실제 반환 값의 범위는 아래 표에 나와 있습니다.

기기 방향입니다.

파란색으로 강조 표시된 위쪽의 값은 W3C 사양에 정의된 값입니다. 녹색으로 표시된 것은 해당 사양과 일치하지만, 빨간색으로 표시된 부분은 다릅니다. 놀랍게도 Android와 Firefox의 조합만이 사양과 일치하는 값을 반환했습니다. 그럼에도 불구하고 구현 측면에서는 자주 발생하는 값을 수용하는 것이 더 합리적입니다. 따라서 World Wide Maze는 iOS 반환 값을 표준으로 사용하고 이에 따라 Android 기기에 맞게 조정합니다.

if android and event.gamma > 180 then event.gamma -= 360

하지만 Nexus 10은 여전히 지원되지 않습니다. Nexus 10은 다른 Android 기기와 동일한 값 범위를 반환하지만 베타 및 감마 값을 역전시키는 버그가 있습니다. 이 문제는 별도로 다루어질 예정입니다. 기본적으로 가로 방향으로 설정되어 있을 수 있습니다.

이 예에서 알 수 있듯이 실제 기기와 관련된 API에 지정된 사양이 있더라도 반환된 값이 해당 사양과 일치한다는 보장은 없습니다. 따라서 모든 예상 기기에서 테스트하는 것이 중요합니다. 또한 예기치 않은 값이 입력될 수 있으며 이에 대한 해결 방법을 찾아야 합니다. World Wide Maze에서는 최초 플레이어에게 튜토리얼의 1단계에서 기기를 보정하라는 메시지를 표시하지만 예기치 않은 기울기 값을 받으면 0 위치로 올바르게 보정되지 않습니다. 따라서 내부 시간 제한이 있으며 제한 시간 내에 보정할 수 없는 경우 플레이어에게 키보드 컨트롤로 전환하라는 메시지가 표시됩니다.

WebSocket

World Wide Maze에서는 스마트폰과 PC가 WebSocket을 통해 연결됩니다. 더 정확하게는 두 시스템 간의 릴레이 서버(예: 스마트폰에서 서버 간)를 통해 연결됩니다. 이는 WebSocket이 브라우저를 서로 직접 연결하는 기능이 없기 때문입니다. WebRTC 데이터 채널을 사용하면 P2P 연결이 가능해지고 릴레이 서버가 필요하지 않지만 구현 시점에는 Chrome Canary 및 Firefox Nightly에서만 이 방법을 사용할 수 있었습니다.

저는 연결 시간이 초과되거나 연결이 끊길 때 다시 연결하는 기능이 포함된 Socket.IO (v0.9.11)라는 라이브러리를 사용하여 구현하기로 했습니다. 이 NodeJS와 Socket.IO 조합은 여러 WebSocket 구현 테스트에서 최고의 서버 측 성능을 보여주므로 이를 NodeJS와 함께 사용했습니다.

숫자로 페어링

  1. PC가 서버에 연결됩니다.
  2. 서버는 임의로 생성된 번호를 PC에 제공하고 숫자와 PC의 조합을 기억합니다.
  3. 휴대기기에서 번호를 지정하고 서버에 연결합니다.
  4. 지정된 숫자가 연결된 PC의 번호와 동일한 경우 휴대기기가 해당 PC와 페어링되어 있는 것입니다.
  5. 지정된 PC가 없으면 오류가 발생합니다.
  6. 휴대기기에서 들어오는 데이터는 페어링된 PC로 전송되며 그 반대의 경우도 마찬가지입니다.

대신 휴대기기에서 처음 연결할 수도 있습니다. 이 경우 기기는 반전됩니다.

탭 동기화

Chrome만의 탭 동기화 기능을 사용하면 페어링 과정이 훨씬 더 쉬워집니다. 이를 통해 PC에서 열려 있는 페이지를 휴대기기에서 쉽게 열 수 있으며 그 반대의 경우도 마찬가지입니다. PC는 서버에서 발급한 연결 번호를 가져와 history.replaceState를 사용하여 페이지의 URL에 추가합니다.

history.replaceState(null, null, '/maze/' + connectionNumber)

탭 동기화를 사용하도록 설정한 경우 몇 초 후에 URL이 동기화되고 휴대기기에서 동일한 페이지를 열 수 있습니다. 휴대기기가 열린 페이지의 URL을 확인하고 숫자가 추가되면 즉시 연결됩니다. 따라서 숫자를 직접 입력하거나 카메라로 QR 코드를 스캔하지 않아도 됩니다.

지연 시간

릴레이 서버가 미국에 있으므로 일본에서 액세스하면 스마트폰의 기울기 데이터가 PC에 도달하기까지 약 200ms의 지연이 발생합니다. 개발 중에 사용된 로컬 환경에 비해 응답 시간이 확실히 느렸지만 저역 통과 필터 (예: EMA 사용)를 삽입하면 눈에 거슬리지 않는 수준으로 개선되었습니다. (실질적으로 프레젠테이션 목적으로도 로우 패스 필터가 필요했습니다. 기울기 센서의 반환 값에는 상당한 양의 노이즈가 포함되어 있으며, 그 값을 화면에 적용하면 상당한 흔들림이 발생했습니다.) 분명히 속도가 느려진 점프에서는 작동하지 않았지만, 이 문제를 해결하기 위해 할 수 있는 것이 없습니다.

처음부터 지연 문제가 예상되었기 때문에 전 세계의 릴레이 서버를 설정하여 클라이언트가 가장 가까운 가용 네트워크에 연결할 수 있도록 하여 지연 시간을 최소화하는 방안을 고려했습니다. 하지만 당시 미국에만 있던 Google Compute Engine (GCE)을 사용할 수 없었죠.

Nagle 알고리즘 문제

일반적으로 Nagle 알고리즘은 TCP 수준에서 버퍼링하여 효율적인 통신을 위해 운영체제에 통합되어 있지만, 이 알고리즘이 사용 설정된 동안에는 실시간으로 데이터를 전송할 수 없습니다. (특히 TCP 지연 확인과 결합하는 경우에 해당합니다. 지연된 ACK가 없는 경우에도 서버가 해외에 있는 등의 요인으로 인해 ACK이 어느 정도 지연되는 경우에도 동일한 문제가 발생합니다.)

Nagle 지연 시간 문제는 Nagle을 사용 중지하는 TCP_NODELAY 옵션이 포함된 Android용 Chrome의 WebSocket에서는 발생하지 않았지만, iOS용 Chrome에서 사용되는 WebKit WebSocket에서는 이 옵션이 사용 설정되지 않았습니다. (동일한 WebKit를 사용하는 Safari도 이 문제가 발생했습니다. 이 문제는 Google을 통해 Apple에 신고되었으며 WebKit의 개발 버전에서 해결된 것으로 보입니다.

이 문제가 발생하면 100ms마다 전송되는 기울기 데이터가 500ms마다 PC에 도달하는 청크로 결합됩니다. 이러한 조건에서는 게임이 작동할 수 없으므로 서버 측에서 짧은 간격 (약 50ms마다)으로 데이터를 전송하도록 하여 이러한 지연 시간을 방지합니다. 짧은 간격으로 ACK를 수신하면 Nagle 알고리즘이 데이터를 전송해도 괜찮다고 생각하게 됩니다.

Nagle 알고리즘 1

위 그래프는 실제 데이터의 수신 간격을 그래프로 보여줍니다. 패킷 간의 시간 간격을 나타냅니다. 녹색은 출력 간격을 나타내고 빨간색은 입력 간격을 나타냅니다. 최솟값은 54ms, 최댓값은 158ms, 중간은 100ms에 가깝습니다. 여기 일본에 있는 릴레이 서버가 있는 iPhone을 사용했습니다. 출력과 입력 모두 약 100ms이고 작동도 원활합니다.

Nagle 알고리즘 2

반대로 이 그래프는 미국에서 서버를 사용한 결과를 보여줍니다. 녹색 출력 간격은 100ms에서 일정하게 유지되지만, 입력 간격은 0ms에서 최저 500ms 사이에서 변동하며, PC가 데이터를 청크 단위로 수신하고 있음을 나타냅니다.

ALT_TEXT_HERE

마지막으로 이 그래프는 서버가 자리표시자 데이터를 전송하도록 하여 지연을 방지한 결과를 보여줍니다. 일본어 서버를 사용하는 것만큼 성능이 좋지는 않지만 입력 간격이 약 100ms에서 비교적 안정적으로 유지되는 것이 분명합니다.

버그?

Android 4 (ICS)의 기본 브라우저에 WebSocket API가 있음에도 불구하고 연결할 수 없어 Socket.IO connect_failed 이벤트가 발생합니다. 내부적으로 시간이 초과되어 서버 측에서도 연결을 확인할 수 없습니다. (WebSocket만으로는 테스트하지 않았으므로 Socket.IO 문제일 수 있습니다.)

릴레이 서버 확장

릴레이 서버의 역할은 그렇게 복잡하지 않으므로 동일한 PC와 휴대기기가 항상 동일한 서버에 연결되어 있는 한 서버 수를 확장하고 늘리는 것이 어렵지 않습니다.

물리학

게임 내 볼의 움직임 (내리막, 지면과 충돌, 벽과 충돌, 아이템 수집 등)은 모두 3D 물리 시뮬레이터를 통해 이루어집니다. 저는 Emscripten을 사용하여 널리 사용되는 Bullet 물리학 엔진의 포트인 Ammo.jsPhysijs와 함께 사용하여 '웹 작업자'로 활용했습니다.

웹 작업자

Web Workers는 별도의 스레드에서 JavaScript를 실행하기 위한 API입니다. 웹 작업자로 실행된 JavaScript는 원래 호출한 스레드와 별도의 스레드로 실행되므로 페이지의 응답성을 유지하면서 과도한 작업을 수행할 수 있습니다. Physijs는 Web Workers를 효율적으로 사용하여 일반적으로 집약적인 3D 물리 엔진이 원활하게 실행되도록 지원합니다. World Wide Maze는 물리 엔진과 WebGL 이미지 렌더링을 완전히 다른 프레임 속도로 처리하므로, WebGL 렌더링 부하가 높아 저사양 시스템에서 프레임 속도가 떨어지더라도 물리 엔진 자체는 60fps를 거의 유지하며 게임 컨트롤을 방해하지 않습니다.

FPS

이 이미지는 Lenovo G570의 최종 프레임 속도를 보여줍니다. 위쪽 상자에는 WebGL (이미지 렌더링)의 프레임 속도가 표시되고 아래쪽 상자에는 물리 엔진의 프레임 속도가 표시됩니다. GPU는 통합된 Intel HD Graphics 3000 칩이므로 이미지 렌더링 프레임 속도가 예상한 60fps를 달성하지 못했습니다. 하지만 물리 엔진이 예상 프레임 속도를 달성했기 때문에 게임플레이도 고성능 컴퓨터에서의 성능과 크게 다르지 않습니다.

활성 웹 작업자가 있는 스레드에는 콘솔 객체가 없으므로 디버깅 로그를 생성하려면 postMessage를 통해 기본 스레드로 데이터를 전송해야 합니다. console4Worker를 사용하면 Worker에 콘솔 객체와 동등한 객체가 생성되므로 디버깅 프로세스가 훨씬 더 쉬워집니다.

서비스 워커

최신 버전의 Chrome에서는 Web Workers를 시작할 때 중단점을 설정할 수 있으며, 이는 디버깅에도 유용합니다. 개발자 도구의 '작업자' 패널에서 확인할 수 있습니다.

성능

다각형 수가 많은 스테이지가 다각형 100,000개를 초과하는 경우도 있지만, 완전히 Physijs.ConcaveMesh (Bolet의 경우 btBvhTriangleMeshShape)로 생성된 경우에도 성능은 특별히 저하되지 않았습니다.

초기에는 충돌 감지가 필요한 객체의 수가 증가함에 따라 프레임 속도가 감소했지만 Physijs에서 불필요한 처리를 제거하여 성능이 향상되었습니다. 이 개선사항은 원본 Physijs의 포크에서 적용되었습니다.

유령 물체

충돌 감지 기능이 있지만 충돌에 영향을 주지 않아 다른 객체에는 영향을 미치지 않는 객체를 글머리기호에서는 '고스트 객체'라고 합니다. Physijs는 공식적으로 고스트 객체를 지원하지 않지만 Physijs.Mesh를 생성한 후 플래그를 조정하여 고스트 객체를 만들 수 있습니다. World Wide Maze는 아이템과 목표 지점의 충돌 감지에 고스트 객체를 사용합니다.

hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)

collision_flags의 경우 1은 CF_STATIC_OBJECT, 4는 CF_NO_CONTACT_RESPONSE입니다. 자세한 내용은 Bullet 포럼, Stack Overflow 또는 Bullet 문서를 참조하세요. Physijs는 Ammo.js용 래퍼이고 Ammo.js는 기본적으로 Bullet과 동일하므로 Bullet에서 할 수 있는 대부분의 작업은 Physijs에서도 실행할 수 있습니다.

Firefox 18 문제

Firefox 버전 17에서 18로 업데이트되면서 웹 작업자가 데이터를 교환하는 방식이 바뀌었고 결과적으로 Physijs는 작동이 중단되었습니다. 이 문제는 GitHub에 보고되었으며 며칠 후 해결되었습니다. 이러한 오픈소스 효율성은 저에게 깊은 인상을 주었지만, 이 사건을 통해 World Wide Maze가 어떻게 여러 가지 다른 오픈소스 프레임워크로 구성되어 있는지 다시 한번 떠올렸습니다. 이 글은 여러분의 의견을 기다리고 있습니다.

asm.js

World Wide Maze와 직접적인 관련이 없지만, Ammo.js는 이미 Mozilla에서 최근에 발표한 asm.js를 지원합니다. asm.js는 기본적으로 Emscripten에서 생성된 JavaScript의 속도를 높이기 위해 만들어졌고, Emscripten을 만든 사람도 Ammo.js의 개발자이기도 하기 때문입니다. Chrome에서 asm.js도 지원하는 경우 물리 엔진의 컴퓨팅 로드가 크게 줄어듭니다. Firefox Nightly에서 테스트했을 때 속도가 현저히 빨랐습니다. C/C++에서 더 빠른 속도를 요구하는 섹션을 작성한 다음 Emscripten을 사용하여 JavaScript로 포팅하는 것이 가장 좋을 수도 있습니다.

WebGL

WebGL 구현에는 가장 활발하게 개발된 라이브러리인 three.js (r53)를 사용했습니다. 버전 57은 개발의 후반 단계에 이미 출시되었지만, API가 대대적으로 변경되었기 때문에 원래 버전을 그대로 유지했습니다.

발광 효과

공의 코어와 항목에 추가되는 발광 효과는 'Kawase 메서드 MGF'의 간단한 버전을 사용하여 구현됩니다. 그러나 가와세 방식을 사용하면 모든 밝은 영역이 꽃이 피지만 World Wide Maze는 발광해야 하는 영역에 대해 별도의 렌더링 타겟을 만듭니다. 이는 웹사이트 스크린샷을 스테이지 텍스처에 사용해야 하며 밝은 영역을 모두 추출하기만 해도 웹사이트 전체가 빛나기 때문입니다(예: 흰색 배경의 경우). HDR로 모든 것을 처리하는 것도 고려했지만 이번에는 구현이 꽤 복잡해질 것이기 때문에 이번에는 반대하기로 했습니다.

발광

왼쪽 상단에는 발광 영역이 개별적으로 렌더링된 후 블러가 적용되는 첫 번째 패스가 나와 있습니다. 오른쪽 하단에는 이미지 크기를 50% 줄인 다음 블러가 적용된 두 번째 패스가 나와 있습니다. 오른쪽 상단에는 이미지가 다시 50% 감소한 후 블러 처리된 세 번째 패스가 표시됩니다. 그런 다음 세 개를 오버레이하여 왼쪽 하단에 표시되는 최종 합성 이미지를 만들었습니다. 블러의 경우 third.js에 포함된 VerticalBlurShaderHorizontalBlurShader를 사용했으므로 추가로 최적화할 여지가 있습니다.

반사 볼

공이 반사된 모습은 third.js의 샘플을 기반으로 합니다. 모든 경로는 공의 위치에서 렌더링되며 환경 지도로 사용됩니다. 환경 맵은 공이 움직일 때마다 업데이트해야 하지만, 60fps로 업데이트하는 데는 집약적이므로 대신 3프레임마다 업데이트됩니다. 결과는 모든 프레임을 업데이트하는 것만큼 매끄럽지는 않지만 달리 지적되지 않는 한 차이는 거의 감지할 수 없습니다.

셰이더, 셰이더, 셰이더...

WebGL에는 모든 렌더링에 셰이더 (꼭짓점 셰이더, 프래그먼트 셰이더)가 필요합니다. 세.js에 포함된 셰이더는 이미 다양한 효과를 허용하지만, 더 정교한 셰이딩과 최적화를 위해서는 직접 작성하는 것이 불가피합니다. World Wide Maze는 물리 엔진으로 CPU를 바쁜 상태로 유지해 주므로, (자바스크립트를 통해) CPU를 처리하는 것이 더 쉬웠을 때도 가능한 한 많은 음영 언어 (GLSL)를 작성하는 방식으로 GPU를 활용하려고 했습니다. 파도 효과는 목표 지점의 불꽃놀이와 공이 나타날 때 사용되는 메시 효과처럼 셰이더에 의존합니다.

셰이더 볼

위의 예는 공이 나타날 때 사용되는 메시 효과 테스트에서 가져온 것입니다. 왼쪽 그림은 게임에서 사용되는 320개의 다각형으로 구성되어 있습니다. 가운데 그림은 약 5,000개의 다각형을 사용하고, 오른쪽 그림은 약 300,000개의 다각형을 사용합니다. 이렇게 많은 다각형이 있더라도 셰이더로 처리하면 30fps의 안정적인 프레임 속도를 유지할 수 있습니다.

셰이더 메시

스테이지 전체에 흩어져 있는 작은 항목은 모두 하나의 메시에 통합되며 개별 이동은 각 다각형 팁을 이동하는 셰이더에 의존합니다. 많은 양의 객체가 있으면 성능이 저하되는지 확인하기 위한 테스트입니다. 약 5,000개의 객체가 여기에 배치되며 약 20,000개의 다각형으로 구성됩니다. 성능 저하가 전혀 없었습니다.

poly2tri

단계는 서버로부터 수신한 개요 정보를 기반으로 형성되고, JavaScript에 의해 폴리곤화됩니다. 이 프로세스의 핵심 부분인 삼각 측량은 third.js에 의해 제대로 구현되지 않으며 보통 실패합니다. 그래서 poly2tri라는 다른 삼각 측량 라이브러리를 직접 통합하기로 했습니다. 3.js 코드가 이전에도 동일한 작업을 시도한 것으로 나타났으며, 코드의 일부를 주석 처리하는 것만으로도 사용할 수 있었습니다. 그 결과 오류가 크게 줄어 플레이할 수 있는 스테이지가 더 많아졌습니다. 가끔 오류가 계속 발생하고, 어떤 이유에서든 poly2tri가 알림을 발행하여 오류를 처리하므로 예외를 발생시키도록 수정했습니다.

poly2tri

위의 예에서는 파란색 윤곽선이 삼각형으로 표시되고 빨간색 다각형이 생성되는 방식을 보여줍니다.

비등방성 필터링

표준 등방성 MIP 매핑은 가로축과 세로축의 이미지 크기를 줄이기 때문에 사각에서 다각형을 보면 World Wide Maze 스테이지의 맨 끝의 텍스처가 가로로 긴 저해상도 텍스처처럼 보입니다. 이 위키백과 페이지의 오른쪽 상단에 있는 이미지가 이에 대한 좋은 예입니다. 실제로는 더 많은 가로 해상도가 필요하며 WebGL (OpenGL)은 비등방성 필터링이라는 메서드를 사용하여 확인합니다. 3.js에서 THREE.Texture.anisotropy에 1보다 큰 값을 설정하면 비등방성 필터링이 사용 설정됩니다. 하지만 이 기능은 확장 프로그램이며 일부 GPU에서는 지원되지 않을 수도 있습니다.

최적화

WebGL 권장사항 도움말에서도 언급했듯이 WebGL (OpenGL) 성능을 개선하는 가장 중요한 방법은 그리기 호출을 최소화하는 것입니다. World Wide Maze 초기 개발 중에는 게임 내 모든 섬, 다리, 가드레일이 별개의 물건이었습니다. 그로 인해 2,000건이 넘는 그리기 호출이 발생하여 복잡한 단계를 다루기가 어려워졌습니다. 그러나 동일한 유형의 객체를 모두 하나의 메시에 패킹하자 그리기 호출이 50여 개로 줄어들어 성능이 크게 향상되었습니다.

추가 최적화를 위해 Chrome 추적 기능을 사용했습니다. Chrome의 개발자 도구에 포함된 프로파일러는 전반적인 메서드 처리 시간을 어느 정도 파악할 수 있지만 추적을 통해 각 부분에 소요되는 시간을 정확히 1/1000초로 확인할 수 있습니다. 추적을 사용하는 방법에 대한 자세한 내용은 이 도움말을 참고하세요.

최적화

위는 공의 반사에 대한 환경 맵을 만든 트레이스 결과입니다. third.js에서 관련성이 있어 보이는 위치에 console.timeconsole.timeEnd를 삽입하면 다음과 같은 그래프가 표시됩니다. 시간은 왼쪽에서 오른쪽으로 흐르며 각 레이어는 호출 스택과 같습니다. console.time 내에 console.time을 중첩하면 추가 측정이 가능합니다. 상단 그래프는 사전 최적화이고 하단 그래프는 사후 최적화입니다. 상단 그래프에서 볼 수 있듯이 사전 최적화 중에 0~5의 각 렌더에 대해 updateMatrix (단어는 잘림)이 호출되었습니다. 하지만 객체의 위치나 방향이 변경될 때만 이 프로세스가 필요하므로 한 번만 호출되도록 수정했습니다.

추적 프로세스 자체가 자연스럽게 리소스를 차지하므로 console.time를 과도하게 삽입하면 실제 성능과 크게 다르기 때문에 최적화할 영역을 정확히 찾아내기가 어려워집니다.

성능 조정자

인터넷의 특성상 게임은 사양이 매우 다양한 시스템에서 플레이될 가능성이 높습니다. 2월 초에 출시된 오즈로 가는 길IFLAutomaticPerformanceAdjust라는 클래스를 사용하여 프레임 속도의 변동에 따라 효과를 축소하여 원활한 재생을 보장합니다. World Wide Maze는 동일한 IFLAutomaticPerformanceAdjust 클래스를 기반으로 빌드되며 게임플레이를 최대한 원활하게 만들기 위해 다음과 같은 순서로 효과를 축소합니다.

  1. 프레임 속도가 45fps 아래로 떨어지면 환경 지도의 업데이트가 중지됩니다.
  2. 그래도 40fps 아래로 떨어지면 렌더링 해상도가 70% (표면 비율의 50%)로 줄어듭니다.
  3. 그래도 40fps 아래로 떨어지면 FXAA (앤티앨리어싱)가 제거됩니다.
  4. 그래도 30fps 미만으로 떨어지면 발광 효과가 제거됩니다.

메모리 누수

third.js를 사용하면 객체를 깔끔하게 제거하는 것이 번거롭습니다. 하지만 그대로 두면 메모리 누수가 발생할 것이 분명해 아래의 방법을 고안했습니다. @rendererTHREE.WebGLRenderer를 나타냅니다. (3.js의 최신 버전에서는 약간 다른 할당 해제 메서드를 사용하므로 이 메서드는 현재와 같이 작동하지 않을 수 있습니다.)

destructObjects: (object) =>
  switch true
    when object instanceof THREE.Object3D
      @destructObjects(child) for child in object.children
      object.parent?.remove(object)
      object.deallocate()
      object.geometry?.deallocate()
      @renderer.deallocateObject(object)
      object.destruct?(this)

    when object instanceof THREE.Material
      object.deallocate()
      @renderer.deallocateMaterial(object)

    when object instanceof THREE.Texture
      object.deallocate()
      @renderer.deallocateTexture(object)

    when object instanceof THREE.EffectComposer
      @destructObjects(object.copyPass.material)
      object.passes.forEach (pass) =>
        @destructObjects(pass.material) if pass.material
        @renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
        @renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
        @renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2

HTML

개인적으로 WebGL 앱의 가장 좋은 점은 HTML로 페이지 레이아웃을 디자인할 수 있다는 것입니다. Flash나 OpenGL (OpenFrameworks)에서 점수 또는 텍스트 디스플레이와 같은 2D 인터페이스를 빌드하는 것은 쉬운 일이 아닙니다. Flash에는 적어도 IDE가 있지만 openFrameworks에 익숙하지 않으면 어렵습니다. Cocos2D와 같은 것을 사용하면 더 쉬울 수 있습니다. 반면, HTML을 사용하면 웹사이트를 구축할 때처럼 CSS로 모든 프런트엔드 디자인 측면을 정밀하게 제어할 수 있습니다. 입자가 로고에 압축되는 것과 같은 복잡한 효과는 불가능하지만, CSS 변환 기능 내에서 일부 3D 효과는 가능합니다. World Wide Maze의 'GOAL' 및 'TIME IS UP' 텍스트 효과는 CSS 전환의 배율을 사용하여 애니메이션으로 표현되었습니다 (대중교통으로 구현됨). (분명히 배경 그라데이션은 WebGL을 사용합니다.)

게임의 각 페이지 (제목, 결과, 순위 등)에는 자체 HTML 파일이 있으며 템플릿으로 로드되면 적절한 시점에 적절한 값으로 $(document.body).append()가 호출됩니다. 한 가지 문제는 추가 전에 마우스 및 키보드 이벤트를 설정할 수 없어서 추가 전에 el.click (e) -> console.log(e)를 시도할 수 없다는 것이었습니다.

국제화(i18n)

HTML 작업도 영어 버전을 만들 때도 편리했습니다. 저는 다국어화를 위해 웹 i18n 라이브러리인 i18next를 사용하기로 했고, 이를 수정하지 않아도 그대로 사용할 수 있었죠.

Google Docs 스프레드시트에서 게임 내 텍스트를 편집하고 번역했습니다. i18next에는 JSON 파일이 필요하므로 스프레드시트를 TSV로 내보낸 다음 맞춤 변환기로 변환했습니다. 출시 직전에 많은 업데이트를 수행했기 때문에 Google Docs도구 스프레드시트에서 내보내기 프로세스를 자동화하면 훨씬 수월했을 것입니다.

페이지가 HTML로 제작되었기 때문에 Chrome의 자동 번역 기능도 정상적으로 작동합니다. 그러나 경우에 따라 언어를 올바르게 감지하지 못하고 완전히 다른 언어 (예: 현재 이 기능은 사용 중지되어 있습니다. (메타 태그를 사용하여 사용 중지할 수 있습니다.)

RequireJS

JavaScript 모듈 시스템으로 RequireJS를 선택했습니다. 게임의 소스 코드 10,000줄은 약 60개의 클래스 (커피 파일)로 나뉘고 개별 js 파일로 컴파일됩니다. RequireJS는 종속 항목에 따라 이러한 개별 파일을 적절한 순서로 로드합니다.

define ->
  class Hoge
    hogeMethod: ->

위에 정의된 클래스 (hoge.coffee)는 다음과 같이 사용할 수 있습니다.

define ['hoge'], (Hoge) ->
  class Moge
    constructor: ->
      @hoge = new Hoge()
      @hoge.hogeMethod()

hoge.js가 작동하려면 moge.js보다 먼저 hoge.js를 로드해야 하며, 'hoge'가 'define'의 첫 번째 인수로 지정되므로 hoge.js가 항상 먼저 로드됩니다 (hoge.js가 로드가 완료되면 다시 호출됨). 이 메커니즘을 AMD라고 하며 AMD를 지원하는 모든 서드 파티 라이브러리를 동일한 종류의 콜백에 사용할 수 있습니다. 그렇지 않은 코드 (예: third.js)도 종속 항목을 미리 지정하는 한 비슷하게 작동합니다.

이는 AS3 가져오기와 유사하므로 이상하게 보이지는 않습니다. 종속된 파일이 많아진 경우 이 방법을 사용할 수 있습니다.

r.js

RequireJS에는 r.js라는 옵티마이저가 포함되어 있습니다. 그러면 모든 종속된 js 파일이 있는 기본 js가 하나로 묶인 다음 UglifyJS (또는 클로저 컴파일러)를 사용하여 축소됩니다. 따라서 브라우저에서 로드해야 하는 파일 수와 총 데이터 양이 줄어듭니다. World Wide Maze의 총 자바스크립트 파일 크기는 약 2MB이며 r.js 최적화를 통해 약 1MB로 줄일 수 있습니다. 게임이 gzip을 사용하여 배포할 수 있다면 250KB로 더 줄어듭니다. (GAE에는 1MB 이상의 gzip 파일을 전송할 수 없는 문제가 있으므로 게임은 현재 1MB의 일반 텍스트로 압축되지 않은 상태로 배포됩니다.)

스테이지 제작 도구

단계 데이터는 다음과 같이 생성되며 미국의 GCE 서버에서 전적으로 수행됩니다.

  1. 스테이지로 변환할 웹사이트의 URL은 WebSocket을 통해 전송됩니다.
  2. PhantomJS가 스크린샷을 찍으면 div 및 img 태그 위치가 검색되어 JSON 형식으로 출력됩니다.
  3. 2단계의 스크린샷과 HTML 요소의 위치 지정 데이터에 기반한 맞춤 C++ (OpenCV, Boost) 프로그램은 불필요한 영역을 삭제하고, 섬을 만들고, 섬을 다리로 연결하고, 가드레일 및 항목 위치를 계산하고, 목표를 설정하는 등의 작업을 수행합니다. 결과는 JSON 형식으로 출력되고 브라우저로 반환됩니다.

PhantomJS

PhantomJS는 화면이 필요하지 않은 브라우저입니다. 창을 열지 않고도 웹페이지를 로드할 수 있으므로 자동 테스트에서 사용하거나 서버 측에서 스크린샷을 캡처할 수 있습니다. 브라우저 엔진은 Chrome과 Safari에서 사용하는 것과 동일한 WebKit이므로 레이아웃과 JavaScript 실행 결과도 표준 브라우저의 결과와 거의 동일합니다.

PhantomJS를 사용하면 자바스크립트 또는 CoffeeScript를 사용하여 실행하려는 프로세스를 작성합니다. 이 샘플에서와 같이 스크린샷을 캡처하는 방법은 매우 간단합니다. Linux 서버 (CentOS)에서 작업 중이었는데 일본어 (M+ FontS)를 표시할 글꼴을 설치해야 했습니다. 그렇더라도 글꼴 렌더링이 Windows나 Mac OS와 다르게 처리되므로 같은 글꼴이 다른 컴퓨터에서는 다르게 보일 수 있습니다 (차이점은 미미합니다).

img 및 div 태그 위치 검색은 기본적으로 표준 페이지에서와 같은 방식으로 처리됩니다. jQuery는 문제없이 사용할 수도 있습니다.

stage_builder

처음에는 DOM 기반 접근 방식을 더 많이 사용하여 스테이지 (Firefox 3D Inspector와 유사)를 생성해 보고 PhantomJS에서 DOM 분석 등을 시도해 보았습니다. 결국 이미지 처리 방식을 정했습니다. 이를 위해 저는 “stage_builder”라는 OpenCV 및 Boost를 사용하는 C++ 프로그램을 작성했습니다. 이 메서드는 다음을 수행합니다.

  1. 스크린샷 및 JSON 파일을 로드합니다.
  2. 이미지와 텍스트를 '섬'으로 변환합니다.
  3. 섬을 연결하기 위한 다리를 만듭니다.
  4. 미로를 만들기 위해 불필요한 다리를 제거합니다.
  5. 큰 항목을 배치합니다.
  6. 작은 항목을 배치합니다.
  7. 가드레일을 배치합니다.
  8. 위치 데이터를 JSON 형식으로 출력합니다.

각 단계는 아래에 자세히 설명되어 있습니다.

스크린샷 및 JSON 파일 로드

일반적인 cv::imread를 사용하여 스크린샷을 로드합니다. JSON 파일에 대해 여러 라이브러리를 테스트했지만 picojson이 가장 사용하기 편한 것 같습니다.

이미지와 텍스트를 '섬'으로 변환하기

스테이지 빌드

위는 aid-dcc.com 뉴스 섹션의 스크린샷입니다 (실제 크기를 보려면 클릭). 이미지와 텍스트 요소는 섬으로 변환해야 합니다. 이러한 섹션을 분리하려면 흰색 배경 색상, 즉 스크린샷에서 가장 많이 사용되는 색상을 삭제해야 합니다. 이 작업이 완료되면 다음과 같이 표시됩니다.

스테이지 빌드

흰색 부분이 잠재적인 섬입니다.

텍스트가 너무 섬세하고 선명하기 때문에 cv::dilate, cv::GaussianBlur, cv::threshold를 사용해 텍스트를 굵게 표시합니다. 이미지 콘텐츠도 누락되었으므로 PhantomJS의 img 태그 데이터 출력에 따라 해당 영역을 흰색으로 채웁니다. 결과 이미지는 다음과 같습니다.

스테이지 빌드

이제 텍스트가 적절한 덩어리를 형성하고 각 이미지가 적절한 섬이 됩니다.

섬을 연결하기 위한 다리 건설

섬이 준비되면 다리로 연결됩니다. 각 섬은 왼쪽, 오른쪽, 위, 아래에 인접한 섬을 찾은 다음 다리를 가장 가까운 섬의 가장 가까운 지점에 연결합니다. 그 결과 다음과 같은 결과가 표시됩니다.

스테이지 빌드

미로를 만들기 위해 불필요한 다리 없애기

모든 브리지를 유지하면 스테이지가 너무 쉽게 탐색할 수 있으므로 미로를 만들기 위해 일부를 제거해야 합니다. 출발 지점으로 섬 한 개 (예: 왼쪽 상단에 있는 섬)가 선택되고 해당 섬에 연결되는 다리 한 개 (임의로 선택됨)가 모두 삭제됩니다. 그런 다음 남은 다리로 연결된 다음 섬에도 같은 일이 일어납니다. 경로가 막다른 골목에 도달하거나 이전에 방문한 섬으로 돌아오면 새로운 섬에 접근할 수 있는 지점으로 되돌아갑니다. 모든 섬이 이런 식으로 처리되면 미로가 완성됩니다.

스테이지 빌드

큰 항목 배치

섬 가장자리에서 가장 먼 지점부터 선택하여 섬의 크기에 따라 하나 이상의 큰 아이템이 각 섬에 배치됩니다. 간단하지는 않지만 아래에 빨간색으로 이러한 내용이 표시되어 있습니다.

스테이지 빌드

가능한 모든 지점 중에서 왼쪽 상단에 있는 지점이 시작 지점 (빨간색 원)으로 설정되고, 오른쪽 하단에 있는 지점이 목표 (녹색 원)로 설정되며, 나머지 지점 중 최대 6개가 큰 항목 게재위치 (보라색 원)로 선택됩니다.

작은 항목 배치

스테이지 빌드

섬 가장자리에서 설정된 거리에서 적절한 수의 작은 물품이 선을 따라 배치됩니다. 위의 이미지 (aid-dcc.com의 이미지가 아님)에서는 투사된 광고배치 선이 회색 오프셋으로 표시되며 섬의 가장자리에서 일정한 간격으로 배치되어 있습니다. 빨간색 점은 작은 항목이 놓인 위치를 나타냅니다. 이 이미지는 개발 중 버전에서 가져온 것이므로 항목은 직선으로 배치되어 있지만 최종 버전에서는 회색 선의 양쪽에 항목을 좀 더 불규칙적으로 분산시킵니다.

가드레일 배치

안전장치는 기본적으로 섬의 외곽 경계를 따라 설치되지만 접근을 위해서는 다리에서 잘려야 합니다. 부스트 도형 라이브러리는 섬 경계 데이터가 다리 양옆의 선과 교차하는 위치를 확인하는 등 기하학적 계산을 간소화하는 데 유용한 것으로 입증되었습니다.

스테이지 빌드

섬을 둘러싼 초록색 선은 안전장치입니다. 이 이미지에서는 잘 보이지 않지만 다리가 있는 곳에 녹색 선이 없습니다. 이는 디버깅에 사용되는 최종 이미지로, JSON으로 출력해야 하는 모든 객체가 포함되어 있습니다. 하늘색 점은 작은 항목이고 회색 점은 다시 시작 지점을 제안하는 것입니다. 공이 바다로 떨어지면 가장 가까운 시작 지점에서 경기가 다시 시작됩니다. 다시 시작 지점은 작은 물품과 거의 같은 방식으로 섬의 가장자리에서 설정된 거리만큼 일정한 간격으로 배열됩니다.

JSON 형식으로 위치 데이터 출력

출력에도 picojson을 사용했습니다. 표준 출력에 데이터를 씁니다. 그러면 호출자 (Node.js)가 이를 수신합니다.

Linux에서 실행할 C++ 프로그램을 Mac에서 만들기

이 게임은 Mac에서 개발되어 Linux에 배포되었지만 OpenCV와 Boost가 두 운영체제 모두에서 존재했기 때문에 컴파일 환경이 구축되어도 개발 자체는 어렵지 않았습니다. Xcode의 명령줄 도구를 사용하여 Mac에서 빌드를 디버그한 다음, 빌드가 Linux에서 컴파일될 수 있도록 automake/autoconf를 사용하여 구성 파일을 만들었습니다. 그런 다음 Linux에서 'configure && make'를 사용하여 실행 파일을 만들기만 하면 되었습니다. 컴파일러 버전 차이로 인해 일부 Linux 관련 버그가 발생했지만 gdb를 사용하여 비교적 쉽게 해결할 수 있었습니다.

결론

이러한 게임은 Flash 또는 Unity로 만들 수 있으며 다양한 이점을 가져올 수 있습니다. 그러나 이 버전에서는 플러그인이 필요하지 않으며 HTML5 + CSS3의 레이아웃 기능은 매우 강력합니다. 작업별로 적합한 도구를 사용하는 것이 중요합니다. 개인적으로 HTML5로 제작된 게임에서 얼마나 좋은 결과를 얻었는지 놀라웠습니다. 아직 많은 분야에서 부족하지만 향후 어떻게 발전할지 기대됩니다.