Simple color grading function for your shaders

Started by GaborD, December 12, 2017, 18:43:37

Previous topic - Next topic

GaborD

You can paste this function into a shader and call it if needed to color grade your output.
Should be used after tonemapping, it assumes already gamma spaced LDR RGB values with the channels in the 0.0 to 1.0 range.


vec3 color_grade(vec3 col3) {
float lmip = clamp((1.0-col3.b),0.00001,0.99999)*30.999;
lmip = clamp(lmip, 0.0, 30.8);
float lmip1 = floor(lmip);
lmip = fract(lmip);
vec2 llook = vec2(clamp((1.0-col3.x)*0.985,0.017,0.9999)*0.03125+lmip1*0.03125, 0.015+col3.g*0.981);
llook.g = clamp(llook.g, 0.0, 0.983);
vec2 llook2 = llook;
llook2.x += 0.03125;
llook.y=1.0-llook.y;
llook2.y=1.0-llook2.y;
return mix(textureLod(lutMap, llook, 0.0).rgb, textureLod(lutMap, llook2, 0.0).rgb, lmip);
}


Using it is simple: new_rgb_value = color_grade(old_rgb_value);

You also need to load a LUT (lookup table) first, should be a sampler2D named lutMap.
This assumes usual 32*32*32 LUTs, saved as 2D texture (so that this will work in engines that can't load volumetric textures.) The interpolation for the third axis is done manually in the function with 2 lookups and a blend factor.
Basically, this means the LUT is a 1024*32 2D texture. I use DDS, but any format will do if it doesn't change values too much due to compression.

I calibrated the values in C3D so they may need slight adjustments in other engines. Easy to check if you are on point: just load the unaltered base LUT into the engine and toggling the color grading should make almost no visual difference. (there is a bit of quantization going on in the LUT due to the small texture sizes, but that shouldn't have too much visual impact because bilinear filtering interpolates the inbetween-values nicely.)

If it looks totally messed up, eventually you have to comment out these:
llook.y=1.0-llook.y;
llook2.y=1.0-llook2.y;


Also fun to do:
Save 8 LUTs into one texture vertically (so that you have a 1024*256 texture with your 8 LUTs) and add this before the final line:

llook.y*=0.125;
llook.y+=luter*0.125;
llook2.y*=0.125;
llook2.y+=luter*0.125;


Define and update an uniform named luter and you can easily switch between your 8 packed LUTs from your main program.
This is what I use. Fun to switch around and see which looks best in a scene.

I attached a base LUT you can use as starting point.
What I do to get new LUTs is this:
I take a screenshot of the scene with no color grading, paste it into Photoshop, paste the base LUT in the next layer.
Then I adjust the look and feel to my liking with adjustment layers on top, so that they effect both image layers.
When I am happy with the look, I select the LUT (it's in an own layer, so just loosely mask around it, move it one pixel and the mask will snap exactly around it) and then I do a shift copy to copy it with all adjustment layers applied.
I can then make a new image, paste the clipboard in and save it as new LUT that will make the rendered runtime scene look exactly like what I adjusted it to in Photoshop.
There is also a LUT based adjustment layer that can load many LUT formats. This makes it very easy to get LUTs from the web into your game.

Have fun :)

Edit:

Added a demo zip with a simple Blitzmax+OpenB3D setup. (no exe, should be safe :) )
Shows how it can be easily used with a scene that has no shaders applied.
If you don't want to mess with a post process setup in your own project you can ofcourse also skip that whole part and just use the color grading function in object shaders as last step.





Derron

I appreciate tutorials on shaders (never done something with that until now):

- you assume people know the basics ("LUT")
- you do not show the "effect": maybe add a screenshot of what it should do

I ask for this because I do not know why you weight R, G, B in the way you weighted it. It looks as if you are doing something which weights some colors more than others (if so then working with L*AB or HSL color schemes might be more calculation intense but also more pleasing to the eyes as weighting is "better" there).


For another "tutorial" I would wish more "why and what I do there" instead of "this is the code and it does this and that".

bye
Ron

GaborD

#2
Yes you are right, this is more like a code snippet than a full tutorial. There is no general code snippet section, that's why I posted it here.
When I have more time I will add details and more explanations to it, thanks for the feedback.


Quote from: Derron on December 12, 2017, 19:40:38
I ask for this because I do not know why you weight R, G, B in the way you weighted it. It looks as if you are doing something which weights some colors more than others (if so then working with L*AB or HSL color schemes might be more calculation intense but also more pleasing to the eyes as weighting is "better" there).

There is actually no weighing going on, it's just a straight lookup based on the original RGB values. You can achieve any color based effect with it if you prepare an appropiate LUT. (LUT just means look up table)

How this works is that you do your tex lookup with each axis in the texture volume being one of the RGB channels instead of using UVs.
My original version actually used a volume texture and tex3D lookups. I just changed it to 2D to get it to work in engines that don't support volume textures. I basically fake the third axis with a manual interpolation. The functionality is the same.

So basically, the volume contains a lookup color for each possible RGB combination, shrunk down to 32 values per channel to keep the texture sizes manageable, because 256*256*256 would be a huge VMEM killer, especially if you want to keep several LUTs in video memory to blend or switch around.
But.. with bilinear filtering helping out, you get a nicely correct value for any RGB combination even with a 32*32*32 LUT.
The trick is to get the lookup positions right, so that a base LUT, which is just the assigned RGB values interpolated from 0 to 1 (1 being fullbright) on each axis, would basically not change the original input. That's why the function has some weird values, those seem to fit quite well.
If you look at the individual color channels of the base.dds, it should be clear how it is built. Each 32*32 square has red from left to right, green from top to bottom. Blue is the third axis, split out into the 32 side by side squares, 1 value each. So basically blue is the one we need to manually interpolate.

The beauty of this system is that it allows the artists to visually tune the output they want, in Photoshop or any other image editing software they are used to.
If you apply the same color value changes to the LUT as you did to the screenshot when you tweaked it to your liking, you will get the exactly same overall effect at runtime in the engine when using that LUT.

Obviously only non localized image effects that are applied the same to every pixel will work, so not things like blurs or smears that take neighboring pixels into account to calculate the result.
But you can select/mask colors and apply effects only to them. As long as the same colors are selected in both the tweaked screenshot and the base LUT, it will work. So you could change all greens to pink or such for nicely weird effects.

blendman

great !
Thanks.

Have a code example for AGK, to test it ?

Thanks again ;)

GaborD

Not yet, but I will make an AGK version and post it on the forums.