Quick answer: Per-pixel alpha surfaces ignore set_alpha. Fade with a temp surface filled (255,255,255,alpha) and BLEND_RGBA_MULT.

A menu screen fades from black using a PNG logo. Surface.set_alpha(128) does nothing — logo stays fully opaque. The PNG converted to per-pixel alpha; set_alpha doesn’t apply.

The Trick: Multiply Alpha

def draw_with_fade(screen, image, pos, alpha):
    if alpha >= 255:
        screen.blit(image, pos)
        return
    temp = image.copy()
    temp.fill((255, 255, 255, alpha), special_flags=pygame.BLEND_RGBA_MULT)
    screen.blit(temp, pos)

Copy the image, multiply with desired alpha across all pixels. RGB stays unchanged; alpha scaled.

Performance Note

Copy+fill+blit per frame for a fade. For a static fade (menu intro), pre-compute the faded surface. For dynamic alpha (e.g., per-frame), it’s ~O(width×height) work.

Numpy Variant

import pygame.surfarray as sa
import numpy as np

def set_pixel_alpha(surface, factor):
    arr = sa.pixels_alpha(surface)
    arr[:] = (arr * factor).astype(np.uint8)
    del arr

Direct numpy access; faster for large surfaces. Modifies in place — copy first if you don’t want to mutate the original.

Loss-Less Repeated Fade

If you call this each frame with diminishing factor, the original alpha gets multiplied repeatedly — lossy. Keep a master copy:

master = image.copy()
while running:
    faded = master.copy()
    set_pixel_alpha(faded, current_factor)
    screen.blit(faded, pos)

Or Avoid Per-Pixel Alpha

For sprites that don’t need per-pixel transparency (just colorkey + global alpha), use convert() + set_colorkey() + set_alpha(). Simpler.

Verifying

Animate alpha from 0 to 255. Logo smoothly fades in. No flicker or banding (assuming linear alpha range). Frame time within target.

“Per-pixel alpha and global alpha don’t mix. Pick one mode per surface, or multiply manually.”

For UI especially, batch your fadeable surfaces — one temp surface per group is faster than per-element copies.