Background alpha blending with FBO

Hello !

In the course of a project, I need multisampled off-screen rendering. I learned how the architecture usually works (a multisampled framebuffer, with depth and potentially stencil renderbuffers attached, as well as either a GL_TEXTURE_2D_MULTISAMPLE texture or a multisampled color renderbuffer as the first color attachement. Rendering performed in this FBO and then resolved to a non-multisampled FBO using glBlitFramebuffer).

I tried using the stencil test on the off-screen render, as well as the scissor test, both work flawlessly, and the anti-aliasing also seems functionnal. My issue is with color blending.

Initially, when the multisampled colorbuffer is created, I imagine it is “filled” with fully transparent pixels. The issue is that the color components of this (fully transparent) clear seems to still be used in blending, even though the blending mode is set to

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

For more clarity, here are a few renders ,with the star on the right being directly drawn to the screen, and the one on the left being rendered to a multisampled framebuffer with a color, depth and stencil renderbuffer (the red rectangle is rendered beforehand, only to appreciate the blending issues)

The renders

In each image, both polygons are rendered to their respective renderbuffers exactly the same (that is, with the same color, alpha and instructions)

With a gl.glClearColor(1f, 1f, 1f, 0f); clear in the MSAA FBO before any rendering.
Capture1

With a gl.glClearColor(0f, 0f, 1f, 0f); clear in the MSAA FBO before any rendering.
Capture2

With a gl.glClearColor(0f, 0f, 0f, 0f); clear in the MSAA FBO before any rendering.
Capture3

Ideally, I’d want both polygons to look exactly the same.

I also tried the same type of render with the non multisampled FBO and the same thing seems to be happening. Do I need to change blending modes (perhaps right before rendering the final texture ?). Is there any other thing that I’m missing ? Here is my code (JOGL, which is in java but should perform exactly as regular OpenGL)

My code
package offscreen;

import java.nio.IntBuffer;
import java.util.List;

import com.jogamp.opengl.GL;
import com.jogamp.opengl.GL2;
import com.jogamp.opengl.GLAutoDrawable;
import com.jogamp.opengl.GLEventListener;
import com.jogamp.opengl.awt.GLJPanel;

public class PanelListener implements GLEventListener{

	IntBuffer fbo;
	IntBuffer fboMSAA;
	IntBuffer texture;
	IntBuffer colorMSAA;
	IntBuffer depthMSAA;
	
	Frame frame;
	GLJPanel panel;
	GL2 gl;
	
	public PanelListener(GLJPanel panel, Frame frame) {
		super();
		this.panel = panel;
		this.frame = frame;
	}

	@Override
	public void display(GLAutoDrawable displayable) {
		gl = displayable.getGL().getGL2();
		
		gl.glClearColor(0f, 0f, 0f, 1f);
		gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);

		gl.glColor4f(1f, 0f, 0f, 1f);
		gl.glRectd(getXScreen(95), getYScreen(95), getXScreen(705), getYScreen(405));
		
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fboMSAA.get(0));
		gl.glViewport(0, 0, 600, 300);

		gl.glClearColor(0f, 0f, 0f, 0f);
		gl.glClear(GL2.GL_COLOR_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);
		gl.glScissor(70, 70, 600 - 140, 300 - 140);
		
		alpha = 0.4f;
		
		gl.glColor4f(0f, 1f, 0f, alpha);
		gl.glBegin(GL2.GL_TRIANGLE_FAN);
		glVertex(150, 150, 600, 300);
		for(int i = 0; i < 6; i++) {
			float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
			glVertex(150 + 120.0 * Math.sin(theta), 150 - 120.0 * Math.cos(theta), 600, 300);
		}
		gl.glEnd();
		
		gl.glDisable(GL2.GL_SCISSOR_TEST);
		
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, 0);
		gl.glBindFramebuffer(GL2.GL_READ_FRAMEBUFFER, fboMSAA.get(0));
		gl.glBindFramebuffer(GL2.GL_DRAW_FRAMEBUFFER, fbo.get(0));
		gl.glReadBuffer(GL2.GL_BACK);
		gl.glBlitFramebuffer(0, 0, 600, 300, 0, 0, 600, 300, GL2.GL_COLOR_BUFFER_BIT, GL2.GL_LINEAR);
		
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, 0);
		gl.glViewport(0, 0, panel.getWidth(), panel.getHeight());

		gl.glColor4f(1f, 1f, 1f, 1f);
		gl.glBindTexture(GL2.GL_TEXTURE_2D, texture.get(0));
		
		gl.glBegin(GL2.GL_QUADS);
		gl.glTexCoord2d(0, 0);
		glVertex(100, 250);
		gl.glTexCoord2d(0, 1);
		glVertex(100, 550);
		gl.glTexCoord2d(1, 1);
		glVertex(700, 550);
		gl.glTexCoord2d(1, 0);
		glVertex(700, 250);
		gl.glEnd();
		
		gl.glBindTexture(GL2.GL_TEXTURE_2D, 0);

		gl.glColor4f(0f, 1f, 0f, alpha);
		gl.glBegin(GL2.GL_TRIANGLE_FAN);
		glVertex(550, 400);
		for(int i = 0; i < 6; i++) {
			float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
			glVertex(550 + 120.0 * Math.sin(theta), 400 + 120.0 * Math.cos(theta));
		}
		gl.glEnd();
	}

	@Override
	public void dispose(GLAutoDrawable displayable) {
		
	}

	@Override
	public void init(GLAutoDrawable displayable) {
		gl = displayable.getGL().getGL2();

		gl.glEnable (GL2.GL_LINE_SMOOTH);
		gl.glEnable (GL2.GL_POINT_SMOOTH);
		gl.glEnable (GL2.GL_SMOOTH);
		gl.glEnable(GL2.GL_TEXTURE_2D);
		
		gl.glDrawBuffer(GL.GL_FRONT_AND_BACK);
		
		gl.glHint(GL2.GL_LINE_SMOOTH_HINT, GL2.GL_NICEST);
		gl.glHint(GL2.GL_POINT_SMOOTH_HINT, GL2.GL_NICEST);
		gl.glShadeModel(GL2.GL_SMOOTH);

		gl.glEnable(GL2.GL_BLEND);
		gl.glBlendFunc(GL2.GL_SRC_ALPHA, GL2.GL_ONE_MINUS_SRC_ALPHA);

		gl.glClearColor(0.3f, 0.3f, 0.3f, 1f);
		gl.glClear(GL2.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);

		gl.glEnable(GL.GL_MULTISAMPLE);

		texture = IntBuffer.allocate(1);
		fbo = IntBuffer.allocate(1);
		fboMSAA = IntBuffer.allocate(1);
		colorMSAA = IntBuffer.allocate(1);
		depthMSAA = IntBuffer.allocate(1);

		//CREATE THE MULTISAMPLED FBO
		gl.glGenFramebuffers(1, fboMSAA);
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fboMSAA.get(0));

		//CREATE THE MULTISAMPLED COLOR RENDERBUFFER
		gl.glGenRenderbuffers(1, colorMSAA);
		gl.glBindRenderbuffer(GL2.GL_RENDERBUFFER, colorMSAA.get(0));
		gl.glRenderbufferStorageMultisample(GL2.GL_RENDERBUFFER, 8, GL2.GL_RGBA8, 600, 300);

		//BIND THE MULTISAMPLED COLOR RENDERBUFFER TO THE FBO
		gl.glFramebufferRenderbuffer(GL2.GL_FRAMEBUFFER, GL2.GL_COLOR_ATTACHMENT0, GL2.GL_RENDERBUFFER, colorMSAA.get(0));

		//CREATE THE MULTISAMPLED STENCIL RENDERBUFFER
		gl.glGenRenderbuffers(1, depthMSAA);
		gl.glBindRenderbuffer(GL2.GL_RENDERBUFFER, depthMSAA.get(0));
		gl.glRenderbufferStorageMultisample(GL2.GL_RENDERBUFFER, 8, GL2.GL_DEPTH24_STENCIL8, 600, 300);

		//BIND THE MULTISAMPLED STENCIL RENDERBUFFER TO THE FBO
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fboMSAA.get(0));
		gl.glFramebufferRenderbuffer(GL2.GL_FRAMEBUFFER, GL2.GL_DEPTH_ATTACHMENT, GL2.GL_RENDERBUFFER, depthMSAA.get(0));
		gl.glFramebufferRenderbuffer(GL2.GL_FRAMEBUFFER, GL2.GL_STENCIL_ATTACHMENT, GL2.GL_RENDERBUFFER, depthMSAA.get(0));

		//CREATE THE CLASSIC FBO
		gl.glGenFramebuffers(1, fbo);
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fbo.get(0));

		//CREATE THE CLASSIC TEXTURE
		gl.glGenTextures(1, texture);
		gl.glBindTexture(GL2.GL_TEXTURE_2D, texture.get(0));
		
		IntBuffer raster = IntBuffer.allocate(600 * 300);

		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL2.GL_TEXTURE_BASE_LEVEL, 0);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL2.GL_TEXTURE_MAX_LEVEL, 0);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL2.GL_MIRRORED_REPEAT);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL2.GL_MIRRORED_REPEAT);
		
		gl.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL2.GL_RGBA8, 600, 300, 0, GL2.GL_BGRA, GL.GL_UNSIGNED_BYTE, raster);

		//BIND THE CLASSIC TEXTURE TO THE FBO
		gl.glFramebufferTexture2D(GL2.GL_FRAMEBUFFER, GL2.GL_COLOR_ATTACHMENT0, GL2.GL_TEXTURE_2D, texture.get(0), 0);
	}

	@Override
	public void reshape(GLAutoDrawable displayable, int x, int y, int w, int h) {
		
	}
	
	double getXScreen(double x) {
		return 2f * (x + 0.5) / (double) panel.getWidth() - 1f;
	}
	
	double getYScreen(double y) {
		return 2f * (panel.getHeight() - y - 0.5) / (double) panel.getHeight() - 1f;
	}
	
	double getXScreen(double x, double w, double h) {
		return 2f * (x + 0.5) / w - 1f;
	}
	
	double getYScreen(double y, double w, double h) {
		return 2f * (h - y - 0.5) / h - 1f;
	}
	
	private void glVertex(double x, double y) {
		gl.glVertex2d(getXScreen(x), getYScreen(y));
	}
	
	private void glVertex(double x, double y, double w, double h) {
		gl.glVertex2d(getXScreen(x, w, h), getYScreen(y, w, h));
	}
}

Thanks for your help !

But that’s not what you told OpenGL to do. You told OpenGL to clear the image to some specific colors. You then told OpenGL to blend against those colors. Therefore, the color values you clear the framebuffer to matter.

The color in the framebuffer does not become insignificant because the alpha is zero. The values mean whatever your math wants them to mean. And your math almost never looks at the destination alpha. So you need to figure out what math you really mean to do here.

1 Like

Hi ! Thanks for the response !

Well I understand that, but let’s take an example :

  1. The MSAA FBO is cleared with let’s say gl.glClearColor(0f, 0f, 1f, 0f);, so the data in the full renderbuffer is (0, 0, 1, 0)
  2. A triangle is drawn on top of it with the color gl.glColor4f(0f, 1f, 0f, 0.4f);

In this case, any section outside of the triangle, of course, still has the color (0, 0, 1, 0), which is a totally transparent blue. In the region of the triangle though, since the blend mode is glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); and that the source alpha is 0, then the source color factor should be 0 and the destination color factor should be 1, meaning that no blue should be present in the triangle, only green, no ? In fact, the math would point to a color value of exactly (0, 1, 0, 0.4) in the triangle…

So even when doing the math, I still don’t get why the clear color is still visible with this specific blending function. Since GL_SRC_ALPHA is the source color factor, then a clear of alpha 0 should mean that the final color isn’t affected by the source color at all.

Anyways, thanks for the response :slight_smile:

EDIT : I also noticed that, beside the blending issue, a border is also visible on the star rendered with an external multisampled FBO, also the color of the transparent clear. Of course if the blending is wrong now this makes sense (for a border pixel, resolving the multisampled renderbuffer means blending the triangle color with the clear color… ?), and should be fixed if blending is fixed

No, it’s blue with an alpha of 0.

No, the triangle is the source. The framebuffer is the destination. The source alpha is therefore the alpha of the triangle you’re drawing (as computed by your shader).

The source alpha would only be 0 if the source triangle said so.

1 Like

Oh yeah you’re right, my bad ^^

Well I kinda got stuck for a while, cause it seemed like the color should depend both on the source and destination alpha (if the destination alpha is 1 then the color only depends on the source alpha, but if it is 0 then the contribution of the destination color should be nothing, so the destination alpha must still be taken into account).

I looked it up and turns out that if blending with a transparent background, the glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); blending function doesn’t behave as I want to. I also came around “alpha premultiplication” but I still don’t really understand it, although apparently it may help with blending in the case where the destination isn’t fully opaque ?

I was just going to say, I think you may want to use Pre-multipled Alpha here instead. It separates the concepts of:

  1. how much light the current layer adds, from
  2. how much light the current layer occludes from the background.

The most readable, most easily-understandable (and easily the most fun) write-up I’ve seen on Pre-multipled Alpha is Tom Forsyth’s. It’s worth your time, whether or not you end up using it. It’ll help you sort out your thinking on what you want blending to do for you (and how you’re going to do it):

I don’t know what you’re referring to here.

Your destination is-what-it-is: the sum-total of the all the contributions behind it (assuming back-to-front blending). If there are no contributions, it’s transparent – i.e. RGBA = (0,0,0,0). Which is perfect when using it (e.g. as an imposter) to pre-composite a layered object, and then later blend this composited result on top of something else.

1 Like

Hm alright, so from what I understand, the way to do this is : only apply premultiplied colors (for example, in the case above, tbe background would be (0, 0, 0, 0) and the green triangle, (0, 0.4, 0, 0.4)), then blend using glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);, which yields (0, 0.4, 0, 0.4), do this for the whole render, and then to get the final straight alpha image, multiply the whole image by 1/alpha ?

The blending equation therefore is :

alphaFinal = alphaSource + alphaDest * (1 - alphaSource)
rbgFinal * alphaFinal = rgbSource * alphaSource + rgbDest * alphaDest * (1 - alphaSource)

This can be applied over and over for multiple layers, but if I then want the final image, I need to unpremultiply it right? With a pixel shader ?

Thanks for your help :slight_smile:

No. If you’re using an alpha channel, the final stage always involves blending over an opaque surface (alpha=1). Bear in mind that the physical screen is always opaque, i.e. the alpha channel has no effect upon what’s sent to the monitor.

To work through the math: if you have 3 colours c1,c2,c3 and their respective alpha values 1,a2,a3, and blend them in that order (c1 is the initial background colour, then c2 is blended onto it and c3 onto the result) using “mix” blending (a*src+(1-a)*dst) you have:

c = (c1.(1-a2)+c2.a2).(1-a3)+c3.a3
= c1.(1-a2).(1-a3) + c2.a2.(1-a3) + c3.a3
= (c1.1).(1-a2).(1-a3) + (c2.a2).(1-a3) + (c3.a3)
= (c1.1).(1-a2-a3+a2.a3) + (c2.a2).(1-a3) + (c3.a3)
= (c1.1).(1-a) + c.a
where
a = a3 + a2.(1-a3) + 0.(1-a2)
c.a = (c3.a3) + (c2.a2).(1-a3) + 0.(1-a2)

IOW, if you use pre-multiplied alpha and blend with glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA), a sequence of blends onto an intermediate buffer followed by blending the intermediate buffer over the destination is equivalent to just blending everything directly onto the destination.

Essentially, pre-multiplied (1,1-α) blending is associative, a~(b~c)=(a~b)~c, whereas un-multiplied (α,1-α) blending isn’t.

Apart from that, if you’re using textures then linear filtering works better with pre-multiplied alpha; un-multiplied alpha results in the colour from transparent pixels bleeding into the interpolated colour.

1 Like

Yes, of course ! since “solving” premultiplied alpha by dividing by the source alpha and then blending with glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); basically divides and then multiplies again by the source alpha, so just blending on the initial image with glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); does exactly the same ! Thanks a lot !

I just have 2 more questions :

  • You said :

Right now I’m actually using a multisampled color renderbuffer, and although the transparency works exactly as I want it to, there is still a black border present (right : directly on the screen, left : on a multisampled framebuffer, then blitted (if that’s a word) to another framebuffer with a non multisampled texture, then rendered to the screen. The black border is hard to notice so I added a zoomed version) :
Capture1


Would it change something to use a multisampled texture with linear filtering instead ?

EDIT : Actually, since the color transformation seems to work, my hunch is that these borders come up when blitting. Let’s say that the samples of the specified pixel contain both (0, 0.5, 0, 0.5) and (0, 0, 0, 0), in which case if there are only these 2 samples, the final color is (0, 0.25, 0, 0.25). I’m not sure if anything can be done about this (?)

  • If I need to work with loaded images for example, Do i need to premultiply them before blending ? and should this operation be done with a shader ?

Thanks !

That’s how it works. But note that (0,0.5,0,0.5) with pre-multiplied alpha is equivalent to (0,1,0,0.5) with un-multiplied alpha, while (0,0.25,0,0.25) with pre-multiplied alpha is equivalent to (0,1,0,0.25) with un-multiplied alpha. IOW, the single-sample buffer has the correct colour and alpha, whereas if you used un-multiplied alpha you’d get ((0,1,0,0.5)+(0,0,0,0))/2 = (0,0.5,0,0.25) rather than (0,1,0,0.25); IOW, the transparent colour (black) would bleed.

So I suspect that the black borders are coming from elsewhere. Are you using (1,1-α) or (α,1-α) blending when you render the single-sample texture onto the screen? If you use a source factor of GL_SRC_ALPHA with pre-multiplied image data, that will darken any pixels with α<1.

Ideally the image itself would use pre-multiplied alpha, i.e. the multiplication should be performed as part of the asset-creation pipeline. Failing that, you should pre-multiply on load, either on the CPU or GPU.

1 Like

I’m using (1,1-α), here is my display :

The code
@Override
public void display(GLAutoDrawable displayable) {
	gl = displayable.getGL().getGL2();
	
	gl.glClearColor(0f, 0f, 0f, 1f);
	gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);

	gl.glColor4f(1f, 0f, 0f, 1f);
	gl.glRectd(getXScreen(95), getYScreen(95), getXScreen(705), getYScreen(405));
	
	gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fboMSAA.get(0));
	gl.glViewport(0, 0, 600, 300);
	
	gl.glBlendFunc(GL2.GL_ONE, GL2.GL_ONE_MINUS_SRC_ALPHA);
	
	gl.glClearColor(0f, 0f, 0f, 0f);
	gl.glClear(GL2.GL_COLOR_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);
	gl.glScissor(70, 70, 600 - 140, 300 - 140);
	
	float alpha = (float) frame.mouseX / (float) panel.getWidth();
	if(alpha > 1f) alpha = 1f;
	if(alpha < 0f) alpha = 0f;
	alpha = 0.8f;
	
	gl.glColor4f(0f, alpha, 0f, alpha);
	gl.glBegin(GL2.GL_TRIANGLE_FAN);
	glVertex(150, 150, 600, 300);
	for(int i = 0; i < 6; i++) {
		float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
		glVertex(150 + 120.0 * Math.sin(theta), 150 - 120.0 * Math.cos(theta), 600, 300);
	}
	gl.glEnd();
	
	List<SVector> polygon = new ArrayList<SVector>();
	polygon.add(new SVector(200, 150));
	for(int i = 0; i < 6; i++) {
		float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
		polygon.add(new SVector(200 + 120.0 * Math.sin(theta), 150 - 120.0 * Math.cos(theta)));
	}
	
	fillShapeNZ(polygon, 600, 300, true);

	gl.glDisable(GL2.GL_SCISSOR_TEST);
	
	gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, 0);
	gl.glBindFramebuffer(GL2.GL_READ_FRAMEBUFFER, fboMSAA.get(0));
	gl.glBindFramebuffer(GL2.GL_DRAW_FRAMEBUFFER, fbo.get(0));
	gl.glReadBuffer(GL2.GL_BACK);
	gl.glBlitFramebuffer(0, 0, 600, 300, 0, 0, 600, 300, GL2.GL_COLOR_BUFFER_BIT, GL2.GL_LINEAR);
	
	gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, 0);
	gl.glViewport(0, 0, panel.getWidth(), panel.getHeight());

	gl.glColor4f(1f, 1f, 1f, 1f);
	gl.glBindTexture(GL2.GL_TEXTURE_2D, texture.get(0));
	
	gl.glBegin(GL2.GL_QUADS);
	gl.glTexCoord2d(0, 0);
	glVertex(100.5, 250.5);
	gl.glTexCoord2d(0, 1);
	glVertex(100.5, 550.5);
	gl.glTexCoord2d(1, 1);
	glVertex(700.5, 550.5);
	gl.glTexCoord2d(1, 0);
	glVertex(700.5, 250.5);
	gl.glEnd();
	
	gl.glBindTexture(GL2.GL_TEXTURE_2D, 0);
	gl.glBlendFunc(GL2.GL_SRC_ALPHA, GL2.GL_ONE_MINUS_SRC_ALPHA);

	gl.glColor4f(0f, 1f, 0f, alpha);
	gl.glBegin(GL2.GL_TRIANGLE_FAN);
	glVertex(550, 400);
	for(int i = 0; i < 6; i++) {
		float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
		glVertex(550 + 120.0 * Math.sin(theta), 400 + 120.0 * Math.cos(theta));
	}
	gl.glEnd();

	polygon = new ArrayList<SVector>();
	polygon.add(new SVector(600, 400));
	for(int i = 0; i < 6; i++) {
		float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
		polygon.add(new SVector(600 + 120.0 * Math.sin(theta), 400 + 120.0 * Math.cos(theta)));
	}
	
	fillShapeNZ(polygon, panel.getWidth(), panel.getHeight(), false);

	int color = get(frame.mouseX, frame.mouseY);
	System.out.println(colorR(color) + " : " + colorG(color) + " : " +colorB(color) + " : " +colorA(color));
}

Yeah of course this makes total sense… I’ve actually tried to print out the actual colors of the FBO texture and the final screen to make sure that I understand the math that is happening, but annoyingly, it only seems to happen on top of some colors. for instance, if the background is white, there is no border, whereas on top of red, there is… Here is an image to illustrate this :
Capture1

EDIT : I managed to check the colors of the pixel right below the green horizontal line, above the magenta. On the star on the left, the texture color was exactly (0, 0.5, 0, 0.5), the background was (1, 0, 1, 1) and after the blending, the result was (0.5, 0.5, 0.5, 1), which mathematically works out, but visually seems really dark compared to the render on the right. On the right, the star is rendered using (α, 1-α), its color should be (0, 1, 0, 0.5) (not premultiplied of course), the background is still (1, 0, 1, 1), and the result is around (0.66, 0.65, 0.66, 0.89) ? When the math suggests a color of (0.5, 0.5, 0.5, 0.75). :sob:

Thanks !

FWIW, blending (0,1,0,0.5) over (1,0,1,1) with (α,1-α) gives (0.5,0.5,0.5,0.75), and blending that over (1,1,1,1) gives (0.625,0.625,0.625,0.8125).

Are there other processes involved that you aren’t taking into account? Your initial post suggests that you’re dealing with some existing system.

Well this is just a standalone test, not the project, so there is pretty much nothing else here…

For completeness, here is the full code I’m running in the example above :

The full code

This is the Frame.java class :

package offscreen;

import java.awt.MouseInfo;

import javax.swing.JFrame;

import com.jogamp.opengl.GLCapabilities;
import com.jogamp.opengl.GLProfile;
import com.jogamp.opengl.awt.GLJPanel;

public class Frame extends JFrame{
	
	public int mouseX = 0;
	public int mouseY = 0;
	
	private static final long serialVersionUID = 1L;
	
	public static GLProfile PROFILE;
	public static GLCapabilities CAPABILITIES;
	public static GLJPanel panel;
	public static PanelListener listener;
	
	public Frame(int width, int height) {
		super();
		
		PROFILE = GLProfile.get(GLProfile.GL2);
		CAPABILITIES = new GLCapabilities(PROFILE);
		
		CAPABILITIES.setSampleBuffers(true);
		CAPABILITIES.setNumSamples(8);
		CAPABILITIES.setStencilBits(8);
		CAPABILITIES.setDoubleBuffered(true);
		CAPABILITIES.setPBuffer(true);
		
		panel = new GLJPanel(CAPABILITIES);
		listener = new PanelListener(panel, this);
		panel.addGLEventListener(listener);
		
		this.setContentPane(panel);
		
		this.setVisible(true);
		this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		this.setSize(width, height);
		
		run();
	}
	
	public void run() {
		while(true) {
			
			mouseX = MouseInfo.getPointerInfo().getLocation().x - panel.getLocationOnScreen().x;
			mouseY = MouseInfo.getPointerInfo().getLocation().y - panel.getLocationOnScreen().y;
			
			panel.display();
			
			try {Thread.sleep(1);}
			catch (InterruptedException e) {e.printStackTrace();}
		}
	}
}

This is the full PanelListener.java class :

package offscreen;

import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.io.File;
import java.io.IOException;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;

import javax.imageio.ImageIO;

import com.jogamp.opengl.GL;
import com.jogamp.opengl.GL2;
import com.jogamp.opengl.GLAutoDrawable;
import com.jogamp.opengl.GLEventListener;
import com.jogamp.opengl.awt.GLJPanel;

public class PanelListener implements GLEventListener{

	IntBuffer fbo;
	IntBuffer fboMSAA;
	IntBuffer texture;
	IntBuffer textureMSAA;
	IntBuffer colorMSAA;
	IntBuffer depthMSAA;
	
	Frame frame;
	GLJPanel panel;
	GL2 gl;
	
	public PanelListener(GLJPanel panel, Frame frame) {
		super();
		this.panel = panel;
		this.frame = frame;
	}

	@Override
	public void display(GLAutoDrawable displayable) {
		gl = displayable.getGL().getGL2();
		
		gl.glClearColor(1f, 1f, 1f, 1f);
		gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);

		gl.glColor4f(0f, 0f, 0f, 1f);
		gl.glRectd(-1f, 1f, 1f, getYScreen(310));
		gl.glColor4f(0f, 0f, 1f, 1f);
		gl.glRectd(-1f, getYScreen(310), 1f, getYScreen(340));
		gl.glColor4f(0f, 1f, 0f, 1f);
		gl.glRectd(-1f, getYScreen(340), 1f, getYScreen(370));
		gl.glColor4f(0f, 1f, 1f, 1f);
		gl.glRectd(-1f, getYScreen(370), 1f, getYScreen(400));
		gl.glColor4f(1f, 0f, 0f, 1f);
		gl.glRectd(-1f, getYScreen(400), 1f, getYScreen(430));
		gl.glColor4f(1f, 0f, 1f, 1f);
		gl.glRectd(-1f, getYScreen(430), 1f, getYScreen(460));
		gl.glColor4f(1f, 1f, 0f, 1f);
		gl.glRectd(-1f, getYScreen(460), 1f, getYScreen(490));
		gl.glColor4f(1f, 1f, 1f, 1f);
		gl.glRectd(-1f, getYScreen(490), 1f, -1f);
		
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fboMSAA.get(0));
		gl.glViewport(0, 0, 600, 300);
		
		gl.glBlendFunc(GL2.GL_ONE, GL2.GL_ONE_MINUS_SRC_ALPHA);
		
		gl.glClearColor(0f, 0f, 0f, 0f);
		gl.glClear(GL2.GL_COLOR_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);
		
		float alpha = (float) frame.mouseX / (float) panel.getWidth();
		if(alpha > 1f) alpha = 1f;
		if(alpha < 0f) alpha = 0f;
		alpha = 0.8f;
		
		gl.glColor4f(0f, alpha, 0f, alpha);
		gl.glBegin(GL2.GL_TRIANGLE_FAN);
		glVertex(150, 150, 600, 300);
		for(int i = 0; i < 6; i++) {
			float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
			glVertex(150 + 120.0 * Math.sin(theta), 150 - 120.0 * Math.cos(theta), 600, 300);
		}
		gl.glEnd();
		
		List<SVector> polygon = new ArrayList<SVector>();
		polygon.add(new SVector(200, 150));
		for(int i = 0; i < 6; i++) {
			float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
			polygon.add(new SVector(200 + 120.0 * Math.sin(theta), 150 - 120.0 * Math.cos(theta)));
		}
		
		fillShapeNZ(polygon, 600, 300, true);
		
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, 0);
		gl.glBindFramebuffer(GL2.GL_READ_FRAMEBUFFER, fboMSAA.get(0));
		gl.glBindFramebuffer(GL2.GL_DRAW_FRAMEBUFFER, fbo.get(0));
		gl.glReadBuffer(GL2.GL_BACK);
		gl.glBlitFramebuffer(0, 0, 600, 300, 0, 0, 600, 300, GL2.GL_COLOR_BUFFER_BIT, GL2.GL_LINEAR);
		
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, 0);
		gl.glViewport(0, 0, panel.getWidth(), panel.getHeight());

		gl.glColor4f(1f, 1f, 1f, 1f);
		gl.glBindTexture(GL2.GL_TEXTURE_2D, texture.get(0));
		
		gl.glBegin(GL2.GL_QUADS);
		gl.glTexCoord2f(0, 0);
		glVertex(100.5, 250.5);
		gl.glTexCoord2f(0, 1);
		glVertex(100.5, 550.5);
		gl.glTexCoord2f(1, 1);
		glVertex(700.5, 550.5);
		gl.glTexCoord2f(1, 0);
		glVertex(700.5, 250.5);
		gl.glEnd();
		
		gl.glBindTexture(GL2.GL_TEXTURE_2D, 0);
		gl.glBlendFunc(GL2.GL_SRC_ALPHA, GL2.GL_ONE_MINUS_SRC_ALPHA);

		gl.glColor4f(0f, 1f, 0f, alpha);
		gl.glBegin(GL2.GL_TRIANGLE_FAN);
		glVertex(550, 400);
		for(int i = 0; i < 6; i++) {
			float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
			glVertex(550 + 120.0 * Math.sin(theta), 400 + 120.0 * Math.cos(theta));
		}
		gl.glEnd();

		polygon = new ArrayList<SVector>();
		polygon.add(new SVector(600, 400));
		for(int i = 0; i < 6; i++) {
			float theta = (float) i * 3.1415926f * 4.0f / 5.0f;
			polygon.add(new SVector(600 + 120.0 * Math.sin(theta), 400 + 120.0 * Math.cos(theta)));
		}
		
		fillShapeNZ(polygon, panel.getWidth(), panel.getHeight(), false);

		int posx = (frame.mouseX - panel.getWidth() / 2) / 10 + 250;
		int posy = (frame.mouseY - panel.getHeight() / 2) / 10 + 400;

		int color = get(posx, posy);
		System.out.println(colorR(color) + " : " + colorG(color) + " : " +colorB(color) + " : " +colorA(color));
		int color2 = get(posx - 101, posy - 252, 600, 300, texture.get(0));
		System.out.println(colorR(color2) + " : " + colorG(color2) + " : " +colorB(color2) + " : " +colorA(color2));
		System.out.println();
		
		gl.glColor4f(1f, 1f, 1f, 1f);
		gl.glRectd(getXScreen(posx-0.5), getYScreen(posy-5.5), getXScreen(posx+0.5), getYScreen(posy+4.5));
		gl.glRectd(getXScreen(posx-5), getYScreen(posy-1.5), getXScreen(posx+5), getYScreen(posy-0.5));

		gl.glColor4f(1f, 1f, 1f, 1f);
		gl.glRectd(getXScreen(900), 1f, 1f, -1f);
		gl.glColor4f(0.7f, 0.7f, 0.7f, 1f);
		gl.glRectd(getXScreen(1000), 1f, getXScreen(1050), -1f);
		
		gl.glColor4f((float) colorR(color) / 255.0f, (float) colorG(color) / 255.0f, (float) colorB(color) / 255.0f, (float) colorA(color) / 255.0f);
		gl.glRectd(getXScreen(1000), getYScreen(400), getXScreen(1100), getYScreen(500));
		gl.glColor4f((float) colorR(color2) / 255.0f, (float) colorG(color2) / 255.0f, (float) colorB(color2) / 255.0f, (float) colorA(color2) / 255.0f);
		gl.glRectd(getXScreen(1000), getYScreen(500), getXScreen(1100), getYScreen(600));
		
	}
	
	public void save(String filename) {
		BufferedImage image = new BufferedImage(panel.getWidth(), panel.getHeight(), BufferedImage.TYPE_INT_ARGB);
		final int[] a = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
		int[] pixels = loadPixels();
		System.arraycopy(pixels, 0, a, 0, pixels.length);
		
		File file = new File(filename);
		String[] sections = filename.split("\\.");
		BufferedImage img;
		
		if(sections[sections.length - 1].equals("jpg")  ||
		   sections[sections.length - 1].equals("jpeg") ||
		   sections[sections.length - 1].equals("JPG")  ||
		   sections[sections.length - 1].equals("JPEG")) {
			img = new BufferedImage(panel.getWidth(), panel.getHeight(), BufferedImage.TYPE_INT_RGB);
			img.createGraphics().drawImage(image, 0, 0, null);
		}
		else if(sections[sections.length - 1].equals("bmp")  ||
		        sections[sections.length - 1].equals("BMP")){
			img = new BufferedImage(panel.getWidth(), panel.getHeight(), BufferedImage.TYPE_3BYTE_BGR);
			img.createGraphics().drawImage(image, 0, 0, null);
		}
		else if(sections[sections.length - 1].equals("wbmp")  ||
		        sections[sections.length - 1].equals("WBMP")){
			img = new BufferedImage(panel.getWidth(), panel.getHeight(), BufferedImage.TYPE_BYTE_BINARY);
			img.createGraphics().drawImage(image, 0, 0, null);
		}
		else {
			img = new BufferedImage(panel.getWidth(), panel.getHeight(), BufferedImage.TYPE_INT_ARGB);
			img.createGraphics().drawImage(image, 0, 0, null);
		}
		img.createGraphics().finalize();
		try {
			ImageIO.write(img, sections[sections.length - 1], file);
		} catch (IOException e) {
			System.err.println("ERROR : could not save the SImage.");
			e.printStackTrace();
		}
	}
	
	public int[] loadPixels() {
		IntBuffer buffer = IntBuffer.allocate(panel.getWidth() * panel.getHeight());
		gl.glReadPixels(0, 0, panel.getWidth(), panel.getHeight(), GL2.GL_BGRA, GL2.GL_UNSIGNED_BYTE, buffer);
		return buffer.array();
	}
	
	public int get(int x, int y) {
		IntBuffer buffer = IntBuffer.allocate(1);
		gl.glReadPixels(x, panel.getHeight() - y, 1, 1, GL2.GL_BGRA, GL2.GL_UNSIGNED_BYTE, buffer);
		return buffer.get(0);
	}
	
	public int get(int x, int y, int w, int h, int tex) {
		if(x >= 0 && x < w && y >= 0 && y < h) {
			gl.glBindTexture(GL.GL_TEXTURE_2D, tex);
			IntBuffer pixelRaster = IntBuffer.allocate(w * h);
			gl.glGetTexImage(GL.GL_TEXTURE_2D, 0, GL2.GL_BGRA, GL.GL_UNSIGNED_BYTE, pixelRaster);
			gl.glBindTexture(GL.GL_TEXTURE_2D, 0);
			return pixelRaster.get(x + y * w);
		}
		else return colorRGB(0, 0, 0);
	}

	@Override
	public void dispose(GLAutoDrawable displayable) {
		
	}

	@Override
	public void init(GLAutoDrawable displayable) {
		gl = displayable.getGL().getGL2();

        gl.glEnable (GL2.GL_LINE_SMOOTH);
        gl.glEnable (GL2.GL_POINT_SMOOTH);
        gl.glEnable (GL2.GL_SMOOTH);
        gl.glEnable(GL2.GL_TEXTURE_2D);
        
        gl.glDrawBuffer(GL.GL_FRONT_AND_BACK);
        
		gl.glHint(GL2.GL_LINE_SMOOTH_HINT, GL2.GL_NICEST);
		gl.glHint(GL2.GL_POINT_SMOOTH_HINT, GL2.GL_NICEST);
		gl.glShadeModel(GL2.GL_SMOOTH);

        gl.glEnable(GL2.GL_BLEND);
        gl.glBlendFunc(GL2.GL_SRC_ALPHA, GL2.GL_ONE_MINUS_SRC_ALPHA);
        
		gl.glClearColor(0.3f, 0.3f, 0.3f, 1f);
		gl.glClear(GL2.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT | GL.GL_STENCIL_BUFFER_BIT);

        gl.glEnable(GL.GL_MULTISAMPLE);

		texture = IntBuffer.allocate(1);
		fbo = IntBuffer.allocate(1);
		textureMSAA = IntBuffer.allocate(1);
		fboMSAA = IntBuffer.allocate(1);
		colorMSAA = IntBuffer.allocate(1);
		depthMSAA = IntBuffer.allocate(1);

		//CREATE THE MULTISAMPLED FBO
		gl.glGenFramebuffers(1, fboMSAA);
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fboMSAA.get(0));

		//CREATE THE MULTISAMPLED COLOR RENDERBUFFER
		gl.glGenRenderbuffers(1, colorMSAA);
		gl.glBindRenderbuffer(GL2.GL_RENDERBUFFER, colorMSAA.get(0));
		gl.glRenderbufferStorageMultisample(GL2.GL_RENDERBUFFER, 8, GL2.GL_RGBA8, 600, 300);

		//BIND THE MULTISAMPLED COLOR RENDERBUFFER TO THE FBO
		gl.glFramebufferRenderbuffer(GL2.GL_FRAMEBUFFER, GL2.GL_COLOR_ATTACHMENT0, GL2.GL_RENDERBUFFER, colorMSAA.get(0));

//		gl.glGenTextures(1,textureMSAA);
//		gl.glBindTexture(GL2.GL_TEXTURE_2D_MULTISAMPLE, textureMSAA.get(0));
//		gl.glTexStorage2DMultisample(GL2.GL_TEXTURE_2D_MULTISAMPLE, 8, GL2.GL_RGBA8, 600, 300, true);
//		gl.glFramebufferTexture2D(GL2.GL_FRAMEBUFFER, GL2.GL_COLOR_ATTACHMENT0, GL2.GL_TEXTURE_2D_MULTISAMPLE, textureMSAA.get(0), 0);

		//CREATE THE MULTISAMPLED STENCIL RENDERBUFFER
		gl.glGenRenderbuffers(1, depthMSAA);
		gl.glBindRenderbuffer(GL2.GL_RENDERBUFFER, depthMSAA.get(0));
		gl.glRenderbufferStorageMultisample(GL2.GL_RENDERBUFFER, 8, GL2.GL_DEPTH24_STENCIL8, 600, 300);

		//BIND THE MULTISAMPLED STENCIL RENDERBUFFER TO THE FBO
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fboMSAA.get(0));
		gl.glFramebufferRenderbuffer(GL2.GL_FRAMEBUFFER, GL2.GL_DEPTH_ATTACHMENT, GL2.GL_RENDERBUFFER, depthMSAA.get(0));
		gl.glFramebufferRenderbuffer(GL2.GL_FRAMEBUFFER, GL2.GL_STENCIL_ATTACHMENT, GL2.GL_RENDERBUFFER, depthMSAA.get(0));

		//CREATE THE CLASSIC FBO
		gl.glGenFramebuffers(1, fbo);
		gl.glBindFramebuffer(GL2.GL_FRAMEBUFFER, fbo.get(0));

		//CREATE THE CLASSIC TEXTURE
		gl.glGenTextures(1, texture);
		gl.glBindTexture(GL2.GL_TEXTURE_2D, texture.get(0));

		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL2.GL_TEXTURE_BASE_LEVEL, 0);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL2.GL_TEXTURE_MAX_LEVEL, 0);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL2.GL_MIRRORED_REPEAT);
		gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL2.GL_MIRRORED_REPEAT);
	    
		gl.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL2.GL_RGBA8, 600, 300, 0, GL2.GL_BGRA, GL.GL_UNSIGNED_BYTE, null);

		//BIND THE CLASSIC TEXTURE TO THE FBO
		gl.glFramebufferTexture2D(GL2.GL_FRAMEBUFFER, GL2.GL_COLOR_ATTACHMENT0, GL2.GL_TEXTURE_2D, texture.get(0), 0);
	}

	@Override
	public void reshape(GLAutoDrawable displayable, int x, int y, int w, int h) {
		
	}
	
	public int[] loadPixels(int tex, int w, int h) {
		gl.glBindTexture(GL.GL_TEXTURE_2D, tex);
		IntBuffer pixelRaster = IntBuffer.allocate(w * h);
		gl.glGetTexImage(GL.GL_TEXTURE_2D, 0, GL2.GL_BGRA, GL.GL_UNSIGNED_BYTE, pixelRaster);
		gl.glBindTexture(GL.GL_TEXTURE_2D, 0);
		return pixelRaster.array();
	}
	
	public void updatePixels(int tex, int w, int h, int[] pixels) {
		gl.glBindTexture(GL.GL_TEXTURE_2D, tex);
		gl.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, w, h, 0, GL2.GL_BGRA, GL.GL_UNSIGNED_BYTE, IntBuffer.wrap(pixels));
		
		gl.glBindTexture(GL.GL_TEXTURE_2D, 0);
	}
	
	double getXScreen(double x) {
		return 2f * (x + 0.5) / (double) panel.getWidth() - 1f;
	}
	
	double getYScreen(double y) {
		return 2f * (panel.getHeight() - y - 0.5) / (double) panel.getHeight() - 1f;
	}
	
	double getXScreen(double x, double w, double h) {
		return 2f * (x + 0.5) / w - 1f;
	}
	
	double getYScreen(double y, double w, double h) {
		return 2f * (h - y - 0.5) / h - 1f;
	}
	
	void fillShapeNZ(List<SVector> polygon, double w, double h, boolean premultiplied) {
		if(polygon.size() > 2) {
			gl.glClear(GL.GL_STENCIL_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT);
			
			gl.glEnable(GL.GL_DEPTH_TEST);
			gl.glEnable(GL.GL_STENCIL_TEST);
			gl.glDisable(GL.GL_CULL_FACE);
			
	        gl.glColorMask(false, false, false, false);
	        gl.glStencilMask(0xFF);
	       
	        gl.glStencilFunc(GL.GL_NEVER, 0, 0xFF);
	        
	        gl.glStencilOpSeparate(GL.GL_BACK, GL.GL_DECR_WRAP, GL.GL_DECR_WRAP, GL.GL_DECR_WRAP);
	        gl.glStencilOpSeparate(GL.GL_FRONT, GL.GL_INCR_WRAP, GL.GL_INCR_WRAP, GL.GL_INCR_WRAP);

		    gl.glBegin(GL.GL_TRIANGLE_FAN);
		    for(SVector v: polygon) glVertex(v.x, v.y, w, h);
		    gl.glEnd();
	
			gl.glColorMask(true, true, true, true);
	        gl.glStencilOp(GL.GL_ZERO, GL.GL_ZERO, GL.GL_ZERO);
	        
	    	gl.glStencilFunc(GL.GL_NOTEQUAL, 0, 0xFF);

			if(premultiplied) gl.glColor4f(0f, 0f, 0.5f, 0.5f);
			else gl.glColor4f(0f, 0f, 1f, 0.5f);

		    gl.glBegin(GL.GL_TRIANGLE_FAN);
		    for(SVector v: polygon) glVertex(v.x, v.y, w, h);
		    gl.glEnd();
		    
			gl.glDisable(GL.GL_DEPTH_TEST);
			gl.glDisable(GL.GL_STENCIL_TEST);
		}
	}
	
	private void glVertex(double x, double y) {
		gl.glVertex2d(getXScreen(x), getYScreen(y));
	}
	
	private void glVertex(double x, double y, double w, double h) {
		gl.glVertex2d(getXScreen(x, w, h), getYScreen(y, w, h));
	}

	static int colorR(int color) {
		return (color >> 16) & 0xFF;
	}

	static int colorG(int color) {
		return (color >> 8) & 0xFF;
	}

	static int colorB(int color) {
		return color & 0xFF;
	}

	static int colorA(int color) {
		return (color >> 24) & 0xFF;
	}

	static int colorRGB(int grey) {
		grey = (int) constrain(grey, 0, 255);

		return (255 << 24) | (grey << 16) | (grey << 8) | grey;
	}

	static int colorRGB(int grey, int alpha) {
		grey  = (int) constrain(grey,  0, 255);
		alpha = (int) constrain(alpha, 0, 255);

		return (alpha << 24) | (grey << 16) | (grey << 8) | grey;
	}
	
	static int colorRGB(int red, int green, int blue) {
		red   = (int) constrain(red,   0, 255);
		green = (int) constrain(green, 0, 255);
		blue  = (int) constrain(blue,  0, 255);

		return (255 << 24) | (red << 16) | (green << 8) | blue;
	}
	
	static int colorRGB(int red, int green, int blue, int alpha) {
		red   = (int) constrain(red,   0, 255);
		green = (int) constrain(green, 0, 255);
		blue  = (int) constrain(blue,  0, 255);
		alpha = (int) constrain(alpha, 0, 255);

		return (alpha << 24) | (red << 16) | (green << 8) | blue;
	}
	
	public static double constrain(double x, double a, double b) {
		return Math.min(Math.max(x, a), b);
	}
}

This is the Main.java class :

package offscreen;

public class Main {
	public static void main(String[] args) {
		@SuppressWarnings("unused")
		Frame frame = new Frame(1280, 720);
	}
}

There is also an SVector.java class, taken from the full project, that just contains 3D vectors aswell as a lot of manipulation functions for these, but they are only used as arguments for fillShapeNZ(List, double, double, boolean) so this code should definitely not matter.

There are lots of utility functions, but most of the important stuff is in the Frame constructor, the display function and the init function.

Well, if i retrace everything happening on the panel, first a clear with (1, 1, 1, 1) is performed, then the rectangular color stripes, of fully opaque colors, so at this point the pixel colors should have an alpha of 1 (for example, the magenta should be (1, 0, 1, 1), and I checked with the get(x, y) function and it is). Then right afterwards, a triangle with color (0, 1, 0, 0.8) is drawn on top of it. The blended color is exactly (0.2, 0.8, 0.2, 1) (or with the 8 bit-per-component colors I’m using, (51, 205, 51, 255)), but on the multisampled edge, the color goes to the (0.66, 0.65, 0.66, 0.89) (or (169, 165, 169, 229) in 8 bit-per-component color).

I should also note that I did try replacing the first clear with (0, 0, 0, 1), and all the colors are exactly the same.

If I was wrong and that the source alpha at this multisampled position wasn’t 0.5, I can solve for it :

The math

α² + (1-α) * 1 = 229/255
α² - α + 26/255 = 0
α = (1 ± sqrt(1 - 104/255))/2 = 0.884758646 or 0.115241354
Since the alpha has to be smaller than 0.8 in the antialiased pixels, then α = 0.115241354
R = 1 * α + Rs * (1-α) = Rs * (1 - α) = Rs * 0.884758646 + 0.115241354 = 0.662745098, therefore Rs = 0.61881706
G = 0 * α + Gs * (1-α) = Gs * (1 - α) = Gs * 0.884758646 = 0.647058824, therefore Gs = 0.731339362
B = 1 * α + Bs * (1-α) = Bs * (1 - α) = Bs * 0.884758646 + 0.115241354 = 0.662745098, therefore Bs = 0.61881706

This all suggests that the source color of blending is around (0.62, 0.73, 0.62, 0.11)…

Now, of course this is all supposing that there is actual (α,1-α) blending in the multisampled result, but I’d assume that the samples are just averaged with the magenta ones.

Since the final alpha is 0.898039216, and the “green” blended samples (which should actually be (0.2, 0.8, 0.2, 1)) have an alpha of 1, these can’t be the ones used (the source has alpha 1 as well as the destination, so averaging the samples MUST make the result have an alpha of 1)

EDIT : There is a mistake here, since the blended green should have an alpha of
0.8 * 0.8 + (1 - 0.8) * 1 = 0.84, which they do.

//OLD CALCULATIONS, WRONG
If the multisample average is between (0, 1, 0, 0.8) and (1, 0, 1, 1), then the proportion of “green” samples must be equal to linmap(229/255, 0.8, 1, 1, 0) = 0.5098039, and then :
R = 0.5098039 * 0 + (1 - 0.5098039) * 1 = 0.4901961
G = 0.5098039 * 1 + (1 - 0.5098039) * 0 = 0.5098039
B = 0.5098039 * 0 + (1 - 0.5098039) * 1 = 0.4901961
A = 0.5098039 * 0.8 + (1 - 0.5098039) * 1 = 0.89803922
Again, this isn’t what we see.
//OLD CALCULATIONS, WRONG

If the multisample average is between (0.2, 0.8, 0.2, 0.84) and (1, 0, 1, 1), then the proportion of “green” samples must be equal to linmap(229/255, 0.84, 1, 1, 0) = 0.6372547, and then :
R = 0.6372547 * 0.2 + (1 - 0.6372547) * 1 = 0.49019624
G = 0.6372547* 0.8 + (1 - 0.6372547) * 0 = 0.50980376
B = 0.6372547* 0.2 + (1 - 0.6372547) * 1 = 0.49019624
1 = 0.6372547* 0.84 + (1 - 0.6372547) * 1 = 0.898039248
Again, this isn’t what we see.

Sorry for the very long response, I wanted to be as thorough as possible haha

Thanks again :slight_smile:

Sorry for the double post.

I’m not sure that this is an issue with blending. I tried setting an opaque background for the multisampled texture and rendering using (α,1-α) and the same thing is happening, except this time the issue can’t be with blending…

Could this issue only be caused by using a multisampled texture and blitting it ? because here all the blending is performed directly on the texture itself… Also, while on the left the sum of the red and green components is always 255 (meaning that the samples are averaged), on the left the sum actually goes up to 372.

The star is geometry rasterized in a MSAA framebuffer, correct? If so, then yes, the MSAA downsample filtering could be causing this. You can figure this out of course by changing the MSAA source framebuffer/texture to a 1X (single-sample) framebuffer/texture and verifying that your problem goes away.

So why is it happening? You take 100% green (0,1,0), rasterize it on a 100% red background (1,0,0), so (without blending) your subsamples are all 100% green or 100% red. Then you tell the driver to naively average the results when downsampling the framebuffer from MSAA → 1X (1 sample/pixel). What do you get? Various blends between those, with (0.5,0.5,0) in the middle, which looks kinda dark.

For an alternative, read this:

In particular, see the diagrams under these sections:

  • Effects of gamma-incorrectnessColour blending
  • Effects of gamma-incorrectnessAntialiasing

This one for instance:

(Source URL: https://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/img/color-blending.jpg)

Another good link to read (with pictures and description that match your problem):

1 Like

Hi !

Yeah that looks pretty much exactly like the problem I’m having, especially, the fact that it mainly happens when overlaying certain colors (such as green on red/magenta)

I tried 1 sample per pixel, and it did go away :

And so from what I read of the ressource you gave, this seems to mean that the action to take is to convert the resolved texture to a gamma correct space with a shader using :
Capture
(That is, of course, if there is no native way to do this)

But other than that I still struggle ot understand what is happening. I first thought that this meant that the screen renderer uses linear gamma sRGB averages for the multisample resolution, while blitting works in regular sRGB space, but the section about antialiasing in the first link suggests the opposite :

"Antialiasing in γ=2.2 space results in overly dark “smoothing pixels” (right image); the text appears too heavy, almost as if it was bold. Running the algorithm in linear space produces much better results (left image), although in this case the font looks a bit too thin. Interestingly, Photoshop antialiases text using γ=1.42 by default, and this indeed seems to yield the best looking results".

Anyways, thanks for the articles and for helping me understand the issue… I never knew that color operations could be so tricky…

If you have any pointers to where to go to solve this issue (for instance, if the shader is an option or if there is a way to enable linear gamma sRGB resolution when blitting) let me know :slight_smile:

EDIT : I wrote this code section (it isn’t a shader yet, just plain code to test it out) :

	public void gammaEncode(int tex, int w, int h) {
		int[] pixels = loadPixels(tex, w, h);
		float gamma = 2.2f;
		
		for(int x = 0; x < w; x++) {
			for(int y = 0; y < h; y++) {
				int p = pixels[x + y * w];
				float r = (float) Math.pow((double) colorR(p) / 255.0, 1.0 / gamma);
				float g = (float) Math.pow((double) colorG(p) / 255.0, 1.0 / gamma);
				float b = (float) Math.pow((double) colorB(p) / 255.0, 1.0 / gamma);
				float a = (float) colorA(p) / 255.0f;
				pixels[x + y * w] = colorRGB((int)(r * 255.0), (int)(g * 255.0), (int)(b * 255.0), (int)(a * 255.0));
			}
		}
		
		updatePixels(tex, w, h, pixels);
	}

And I applied it to the FBO texture and this is the result I got (with x8 MSAA) :

Capture

This looks really good, so I think the shader may be the way to go :slight_smile:

Alright so this is probably the last update. It seems that simply using :

gl.glEnable(GL2.GL_FRAMEBUFFER_SRGB);

Makes everything looks as it’s supposed to, with alpha and color. here is the image with no need for color transformation shaders :

Capture

And so this is using premultiplied alpha with GL_FRAMEBUFFER_SRGB enabled.

Thank you all for your amazing help :slight_smile:

Great! Glad you were able to clear up your blending and downsampling artifacts.

1 Like