12836dd2d2
- Introduced a hybrid collision detection approach that utilizes NumPy for vectorized operations, improving performance for games with many entities (200+). - Added a spatial grid for efficient lookups and AABB (Axis-Aligned Bounding Box) collision detection. - Implemented a new `CollisionSystem` class with methods for registering units, checking collisions, and managing spatial data. - Created performance tests to benchmark the new collision system against the old O(n²) method, demonstrating significant speed improvements. - Updated existing code to integrate the new collision detection system and ensure compatibility with game logic.
467 lines
14 KiB
Markdown
467 lines
14 KiB
Markdown
# Analisi Performance Rendering SDL2 - Mice!
|
||
|
||
## Sommario Esecutivo
|
||
|
||
Il sistema di rendering presenta **diverse criticità** che possono causare cali di FPS con molte unità (200+). Ho identificato 7 problemi principali e relative soluzioni.
|
||
|
||
---
|
||
|
||
## 🔴 CRITICITÀ IDENTIFICATE
|
||
|
||
### 1. **Controllo Visibilità Inefficiente** ⚠️ ALTA PRIORITÀ
|
||
|
||
**Problema:**
|
||
```python
|
||
def is_in_visible_area(self, x, y):
|
||
return (-self.w_offset - self.cell_size <= x <= self.width - self.w_offset and
|
||
-self.h_offset - self.cell_size <= y <= self.height - self.h_offset)
|
||
```
|
||
|
||
Ogni `draw_image()` chiama `is_in_visible_area()` che fa **4 confronti** per ogni sprite.
|
||
|
||
**Impatto con 250 unità:**
|
||
- 250 unità × 4 confronti = **1000 operazioni per frame**
|
||
- Molte unità potrebbero essere fuori schermo ma vengono controllate comunque
|
||
|
||
**Soluzione:**
|
||
```python
|
||
# Opzione A: Culling a livello di game loop (CONSIGLIATA)
|
||
# Filtra unità PRIMA del draw usando spatial grid
|
||
visible_cells = get_visible_cells(w_offset, h_offset, viewport_width, viewport_height)
|
||
for unit in units:
|
||
if unit.position in visible_cells or unit.position_before in visible_cells:
|
||
unit.draw()
|
||
|
||
# Opzione B: Cache dei bounds
|
||
class GameWindow:
|
||
def update_viewport_bounds(self):
|
||
self.visible_x_min = -self.w_offset - self.cell_size
|
||
self.visible_x_max = self.width - self.w_offset
|
||
self.visible_y_min = -self.h_offset - self.cell_size
|
||
self.visible_y_max = self.height - self.h_offset
|
||
|
||
def is_in_visible_area(self, x, y):
|
||
return (self.visible_x_min <= x <= self.visible_x_max and
|
||
self.visible_y_min <= y <= self.visible_y_max)
|
||
```
|
||
|
||
**Guadagno stimato:** 10-15% con 200+ unità
|
||
|
||
---
|
||
|
||
### 2. **Chiamate renderer.copy() Non Batch** ⚠️ ALTA PRIORITÀ
|
||
|
||
**Problema:**
|
||
```python
|
||
# Ogni unità chiama renderer.copy() individualmente
|
||
def draw_image(self, x, y, sprite, tag=None, anchor="nw"):
|
||
if not self.is_in_visible_area(x, y):
|
||
return
|
||
sprite.position = (x + self.w_offset, y + self.w_offset)
|
||
self.renderer.copy(sprite, dstrect=sprite.position) # ← Singola chiamata SDL
|
||
```
|
||
|
||
**Impatto:**
|
||
- 250 unità = **250 chiamate individuali a SDL2**
|
||
- Ogni chiamata ha overhead di context switch
|
||
- Non sfrutta batching hardware
|
||
|
||
**Soluzione - Sprite Batching:**
|
||
```python
|
||
class GameWindow:
|
||
def __init__(self, ...):
|
||
self.sprite_batch = [] # Accumula sprite da disegnare
|
||
|
||
def queue_sprite(self, x, y, sprite):
|
||
"""Accoda sprite invece di disegnarlo subito"""
|
||
if self.is_in_visible_area(x, y):
|
||
self.sprite_batch.append((sprite, x + self.w_offset, y + self.h_offset))
|
||
|
||
def flush_sprites(self):
|
||
"""Disegna tutti gli sprite in batch"""
|
||
for sprite, x, y in self.sprite_batch:
|
||
sprite.position = (x, y)
|
||
self.renderer.copy(sprite, dstrect=sprite.position)
|
||
self.sprite_batch.clear()
|
||
|
||
# Nel game loop
|
||
for unit in units:
|
||
unit.draw() # Ora usa queue_sprite invece di draw_image
|
||
renderer.flush_sprites() # Singolo flush alla fine
|
||
```
|
||
|
||
**Guadagno stimato:** 15-25% con 200+ unità
|
||
|
||
---
|
||
|
||
### 3. **Calcolo Posizioni Ridondante** ⚠️ MEDIA PRIORITÀ
|
||
|
||
**Problema in Rat.draw():**
|
||
```python
|
||
def draw(self):
|
||
start_perf = self.game.render_engine.get_perf_counter() # ← Non utilizzato!
|
||
direction = self.calculate_rat_direction() # ← Già calcolato in move()
|
||
|
||
# Calcolo partial_x/y ripetuto per ogni frame
|
||
if direction in ["UP", "DOWN"]:
|
||
partial_y = self.partial_move * self.game.cell_size * (1 if direction == "DOWN" else -1)
|
||
else:
|
||
partial_x = self.partial_move * self.game.cell_size * (1 if direction == "RIGHT" else -1)
|
||
|
||
x_pos = self.position_before[0] * self.game.cell_size + ...
|
||
y_pos = self.position_before[1] * self.game.cell_size + ...
|
||
|
||
# get_image_size() chiamato ogni frame
|
||
image_size = self.game.render_engine.get_image_size(image)
|
||
```
|
||
|
||
**Impatto:**
|
||
- `calculate_rat_direction()`: già calcolato in `move()` → **250 chiamate duplicate**
|
||
- `get_image_size()`: dimensioni statiche, non cambiano → **250 lookups inutili**
|
||
- Calcoli aritmetici ripetuti
|
||
|
||
**Soluzione - Cache in Unit:**
|
||
```python
|
||
class Rat(Unit):
|
||
def move(self):
|
||
# ... existing move logic ...
|
||
self.direction = self.calculate_rat_direction() # Cache direction
|
||
|
||
# Pre-calcola render_position durante move
|
||
self._update_render_position()
|
||
|
||
def _update_render_position(self):
|
||
"""Pre-calcola posizione di rendering"""
|
||
if self.direction in ["UP", "DOWN"]:
|
||
partial_y = self.partial_move * self.game.cell_size * (1 if self.direction == "DOWN" else -1)
|
||
partial_x = 0
|
||
else:
|
||
partial_x = self.partial_move * self.game.cell_size * (1 if self.direction == "RIGHT" else -1)
|
||
partial_y = 0
|
||
|
||
image_size = self.game.rat_image_sizes[self.sex if self.age > AGE_THRESHOLD else "BABY"][self.direction]
|
||
|
||
self.render_x = self.position_before[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2 + partial_x
|
||
self.render_y = self.position_before[1] * self.game.cell_size + (self.game.cell_size - image_size[1]) // 2 + partial_y
|
||
self.bbox = (self.render_x, self.render_y, self.render_x + image_size[0], self.render_y + image_size[1])
|
||
|
||
def draw(self):
|
||
sex = self.sex if self.age > AGE_THRESHOLD else "BABY"
|
||
image = self.game.rat_assets_textures[sex][self.direction]
|
||
self.game.render_engine.draw_image(self.render_x, self.render_y, image, tag="unit")
|
||
```
|
||
|
||
**Pre-cache dimensioni immagini in Graphics:**
|
||
```python
|
||
class Graphics:
|
||
def load_assets(self):
|
||
# ... existing code ...
|
||
|
||
# Pre-cache image sizes
|
||
self.rat_image_sizes = {}
|
||
for sex in ["MALE", "FEMALE", "BABY"]:
|
||
self.rat_image_sizes[sex] = {}
|
||
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
|
||
texture = self.rat_assets_textures[sex][direction]
|
||
self.rat_image_sizes[sex][direction] = texture.size
|
||
```
|
||
|
||
**Guadagno stimato:** 5-10% con 200+ unità
|
||
|
||
---
|
||
|
||
### 4. **Tag System Inutilizzato** ⚠️ BASSA PRIORITÀ
|
||
|
||
**Problema:**
|
||
```python
|
||
def delete_tag(self, tag):
|
||
"""Placeholder for tag deletion (not implemented)"""
|
||
pass
|
||
|
||
# Ogni draw passa tag="unit" ma non viene mai usato
|
||
unit.draw() # → draw_image(..., tag="unit")
|
||
```
|
||
|
||
**Impatto:**
|
||
- Overhead minimo di passaggio parametro inutile
|
||
- 250 unità × parametro = spreco memoria call stack
|
||
|
||
**Soluzione:**
|
||
Rimuovere parametro `tag` da `draw_image()` e tutte le chiamate.
|
||
|
||
**Guadagno stimato:** 1-2%
|
||
|
||
---
|
||
|
||
### 5. **Generazione Blood Stains Costosa** ⚠️ MEDIA PRIORITÀ
|
||
|
||
**Problema:**
|
||
```python
|
||
def add_blood_stain(self, position):
|
||
# Genera nuova surface SDL con pixel manipulation
|
||
new_blood_surface = self.render_engine.generate_blood_surface() # LENTO
|
||
|
||
if position in self.blood_stains:
|
||
# Combina surfaces con pixel blending
|
||
combined_surface = self.render_engine.combine_blood_surfaces(...) # MOLTO LENTO
|
||
|
||
# WORST: Rigenera TUTTO il background
|
||
self.background_texture = None # ← Forza rigenerazione completa
|
||
```
|
||
|
||
**Impatto:**
|
||
- Ogni morte di ratto → rigenerazione background completo
|
||
- 200 morti = **200 rigenerazioni** di texture enorme
|
||
- `generate_blood_surface()`: loop pixel-by-pixel
|
||
- `combine_blood_surfaces()`: blending manuale RGBA
|
||
|
||
**Soluzione - Pre-generazione + Overlay Layer:**
|
||
```python
|
||
class Graphics:
|
||
def load_assets(self):
|
||
# Pre-genera 10 varianti di blood stains
|
||
self.blood_stain_pool = [
|
||
self.render_engine.generate_blood_surface()
|
||
for _ in range(10)
|
||
]
|
||
self.blood_stain_textures = [
|
||
self.render_engine.factory.from_surface(surface)
|
||
for surface in self.blood_stain_pool
|
||
]
|
||
|
||
# Layer separato per blood
|
||
self.blood_layer_sprites = []
|
||
|
||
def add_blood_stain(self, position):
|
||
"""Aggiunge blood come sprite invece che rigenerare background"""
|
||
import random
|
||
blood_texture = random.choice(self.blood_stain_textures)
|
||
|
||
x = position[0] * self.cell_size
|
||
y = position[1] * self.cell_size
|
||
|
||
self.blood_layer_sprites.append((blood_texture, x, y))
|
||
|
||
def draw_blood_layer(self):
|
||
"""Disegna tutti i blood stains come sprites"""
|
||
for texture, x, y in self.blood_layer_sprites:
|
||
self.render_engine.draw_image(x, y, texture, tag="blood")
|
||
|
||
# Nel game loop
|
||
self.draw_maze() # Background statico (UNA SOLA VOLTA)
|
||
self.draw_blood_layer() # Blood stains come sprites
|
||
# ... draw units ...
|
||
```
|
||
|
||
**Guadagno stimato:** 20-30% durante scenari con molte morti
|
||
|
||
---
|
||
|
||
### 6. **Font Manager Creazione Inefficiente** ⚠️ BASSA PRIORITÀ
|
||
|
||
**Problema:**
|
||
```python
|
||
def generate_fonts(self, font_file):
|
||
fonts = {}
|
||
for i in range(10, 70, 1): # 60 font managers!
|
||
fonts.update({i: sdl2.ext.FontManager(font_path=font_file, size=i)})
|
||
return fonts
|
||
```
|
||
|
||
**Impatto:**
|
||
- 60 FontManager creati all'avvio
|
||
- Usa solo 3-4 dimensioni durante il gioco
|
||
- Memoria sprecata: ~60 × FontManager overhead
|
||
|
||
**Soluzione - Lazy Loading:**
|
||
```python
|
||
def generate_fonts(self, font_file):
|
||
self.font_file = font_file
|
||
self.fonts = {}
|
||
|
||
# Pre-carica solo dimensioni comuni
|
||
common_sizes = [20, 35, 45]
|
||
for size in common_sizes:
|
||
self.fonts[size] = sdl2.ext.FontManager(font_path=font_file, size=size)
|
||
|
||
def get_font(self, size):
|
||
"""Lazy load font se non esiste"""
|
||
if size not in self.fonts:
|
||
self.fonts[size] = sdl2.ext.FontManager(font_path=self.font_file, size=size)
|
||
return self.fonts[size]
|
||
```
|
||
|
||
**Guadagno:** Startup time: -200ms, Memoria: -5MB
|
||
|
||
---
|
||
|
||
### 7. **Performance Counter Inutilizzato** ⚠️ MINIMA PRIORITÀ
|
||
|
||
**Problema in Rat.draw():**
|
||
```python
|
||
def draw(self):
|
||
start_perf = self.game.render_engine.get_perf_counter() # Mai usato!
|
||
# ... resto del codice ...
|
||
```
|
||
|
||
**Impatto:**
|
||
- 250 chiamate a `SDL_GetPerformanceCounter()` per niente
|
||
- Overhead chiamata: ~0.001ms × 250 = 0.25ms/frame
|
||
|
||
**Soluzione:**
|
||
Rimuovere la riga o usarla per profiling reale.
|
||
|
||
---
|
||
|
||
## 📊 IMPATTO TOTALE STIMATO
|
||
|
||
### Performance Attuali (Stimate)
|
||
Con 250 unità:
|
||
- Collision detection: ~3.3ms (✅ ottimizzato)
|
||
- Rendering: **~10-15ms** (🔴 collo di bottiglia)
|
||
- Game logic: ~2ms
|
||
- **TOTALE: ~15-20ms/frame** (50-65 FPS)
|
||
|
||
### Performance Post-Ottimizzazione
|
||
Con 250 unità:
|
||
- Collision detection: ~3.3ms
|
||
- Rendering: **~4-6ms** (✅ migliorato 2.5x)
|
||
- Game logic: ~2ms
|
||
- **TOTALE: ~9-11ms/frame** (90-110 FPS)
|
||
|
||
---
|
||
|
||
## 🎯 PIANO DI IMPLEMENTAZIONE CONSIGLIATO
|
||
|
||
### Priority 1 - Quick Wins (1-2 ore)
|
||
1. ✅ **Viewport culling** (soluzione A - spatial grid)
|
||
2. ✅ **Cache render positions** in Rat
|
||
3. ✅ **Pre-cache image sizes**
|
||
4. ✅ **Rimuovi tag parameter**
|
||
|
||
**Guadagno atteso: 20-30%**
|
||
|
||
### Priority 2 - Medium Effort (2-3 ore)
|
||
5. ✅ **Blood stain overlay layer** (invece di rigenerazione)
|
||
6. ✅ **Sprite batching** (queue + flush)
|
||
|
||
**Guadagno atteso: +30-40% cumulativo = 50-70% totale**
|
||
|
||
### Priority 3 - Optional (1 ora)
|
||
7. ✅ **Lazy font loading**
|
||
8. ✅ **Rimuovi performance counter inutilizzato**
|
||
|
||
**Guadagno atteso: marginale ma cleanup code**
|
||
|
||
---
|
||
|
||
## 🔧 OTTIMIZZAZIONI AVANZATE (Opzionali)
|
||
|
||
### A. Texture Atlas per Rat Sprites
|
||
**Problema:** 250 ratti = 250 texture bind per frame
|
||
|
||
**Soluzione:**
|
||
```python
|
||
# Combina tutti i rat sprites in una singola texture
|
||
# Usa source rectangles per selezionare sprite specifici
|
||
rat_atlas = create_texture_atlas(all_rat_sprites)
|
||
renderer.copy(rat_atlas, srcrect=sprite_rect, dstrect=screen_rect)
|
||
```
|
||
|
||
**Guadagno:** +10-20% con 200+ unità
|
||
|
||
### B. Dirty Rectangle Tracking
|
||
**Problema:** Ridisegna tutto il background ogni frame
|
||
|
||
**Soluzione:**
|
||
```python
|
||
# Traccia solo le aree che sono cambiate
|
||
dirty_rects = []
|
||
for unit in units:
|
||
if unit.moved:
|
||
dirty_rects.append(unit.previous_rect)
|
||
dirty_rects.append(unit.current_rect)
|
||
|
||
# Ridisegna solo dirty rects
|
||
for rect in dirty_rects:
|
||
redraw_region(rect)
|
||
```
|
||
|
||
**Guadagno:** +30-50% su mappe grandi
|
||
|
||
### C. Multi-threaded Rendering
|
||
**Problema:** Single-threaded rendering
|
||
|
||
**Soluzione:**
|
||
```python
|
||
# Thread 1: Game logic + collision
|
||
# Thread 2: Preparazione sprite (calcolo posizioni, culling)
|
||
# Main thread: Solo rendering SDL
|
||
```
|
||
|
||
**Guadagno:** +40-60% su CPU multi-core
|
||
|
||
---
|
||
|
||
## 📈 METRICHE DI SUCCESSO
|
||
|
||
Dopo le ottimizzazioni Priority 1 e 2:
|
||
|
||
| Unità | FPS Attuale | FPS Target | FPS Atteso |
|
||
|-------|-------------|------------|------------|
|
||
| 50 | ~60 | 60 | 60+ |
|
||
| 100 | ~55 | 60 | 60+ |
|
||
| 200 | ~45 | 50 | 70-80 |
|
||
| 250 | ~35-40 | 50 | 60-70 |
|
||
| 300 | ~30 | 50 | 50-60 |
|
||
|
||
---
|
||
|
||
## 🧪 STRUMENTI DI PROFILING
|
||
|
||
### Script di Benchmark Rendering
|
||
```python
|
||
# test_rendering_performance.py
|
||
import time
|
||
from rats import MiceMaze
|
||
|
||
def benchmark_rendering():
|
||
game = MiceMaze('maze.json')
|
||
|
||
# Spawna 250 ratti
|
||
for _ in range(250):
|
||
game.spawn_rat()
|
||
|
||
# Misura 100 frame
|
||
render_times = []
|
||
for _ in range(100):
|
||
start = time.perf_counter()
|
||
|
||
# Solo rendering (no game logic)
|
||
game.draw_maze()
|
||
for unit in game.units.values():
|
||
unit.draw()
|
||
game.renderer.present()
|
||
|
||
render_times.append((time.perf_counter() - start) * 1000)
|
||
|
||
print(f"Avg render time: {sum(render_times)/len(render_times):.2f}ms")
|
||
print(f"Min: {min(render_times):.2f}ms, Max: {max(render_times):.2f}ms")
|
||
```
|
||
|
||
---
|
||
|
||
## 💡 CONCLUSIONI
|
||
|
||
Il rendering è **il principale bottleneck** con 200+ unità, non le collisioni.
|
||
|
||
**Ottimizzazioni critiche:**
|
||
1. Viewport culling (15% gain)
|
||
2. Sprite batching (25% gain)
|
||
3. Blood stain overlay (30% gain in scenari con morti)
|
||
4. Cache render positions (10% gain)
|
||
|
||
**Implementando Priority 1 + 2 si ottiene ~2.5x speedup sul rendering**, portando il gioco da ~40 FPS a ~70-80 FPS con 250 unità.
|
||
|
||
Il sistema di collisioni NumPy è già ottimizzato (3.3ms), quindi il focus deve essere sul rendering SDL2.
|