Search code examples
pythonalphaalphablendingdropshadowpygame-ce

Why are my blurred drop shadows lightening instead of darkening a semi-transparent color in Pygame?


I feel like there is some fundamental aspect of alpha blending that I'm not understanding here.

I'm doing the following in Pygame to create a blurred drop shadow:

  • draw a white object
  • draw a black version of it on another surface
  • apply gaussian blur to the black version
  • blit the dark version and then the white version to a new surface

So far, so good. Now I blit this on top of a surface filled with a semi-transparent color, and then I blit that on top of the background. When I run this, however, the alpha of the shadow seems to pull the semi-transparent surface with it and makes the shadow brighter instead of black.

In my real project, I can't blit the two surfaces independently on the background: I need to return a single merged surface.

This is what the "wrong" drop shadow looks like.

a screenshot showing a white circle with a light grey drop shadow

As soon as I make the semi-transparent surface completely transparent, the issue disappears, and the shadow is dark again.

a screenshot showing a white circle with a black drop shadow

I have tried playing around with all the special_flags to change the blending mode when blitting, but no combination seems to help, or perhaps I just don't understand what I'm supposed to use.

Here is code to fully reproduce the above:

import pygame as pg


pg.init()
screen = pg.display.set_mode((400, 400))
clock = pg.time.Clock()

black_circle_surface = pg.Surface((100, 100), pg.SRCALPHA)
pg.draw.circle(black_circle_surface, (0, 0, 0), (50, 50), 25)

white_circle_surface = pg.Surface((100, 100), pg.SRCALPHA)
pg.draw.circle(white_circle_surface, (255, 255, 255), (50, 50), 25)

circle_and_shadow_surface = pg.Surface((100, 100), pg.SRCALPHA)
circle_and_shadow_surface.blit(
    pg.transform.gaussian_blur(black_circle_surface, radius=10), (0, 0)
)
circle_and_shadow_surface.blit(white_circle_surface, (0, 0))

semi_transparent_surface = pg.Surface((200, 200), pg.SRCALPHA)
semi_transparent_surface.fill(
    (255, 255, 255, 1)  # change the 1 to a 0 here to make the issue disappear
)
semi_transparent_surface.blit(
    circle_and_shadow_surface,
    (50, 50),
)

running = True
while running:
    for event in pg.event.get():
        if event.type == pg.QUIT:
            running = False

    screen.fill((20, 70, 80))

    screen.blit(semi_transparent_surface, (100, 100))

    pg.display.update()
    clock.tick(60)

How can I pre-mix the two surfaces together into one while preserving the black shadow?


Solution

  • NOTE: https://github.com/MyreMylar answered this for me on https://github.com/pygame-community/pygame-ce/issues/2808, I'm copying a slightly edited version of their response here with their permission.


    You need to use premultiplied alpha blending.

    In the average application it is safe just to pre-multiply everything and always blit with premultiplication, but as it happens here you have some alpha'd black and solid white pixels which are not affected by an alpha pre-multiplication operation (multiply a 0 colour by anything and it is still 0, multiply 255 by 1 and it is still 255):

    [...]

    import pygame
    import pygame as pg
    
    
    pg.init()
    screen = pg.display.set_mode((400, 400))
    clock = pg.time.Clock()
    
    black_circle_surface = pg.Surface((100, 100), pg.SRCALPHA)
    pg.draw.circle(black_circle_surface, (0, 0, 0), (50, 50), 25)
    black_circle_surface = pg.transform.gaussian_blur(
        black_circle_surface, radius=10
    )  # no need to pre-multiply as colour is zeros will not change whatever we multiply it by
    
    white_circle_surface = pg.Surface((100, 100), pg.SRCALPHA)
    white_circle_surface.fill(
        (0, 0, 0, 0)
    )  # no need to pre-multiply, alpha is zero and colour is zero
    pg.draw.circle(
        white_circle_surface, (255, 255, 255), (50, 50), 25
    )  # no need to pre-multiply alpha and colour are the same (either 0 or 255)
    
    circle_and_shadow_surface = pg.Surface((100, 100), pg.SRCALPHA)
    circle_and_shadow_surface.fill((0, 0, 0, 0))  # no need to pre-multiply alpha is zero
    circle_and_shadow_surface.blit(black_circle_surface, (0, 0))
    circle_and_shadow_surface.blit(white_circle_surface, (0, 0))
    
    semi_transparent_surface = pg.Surface((200, 200), pg.SRCALPHA)
    semi_transparent_surface.fill(
        (255, 255, 255, 1)  # change the 1 to a 0 here to make the issue disappear
    )
    semi_transparent_surface = (
        semi_transparent_surface.convert_alpha().premul_alpha()
    )  # need to pre-multiply RGB values will all be changed to 1
    semi_transparent_surface.blit(
        circle_and_shadow_surface, (50, 50), special_flags=pygame.BLEND_PREMULTIPLIED
    )
    
    running = True
    while running:
        for event in pg.event.get():
            if event.type == pg.QUIT:
                running = False
    
        screen.fill((20, 70, 80))
    
        screen.blit(
            semi_transparent_surface, (100, 100), special_flags=pygame.BLEND_PREMULTIPLIED
        )
    
        pg.display.update()
        clock.tick(60)
    

    This is the normal behaviour of the standard 'straight' alpha (the pygame default) and the superior 'premultiplied' alpha blending.

    The main advantage of 'straight' alpha is that it is easier to understand and easy to dynamically alter the alpha value - otherwise it sucks.

    Here is what the premultiplied alpha version looks like if we dial the semi-transparent surface up to 50 alpha:

    an image showing the correctly rendered drop shadow