Web Workers con canvas
- Actualización Noviembre 2012: He actualizado este tema agregando código resaltado y revisando algunas cosas.
- Actualización Junio 2013: Revisión del soporte transferable en Firefox 22.
Soporte Web Worker, TypedArray y Transferable
En html5test.com se puede comprobar el soporte de las características de HTML5 en los navegadores, observándose que los actuales soportan los Web Workers, aunque con distinto grado en la adopción de las particularidades de esta característica. En este tema intento aplicar lo aprendido en un caso de uso concreto: mejorar los tiempos de proceso en el elemento Canvas del Generador de fractales. En lugar de hacer cambios en esa aplicación sin saber como resultará será mejor hacer unas pruebas previas con algo más sencillo. Esta página contiene siete ejemplos que generan un mismo fractal Julia. Inicialmente he tomado la configuración que aparece en el Trazador de gráficas matemáticas para el ejemplo expuesto en la sección Fractales de Julia en c=(-0.75, 0), aunque aquí también pueden modificarse los parámetros para generar otros ejemplos.
Dado el diferente nivel de soporte de los navegadores con los Workers, es necesario detectarlo previamente y actuar en su caso. Hay scripts que pueden ayudar en esto de la detección de características HTML5, como modernizr.com. Pero prefiero hacerme mi propio evaluador test-worker.js. El test se realiza llamando a la función var t = testWorker()
. Luego accedemos a cada soporte, por ejemplo, con t.testArray
que contiene un booleano para ver si pasó el test de enviar un array convencional. Al arrancar la página se ejecutará este test, cuyo resultado mostramos a continuación:
Ejemplo:
Test de soporteworker
y prueba de envío y transferencia de un arrayBuffer
usando el módulo test-worker.js con la función testWorker()
:Código completo de este ejemplo
Se evalúa el soporte Worker
y de arrayBuffer
. Éste último permite una mayor velocidad en la transferencia de datos, pues éstos se envían como datos binarios, algo que hasta ahora JavaScript no tenía. Esta técnica se conoce como Typed Array (arrays tipados o algo así). Un arrayBuffer
es una sucesión de bytes que toman un sentido cuando se obtiene una vista de los mismos. Así Uint8Array
es una vista de enteros de 8 bits sin signo, lo que en el lenguaje C se conoce como unsigned char
. La vista Uint8ClampedArray
es especial para usar con <canvas>
, como veremos en los últimos ejemplos de esta página.
Se realiza un testArray
para verificar que se puede enviar un array convencional de longitud 1, es decir, declarado como new Array(1)
. Si esta prueba es correcta significa que se ha verificado la comunicación con el Worker. Si hay soporte arrayBuffer
se realiza la prueba testBuffer
y testTransferable
enviando las vistas Uint8ClampedArray
o Uint8Array
si no soporta la anterior.
El concepto de Transferable se explica en Cloning and Transferring ArrayBuffers and Views. Pues no debemos olvidar que el Worker no tiene acceso a variables globales (ni al DOM). Todos los datos a comunicar con el Worker debe ser copiados (clonados) antes de ser enviados o recibidos. Si los datos son binarios el clonado es más rápido. Pero lo que resulta (casi) inmediato es la transferencia del dato, o mejor dicho, de la propiedad del dato. Lo veremos en ejecución en los ejemplos, aunque también puede consultar el artículo html5rocks.com: TRANSFERABLE OBJECTS: LIGHTNING FAST! que expone un test sobre la transferencia de datos y algunas notas interesantes. Por ahora esto de Transferable sólo lo ejecuta Chrome, pero será un soporte general en el futuro dado que arregla el problema del coste del paso de datos en los Workers.
Este test, que servirá para gestionar los ejemplos de esta página, ha dado los siguientes resultados con los navegadores actuales:
Navegador | worker | arrayBuffer | Uint8Array | Uint8ClampedArray | testArray | testBuffer | testTransferable |
---|---|---|---|---|---|---|---|
Chrome 18.0 | OK | OK | OK | OK | OK | OK | OK |
Firefox 11.0 | OK | OK | OK | OK | OK | OK | NO |
Safari 5.1 | OK | OK | OK | NO | OK | OK | NO |
Opera 11.62 | OK | OK | OK | NO | OK | NO | NO |
El test nos permite saber el soporte y actuar en consecuencia. Por ejemplo, Opera 11.62 permite Worker pero no los arrayBuffer, por lo que la ejecución deberá realizarse clonando un array convencional (testArray
es OK para este navegador).
Nota soporte transferable en Firefox 18-22
30 junio 2013
Firefox 18 añadió soporte para transferable en agosto 2012, pero incluso hasta la actual Firefox 22 (Junio 2013) hay un malfuncionamiento que se detalla en un nota aclaratoria de un tema anterior. En este tema he modificado ahora los ejemplos 5, 6 y 7 que utilizan el soporte transferable para que funcionen también en Firefox 22. En el código de cada ejemplo se comentan los cambios realizados.
En este tema desarrollaré una serie de ejemplos mientras intento aplicar el uso de Worker para mejorar la respuesta. Como aplicación práctica voy a usar el tema de Fractales. Es en principio un caso de uso que se presta a utilizar Workers, pues se trata de un bucle que realiza muchos cálculos y genera un volumen importante de datos. Mientras se ejecuta no es necesario interaccionar con el bucle y así nos queda la interfaz de usuario sin bloquear.
Configurar parámetros para generar Fractal
Estos campos configuran la ejecución para todos los ejemplos de esta página. Con estos valores iniciales he realizado el estudio comparativo para tratar de mejorar los tiempos de ejecución.
Ejemplo:
Configuración para generar el fractal en todos los ejemplos de esta página:Código completo de este ejemplo
Ejemplo 1: Fractal con canvas
Ejemplo:
Código completo de este ejemplo
La ejecución del fractal se basa en iterar por todos los puntos del plano. Con un plano de 200×200 serán 40000 iteraciones. Como mínimo, pues si recordamos el algoritmo para seleccionar puntos que expuse en el tema Fractales con el trazador de gráficas:
Declarar plano XY [x1,x2]×[y1,y2] Bucle x=x1 hasta x=x2 Bucle y=y1 hasta y=y2 Bucle seleccionador de puntos Si (x,y) cumple la condición de selección Salir de este bucle Fin si Fin bucle Si completamos este bucle Pintar punto (x,y) Fin si Fin bucle Fin bucle
Para cada una de esas 40000 iteraciones se producirán más en el bucle seleccionador de puntos. Los ejemplo de partida en esta página se corresponde con un Fractal de Julia con el parámetro (-0.75,0), y un valor de escape de 30. Por lo tanto el máximo de iteraciones serían 200×200×30 = 1200000, pero como todos los puntos no escapan, al final se ejecuta con 346264 iteraciones.
En este primer ejemplo se aplica ese algoritmo directamente para verter los puntos seleccionados en el elemento Canvas, SIN WORKER. En Chrome 18.0 esto se ejecuta en unos 340 ms. Voy a seguir poniendo estas mediciones siempre referidas a este navegador, pero entendiendo que son relativas a los medios empleados. Pues lo que interesa son los comparativos entre los distintos ejemplos más que los valores absolutos.
Ejemplo 2: Fractal con canvas y setTimeout
Ejemplo:
Código completo de este ejemplo
Dado que no estamos usando Worker, la ejecución anterior bloquea el resto de ejecuciones del navegador. Una forma de evitarlo sin usar Worker es mediante setTimeout()
. Esto lo expliqué en un tema anterior Simulando concurrencia en JavaScript. Además el efecto visual de ir presentando lo que se va generando es una medida que ayuda al usuario a saber que algo se está ejecutando. Puede ser incluso preferible aunque la ejecución se demore hasta en torno a los 1000 ms (Chrome 18.0).
Ejemplo 3: Fractal con canvas y un worker con array convencional
Ejemplo:
Código completo de este ejemplo
worker-fractal.js
.¿Y qué pasa con los Workers? ¿Pueden mejorar lo anterior?. Este ejemplo usa un único Worker y un array convencional para pasar los datos hacia y desde el Worker. Pero el tiempo de 360 ms. (Chrome 18.0) no mejora con respecto al primer ejemplo. En forma resumida el script en esta página hace lo siguiente:
La función ejecutarFractal3()
envía los datos con postMessage()
al Worker. Son los parámetros necesarios para ejecutar el bucle seleccionador de puntos en el script worker-fractal.js. De forma resumida hace lo siguiente:
Se devuelve un array convencional con los valores k
de escape. Las posiciones (x,y) se deducirán de la ordenación de los puntos en el bucle. De nuevo en la página principal el manejador del evento message
recibe el objeto event.data
. Iterará por el array de valores k
, tantos como puntos hay en el plano, dando un color a cada punto en función de ese valor de escape. El punto (x,y) se obtiene de la ordenación del bucle, pintándose con fillRect()
.
Ejemplo 4: Fractal con canvas y tres workers con array convencional
Ejemplo:
Código completo de este ejemplo
worker-fractal.js
(el mismo del ejemplo anterior).¿Y si dividimos la ejecución entre varios Workers?. En este ejemplo usamos tres Workes. Cada uno se encarga de un tercio del fractal usando el mismo script vinculado worker-fractal.js con el array convencional como antes. No hay mejora, pues el tiempo es ahora de 370 ms (Chrome 18.0).
Ejemplo 5: Fractal con canvas, worker,arrayBuffer y transferable
Ejemplo:
Código completo de este ejemplo
worker-fractal5.js
.Ya uno empieza a intuir que el problema no está tanto en el coste de ejecución del script del Worker como en el trasvase de datos. Estos ejemplos necesitan un total de 200×200=40000 puntos. A la vuelta del Worker gestionamos el pintado de puntos en el Canvas usando el valor de escape para dotar de diferentes colores al punto (x,y), obtenidos por el orden que ocupan según fueron generados en el bucle:
y = n%alto;
x = Math.floor(n/alto);
Recordemos que los datos que se comunican con el Worker deben ser clonados antes de ser enviados. Y esto es lo que aporta mayor coste. Una forma para evitarlo es usar los TypedArray. El tipo ArrayBuffer nos permite manipular los datos como binarios, pues se trata de un buffer de bytes sin ninguna estructura que pueda entorpecer el clonado. En el script del Worker hacemos esto:
if (testBuffer==1){
arrayPuntos = new Uint16Array(tamanyo);
} else {
arrayPuntos = new Array(tamanyo);
}
...BUCLE EJECUCIÓN...
if (testTransferable){
self.postMessage(arrayPuntos, [arrayPuntos.buffer]);
} else {
self.postMessage(arrayPuntos);
}
Si el navegador soporta ArrayBuffer usamos el tipo Uint16Array
para crear un array pero basado en un ArrayBuffer. Ese array se comporta exactamente igual que uno convencional, pero la clonación es más rápida. En el script contemplo la posibilidad de que no se soporte ArrayBuffer, usando entonces un array convencional con new Array()
.
Tras finalizar la ejecución en el Worker devolvemos los datos simplemente con self.postMessage(arrayPuntos)
, pudiendo ser ese array uno convencional o un ArrayBuffer. Pero si el navegador soporta la transferencia (Transferable) de la propiedad del ArrayBuffer (ver Transferring ArrayBuffers) habremos conseguido eliminar el clonado. Porque lo que realmente se está haciendo es transferir la propiedad de la variable. Esto se hace poniendo el buffer como segundo argumento en self.postMessage(arrayPuntos, [arrayPuntos.buffer])
.
Sólo Chrome 18.0 a fecha de esta prueba soporta transferable (y obviamente ArrayBuffer). En el ejemplo he desglosado los tiempos de cada parte, ocupando sólo unos 25 ms la ejecución en el Worker (midiendo desde antes de enviar los datos de partida hasta después de recibir los datos de resultado). Mientras que el volcado de puntos en el canvas, que se ejecuta en esta página principal, ocupa unos 313 ms. Pero aún así el tiempo total de 338 ms no se ha reducido mucho con respecto a ejemplos anteriores.
Ejemplo 6: Fractal con canvas, worker,arrayBuffer, transferable y putImageData
Ejemplo:
Código completo de este ejemplo
worker-fractal6.js
.Con el ejemplo del apartado anterior no rebajamos mucho el tiempo. Pero al menos se dividió el problema en dos partes. Hemos resuelto lo del Worker, al menos si hay soporte de Transferable. Ahora nos queda ver que pasa con el volcado de puntos en el elemento Canvas. ¿Podemos rebajar esos 313 ms del Canvas que es un valor grande comparado con los 25 ms del Worker?
El elemento <canvas>
permite la manipulación a nivel de píxeles (Ver Pixel manipulation). Podemos crear un objeto ImageData
con context.createImageData(sw,sh)
. Se trata de un objeto con las dimensiones dadas sw,sh
en píxeles que nos servirá para gestionar el contenido del Canvas. Así con getImageData(sx, sy, sw, sh)
recuperamos el contenido de los píxeles del Canvas y con putImageData(imagedata, dx, dy)
ponemos un contenido. Los datos que representan una imagen son una serie continua de valores red,green,blue,alfa
de tipo byte, es decir, enteros en el rango [0..255]
. No hay ninguna estructura subyacente, es decir, se trata de un buffer de datos. JavaScript genera la imagen extrayendo los bytes de forma consecutiva en grupos de cuatro y aplicándolos a cada punto del Canvas, empezando en la esquina superior izquierda y en sentido horizontal-vertical.
Esta forma es más rápida que ir llenando píxeles de 1×1 con fillRect(x,y,1,1)
, como hacia en los ejemplos anteriores. Al igual que antes, la ejecución sigue desglosada en dos partes, pero ahora usamos putImageData()
para volcar el Fractal en el Canvas. Veámos todo el proceso de forma resumida:
Desde la página ejecutamos lo necesario para generar y pintar un Fractal, enviando los datos necesarios al Worker:
Según admita o no el arrayBuffer hemos preparado un array de salida que también podrá enviarse con transferencia si es soportado. Esta parte nada tiene que ver con la generación en el Worker, pero así los datos le llegan antes.
En el Worker se reciben los datos donde se ejecutará el script que genera el Fractal y también en este caso adjudica los colores a cada punto. Esto antes no se hacia en el Worker sino en la página principal. En pseudo-código es algo así:
Declarar un arrayBuffer Uint8ClampedArray Bucle x<ancho Bucle y<alto Bucle k<escape Si no escapa salir Fin bucle Si k<escape red = funcion(k) green = funcion(k) blue = funcion(k) alfa = 255 En otro caso red = un valor red green = un valor green blue = un valor blue alfa = 255 Fin si Poner red,green,blue,alfa en el arrayBuffer Fin bucle Fin bucle Devolver el arrayBuffer
Necesitamos pasar el grupo
red, green, blue, alfa
que son valores enteros[0..255]
que representan un byte para el color y opacidad de cada punto. La opacidad que en CSS esopacity
, es un valor en el rango[0..1]
pero que se aplica al rango[0..255]
, siendo el máximo el de mayor opacidad.Hay que observar que el arrayBuffer declarado debe ser
Uint8ClampedArray
que es especial para luego ser volcado en el Canvas conputImageData()
. En todo caso y aunque en este pseudo-código no aparece, en el script del ejemplo se observa la posibilidad de que este tipo no se soporte, en cuyo caso se usarán otros tipos pero a costa de empeorar los tiempos.De vuelta en la página con los datos del Worker volcamos los puntos en el Canvas:
Los datos vienen en el arrayBuffer
event.data
pudiéndose acceder a ellos como si fuera un array convencional. Veáse que no tenemos que preocuparnos de como vienen los datos en el arrayBuffer, sino simplemente traspasarlo uno tras otro alImageData
.
Esta acción de manipulación de píxeles en el Canvas con putImageData()
es más rápida que el uso de fillRect()
para pintar cada punto. El tiempo total baja hasta los 25 ms (en segunda ejecución), que comparado con los 338 ms del ejemplo anterior es un ahorro muy importante. El tiempo de generación es similar, por lo que todo el ahorro se consigue en el vertido en el Canvas. Por supuesto, siempre que se soporte lo necesario: Transferable
, Uint8ClampedArray
y putImageData
.
Ejemplo 7: Fractal con canvas, worker, arrayBuffer, transferable y putImageData, gestionado en secciones
Ejemplo:
Código completo de este ejemplo
worker-fractal7.js
.Por último y como una mejora en la presentación, podemos aplicar un efecto de "cortinilla" para que el fractal vaya presentándose a medida que se genera. Algo como el segundo ejemplo donde se usaba setTimeout
. Lo que hacemos es ejecutar el fractal de forma seccionada, generando columnas de 1×alto
puntos en el Worker e irlas enviando al Canvas para que las vaya pintando a medida que las reciba. El tiempo final no se incrementa demasiado, pues pasa de 25 ms a unos 50 ms, pero el efecto final para el usuario es más satisfactorio. Esto apenas se aprecia con generaciones que ocupan tan poco tiempo como en este ejemplo, pero con otras más pesadas si es de agradecer que se vayan presentando los datos a medida que se generan.
Resumen de ejecución con Web Workers en navegadores
Por último ejecutaré los ejemplos en varios de los navegadores actuales. Anotaré los tiempos más frecuentes tras ejecutar repetidas veces el mismo ejemplo. No pretendo hacer un registro estadístico con estos datos, sino más bien mostrar el nivel de soporte pues los valores absolutos dependen del entorno donde se esté ejecutando. Los tiempos se dan en milisegundos, midiendo desde antes de enviar los datos al Worker hasta que se recibe el último píxel y se pone en el Canvas.
Navegador | 1) sólo canvas | 2) setTimeout | 3) worker + array convencional | 4) tres workers + array convencional | 5) worker + arrayBuffer + transferable | 6) ... + putImageData | 7) ... + seccionado | No soporta |
---|---|---|---|---|---|---|---|---|
Chrome 18.0 | 340 | 1000 | 360 | 370 | 338 | 25 | 50 | |
Firefox 11.0 | 900 | 1800 | 920 | 920 | 910 | 150 | 240 | Transferable |
Safari 5.1 | 320 | 990 | 340 | 320 | 520 | 820 | 425 | Uint8ClampedArray, Transferable |
Opera 11.62 | 360 | 500 | 350 | 370 | 370 | 330 | 460 | Uint8ClampedArray, testBuffer, Transferable |
En resumen, los Web Workers pueden resultar apropiados para ejecutar algoritmos que manejen puntos para un Canvas, pero cuando la característica de la transferencia (transferable) está soportada se consigue el mejor rendimiento.