Monday, May 25, 2020

Reverse engineering the rendering of The Witcher 3, part 19 - portals

This post is a part of the series "Reverse engineering the rendering of The Witcher 3".


If you have played The Witcher 3 for long enough, you know that Geralt is not a huge fan of portals. Let's find out if they are really that scary.

There are two types of portals in the game:
Blue portal
Fire portal

I will explain how the fire one is built. It's mostly because its code is simpler comparing to the blue one :)

Here is how the fire portal looks in the game:


The most important part of course is fire rotating towards the centre, but there is more than meets the eye. More about it later.

The plan for today is pretty standard: I will describe geometry first, the vertex and the pixel shaders later. Quite a few screenshots and videos incoming.

In terms of general rendering details, the portals are drawn in forward pass with blending enabled - pretty widespread approach in the game, check shooting stars article for more info.

Let's get going.


1. Geometry

Here's how the portal mesh looks like:
Local space - Front view

Local space - Side view

The mesh reminds Gabriel's Horn. The vertex shader squeezes it along one axis, here's the same mesh afterwards as seen from side (in world space):
The portal mesh after vertex shader (side view)

Besides position, each vertex has extra data associated with it: The relevant ones are (at this point I'll show visualization from RenderDoc, they will be described in more detail later):

Texcoords (float2):


Tangent (float3):



Color (float3):


All of them will be used later, but already at this point there is too much data for .obj file so exporting this mesh can be problematic. What I did was exporting every channel to a separate .csv file, and then I'm loading all the .csv files in my C++ application and am assembling the mesh in runtime from such loaded data.



2. Vertex shader

The vertex shader is not particularly interesting, let's have a quick look at the relevant fragment anyway:
 vs_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb1[7], immediateIndexed  
    dcl_constantbuffer cb2[6], immediateIndexed  
    dcl_input v0.xyz  
    dcl_input v1.xy  
    dcl_input v3.xyz  
    dcl_input v4.xyzw  
    dcl_input v6.xyzw  
    dcl_input v7.xyzw  
    dcl_input v8.xyzw  
    dcl_output o0.xyz  
    dcl_output o1.xyzw  
    dcl_output o2.xyz  
    dcl_output o3.xyz  
    dcl_output_siv o4.xyzw, position  
    dcl_temps 3  
   0: mov o0.xy, v1.xyxx  
   1: mul r0.xyzw, v7.xyzw, cb1[6].yyyy  
   2: mad r0.xyzw, v6.xyzw, cb1[6].xxxx, r0.xyzw  
   3: mad r0.xyzw, v8.xyzw, cb1[6].zzzz, r0.xyzw  
   4: mad r0.xyzw, cb1[6].wwww, l(0.000000, 0.000000, 0.000000, 1.000000), r0.xyzw  
   5: mad r1.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx  
   6: mov r1.w, l(1.000000)  
   7: dp4 o0.z, r1.xyzw, r0.xyzw  
   8: mov o1.xyzw, v4.xyzw  
   9: dp4 o2.x, r1.xyzw, v6.xyzw  
  10: dp4 o2.y, r1.xyzw, v7.xyzw  
  11: dp4 o2.z, r1.xyzw, v8.xyzw  
  12: mad r0.xyz, v3.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)  
  13: dp3 r2.x, r0.xyzx, v6.xyzx  
  14: dp3 r2.y, r0.xyzx, v7.xyzx  
  15: dp3 r2.z, r0.xyzx, v8.xyzx  
  16: dp3 r0.x, r2.xyzx, r2.xyzx  
  17: rsq r0.x, r0.x  
  18: mul o3.xyz, r0.xxxx, r2.xyzx  

The vertex shader looks pretty similar to the other ones we've seen in this series.
After a quick analysis and comparing with input layout, the output struct can be written like so:
 struct VS_OUTPUT  
 {  
      float3 TexcoordAndViewSpaceDepth : TEXCOORD0;  
      float3 Color : TEXCOORD1;  
      float3 WorldSpacePosition : TEXCOORD2;  
      float3 Tangent : TEXCOORD3;  
      float4 PositionH : SV_Position;  
 };  


One thing I wanted to point out is how the shader retrieves view-space depth (o0.z): it's just .w component of SV_Position.

there is a thread from gamedev.net which explains it in a bit more detail.



3. Pixel shader

Here is an example scene just before drawing a portal...:


...and after:


also, there is an useful "Clear Before Draw" overlay option in RenderDoc texture viewer, so we can precisely see the drawn portal:

The first observation is that the actual fire layer is drawn only in the central area of the mesh.

The pixel shader is 186 lines long, I put it here for convenience and reference. As usual, I will be showing relevant assembly fragments while explaining things.

It's also worth to notice that 100 lines of 186 are related with fog calculations.

To start, there are 4 textures attached as input: fire (t0), noise/smoke (t1), scene color (t6) and scene depth (t15):

Fire texture
Noise/smoke texture
Scene color
Scene depth
There is also a dedicated constant buffer with 14 params which control the effect:

While the inputs: position, tangent and texcoords are quite simple concepts, let's take a closer look at the "Color" channel. After a few experiments it seems this is not a color per se but rather three different masks which the shader uses to distinguish between individual layers and where to apply certain effects:

Color.r - heat haze mask. As the name implies, it's used for heat haze effect (more about it later):


Color.g - inner mask. Used mostly for the fire effect


Color.b - back mask. Used to determine where the "back" of the portal is.


In case of such effects I think it's better to describe particular layers individually instead of analyzing the assembly from the very start to the very end like I used to do long time ago.

So, here we go:



3.1. Fire layer

First, let's investigate the most important bit: a fire layer. Here is a video of it:


The basic idea to achieve such effect is using the static texcoords from per-vertex data and animate them using elapsed time variable from constant buffer. Having such animated texcoords, we sample a texture (fire in this case) with warp/repeat sampler.

Interestingly, in this particular effect actually only the .r channel of the fire texture is sampled. To make the effect more convincing two layers of fire are obtained this way and then they are modulated together.

Alright, alright... let's see some code finally!

We start with making the texcoords more dynamic as they reach the center of the mesh:
   const float2 texcoords = Input.TextureUV;  
   const float uvSquash = cb4_v4.x; // 2.50  
   ...      
 
   const float y_cutoff = 0.2;  
   const float y_offset = pow(texcoords.y - y_cutoff, uvSquash);  

here is the same, but in assembly lang:
  21: add r1.z, v0.y, l(-0.200000)  
  22: log r1.z, r1.z  
  23: mul r1.z, r1.z, cb4[4].x  
  24: exp r1.z, r1.z  


Then, the shader obtains texcoords for the first fire layer and samples the fire texture:
   const float elapsedTimeSeconds = cb0_v0.x;  
   const float uvScaleGlobal1 = cb4_v2.x; // 1.00  
   const float uvScale1 = cb4_v3.x;    // 0.15  
   ...  

   // Sample fire1 - the first fire layer  
   float fire1; // r1.w  
   {
     float2 fire1Uv;  
     fire1Uv.x = texcoords.x;  
     fire1Uv.y = uvScale1 * elapsedTimeSeconds + y_offset;  
        
     const float scaleGlobal = floor(uvScaleGlobal1); // 1.0
     fire1Uv *= scaleGlobal;  
       
     fire1 = texFire.Sample(samplerLinearWrap, fire1Uv).x;  
   }  
   

The corresponding assembly snippet is:
  25: round_ni r1.w, cb4[2].x  
  26: mad r2.y, cb4[3].x, cb0[0].x, r1.z  
  27: mov r2.x, v0.x  
  28: mul r2.xy, r1.wwww, r2.xyxx  
  29: sample_indexable(texture2d)(float,float,float,float) r1.w, r2.xyxx, t0.yzwx, s0  


Here's how the first layer looks like for elapsedTimeSeconds = 50.0:



And to show what y_cutoff actually does, here is the same scene but y_cutoff = 0.5:



This way we have obtained the first layer. Now, the shader obtains the second one:
   const float uvScale2 = cb4_v6.x;       // 0.06  
   const float uvScaleGlobal2 = cb4_v7.x; // 1.00  
   ...  
   
   // Sample fire2 - the second fire layer  
   float fire2; // r1.z  
   {            
     float2 fire2Uv;  
     fire2Uv.x = texcoords.x - uvScale2 * elapsedTimeSeconds;  
     fire2Uv.y = uvScale2 * elapsedTimeSeconds + y_offset;  
     
     const float fire2_scale = floor(uvScaleGlobal2);  
     fire2Uv *= fire2_scale;  
     
     fire2 = texFire.Sample(samplerLinearWrap, fire2Uv).x;  
   }  

and the assembly snippet responsible for it:
  144: mad r2.x, -cb0[0].x, cb4[6].x, v0.x  
  145: mad r2.y, cb0[0].x, cb4[6].x, r1.z  
  146: round_ni r1.z, cb4[7].x  
  147: mul r2.xy, r1.zzzz, r2.xyxx  
  148: sample_indexable(texture2d)(float,float,float,float) r1.z, r2.xyxx, t0.yzxw, s0  

So, as you can see, the only difference are UVs: Now the X is animated as well.

The second layer looks like so:


Once we have the two layers of inner fire, it's time to modulate them. This is a bit more complicated than a simple multiplication though, as the inner mask is involved:
   const float innerMask = Input.Color.y;  
   const float portalInnerColorSqueeze = cb4_v8.x; // 3.00  
   const float portalInnerColorBoost = cb4_v9.x; // 188.00  
   ...  
        
   // Calculate inner fire influence  
   float inner_influence;  // r1.z
   {  
     // innerMask and "-1.0" are used here to control where the inner part of a portal is.  
     inner_influence = fire1 * fire2 + innerMask;  
     inner_influence = saturate(inner_influence - 1.0);  
       
     // Exponentation to hide less luminous elements of inner portal  
     inner_influence = pow(inner_influence, portalInnerColorSqueeze);  
       
     // Boost the intensity  
     inner_influence *= portalInnerColorBoost;  
   }  

And corresponding assembly:
  149: mad r1.z, r1.w, r1.z, v1.y  
  150: add_sat r1.z, r1.z, l(-1.000000)  
  151: log r1.z, r1.z  
  152: mul r1.z, r1.z, cb4[8].x  
  153: exp r1.z, r1.z  
  154: mul r1.z, r1.z, cb4[9].x  

Once we have inner_influence, which is nothing more than just a mask for inner fire, all we have to do is to multiply the mask with the inner fire color:

   // Calculate portal color  
   const float3 colorPortalInner = cb4_v5.rgb; // (1.00, 0.60, 0.21961)  
   ...  
   
   const float3 portal_inner_final = pow(colorPortalInner, 2.2) * inner_influence;  

the assembly:
  155: log r2.xyz, cb4[5].xyzx  
  156: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  157: exp r2.xyz, r2.xyzx  
  ...  
  170: mad r2.xyz, r2.xyzx, r1.zzzz, r3.xyzx  


Here is a video which shows particular layers of inner fire in action: The order: the first layer, the second layer, the inner influence and the final inner color:



3.2. Glow

Once we have the inner fire, let's take at the second layer: glow. Here is the video which shows inner fire only, then glow only and then their sum - the final fire effect:



Here's how the shader calculates the glow. Similar to the inner fire, at first a mask is generated and then multiplied with glow color from the constant buffer.
   const float portalOuterGlowAttenuation = cb4_v10.x; // 0.30  
   const float portalOuterColorBoost = cb4_v11.x; // 1.50
   const float3 colorPortalOuterGlow = cb4_v12.rgb; // (1.00, 0.61961, 0.30196)  
   ...  
  
   // Calculate outer portal glow  
   float outer_glow_influence;  
   {    
     float outer_mask = (1.0 - backMask) * innerMask;  
       
     const float perturbParam = fire1*fire1;  
     float outer_mask_perturb = lerp( 1.0 - portalOuterGlowAttenuation, 1.0, perturbParam );  
       
     outer_mask *= outer_mask_perturb;  
     outer_glow_influence = outer_mask * portalOuterColorBoost;  
   }  
     
   // the final glow color  
   const float3 portal_outer_final = pow(colorPortalOuterGlow, 2.2) * outer_glow_influence; 
 
   // and the portal color, the sum of fire and glow
   float3 portal_final = portal_inner_final + portal_outer_final;


Here's how the outer_mask looks:

 (1.0 - backMask) * innerMask


The glow is not a constant color. To make it more interesting, it uses animated first fire layer (squared) so wobbles going towards the centre can be noticed:



And the assembly responsible for the glow:
  158: add r2.w, -v1.z, l(1.000000)  
  159: mul r2.w, r2.w, v1.y  
  160: mul r1.w, r1.w, r1.w  
  161: add r3.x, l(1.000000), -cb4[10].x  
  162: add r3.y, -r3.x, l(1.000000)  
  163: mad r1.w, r1.w, r3.y, r3.x  
  164: mul r1.w, r1.w, r2.w  
  165: mul r1.w, r1.w, cb4[11].x  
  166: log r3.xyz, cb4[12].xyzx  
  167: mul r3.xyz, r3.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  168: exp r3.xyz, r3.xyzx  
  169: mul r3.xyz, r1.wwww, r3.xyzx  
  170: mad r2.xyz, r2.xyzx, r1.zzzz, r3.xyzx  



3.3. Heat haze

When I started analyzing how the portal shader actually works, I was wondering why exactly it needs the scene color without the portal as one of input textures. My main point was "hey, we are using blending here, so it's enough to return a pixel with zero alpha to keep the background color".

The shader has a subtle yet nice effect of heat haze - heat and energy are coming from it so the background is distorted.

The idea is to offset the pixel texcoords and sample the background color texture with the new coordinates - an operation which is impossible with simple blending.

Here is a video which demostrates how this works - the order: full effect first, then heat haze as in the shader, in the end I'm multiplying the offset by 10 to exaggerate the effect.


Let's see how the offset is actually calculated.
   const float ViewSpaceDepth = Input.ViewSpaceDepth;  
   const float3 Tangent = Input.Tangent;  
   const float backgroundDistortionStrength = cb4_v1.x; // 0.40  

   // Fades smoothly from the outer edges to the back of a portal
   const float heatHazeMask = Input.Color.x;
   ...  
     
   // The heat haze effect is view dependent thanks to tangent vectors in view space.  
   float2 heatHazeOffset = mul( normalize(Tangent), (float3x4)g_mtxView);  
   heatHazeOffset *= float2(-1, 1);  
     
   // Fade the effect as camera is further from a portal  
   const float heatHazeDistanceFade = backgroundDistortionStrength / ViewSpaceDepth;  
   heatHazeOffset *= heatHazeDistanceFade;  
        
   heatHazeOffset *= heatHazeMask;  
   
   // this is what animates the heat haze effect  
   heatHazeOffset *= pow(fire1, 0.2);  
        
   // Actually I don't know what's this :)  
   // It was 1.0 usually so I won't bother discussing this.  
   heatHazeOffset *= vsDepth2;  


The relevant assembly is a bit scattered throughout the code, here it is:
  11: dp3 r1.x, v3.xyzx, v3.xyzx  
  12: rsq r1.x, r1.x  
  13: mul r1.xyz, r1.xxxx, v3.xyzx  
  14: mul r1.yw, r1.yyyy, cb12[2].xxxy  
  15: mad r1.xy, cb12[1].xyxx, r1.xxxx, r1.ywyy  
  16: mad r1.xy, cb12[3].xyxx, r1.zzzz, r1.xyxx  
  17: mul r1.xy, r1.xyxx, l(-1.000000, 1.000000, 0.000000, 0.000000)  
  18: div r1.z, cb4[1].x, v0.z  
  19: mul r1.xy, r1.zzzz, r1.xyxx  
  20: mul r1.xy, r1.xyxx, v1.xxxx  
  ...  
  33: mul r1.xy, r1.xyxx, r2.xxxx  
  34: mul r1.xy, r0.zzzz, r1.xyxx  


Once we have the offset calculated, let's use it!
   const float2 backgroundSceneMaxUv = cb0_v2.zw; // (1.0, 1.0)  
   const float2 invViewportSize = cb0_v1.zw; // (1.0 / 1920.0, 1.0 / 1080.0 )
        
   // Obtain background scene color - we need to obtain it from texture  
   // for distortion effect  
   float3 sceneColor;  
   {  
     const float2 sceneUv_0 = pixelUv + backgroundSceneMaxUv*heatHazeOffset;  
     const float2 sceneUv_1 = backgroundSceneMaxUv - 0.5*invViewportSize;  
             
     const float2 sceneUv = min(sceneUv_0, sceneUv_1);  
       
     sceneColor = texScene.SampleLevel(sampler6, sceneUv, 0).rgb;  
   }  


  175: mad r0.xy, cb0[2].zwzz, r1.xyxx, r0.xyxx  
  176: mad r1.xy, -cb0[1].zwzz, l(0.500000, 0.500000, 0.000000, 0.000000), cb0[2].zwzz  
  177: min r0.xy, r0.xyxx, r1.xyxx  
  178: sample_l(texture2d)(float,float,float,float) r1.xyz, r0.xyxx, t6.xyzw, s6, l(0)  

So, in the end we have sceneColor.



3.4. "Destination" color

By "destination" color I refer to the central part of the portal:



Unfortunately, this is all black. And the reason for that is fog.

I have already explored fog solution more or less in part 15 of the series. In the portal shader fog calculations are in [35-135] lines of the source assembly.

HLSL:
 struct FogResult  
 {  
   float4 paramsFog;  
   float4 paramsAerial;  
 };  
   
 ...  
   
 FogResult fog;  
 {  
   const float3 CameraPosition = cb12_v0.xyz;  
   const float fogStart = cb12_v22.z; // near plane  
     
   fog = CalculateFog( WSPosition, CameraPosition, fogStart, false );   
 }  
   
 ...  
   
 const float3 destination_color = fog.paramsFog.a * fog.paramsFog.rgb;  

So this is what brings us the final scene:

The thing is, in this frame camera is so close to the portal that the estimated destination_color is equal to zero so the black center of the portal is actually fog! (or, lack of fog, technically).

Since we are allowed to inject shaders into the game via RenderDoc, let's try to manually offset the camera:
  const float3 CameraPosition = cb12_v0.xyz + float3(100, 100, 0);  

And here's the result:

Ha!

So, while it has very little sense to use fog calculatons in this particular scenario, in theory there is nothing what stops us from using, for instance, a landscape from another world as the destination_color (maybe an extra pair of texcoords would be needed but still, this is perfectly doable).

Using fog could be helpful in case of huge portal which player can see from great distance.


3.5. Mixing (heat hazed) scene color with destination

I was wondering where to put this section - to "destination color" or maybe to "putting all this together" but I decided to make new subsection instead :)

At this point we have sceneColor described in 3.3 which already contains heat haze effect and we also have destination_color from 3.4. 

They are interpolated with lerp:
  178: sample_l(texture2d)(float,float,float,float) r1.xyz, r0.xyxx, t6.xyzw, s6, l(0)  
  179: mad r3.xyz, r4.wwww, r4.xyzx, -r1.xyzx  
  180: mad r0.xyw, r0.wwww, r3.xyxz, r1.xyxz  

What is the value that interpolates them (r0.w) ?
This is where the noise/smoke texture is actually used.

It's used to produce, as I called it, "portal destination mask".



And a video (first the full effect, then the destination mask, then the interpolated heat hazed scene color with destination color):


Take a look at this HLSL snippet:
   // Determines the back part of a portal  
   const float backMask = Input.Color.z;  
   
   const float ViewSpaceDepth = Input.TexcoordAndViewSpaceDepth.z;  
   const float viewSpaceDepthScale = cb4_v0.x; // 0.50    
   ...  
   
   // Load depth from texture  
   float hardwareDepth = texDepth.SampleLevel(sampler15, pixelUv, 0).x;  
   float linearDepth = getDepth(hardwareDepth);  
     
   // cb4_v0.x = 0.5  
   float vsDepthScale = saturate( (linearDepth - ViewSpaceDepth) * viewSpaceDepthScale );  
     
   float vsDepth1 = 2*vsDepthScale;
   
   ....  
   
   // Calculate 'portal destination' mask - maybe we would like see a glimpse of where a portal leads  
   // like landscape from another planet - the shader allows for it.  
   float portal_destination_mask;  
   {    
     const float region_mask = dot(backMask.xx, vsDepth1.xx);  
     
     const float2 _UVScale = float2(4.0, 1.0);  
     const float2 _TimeScale = float2(0.0, 0.2);  
     const float2 _UV = texcoords * _UVScale + elapsedTime * _TimeScale;  
       
     portal_destination_mask = texNoise.Sample(sampler0, _UV).x;  
     portal_destination_mask = saturate(portal_destination_mask + region_mask - 1.0);  
     portal_destination_mask *= portal_destination_mask; // line 143, r0.w  
   }  

The portal destination mask is mostly obtained the same way as fire - using animated texture coordinates. It uses "region_mask" variable to adjust where the effect is placed.

To obtain region_mask, another vaiable called vsDepth1 is used. I will describe it a bit in the next section. It does have a marginal effect on the destination mask though.

The corresponding assembly for the destination mask is:
  137: dp2 r0.w, v1.zzzz, r0.zzzz  
  138: mul r2.xy, cb0[0].xxxx, l(0.000000, 0.200000, 0.000000, 0.000000)  
  139: mad r2.xy, v0.xyxx, l(4.000000, 1.000000, 0.000000, 0.000000), r2.xyxx  
  140: sample_indexable(texture2d)(float,float,float,float) r2.x, r2.xyxx, t1.xyzw, s0  
  141: add r0.w, r0.w, r2.x  
  142: add_sat r0.w, r0.w, l(-1.000000)  
  143: mul r0.w, r0.w, r0.w  



3.6. Putting all this together

Phew, we are almost done.

Let's obtain the portal color first:
 // Calculate portal color  
 float3 portal_final;  
 {  
   const float3 portal_inner_color = pow(colorPortalInner, 2.2) * inner_influence;  
   const float3 portal_outer_color = pow(colorPortalOuterGlow, 2.2) * outer_glow_influence;  
     
   portal_final = portal_inner_color + portal_outer_color;  
   portal_final *= vsDepth1; // fade the effect to avoid harsh artifacts due to depth test  
   portal_final *= portalFinalColorFilter; // this was (1,1,1) - so not relevant  
 }  
   

The only aspect I'd like to discuss here is vsDepth1.

Here is how this mask looks like:

In the previous subsection I showed how this is obtained, basically a "linear depth buffer" which is used to reduce the portal's color so there is no harsh cutoff due to depth test.

Consider the final scene again, with and without the multiplication with vsDepth1.



Once we have portal_final, obtaining the final color is easy:
   const float finalPortalAmount = cb2_v0.x; // 0.99443  
   const float3 finalColorFilter = cb2_v2.rgb; // (1.0, 1.0, 1.0)  
   const float finalOpacityFilter = cb2_v2.a; // 1.0  
   ...  
   
   // Alpha component for blending  
   float opacity = saturate( lerp(cb2_v0.x, 1, cb4_v13.x) );  
   
   // Calculate the final color  
   float3 finalColor;  
   {  
     // Mix the scene color (with heat haze effect) with the 'destination color'.  
     // In this particular example fog is used as destination (which is black where camera is nearby)  
     // but in theory there is nothing which stops us from putting here a landscape from another world.  
     const float3 destination_color = fog.paramsFog.a * fog.paramsFog.rgb;      
     finalColor = lerp( sceneColor, destination_color, portal_destination_mask );  
       
     // Add the portal color  
     finalColor += portal_final * finalPortalAmount;  
       
     // Final filter  
     finalColor *= finalColorFilter;  
   }  
        
   opacity *= finalOpacityFilter;  
     
   return float4(finalColor * opacity, opacity);  

So this is it. There is an extra finalPortalAmount variable which decides how much of the fire you actually see. I haven't tested it in such detail, but I imagine it's used when the portal appears and disappears - for a brief amount of time you don't see fire, but the whole rest instead - glow, the destination color etc.



4. Summary

The final HLSL shader is here if you are interested. I had to reorder a few lines in order to get the same assembly as the original one, but it doesn't interrupt the general flow. The shader is RenderDoc ready, all cbuffers are there etc, so you can inject it and experiment on your own.

Hope you enjoyed it - thanks for reading!

Wednesday, March 11, 2020

Reverse engineering the rendering of The Witcher 3, part 18 - color grading

This post is a part of the series "Reverse engineering the rendering of The Witcher 3".


One of the postfx effects you can encounter pretty much everywhere in The Witcher 3 is color grading (aka color correction). The idea is to use a lookup table (LUT) texture to map one color set to another.

A usual workflow looks like this: there is a neutral (output color = input color) lookup table, which is edited in tools like Adobe Photoshop - enhancing contrast/brightness/saturation/hue etc... all sorts of modifications and adjustments which could be quite expensive to calculate in real-time. Thanks to LUTs, they can be replaced with cheaper texture lookups.

There are at least 3 different kinds of color LUT tables I'm aware of: 3D ones, "long" 2D ones and "square" 2D ones.

A neutral "long" 2D LUT

A neutral "square" 2D LUT

Before we get to The Witcher 3 implementation, here is a few useful links about this technique:

Nice OpenGL implementation with online demo
Color Grading / Correction
Metal Gear Solid V Graphics Study (good read in general, has a section about color grading)
Color grading with Look-up Textures (LUT)
a thread from gamedev.net
GPU Gems 2 article - color grading with 3D textures
UE4 docs about creating and using color LUTs



Let's take a look at the example LUT which is used in White Orchard, near the beginning of the game - most of green was changed to yellow:

The Witcher 3 uses 512x512 2D lookup textures.
As a general rule, color grading is expected to work in LDR space. This brings 2563 possible input values - more than 16 million combinations which are going to be mapped to only 5122=262 144 values. To cover whole input range, bilinear sampling is used.

And now comparison screenshots: before and after color grading pass.


As you can see, the difference is subtle yet noticeable - sky has a bit more orangish tint.

As for The Witcher 3 implementation, both input and output rendertargets are fullscreen floating-point (R11G11B10) textures. Interestingly, in this particular scene the brightest input pixel channels (near the Sun) have values exceeding 1.0f - even up to ~2.0f!

Here is the pixel shader assembly:
 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb3[2], immediateIndexed  
    dcl_sampler s0, mode_default  
    dcl_sampler s1, mode_default  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_resource_texture2d (float,float,float,float) t1  
    dcl_input_ps linear v1.xy  
    dcl_output o0.xyzw  
    dcl_temps 5  
   0: max r0.xy, v1.xyxx, cb3[0].xyxx  
   1: min r0.xy, r0.xyxx, cb3[0].zwzz  
   2: sample_indexable(texture2d)(float,float,float,float) r0.xyzw, r0.xyxx, t0.xyzw, s0  
   3: log r1.xyz, abs(r0.xyzx)  
   4: mul r1.xyz, r1.xyzx, l(0.454545, 0.454545, 0.454545, 0.000000)  
   5: exp r1.xyz, r1.xyzx  
   6: mad r2.xyz, r1.xyzx, l(1.000000, 1.000000, 0.996094, 0.000000), l(0.000000, 0.000000, 0.015625, 0.000000)  
   7: min r2.xyz, r2.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)  
   8: min r2.z, r2.z, l(0.999990)  
   9: add r2.xy, r2.xyxx, l(0.007813, 0.007813, 0.000000, 0.000000)  
  10: mul r2.xyzw, r2.xyzz, l(0.996094, 0.996094, 64.000000, 8.000000)  
  11: max r2.xy, r2.xyxx, l(0.015625, 0.015625, 0.000000, 0.000000)  
  12: min r2.xy, r2.xyxx, l(0.984375, 0.984375, 0.000000, 0.000000)  
  13: round_ni r3.xz, r2.wwww  
  14: mad r2.z, -r3.x, l(8.000000), r2.z  
  15: round_ni r3.y, r2.z  
  16: mul r2.zw, r3.yyyz, l(0.000000, 0.000000, 0.125000, 0.125000)  
  17: mad r2.xy, r2.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r2.zwzz  
  18: sample_l(texture2d)(float,float,float,float) r2.xyz, r2.xyxx, t1.xyzw, s1, l(0)  
  19: mul r2.w, r1.z, l(63.750000)  
  20: round_ni r2.w, r2.w  
  21: mul r1.w, r2.w, l(0.015625)  
  22: mad r1.z, r1.z, l(63.750000), -r2.w  
  23: min r1.xyw, r1.xyxw, l(1.000000, 1.000000, 0.000000, 1.000000)  
  24: min r1.w, r1.w, l(0.999990)  
  25: add r1.xy, r1.xyxx, l(0.007813, 0.007813, 0.000000, 0.000000)  
  26: mul r1.xy, r1.xyxx, l(0.996094, 0.996094, 0.000000, 0.000000)  
  27: max r1.xy, r1.xyxx, l(0.015625, 0.015625, 0.000000, 0.000000)  
  28: min r1.xy, r1.xyxx, l(0.984375, 0.984375, 0.000000, 0.000000)  
  29: mul r3.xy, r1.wwww, l(64.000000, 8.000000, 0.000000, 0.000000)  
  30: round_ni r4.xz, r3.yyyy  
  31: mad r1.w, -r4.x, l(8.000000), r3.x  
  32: round_ni r4.y, r1.w  
  33: mul r3.xy, r4.yzyy, l(0.125000, 0.125000, 0.000000, 0.000000)  
  34: mad r1.xy, r1.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r3.xyxx  
  35: sample_l(texture2d)(float,float,float,float) r1.xyw, r1.xyxx, t1.xywz, s1, l(0)  
  36: add r2.xyz, -r1.xywx, r2.xyzx  
  37: mad r1.xyz, r1.zzzz, r2.xyzx, r1.xywx  
  38: log r1.xyz, abs(r1.xyzx)  
  39: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  40: exp r1.xyz, r1.xyzx  
  41: mad r1.xyz, cb3[1].zzzz, r1.xyzx, -r0.xyzx  
  42: mad o0.xyz, cb3[1].yyyy, r1.xyzx, r0.xyzx  
  43: mov o0.w, r0.w  
  44: ret  

In general, The Witcher 3 doesn't reinvent the wheel here and uses a lot of "security" code. Makes sense since this is one of the effects when you have to be extra careful with texture coordinates.

Still two LUT fetches are needed as it's a consequence of using 2D texture - this is to simulate bilinear sampling for the blue channel. In the OpenGL implementation above merging of these two fetches is based on fractional part of the blue channel.

What I find interesting is lack of ceil (round_pi) and frac (frc) instructions in the assembly. However, there is quite a few floor (round_ni) instructions.

The shader starts with fetching an input color texture and getting a gamma-space color from it:
   float3 LinearToGamma(float3 c) { return pow(c, 1.0/2.2); }
   float3 GammaToLinear(float3 c) { return pow(c, 2.2); }

   ...   

   // Set range of allowed texcoords  
   float2 minAllowedUV = cb3_v0.xy;  
   float2 maxAllowedUV = cb3_v0.zw;  
   float2 samplingUV = clamp( Input.Texcoords, minAllowedUV, maxAllowedUV );  
   
   // Get color in *linear* space  
   float4 inputColorLinear = texture0.Sample( samplerPointClamp, samplingUV );

   // Calculate color in *gamma* space for RGB
   float3 inputColorGamma = LinearToGamma( inputColorLinear.rgb );  

The min and max allowed sampling coordinates are from cbuffer:
This particular frame was captured in 1920x1080 - the max ones are: (1919/1920, 1079/1080)

It can be quite easily noticed that the shader assembly contains two fairly similar blocks of code followed by a LUT fetch. So I came up with a helper function which calculates uv for LUT. Let's take a look at the relevant assembly first:
   7: min r2.xyz, r2.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)  
   8: min r2.z, r2.z, l(0.999990)  
   9: add r2.xy, r2.xyxx, l(0.007813, 0.007813, 0.000000, 0.000000)  
  10: mul r2.xyzw, r2.xyzz, l(0.996094, 0.996094, 64.000000, 8.000000)  
  11: max r2.xy, r2.xyxx, l(0.015625, 0.015625, 0.000000, 0.000000)  
  12: min r2.xy, r2.xyxx, l(0.984375, 0.984375, 0.000000, 0.000000)  
  13: round_ni r3.xz, r2.wwww  
  14: mad r2.z, -r3.x, l(8.000000), r2.z  
  15: round_ni r3.y, r2.z  
  16: mul r2.zw, r3.yyyz, l(0.000000, 0.000000, 0.125000, 0.125000)  
  17: mad r2.xy, r2.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r2.zwzz  
  18: sample_l(texture2d)(float,float,float,float) r2.xyz, r2.xyxx, t1.xyzw, s1, l(0)  

r2.xyz is the input color here.
The first thing happening is making sure that the input is in [0-1] range. (line 7). This is for instance used for pixels with components > 1.0 like the Sun ones I mentioned earlier.

Then the blue channel is multiplied by 0.99999 (line 8) to make sure that floor(color.b) will return value in [0-7] range.

To calculate LUT coordinates, the first thing the shader does is remapping red and green channels to "squeeze" them in the top left slice. The blue channel [0-1] is cut into 64 pieces which corresponds to all the 64 slices in the lookup texture. Based on the current value of the blue channel a proper slice is picked and offset for it is calculated.

An example
Let's pick (0.75, 0.5, 1.0) for instance. Red and green channels are mapped to the top left slice which yields:

float2 rgOffset = (0.75, 0.5) / 8 = (0.09375, 0.0625)

Then we check in which of 64 slices the value of blue (1.0) is located. Of course in this case it's the last one - 64.
The offset is expressed as slices (rowOffset, columnOffset):

float blue_rowOffset = 7.0;
float blue_columnOffset = 7.0;
float2 blueOffset =float2(blue_rowOffset, blue_columnOffset) / 8.0 = (0.875, 0.875)

In the end we just sum the offsets:

float2 finalUV = rgOffset + blueOffset;

finalUV = (0.09375, 0.0625) + (0.875, 0.875) = (0.96875, 0.9375)

-------------------------------

This was just a brief example. Let's go to the implementation details now.

For red and green channels (r2.xy) a half-pixel offset is added (0.5 / 64) at line 9. Then we multiply them by 0.996094 (line 10) and clamp them to a special range (lines 11-12).

A half pixel offset is quite obvious thing - we want to sample from the center of a pixel. Much more mysterious thing is the scale factor from line 10 - it's equal to 63,75/64.0 - more on this in a minute.

In the end the coordinates are clamped to [1/64 - 63/64] range.
Why do we need it? I don't know for sure but it looks like making sure that bilinear sampling never samples outside of a slice.

Here is an image with an example 6x6 slice which shows how this clamp actually works:

Here is the scene without the clamping applied - notice pretty serious discolorations around the Sun :

for easier comparision the result from the game again:


Here is a code snippet for this part:
   // * Calculate red/green offset  
        
   // half-pixel offset to always sample within centre of a pixel  
   const float halfOffset = 0.5 / 64.0;  
   const float scale = 63.75/64.0;  
      
   float2 rgOffset;  
   rgOffset = halfOffset + color.rg;  
   rgOffset *= scale;  
   
   rgOffset.xy = clamp(rgOffset.xy, float2(1.0/64.0, 1.0/64.0), float2(63.0/64.0, 63.0/64.0) );  
   
   // place within the top left slice  
   rgOffset.xy /= 8.0;  

Now it's time to find out offset for the blue channel.

To find rows offset, blue channel is divided into 8 segments, each one covering exactly one row of the lookup texture.
   // rows  
   bOffset.y = floor(color.b * 8);  

To find a column offset, the obtained value must be further divided to 8 smaller segments which map to all 8 slices in a row. The equation from the shader is a bit messy:
   // columns  
   bOffset.x = floor(color.b * 64 - 8*bOffset.y );       

It's worth to note at this point that:

frac(x) = x - floor(x)

So the equation can be rewritten as:
 bOffset.x = floor(8 * frac(color.b * 8) );  

And here is a code snippet for it:
   // * Calculate blue offset  
   float2 bOffset;  
     
   // rows  
   bOffset.y = floor(color.b * 8);  
     
   // columns  
   bOffset.x = floor(color.b * 64 - 8*bOffset.y );      
   // or: 
   // bOffset.x = floor(8 * frac(color.b * 8) );  
     
   // at this moment bOffset stores values in [0-7] range, we have to divide it by 8.0.  
   bOffset /= 8.0;  
     
   float2 lutPos = rgOffset + bOffset;  
   return lutPos;  

This way we obtained the function which gives texture coordinates to sample the LUT texture. Let's call this function 'getUV'.
 float2 getUV(in float3 color)  
 {  
  ...  
 }  

----------------------------------------------------------

Let's back to the main shader function. As mentioned earlier, because of using 2D LUT two LUT fetches (from two slices next to each other) are needed to simulate bilinear sampling for the blue channel.

Consider the following piece of HLSL:
   // Part 1  
   float scale_1 = 63.75/64.0;  
   float offset_1 = 1.0/64.0;   // 0.015625  
   float3 inputColor1 = inputColorGamma;    
   inputColor1.b = inputColor1.b * scale_1 + offset_1;  
     
   float2 uv1 = getUV(inputColor1);  
   float3 color1 = texLUT.SampleLevel( sampler1, uv1, 0 ).rgb;  
     
   // Part 2  
   float3 inputColor2 = inputColorGamma;  
   inputColor2.b = floor(inputColorGamma.b * 63.75) / 64;  
     
   float2 uv2 = getUV(inputColor2);  
   float3 color2 = texLUT.SampleLevel( sampler1, uv2, 0 ).rgb;  

   // frac(x) = x - floor(x);
   //float blueInterp = inputColorGamma.b*63.75 - floor(inputColorGamma.b * 63.75);
   float blueInterp = frac(inputColorGamma.b * 63.75);
    
   // Final LUT-corrected color
   const float lutCorrectedMult = cb3_v1.z;
    
   float3 finalLUT = lerp(color2, color1, blueInterp);
   finalLUT = lutCorrectedMult * GammaToLinear(finalLUT);

The idea is to fetch colors from the two slices which are next to each other and interpolate between them - amount of interpolation is based on fractional part of input blue color.

The 'part 1' is fetching a color from "further" slice due to explicit offset of blue ( + 1.0 / 64 );

The result of interpolation is stored in 'finalLUT' variable. Note that after that the result is back to linear space and is multiplied by lutCorrectedMult. In this particular frame its value is 1.00916. This allows to modify the intensity of the LUT color.

Obviously, the most intriguing part is "63.75" and "63.75 / 64". Where does it come from, I'm not sure. The only explanation I found is: 63.75 / 64.0 = 510.0 / 512.0. As stated earlier, there is a clamp for .rg channels which, when you add a blue offset, effectively means that the most outer rows and colums of LUT are not going to be directly used. I think that colors are explicitly 'squeezed' to fit into the center 510x510 region of the lookup texture.

Let's assume that inputColorGamma.b = 0.75 / 64.0.
Here's how it works:

Here we have the first four slices (1-4) which cover blue channel from [0 - 4/64].
By the location of the pixel it looks like the red and green channels are about 0.75 and 0.5, respectively.

We fetch the LUT twice - "Part 1" is pointing to slice 2 while "Part 2" is pointing to the first slice.
And the interpolation is based on the fractional part of the color which is 0.75.

So the final result has 75% of color from the first slice and 25% of color from the second one.

------------------------------------------------------

We are almost finished. The last thing to do is:
   // Calculate the final color  
   const float lutCorrectedInfluence = cb3_v1.y; // 0.20 in this frame  
   float3 finalColor = lerp(inputColorLinear.rgb, finalLUT, lutCorrectedInfluence);  
     
   return float4( finalColor, inputColorLinear.a );  

Ha! In this case the final color consists of 80% of the input color and 20% of the LUT color!

Let's do a quick image comparison once again: the input color (which is basically 0% of color grading), the final frame (20%) and fully processed image (100% of color grading influence):

0% of color grading
20% of color grading (the original shader)
100% of color grading




More LUTs

There are cases when The Witcher 3 uses more than just one LUT.

Here's a scene which uses two LUTs:
Before color grading pass
After color grading pass
The LUTs being used are:
LUT 1 (texture1)
LUT 2 (texture2)

Let's consider the assembly snippet from this variant of the shader:
  18: sample_l(texture2d)(float,float,float,float) r3.xyz, r2.xyxx, t2.xyzw, s2, l(0)  
  19: sample_l(texture2d)(float,float,float,float) r2.xyz, r2.xyxx, t1.xyzw, s1, l(0)  
   ...  
  36: sample_l(texture2d)(float,float,float,float) r4.xyz, r1.xyxx, t2.xyzw, s2, l(0)  
  37: sample_l(texture2d)(float,float,float,float) r1.xyw, r1.xyxx, t1.xywz, s1, l(0)  
  38: add r3.xyz, r3.xyzx, -r4.xyzx  
  39: mad r3.xyz, r1.zzzz, r3.xyzx, r4.xyzx  
  40: log r3.xyz, abs(r3.xyzx)  
  41: mul r3.xyz, r3.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  42: exp r3.xyz, r3.xyzx  
  43: add r2.xyz, -r1.xywx, r2.xyzx  
  44: mad r1.xyz, r1.zzzz, r2.xyzx, r1.xywx  
  45: log r1.xyz, abs(r1.xyzx)  
  46: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  47: exp r1.xyz, r1.xyzx  
  48: add r2.xyz, -r1.xyzx, r3.xyzx  
  49: mad r1.xyz, cb3[1].xxxx, r2.xyzx, r1.xyzx  
  50: mad r1.xyz, cb3[1].zzzz, r1.xyzx, -r0.xyzx  
  51: mad o0.xyz, cb3[1].yyyy, r1.xyzx, r0.xyzx  
  52: mov o0.w, r0.w  
  53: ret  

Luckily, this is quite simple. Following the assembly we get:

   // Part 1  
   // ...  
   float2 uv1 = getUV(inputColor1);  
   float3 lut2_color1 = texture2.SampleLevel( sampler2, uv1, 0 ).rgb;  
   float3 lut1_color1 = texture1.SampleLevel( sampler1, uv1, 0 ).rgb;  
     
   // Part 2  
   // ...  
   float2 uv2 = getUV(inputColor2);  
   float3 lut2_color2 = texture2.SampleLevel( sampler2, uv2, 0 ).rgb;  
   float3 lut1_color2 = texture1.SampleLevel( sampler1, uv2, 0 ).rgb;  
     
   float blueInterp = frac(inputColorGamma.b * 63.75);  
    
   float3 lut2_finalLUT = lerp(lut2_color2, lut2_color1, blueInterp);  
   lut2_finalLUT = GammaToLinear(lut2_finalLUT);  
        
   float3 lut1_finalLUT = lerp(lut1_color2, lut1_color1, blueInterp);  
   lut1_finalLUT = GammaToLinear(lut1_finalLUT);  
        
   const float lut_Interp = cb3_v1.x;  
   float3 finalLUT = lerp(lut1_finalLUT, lut2_finalLUT, lut_Interp);  
        
   const float lutCorrectedMult = cb3_v1.z;  
   finalLUT *= lutCorrectedMult;  
     
   // Calculate the final color  
   const float lutCorrectedInfluence = cb3_v1.y;  
   float3 finalColor = lerp(inputColorLinear.rgb, finalLUT, lutCorrectedInfluence);  
     
   return float4( finalColor, inputColorLinear.a );  
 }  

Once the two colors from LUT are available, there is a interpolation between them with lut_Interp. The rest is pretty much the same as the one-LUT variant.

In this case the only extra variable is lut_interp which tells how the LUTs are mixed.
Its value in this particular frame is ~0.96 which means that finalLUT has 96% of color from the LUT2 and 4% of color from LUT1.



However, this is not the end yet! The scene I was investigating in part 15 uses three LUTs!
Let's take a look!

Before color grading pass
After color grading pass
LUT1 (texture1)
LUT2 (texture2)
LUT3 (texture3)

Again, the assembly snippet:

  23: mad r2.yz, r2.yyzy, l(0.000000, 0.125000, 0.125000, 0.000000), r3.xxyx  
  24: sample_l(texture2d)(float,float,float,float) r3.xyz, r2.yzyy, t2.xyzw, s2, l(0)  
  ...  
  34: mad r1.xy, r1.xyxx, l(0.125000, 0.125000, 0.000000, 0.000000), r1.zwzz  
  35: sample_l(texture2d)(float,float,float,float) r4.xyz, r1.xyxx, t2.xyzw, s2, l(0)  
  36: add r4.xyz, -r3.xyzx, r4.xyzx  
  37: mad r3.xyz, r2.xxxx, r4.xyzx, r3.xyzx  
  38: log r3.xyz, abs(r3.xyzx)  
  39: mul r3.xyz, r3.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  40: exp r3.xyz, r3.xyzx  
  41: sample_l(texture2d)(float,float,float,float) r4.xyz, r1.xyxx, t1.xyzw, s1, l(0)  
  42: sample_l(texture2d)(float,float,float,float) r1.xyz, r1.xyxx, t3.xyzw, s3, l(0)  
  43: sample_l(texture2d)(float,float,float,float) r5.xyz, r2.yzyy, t1.xyzw, s1, l(0)  
  44: sample_l(texture2d)(float,float,float,float) r2.yzw, r2.yzyy, t3.wxyz, s3, l(0)  
  45: add r4.xyz, r4.xyzx, -r5.xyzx  
  46: mad r4.xyz, r2.xxxx, r4.xyzx, r5.xyzx  
  47: log r4.xyz, abs(r4.xyzx)  
  48: mul r4.xyz, r4.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  49: exp r4.xyz, r4.xyzx  
  50: add r3.xyz, r3.xyzx, -r4.xyzx  
  51: mad r3.xyz, cb3[1].xxxx, r3.xyzx, r4.xyzx  
  52: mad r3.xyz, cb3[1].zzzz, r3.xyzx, -r0.xyzx  
  53: mad r3.xyz, cb3[1].yyyy, r3.xyzx, r0.xyzx  
  54: add r1.xyz, r1.xyzx, -r2.yzwy  
  55: mad r1.xyz, r2.xxxx, r1.xyzx, r2.yzwy  
  56: log r1.xyz, abs(r1.xyzx)  
  57: mul r1.xyz, r1.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  58: exp r1.xyz, r1.xyzx  
  59: mad r1.xyz, cb3[2].zzzz, r1.xyzx, -r0.xyzx  
  60: mad r0.xyz, cb3[2].yyyy, r1.xyzx, r0.xyzx  
  61: mov o0.w, r0.w  
  62: add r0.xyz, -r3.xyzx, r0.xyzx  
  63: mad o0.xyz, cb3[2].wwww, r0.xyzx, r3.xyzx  
  64: ret  

Unfortunately, this variant of the shader is much more messy than previous two ones. For instance, UVs named "uv1" so far occured in the assembly before "uv2" (compare the assembly of the shader with only one LUT). Here it's not the case - UVs for "Part 1" are calculated at line 34 whereas UVs for "Part 2" are obtained at line 23.

After spending much more time than I expected on investigating what's going on here and wondering why Part2 seems to be swapped with Part1, the HLSL snippet for 3 LUTs looks like this:
   // Part 1   
   // ...   
   float2 uv1 = getUV(inputColor1);   
   float3 lut3_color1 = texture3.SampleLevel( sampler3, uv1, 0 ).rgb;  
   float3 lut2_color1 = texture2.SampleLevel( sampler2, uv1, 0 ).rgb;   
   float3 lut1_color1 = texture1.SampleLevel( sampler1, uv1, 0 ).rgb;   
      
   // Part 2   
   // ...   
   float2 uv2 = getUV(inputColor2);   
   float3 lut3_color2 = texture3.SampleLevel( sampler3, uv2, 0 ).rgb;  
   float3 lut2_color2 = texture2.SampleLevel( sampler2, uv2, 0 ).rgb;   
   float3 lut1_color2 = texture1.SampleLevel( sampler1, uv2, 0 ).rgb;   
      
   float blueInterp = frac(inputColorGamma.b * 63.75);   
     
   // At first compute linear color for LUT 2 [assembly lines 36-40]  
   float3 lut2_finalLUT = lerp(lut2_color2, lut2_color1, blueInterp);   
   lut2_finalLUT = GammaToLinear(lut2_finalLUT);   
   
   // Compute linear color for LUT 1 [assembly: 45-49]      
   float3 lut1_finalLUT = lerp(lut1_color2, lut1_color1, blueInterp);   
   lut1_finalLUT = GammaToLinear(lut1_finalLUT);   
     
   // Interpolate between LUT 1 and LUT 2 [assembly: 50-51]  
   const float lut12_Interp = cb3_v1.x;   
   float3 lut12_finalLUT = lerp(lut1_finalLUT, lut2_finalLUT, lut12_Interp);   
    
   // Multiply the LUT1-2 intermediate result with scale factor [assembly: 52]  
   const float lutCorrectedMult_LUT1_2 = cb3_v1.z;   
   lut12_finalLUT *= lutCorrectedMult;   
      
   // Mix LUT1-2 intermediate result with the scene color [assembly: 52-53]  
   const float lutCorrectedInfluence_12 = cb3_v1.y;   
   lut12_finalLUT = lerp(inputColorLinear.rgb, lut12_finalLUT, lutCorrectedInfluence_12);   
   
   // Compute linear color for LUT3 [assembly: 54-58]  
   float3 lut3_finalLUT = lerp(lut3_color2, lut3_color1, blueInterp);  
   lut3_finalLUT = GammaToLinear(lut3_finalLUT);  
   
   // Multiply the LUT3 intermediate result with the scale factor [assembly: 59]  
   const float lutCorrectedMult_LUT3 = cb3_v2.z;  
   lut3_finalLUT *= lutCorrectedMult_LUT3;  
   
   // Mix LUT3 intermediate result with the scene color [assembly: 59-60]  
   const float lutCorrectedInfluence3 = cb3_v2.y;  
   lut3_finalLUT = lerp(inputColorLinear.rgb, lut3_finalLUT, lutCorrectedInfluence3);  
   
   // The final mix between LUT1+2 and LUT3 influence [assembly: 62-63]  
   const float finalInfluence = cb3_v2.w;  
   float3 finalColor = lerp(lut12_finalLUT, lut3_finalLUT, finalInfluence);  
   
   return float4( finalColor, inputColorLinear.a );   
}   


Once all texture fetches are complete, at first the results of LUT1 and LUT2 are interpolated, multiplied by a scale factor and then combined with the linear main scene color. Let's call the result lut12_finalLUT.

Then pretty much the same happens for LUT3 - multiply by a another scale factor and combine with the main scene color which yields lut3_finalLUT.

In the end both intermediate results are interpolated again.

Here are the values from cbuffer:



Summary

In this post I have explained briefly what the color grading is, provided a few useful links and have shown how it's implemented in The Witcher 3 in three variants - using 1, 2 or 3 LUTs.

Thanks for reading.