Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SDL 2: Per Pixel Alpha error. #1289

Closed
4 of 8 tasks
Pololot64 opened this issue Sep 7, 2019 · 46 comments
Closed
4 of 8 tasks

SDL 2: Per Pixel Alpha error. #1289

Pololot64 opened this issue Sep 7, 2019 · 46 comments
Assignees
Labels
critical hard A hard challenge to solve Surface pygame.Surface
Milestone

Comments

@Pololot64
Copy link

Pololot64 commented Sep 7, 2019

  • minimal working example to reproduce the issue
  • write unit test(s) (see test_src_alpha_issue_1289) edit the file to unskip test, then python3 tests/surface_test.py -k test_src_alpha_issue_1289
  • add usable BLEND_PREMULTIPLIED special_flag to blit & document.
  • find conditions for the (src_surf+dest_surf+blit opts) to use the new blit. (see test_src_alpha_issue_1289 to help debug this) inside pgSurface_Blit in surface.c (started... see below in this issue description)
  • implement a more comprehensive test case with more example values and edge cases.
  • implement SDL1 compatible blitter for the specific case (similar to new BLEND_PREMULTIPLIED blitter) 'BLEND_SDL1_ALPHA'. This should be the default for backwards compatibility. Only with SSE2+ARM neon, not mmx (because 99.99% of x86 systems have SSE2 these days).
  • the SDL2 blitter should be available as 'BLEND_SDL2_ALPHA'
  • a function/option which makes 'BLEND_SDL2_ALPHA' the default.

Blend mode formula SDL2: https://hg.libsdl.org/SDL/file/3b03741c0095/include/SDL_blendmode.h#l37

         SDL_BLENDMODE_BLEND = 0x00000001,    /**< alpha blending
                                                   dstRGB = (srcRGB * srcA) + (dstRGB * (1-srcA))
                                                   dstA = srcA + (dstA * (1-srcA)) */

The BLEND_PREMULTIPLIED PRs for reference (the new blitter should be mostly the same as this)

find conditions for the (src_surf+dest_surf+blit opts) to use the new blit.

(see test_src_alpha_issue_1289 to help debug this) inside pgSurface_Blit in surface.c

#if IS_SDLv1
        result = SDL_BlitSurface(src, srcrect, dst, dstrect);
#else
        printf("meow1\n");
        printf("dst->format->BytesPerPixel:%i\n", dst->format->BytesPerPixel);
        printf("SDL_ISPIXELFORMAT_ALPHA(dst->format->format):%i\n", SDL_ISPIXELFORMAT_ALPHA(dst->format->format));
        printf("SDL_GetSurfaceAlphaMod(dst, &alpha) == 0:%i\n", SDL_GetSurfaceAlphaMod(dst, &alpha) == 0);

        printf("src->format->BytesPerPixel:%i\n", src->format->BytesPerPixel);
        printf("SDL_ISPIXELFORMAT_ALPHA(src->format->format):%i\n", SDL_ISPIXELFORMAT_ALPHA(src->format->format));
        printf("SDL_GetSurfaceAlphaMod(src, &alpha) == 0:%i\n", SDL_GetSurfaceAlphaMod(src, &alpha) == 0);

        if ((dst->format->BytesPerPixel == 4 && src->format->BytesPerPixel == 4) &&
            (SDL_ISPIXELFORMAT_ALPHA(dst->format->format) || SDL_GetSurfaceAlphaMod(dst, &alpha) == 0) &&
            (SDL_ISPIXELFORMAT_ALPHA(src->format->format) || SDL_GetSurfaceAlphaMod(src, &alpha) == 0)
          ) {
          printf("meow3 -> do special blit for SDL1 compat.");
        }

        result = SDL_BlitSurface(src, srcrect, dst, dstrect);

#endif /* IS_SDLv2 */

Images with alpha that are only white blit as grey. I am trying to lighten the screen but it is darkening it instead
Screenshot (8)
Screenshot (9)

Related Docs: https://www.pygame.org/docs/ref/surface.html#pygame.Surface.blit

@illume illume changed the title Pygame 2: Per Pixel Alpha error. SDL 2: Per Pixel Alpha error. Sep 7, 2019
@illume
Copy link
Member

illume commented Sep 7, 2019

Hi.

Thanks for the issue report!

Does your issue have anything to do with this? #1254 (screen defaults to white instead of black bug)

Do you have a script that can reproduce the issue?

cheers,

@illume illume added this to the 2.0 milestone Sep 7, 2019
@Pololot64
Copy link
Author

It does have to do with it. I found a workaround by blitting with "special_flags=(pygame.BLEND_RGBA_ADD)"

This will work for now. I see it is a known error :-)

@robertpfeiffer
Copy link
Contributor

I don't understand the problem here. Can you please post a snippet of code that shows the problem? I'll fix #1254 if that is what causes your problem, but I'd like to have a test case first.

@Pololot64
Copy link
Author

I am simply blitting an image with transparency onto a transparent surface created with:
lumen = pygame.Surface(size, SRCALPHA).convert_alpha()
Then I am blitting "lumen" onto the main canvas.

The transparent image is white but causes a dark overlay

@robertpfeiffer
Copy link
Contributor

I thought I knew what you meant, but your comment made me more confused. It's definitely not #1254.

So you have an image with semi-transparency. You blit that image to a surface with per-pixel alpha. Then you blit the second surface to the screen. I have trouble reproducing the bug.

@Pololot64
Copy link
Author

Lets say I blit an image that has only one color: [255, 255, 255, 100]. When blitted over something, this should lighten what is under it. Instead, If I blit it over a background with the color [255, 255, 255, 255], it turns grey. It should be invisible. Does that make more sense?

@robertpfeiffer
Copy link
Contributor

robertpfeiffer commented Sep 12, 2019

This seems to work the same in 1.9.6 and 2.0-dev3

import pygame
pygame.init()

screen=pygame.display.set_mode((200,200))
screen.fill((255,255,255,255))

surf2=pygame.Surface((100,100)).convert_alpha()
surf2.fill((255,255,255,100))

surf3=pygame.Surface((100,100)).convert_alpha()
surf3.fill((0,0,0,100))

screen.blit(surf3, (50,50))
screen.blit(surf2, (0,0))

surf2.blit(surf3, (25,25))
screen.blit(surf2, (100,100))

pygame.display.flip()
input()

@Pololot64
Copy link
Author

Pololot64 commented Sep 12, 2019

Ok. I am sorry I was not clear enough :-) I had to rush to do my responses. I will adapt your code:

#Use the attached photo white.png in the same directory as the script.
white

import pygame
pygame.init()

screen=pygame.display.set_mode((200,200))
screen.fill((255,255,255,255))

#First make surf2 have a transparent background with SRCALPHA
#This is a white image with transparency
white_transparent_image = pygame.image.load("white.png")

#Just check a value of a pixel in the image
print(white_transparent_image.get_at([10, 10]))
#This returns (255, 255, 255, 107) for me... ok so far



surf2=pygame.Surface((100,100), pygame.SRCALPHA).convert_alpha()
surf2.blit(white_transparent_image, [0, 0])






screen.blit(surf2, (0,0))

#What is the value now showing on the screen where the image is blitted?

print(screen.get_at([10, 10]))

#Wait... (191, 191, 191, 255)
#Why does (255, 255, 255, 107) blitted on top of (255,255,255,255) become (191, 191, 191, 255)?
#Wouldn't white blitted on top of white stay white... why do the pixels become darker??

screen.blit(surf2, (100,100))

pygame.display.flip()
input()

@dlon
Copy link
Member

dlon commented Sep 13, 2019

Reproduces for me. Here's a test case without an image file:

import pygame
pygame.init()

screen=pygame.display.set_mode((200,200))
screen.fill((255,255,255,255))
pygame.display.flip()

white_transparent_image = pygame.Surface((256, 256), pygame.SRCALPHA)
white_transparent_image.fill((255, 255, 255, 100))

#This returns (255, 255, 255, 100):
print(white_transparent_image.get_at([10, 10]))

surf2=pygame.Surface((256,256), pygame.SRCALPHA)
surf2.blit(white_transparent_image, [0, 0])

# this shows a grey screen:
screen.blit(surf2, (0,0))
# this gives the correct result (white):
#screen.blit(white_transparent_image, (0,0))

print(screen.get_at([10, 10]))

pygame.display.flip()
input()

@dlon
Copy link
Member

dlon commented Sep 13, 2019

So the problem is that you're blending a semi-transparent white image with a black surface (surf2), which produces an opaque grey image. This is the expected behavior, I believe.

@dlon
Copy link
Member

dlon commented Sep 13, 2019

It does seem to produce strange results even if the target surface is transparent:

import pygame
pygame.display.init()

pygame.display.set_mode((120, 120))


white_transparent_image = pygame.Surface((256, 256), pygame.SRCALPHA)
white_transparent_image.fill((255, 255, 255, 100))

# This returns (255, 255, 255, 100):
print(white_transparent_image.get_at([10, 10]))

surf2=pygame.Surface((256,256), pygame.SRCALPHA).convert_alpha()
surf2.fill((0,0,0,0))
# This returns (0, 0, 0, 0):
print(surf2.get_at([10, 10]))

# This returns (99, 99, 99, 99):
surf2.blit(white_transparent_image, [0, 0])
print(surf2.get_at([10, 10]))

Setting the RGB value to white gives you the desired result (plus a small error):

import pygame
pygame.display.init()
pygame.display.set_mode((120, 120))

white_transparent_image = pygame.Surface((256, 256), pygame.SRCALPHA)
white_transparent_image.fill((255, 255, 255, 100))

surf2=pygame.Surface((256,256), pygame.SRCALPHA).convert_alpha()
surf2.fill((255,255,255,0))

# This returns (253, 253, 253, 99):
surf2.blit(white_transparent_image, [0, 0])
print(surf2.get_at([10, 10]))

@Pololot64
Copy link
Author

What do you think is causing this...?

@dlon
Copy link
Member

dlon commented Sep 15, 2019

It's caused by the blend mode that is used. DstRGB = SrcAlpha * SrcRGB + (1 - SrcAlpha) * DstRGB doesn't produce the correct result. I think what we might want is this: DstRGB = SrcAlpha * SrcRGB + (1 - SrcAlpha) * DstAlpha * DstRGB

@Pololot64
Copy link
Author

Since that is what should be fixed, should I close this issue or leave it open?

@Pololot64
Copy link
Author

That explains how the issue was fixed when I changed the blendmode

@illume
Copy link
Member

illume commented Oct 3, 2019

This is the minimal example I can find to show the difference.

import pygame
  
surf1 = pygame.Surface((1, 1), pygame.SRCALPHA)
surf1.fill((255, 255, 255, 100))

surf2=pygame.Surface((1, 1), pygame.SRCALPHA)
surf2.blit(surf1, (0, 0))

print("surf1.get_at((0, 0))", surf1.get_at((0, 0)))
print("surf2.get_at((0, 0))", surf2.get_at((0, 0)))

@illume
Copy link
Member

illume commented Oct 3, 2019

If you use this code, you will see that the surface is defaulting to black (in pygame2), rather than to white (in 1.9.6 with SDL1).

import pygame
  
surf1 = pygame.Surface((1, 1), pygame.SRCALPHA, 32)
surf1.fill((255, 255, 255, 100))

surf2=pygame.Surface((1, 1), pygame.SRCALPHA, 32)
print("surf2.get_at((0,0))", surf2.get_at((0,0)))
#surf2.fill((255, 255, 255, 255))
surf2.fill((0, 0, 0, 255))
surf2.blit(surf1, (0, 0))

print("surf1.get_at((0, 0))", surf1.get_at((0, 0)))
print("surf2.get_at((0,0))", surf2.get_at((0,0)))

@illume
Copy link
Member

illume commented Oct 4, 2019

I pushed a failing test case here: #1372

(works for SDL1)

@illume
Copy link
Member

illume commented Oct 4, 2019

From https://wiki.libsdl.org/SDL_BlitSurface :

RGBA->RGBA:

    Source surface blend mode set to SDL_BLENDMODE_BLEND:
        alpha-blend (using the source alpha-channel and per-surface alpha) SDL_SRCCOLORKEY ignored. 
    Source surface blend mode set to SDL_BLENDMODE_NONE:
        copy all of RGBA to the destination. if SDL_SRCCOLORKEY set, only copy the pixels matching the RGB values of the source color key, ignoring alpha in the comparison. 

We see that if the source and dest have a blendmode then they blend. I guess previous behaviour is that they do not blend.

From https://wiki.libsdl.org/SDL_SetSurfaceBlendMode :

To copy a surface to another surface (or texture) without blending with the existing data, the blendmode of the SOURCE surface should be set to 'SDL_BLENDMODE_NONE'.

Perhaps a solution is to check if the src has a blendmode == blend, and if so remove the blend mode temporarily.

@illume
Copy link
Member

illume commented Oct 4, 2019

There's another test case that shows blending should be on when set_alpha(int) is used...

def test_set_alpha_value(self):
    """surf.set_alpha(x), where x != None, enables blending"""
    s = pygame.Surface((1,1), SRCALPHA, 32)
    s.fill((0, 255, 0, 128))
    s.set_alpha(255)

    s2 = pygame.Surface((1,1), SRCALPHA, 32)
    s2.fill((255, 0, 0, 255))
    s2.blit(s, (0, 0))
    self.assertGreater(s2.get_at((0, 0))[0], 0, "the red component should be above 0")

@illume
Copy link
Member

illume commented Nov 19, 2019

JoKing wrote:
I tracked down #742 to be because with SDL 2 in function set_alpha we use SDL_SetSurfaceAlphaMod and inside that function (in SDL source code) there is the line surface->map->info.flags &= ~SDL_COPY_MODULATE_ALPHA that only gets set if alpha == 255 (otherwise it is surface->map->info.flags |= SDL_COPY_MODULATE_ALPHA), you can see that in https://github.com/emscripten-ports/SDL2/blob/master/src/video/SDL_surface.c.
That has the effect on SDL_CalculateBlit (flag SDL_COPY_MODULATE_ALPHA is used in SDL_ChooseBlitFunc, all found in https://github.com/emscripten-ports/SDL2/blob/master/src/video/SDL_blit.c) so it sets blit to be NULL and that causes Blit combination not supported error (SDL_CalculateBlit in our code is used in surface.c on line 4088 where it returns 1 instead of 0). Sadly I don't know how to fix this but in my opinion this is the reason this code fails in SDL 2.
this could also be connected to the crit issue we are working on right now because it uses the same line (s.set_alpha(255)) in test_set_alpha_value

@illume
Copy link
Member

illume commented Nov 23, 2019

#1372 (review)

Why should this be true? I would surf1.blit(surf2) to alpha-blend by default?

@lordmauve Good question. This test passes with pygame 1.9.6 and pygame 1.9.1, but not pygame 2.0.0.dev7 (Ubuntu 18.04). So it's a case of keeping backwards compatibility.

note for self: python test/surface_test.py SurfaceTypeTest.test_src_alpha_issue_1289


Sorry, the test_src_alpha_issue_1289 passes when run by itself in 2.0.0.dev7.

It's currently marked as skipped, because it crashes when run with other tests.

    @unittest.skip("causes failures in other tests if run, so skip")
    def test_src_alpha_issue_1289(self):
        """ blit should be white.
        """
        surf1 = pygame.Surface((1, 1), pygame.SRCALPHA, 32)
        surf1.fill((255, 255, 255, 100))

        surf2 = pygame.Surface((1, 1), pygame.SRCALPHA, 32)
        self.assertEqual(surf2.get_at((0, 0)), (0, 0, 0, 0))
        surf2.blit(surf1, (0, 0))

        self.assertEqual(surf1.get_at((0, 0)), (255, 255, 255, 100))
        self.assertEqual(surf2.get_at((0, 0)), (255, 255, 255, 100))

Here is how it fails when run with other tests...

======================================================================
FAIL: test_src_alpha_issue_1289 (pygame.tests.surface_test.SurfaceTypeTest)
blit should be white.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/pygame-2.0.0.dev7-py3.7-macosx-10.13-x86_64.egg/pygame/tests/surface_test.py", line 747, in test_src_alpha_issue_1289
    self.assertEqual(surf2.get_at((0, 0)), (255, 255, 255, 100))
AssertionError: (99, 99, 99, 99) != (255, 255, 255, 100)

@illume
Copy link
Member

illume commented Nov 23, 2019

Next step: make a pure C test case that shows the behavior for making post to the libsdl bug tracker and discussion forum. This will also make sure it's not something pygame is doing.

@lordmauve
Copy link
Contributor

If the default behaviour for blit is to copy, then we need a separate blend mode to alpha blend correctly. The existing blend flags seem to be more unusual operations like additive or multiplicative blending.

Normal alpha blending, onto an RGBA surface, with a non-premultiplied input, is

dest.rgb = src.a * src.rgb + (1 - src.a) * dest.rgb
dest.a = src.a  + (1 - src.a) * dest.a

But this gives premultiplied output. If you want the buffer to end up with non-premultiplied alpha, for another blit operation, then you need to divide

if dest.a:
    dest.rgb /= dest.a

I think the trick is to always do pre-multiplied alpha.

Then the blend equations are always

dest.rgb = src.rgb + (1 - src.a) * dest.rgb
dest.a = src.a  + (1 - src.a) * dest.a

and you can always simple assume premultiplied alpha.

@lordmauve
Copy link
Contributor

So working backwards, SDL_BLENDMODE_BLEND seems to follow the first of those equations, and therefore, src is non premultiplied, dest is premultiplied.

@lordmauve
Copy link
Contributor

lordmauve commented Nov 23, 2019

So you shouldn't be able to use the target of a blit as the direct input to another blit - otherwise you'll see an unexpected grey.

@illume
Copy link
Member

illume commented Nov 23, 2019

It seems the default is to blend if they are SRCALPHA surfaces. SDL1 has a weird special case where src alpha is 0.

@illume illume unpinned this issue Jan 8, 2020
@xkzl
Copy link

xkzl commented Apr 21, 2020

Is there some plans of supporting ARGB32_Premultiplied format ?

@MyreMylar
Copy link
Contributor

MyreMylar commented Apr 30, 2020

Was discussing this on discord and made a simple test case demonstrating the situations where it crops up in pygame GUI:

import pygame

pygame.init()
window_surface = pygame.display.set_mode((640, 480))

background = pygame.Surface((640, 480))
background.fill(pygame.Color('#fb8691'))


tooltip_border = pygame.Surface((100, 100), flags=pygame.SRCALPHA)
tooltip_border.fill(pygame.Color('#ffB2A5'))
tooltip_rect = pygame.Rect(0, 0, 100, 100)
tooltip_rect.center = (320, 240)

tooltip_subtract = pygame.Surface((98, 98), flags=pygame.SRCALPHA)
tooltip_subtract.fill(pygame.Color('#FFFFFFFF'))
tooltip_border.blit(tooltip_subtract, (1, 1), special_flags=pygame.BLEND_RGBA_SUB)

tooltip_fill = pygame.Surface((98, 98), flags=pygame.SRCALPHA)
tooltip_fill.fill(pygame.Color('#fb9e91B0'))
tooltip_border.blit(tooltip_fill, (1, 1))

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()

    window_surface.blit(background, (0, 0))

    window_surface.blit(tooltip_border, tooltip_rect)

    pygame.display.update()

In pygame 2.0.0.dev7 (on windows) it looks like this:

image

While in 1.9.6 it looks like this:

image

I assume down to this issue.

@robertpfeiffer mentioned an interest in looking at it on linux to confirm it happens on all platforms.

@xkzl
Copy link

xkzl commented Apr 30, 2020

Not sure, what to conclude about it, if it is related to premultiplied alpha.
But here is the test result on the last ubuntu version
pygame 1.9.6
Capture d’écran 2020-04-30 à 14 31 05
pygame 2.0.0dev6
Capture d’écran 2020-04-30 à 14 31 28

@MyreMylar
Copy link
Contributor

MyreMylar commented May 4, 2020

I had a bash at implementing the pre-multiplied alpha technique @lordmauve was talking about using the current pygame blend modes.

Example:

from typing import Union, Tuple

import pygame

pygame.init()
window_surface = pygame.display.set_mode((640, 480))

background = pygame.Surface((640, 480))
background.fill(pygame.Color('#fb8691'))  # No alpha so can use normal blit

def pre_mult_alpha_blit(source_rgba: pygame.Surface,
                        source_alpha: pygame.Surface,
                        dest_surface_rgba: pygame.Surface,
                        dest_alpha: Union[pygame.Surface, None],
                        one_surf: pygame.Surface,
                        pos: Union[pygame.Rect, Tuple[int, int]]):
    # All RGBA surfaces should be be prepared for this blend mode by multiplying their alpha
    # with their RGB values.
    inv_source_alpha = pygame.transform.scale(one_surf, source_alpha.get_size())
    inv_source_alpha.blit(source_alpha, (0, 0), special_flags=pygame.BLEND_RGBA_SUB)
    dest_surface_rgba.blit(inv_source_alpha, pos, special_flags=pygame.BLEND_RGBA_MULT)
    dest_surface_rgba.blit(source_rgba, pos, special_flags=pygame.BLEND_RGBA_ADD)

    if dest_alpha is not None:
        dest_alpha.blit(inv_source_alpha, pos, special_flags=pygame.BLEND_RGBA_MULT)
        dest_alpha.blit(source_alpha, pos, special_flags=pygame.BLEND_RGBA_ADD)


def get_premul_colour(original_colour):
    alpha_mul = original_colour.a / 255
    return pygame.Color(int(original_colour.r * alpha_mul),
                        int(original_colour.g * alpha_mul),
                        int(original_colour.b * alpha_mul),
                        original_colour.a)


def get_alpha_colour(original_colour):
    alpha = original_colour.a
    return pygame.Color(alpha, alpha, alpha, alpha)


# 0. Create a universal 'one' surface we can use to invert alpha surfaces
one_surf = pygame.Surface((1, 1), flags=pygame.SRCALPHA)
one_surf.fill(pygame.Color('#FFFFFFFF'))

# 1. Build the border surface
# making an alpha enabled surface, so we'll need to pre-multiply the fill colour
# and keep a copy of the alpha colour as a surface
tooltip_border = pygame.Surface((100, 100), flags=pygame.SRCALPHA)
tooltip_border_alpha = pygame.Surface((100, 100), flags=pygame.SRCALPHA)
tooltip_border.fill(get_premul_colour(pygame.Color('#ffB2A5E0')))
tooltip_border_alpha.fill(get_alpha_colour(pygame.Color('#ffB2A5E0')))

# 2. Cutting a hole in our border surfaces for the fill background surface
tooltip_subtract = pygame.Surface((98, 98), flags=pygame.SRCALPHA)
tooltip_subtract.fill(pygame.Color('#FFFFFFFF'))
tooltip_border.blit(tooltip_subtract, (1, 1), special_flags=pygame.BLEND_RGBA_SUB)
tooltip_border_alpha.blit(tooltip_subtract, (1, 1), special_flags=pygame.BLEND_RGBA_SUB)

# 3. Building the background fill surface
# making an alpha enabled surface, so we'll need to pre-multiply the fill colour
# and keep a copy of the alpha colour as a surface
tooltip_fill = pygame.Surface((98, 98), flags=pygame.SRCALPHA)
tooltip_fill_alpha = pygame.Surface((98, 98), flags=pygame.SRCALPHA)
tooltip_fill.fill(get_premul_colour(pygame.Color('#fb9e91B0')))
tooltip_fill_alpha.fill(get_alpha_colour(pygame.Color('#fb9e91B0')))

# 4. blit the background onto the border using our new 'blendmode'
pre_mult_alpha_blit(source_rgba=tooltip_fill,
                    source_alpha=tooltip_fill_alpha,
                    dest_surface_rgba=tooltip_border,
                    dest_alpha=tooltip_border_alpha,
                    one_surf=one_surf,
                    pos=(1, 1))

# 5. create two position rectangles to blit with to check the technique works
tooltip_rect = pygame.Rect(0, 0, 100, 100)
tooltip_rect.center = (320, 240)
tooltip_rect_2 = tooltip_rect.copy()
tooltip_rect_2.x += 50
tooltip_rect_2.y += 50

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()

    window_surface.blit(background, (0, 0))

    pre_mult_alpha_blit(source_rgba=tooltip_border,
                        source_alpha=tooltip_border_alpha,
                        dest_surface_rgba=window_surface,
                        dest_alpha=None,
                        one_surf=one_surf,
                        pos=tooltip_rect)

    pre_mult_alpha_blit(source_rgba=tooltip_border,
                        source_alpha=tooltip_border_alpha,
                        dest_surface_rgba=window_surface,
                        dest_alpha=None,
                        one_surf=one_surf,
                        pos=tooltip_rect_2)

    pygame.display.update()

A lot of extra blits, surfaces and scale there to make it all hang together under the current system. On the upside it does seem to work:

image

That's in SDL2 and SDL1, as far as I can tell it looks the same.


So as far as I can see it there are two paths heading forwards:

  1. Add back the SDL1 bug/feature that sort of faked pre-multiplied alpha in the cases where the destination alpha was equal to zero.
  2. Implement pre-multiplied alpha blending properly as a special_flag blendmode on the surface blit method in pygame 2 and point people towards it if they encounter this issue. Likely also add support for loading images with a 'pre-multiply alpha' baking step as a way to make it easy to support both versions of pygame.

@MyreMylar
Copy link
Contributor

Hmm a bit of digging has uncovered that there is an undocumented blend mode for this already!

Here's my example above remade with the secret blendomode; BLEND_PREMULTIPLIED:

import pygame

pygame.init()
window_surface = pygame.display.set_mode((640, 480))

background = pygame.Surface((640, 480))
background.fill(pygame.Color('#fb8691'))  # No alpha so can use normal blit


def get_premul_colour(original_colour):
    alpha_mul = original_colour.a / 255
    return pygame.Color(int(original_colour.r * alpha_mul),
                        int(original_colour.g * alpha_mul),
                        int(original_colour.b * alpha_mul),
                        original_colour.a)


def get_alpha_colour(original_colour):
    alpha = original_colour.a
    return pygame.Color(alpha, alpha, alpha, alpha)


# 1. Build the border surface
# making an alpha surface, so we'll need to pre multiply the fill colour
# and build an inverse alpha surface
tooltip_border = pygame.Surface((100, 100), flags=pygame.SRCALPHA)
tooltip_border.fill(get_premul_colour(pygame.Color('#ffB2A5E0')))


# 2. Cutting a hole in our border surfaces for the fill background surface
tooltip_subtract = pygame.Surface((98, 98), flags=pygame.SRCALPHA)
tooltip_subtract.fill(pygame.Color('#FFFFFFFF'))
tooltip_border.blit(tooltip_subtract, (1, 1), special_flags=pygame.BLEND_RGBA_SUB)

# 3. Building the background fill surface
# making an alpha surface, so we'll need to pre multiply the fill colour
# and build an inverse alpha surface
tooltip_fill = pygame.Surface((98, 98), flags=pygame.SRCALPHA)
tooltip_fill.fill(get_premul_colour(pygame.Color('#fb9e91B0')))


# 4. blit the background onto the border
tooltip_border.blit(tooltip_fill, (1, 1),
                    special_flags=pygame.BLEND_PREMULTIPLIED)

tooltip_rect = pygame.Rect(0, 0, 100, 100)
tooltip_rect.center = (320, 240)
tooltip_rect_2 = tooltip_rect.copy()
tooltip_rect_2.x += 50
tooltip_rect_2.y += 50

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()

    window_surface.blit(background, (0, 0))

    window_surface.blit(tooltip_border, tooltip_rect, special_flags=pygame.BLEND_PREMULTIPLIED)
    window_surface.blit(tooltip_border, tooltip_rect_2, special_flags=pygame.BLEND_PREMULTIPLIED)

    pygame.display.update()

So I guess the most important action for this issue is actually to document this blend mode asap.

@MyreMylar
Copy link
Contributor

I had a go at swapping the Pygame GUI package over to using BLEND_PREMULTIPLIED. It's fairly doable, but the big wall I've run into is that swapping all my blits over to BLEND_PREMULTIPLIED seems to lead to about five times slower performance. Theoretically it should be a tiny bit faster because :

dest.rgb = src.rgb + (1 - src.a) * dest.rgb
dest.a = src.a + (1 - src.a) * dest.a

should be one less operation than:

dest.rgb = src.a * src.rgb + (1 - src.a) * dest.rgb
dest.a = src.a + (1 - src.a) * dest.a

But I guess how it is implemented in pygame versus a standard SDL blit is not even close.


Assuming for a moment that a SDL 2 version of BLEND_PREMULTIPLIED could be made five times faster, these are the helper functions I used to port Pygame GUI:

def premul_col(original_colour: pygame.Color) -> pygame.Color:
    """
    Perform a pre-multiply alpha operation on a pygame colour
    """
    alpha_mul = original_colour.a / 255
    return pygame.Color(int(original_colour.r * alpha_mul),
                        int(original_colour.g * alpha_mul),
                        int(original_colour.b * alpha_mul),
                        original_colour.a)


def restore_premul_col(premul_colour: pygame.Color) -> pygame.Color:
    """
    Restore a pre-multiplied alpha colour back to an approximation of it's initial value.

    NOTE: Because of the rounding to integers this cannot be exact.
    """
    inverse_alpha_mul = 1.0 / max(0.001, (premul_colour.a / 255))

    return pygame.Color(int(premul_colour.r * inverse_alpha_mul),
                        int(premul_colour.g * inverse_alpha_mul),
                        int(premul_colour.b * inverse_alpha_mul),
                        premul_colour.a)


def premul_alpha_surface(surface: pygame.Surface) -> pygame.Surface:
    """
    Perform a pre-multiply alpha operation on a pygame surface's colours.
    """
    surf_copy = surface.copy()
    surf_copy.fill(pygame.Color('#FFFFFF00'), special_flags=pygame.BLEND_RGB_MAX)
    manipulate_surf = pygame.Surface(surf_copy.get_size(), pygame.SRCALPHA)
    manipulate_surf.fill(pygame.Color('#00000001'))  # Can't be exactly transparent black or we trigger SDL1 'bug'
    manipulate_surf.blit(surf_copy, (0, 0))
    surface.blit(manipulate_surf, (0, 0), special_flags=pygame.BLEND_RGB_MULT)
    return surface


def render_white_text_alpha_black_bg(font: pygame.font.Font, text: str) -> pygame.Surface:
    """
    Render text with a zero alpha background with 0 in the other colour channels. Appropriate for
    use with BLEND_PREMULTIPLIED and for colour/gradient multiplication.
    """
    text_render = font.render(text, True, pygame.Color('#FFFFFFFF'))
    final_surface = pygame.Surface(text_render.get_size(), pygame.SRCALPHA)
    final_surface.fill(pygame.Color('#00000001'))  # Can't be exactly transparent black or we trigger SDL1 'bug'
    final_surface.blit(text_render, (0, 0))
    return final_surface


def apply_colour_to_surface(colour: pygame.Color,
                            shape_surface: pygame.Surface,
                            rect: Union[pygame.Rect, None] = None):
    """
    Apply a colour to a shape surface by multiplication blend. This works best when the shape
    surface is predominantly white.

    :param colour: The colour to apply.
    :param shape_surface: The shape surface to apply the colour to.
    :param rect: A rectangle to apply the colour inside of.

    """
    if rect is not None:
        colour_surface = pygame.Surface(rect.size, flags=pygame.SRCALPHA, depth=32)
        colour_surface.fill(colour)
        shape_surface.blit(colour_surface, rect, special_flags=pygame.BLEND_RGBA_MULT)
    else:
        colour_surface = pygame.Surface(shape_surface.get_size(),
                                        flags=pygame.SRCALPHA, depth=32)
        colour_surface.fill(colour)
        shape_surface.blit(colour_surface, (0, 0), special_flags=pygame.BLEND_RGBA_MULT)

Basically I had to wrap with one of these every time a colour was passed into the UI system (this was basically one place) or an image (a couple of places) from outside. I also had to change every place I rendered a transparent background text surface because that has a white colour on the transparent pixels which is obviously not pre-multiply friendly.

I guess if your use case is not performance critical then the BLEND_PREMULTIPLIED flag is still usable.

  • All caveats that I may have made a mistake somewhere.

Perfomance/Appearance Images from Pygame GUI

1.9.6 - Without pre-multiplied alpha blending:
1_9_6_no_pre_mul

2.0.0.dev6 - Without pre-multiplied alpha blending:
2_0_6_no_pre_mul
(Note visual errors)

1.9.6 - With pre-multiplied alpha blending:
1_9_6_pre_mul

2.0.0.dev6 - With pre-multiplied alpha blending:
2_0_6_pre_mul
(Visual errors gone, but frame rate gone from best to worst)

You can try out the pre-mul-alpha version of Pygame GUI on this branch:
https://github.com/MyreMylar/pygame_gui/tree/premul-alpha

@MyreMylar
Copy link
Contributor

MyreMylar commented May 6, 2020

Did some investigation into making pre-mul alpha faster by looking at what SDL is doing. From what I can tell for speed (on windows and probably the main desktop platforms) you want an MMX using function doing something like this:

/* fast ARGB888->(A)RGB888 blending with pixel alpha */
static void
blit_blend_premultiplied(SDL_BlitInfo * info)
{
    int n;
    int width = info->width;
    int height = info->height;
    Uint32 *srcp = (Uint32 *) info->s_pixels;
    int srcskip = info->s_skip >> 2;
    Uint32 *dstp = (Uint32 *) info->d_pixels;
    int dstskip = info->d_skip >> 2;
    SDL_PixelFormat *sf = info->src;
    Uint32 amask = sf->Amask;
    Uint32 ashift = sf->Ashift;
    Uint64 multmask, multmask2;

    __m64 src1, dst1, mm_alpha, mm_zero, mm_alpha2;

    mm_zero = _mm_setzero_si64();       /* 0 -> mm_zero */
    multmask = 0x00FF;
    multmask <<= (ashift * 2);
    multmask2 = 0x00FF00FF00FF00FFULL;

    while (height--) {
        /* *INDENT-OFF* */
        LOOP_UNROLLED4({
        Uint32 alpha = *srcp & amask;
        if (alpha == 0) {
            /* do nothing */
        } else if (alpha == amask) {
            *dstp = *srcp;
        } else {
            src1 = _mm_cvtsi32_si64(*srcp); /* src(ARGB) -> src1 (0000ARGB) */
            src1 = _mm_unpacklo_pi8(src1, mm_zero); /* 0A0R0G0B -> src1 */

            dst1 = _mm_cvtsi32_si64(*dstp); /* dst(ARGB) -> dst1 (0000ARGB) */
            dst1 = _mm_unpacklo_pi8(dst1, mm_zero); /* 0A0R0G0B -> dst1 */

            mm_alpha = _mm_cvtsi32_si64(alpha); /* alpha -> mm_alpha (0000000A) */
            mm_alpha = _mm_srli_si64(mm_alpha, ashift); /* mm_alpha >> ashift -> mm_alpha(0000000A) */
            mm_alpha = _mm_unpacklo_pi16(mm_alpha, mm_alpha); /* 00000A0A -> mm_alpha */
            mm_alpha2 = _mm_unpacklo_pi32(mm_alpha, mm_alpha); /* 0A0A0A0A -> mm_alpha2 */
            mm_alpha = _mm_or_si64(mm_alpha2, *(__m64 *) & multmask);    /* 0F0A0A0A -> mm_alpha */
            mm_alpha2 = _mm_xor_si64(mm_alpha2, *(__m64 *) & multmask2);    /* 255 - mm_alpha -> mm_alpha */

            /* blend */
            // Removed first two instructions so we don't apply alpha to source
            //src1 = _mm_mullo_pi16(src1, mm_alpha);
            //src1 = _mm_srli_pi16(src1, 8);
            dst1 = _mm_mullo_pi16(dst1, mm_alpha2);
            dst1 = _mm_srli_pi16(dst1, 8);
            dst1 = _mm_add_pi16(src1, dst1);
            dst1 = _mm_packs_pu16(dst1, mm_zero);

            *dstp = _mm_cvtsi64_si32(dst1); /* dst1 -> pixel */
        }
        ++srcp;
        ++dstp;
        }, n, width);
        /* *INDENT-ON* */
        srcp += srcskip;
        dstp += dstskip;
    }
    _mm_empty();
}

Which is ported to pygame from here:

https://hg.libsdl.org/SDL/file/015943013626/src/video/SDL_blit_A.c#l330

I know I've got something slightly wrong because it's not displaying my text boxes, and also segfaults in threading code with another of my text box samples - but it's damn close!

Example screenshot using above version of pre-multiplied alpha:

image

Performance is right, most GUI elements look right...

@MyreMylar
Copy link
Contributor

I also updated the issue about this on the SDL bugzilla tracker here:
https://bugzilla.libsdl.org/show_bug.cgi?id=4729

@nthykier
Copy link
Contributor

nthykier commented May 8, 2020

Hi,

Can you post your full reproducer for the "Options UI" screenshots you have been posting? :)

@MyreMylar
Copy link
Contributor

Can you post your full reproducer for the "Options UI" screenshots you have been posting? :)

Sure, to get this going you need:

pygame faster multiply blending branch: https://github.com/MyreMylar/pygame/tree/faster-pre-mul-alpha-blending

pygame_gui pre mul alpha branch: https://github.com/MyreMylar/pygame_gui/tree/premul-alpha

And then the actual sample code is just the 'general_ui_test.py' file from the pygame gui examples here:
https://github.com/MyreMylar/pygame_gui_examples

It's a bit of a lot to grab, I could probably make a more condensed stress test for pre-multiply alpha that just made lots of transparent surfaces that blitted onto each other. Basically the code posted in this comment with a loop and some calls to random.

The pygame branch has working MMX & SSE2 implementations of pre mul blending in it now.

@MyreMylar MyreMylar added the hard A hard challenge to solve label May 16, 2020
@Pololot64
Copy link
Author

I am starting to regret bringing this up. Sorry guys but amazing work so far! :-)

@MyreMylar
Copy link
Contributor

This issue is sort of related: #864

@illume
Copy link
Member

illume commented Sep 27, 2020

Some hacky progress in #2122

@illume
Copy link
Member

illume commented Oct 23, 2020

Closed thanks to @MyreMylar in #2213

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
critical hard A hard challenge to solve Surface pygame.Surface
Projects
None yet
Development

No branches or pull requests

8 participants