memononen / nanovg

Antialiased 2D vector drawing library on top of OpenGL for UI and visualizations.
zlib License
5.06k stars 767 forks source link

Rendering gamma-correct NanoVG output #593

Closed unvestigate closed 3 years ago

unvestigate commented 3 years ago

Hi,

I am trying (and failing) to fit NanoVG into my renderer which does most of its lighting calculations in linear color space. I do this by using sRGB texture formats for my color textures (which converts from sRGB to linear when sampling) and converting any colors passed into the shaders to linear space before doing calculations on them. I then use the DXGI_FORMAT_R8G8B8A8_UNORM_SRGB format for my back buffer when rendering, which converts the final result back to sRGB for rendering. I use D3D11 for rendering, if that matters.

In the following image you can see the NanoVG output when I don't use an sRGB render target. This is pretty much how the example renderer works AFAIK, and the image looks good and sharp. The only thing that looks wrong are the animal textures, since they still used the sRGB format when I took the image. Using them without the sRGB format fixes that too. This is essentially what I want (but with an sRGB render target).

gamma_1

In the next image you can see what happens when I switch to an sRGB render target. All the colors get washed out since we are now rendering gamma-space colors as if they were linear. However, you can see that the animal textures are now rendered correctly, as expected. (Don't mind the changing background color. It is the clear color and it is about the only color I don't care to gamma correct.) Other than being washed out, the NVG output looks more or less correct. There are clearly some problems, especially on the color wheel as the colors are not being interpolated correctly, but otherwise it looks okay.

gamma_2

To correct the image I added a color conversion from gamma -> linear at the start of the NanoVG pixel shader (or fragment shader in OpenGL lingo). The output can be seen in the following image.

gamma_3

The converted colors are the "inner color" and "outer color" values. I cannot find any other values to change, but let me know if there are more. The image below shows the output. The conversion looks like this:

float convertSRGBToLinear(float val)
{
    // The official transformation:
    const float a = 0.055f;

    if (val <= 0.04045f)
        return val / 12.92f;
    else
        return pow((val + a) / (1.0f + a), 2.4f);
}

...and it is applied to the RGB channels. It is a bit better than before but clearly we can see that the gradients aren't working correctly. The UI window looks a lot more "flat" than before, the blue section on the color wheel looks very "narrow" and the blue-green gradient under the bezier curve is hardly visible.

Do you have any idea what I am doing wrong? I am not super experienced with these things and I have run out of ideas.

unvestigate commented 3 years ago

I have been going at this for some time now, without much success. It seems no matter how/where I do the conversions something breaks. Since the output of the pixel shader is the backbuffer in sRGB format, it should be enough to convert the colors to linear space before doing any calculations and write straight out to the RT. However, this way the gradients are clearly not correct. They are a bit better if we don't convert at all, but then the output is all washed out (as expected).

Finally, if we convert to linear AFTER doing the color calculations most things seem to work fine but the NanoVG antialiasing is clearly broken. This is most visible on with the animal images where there is a lot of thin details and slow movements, and converting to linear makes the lines flicker.

I am sure this is something on my side rather than a bug with NanoVG but it is unclear to me what needs to be changed. How have other people dealt with this? Would it be easier to convert to sRGB in a shader before the NanoVG pass begins and write straight out to a normal (non-sRGB type) render target from NanoVG (like the demos do)?

memononen commented 3 years ago

I did not put much thought on gamma when making NanoVG. In theory it should be enough to convert the inputs to linear, render everything to linear target, and then gamma corrected before target displayed. Composition and gradients will look different. Composition correct, gradients lighter and edge-AA too sharp.

One way to counter for the AA is to apply some inverse gamma for strokeAlpha and scissor (after they have been combined). That should prevent the over sharpness. Ditto for text glyphs. I guess the easiest would be to do that in the shader.

color *= inverseGamma(strokeAlpha * scissor);

This article mentions using gamma 1.45 for text, not sure how that would look like as inverse. https://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/

unvestigate commented 3 years ago

Thanks for the tip regarding the AA. Tbh, my biggest gripe is with the gradients being wrong. I made a small test with how they work in gamma space vs supposedly-corrected linear space. Here is a gradient rendered without sRGB (ie. like the demos):

gamma_5

The rectangle in the middle is painted with a linear gradient. It starts and ends where the white rectangle starts and ends, and it looks correct. Here is the same thing when rendered using an sRGB render target:

gamma_4

For this, I applied the same sRGB->linear conversions as detailed in my original post. Unsurprisingly (considering how gamma curves work) the gradient is shifted so that almost all of the interim colors are near the dark end. I tried fiddling around with the lerping factor which produces the gradient in the shader. While this allowed me to remap certain gradients (such as this one) others (such as the color wheel in the demo data) broke spectacularily. I am unsure how to make all of them look correct in linear space.

Tomorrow I am going to try separating out the NanoVG rendering into a purely gamma-colored rendering pass. I think the out-of-the-box result looks so good that I don't feel like making it uglier by forcing it into linear space :)

Kiitos ja kumarrus!

unvestigate commented 3 years ago

I refactored my renderer to do gamma correction before the NanoVG render pass and render the NanoVG output to a non-sRGB RGBA8_UNORM back buffer. This makes the output look just like the demos. I will close this issue for now as it solves my immediate issues.