Case study - All'interno del World Wide Maze

Il World Wide Maze è un gioco in cui si utilizza lo smartphone per muoversi all'interno di labirinti 3D creati a partire da siti web allo scopo di raggiungere i propri obiettivi.

Labirinto per il mondo

Il gioco offre un uso intensivo delle funzioni HTML5. Ad esempio, l'evento DeviceOrientation recupera i dati di inclinazione dallo smartphone, che vengono poi inviati al PC tramite WebSocket, dove i giocatori trovano la propria strada negli spazi 3D realizzati da WebGL e Web Workers.

In questo articolo, spiegherò con precisione come vengono utilizzate queste funzioni, il processo di sviluppo complessivo e i punti chiave per l'ottimizzazione.

DeviceOrientation

L'evento DeviceOrientation (esempio) viene utilizzato per recuperare i dati sull'inclinazione dallo smartphone. Quando addEventListener viene utilizzato con l'evento DeviceOrientation, a intervalli regolari viene richiamato un callback con l'oggetto DeviceOrientationEvent come argomento. Gli intervalli variano in base al dispositivo utilizzato. Ad esempio, in iOS + Chrome e iOS + Safari, il callback viene richiamato circa ogni 1/20 di secondo, mentre in Android 4 + Chrome viene richiamato circa ogni 1/10 di secondo.

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

L'oggetto DeviceOrientationEvent contiene dati di inclinazione per ciascuno degli assi X, Y e Z in gradi (non radianti) (scopri di più su HTML5Rocks). Tuttavia, i valori restituiti variano anche in base alla combinazione di dispositivo e browser utilizzata. Gli intervalli dei valori effettivi restituiti sono indicati nella tabella seguente:

Orientamento del dispositivo.

I valori in alto, evidenziati in blu, sono quelli definiti nelle specifiche W3C. Quelle evidenziate in verde corrispondono a queste specifiche, mentre quelle evidenziate in rosso deviano. Sorprendentemente, solo la combinazione Android-Firefox ha restituito valori corrispondenti alle specifiche. Ciononostante, quando si tratta di implementazione è più logico inserire i valori che si ripetono di frequente. Di conseguenza, World Wide Maze utilizza i valori restituiti per iOS come standard e si adatta di conseguenza ai dispositivi Android.

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

Il Nexus 10 non è ancora supportato. Sebbene il Nexus 10 restituisca lo stesso intervallo di valori degli altri dispositivi Android, esiste un bug che inverte i valori beta e gamma. Questo problema verrà risolto separatamente. (Forse l'orientamento predefinito è orizzontale?)

Come dimostrato, anche se le API che coinvolgono dispositivi fisici hanno impostato delle specifiche, non vi è alcuna garanzia che i valori restituiti corrispondano a queste specifiche. Testarli su tutti i potenziali dispositivi è quindi fondamentale. Significa anche che potrebbero essere inseriti valori imprevisti, il che richiede la creazione di soluzioni alternative. World Wide Maze chiede ai nuovi giocatori di calibrare i loro dispositivi come passaggio 1 del tutorial, ma non verrà calibrato correttamente in posizione zero se riceve valori di inclinazione imprevisti. Pertanto, ha un limite di tempo interno e chiede al player di passare ai controlli da tastiera se non riesce a eseguire la calibrazione entro questo limite.

WebSocket

In World Wide Maze, lo smartphone e il PC sono collegati tramite WebSocket. In modo più preciso, vengono collegati tra loro tramite un server di inoltro, ovvero da smartphone a server con PC. Questo perché WebSocket non riesce a connettere i browser direttamente tra loro. L'utilizzo dei canali di dati WebRTC consente la connettività peer-to-peer ed elimina la necessità di un server di inoltro, ma al momento dell'implementazione questo metodo può essere utilizzato solo con Chrome Canary e Firefox Nightly.

Ho scelto di implementare utilizzando una libreria denominata Socket.IO (v0.9.11), che include funzionalità per la riconnessione in caso di timeout o disconnessione della connessione. L'ho utilizzata insieme a NodeJS, poiché questa combinazione NodeJS + Socket.IO ha mostrato le migliori prestazioni lato server in diversi test di implementazione di WebSocket.

Accoppiamento in base ai numeri

  1. Il PC si connette al server.
  2. Il server assegna al PC un numero generato in modo casuale e memorizza la combinazione di numero e PC.
  3. Dal tuo dispositivo mobile, specifica un numero e connettiti al server.
  4. Se il numero specificato è lo stesso utilizzato da un PC connesso, il dispositivo mobile viene associato a quel PC.
  5. Se non è stato designato un PC, si verifica un errore.
  6. Quando i dati provengono dal tuo dispositivo mobile, vengono inviati al PC con cui sono associati e viceversa.

In alternativa, puoi effettuare la connessione iniziale dal tuo dispositivo mobile. In questo caso, i dispositivi vengono semplicemente invertiti.

Sincronizzazione delle schede

La funzione Sincronizzazione schede specifica di Chrome rende ancora più semplice la procedura di accoppiamento. che consente di aprire facilmente le pagine aperte su un dispositivo mobile (e viceversa). Il PC prende il numero di connessione emesso dal server e lo aggiunge all'URL di una pagina utilizzando history.replaceState.

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

Se la sincronizzazione delle schede è attivata, l'URL viene sincronizzato dopo alcuni secondi e la stessa pagina può essere aperta sul dispositivo mobile. Il dispositivo mobile controlla l'URL della pagina aperta e, se viene aggiunto un numero, la connessione inizia immediatamente. In questo modo non sarà necessario inserire numeri manualmente o scansionare i codici QR con una fotocamera.

Latenza

Dal momento che il server di inoltro si trova negli Stati Uniti, l'accesso dal Giappone comporta un ritardo di circa 200 ms prima che i dati relativi all'inclinazione dello smartphone raggiungano il PC. I tempi di risposta sono stati chiaramente lenti rispetto a quelli dell'ambiente locale utilizzato durante lo sviluppo, ma l'inserimento di qualcosa come un filtro passa-basso (ho usato EMA) ha permesso di raggiungere livelli discreti. (In pratica, era necessario un filtro passa-basso anche a scopo di presentazione; i valori di ritorno dal sensore di inclinazione includevano una notevole quantità di rumore e l'applicazione di questi valori allo schermo in quanto comportava molte scosse.) Non ha funzionato con i salti, che erano chiaramente lenti, ma non è stato possibile fare nulla per risolvere il problema.

Dato che mi aspettavo problemi di latenza sin dall'inizio, ho considerato la possibilità di configurare server di inoltro in tutto il mondo, in modo che i client potessero connettersi a quelli più vicini disponibili (riducendo così al minimo la latenza). Tuttavia, ho finito per utilizzare Google Compute Engine (GCE), che all'epoca esisteva solo negli Stati Uniti, quindi non è stato possibile.

Il problema dell'algoritmo Nagle

In genere, l'algoritmo Nagle è incorporato nei sistemi operativi per una comunicazione efficiente mediante il buffering a livello TCP, ma ho scoperto che non potevo inviare dati in tempo reale mentre questo algoritmo era abilitato. (In particolare se combinata con la conferma ritardata TCP. Anche senza ritardo di ACK, lo stesso problema si verifica se ACK subisce un certo ritardo a causa di fattori quali la posizione del server all'estero).

Il problema di latenza Nagle non si è verificato con WebSocket in Chrome per Android, che include l'opzione TCP_NODELAY per disattivare Nagle, ma si è verificato con WebKit WebSocket utilizzato in Chrome per iOS, per il quale questa opzione non è attivata. Anche Safari, che utilizza lo stesso WebKit, ha riscontrato questo problema. Il problema è stato segnalato ad Apple tramite Google ed è stato apparentemente risolto nella versione di sviluppo di WebKit.

Quando si verifica questo problema, i dati di inclinazione inviati ogni 100 ms vengono combinati in blocchi che raggiungono il PC solo ogni 500 ms. Il gioco non può funzionare in queste condizioni, quindi evita questa latenza facendo inviare i dati dal lato server a brevi intervalli (ogni 50 ms circa). Credo che la ricezione di ACK a brevi intervalli induca l'algoritmo di Nagle a pensare che sia corretto inviare dati.

Algoritmo Nagle 1

Il grafico riportato sopra mostra gli intervalli dei dati effettivi ricevuti. Indica gli intervalli di tempo tra un pacchetto e l'altro; il verde rappresenta gli intervalli di output e il rosso gli intervalli di input. Il minimo è 54 ms, il massimo è 158 ms e il centro è vicino a 100 ms. Qui ho utilizzato un iPhone con un server di inoltro situato in Giappone. Sia l'output che l'input hanno una durata di circa 100 ms e il funzionamento è fluido.

Algoritmo Nagle 2

Al contrario, questo grafico mostra i risultati dell'utilizzo del server negli Stati Uniti. Mentre gli intervalli di uscita verdi rimangono invariati a 100 ms, gli intervalli di input variano tra minimi di 0 ms e massimi di 500 ms, indicando che il PC sta ricevendo i dati in blocchi.

ALT_TEXT_HERE

Infine, questo grafico mostra i risultati di come evitare la latenza facendo inviare al server i dati segnaposto. Anche se le prestazioni non sono soddisfacenti rispetto all'uso del server giapponese, è chiaro che gli intervalli di input rimangono relativamente stabili a circa 100 ms.

Un bug?

Nonostante il browser predefinito in Android 4 (ICS) abbia un'API WebSocket, non riesce a connettersi, causando un evento connect_failed Socket.IO. Si verifica il timeout internamente e anche il lato server non è in grado di verificare la connessione. (non l'ho provato solo con WebSocket, quindi potrebbe trattarsi di un problema di Socket.IO).

Scalabilità dei server di inoltro

Poiché il ruolo del server di inoltro non è così complicato, lo scale up e l'aumento del numero di server non dovrebbe essere difficile, purché ti assicuri che lo stesso PC e lo stesso dispositivo mobile siano sempre connessi allo stesso server.

Fisica

Il movimento della palla durante il gioco (rotazione in discesa, collisione con il terreno, collisione con muri, raccolta di oggetti e così via) viene eseguito tramite un simulatore di fisica 3D. Ho usato Ammo.js, una porta del motore fisico Bullet, ampiamente utilizzato in JavaScript utilizzando Emscripten, insieme a Physijs per utilizzarlo come "Web Worker".

Web worker

Web worker è un'API per l'esecuzione di JavaScript in thread separati. JavaScript avviato come web worker viene eseguito come thread separato da quello che lo chiamava in origine, quindi è possibile eseguire attività pesanti mantenendo la pagina reattiva. Physijs utilizza i web worker in modo efficiente per consentire al motore fisico 3D, che solitamente richiede un uso intensivo, di funzionare senza problemi. World Wide Maze gestisce il motore fisico e il rendering delle immagini WebGL a frequenze di fotogrammi completamente diverse, quindi anche se la frequenza fotogrammi cala su un computer con specifiche basse a causa dell'elevato carico del rendering WebGL, il motore fisico manterrà più o meno 60 fps senza ostacolare i controlli del gioco.

f/s

Questa immagine mostra le frequenze fotogrammi risultanti su un Lenovo G570. Il riquadro superiore mostra la frequenza fotogrammi per WebGL (rendering delle immagini), mentre quello inferiore mostra la frequenza fotogrammi per il motore fisico. La GPU è un chip integrato Intel HD Graphics 3000, quindi la frequenza fotogrammi del rendering delle immagini non ha raggiunto i 60 fps previsti. Tuttavia, poiché il motore fisico ha raggiunto la frequenza fotogrammi prevista, il gameplay non è poi così diverso dalle prestazioni su un computer con specifiche elevate.

Poiché i thread con web worker attivi non hanno oggetti della console, i dati devono essere inviati al thread principale tramite postMessage per generare i log di debug. L'uso di console4Worker crea l'equivalente di un oggetto console nel worker, semplificando notevolmente il processo di debug.

Service worker

Le versioni recenti di Chrome ti consentono di impostare punti di interruzione durante l'avvio di web worker, utile anche per il debug. Puoi trovarlo nel riquadro "Worker" in Strumenti per sviluppatori.

Esibizione

Le fasi con un numero elevato di poligoni a volte superano i 100.000 poligoni, ma le prestazioni non ne hanno risentito in particolare quando sono state generate interamente come Physijs.ConcaveMesh (btBvhTriangleMeshShape in Bullet).

Inizialmente, la frequenza fotogrammi è diminuita con l'aumento del numero di oggetti che richiedono il rilevamento delle collisioni, ma l'eliminazione dell'elaborazione non necessaria in Physijs ha migliorato le prestazioni. Questo miglioramento è stato apportato a una forchetta dei Physijs originali.

Oggetti fantasma

Gli oggetti che dispongono del rilevamento delle collisioni ma non hanno alcun impatto sulla collisione e quindi non hanno alcun effetto su altri oggetti sono chiamati "oggetti fantasma" in Bullet. Anche se Physijs non supporta ufficialmente gli oggetti fantasma, è possibile crearli lì armeggiando con i flag dopo aver generato un Physijs.Mesh. World Wide Maze utilizza oggetti fantasma per il rilevamento delle collisioni di oggetti e punti obiettivo.

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

Per collision_flags, 1 è CF_STATIC_OBJECT e 4 è CF_NO_CONTACT_RESPONSE. Prova a cercare nel forum di Bullet, in Stack Overflow o nella documentazione di Bullet per ulteriori informazioni. Poiché Physijs è un wrapper per Ammo.js e Ammo.js è sostanzialmente identico a Bullet, pertanto la maggior parte delle operazioni che possono essere eseguite in Bullet può essere eseguita anche in Physijs.

Il problema di Firefox 18

L'aggiornamento di Firefox dalla versione 17 alla 18 ha cambiato il modo in cui i web worker scambiavano dati e di conseguenza Physijs ha smesso di funzionare. Il problema è stato segnalato su GitHub e risolto dopo alcuni giorni. Questa efficienza open source mi ha colpito, ma l'incidente mi ha anche ricordato come World Wide Maze sia composto da diversi framework open source. Ti scrivo questo articolo nella speranza di fornire il tuo feedback.

asm.js

Sebbene ciò non riguardi direttamente il World Wide Maze, Ammo.js supporta già il file asm.js recentemente annunciato di Mozilla (non sorprende in quanto asm.js è stato fondamentalmente creato per velocizzare il codice JavaScript generato da Emscripten e il creatore di Emscripten è anche il creatore di Ammo.js). Se Chrome supporta anche asm.js, il carico di calcolo del motore fisico dovrebbe diminuire notevolmente. La velocità è risultata notevolmente più elevata durante il test con Firefox Nightly. Forse sarebbe meglio scrivere sezioni che richiedono una maggiore velocità in C/C++ e poi portarle in JavaScript utilizzando Emscripten?

WebGL

Per l'implementazione di WebGL ho utilizzato la libreria più sviluppata, ovvero three.js (r53). Sebbene la revisione 57 fosse già stata rilasciata nelle ultime fasi di sviluppo, erano state apportate importanti modifiche all'API, quindi ho mantenuto la revisione originale per la release.

Effetto luminescenza

L'effetto incandescenza aggiunto al nucleo della pallina e agli elementi viene implementato utilizzando una semplice versione del cosiddetto "Kawase Method MGF". Tuttavia, mentre il metodo Kawase fa fiorire tutte le aree luminose, il World Wide Maze crea target di rendering separati per le aree che devono essere illuminate. Questo perché lo screenshot di un sito web deve essere utilizzato per le texture sullo stage e la semplice estrazione di tutte le aree luminose darebbe visibilità all'intero sito web se, ad esempio, aveva uno sfondo bianco. Ho anche preso in considerazione l'elaborazione di tutto in HDR, ma questa volta ho deciso di non farlo perché l'implementazione sarebbe diventata piuttosto complicata.

Glow

In alto a sinistra è mostrato il primo passaggio, in cui le aree luminose sono state visualizzate separatamente e poi è stata applicata una sfocatura. In basso a destra viene mostrato il secondo passaggio, in cui le dimensioni dell'immagine sono state ridotte del 50% e viene applicata una sfocatura. In alto a destra viene mostrato il terzo passaggio, dove l'immagine è stata nuovamente ridotta del 50% e poi sfocata. I tre elementi sono stati poi sovrapposti per creare l'immagine composita finale mostrata in basso a sinistra. Per la sfocatura ho usato VerticalBlurShader e HorizontalBlurShader, inclusi in tre.js, quindi c'è ancora spazio per un'ulteriore ottimizzazione.

Palla riflettente

Il riflesso sulla palla si basa su un campione di tre.js. Tutte le direzioni vengono visualizzate dalla posizione della palla e utilizzate come mappe dell'ambiente. Le mappe ambientali devono essere aggiornate ogni volta che la palla si muove, ma poiché l'aggiornamento a 60 f/s è impegnativo, vengono aggiornate ogni tre frame. Il risultato non è uniforme come l'aggiornamento di ogni frame, ma la differenza è praticamente impercettibile se non viene sottolineata.

Shader, Shader, Shader...

WebGL richiede Shader (Vertex Shader, Shad Shaker) per tutto il rendering. Anche se gli ombreggiatori inclusi in tre.js consentono già un'ampia gamma di effetti, è inevitabile scrivere il proprio codice per ottenere un'ombreggiatura e un'ottimizzazione più elaborate. Poiché World Wide Maze tiene occupata la CPU con il suo motore fisico, ho provato a utilizzare la GPU scrivendo il più possibile in shading language (GLSL), anche quando l'elaborazione della CPU (tramite JavaScript) sarebbe stata più semplice. Gli effetti delle onde dell'oceano si basano sugli ombreggiatori, naturalmente, così come sui fuochi d'artificio nei punti di porta e sull'effetto mesh utilizzato quando appare la pallina.

Palline ombreggiate

Quanto riportato sopra deriva dai test dell'effetto mesh utilizzato quando compare la palla. Quella a sinistra è quella utilizzata in-game, composta da 320 poligoni. Quella al centro utilizza circa 5000 poligoni, mentre quella a destra ne utilizza circa 300.000. Nonostante la moltitudine di poligoni, l'elaborazione con Shader può mantenere una frequenza fotogrammi stabile di 30 f/s.

Mesh ombreggiatore

I piccoli elementi sparsi nello stage sono tutti integrati in un unico mesh, e i singoli movimenti si basano sullo spostamento di ogni punta del poligono da parte degli ombrelli. Questo è un test per vedere se le prestazioni potrebbero risentirne con un numero elevato di oggetti presenti. Qui sono disposti circa 5000 oggetti, composti da circa 20.000 poligoni. Le prestazioni non hanno risentito del tutto.

poly2tri

Le fasi vengono formattate in base alle informazioni sui contorni ricevute dal server e poi poligonizzate da JavaScript. La triangolazione, una parte fondamentale di questo processo, viene implementata male da tre.js e solitamente ha esito negativo. Pertanto, ho deciso di integrare io stesso un'altra libreria di triangolazione chiamata poly2tri. È emerso che tre.js aveva provato a fare lo stesso in passato, quindi l'ho fatto funzionare semplicemente scrivendo un commento. Di conseguenza, gli errori sono diminuiti significativamente, consentendo molti più livelli di gioco. L'errore occasionale persiste e, per qualche motivo, poly2tri gestisce gli errori inviando avvisi, quindi l'ho modificato in modo da generare eccezioni.

poly2tri

Sopra mostra come vengono triangolati i contorni blu e vengono generati poligoni rossi.

Filtro anisotropico

Poiché la mappatura MIP isotropica standard ridimensiona le immagini su entrambi gli assi orizzontale e verticale, la visualizzazione dei poligoni da angoli obliqui consente di far apparire le texture all'estremità più estrema degli stadi del World Wide Maze come texture allungate orizzontalmente e a bassa risoluzione. L'immagine in alto a destra in questa pagina di Wikipedia mostra un buon esempio. In pratica, è necessaria una risoluzione più orizzontale, che WebGL (OpenGL) risolve utilizzando un metodo chiamato filtro anisotropico. Nel campo tre.js, l'impostazione di un valore maggiore di 1 per THREE.Texture.anisotropy consente il filtro anisotropico. Tuttavia, questa funzionalità è un'estensione e potrebbe non essere supportata da tutte le GPU.

Optimize

Come accennato anche in questo articolo sulle best practice di WebGL, il modo più cruciale per migliorare le prestazioni di WebGL (OpenGL) è ridurre al minimo le chiamate di disegno. Durante lo sviluppo iniziale del World Wide Maze, tutte le isole, i ponti e i guard rail presenti nel gioco erano oggetti separati. Questo a volte comportava oltre 2.000 chiamate di estrazione, rendendo le fasi complesse ingombranti. Tuttavia, una volta compressi gli stessi tipi di oggetti in un unico mesh, le chiamate di disegno si sono ridotte a circa 50, migliorando notevolmente le prestazioni.

Ho utilizzato la funzionalità di tracciamento di Chrome per un'ulteriore ottimizzazione. I profiler inclusi negli Strumenti per sviluppatori di Chrome possono determinare in una certa misura i tempi complessivi di elaborazione dei metodi, ma il tracciamento può indicarti con precisione quanto tempo impiega ogni parte, fino a 1/1000 di secondo. Per informazioni dettagliate su come utilizzare il tracciamento, consulta questo articolo.

Ottimizzazione

Le tracce riportate sopra sono risultati della creazione di mappe ambientali per il riflesso della palla. L'inserimento di console.time e console.timeEnd in posizioni apparentemente pertinenti in tre.js ci fornisce un grafico simile a questo. Il tempo scorre da sinistra a destra e ogni livello è simile a uno stack di chiamate. La nidificazione di un file console.time all'interno di un console.time consente ulteriori misurazioni. Il grafico in alto è la fase di pre-ottimizzazione e in basso il grafico in post-ottimizzazione. Come mostra il grafico in alto, updateMatrix (sebbene la parola sia troncata) è stato chiamato per ciascuno dei rendering da 0 a 5 durante la pre-ottimizzazione. L'ho modificato in modo che venga chiamato solo una volta, però, poiché questo processo è necessario solo quando gli oggetti cambiano posizione o orientamento.

Il processo di tracciamento stesso richiede una quantità eccessiva di risorse, pertanto un eccessivo inserimento di console.time può causare una deviazione significativa rispetto alle prestazioni effettive, rendendo difficile l'individuazione delle aree da ottimizzare.

Aggiustamento del rendimento

Data la natura di internet, il gioco verrà probabilmente utilizzato su sistemi con specifiche molto diverse. Find Your Way to Oz, pubblicato all'inizio di febbraio, utilizza una classe chiamata IFLAutomaticPerformanceAdjust per ridurre gli effetti in base alle fluttuazioni della frequenza fotogrammi, contribuendo a garantire una riproduzione uniforme. World Wide Maze si basa sulla stessa classe IFLAutomaticPerformanceAdjust e riduce gli effetti nel seguente modo per rendere il gameplay il più agevole possibile:

  1. Se la frequenza fotogrammi scende al di sotto di 45 f/s, l'aggiornamento delle mappe ambientali viene interrotto.
  2. Se scende ancora al di sotto di 40 f/s, la risoluzione del rendering si riduce al 70% (50% del rapporto di superficie).
  3. Se scende ancora sotto i 40 fps, l'FXAA (anti-aliasing) viene eliminato.
  4. Se scende ancora al di sotto di 30 f/s, gli effetti bagliori vengono eliminati.

Perdita di memoria

Eliminare ordinatamente gli oggetti è una sorta di problema con 3.js. Tuttavia, lasciarli invariati porterebbe ovviamente a perdite di memoria, quindi ho ideato il metodo di seguito. @renderer si riferisce a THREE.WebGLRenderer. (L'ultima revisione di tre.js utilizza un metodo di deallocation leggermente diverso, quindi probabilmente non funzionerà così com'è.)

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

Personalmente, penso che l'aspetto migliore dell'app WebGL sia la capacità di progettare il layout delle pagine in HTML. La creazione di interfacce 2D, come la visualizzazione di punteggi o testi in Flash o openFrameworks (OpenGL), è un problema. Flash ha almeno un IDE, ma openFrameworks è difficile se non si è abituati a farlo (l'utilizzo di uno strumento come Cocos2D potrebbe semplificare l'operazione). L'HTML, d'altra parte, consente un controllo preciso di tutti gli aspetti della progettazione del frontend con CSS, proprio come durante la creazione dei siti web. Sebbene siano impossibili effetti complessi come le particelle che condensano in un logo, sono possibili alcuni effetti 3D all'interno delle funzionalità di CSS Transforms. Gli effetti di testo "GOAL" e "TIME IS UP" di World Wide Maze vengono animati utilizzando la scala in CSS Transizione (implementato con Transit). (ovviamente per le gradazioni dello sfondo viene usato WebGL.)

Ogni pagina del gioco (titolo, RESULT, Ranking e così via) ha un proprio file HTML e, una volta caricati come modelli, $(document.body).append() viene chiamato con i valori appropriati al momento opportuno. Si è verificato un problema a causa del fatto che non era possibile impostare gli eventi di mouse e tastiera prima dell'aggiunta, quindi tentare el.click (e) -> console.log(e) prima dell'aggiunta non ha funzionato.

Internazionalizzazione

Lavorare in HTML è stato anche conveniente per creare la versione in lingua inglese. Per le mie esigenze di internazionalizzazione, ho scelto di utilizzare i18next, una libreria i18n web, che ho potuto utilizzare così com'è, senza modifiche.

La modifica e la traduzione del testo in-game sono state effettuate nel foglio di lavoro di Documenti Google. Poiché i18next richiede file JSON, ho esportato i fogli di lavoro in TSV e poi li ho convertiti con un convertitore personalizzato. Ho apportato molti aggiornamenti poco prima del rilascio, quindi automatizzare il processo di esportazione dal foglio di lavoro di Documenti Google avrebbe semplificato le cose.

Anche la funzionalità di traduzione automatica di Chrome funziona normalmente poiché le pagine sono create in HTML. Tuttavia, a volte non riesce a rilevare correttamente la lingua, scambiandola per una completamente diversa (ad esempio, vietnamita), pertanto questa funzionalità è attualmente disattivata. Può essere disattivata utilizzando i meta tag.

RequireJS

Ho scelto RequireJS come sistema di moduli JavaScript. Le 10.000 righe di codice sorgente del gioco sono suddivise in circa 60 classi (= file di caffè) e compilate in singoli file js. RequestJS carica questi singoli file nell'ordine appropriato in base alla dipendenza.

define ->
  class Hoge
    hogeMethod: ->

La classe definita sopra (hoge.esame) può essere utilizzata nel seguente modo:

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

Affinché funzioni, hoge.js deve essere caricato prima di moge.js e, poiché "hoge" è designato come primo argomento di "define", hoge.js viene sempre caricato per primo (valore richiamato al termine del caricamento di hoge.js). Questo meccanismo è chiamato AMD e qualsiasi libreria di terze parti può essere utilizzata per lo stesso tipo di callback, purché supporti AMD. Anche quelli che non lo fanno (ad es. tre.js) avranno un rendimento simile purché le dipendenze siano specificate in anticipo.

Questa procedura è simile all'importazione di AS3, quindi non dovrebbe sembrare così strana. Se in realtà hai più file dipendenti, questa è una possibile soluzione.

r.js

RichiediJS include un ottimizzatore chiamato r.js. Questo raggruppa il codice js principale con tutti i file js dipendenti in un unico file, quindi lo minimizza utilizzando UglifyJS (o Closure Compiler). In questo modo vengono ridotti il numero di file e la quantità totale di dati che il browser deve caricare. Le dimensioni totali del file JavaScript per il World Wide Maze sono di circa 2 MB e possono essere ridotte a circa 1 MB con l'ottimizzazione r.js. Se il gioco potesse essere distribuito utilizzando gzip, questo verrà ulteriormente ridotto a 250 kB. GAE ha un problema che non consente la trasmissione di file gzip di almeno 1 MB, quindi il gioco viene attualmente distribuito non compresso come 1 MB di testo normale.

Costruttore di palcoscenico

I dati di fase vengono generati come segue, eseguiti interamente sul server GCE negli Stati Uniti:

  1. L'URL del sito web da convertire in una fase viene inviato tramite WebSocket.
  2. PhantomJS acquisisce uno screenshot; le posizioni dei tag div e img vengono recuperate e generate in formato JSON.
  3. In base allo screenshot del passaggio 2 e ai dati di posizionamento degli elementi HTML, un programma C++ personalizzato (OpenCV, Boost) elimina le aree non necessarie, genera isole, collega le isole con ponti, calcola le posizioni dei guard rail e degli elementi, imposta il punto obiettivo e così via. I risultati vengono restituiti in formato JSON e restituiti al browser.

PhantomJS

PhantomJS è un browser che non richiede alcuno schermo. Può caricare pagine web senza aprire finestre, quindi può essere utilizzata nei test automatici o per acquisire screenshot sul lato server. Il suo motore del browser è WebKit, lo stesso utilizzato da Chrome e Safari, quindi anche il layout e i risultati di esecuzione di JavaScript sono più o meno uguali a quelli dei browser standard.

Con PhantomJS, viene utilizzato JavaScript o CoffeeScript per scrivere i processi che vuoi eseguire. Acquisire screenshot è molto semplice, come mostrato in questo esempio. Stavo lavorando su un server Linux (CentOS), quindi dovevo installare dei caratteri per visualizzare il giapponese (M+ FONTS). Anche in questo caso, il rendering dei caratteri viene gestito in modo diverso rispetto a Windows o Mac OS, quindi lo stesso carattere può avere un aspetto diverso su altri computer (la differenza, però, è minima).

Il recupero delle posizioni dei tag img e div viene gestito sostanzialmente allo stesso modo delle pagine standard. Anche jQuery può essere utilizzato senza problemi.

stage_builder

Inizialmente ho considerato l'utilizzo di un approccio più basato sul DOM per generare fasi (simile all'ispettore 3D di Firefox) e ho tentato qualcosa come un'analisi DOM in PhantomJS. Alla fine, però, ho optato per un approccio di elaborazione delle immagini. A questo scopo ho scritto un programma C++ che usa OpenCV e Boost chiamato "stage_builder". Esegue le seguenti operazioni:

  1. Carica lo screenshot e i file JSON.
  2. Converte immagini e testo in "isole".
  3. Crea ponti per collegare le isole.
  4. Elimina i ponti inutili per creare un labirinto.
  5. Consente di inserire elementi di grandi dimensioni.
  6. Posiziona piccoli oggetti.
  7. Posiziona guard rail.
  8. Restituisce come output i dati di posizionamento in formato JSON.

Ogni passaggio è descritto in dettaglio di seguito.

Caricamento dello screenshot e dei file JSON in corso...

Il solito cv::imread viene utilizzato per caricare gli screenshot. Ho testato diverse librerie per i file JSON, ma picojson mi sembrava la più facile da utilizzare.

Conversione di immagini e testo in "isole"

Fase di creazione

Quello sopra è uno screenshot della sezione Notizie di aid-dcc.com (fai clic per visualizzare le dimensioni effettive). Le immagini e gli elementi di testo devono essere convertiti in isole. Per isolare queste sezioni, dobbiamo eliminare il colore di sfondo bianco, in altre parole il colore più diffuso nello screenshot. Ecco come funziona al termine dell'operazione:

Fase di creazione

Le sezioni bianche sono le potenziali isole.

Il testo è troppo fine e nitido, pertanto lo aumenteremo con cv::dilate, cv::GaussianBlur e cv::threshold. Mancano anche i contenuti dell'immagine, pertanto riempiremo queste aree di bianco, in base ai dati del tag img generati da PhantomJS. L'immagine risultante ha il seguente aspetto:

Fase di creazione

Il testo ora forma aggregazioni adatte e ogni immagine è un'isola vera e propria.

Creazione di ponti per collegare le isole

Una volta pronte, le isole vengono collegate tramite ponti. Ogni isola cerca le isole adiacenti a sinistra, destra, sopra e sotto, quindi collega un ponte al punto più vicino all'isola più vicina, ottenendo un risultato simile al seguente:

Fase di creazione

Eliminando i ponti inutili per creare un labirinto

Mantenere tutti i ponti renderebbe il palco troppo facile da percorrere, quindi è necessario eliminarne alcuni per creare un labirinto. Come punto di partenza viene scelta un'isola (ad es. quella in alto a sinistra) e tutti i ponti (selezionati a caso) che si collegano a quell'isola tranne uno vengono eliminati. Poi si fa la stessa cosa per l'isola successiva collegata dal ponte che rimane. Una volta che il sentiero raggiunge un punto morto o torna a un'isola già visitata, torna indietro a un punto che consente l'accesso a una nuova isola. Il labirinto viene completato una volta che tutte le isole sono state elaborate in questo modo.

Fase di creazione

Posizionare elementi di grandi dimensioni

Su ciascuna isola vengono posizionati uno o più oggetti di grandi dimensioni a seconda delle dimensioni, scegliendo tra i punti più lontani dai bordi dell'isola. Sebbene non siano molto chiari, questi punti sono riportati in rosso qui sotto:

Fase di creazione

Tra tutti questi punti possibili, quello in alto a sinistra viene impostato come punto di partenza (cerchio rosso), quello in basso a destra come obiettivo (cerchio verde) e un massimo di sei del resto viene scelto per il posizionamento degli elementi di grandi dimensioni (cerchio viola).

Posizionare elementi di piccole dimensioni

Fase di creazione

Numerosi oggetti di piccole dimensioni adatti sono posizionati lungo linee a distanze prestabilite dai bordi dell'isola. L'immagine qui sopra (non proveniente da aid-dcc.com) mostra le linee di posizionamento previste in grigio, in offset e a intervalli regolari dai bordi dell'isola. I punti rossi indicano dove sono posizionati gli oggetti piccoli. Poiché questa immagine appartiene a una versione di metà sviluppo, gli elementi sono disposti in linee rette, ma la versione finale li disperde in modo leggermente più irregolare ai lati delle linee grigie.

Posizionamento di guard rail

I guard rail sono posizionati lungo i confini esterni delle isole, ma devono essere tagliati sui ponti per consentire l'accesso. A questo scopo, la libreria Geometry di Boost si è rivelata utile, semplificando i calcoli geometrici come la determinazione del punto in cui i dati sui confini delle isole si intersecano con le linee su entrambi i lati di un ponte.

Fase di creazione

Le linee verdi che delineano le isole sono i guard rail. Può essere difficile da vedere in questa immagine, ma non ci sono linee verdi dove si trovano i ponti. Questa è l'immagine finale utilizzata per il debug, in cui sono inclusi tutti gli oggetti che devono essere generati come output in JSON. I punti celesti sono piccoli oggetti e i punti grigi sono proposti come punti di riavvio. Quando la palla cade nell'oceano, il gioco riprende dal punto di riavvio più vicino. I punti di partenza sono disposti più o meno come gli oggetti piccoli, a intervalli regolari a una determinata distanza dal bordo dell'isola.

Output dei dati di posizionamento in formato JSON

Ho utilizzato anche picojson per l'output. Scrive i dati nell'output standard, che viene poi ricevuto dal chiamante (Node.js).

Creazione di un programma C++ su un Mac da eseguire in Linux

Il gioco è stato sviluppato su un Mac e distribuito in Linux, ma poiché OpenCV e Boost esistevano per entrambi i sistemi operativi, lo sviluppo stesso non era difficile una volta stabilito l'ambiente di compilazione. Ho utilizzato gli strumenti a riga di comando in Xcode per eseguire il debug della build su Mac, quindi ho creato un file di configurazione utilizzando automake/autoconf, in modo che la build potesse essere compilata in Linux. Quindi ho dovuto semplicemente utilizzare "configure && make" in Linux per creare il file eseguibile. Ho riscontrato alcuni bug specifici di Linux a causa delle differenze di versione del compilatore, ma sono riuscito a risolverli relativamente facilmente utilizzando gdb.

Conclusione

Un gioco come questo potrebbe essere creato con Flash o Unity, che offrirebbe numerosi vantaggi. Tuttavia, questa versione non richiede plug-in e le caratteristiche di layout di HTML5 + CSS3 si sono rivelate estremamente potenti. È decisamente importante avere gli strumenti giusti per ogni attività. Personalmente sono rimasto sorpreso di quanto il gioco si sia rivelato efficace per un gioco interamente realizzato in HTML5 e, sebbene manchi ancora in molte aree, non vedo l'ora di scoprire come si svilupperà in futuro.