Aprende a crear juegos en HTML5 Canvas

domingo, 5 de octubre de 2014

Doble búfer y escalado pixeleado

Es común que en los temas de desarrollo de videojuegos se vea sobre una técnica llamada doble búfer. La mayoría de los programas cuando dibujan en pantalla, lo hacen en tiempo real. Esto hace que uno pueda percibir como la pantalla se limpia y se vuelve a dibujar, causando un parpadeo molesto. Para evitar este efecto indeseado, la técnica consistía en dibujar toda la pantalla del juego en un lienzo oculto, y posteriormente dibujar este liezo final sobre el lienzo que se muestra al usuario. Esta es la técnica conocida bajo el nombre de doble búfer.

Sin embargo, la tecnología de HTML5 Canvas parece ya manejar esta técnica por detrás, por lo que ya no resulta necesario implementarla como en otros lenguajes.

Aun cuando este es el propósito principal del doble búfer, no es la única razón para la que es ocupada. Muchos efectos visuales avanzados como sustracción de colores, iluminación, distorsión y brillo se ejecutan sobre un búfer oculto antes de mostrarse al usuario, sobre todo en juegos 3D.

El día de hoy, aprenderemos como usar el doble búfer para hacer un efecto muy sencillo, que es el escalado pixeleado, el cual nos permitirá crear juegos con aspecto retro para pantallas grandes con HTML5 Canvas.

Para empezar, declararemos las variables que contendrán nuestro buffer y su contexto:
    var buffer = null,
        bufferCtx = null;
Dentro de la función "init", creamos de forma dinámica un elemento canvas que asignamos a la variable "buffer". Después asignamos a "bufferCtx" el contexto 2D de nuestro búfer, y finalmente asignamos el ancho y alto predefinido a nuestro búfer:
        // Load buffer
        buffer = document.createElement('canvas');
        bufferCtx = buffer.getContext('2d');
        buffer.width = 300;
        buffer.height = 150;
Finalmente, para dibujar nuestro búfer en el lienzo principal, hemos de modificar la función "repaint" de tal forma que la función "paint" envíe el contexto del búfer en lugar del contexto principal. Una vez que nuestro juego haya sido dibujado en el búfer, proseguimos a dibujar el lienzo principal limpiando la pantalla, y dibujando el resultado de nuestro búfer como una imagen, asignándole el ancho y alto del lienzo principal:

    function repaint() {
        window.requestAnimationFrame(repaint);
        paint(bufferCtx);

        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(buffer, 0, 0, canvas.width, canvas.height);
    }
Dado que ahora el tamaño de nuestro lienzo virtual en el juego es el del búfer y no el del lienzo principal, no olvides convertir todas las referencias a "canvas" dentro de las funciones "run" y "paint" por referencias a "buffer"; especialmente aquellas que obtienen los valores de su ancho y alto para definir los límites del escenario.

Ahora que nuestro doble buffer esta listo, es tiempo de realizar la mágia que hemos esperado desde el comienzo de esta entrada. De forma predefinida, el suavizado de imágenes escaladas está activado, por lo que para poder dibujar imágenes con efecto pixeleado, necesitamos desactivar dicho suavizado. Para hacer dicho efecto, agregamos esta línea a nuestro contexto principal dentro de la función "repaint", justo antes de dibujar nuestro búfer en el lienzo principal:
        ctx.imageSmoothingEnabled = false;
Así es, toda la complejidad de este efecto se reduce a una línea. Quizá ahora te preguntes ¿Por qué no simplemente asignamos esa línea desde el comienzo y nos evitamos todas las complicaciones del doble búfer? Pues bien, resulta que esta línea solo afecta a los dibujos escalados por el contexto que los dibuja, por lo que escalar el lienzo con CSS como lo hemos hecho antes no se vería afectado por esta línea.

La alternativa que se tendría a escalar el búfer de esta forma, sería escalar cada uno de los elementos dentro de nuestro lienzo, lo cual no solo sería complicado al tener que recalcular la posición y escala de cada elemento, si no que además impactaría de forma negativa al procesador, lo que podría hacer a nuestro juego muy lento en caso de ser ya un proyecto grande y complejo.

Podemos ver el resultado de este código a continuación:

[Canvas not supported by your browser]
Dado que el parámetro imageSmoothingEnabled aun no es un estándar,  es necesario agregar además las versiones con prefijo adecuados para cada motor:
        ctx.webkitImageSmoothingEnabled = false;
        ctx.mozImageSmoothingEnabled = false;
        ctx.msImageSmoothingEnabled = false;
        ctx.oImageSmoothingEnabled = false;

Doble búfer y llenado de pantalla


Otra ventaja del doble búfer es la facilidad con la que se puede aprovechar al máximo el årea donde se está dibujando. Centrar el contenido de nuestro búfer es bastante sencillo. Para empezar, necesitamos almacenar la escala del búfer, así como la compensación de distancia con respecto al centro:
    var bufferScale = 1,
        bufferOffsetX = 0,
        bufferOffsetY = 0;
Creamos una función resize como vimos en el tema Estirar el canvas y llenar la pantalla. Al comienzo, asignaremos el tamaño de nuestro lienzo principal al tamaño completo de la pantalla. Posteriormente, obtendremos la escala del búfer de la misma forma que aprendimos en la entrada anterior, y finalmente, calculamos la compensación mediante la diferencia del lienzo principal y el buffer escalado, dividido entre dos:
    function resize() {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
       
        var w = window.innerWidth / buffer.width;
        var h = window.innerHeight / buffer.height;
        bufferScale = Math.min(h, w);
       
        bufferOffsetX = (canvas.width - (buffer.width * bufferScale)) / 2;
        bufferOffsetY = (canvas.height - (buffer.height * bufferScale)) / 2;
    }
No olvides llamar la función "resize" tanto al inicio, como en el escucha de cambio de tamaño. Finalmente, para ver reflejados estos cambios, actualiza la función repaint para dibujar el buffer con los parámetros correspondientes:
        ctx.drawImage(buffer, bufferOffsetX, bufferOffsetY, buffer.width * bufferScale, buffer.height * bufferScale);
Puedes ver el resultado final en canvas.ninja/?js=snake-pixel, donde podrás comprobar que, independiente del tamaño del navegador, el juego se encontrará siempre centrado, tanto de forma horizontal como vertical.

De esta forma, es como hemos aprendido como mantener un lienzo con estilo pixeleado después de escalarlo gracias a un doble búfer.

Código final:

/*jslint bitwise:true, es5: true */
(function (window, undefined) {
    'use strict';
    var KEY_ENTER = 13,
        KEY_LEFT = 37,
        KEY_UP = 38,
        KEY_RIGHT = 39,
        KEY_DOWN = 40,
        
        canvas = null,
        ctx = null,
        buffer = null,
        bufferCtx = null,
        bufferScale = 1,
        bufferOffsetX = 0,
        bufferOffsetY = 0,
        lastPress = null,
        pause = true,
        gameover = true,
        fullscreen = false,
        body = [],
        food = null,
        //var wall = [],
        dir = 0,
        score = 0,
        iBody = new Image(),
        iFood = new Image(),
        aEat = new Audio(),
        aDie = new Audio();

    window.requestAnimationFrame = (function () {
        return window.requestAnimationFrame ||
            window.mozRequestAnimationFrame ||
            window.webkitRequestAnimationFrame ||
            function (callback) {
                window.setTimeout(callback, 17);
            };
    }());

    document.addEventListener('keydown', function (evt) {
        if (evt.which >= 37 && evt.which <= 40) {
            evt.preventDefault();
        }

        lastPress = evt.which;
    }, false);
    
    function Rectangle(x, y, width, height) {
        this.x = (x === undefined) ? 0 : x;
        this.y = (y === undefined) ? 0 : y;
        this.width = (width === undefined) ? 0 : width;
        this.height = (height === undefined) ? this.width : height;
    }

    Rectangle.prototype = {
        constructor: Rectangle,
        
        intersects: function (rect) {
            if (rect === undefined) {
                window.console.warn('Missing parameters on function intersects');
            } else {
                return (this.x < rect.x + rect.width &&
                    this.x + this.width > rect.x &&
                    this.y < rect.y + rect.height &&
                    this.y + this.height > rect.y);
            }
        },
        
        fill: function (ctx) {
            if (ctx === undefined) {
                window.console.warn('Missing parameters on function fill');
            } else {
                ctx.fillRect(this.x, this.y, this.width, this.height);
            }
        },
        
        drawImage: function (ctx, img) {
            if (img === undefined) {
                window.console.warn('Missing parameters on function drawImage');
            } else {
                if (img.width) {
                    ctx.drawImage(img, this.x, this.y);
                } else {
                    ctx.strokeRect(this.x, this.y, this.width, this.height);
                }
            }
        }
    };

    function random(max) {
        return ~~(Math.random() * max);
    }

    function resize() {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
       
        var w = window.innerWidth / buffer.width;
        var h = window.innerHeight / buffer.height;
        bufferScale = Math.min(h, w);
       
        bufferOffsetX = (canvas.width - (buffer.width * bufferScale)) / 2;
        bufferOffsetY = (canvas.height - (buffer.height * bufferScale)) / 2;
    }

    function reset() {
        score = 0;
        dir = 1;
        body.length = 0;
        body.push(new Rectangle(40, 40, 10, 10));
        body.push(new Rectangle(0, 0, 10, 10));
        body.push(new Rectangle(0, 0, 10, 10));
        food.x = random(buffer.width / 10 - 1) * 10;
        food.y = random(buffer.height / 10 - 1) * 10;
        gameover = false;
    }

    function paint(ctx) {
        var i = 0,
            l = 0;
        
        // Clean canvas
        ctx.fillStyle = '#030';
        ctx.fillRect(0, 0, buffer.width, buffer.height);

        // Draw player
        ctx.strokeStyle = '#0f0';
        for (i = 0, l = body.length; i < l; i += 1) {
            body[i].drawImage(ctx, iBody);
        }
        
        // Draw walls
        //ctx.fillStyle = '#999';
        //for (i = 0, l = wall.length; i < l; i += 1) {
        //    wall[i].fill(ctx);
        //}
        
        // Draw food
        ctx.strokeStyle = '#f00';
        food.drawImage(ctx, iFood);

        // Draw score
        ctx.fillStyle = '#fff';
        ctx.fillText('Score: ' + score, 0, 10);
        
        // Debug last key pressed
        //ctx.fillText('Last Press: '+lastPress,0,20);
        
        // Draw pause
        if (pause) {
            ctx.textAlign = 'center';
            if (gameover) {
                ctx.fillText('GAME OVER', 150, 75);
            } else {
                ctx.fillText('PAUSE', 150, 75);
            }
            ctx.textAlign = 'left';
        }
    }

    function act() {
        var i = 0,
            l = 0;
        
        if (!pause) {
            // GameOver Reset
            if (gameover) {
                reset();
            }

            // Move Body
            for (i = body.length - 1; i > 0; i -= 1) {
                body[i].x = body[i - 1].x;
                body[i].y = body[i - 1].y;
            }

            // Change Direction
            if (lastPress === KEY_UP && dir !== 2) {
                dir = 0;
            }
            if (lastPress === KEY_RIGHT && dir !== 3) {
                dir = 1;
            }
            if (lastPress === KEY_DOWN && dir !== 0) {
                dir = 2;
            }
            if (lastPress === KEY_LEFT && dir !== 1) {
                dir = 3;
            }

            // Move Head
            if (dir === 0) {
                body[0].y -= 10;
            }
            if (dir === 1) {
                body[0].x += 10;
            }
            if (dir === 2) {
                body[0].y += 10;
            }
            if (dir === 3) {
                body[0].x -= 10;
            }

            // Out Screen
            if (body[0].x > canvas.width - body[0].width) {
                body[0].x = 0;
            }
            if (body[0].y > canvas.height - body[0].height) {
                body[0].y = 0;
            }
            if (body[0].x < 0) {
                body[0].x = canvas.width - body[0].width;
            }
            if (body[0].y < 0) {
                body[0].y = canvas.height - body[0].height;
            }

            // Food Intersects
            if (body[0].intersects(food)) {
                body.push(new Rectangle(0, 0, 10, 10));
                score += 1;
                food.x = random(buffer.width / 10 - 1) * 10;
                food.y = random(buffer.height / 10 - 1) * 10;
                aEat.play();
            }

            // Wall Intersects
            //for (i = 0, l = wall.length; i < l; i += 1) {
            //    if (food.intersects(wall[i])) {
            //        food.x = random(canvas.width / 10 - 1) * 10;
            //        food.y = random(canvas.height / 10 - 1) * 10;
            //    }
            //
            //    if (body[0].intersects(wall[i])) {
            //        gameover = true;
            //        pause = true;
            //    }
            //}

            // Body Intersects
            for (i = 2, l = body.length; i < l; i += 1) {
                if (body[0].intersects(body[i])) {
                    gameover = true;
                    pause = true;
                    aDie.play();
                }
            }
        }
        // Pause/Unpause
        if (lastPress === KEY_ENTER) {
            pause = !pause;
            lastPress = null;
        }
    }

    function repaint() {
        window.requestAnimationFrame(repaint);
        paint(bufferCtx);

        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.imageSmoothingEnabled = false;
        ctx.drawImage(buffer, bufferOffsetX, bufferOffsetY, buffer.width * bufferScale, buffer.height * bufferScale);
    }

    function run() {
        setTimeout(run, 50);
        act();
    }

    function init() {
        // Get canvas and context
        canvas = document.getElementById('canvas');
        ctx = canvas.getContext('2d');
        canvas.width = 600;
        canvas.height = 300;

        // Load buffer
        buffer = document.createElement('canvas');
        bufferCtx = buffer.getContext('2d');
        buffer.width = 300;
        buffer.height = 150;

        // Load assets
        iBody.src = 'assets/body.png';
        iFood.src = 'assets/fruit.png';
        aEat.src = 'assets/chomp.m4a';
        aDie.src = 'assets/dies.m4a';

        // Create food
        food = new Rectangle(80, 80, 10, 10);

        // Create walls
        //wall.push(new Rectangle(50, 50, 10, 10));
        //wall.push(new Rectangle(50, 100, 10, 10));
        //wall.push(new Rectangle(100, 50, 10, 10));
        //wall.push(new Rectangle(100, 100, 10, 10));

        // Start game
        resize();
        run();
        repaint();
    }
    
    window.addEventListener('load', init, false);
    window.addEventListener('resize', resize, false);
}(window));

13 comentarios:

  1. que bueno, gracias por volver, he leido que tambien sirve ara optimizar el juego?

    ResponderEliminar
    Respuestas
    1. Optimiza muchos efectos visuales, como el que ya he mostrado, y optimiza el re-dibujado en otras tecnologías fuera de HTML5 Canvas, como expliqué al comienzo. ¿No se si por ahí iba tu duda, o quizá preguntabas por otro caso diferente?

      Eliminar
  2. Buen tuto y muy bien explicado, pero hay un problema: en Firefox sigue haciendo el suavizado (Firefox v32.0.3 en Linux Mint 17 Cinnamon).

    Y ya que me he puesto a comentar te hago una sugerencia: podrías poner en algún sitio las entradas por orden. Está muy bien el índice como lo tienes puesto pero si no estás atento se te pasan entradas nuevas como esta que no la había visto.

    Un saludo!!

    ResponderEliminar
    Respuestas
    1. Parece ser que en los Sistemas Operativos basados en Linux la propiedad aun no está estandarizada. ¿Puedes confirmarme si cambiar a ctx.mozImageSmoothingEnabled funciona o tampoco lo hace?

      Sobre poner las entradas en orden cronológico, a veces se ponen tiempo después temas que funcionan mejor en lugares anteriores como habrás notado en este, por lo que temo tener las entradas en orden cronológico podrían causar bastante caos y confusión. Aún así, las redes sociales inevitablemente ordenan las entradas de forma cronológica, por lo que son un buen medio para conocer las entradas más recient s en el blog. También puedes suscribirte por correo o RSS para enterarte de las entradas más recientes en cuanto sean públicas.

      Eliminar
    2. Sí, con el prefijo "moz" si funciona. En Chromium funciona sin prefijo. También he probado en Windows 8.1 y con Firefox 29 y Firefox 32 no funciona (hay que poner prefijo). Para terminar lo he testeado también en Internet Explorer 11 y hay que poner ctx.msImageSmoothingEnabled. Así que por ahora solamente Chrome lo admite sin prefijo.

      Eliminar
    3. Estuve investigando largo rato, pero no encontré información del soporte de esta propiedad hoy día. De cualquier forma, dado tu reporte, he agregado un pequeño fix para que funcione bien el código ahora en cualquier navegador actual. Gracias por tu ayuda.

      Dato curioso: Parece ser que Firefox si soporta la propiedad sin prefijo para Mac...

      Eliminar
  3. Realmente interesante, pero podrías decirme, ¿Es realmente necesario todo esto del double buffering con canvas?, ya has dicho que canvas tiene un modo de procesar esto por debajo de la mesa para evitar el flickering, pero me queda la duda con respecto a los efectos visuales, estoy ansioso por leer tu respuesta...

    ResponderEliminar
    Respuestas
    1. Solo es necesario si quieres aplicar algún efecto especial sobre todo el lienzo, tal como el efecto pixel que mostramos aquí. Si no aplicas uno, realmente no tiene mucho sentido que hagas uso de él...

      Eliminar
    2. ¿Pero ese efecto no se puede conseguir sin el double buffering?

      Eliminar
    3. Hasta donde he podido investigar, esta es la mejor forma y más sencilla de hacerlo...

      Eliminar
    4. Mira he encontrado un articulo interesante que habla del double buffering como medio para mejorar el rendimiento de tus juegos, lo dejo aquí por si a alguien le interesa,,,

      http://www.html5rocks.com/en/tutorials/canvas/performance/

      Eliminar
  4. Hola, Karl Tayfer, me han servido mucho tus tutoriales para adentrarme en el mundo de la programación web y de videojuegos. Actualmente se me hace difícil pensar o idear un juego en cual enfocarme pero mientras espero esa inspiración sigo aprendiendo Javascript acompañado con HTML y CSS.

    Hoy estuve leyendo varias páginas y vi una propiedad muy llamativa de CSS el cual logra el mismo efecto que explicas en este post, lo apliqué al tutorial anterior en tu misma página con el inspector de Chrome.
    Apliqué image-rendering: pixelated; dentro de style del canvas y lo comparé con el juego pixelado que explicas aquí y veo que tiene el mismo efecto. Es una muy buena alternativa, aunque tal vez no disponible para todos los navegadores. Te dejo una captura:

    http://2.bp.blogspot.com/-hswWm6ARNMA/VXsdGbMwf4I/AAAAAAAAAVE/xAGPNewmkrc/s1600/Screenshot%2B2015-06-12%2Bat%2B12.46.57%2BPM.png

    Gracias por los tutoriales, me gusta y admiro lo que haces por extender tus conocimientos. Saludos.

    ResponderEliminar
    Respuestas
    1. ¡Muchas gracias por tu comentario! Desafortunadamente, tal como dices, esta propiedad aún no está disponible para todos los navegadores, lo que no le hace una buena opción si realmente deseas conseguir este efecto en la mayor cantidad de los usuarios.

      Igualmente, estamos a la espera de que sea un estándar para facilitar esta tarea, y de cualquier forma ha sido una maravillosa oportunidad para explicar los doble buffers, que sirven además para muchas otras opciones.

      Eliminar