commit 91a723e40df687b9fb5aa8064984c5a6d2b714de Author: Andras Schmelczer Date: Sun May 3 15:03:33 2026 +0100 Initial diff --git a/animation-day.mp4 b/animation-day.mp4 new file mode 100644 index 0000000..a6c2e67 Binary files /dev/null and b/animation-day.mp4 differ diff --git a/animation-night.mp4 b/animation-night.mp4 new file mode 100644 index 0000000..cd0dcee Binary files /dev/null and b/animation-night.mp4 differ diff --git a/animation.mp4 b/animation.mp4 new file mode 100644 index 0000000..d0acd20 Binary files /dev/null and b/animation.mp4 differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..5175c96 --- /dev/null +++ b/config.py @@ -0,0 +1 @@ +WIDTH, HEIGHT = 1280, 720 diff --git a/ember.py b/ember.py new file mode 100644 index 0000000..6a2858b --- /dev/null +++ b/ember.py @@ -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), + ), + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..aeb3a0f --- /dev/null +++ b/main.py @@ -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() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..2960574 --- /dev/null +++ b/utils.py @@ -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 diff --git a/wind.py b/wind.py new file mode 100644 index 0000000..c1f52d8 --- /dev/null +++ b/wind.py @@ -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