356. Ritmo de fotogramas con plazo absoluto
Sustituir el ritmo de sleep relativo por plazos absolutos de 60 FPS.
Lección 356 de 356 · tests/chapter_14_optimizations/test_356_absolute_deadline_frame_pacing.py
Archivo a actualizar
main.pyComportamiento manual esperado tras este paso
Al lanzarse con PyPy y una ROM local legal de Super Mario Bros., el arranque puede inicialmente ejecutarse por debajo del objetivo mientras PyPy calienta sus rutas ejecutadas con frecuencia. Tras aproximadamente 30 segundos, el juego debería volverse fluido y mantenerse cerca del objetivo a largo plazo de 60 FPS en una máquina con suficiente margen de procesamiento.
Por qué existe este paso
Cada iteración del bucle del frontend avanza un fotograma emulado completo. Si el bucle se ejecuta más rápido que la tasa de visualización prevista, el juego, la animación y la entrada avanzan demasiado rápido; si el ritmo retrasa demasiado cada fotograma, toda la máquina emulada funciona con demasiada lentitud. Por tanto, el frontend necesita alinear los fotogramas completados con una línea de tiempo de reloj de pared estable en lugar de limitarse a añadir un retraso aproximado tras cada fotograma.
El ritmo relativo calcula cada retraso a partir de la duración del fotograma actual. El sobrepaso del temporizador y la latencia de planificación desplazan entonces el punto de partida del siguiente fotograma, de modo que pequeños errores pueden acumularse como deriva de temporización. Un plazo absoluto, en cambio, otorga a cada fotograma una posición en un único calendario continuo de 60 Hz. Un fotograma que termina pronto espera; un fotograma ligeramente tardío mantiene la línea de tiempo establecida; un retraso de al menos un fotograma completo reinicia el calendario para que el frontend no intente una ráfaga de recuperación sin límite.
A 60 FPS, los fotogramas programados consecutivos están separados por aproximadamente 16,67 ms. Esta política controla la velocidad de presentación en el mundo real dejando las transiciones deterministas de CPU, PPU y renderizado independientes del reloj del host.
Nombres y responsabilidades
- No function is renamed.
- NES_NTSC_FPS is replaced by TARGET_DISPLAY_FPS because this frontend policy now
targets an integer 60 Hz display schedule.
- TARGET_FRAME_SECONDS keeps its name, but is derived from TARGET_DISPLAY_FPS.
- frame_start_time, frame_end_time, frame_elapsed_time, and wait_time are removed;
they belong to the superseded relative-delay implementation.
- next_frame_deadline is new mutable frontend scheduling state. It stores one
absolute timestamp and advances by exactly one target duration per frame.Cambios completos en main.py:
# --- DELETED BLOCK: APPROXIMATE NTSC FRAME TARGET ---
# NES_NTSC_FPS = 60.0988
# TARGET_FRAME_SECONDS = 1.0 / NES_NTSC_FPS
# --- END DELETED BLOCK ---
# --- NEW BLOCK: 60 FPS ABSOLUTE FRAME TARGET ---
TARGET_DISPLAY_FPS = 60
TARGET_FRAME_SECONDS = 1.0 / TARGET_DISPLAY_FPS
# --- END NEW BLOCK ---
...
running = True
# --- NEW LINE: FIRST ABSOLUTE FRAME DEADLINE ---
next_frame_deadline = time.perf_counter() + TARGET_FRAME_SECONDS
# --- END NEW LINE ---
last_fps_report_time = time.perf_counter()
frames_since_last_report = 0
while running:
# --- DELETED LINE: RELATIVE FRAME START ---
# frame_start_time = time.perf_counter()
# --- END DELETED LINE ---
...
executed = console.step_until_next_frame()
framebuffer = console.render_framebuffer()
draw_framebuffer(window, framebuffer, SCALE)
pygame.display.flip()
# --- DELETED BLOCK: RELATIVE REMAINING-TIME SLEEP ---
# frame_end_time = time.perf_counter()
# frame_elapsed_time = frame_end_time - frame_start_time
# wait_time = TARGET_FRAME_SECONDS - frame_elapsed_time
#
# if wait_time > 0:
# time.sleep(wait_time)
# --- END DELETED BLOCK ---
# --- NEW BLOCK: WAIT FOR AND ADVANCE THE ABSOLUTE DEADLINE ---
while time.perf_counter() < next_frame_deadline:
pass
now = time.perf_counter()
next_frame_deadline += TARGET_FRAME_SECONDS
# Abandon a large backlog instead of creating a catch-up spiral.
if now - next_frame_deadline >= TARGET_FRAME_SECONDS:
next_frame_deadline = now + TARGET_FRAME_SECONDS
# --- END NEW BLOCK ---
...Cómo estabiliza el plazo absoluto la temporización de fotogramas
Sea T = 16,67 ms una duración de fotograma objetivo.
Fast frame:
current deadline = 16.67 ms
work finishes = 14.00 ms
wait = 2.67 ms
next deadline = 16.67 + T = 33.34 ms
Slightly late frame:
current deadline = 33.34 ms
work finishes = 34.67 ms
lateness = 1.33 ms
next deadline = 33.34 + T = 50.01 msEl fotograma tardío no mueve la línea de tiempo establecida. El siguiente fotograma comienza sin espera adicional y dispone hasta 50,01 ms para recuperar el retraso anterior de 1,33 ms. Si su trabajo termina a los 48,67 ms, espera hasta los 50,01 ms y vuelve a estar sincronizado.
Esta adición es lo que conserva la línea de tiempo
next_frame_deadline += TARGET_FRAME_SECONDSAvanza a partir del plazo programado anterior. Sustituirlo por next_frame_deadline = now + TARGET_FRAME_SECONDS después de cada fotograma conservaría cada pequeño retraso y provocaría una deriva de temporización a largo plazo.
Severely late frame:
current deadline = 50.00 ms
work finishes = 90.00 ms
normally advanced deadline = 66.67 ms
remaining backlog = 23.33 msEl plazo recién programado está ahora más de un fotograma completo por detrás del momento actual. Mantenerlo provocaría fotogramas de recuperación inmediatos y repetidos, así que el reinicio abandona ese retraso acumulado obsoleto:
next_frame_deadline = now + TARGET_FRAME_SECONDSEl nuevo plazo pasa a ser 106,67 ms. Por tanto, un pequeño retraso se recupera contra el calendario existente, mientras que un retraso severo inicia un calendario nuevo en lugar de producir una espiral de recuperación sin límite.
El plazo absoluto evita la deriva de temporización relativa. Los fotogramas con un pequeño retraso pueden recuperarse contra el calendario existente; quedarse más de un fotograma completo por detrás reinicia el plazo y evita una espiral de recuperación sin límite.
Invariantes importantes
- el ritmo de reloj de pared sigue siendo política del frontend
- la emulación, el renderizado y la presentación ocurren antes de la espera
- la contabilidad de FPS ocurre después de la espera
- los plazos avanzan a partir del plazo anterior, no a partir del final de cada fotograma rápido
- un retraso de un fotograma completo o más reinicia el calendario
- no queda ningún ritmo relativo con time.sleep
Compensación
La espera activa evita el comportamiento de reactivación grosero de los sleeps cortos del sistema operativo, pero consume más CPU y energía mientras un fotograma va adelantado. Mejora la precisión de temporización en un sistema de propósito general; no convierte al frontend en un sistema de tiempo real estricto ni hace que un fotograma que excede su presupuesto termine más rápido.
Concepto erróneo habitual
El ritmo de fotogramas no es una optimización de rendimiento. No puede elevar una ejecución lenta a 60 FPS; solo evita que una ejecución suficientemente rápida avance el tiempo emulado con demasiada rapidez y limita la deriva de temporización a largo plazo.
Ejecutar esta lección
uv run pytest tests/chapter_14_optimizations/test_356_absolute_deadline_frame_pacing.py -v