316. Fast pygame framebuffer draw

Improve pygame framebuffer drawing by replacing per-pixel rectangles with bulk blit.

Lesson 316 of 356 · tests/chapter_10_performance/test_316_fast_pygame_framebuffer_draw.py

File to update

tools/show_framebuffer.py

Why this step exists

The original draw_framebuffer() was intentionally simple and educational:

for each framebuffer pixel:
    create/fill one scaled pygame rectangle

For a NES frame, that means

256 * 240 = 61,440 pygame drawing operations per frame

That is slow because Python repeatedly crosses into pygame/SDL for thousands of tiny rectangles.

This step keeps the old implementation for comparison by renaming it

old_draw_framebuffer(...)

Then it creates a new faster draw_framebuffer(...) that:

1. packs the framebuffer pixels into one RGB byte buffer
2. creates one pygame Surface from that buffer
3. scales that Surface when needed
4. blits the complete image to the window

Mental model

Old path:
    Python -> pygame draw tiny rect
    Python -> pygame draw tiny rect
    Python -> pygame draw tiny rect
    ... 61,440 times per frame

New path:
    Python builds one RGB buffer
    pygame uploads/scales/blits one image

Bulk operations are usually much faster than many small Python-to-pygame calls.

Example implementation

def old_draw_framebuffer(
    surface: pygame.Surface,
    framebuffer: Framebuffer,
    scale: int,
) -> None:
    # Keep the old rectangle-based implementation for comparison.
    ...


def draw_framebuffer(
    surface: pygame.Surface,
    framebuffer: Framebuffer,
    scale: int,
) -> None:
    # Write the framebuffer to pygame surface using one image upload.
    rgb_bytes = bytearray(framebuffer.width * framebuffer.height * 3)

    write_index = 0
    for color in framebuffer.pixels:
        red, green, blue = color
        rgb_bytes[write_index] = red
        rgb_bytes[write_index + 1] = green
        rgb_bytes[write_index + 2] = blue
        write_index += 3

    frame_surface = pygame.image.frombuffer(
        bytes(rgb_bytes),
        (framebuffer.width, framebuffer.height),
        "RGB",
    )

    if scale == 1:
        surface.blit(frame_surface, (0, 0))
        return

    scaled_surface = pygame.transform.scale(
        frame_surface,
        (framebuffer.width * scale, framebuffer.height * scale),
    )
    surface.blit(scaled_surface, (0, 0))

Important boundary

This optimization belongs in tools/show_framebuffer.py. The emulator core still produces a pure Framebuffer and must not import pygame.

Out of scope

  • caching surfaces/buffers
  • NumPy/surfarray
  • Numba
  • changing main.py
  • frame pacing / speed cap

Run this lesson

uv run pytest tests/chapter_10_performance/test_316_fast_pygame_framebuffer_draw.py -v