우수사례 - Inside World Wide Maze

World Wide Maze는 스마트폰을 사용하여 웹사이트에서 만든 3D 미로를 통해 굴러가는 공을 탐색하여 목표 지점에 도달하는 게임입니다.

World Wide Maze

이 게임은 HTML5 기능을 많이 사용합니다. 예를 들어 DeviceOrientation 이벤트는 스마트폰에서 기울기 데이터를 가져와 WebSocket을 통해 PC로 전송합니다. PC에서 플레이어는 WebGLWeb Worker로 빌드된 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을 통해 연결됩니다. 더 정확하게는 스마트폰에서 서버를 거쳐 PC로 연결됩니다. WebSocket에는 브라우저를 서로 직접 연결하는 기능이 없기 때문입니다. WebRTC 데이터 채널을 사용하면 피어 투 피어 연결이 가능해지고 중계 서버가 필요 없게 되지만, 구현 시 이 방법은 Chrome Canary 및 Firefox Nightly에서만 사용할 수 있었습니다.

연결 시간 초과 또는 연결 해제 시 다시 연결하는 기능이 포함된 Socket.IO (v0.9.11)라는 라이브러리를 사용하여 구현하기로 했습니다. NodeJS와 함께 사용했습니다. 이 NodeJS + Socket.IO 조합이 여러 WebSocket 구현 테스트에서 가장 우수한 서버 측 성능을 보였기 때문입니다.

숫자로 페어링

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

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

Nagle 알고리즘 1

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

Nagle 알고리즘 2

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

ALT_TEXT_HERE

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

버그인가요?

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

릴레이 서버 확장

중계 서버의 역할은 그리 복잡하지 않으므로 동일한 PC와 휴대기기가 항상 동일한 서버에 연결되어 있는지 확인하는 한 서버의 확장 및 수 증가는 어렵지 않습니다.

물리학

게임 내 공의 움직임 (경사면을 따라 굴러감, 지면과 충돌, 벽과 충돌, 아이템 수집 등)은 모두 3D 물리 시뮬레이터로 처리됩니다. 널리 사용되는 Bullet 물리 엔진을 Emscripten을 사용하여 JavaScript로 포팅한 Ammo.jsPhysijs를 함께 사용하여 '웹 워커'로 활용했습니다.

웹 작업자

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

FPS

이 이미지는 Lenovo G570에서 발생한 프레임 속도를 보여줍니다. 상단 상자는 WebGL (이미지 렌더링)의 프레임 속도를 보여주고 하단 상자는 물리 엔진의 프레임 속도를 보여줍니다. GPU가 통합형 Intel HD Graphics 3000 칩이므로 이미지 렌더링 프레임 속도가 예상되는 60fps에 도달하지 못했습니다. 하지만 물리 엔진이 예상 프레임 속도를 달성했으므로 게임플레이는 고사양 머신의 성능과 크게 다르지 않습니다.

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

서비스 워커

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

성능

다각형 수가 많은 스테이지는 10만 개를 초과하는 경우도 있지만, Physijs.ConcaveMesh (Bullet의 btBvhTriangleMeshShape)로 완전히 생성되더라도 성능이 크게 저하되지는 않았습니다.

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

유령 객체

충돌 감지는 있지만 충돌 시 영향이 없으므로 다른 객체에 영향을 미치지 않는 객체를 Bullet에서는 '유령 객체'라고 합니다. 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에 신고되었으며 며칠 후에 해결되었습니다. 이 오픈소스 효율성에 감탄하면서도 이 사건을 통해 월드 와이드 메이즈가 여러 오픈소스 프레임워크로 구성되어 있다는 사실을 다시 한번 확인할 수 있었습니다. 이 도움말을 작성하는 목적은 의견을 제공하기 위함입니다.

asm.js

이는 월드 와이드 메이즈와 직접적인 관련이 없지만 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 Method MGF'의 간단한 버전을 사용하여 구현됩니다. 그러나 카와세 방법은 모든 밝은 영역을 빛나게 하는 반면 월드와이드 메이즈는 빛나야 하는 영역을 위한 별도의 렌더링 타겟을 만듭니다. 이는 무대 텍스처에 웹사이트 스크린샷을 사용해야 하며, 밝은 영역을 모두 추출하면 흰색 배경이 있는 경우 웹사이트 전체가 빛나게 되기 때문입니다. 모든 것을 HDR로 처리하는 것도 고려했지만 구현이 매우 복잡해질 수 있으므로 이번에는 하지 않기로 결정했습니다.

발광

왼쪽 상단에는 첫 번째 패스가 표시되어 있으며, 여기서 발광 영역이 별도로 렌더링된 후 흐리게 처리되었습니다. 오른쪽 하단에는 이미지 크기를 50% 줄인 다음 흐리게 처리한 두 번째 패스가 표시되어 있습니다. 오른쪽 상단에는 세 번째 패스가 표시되어 있으며, 여기서 이미지가 다시 50% 축소된 후 흐리게 처리되었습니다. 그런 다음 세 개의 이미지를 겹쳐 왼쪽 하단에 표시된 최종 합성 이미지를 만들었습니다. 흐리게 처리에는 three.js에 포함된 VerticalBlurShaderHorizontalBlurShader를 사용했으므로 아직 최적화할 여지가 있습니다.

반사 공

공의 반사는 three.js의 샘플을 기반으로 합니다. 모든 방향은 공의 위치에서 렌더링되고 환경 맵으로 사용됩니다. 환경 맵은 공이 움직일 때마다 업데이트되어야 하지만 60fps로 업데이트하는 것은 부담이 되므로 대신 3프레임마다 업데이트됩니다. 결과는 모든 프레임을 업데이트하는 것만큼 원활하지는 않지만, 지적하지 않는 한 차이를 거의 감지할 수 없습니다.

셰이더, 셰이더, 셰이더…

WebGL은 모든 렌더링에 셰이더 (꼭짓점 셰이더, 조각 셰이더)가 필요합니다. three.js에 포함된 셰이더를 사용하면 이미 다양한 효과를 사용할 수 있지만, 더 정교한 음영 처리와 최적화를 위해서는 직접 작성해야 합니다. World Wide Maze는 물리 엔진으로 CPU를 계속 사용하기 때문에 JavaScript를 통한 CPU 처리가 더 쉬울 때도 셰이딩 언어(GLSL)로 최대한 많이 작성하여 GPU를 활용하려고 했습니다. 물론 바다 물결 효과는 골 포인트의 불꽃놀이와 공이 표시될 때 사용되는 메시 효과와 마찬가지로 셰이더를 사용합니다.

셰이더 볼

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

셰이더 메시

스테이지 전체에 흩어져 있는 작은 항목은 모두 하나의 메시에 통합되어 있으며, 개별적인 움직임은 각 다각형 꼭짓점을 이동하는 셰이더에 의존합니다. 이는 많은 수의 객체가 있을 때 성능이 저하되는지 확인하기 위한 테스트에서 가져온 것입니다. 여기에는 약 20,000개의 다각형으로 구성된 약 5,000개의 객체가 배치되어 있습니다. 성능이 전혀 저하되지 않았습니다.

poly2tri

스테이지는 서버에서 수신한 윤곽선 정보를 기반으로 형성된 후 JavaScript로 다각형화됩니다. 이 프로세스의 핵심 부분인 삼각측정은 three.js에서 제대로 구현되지 않으며 일반적으로 실패합니다. 따라서 poly2tri라는 다른 삼각 측정 라이브러리를 직접 통합하기로 했습니다. 알고 보니 three.js에서 이전에 동일한 작업을 시도한 적이 있었던 것 같습니다. 일부를 주석 처리하여 작동하도록 했습니다. 그 결과 오류가 크게 줄어들어 플레이 가능한 스테이지가 훨씬 더 많아졌습니다. 가끔 오류가 지속되고 poly2tri가 알림을 발행하여 오류를 처리하는 이유로 대신 예외를 발생시키도록 수정했습니다.

poly2tri

위의 그림은 파란색 윤곽선이 삼각형으로 분할되고 빨간색 다각형이 생성되는 방식을 보여줍니다.

비등방성 필터링

표준 등방성 MIP 매핑은 가로 및 세로 축 모두에서 이미지 크기를 줄이므로 사각형을 경사각에서 보면 월드 와이드 미로 스테이지의 먼 끝에 있는 텍스처가 가로로 늘어난 저해상도 텍스처처럼 보입니다. 이 위키피디아 페이지의 오른쪽 상단 이미지가 좋은 예입니다. 실제로는 더 많은 가로 해상도가 필요하며 WebGL (OpenGL)은 비등방성 필터링이라는 메서드를 사용하여 이를 해결합니다. three.js에서 THREE.Texture.anisotropy에 1보다 큰 값을 설정하면 비등방성 필터링이 사용 설정됩니다. 그러나 이 기능은 확장 프로그램이므로 일부 GPU에서는 지원되지 않을 수 있습니다.

최적화

WebGL 권장사항 도움말에서도 언급했듯이 WebGL (OpenGL) 성능을 개선하는 가장 중요한 방법은 그리기 호출을 최소화하는 것입니다. 월드 와이드 미로의 초기 개발 단계에서 모든 인게임 섬, 다리, 가드레일은 별도의 객체였습니다. 이로 인해 드로우 호출이 2,000회가 넘는 경우가 있어 복잡한 스테이지를 다루기 어려웠습니다. 하지만 동일한 유형의 객체를 모두 하나의 메시에 패킹하자 그리기 호출이 50개 정도로 줄어 성능이 크게 개선되었습니다.

추가 최적화를 위해 Chrome 추적 기능을 사용했습니다. Chrome 개발자 도구에 포함된 프로파일러는 전체 메서드 처리 시간을 어느 정도까지는 파악할 수 있지만, 추적을 사용하면 각 부분에 걸리는 시간을 정확하게 파악할 수 있습니다(1/1, 000초까지). 추적을 사용하는 방법에 관한 자세한 내용은 이 도움말을 참고하세요.

최적화

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

추적 프로세스 자체가 리소스를 사용하기 때문에 console.time를 과도하게 삽입하면 실제 성능에서 상당한 편차가 발생하여 최적화할 영역을 정확하게 파악하기 어려울 수 있습니다.

실적 조정자

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

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

메모리 누수

three.js에서는 객체를 깔끔하게 삭제하는 것이 다소 번거롭습니다. 하지만 그대로 두면 메모리 누수가 발생할 수 있으므로 아래 메서드를 고안했습니다. @rendererTHREE.WebGLRenderer를 참조합니다. (three.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 또는 openFrameworks (OpenGL)에서 점수나 텍스트 표시와 같은 2D 인터페이스를 빌드하는 것은 다소 번거롭습니다. Flash에는 적어도 IDE가 있지만 openFrameworks는 익숙하지 않으면 어렵습니다 (Cocos2D와 같은 것을 사용하면 더 쉬울 수 있음). 반면 HTML은 웹사이트를 빌드할 때와 마찬가지로 CSS로 모든 프런트엔드 디자인 측면을 정확하게 제어할 수 있습니다. 입자가 로고로 응축되는 것과 같은 복잡한 효과는 불가능하지만 CSS 변환 기능 내에서 일부 3D 효과는 가능합니다. World Wide Maze의 'GOAL' 및 'TIME IS UP' 텍스트 효과는 CSS 전환의 크기를 사용하여 애니메이션 처리됩니다 (Transit로 구현됨). 물론 배경 그라데이션은 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'가 'define'의 첫 번째 인수로 지정되므로 hoge.js가 항상 먼저 로드됩니다 (hoge.js 로드가 완료되면 다시 호출됨). 이 메커니즘을 AMD라고 하며, AMD를 지원하는 한 모든 서드 파티 라이브러리를 동일한 종류의 콜백에 사용할 수 있습니다. 그렇지 않은 라이브러리 (예: three.js)도 종속 항목이 사전에 지정되어 있으면 비슷하게 실행됩니다.

이는 AS3를 가져오는 것과 유사하므로 이상하게 느껴지지 않습니다. 종속 파일이 더 많아지는 경우 이 방법을 사용해 보세요.

r.js

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

스테이지 빌더

스테이지 데이터는 다음과 같이 생성되며, 미국의 GCE 서버에서 완전히 실행됩니다.

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

PhantomJS

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

PhantomJS에서는 JavaScript 또는 CoffeeScript를 사용하여 실행하려는 프로세스를 작성합니다. 스크린샷을 캡처하는 것은 매우 쉽습니다(이 샘플 참고). Linux 서버 (CentOS)에서 작업 중이므로 일본어를 표시할 글꼴 (M+ FONTS)을 설치해야 했습니다. 그럼에도 불구하고 글꼴 렌더링은 Windows 또는 Mac OS와 다르게 처리되므로 동일한 글꼴이 다른 컴퓨터에서 다르게 보일 수 있습니다 (차이가 거의 없음).

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

stage_builder

처음에는 더 DOM 기반의 접근 방식을 사용하여 단계를 생성하는 방법을 고려해 보았으며 (Firefox 3D 검사기와 유사) PhantomJS에서 DOM 분석과 같은 작업을 시도해 보았습니다. 하지만 결국 이미지 처리 접근 방식을 선택했습니다. 이를 위해 OpenCV 및 Boost를 사용하는 C++ 프로그램인 'stage_builder'를 작성했습니다. 다음을 실행합니다.

  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에서 가져온 이미지가 아님)는 투사된 게재위치 선을 회색으로 표시하고, 이 선을 오프셋하여 섬 가장자리에서 일정한 간격을 두고 배치합니다. 빨간색 점은 작은 항목이 배치된 위치를 나타냅니다. 이 이미지는 개발 중반 버전의 이미지이므로 항목이 직선으로 배치되어 있지만 최종 버전에서는 회색 선의 양쪽에 항목이 약간 더 불규칙하게 흩어져 있습니다.

가드레일 배치

가드레일은 기본적으로 섬의 외부 경계를 따라 배치되지만 접근을 허용하려면 다리에서 잘라야 합니다. Boost 기하학 라이브러리는 섬 경계 데이터가 다리 양쪽의 선과 교차하는 지점을 확인하는 등의 기하학적 계산을 간소화하여 이 작업에 유용했습니다.

스테이지 빌드

섬의 윤곽을 나타내는 녹색 선은 가드레일입니다. 이 이미지에서는 보기 어려울 수 있지만 다리가 있는 위치에 초록색 선이 없습니다. 디버깅에 사용되는 최종 이미지로, 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로 제작된 게임이 이렇게 잘 만들어진 것에 개인적으로 놀랐습니다. 아직 많은 부분이 부족하지만 향후 어떻게 발전할지 기대됩니다.