Volver al inicio

Math Animations

Una colección de fractales y algoritmos visuales generados con Python, NumPy y Raylib. Cada video es el resultado de procesar miles de puntos matemáticos frame por frame.

1

Dragon Curve

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

dragon_curve_ppm.py
import math
import os
import numpy as np
import pyray as rl
WIDTH, HEIGHT = 800, 800
FPS = 1
DURATION = 10
MAX_FRAMES = DURATION
OUTPUT_DIR = "dragon_frames"
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
def get_dragon_points(iterations):
points = [(0, 0), (1, 0)]
for _ in range(iterations):
last_x, last_y = points[-1]
new_points = []
for i in range(len(points) - 2, -1, -1):
px, py = points[i]
# Rotar 90 grados alrededor del último punto
nx = last_x - (py - last_y)
ny = last_y + (px - last_x)
new_points.append((nx, ny))
points.extend(new_points)
return points
def main():
rl.set_config_flags(rl.FLAG_WINDOW_HIDDEN)
rl.init_window(WIDTH, HEIGHT, b"Dragon")
for i in range(MAX_FRAMES):
depth = i + 2 # De 2 a 11 iteraciones
rl.begin_drawing()
rl.clear_background(rl.BLACK)
raw_points = get_dragon_points(depth)
# Centrar y escalar
xs = [p[0] for p in raw_points]
ys = [p[1] for p in raw_points]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
scale = min(
(WIDTH - 150) / (max_x - min_x + 1),
(HEIGHT - 150) / (max_y - min_y + 1),
)
off_x = WIDTH / 2 - (min_x + max_x) * scale / 2
off_y = HEIGHT / 2 - (min_y + max_y) * scale / 2
for p in range(len(raw_points) - 1):
p1 = rl.Vector2(
off_x + raw_points[p][0] * scale,
off_y + raw_points[p][1] * scale,
)
p2 = rl.Vector2(
off_x + raw_points[p + 1][0] * scale,
off_y + raw_points[p + 1][1] * scale,
)
rl.draw_line_ex(p1, p2, 2, rl.GOLD)
rl.end_drawing()
img = rl.load_image_from_screen()
with open(
os.path.join(OUTPUT_DIR, f"frame_{i:04d}.ppm"), "wb"
) as f:
pixels = np.frombuffer(
rl.ffi.buffer(img.data, WIDTH * HEIGHT * 4)[:],
dtype=np.uint8,
).reshape((HEIGHT, WIDTH, 4))
f.write(
f"""P6
{WIDTH} {HEIGHT}
255
""".encode()
+ pixels[:, :, :3].tobytes()
)
rl.unload_image(img)
rl.close_window()
if __name__ == "__main__":
main()
python
2

Hilbert Curve

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

hilbert_curve_ppm.py
import os
import numpy as np
import pyray as rl
WIDTH, HEIGHT = 800, 800
FPS = 1
DURATION = 10
MAX_FRAMES = DURATION
OUTPUT_DIR = "hilbert_frames"
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
def rot(n, x, y, rx, ry):
if ry == 0:
if rx == 1:
x, y = n - 1 - x, n - 1 - y
x, y = y, x
return x, y
def d2xy(n, d):
t, x, y = d, 0, 0
s = 1
while s < n:
rx = 1 & (t // 2)
ry = 1 & (t ^ rx)
x, y = rot(s, x, y, rx, ry)
x += s * rx
y += s * ry
t //= 4
s *= 2
return x, y
def main():
rl.set_config_flags(rl.FLAG_WINDOW_HIDDEN)
rl.init_window(WIDTH, HEIGHT, b"Hilbert")
for i in range(MAX_FRAMES):
order = i + 1 # De 1 a 10 orden
n = 2**order
total_points = n * n
rl.begin_drawing()
rl.clear_background(rl.BLACK)
spacing = (WIDTH - 100) / n
points = []
for d in range(total_points):
x, y = d2xy(n, d)
points.append(
rl.Vector2(
50 + x * spacing + spacing / 2,
50 + y * spacing + spacing / 2,
)
)
for p in range(len(points) - 1):
rl.draw_line_ex(points[p], points[p + 1], 2, rl.LIME)
rl.end_drawing()
img = rl.load_image_from_screen()
with open(
os.path.join(OUTPUT_DIR, f"frame_{i:04d}.ppm"), "wb"
) as f:
pixels = np.frombuffer(
rl.ffi.buffer(img.data, WIDTH * HEIGHT * 4)[:],
dtype=np.uint8,
).reshape((HEIGHT, WIDTH, 4))
f.write(
f"""P6
{WIDTH} {HEIGHT}
255
""".encode()
+ pixels[:, :, :3].tobytes()
)
rl.unload_image(img)
rl.close_window()
if __name__ == "__main__":
main()
python
3

Koch Snowflake

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

koch_snowflake_ppm.py
import math
import os
import numpy as np
import pyray as rl
WIDTH, HEIGHT = 800, 800
FPS = 1
DURATION = 10
MAX_FRAMES = DURATION
OUTPUT_DIR = "koch_frames"
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
def save_ppm(filename, width, height, data):
with open(filename, "wb") as f:
f.write(
f"""P6
{width} {height}
255
""".encode()
)
pixels = np.frombuffer(data, dtype=np.uint8).reshape(
(height, width, 4)
)
f.write(pixels[:, :, :3].tobytes())
def draw_koch_line(p1, p2, depth):
if depth == 0:
rl.draw_line_v(p1, p2, rl.SKYBLUE)
else:
dx, dy = (p2.x - p1.x) / 3, (p2.y - p1.y) / 3
a = rl.Vector2(p1.x + dx, p1.y + dy)
c = rl.Vector2(p1.x + 2 * dx, p1.y + 2 * dy)
# Pico del triángulo equilátero
angle = -math.pi / 3
s, c_val = math.sin(angle), math.cos(angle)
bx = (c.x - a.x) * c_val - (c.y - a.y) * s + a.x
by = (c.x - a.x) * s + (c.y - a.y) * c_val + a.y
b = rl.Vector2(bx, by)
draw_koch_line(p1, a, depth - 1)
draw_koch_line(a, b, depth - 1)
draw_koch_line(b, c, depth - 1)
draw_koch_line(c, p2, depth - 1)
def main():
rl.set_config_flags(rl.FLAG_WINDOW_HIDDEN)
rl.init_window(WIDTH, HEIGHT, b"Koch")
for i in range(MAX_FRAMES):
depth = i # De 0 a 9 iteraciones
rl.begin_drawing()
rl.clear_background(rl.BLACK)
# Triángulo inicial
size = 500
p1 = rl.Vector2(WIDTH / 2, HEIGHT / 2 - 280)
p2 = rl.Vector2(WIDTH / 2 - 250, HEIGHT / 2 + 150)
p3 = rl.Vector2(WIDTH / 2 + 250, HEIGHT / 2 + 150)
draw_koch_line(p1, p3, depth)
draw_koch_line(p3, p2, depth)
draw_koch_line(p2, p1, depth)
rl.end_drawing()
img = rl.load_image_from_screen()
save_ppm(
os.path.join(OUTPUT_DIR, f"frame_{i:04d}.ppm"),
WIDTH,
HEIGHT,
rl.ffi.buffer(img.data, WIDTH * HEIGHT * 4)[:],
)
rl.unload_image(img)
rl.close_window()
if __name__ == "__main__":
main()
python
4

Mandelbrot Zoom

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

mandelbrot_ppm.py
from concurrent.futures import ProcessPoolExecutor
import math
import os
import shutil
import time
import numpy as np
# CONFIGURACION
WIDTH, HEIGHT = 800, 800
FPS = 30
DURATION = 30
MAX_FRAMES = FPS * DURATION
VIDEO_NAME = "mandelbrot"
OUTPUT_DIR = f"{VIDEO_NAME}_frames"
MAX_ITER = 120
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR, exist_ok=True)
def get_mandelbrot(width, height, x_min, x_max, y_min, y_max):
x = np.linspace(x_min, x_max, width)
y = np.linspace(y_min, y_max, height)
X, Y = np.meshgrid(x, y)
c = X + 1j * Y
z = np.zeros(c.shape, dtype=complex)
img = np.zeros(c.shape, dtype=float)
mask = np.full(c.shape, True, dtype=bool)
for i in range(MAX_ITER):
z[mask] = z[mask] ** 2 + c[mask]
mask[np.abs(z) > 2] = False
img[mask] = i
return img
def render_frame(i):
target_x, target_y = -0.743643887, 0.131825904
start_scale, end_scale = 1.5, 0.0001
t = i / (MAX_FRAMES - 1)
scale = start_scale * math.pow(end_scale / start_scale, t)
data = get_mandelbrot(
WIDTH,
HEIGHT,
target_x - scale,
target_x + scale,
target_y - scale,
target_y + scale,
)
norm = data / MAX_ITER
rgb = np.stack(
[
(255 * norm**0.5).astype(np.uint8),
(255 * norm).astype(np.uint8),
(255 * norm**2).astype(np.uint8),
],
axis=-1,
)
filename = os.path.join(OUTPUT_DIR, f"frame_{i:04d}.ppm")
with open(filename, "wb") as f:
f.write(
f"P6\n{WIDTH} {HEIGHT}\n255\n".encode() + rgb.tobytes()
)
return i
def main():
print(
f"Iniciando renderizado de Mandelbrot: {MAX_FRAMES} cuadros"
)
start_time = time.time()
# Generar Frames
with ProcessPoolExecutor() as executor:
list(executor.map(render_frame, range(MAX_FRAMES)))
# Generar video
video_name = f"{VIDEO_NAME}.mp4"
print(f"Generando video final: {video_name}")
os.system(
f"ffmpeg -y -framerate {FPS} "
f"-i {output_subdir}/frame_%04d.ppm "
f"-c:v libx264 -pix_fmt yuv420p {video_name} > /dev/null 2>&1"
)
shutil.rmtree(OUTPUT_DIR)
end_time = time.time()
print(f"Renderizado completado en {end_time - start_time:.2f} s")
if __name__ == "__main__":
main()
python
5

Mandelbulb 3D

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

mandelbulb_3d.py
import pyray as rl
import numpy as np
import os
import math
# CONFIGURACION
WIDTH, HEIGHT = 800, 800
OUTPUT_DIR = "render_mandelbulb_3d"
MAX_FRAMES = 900 # 30 segundos
FPS = 30
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
# SHADER DE RAYMARCHING CON gl_FragCoord (Mas robusto)
VS_CODE = b"""
#version 330
in vec3 vertexPosition;
uniform mat4 mvp;
void main() {
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
FS_CODE = b"""
#version 330
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform vec3 uCamPos;
uniform vec3 uCamTarget;
out vec4 finalColor;
float map(vec3 p) {
vec3 z = p;
float dr = 1.0;
float r = 0.0;
float power = 8.0 + sin(uTime * 0.4) * 2.0;
for (int i = 0; i < 15; i++) {
r = length(z);
if (r > 2.0) break;
float theta = acos(clamp(z.z / r, -1.0, 1.0));
float phi = atan(z.y, z.x);
dr = pow(r, power - 1.0) * power * dr + 1.0;
float zr = pow(r, power);
theta = theta * power;
phi = phi * power;
z = zr * vec3(sin(theta) * cos(phi), sin(phi) * sin(theta), cos(theta));
z += p;
}
return 0.5 * log(r) * r / dr;
}
vec3 getNormal(vec3 p) {
float h = 0.001;
vec2 e = vec2(h, 0.0);
return normalize(vec3(
map(p + e.xyy) - map(p - e.xyy),
map(p + e.yxy) - map(p - e.yxy),
map(p + e.yyx) - map(p - e.yyx)
));
}
void main() {
// Calcular UVs centrados usando la resolucion real
vec2 uv = (gl_FragCoord.xy / uRes.xy) - 0.5;
uv.x *= (uRes.x / uRes.y);
vec3 ro = uCamPos;
vec3 target = uCamTarget;
// Matriz de vista de camara
vec3 cw = normalize(target - ro);
vec3 cp = vec3(0.0, 1.0, 0.0);
vec3 cu = normalize(cross(cw, cp));
vec3 cv = normalize(cross(cu, cw));
// Direccion del rayo (con FOV de 1.5)
vec3 rd = normalize(uv.x * cu + uv.y * cv + 1.5 * cw);
float tmax = 15.0;
float t = 0.0;
float d = 0.0;
bool hit = false;
for (int i = 0; i < 150; i++) {
d = map(ro + rd * t);
if (d < 0.0001) { hit = true; break; }
t += d;
if (t > tmax) break;
}
vec3 color = vec3(0.02, 0.02, 0.05); // Fondo oscuro
if (hit) {
vec3 p = ro + rd * t;
vec3 n = getNormal(p);
vec3 ld = normalize(vec3(1.0, 1.0, 1.0)); // Luz
float diff = max(dot(n, ld), 0.0);
float amb = 0.15;
// Color psicodelico basado en normales
vec3 baseCol = 0.5 + 0.5 * cos(uTime * 0.2 + n + vec3(0, 2, 4));
color = baseCol * (diff + amb);
// Niebla para dar profundidad
color = mix(color, vec3(0.02, 0.02, 0.05), 1.0 - exp(-0.15 * t));
}
finalColor = vec4(color, 1.0);
}
"""
def save_ppm(filename, width, height, data):
with open(filename, "wb") as f:
f.write(f"P6\n{width} {height}\n255\n".encode())
pixels = np.frombuffer(data, dtype=np.uint8).reshape(
(height, width, 4)
)
# Raylib load_image_from_screen devuelve la imagen invertida en Y
# La flipeamos para que el video salga bien
pixels = np.flipud(pixels)
f.write(pixels[:, :, :3].tobytes())
def main():
rl.set_config_flags(rl.FLAG_WINDOW_HIDDEN)
rl.init_window(WIDTH, HEIGHT, b"Mandelbulb 3D Ultra Fix")
shader = rl.load_shader_from_memory(VS_CODE, FS_CODE)
u_res = rl.get_shader_location(shader, b"uRes")
u_time = rl.get_shader_location(shader, b"uTime")
u_campos = rl.get_shader_location(shader, b"uCamPos")
u_camtarget = rl.get_shader_location(shader, b"uCamTarget")
res_val = rl.ffi.new("float[2]", [float(WIDTH), float(HEIGHT)])
rl.set_shader_value(
shader, u_res, res_val, rl.SHADER_UNIFORM_VEC2
)
print(f"Renderizando Mandelbulb 3D (Correccion UV y Camara)...")
for i in range(MAX_FRAMES):
time = i / FPS
# Orbita estable a radio 4.5
cam_x = 4.5 * math.sin(time * 0.2)
cam_z = 4.5 * math.cos(time * 0.2)
cam_y = 1.5 * math.cos(time * 0.1)
u_time_val = rl.ffi.new("float*", time)
u_campos_val = rl.ffi.new("float[3]", [cam_x, cam_y, cam_z])
u_camtarget_val = rl.ffi.new("float[3]", [0.0, 0.0, 0.0])
rl.set_shader_value(
shader, u_time, u_time_val, rl.SHADER_UNIFORM_FLOAT
)
rl.set_shader_value(
shader, u_campos, u_campos_val, rl.SHADER_UNIFORM_VEC3
)
rl.set_shader_value(
shader,
u_camtarget,
u_camtarget_val,
rl.SHADER_UNIFORM_VEC3,
)
rl.begin_drawing()
rl.clear_background(rl.BLACK)
rl.begin_shader_mode(shader)
rl.draw_rectangle(0, 0, WIDTH, HEIGHT, rl.WHITE)
rl.end_shader_mode()
rl.end_drawing()
img = rl.load_image_from_screen()
save_ppm(
os.path.join(OUTPUT_DIR, f"frame_{i:04d}.ppm"),
WIDTH,
HEIGHT,
rl.ffi.buffer(img.data, WIDTH * HEIGHT * 4)[:],
)
rl.unload_image(img)
if i % 30 == 0:
print(f"Progreso: {i}/{MAX_FRAMES}")
rl.unload_shader(shader)
rl.close_window()
print("Renderizado finalizado con exito.")
if __name__ == "__main__":
main()
python
6

Burning Ship Fractal

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

burning_ship_ppm.py
import math
import os
import numpy as np
# CONFIGURATION
WIDTH, HEIGHT = 800, 800
FPS = 24
DURATION = 10
MAX_FRAMES = FPS * DURATION
OUTPUT_DIR = "burning_ship_frames"
MAX_ITER = 100
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR, exist_ok=True)
def save_ppm(filename, width, height, rgb_data):
"""Saves RGB data to a PPM P6 file."""
with open(filename, "wb") as f:
header = f"""P6
{width} {height}
255
"""
f.write(header.encode())
f.write(rgb_data.tobytes())
def get_burning_ship(
width, height, x_min, x_max, y_min, y_max, max_iter
):
"""Calculates the Burning Ship fractal using numpy."""
x = np.linspace(x_min, x_max, width)
y = np.linspace(y_min, y_max, height)
X, Y = np.meshgrid(x, y)
c = X + 1j * Y
z = np.zeros(c.shape, dtype=complex)
img = np.zeros(c.shape, dtype=int)
mask = np.full(c.shape, True, dtype=bool)
for i in range(max_iter):
# Burning Ship formula: z = (|Re(z)| + i|Im(z)|)^2 + c
# Which expands to:
# Re(z_next) = Re(z)^2 - Im(z)^2 + Re(c)
# Im(z_next) = 2 * |Re(z) * Im(z)| + Im(c)
# (Note: taking absolute values before squaring/multiplying)
z_re = np.abs(z.real)
z_im = np.abs(z.imag)
z[mask] = (z_re[mask] + 1j * z_im[mask]) ** 2 + c[mask]
mask[np.abs(z) > 2] = False
img[mask] = i
return img
def colormap(iterations, max_iter):
"""Simple colormap for the fractal."""
# Normalized iterations 0..1
norm = iterations / max_iter
# Create some colors (R, G, B)
# Burning ship looks good with fire colors: Red, Orange, Yellow
r = (255 * norm).astype(np.uint8)
g = (255 * (norm**2)).astype(np.uint8)
b = (255 * (norm**4)).astype(np.uint8)
# Stack them to get RGB
return np.stack([r, g, b], axis=-1)
def main():
# Zoom parameters
# The "Ship" is around x=-1.75, y=-0.03
target_x = -1.755
target_y = -0.04
start_scale = 2.0
end_scale = 0.005
print(
f"Generating {MAX_FRAMES} frames of Burning Ship Fractal..."
)
for frame_idx in range(MAX_FRAMES):
# Exponential zoom
t = frame_idx / (MAX_FRAMES - 1)
# scale = start_scale * (end_scale / start_scale) ** t
# Smoother zoom using ease-in-out or just simple power
scale = start_scale * math.pow(end_scale / start_scale, t)
x_min = target_x - scale
x_max = target_x + scale
y_min = target_y - scale
y_max = target_y + scale
# Calculate fractal
fractal_data = get_burning_ship(
WIDTH, HEIGHT, x_min, x_max, y_min, y_max, MAX_ITER
)
# Apply colormap
rgb_image = colormap(fractal_data, MAX_ITER)
# Save PPM
filename = os.path.join(
OUTPUT_DIR, f"frame_{frame_idx:04d}.ppm"
)
save_ppm(filename, WIDTH, HEIGHT, rgb_image)
if frame_idx % 24 == 0:
print(
f"Progress: {frame_idx}/{MAX_FRAMES} frames "
f"({(frame_idx / MAX_FRAMES) * 100:.1f}%)"
)
print(f"Done! Frames saved in {OUTPUT_DIR}")
print("To create the video, run:")
print(
f"ffmpeg -framerate {FPS} -i "
f"{OUTPUT_DIR}/frame_%04d.ppm -c:v libx264 "
"-pix_fmt yuv420p burning_ship.mp4"
)
if __name__ == "__main__":
main()
python
7

Flower Bloom (Julia)

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

flower_bloom_ppm.py
import math
import os
import numpy as np
import time
from concurrent.futures import ProcessPoolExecutor
# CONFIGURACIÓN
WIDTH, HEIGHT = 800, 800
FPS = 30
DURATION = 30
MAX_FRAMES = FPS * DURATION
OUTPUT_DIR = "flower_bloom_frames"
MAX_ITER = 80
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR, exist_ok=True)
def save_ppm(filename, width, height, rgb_data):
with open(filename, "wb") as f:
header = f"P6\n{width} {height}\n255\n"
f.write(header.encode())
f.write(rgb_data.tobytes())
def get_julia_bloom(width, height, c, zoom=1.2):
x = np.linspace(-zoom, zoom, width)
y = np.linspace(-zoom, zoom, height)
X, Y = np.meshgrid(x, y)
z = X + 1j * Y
img = np.zeros(z.shape, dtype=float)
mask = np.full(z.shape, True, dtype=bool)
for i in range(MAX_ITER):
z[mask] = z[mask] ** 2 + c
mask[np.abs(z) > 2] = False
img[mask] = i
return img
def colormap_flower(iterations):
norm = iterations / MAX_ITER
r = (255 * np.sin(norm * math.pi)).astype(np.uint8)
g = (100 * (norm**2)).astype(np.uint8)
b = (200 * norm).astype(np.uint8)
return np.stack([r, g, b], axis=-1)
def render_frame(frame_idx):
# CAMBIO CLAVE: Usamos el tiempo real basado en FPS original para que la velocidad sea constante
# t = frame_idx / 300 # Si antes 300 cuadros eran 1 ciclo, mantenemos esa escala
# O mejor, t representa segundos reales:
t_seconds = frame_idx / FPS
# Mantenemos la velocidad de rotación original (1 ciclo cada 10 segundos)
angle = 2 * math.pi * (t_seconds / 10.0)
radius = 0.7885
c = radius * np.complex128(
complex(math.cos(angle), math.sin(angle))
)
# El latido del zoom también basado en segundos reales
zoom = 1.3 + 0.2 * math.sin(2 * math.pi * (t_seconds / 5.0))
fractal_data = get_julia_bloom(WIDTH, HEIGHT, c, zoom)
rgb_image = colormap_flower(fractal_data)
filename = os.path.join(OUTPUT_DIR, f"frame_{frame_idx:04d}.ppm")
save_ppm(filename, WIDTH, HEIGHT, rgb_image)
return frame_idx
def main():
print(
f"Iniciando renderizado paralelo de {MAX_FRAMES} cuadros con tiempo corregido..."
)
start_time = time.time()
with ProcessPoolExecutor() as executor:
list(executor.map(render_frame, range(MAX_FRAMES)))
end_time = time.time()
total_time = end_time - start_time
print(
f"\n\u00a1Renderizado completado en {total_time:.2f} segundos!"
)
if __name__ == "__main__":
main()
python
8

Pythagoras Tree

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

pythagoras_tree_ppm.py
import math
import os
import numpy as np
import pyray as rl
# CONFIGURATION
WIDTH, HEIGHT = 800, 800
FPS = 24
DURATION = 10
MAX_FRAMES = FPS * DURATION
OUTPUT_DIR = "pythagoras_tree_frames"
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR, exist_ok=True)
def save_ppm(filename, width, height, image_data):
"""Saves Raylib image data to PPM P6."""
with open(filename, "wb") as f:
header = f"""P6
{width} {height}
255
"""
f.write(header.encode())
# image_data is RGBA, we need RGB
pixels = np.frombuffer(image_data, dtype=np.uint8).reshape(
(height, width, 4)
)
f.write(pixels[:, :, :3].tobytes())
def draw_pythagoras_tree(x, y, size, angle, depth, tilt_angle):
if depth == 0:
return
# Calculate vertices of the square growing UP
# base vector along the bottom of the square
dx = size * math.cos(angle)
dy = size * math.sin(angle)
# perpendicular vector going "up" (subtracting from Y in Raylib)
ux = size * math.sin(angle)
uy = -size * math.cos(angle)
p1 = rl.Vector2(x, y)
p2 = rl.Vector2(x + dx, y + dy)
p3 = rl.Vector2(p2.x + ux, p2.y + uy)
p4 = rl.Vector2(p1.x + ux, p1.y + uy)
# Draw the square
color = rl.color_from_hsv(depth * 30, 0.6, 0.9)
thickness = max(1.0, depth * 0.5)
rl.draw_line_ex(p1, p2, thickness, color)
rl.draw_line_ex(p2, p3, thickness, color)
rl.draw_line_ex(p3, p4, thickness, color)
rl.draw_line_ex(p4, p1, thickness, color)
# Calculate the top triangle vertex p5
alpha = tilt_angle
size_left = size * math.cos(alpha)
size_right = size * math.sin(alpha)
# The left branch starts at p4 at angle (angle - alpha)
# The right branch starts at p5 at angle (angle + pi/2 - alpha)
p5 = rl.Vector2(
p4.x + size_left * math.cos(angle - alpha),
p4.y + size_left * math.sin(angle - alpha),
)
# Recursive calls
draw_pythagoras_tree(
p4.x, p4.y, size_left, angle - alpha, depth - 1, tilt_angle
)
draw_pythagoras_tree(
p5.x,
p5.y,
size_right,
angle + (math.pi / 2 - alpha),
depth - 1,
tilt_angle,
)
def main():
rl.set_config_flags(rl.FLAG_WINDOW_HIDDEN)
rl.init_window(WIDTH, HEIGHT, b"Pythagoras Tree Animation")
print(f"Generating {MAX_FRAMES} frames of Pythagoras Tree...")
for frame_idx in range(MAX_FRAMES):
# Animate the tilt angle (swaying effect)
# t goes from 0 to 1
t = frame_idx / MAX_FRAMES
# Cycle angle between 30 and 60 degrees (in radians)
current_tilt = (math.pi / 4) + (math.pi / 12) * math.sin(
2 * math.pi * t
)
rl.begin_drawing()
rl.clear_background(rl.BLACK)
# Draw the tree from the bottom center
# Base size 120, starting at bottom
draw_pythagoras_tree(
WIDTH // 2 - 60, HEIGHT - 50, 120, 0, 10, current_tilt
)
rl.draw_text(
f"FRAME: {frame_idx}/{MAX_FRAMES}".encode(),
20,
20,
20,
rl.RAYWHITE,
)
rl.end_drawing()
# Capture and save
img = rl.load_image_from_screen()
pixel_data = rl.ffi.buffer(img.data, WIDTH * HEIGHT * 4)[:]
filename = os.path.join(
OUTPUT_DIR, f"frame_{frame_idx:04d}.ppm"
)
save_ppm(filename, WIDTH, HEIGHT, pixel_data)
rl.unload_image(img)
if frame_idx % 24 == 0:
print(
f"Progress: {frame_idx}/{MAX_FRAMES} frames "
f"({(frame_idx / MAX_FRAMES) * 100:.1f}%)"
)
rl.close_window()
print(f"Done! Frames saved in {OUTPUT_DIR}")
print("To create the video, run:")
print(
f"ffmpeg -framerate {FPS} -i {OUTPUT_DIR}/frame_%04d.ppm "
"-c:v libx264 -pix_fmt yuv420p pythagoras_tree.mp4"
)
if __name__ == "__main__":
main()
python
9

Sierpinski Gasket

* Animación generada exportando frames en formato PPM y ensamblados con FFMPEG.

Python Source Code

sierpinski_ppm.py
import os
import numpy as np
import pyray as rl
WIDTH, HEIGHT = 800, 800
FPS = 24
DURATION = 10
MAX_FRAMES = FPS * DURATION
OUTPUT_DIR = "sierpinski_frames"
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
def draw_sierpinski(p1, p2, p3, depth):
if depth == 0:
rl.draw_triangle(p1, p2, p3, rl.ORANGE)
else:
p12 = rl.Vector2((p1.x + p2.x) / 2, (p1.y + p2.y) / 2)
p23 = rl.Vector2((p2.x + p3.x) / 2, (p2.y + p3.y) / 2)
p31 = rl.Vector2((p3.x + p1.x) / 2, (p3.y + p1.y) / 2)
draw_sierpinski(p1, p12, p31, depth - 1)
draw_sierpinski(p12, p2, p23, depth - 1)
draw_sierpinski(p31, p23, p3, depth - 1)
def main():
rl.set_config_flags(rl.FLAG_WINDOW_HIDDEN)
rl.init_window(WIDTH, HEIGHT, b"Sierpinski")
for i in range(MAX_FRAMES):
t = i / MAX_FRAMES
depth = int(1 + 7 * t)
rl.begin_drawing()
rl.clear_background(rl.BLACK)
p1 = rl.Vector2(WIDTH / 2, 50)
p2 = rl.Vector2(50, HEIGHT - 50)
p3 = rl.Vector2(WIDTH - 50, HEIGHT - 50)
draw_sierpinski(p1, p2, p3, depth)
rl.end_drawing()
img = rl.load_image_from_screen()
with open(
os.path.join(OUTPUT_DIR, f"frame_{i:04d}.ppm"), "wb"
) as f:
pixels = np.frombuffer(
rl.ffi.buffer(img.data, WIDTH * HEIGHT * 4)[:],
dtype=np.uint8,
).reshape((HEIGHT, WIDTH, 4))
f.write(
f"""P6
{WIDTH} {HEIGHT}
255
""".encode()
+ pixels[:, :, :3].tobytes()
)
rl.unload_image(img)
rl.close_window()
if __name__ == "__main__":
main()
python

Christopher Villamarín © 2026 - Hecho con Raylib y Python