18 – Como hacer una blob animado en HTML - Parte III

Posted on Sep 18, 2021 – Updated on Sep 18, 2021 - by Franco
Cover del post

Bienvenidos a la tercera parte de nuestro tutorial sobre como crear un blob animado usando solo Canvas API. Si te perdiste las partes anteriores puedes encontrarlas a continuación:

  • Parte I: Aprenderemos a utilizar el API de HTML Canvas para dibujar formas primitivas. link
  • Parte II: Haremos un pequeño anillo 3D proyectado en 2 dimensiones. link
  • Parte III: Haremos una Esfera completa y le añadiremos rotación en los tres ejes. Este
  • Parte IV: Distorsionaremos la esfera para hacerla parecer un blob. (Próximamente)
  • Parte V: Le agregaremos interacciones con el mouse. (Próximamente)
  • Parte VI: Le agregaremos controles interactivos para ‘jugar’ con los parametros de nuestra esfera. (Próximamente)

Recapitulación:

Resultado post 2, circunferencia 30 deg

Hasta ahora, hemos hecho una circunferencia. El buen observador puede que haya notado que nuestra última representación de la circunferencia tiene un ligero problema: Los círculos más cercanos a la cámara se ven igual de grandes que los más alejados, así no se comportan los objetos en la vida real, corrijamoslo rápidamente.

Corrigiendo nuestra perspectiva

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	//...
	const point_in_2d = {
		x: getProjection(distance, point.x, point.z, z_camera),
		y: getProjection(distance, point.y, point.z, z_camera),
	}

	radius = radius + (point.z/20) 			// AGREGAMOS ESTO

	ctx.fillStyle = color;				// cambiamos el color de relleno a rojo
	// ...
}

Al hacer esto, reducimos los circulos ligeramente más cercanos a la cámara.

Circunferencia con circulos

Dibujando una esfera

Ahora ya podemos dibujar una esfera. Visto de cierta manera una esfera no es más que una serie de circunferencias de distinto radio, así que tenemos que dibujar varias circunferencias reduciendo poco a poco su circunferencia hasta llegar a los polos.

Primero, refactoricemos nuestro generador de circunferencia a una función draw_circumference()

1
2
3
4
5
6
7
8
9
function draw_circumference(points_in_circle, radius, fixed_y) {
	const arc_distance = (2 * Math.PI) / points_in_circle;
	for(var n = 0; n < points_in_circle; n++){
		const x = Math.cos(arc_distance * n) * radius; 
		const y = fixed_y; 		// nuestro circulo será paralelo al plano Y.
		const z = Math.sin(arc_distance * n) * radius;
		points.push({x, y, z}); // creamos el punto y lo insertamos en el arreglo.
	}
}

Ahora dibujaremos un cilindro. Para hacerlo, simplemente llamares a la función cambiando la posición y de la circunferencia:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function draw_circumference(points_in_circle, radius, fixed_y){
	const arc_distance = (2 * Math.PI) / points_in_circle;
	for(var n = 0; n < points_in_circle; n++){
		const x = Math.cos(arc_distance * n) * radius; 
		const y = fixed_y; 		// nuestro circulo será paralelo al plano Y.
		const z = Math.sin(arc_distance * n) * radius;
		points.push({x, y, z}); // creamos el punto y lo insertamos en el arreglo.
	}
}
const radius = 20; 	// radio del circulo
const points_in_circle = 100;
const arc_distance = (2 * Math.PI) / points_in_circle;

// dibujando un cilindro.
draw_circumference(points_in_circle, radius, 0);
draw_circumference(points_in_circle, radius, 1);
draw_circumference(points_in_circle, radius, 2);
draw_circumference(points_in_circle, radius, 3);
draw_circumference(points_in_circle, radius, 4);
draw_circumference(points_in_circle, radius, 5);

Resultado 6 circunferencias

Cuando empezamos a repetir código muy similar es un indicador que estamos haciendo algo mal (DRY). Refactorizémoslo para usar un bucle for.

1
2
3
4
5
6
7
8
9
// dibujando un cilindro.
const radius = 20; 	// radio del circulo
const points_in_circle = 100;
const arc_distance = (2 * Math.PI) / points_in_circle;
const levels = 20;

for (var i = 0; i < levels; i++){
	draw_circumference(points_in_circle, radius, i);
}

Ahora solo tenemos que ajustar la variable levels para cambiar la cantidad de circulos:

Resultado 20 circunferencias

Si queremos hacer un circulo podriamos intentar modificar el radio en cada nivel hasta quedarnos con un radio de 1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// dibujando un cilindro.
const radius = 20; 	// radio del circulo
const points_in_circle = 100;
const arc_distance = (2 * Math.PI) / points_in_circle;
const levels = 20;

for (var i = 0; i < levels; i++){
	const radius_mod = radius - i;
	draw_circumference(points_in_circle, radius_mod, i);
}

Pero como podemos ver, eso nos daría un cono, dado que el radio de una esfera no se reduce linealmente. 😐

Resultado un cono

Ahora, tenemos que hacer un poquito de matemáticas para calcular cuantos niveles necesitamos para construir nuestro circulo, y por cuanto reducir el radio de nuestra esfera. Como siempre, puedes saltarte esta parte.

La forma más sencilla de verlo, es dibujar una circunferencia rotada en 90 grados y veremos que el radio de nuestra circunferencia rotada vendría a ser el coseno(angulo) actual. Mientras que el seno(angulo) nos dará la distancia del centro.

1
2
	let radius = Math.cos(beta) * this.radius
	let y = Math.sin(beta) * this.radius;

Reemplazemos estas formulas en nuestro código:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// dibujando una esfera.
const radius = 20; 	// radio del circulo
const points_in_circle = 100;
const arc_distance = (2 * Math.PI) / points_in_circle;

for (var i = 0; i < points_in_circle; i++){
	const radius_mod = Math.cos(arc_distance * i) * radius;
	let y = Math.sin(arc_distance * i) * radius;
	draw_circumference(points_in_circle, radius_mod, y);
}

Resultado de esfera

Wow. Ya tenemos nuestra esfera: 🔴.

Por si acaso, este es el código completo que tenemos hasta ahora:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<html lang="en">
	<head>
		<meta charset="UTF-8">
  		<meta http-equiv="X-UA-Compatible" content="IE=edge">
  		<meta name="viewport" content="width=device-width, initial-scale=1.0">
  		<title>Sphere</title>
		<style>
			/* ESTILOS */
		</style>
	</head>
	<body>
		<h1> Sphere.  </h1>
		<h2> <span id="x"> X </span> WeMake.  </h2>
		<canvas id="canvas"></canvas>
	<script>
		// 0. Setup
		const canvas = document.getElementById('canvas')
		const ctx = canvas.getContext('2d')
		const w = canvas.width = 720 // Ancho: esto puede variarse al tamaño que quieran
		const h = canvas.height = 720 // Alto: esto puede variarse al tamaño que quieran

		function getProjection(distance, xy, z, z_camera) {
			return ((distance * xy) / (z_camera- z))
		}

		const points = [] 	// lista o arreglo de puntos

		function draw_circumference(points_in_circle, radius, fixed_y){
			const arc_distance = (2 * Math.PI) / points_in_circle;
			for(var n = 0; n < points_in_circle; n++){
				const x = Math.cos(arc_distance * n) * radius; 
				const y = fixed_y; 		// nuestro circulo será paralelo al plano Y.
				const z = Math.sin(arc_distance * n) * radius;
				points.push({x, y, z}); // creamos el punto y lo insertamos en el arreglo.
			}
		}
		// dibujando un cilindro.
		const radius = 20; 	// radio del circulo
		const points_in_circle = 100;
		const arc_distance = (2 * Math.PI) / points_in_circle;

		for (var i = 0; i < points_in_circle; i++){
			const radius_mod = Math.cos(arc_distance * i) * radius;
			let y = Math.sin(arc_distance * i) * radius;
			draw_circumference(points_in_circle, radius_mod, y);
		}

		function rotateX(point, amount){
			let y = point.y
			point.y = (y * Math.cos(amount)) + (point.z * Math.sin(amount) * -1.0)
			point.z = (y * Math.sin(amount)) + (point.z * Math.cos(amount))
			return point;
		}

		function radianToDegrees(angleInRadians){
			return angleInRadians * Math.PI / 180;
		}

		function draw_point(point, color = 'red', radius = 5){
			const distance = 1000; 			// distancia de la camara al plano
			const z_camera = 100; 			// distancia de la camara en Z

			point = rotateX(point, radianToDegrees(-10)) // Rotamos ligeramente en 10 deg

			const point_in_2d = {
				x: getProjection(distance, point.x, point.z, z_camera),
				y: getProjection(distance, point.y, point.z, z_camera),
			}

			radius = radius + (point.z/20) 			// AGREGAMOS ESTO

			ctx.fillStyle = color;				// cambiamos el color de relleno a rojo
			ctx.beginPath()						// 
			ctx.arc(point_in_2d.x + w/2, 		// coordenada en X
					point_in_2d.y + h/2, 		// coordenada en Y
					radius, 							// radio del arco
					0,	 								// angulo de Inicio
					2 * Math.PI,					// angulo de fin
					true)  							// Antihorario/Horario
			ctx.fill() 								// Pintamos las figuras cerradas que tenemos pendientes
		}

				for(const point of points){
					draw_point(point);
				}
	</script>
	</body>
</html>

Por último, en esta parte animaremos nuestra esfera para que rote ligeramente en los tres ejes.

Primero, definiremos las funciones de rotación en los otros ejes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function rotateX(point, amount){
	let y = point.y
	point.y = (y * Math.cos(amount)) + (point.z * Math.sin(amount) * -1.0)
	point.z = (y * Math.sin(amount)) + (point.z * Math.cos(amount))
	return point;
}
function rotateY(point, amount){
	let x = point.x
	point.x = (x * Math.cos(amount)) + (point.z * Math.sin(amount) * -1.0)
	point.z = (x * Math.sin(amount)) + (point.z * Math.cos(amount))
	return point;
}
function rotateZ(point, amount){
	let x = point.x
	point.x = (x * Math.cos(amount)) + (point.y * Math.sin(amount) * -1.0)
	point.y = (x * Math.sin(amount)) + (point.y * Math.cos(amount))
	return point;
}

Ahora, quitaremos la rotación en nuestra función draw_point

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function draw_point(point, color = 'red', radius = 5){
	const distance = 1000; 			// distancia de la camara al plano
	const z_camera = 100; 			// distancia de la camara en Z

	// Comentamos esta linea, ya no vamos a rotar el circulo de manera fija
	// point = rotateX(point, radianToDegrees(-10)) // Rotamos ligeramente en 10 deg

	const point_in_2d = {
		x: getProjection(distance, point.x, point.z, z_camera),
		y: getProjection(distance, point.y, point.z, z_camera),
	}
	// ...

Ahora en nuestro bucle de dibujo haremos la rotación:

1
2
3
4
5
6
for(const point of points){
	let mod_point = rotateX(point, radianToDegrees(-10)) // Rotamos ligeramente en 10 deg
	mod_point = rotateY(mod_point, radianToDegrees(-10)) // Rotamos ligeramente en 10 deg
	mod_point = rotateZ(mod_point, radianToDegrees(-10)) // Rotamos ligeramente en 10 deg
	draw_point(mod_point);
}

Esfera rotada 10 grados en los tres ejes

Por último, rotaremos la esfera ligeramente digamos 60 grados por segundo, es decir para que de una vuelta completa en 6 segundos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const rotation_degree = 1;

window.setInterval(()=>{
	for(const point of points){
		let mod_point = rotateX(point, radianToDegrees(rotation_degree))
		mod_point = rotateY(mod_point, radianToDegrees(rotation_degree))
		mod_point = rotateZ(mod_point, radianToDegrees(rotation_degree))
		draw_point(mod_point);
	}
}, 1000/60)

Veamos como se ve:

Resultado circulo completo

¿Qué sucedió? ¿Por qué se convirtió nuestra esfera en un círculo?

El problema es que estamos pintando una esfera rotada encima de una esfera rotada, sin limpiar nuestro canvas, al hacer eso los puntos se sobreponen y se pinta un círculo completo.

Para corregirlo, borraremos el canvas en cada iteración

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const rotation_degree = 1;

window.setInterval(()=>{
	// guardamos el estado y limpiamos el canvas
	ctx.save()
	ctx.clearRect(0, 0, w, h)

	for(const point of points){
		let mod_point = rotateX(point, radianToDegrees(rotation_degree))
		mod_point = rotateY(mod_point, radianToDegrees(rotation_degree))
		mod_point = rotateZ(mod_point, radianToDegrees(rotation_degree))
		draw_point(mod_point);
	}

	// recuperamos el estado
	ctx.restore()

}, 1000/60)

Quizá podemos bajar el radio de nuestro circulo para apreciar mejor su rotación:

1
2
3
//...
function draw_point(point, color = 'red', radius = 2){
//...

Perfecto, eso es todo para este post, en la siguiente parte veremos como distorsionarla para que parezca el blob y le agregaremos un poco más de estilo para que se vea mejor en 3D.

Hasta la próxima parte.


Código completo:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<html lang="en">
	<head>
		<meta charset="UTF-8">
  		<meta http-equiv="X-UA-Compatible" content="IE=edge">
  		<meta name="viewport" content="width=device-width, initial-scale=1.0">
  		<title>Sphere</title>
		<style>
			/* ESTILOS */
		</style>
	</head>
	<body>
		<h1> Sphere.  </h1>
		<h2> <span id="x"> X </span> WeMake.  </h2>
		<canvas id="canvas"></canvas>
	<script>
// 0. Setup
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
const w = canvas.width = 720 // Ancho: esto puede variarse al tamaño que quieran
const h = canvas.height = 720 // Alto: esto puede variarse al tamaño que quieran

function getProjection(distance, xy, z, z_camera) {
	return ((distance * xy) / (z_camera- z))
}

const points = [] 	// lista o arreglo de puntos

function draw_circumference(points_in_circle, radius, fixed_y){
	const arc_distance = (2 * Math.PI) / points_in_circle;
	for(var n = 0; n < points_in_circle; n++){
		const x = Math.cos(arc_distance * n) * radius; 
		const y = fixed_y; 		// nuestro circulo será paralelo al plano Y.
		const z = Math.sin(arc_distance * n) * radius;
		points.push({x, y, z}); // creamos el punto y lo insertamos en el arreglo.
	}
}
// dibujando un cilindro.
const radius = 20; 	// radio del circulo
const points_in_circle = 100;
const arc_distance = (2 * Math.PI) / points_in_circle;

for (var i = 0; i < points_in_circle; i++){
	const radius_mod = Math.cos(arc_distance * i) * radius;
	let y = Math.sin(arc_distance * i) * radius;
	draw_circumference(points_in_circle, radius_mod, y);
}

function rotateX(point, amount){
	let y = point.y
	point.y = (y * Math.cos(amount)) + (point.z * Math.sin(amount) * -1.0)
	point.z = (y * Math.sin(amount)) + (point.z * Math.cos(amount))
	return point;
}
function rotateY(point, amount){
	let x = point.x
	point.x = (x * Math.cos(amount)) + (point.z * Math.sin(amount) * -1.0)
	point.z = (x * Math.sin(amount)) + (point.z * Math.cos(amount))
	return point;
}
function rotateZ(point, amount){
	let x = point.x
	point.x = (x * Math.cos(amount)) + (point.y * Math.sin(amount) * -1.0)
	point.y = (x * Math.sin(amount)) + (point.y * Math.cos(amount))
	return point;
}

function radianToDegrees(angleInRadians){
	return angleInRadians * Math.PI / 180;
}

function draw_point(point, color = 'red', radius = 2){
	const distance = 1000; 			// distancia de la camara al plano
	const z_camera = 100; 			// distancia de la camara en Z

	// point = rotateX(point, radianToDegrees(-10)) // Rotamos ligeramente en 10 deg

	const point_in_2d = {
		x: getProjection(distance, point.x, point.z, z_camera),
		y: getProjection(distance, point.y, point.z, z_camera),
	}

	radius = radius + (point.z/20) 			// AGREGAMOS ESTO

	ctx.fillStyle = color;				// cambiamos el color de relleno a rojo
	ctx.beginPath()						// 
	ctx.arc(point_in_2d.x + w/2, 		// coordenada en X
			point_in_2d.y + h/2, 		// coordenada en Y
			radius, 							// radio del arco
			0,	 								// angulo de Inicio
			2 * Math.PI,					// angulo de fin
			true)  							// Antihorario/Horario
	ctx.fill() 								// Pintamos las figuras cerradas que tenemos pendientes
}
const rotation_degree = 1;

window.setInterval(()=>{
	// guardamos el estado y limpiamos el canvas
	ctx.save()
	ctx.clearRect(0, 0, w, h)

	for(const point of points){
		let mod_point = rotateX(point, radianToDegrees(rotation_degree))
		mod_point = rotateY(mod_point, radianToDegrees(rotation_degree))
		mod_point = rotateZ(mod_point, radianToDegrees(rotation_degree))
		draw_point(mod_point);
	}

	// recuperamos el estado
	ctx.restore()

}, 1000/60)
	</script>
	</body>
</html>