Please note, this is a STATIC archive of website developer.mozilla.org from 03 Nov 2016, cach3.com does not collect or store any user information, there is no "phishing" involved.

Anatomy of a video game

Esta traducción está incompleta. Por favor, ayuda a traducir este artículo del inglés.

Presentar, aceptar, traducir, calcular, repetir

El objetivo de todo videojuego es presentar al usuario una situación, aceptar su entrada, traducir esas señales a acciones, y calcular una nueva situación a partir de esos actos. Los juegos están iterando continuamente por estas etapas, una y otra vez, hasta que ocurre alguna condición final (como ganas, perder, o salirse para ir a la cama). De forma poco sorpresiva, este patrón corresponde a cómo se programa el motor de un juego.

Los detalles dependen del juego.

Algunos juegos dirigen este ciclo con la entrada del usuario. Imagina que estás desarrollando un juego del tipo "encuentra las diferencias entre estas dos imágenes similares". Estos juegos presentan dos imágenes al usuario; aceptan sus clics (o toques); interpretan la entrada como éxito, fallo, pausa, interacción del menú, etc; y finalmente, calculan una escena actualizada resultante de esa entrada. El bucle del juego avanza con la entrada del usuario, y se queda en espera hasta que ésta se produce. Esto es más bien un enfoque basado por turnos que no requiere una actualización constante cada frame, sólo cuando el jugador reacciona.

Otros juegos requieren control sobre cada uno de los más pequeños aspectos posibles. Se aplican los mismos principios de antes, con una ligera variación: cada frame de la animación hace progresar el ciclo, y cualquier cambio en la entrada del usuario se captura en el primer turno disponible. Este modelo de una-vez-por-frame se implementa en algo llamado bucle principal. Si el bucle de tu juego está basado en tiempo, éste será una ley a la que se tendrán que atener tus simulaciones.

Pero puede no necesitar de un control por-frame. El bucle de tu juego podría ser similar al ejemplo de encuentra las diferencias, y basarse en las entradas del usuario. Podría necesitar tanto entradas como tiempo simulado. Podría incluso estar totalmente basado en algo diferente.

Por suerte, el JavaScript moderno (que se describe en las siguientes secciones) hace sencillo desarrollar un bucle principal eficiente, tipo ejecutar-una-vez-por-frame. Por supuesto, estará tan optimizado como tú lo hagas. Si algo parece que debería ir acoplado a un evento más infrecuente, suele ser una buena idea (aunque no siempre) sacarlo fuera del bucle principal.

Crear un bucle principal en JavaScript

JavaScript funciona mejor con eventos y funciones callback. Los navegadores modernos se esfuerzan en invocar los métodos sólo en cuanto se necesita, y reposar (o hacer otras tareas) en los intervalos. Es una idea excelente añadir tu código a los momentos apropiados. Considera si tu función necesita de verdad ser invocada en un intervalo estricto de tiempo, a cada frame, o sólo cuando haya ocurrido alguna otra cosa. Especificándole al navegador cuándo tiene que ser invocada tu función le permite optimizarla. Además, probablemente haga tu trabajo más fácil.

Algún código necesita ejecutarse frame a frame, así que ¿por qué añadir esa función a cualquier otra cosa que al planificador de redibulado del navegador? En la web, window.requestAnimationFrame() será la base de la mayoría de bucles principales frame-a-frame bien programados. Al invocarla, hay que pasarle una función callback. Esa función callback se ejecutará en el momento apropiado antes del próximo redibujado. Aquí hay un ejemplo de un bucle principal sencillo:

window.main = function () {
  window.requestAnimationFrame( main );
  
  // Lo que sea que tenga que hacer tu bucle principal
};

main(); // Empezar el ciclo

Note: In each of the main() methods discussed here, we schedule a new requestAnimationFrame before performing our loop contents. That is not by accident and it is considered best practice. Calling the next requestAnimationFrame early ensures the browser receives it on time to plan accordingly even if your current frame misses its VSync window.

The above chunk of code has two statements. The first statement creates a function as a global variable called main(). This function does some work and also tells the browser to call itself next frame with window.requestAnimationFrame(). The second statement calls the main() function, defined in the first statement. Because main() is called once in the second statement and every call of it places itself in the queue of things to do next frame, main() is synchronized to your framerate.

Of course this loop is not perfect. Before we discuss ways to change it, let us discuss what it already does well.

Timing the main loop to when the browser paints to the display allows you to run your loop as frequently as the browser wants to paint. You are given control over each frame of animation. It is also very simple because main() is the only function getting looped. A First Person Shooter (or a similar game) presents a new scene once every frame. You cannot really get more smooth and responsive than that.

But do not immediately assume animations require frame-by-frame control. Simple animations can be easily performed, even GPU-accelerated, with CSS animations and other tools included in the browser. There are a lot of them and they will make your life easier.

Building a better main loop in Javascript

There are two obvious issues with our previous main loop: main() pollutes the window object (where all global variables are stored) and the example code did not leave us with a way to stop the loop unless the whole tab is closed or refreshed. For the first issue, if you want the main loop to just run and you do not need easy (direct) access to it, you could create it as an Immediately-Invoked Function Expression (IIFE).

/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*/

;(function () {
  function main() {
    window.requestAnimationFrame( main );
    
    // Your main loop contents.
  }
  
  main(); // Start the cycle
})();

When the browser comes across this IIFE, it will define your main loop and immediately queue it for the next frame. It will not be attached to any object and main (or main() for methods) will be a valid unused name in the rest of the application, free to be defined as something else.

Note: In practice, it is more common to prevent the next requestAnimationFrame() with an if-statement, rather than calling cancelAnimationFrame().

For the second issue, stopping the main loop, you will need to cancel the call to main() with window.cancelAnimationFrame(). You will need to pass cancelAnimationFrame() the ID token given by requestAnimationFrame() when it was last called. Let us assume that your game's functions and variables are built on a namespace that you called MyGame. Expanding our last example, the main loop would now look like:

/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*/

;(function () {
  function main() {
    MyGame.stopMain = window.requestAnimationFrame( main );
    
    // Your main loop contents.
  }
  
  main(); // Start the cycle
})();

We now have a variable declared in our MyGame namespace, which we call stopMain, that contains the ID returned from our main loop's most recent call to requestAnimationFrame(). At any point, we can stop the main loop by telling the browser to cancel the request that corresponds to our token.

window.cancelAnimationFrame( MyGame.stopMain );

The key to programming a main loop, in JavaScript, is to it attach to whatever event should be driving your action and pay attention to how the different systems involved interplay. You may have multiple components driven by multiple different types of events. This feels like unnecessary complexity but it might just be good optimization (not necessarily, of course). The problem is that you are not programming a typical main loop. In Javascript, you are using the browser's main loop and you are trying to do so effectively.

Building a more optimized main loop in JavaScript

Ultimately, in JavaScript, the browser is running its own main loop and your code exists in some of its stages. The above sections describe main loops which try not to wrestle away control from the browser. These main methods attach themselves to window.requestAnimationFrame(), which asks the browser for control over the upcoming frame. It is up to the browser how to relate these requests to their main loop. The W3C spec for requestAnimationFrame does not really define exactly when the browsers must perform the requestAnimationFrame callbacks. This can be a benefit because it leaves browser vendors free to experiment with the solutions that they feel are best and tweak it over time.

Modern versions of Firefox and Google Chrome (and probably others) attempt to connect requestAnimationFrame callbacks to their main thread at the very beginning of a frame's timeslice. The browser's main thread thus tries to look like the following:

  1. Start a new frame (while the previous frame is handled by the display).
  2. Go through the list of requestAnimationFrame callbacks and invoke them.
  3. Perform garbage collection and other per-frame tasks when the above callbacks stop controlling the main thread.
  4. Sleep (unless an event interrupts the browser's nap) until the monitor is ready for your image (VSync) and repeat.

You can think about developing realtime applications as having a budget of time to do work. All of the above steps must take place every 16-and-a-half milliseconds to keep up with a 60 Hz display. Browsers invoke your code as early as possible to give it maximum computation time. Your main thread will often start workloads that are not even on the main thread (such as rasterization or shaders in WebGL). Long calculations can be performed on a Web Worker or a GPU at the same time as the browser uses its main thread to manage garbage collection, its other tasks, or handle asynchronous events.

While we are on the topic of budgeting time, many web browsers have a tool called High Resolution Time. The Date object is no longer the recognised method for timing events because it is very imprecise and can be modified by the system clock. High Resolution Time, on the other hand, counts the number of milliseconds since navigationStart (when the previous document is unloaded). This value is returned as a decimal number accurate to a thousandth of a millisecond. It is known as a DOMHighResTimeStamp but, for all intents and purposes, consider it a floating point number.

Note: Systems (hardware or software) that are not capable of microsecond accuracy are allowed to provide millisecond accuracy as a minimum. They should provide 0.001ms accuracy if they are capable of it, however.

This value is not too useful alone, since it is relative to a fairly uninteresting event, but it can be subtracted from another timestamp to accurately and precisely determine how much time elapsed between those two points. To acquire one of these timestamps, you can call window.performance.now() and store the result as a variable.

var tNow = window.performance.now();

Back to the topic of the main loop. You will often want to know when your main function was invoked. Because this is common, window.requestAnimationFrame() always provides a DOMHighResTimeStamp to callbacks as an argument when they are executed. This leads to another enhancement to our previous main loops.

/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*/

;(function () {
  function main( tFrame ) {
    MyGame.stopMain = window.requestAnimationFrame( main );
    
    // Your main loop contents.
    // tFrame, from "function main ( tFrame )", is now a DOMHighResTimeStamp provided by rAF.
  }
  
  main(); // Start the cycle
})();

Several other optimizations are possible and it really depends on what your game attempts to accomplish. Your game genre will obviously make a difference but it could even be more subtle than that. You could draw every pixel individually on a canvas or you could layer DOM elements (including multiple WebGL canvases with transparent backgrounds if you want) into a complex hierarchy. Each of these paths will lead to different opportunities and constraints.

It is decision... time

You will need to make hard decisions about your main loop: how to simulate the accurate progress of time. If you demand per-frame control then you will need to determine how frequently your game will update and draw. You might even want update and draw to occur at different rates. You will also need to consider how gracefully your game will fail if the user's system cannot keep up with the workload. Let us start by assuming that you will handle user input and update the game state every time you draw. We will branch out later.

Note: Changing how your main loop deals with time is a debugging nightmare, everywhere. Think about your needs, carefully, before working on your main loop.

What most browser games should look like

If your game can hit the maximum refresh rate of any hardware you support then your job is fairly easy. You can simply update, render, and then do nothing until VSync.

/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*/

;(function () {
  function main( tFrame ) {
    MyGame.stopMain = window.requestAnimationFrame( main );
    
    update( tFrame ); //Call your update method. In our case, we give it rAF's timestamp.
    render();
  }
  
  main(); // Start the cycle
})();

If the maximum refresh rate cannot be reached, quality settings could be adjusted to stay under your time budget. The most famous example of this concept is the game from id Software, RAGE. This game removed control from the user in order to keep its calculation time at roughly 16ms (or roughly 60fps). If computation took too long then rendered resolution would decrease, textures and other assets would fail to load or draw, and so forth. This (non-web) case study made a few assumptions and tradeoffs:

  • Each frame of animation accounts for user input.
  • No frame needs to be extrapolated (guessed) because each draw has its own update.
  • Simulation systems can basically assume that each full update is ~16ms apart.
  • Giving the user control over quality settings would be a nightmare.
  • Different monitors input at different rates: 30 FPS, 75 FPS, 100 FPS, 120 FPS, 144 FPS, etc.
  • Systems that are unable to keep up with 60 FPS lose visual quality to keep the game running at optimal speed (eventually it outright fails, if quality becomes too low.)

Other ways to handle variable refresh rate needs

Other methods of tackling the problem exist.

One common technique is to update the simulation at a constant frequency and then draw as much (or as little) of the actual frames as possible. The update method can continue looping without care about what the user sees. The draw method can view the last update and when it happened. Since draw knows when it represents, and the simulation time for the last update, it can predict a plausible frame to draw for the user. It does not matter whether this is more frequent than the official update loop (or even less frequent). The update method sets checkpoints and, as frequently as the system allows, the render method draws instants of time around them. There are many ways to separate the update method in web standards:

  • Draw on requestAnimationFrame and update on a window.setInterval or window.setTimeout.
    • This uses processor time even when unfocused or minimized, hogs the main thread, and is probably an artifact of traditional game loops (but it is simple.)
  • Draw on requestAnimationFrame and update on a setInterval or setTimeout in a Web Worker.
    • This is the same as above, except update does not hog the main thread (nor does the main thread hog it). This is a more complex solution, and might be too much overhead for simple updates.
  • Draw on requestAnimationFrame and use it to poke a Web Worker containing the update method with the number of ticks to compute, if any.
    • This sleeps until requestAnimationFrame is called and does not pollute the main thread, plus you are not relying on old fashioned methods. Again, this is a bit more complex than the previous two options, and starting each update will be blocked until the browser decides to fire rAF callbacks.

Each of these methods have similar tradeoffs:

  • Users can skip rendering frames or interpolate extra ones depending on their performance.
  • You can count on all users updating non-cosmetic variables at the same constant frequency minus hiccups.
  • Much more complicated to program than the basic loops we saw earlier.
  • User input is completely ignored until the next update (even if the user has a fast device).
  • The mandatory interpolation has a performance penalty.

A separate update and draw method could look like the following example. For the sake of demonstration, the example is based on the third bullet point, just without using Web Workers for readability (and, let's be honest, writeability).

Note: This example, specifically, is in need of technical review.

/*
* Starting with the semicolon is in case whatever line of code above this example
* relied on automatic semicolon insertion (ASI). The browser could accidentally
* think this whole example continues from the previous line. The leading semicolon
* marks the beginning of our new line if the previous one was not empty or terminated.
*
* Let us also assume that MyGame is previously defined.
*
* MyGame.lastRender keeps track of the last provided requestAnimationFrame timestamp.
* MyGame.lastTick keeps track of the last update time. Always increments by tickLength.
* MyGame.tickLength is how frequently the game state updates. It is 20 Hz (50ms) here.
*
* timeSinceTick is the time between requestAnimationFrame callback and last update.
* numTicks is how many updates should have happened between these two rendered frames.
*
* render() is passed tFrame because it is assumed that the render method will calculate
*          how long it has been since the most recently passed update tick for 
*          extrapolation (purely cosmetic for fast devices). It draws the scene.
*
* update() calculates the game state as of a given point in time. It should always
*          increment by tickLength. It is the authority for game state. It is passed 
*          the DOMHighResTimeStamp for the time it represents (which, again, is always 
*          last update + MyGame.tickLength unless a pause feature is added, etc.)
*
* setInitialState() Performs whatever tasks are leftover before the mainloop must run.
*                   It is just a generic example function that you might have added.
*/

;(function () {
  function main( tFrame ) {
    MyGame.stopMain = window.requestAnimationFrame( main );
    var nextTick = MyGame.lastTick + MyGame.tickLength;
    var numTicks = 0;

    //If tFrame < nextTick then 0 ticks need to be updated (0 is default for numTicks).
    //If tFrame = nextTick then 1 tick needs to be updated (and so forth).
    //Note: As we mention in summary, you should keep track of how large numTicks is.
    //If it is large, then either your game was asleep, or the machine cannot keep up.
    if (tFrame > nextTick) {
      var timeSinceTick = tFrame - MyGame.lastTick;
      numTicks = Math.floor( timeSinceTick / MyGame.tickLength );
    }

    queueUpdates( numTicks );
    render( tFrame );
    MyGame.lastRender = tFrame;
  }

  function queueUpdates( numTicks ) {
    for(var i=0; i < numTicks; i++) {
      MyGame.lastTick = MyGame.lastTick + MyGame.tickLength; //Now lastTick is this tick.
      update( MyGame.lastTick );
    }
  }

  MyGame.lastTick = performance.now();
  MyGame.lastRender = MyGame.lastTick; //Pretend the first draw was on first update.
  MyGame.tickLength = 50; //This sets your simulation to run at 20Hz (50ms)
  
  setInitialState();
  main(performance.now()); // Start the cycle
})();

Another alternative is to simply do certain things less often. If a portion of your update loop is difficult to compute but insensitive to time, you might consider scaling back its frequency and, ideally, spreading it out into chunks throughout that lengthened period. An implicit example of this is found over at The Artillery Blog for Artillery Games, where they adjust their rate of garbage generation to optimize garbage collection. Obviously, cleaning up resources is not time sensitive (especially if tidying is more disruptive than the garbage itself).

This may also apply to some of your own tasks. Those are good candidates to throttle when available resources become a concern.

Summary

I want to be clear that any of the above, or none of them, could be best for your game. The correct decision entirely depends on the trade-offs that you are willing (and unwilling) to make. The concern is mostly with switching to another option. Fortunately, I do not have any experience with this but I have heard it is an excruciating game of Whack-a-Mole.

An important thing to remember for managed platforms, like the web, is that your loop may stop execution for significant periods of time. This could occur when the user unselects your tab and the browser sleeps (or slows) its requestAnimationFrame callback interval. You have many ways to deal with this situation and this could depend on whether your game is single player or multiplayer. Some choices are:

  • Consider the gap "a pause" and skip the time.
    • You can probably see how this is problematic for most multiplayer games.
  • You can simulate the gap to catch up.
    • This can be a problem for long drops and/or complex updates.
  • You can recover the game state from a peer or the server.
    • This is ineffective if your peers or server are out-of-date too, or they don't exist because the game is single player and doesn't have a server.

Once your main loop has been developed and you have decided on a set of assumptions and tradeoffs which suit your game, it is now just a matter of using your decisions to calculate any applicable physics, AI, sounds, network synchronization, and whatever else your game may require.

Etiquetas y colaboradores del documento

Etiquetas: 
 Colaboradores en esta página: cnaucler
 Última actualización por: cnaucler,