This commit is contained in:
Andras Schmelczer 2026-05-03 15:03:33 +01:00
commit 91a723e40d
8 changed files with 283 additions and 0 deletions

BIN
animation-day.mp4 Normal file

Binary file not shown.

BIN
animation-night.mp4 Normal file

Binary file not shown.

BIN
animation.mp4 Normal file

Binary file not shown.

1
config.py Normal file
View file

@ -0,0 +1 @@
WIDTH, HEIGHT = 1280, 720

82
ember.py Normal file
View file

@ -0,0 +1,82 @@
import pygame
from typing import Tuple
import numpy as np
import pygame.gfxdraw
from math import ceil
from config import HEIGHT, WIDTH
from utils import clamp, mix
BLUR_RADIUS = 3
TTL_LOSS_NEAR_BORDER_IN_PIXELS = 200
UP_DRAFT = 300
class Ember:
def __init__(self):
self.reset()
def reset(self):
self.position = (np.random.randint(0, WIDTH), HEIGHT)
self.up_draft = np.random.uniform(0.3, 1) * UP_DRAFT
self.time_to_live = np.random.uniform(1, 6)
self.color_offset = np.random.uniform(0, 1)
self.size = np.random.uniform(2.5, 6.5)
def update(self, velocity: Tuple[float, float], delta_time: float):
self.position = (
self.position[0] + velocity[0] * delta_time,
self.position[1] + (velocity[1] - self.up_draft) * delta_time,
)
self.time_to_live -= delta_time
if self.distance_from_screen_border() < TTL_LOSS_NEAR_BORDER_IN_PIXELS:
self.time_to_live = min(
self.distance_from_screen_border() / TTL_LOSS_NEAR_BORDER_IN_PIXELS,
self.time_to_live,
)
if self.time_to_live < 0:
self.reset()
def draw(self, screen: pygame.Surface):
color = (
int(mix(255, 189, self.color_offset)),
int(mix(165, 40, self.color_offset)),
int(mix(0, 40, self.color_offset)),
255 * (1 - clamp((0.5 - self.time_to_live) * 2, 0, 1) ** 2),
)
draw_antialiased_circle(
screen, self.position[0], self.position[1], int(self.size), color
)
def distance_from_screen_border(self):
return min(
self.position[0],
WIDTH - self.position[0],
self.position[1],
# HEIGHT - self.position[1],
)
def __str__(self) -> str:
return f"Ember(position={self.position}, velocity={self.velocity}, time_to_live={self.time_to_live})"
def draw_antialiased_circle(surface: pygame.Surface, x, y, radius, color):
for dx in range(-ceil(radius), ceil(radius) + 1):
for dy in range(-ceil(radius), ceil(radius) + 1):
distance = ((dx - x % 1) ** 2 + (dy - y % 1) ** 2) ** 0.5 - radius
if (
distance < 0
and 0 <= x + dx < surface.get_width()
and 0 <= y + dy < surface.get_height()
):
alpha = min(color[3] / 255, -distance / BLUR_RADIUS)
value = surface.get_at((int(x + dx), int(y + dy)))
surface.set_at(
(int(x + dx), int(y + dy)),
(
mix(value[0], color[0], alpha),
mix(value[1], color[1], alpha),
mix(value[2], color[2], alpha),
mix(value[3], color[3], alpha),
),
)

73
main.py Normal file
View file

@ -0,0 +1,73 @@
from typing import List
import pygame
import sys
import numpy as np
import imageio
from config import HEIGHT, WIDTH
from tqdm import tqdm
from ember import Ember
from wind import WindField
IS_DEVELOPMENT = False
EMBER_COUNT = 600
FRAME_RATE = 60
RUNNING_TIME_IN_SECONDS = 300
WIND_STRENTH = 300
delta_time = 1 / 150
embers: List[Ember] = [Ember() for _ in range(EMBER_COUNT)]
wind_field = WindField(WIDTH, HEIGHT)
pygame.init()
flags = pygame.HWSURFACE | pygame.HWACCEL
screen = pygame.display.set_mode((WIDTH, HEIGHT), flags)
pygame.display.set_caption("Animated Video")
clock = pygame.time.Clock()
video_file = "animation.mp4"
video_writer = imageio.get_writer(video_file, fps=FRAME_RATE)
for frame in tqdm(range(RUNNING_TIME_IN_SECONDS * FRAME_RATE)):
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
# screen.fill((0, 0, 0))
# Add a semi-transparent rectangle to create the trail effect
fade_surface = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
fade_surface.fill(
(0, 0, 0, 10)
) # Adjust the last parameter for the opacity (0-255)
screen.blit(fade_surface, (0, 0))
wind_field.update(time=frame / 150)
# wind_field.draw(screen)
for ember in embers:
wind = wind_field.get_wind(ember.position)
velocity_x = wind[0] * WIND_STRENTH
velocity_y = wind[1] * WIND_STRENTH
ember.update((velocity_x, velocity_y), delta_time=delta_time)
ember.draw(screen)
pygame.display.flip()
if not IS_DEVELOPMENT:
current_frame = pygame.surfarray.array3d(screen)
current_frame = np.rot90(current_frame)
# current_frame = np.flip(current_frame, axis=0)
video_writer.append_data(current_frame.copy())
if IS_DEVELOPMENT:
clock.tick(FRAME_RATE)
if not IS_DEVELOPMENT:
video_writer.close()

6
utils.py Normal file
View file

@ -0,0 +1,6 @@
def clamp(x, min_value, max_value):
return max(min(x, max_value), min_value)
def mix(a, b, mix):
return a * (1 - mix) + b * mix

121
wind.py Normal file
View file

@ -0,0 +1,121 @@
from typing import Tuple
import numpy as np
import noise
import pygame
from utils import clamp
SEED = 42
np.random.seed(SEED)
class WindField:
def __init__(self, width, height, downscale=10):
self.width = int(width / downscale)
self.height = int(height / downscale)
self.downscale = downscale
self.field_x = np.zeros((self.width, self.height))
self.field_y = np.zeros((self.width, self.height))
def update(
self,
time=0,
scale=15.0,
time_scale=2.0,
octaves=5,
persistence=0.3,
lacunarity=4.0,
):
for i in range(self.width):
for j in range(self.height):
self.field_x[i][j] = noise.pnoise3(
i / scale,
j / scale,
time / time_scale,
octaves=octaves,
persistence=persistence,
lacunarity=lacunarity,
base=SEED,
)
self.field_y[i][j] = noise.pnoise3(
i / scale,
j / scale,
time / time_scale,
octaves=octaves,
persistence=persistence,
lacunarity=lacunarity,
base=SEED + 1,
)
def draw(self, screen):
for i in range(self.width):
for j in range(self.height):
color = (
abs(self.field_x[i][j] * 255),
abs(self.field_y[i][j] * 255),
0,
)
pygame.draw.rect(
screen,
color,
(
i * self.downscale,
j * self.downscale,
self.downscale,
self.downscale,
),
)
# draw with get_wind
# def draw(self, screen):
# for i in range(self.width * self.downscale):
# for j in range(self.height * self.downscale):
# wind = self.get_wind((i, j))
# color = (
# abs(wind[0] * 255),
# abs(wind[1] * 255),
# 0,
# )
# pygame.draw.rect(
# screen,
# color,
# (
# i,
# j,
# 1,
# 1,
# ),
# )
def get_wind(self, position: Tuple[int, int]) -> Tuple[float, float]:
x, y = position
x /= self.downscale
y /= self.downscale
x0 = int(x)
y0 = int(y)
x1 = x0 + 1
y1 = y0 + 1
x0 = clamp(x0, 0, self.width - 1)
y0 = clamp(y0, 0, self.height - 1)
x1 = clamp(x1, 0, self.width - 1)
y1 = clamp(y1, 0, self.height - 1)
dx = x - x0
dy = y - y0
wind_x = (
(1 - dx) * (1 - dy) * self.field_x[x0][y0]
+ dx * (1 - dy) * self.field_x[x1][y0]
+ (1 - dx) * dy * self.field_x[x0][y1]
+ dx * dy * self.field_x[x1][y1]
)
wind_y = (
(1 - dx) * (1 - dy) * self.field_y[x0][y0]
+ dx * (1 - dy) * self.field_y[x1][y0]
+ (1 - dx) * dy * self.field_y[x0][y1]
+ dx * dy * self.field_y[x1][y1]
)
return wind_x, wind_y