Thursday, March 5, 2020

Reverse engineering the rendering of The Witcher 3, part 17 - the Milky Way

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


In the previous post I explained how shooting stars are implemented in The Witcher 3. That effect doesn't appear in "Blood & Wine". In this post I'm going to cover an effect which is exclusive to that DLC: the Milky Way

Here is a video which presents Milky Way in the action.


And a few screenshots: (1) before the skydome draw call, (2) with the Milky Way color only, (3) after the call:





The final frame with the Milky Way only (no sky color, no stars...) looks like this:


The Milky Way effect, which is one of major changes comparing to the '2015' version of the game was briefly mentioned in part 12. Let's find out how it's implemented!

The agenda is quite usual: geometry-related stuff will be briefly covered first, pixel shader afterwards.




1. Geometry

Let's start from the skydome mesh being used. There are two major differences between the one from 2015 (base game + Hearts of Stone, I like to refer to it as simply '2015') and the Blood & Wine (2016) one:

a)  B&W one is much more dense,
b)  B&W one has normal vectors being used.

Here is the skydome mesh from 2015 - DrawIndexed(720)
The Witcher 3 '2015' skydome mesh - 720 indices

Here is the one from B&W - DrawIndexed(2640):
The Witcher 3 B&W skydome mesh - 2640 indices

Here is B&W one again - I've drawn how normals are distributed - they point to 'the centre' of the mesh.
The Witcher 3 B&W skydome mesh with normals




2. Vertex shader

The vertex shader for skydome is quite simple.
Here is the relevant assembly code. I omitted calculating SV_Position for simplicity's sake:
 vs_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb1[4], immediateIndexed  
    dcl_constantbuffer cb2[6], immediateIndexed  
    dcl_input v0.xyz  
    dcl_input v1.xy  
    dcl_input v2.xyz  
    dcl_output o0.xyzw  
    dcl_output o1.xyzw  
    dcl_output_siv o2.xyzw, position  
    dcl_temps 3  
   0: mov o0.xy, v1.xyxx  
   1: mad r0.xyz, v0.xyzx, cb2[4].xyzx, cb2[5].xyzx  
   2: mov r0.w, l(1.000000)  
   3: dp4 o0.z, r0.xyzw, cb2[0].xyzw  
   4: dp4 o0.w, r0.xyzw, cb2[1].xyzw  
   5: mad r1.xyz, v2.xyzx, l(2.000000, 2.000000, 2.000000, 0.000000), l(-1.000000, -1.000000, -1.000000, 0.000000)  
   6: dp3 r2.x, r1.xyzx, cb2[0].xyzx  
   7: dp3 r2.y, r1.xyzx, cb2[1].xyzx  
   8: dp3 r2.z, r1.xyzx, cb2[2].xyzx  
   9: dp3 r1.x, r2.xyzx, r2.xyzx  
  10: rsq r1.x, r1.x  
  11: mul o1.xyz, r1.xxxx, r2.xyzx  
  12: dp4 o1.w, r0.xyzw, cb2[2].xyzw  

The inputs from vertex buffer are:
1) Position in local space [0-1] - v0.xyz,
2) Texcoords - v1.xy,
3) Normal vector [0-1] - v2.xyz

The inputs from cbuffer:
1) World matrix (0-3) - classic approach: uniform scaling and translation by camera position,
2) Scale & Bias for a vertex (4-5) - a trick used throughout the game to rescale from [0-1] local space to [-1;1] one and potentially 'squueze' meshes.

And a quick explanatiion what is going on in the shader:

The shader starts with a simple passing texcoords further (line 0). For a vertex's world position, scale and bias are applied to it (line 1) and the result is multiplied by a world matrix (lines 3-4, 12). For a normal vector, it has to be remapped from [0-1] to [-1;1] range (line 5), then it's multiplied by the world matrix (lines 6-8) and it's normalized in the end (lines 9-11).

The final output data layout is:




3. Pixel shader

Calculations of the Milky Way are just one part of the sky shader. B&W one is much longer than 2015 version. Its length is 385 assembly lines, comparing to 267 of the 2015 variant.

Let's see the fragment of the assembly responsible for the Milky Way:
  175: sample_indexable(texturecube)(float,float,float,float) r4.xyz, r2.xyzx, t0.xyzw, s0  
  176: mul r4.xyz, r4.xyzx, r4.xyzx  
  177: sample_indexable(texturecube)(float,float,float,float) r0.w, r2.xyzx, t1.yzwx, s0  
  178: dp3 r1.w, v1.xyzx, v1.xyzx  
  179: rsq r1.w, r1.w  
  180: mul r2.xyz, r1.wwww, v1.xyzx  
  181: dp3 r1.w, cb12[204].yzwy, cb12[204].yzwy  
  182: rsq r1.w, r1.w  
  183: mul r5.xyz, r1.wwww, cb12[204].yzwy  
  184: dp3 r1.w, r2.xyzx, r5.xyzx  
  185: mad_sat r0.w, r0.w, l(0.200000), r1.w  
  186: ge r1.w, l(0.497925), r0.w  
  187: if_nz r1.w  
  188:  ge r1.w, l(0.184939), r0.w  
  189:  mul r2.y, r0.w, l(5.407188)  
  190:  min r2.z, r2.y, l(1.000000)  
  191:  mad r2.w, r2.z, l(-2.000000), l(3.000000)  
  192:  mul r2.z, r2.z, r2.z  
  193:  mul r2.z, r2.z, r2.w  
  194:  mul r5.xyz, r2.zzzz, l(0.949254, 0.949254, 0.949254, 0.000000)  
  195:  mov r2.x, l(0.949254)  
  196:  movc r2.xw, r1.wwww, r2.xxxy, l(0.000000, 0.000000, 0.000000, 0.500000)  
  197:  not r4.w, r1.w  
  198:  if_z r1.w  
  199:   ge r1.w, l(0.239752), r0.w  
  200:   add r5.w, r0.w, l(-0.184939)  
  201:   mul r6.y, r5.w, l(18.243849)  
  202:   mov_sat r5.w, r6.y  
  203:   mad r6.z, r5.w, l(-2.000000), l(3.000000)  
  204:   mul r5.w, r5.w, r5.w  
  205:   mul r5.w, r5.w, r6.z  
  206:   mad r5.w, r5.w, l(-0.113726), l(0.949254)  
  207:   movc r5.xyz, r1.wwww, r5.wwww, r5.zzzz  
  208:   and r7.xyz, r1.wwww, l(0.949254, 0.949254, 0.949254, 0.000000)  
  209:   mov r6.x, l(0.835528)  
  210:   movc r2.xw, r1.wwww, r6.xxxy, r2.xxxw  
  211:   mov r2.xyzw, r2.xxxw  
  212:  else  
  213:   mov r7.xyz, l(0, 0, 0, 0)  
  214:   mov r2.xyzw, r2.xxxw  
  215:   mov r1.w, l(-1)  
  216:  endif  
  217:  not r5.w, r1.w  
  218:  and r4.w, r4.w, r5.w  
  219:  if_nz r4.w  
  220:   ge r5.w, r0.w, l(0.239752)  
  221:   ge r6.x, l(0.294564), r0.w  
  222:   and r1.w, r5.w, r6.x  
  223:   add r5.w, r0.w, l(-0.239752)  
  224:   mul r6.w, r5.w, l(18.244175)  
  225:   mov_sat r5.w, r6.w  
  226:   mad r7.w, r5.w, l(-2.000000), l(3.000000)  
  227:   mul r5.w, r5.w, r5.w  
  228:   mul r5.w, r5.w, r7.w  
  229:   mad r5.w, r5.w, l(0.015873), l(0.835528)  
  230:   movc r5.xyz, r1.wwww, r5.wwww, r5.xyzx  
  231:   movc r7.xyz, r1.wwww, l(0.835528, 0.835528, 0.835528, 0.000000), r7.xyzx  
  232:   mov r6.xyz, l(0.851401, 0.851401, 0.851401, 0.000000)  
  233:   movc r2.xyzw, r1.wwww, r6.xyzw, r2.xyzw  
  234:  endif  
  235:  not r5.w, r1.w  
  236:  and r4.w, r4.w, r5.w  
  237:  if_nz r4.w  
  238:   ge r1.w, r0.w, l(0.294564)  
  239:   add r0.w, r0.w, l(-0.294564)  
  240:   mul r6.w, r0.w, l(4.917364)  
  241:   mov_sat r0.w, r6.w  
  242:   mad r4.w, r0.w, l(-2.000000), l(3.000000)  
  243:   mul r0.w, r0.w, r0.w  
  244:   mul r0.w, r0.w, r4.w  
  245:   mad r0.w, r0.w, l(-0.851401), l(0.851401)  
  246:   movc r5.xyz, r1.wwww, r0.wwww, r5.xyzx  
  247:   movc r7.xyz, r1.wwww, l(0.851401, 0.851401, 0.851401, 0.000000), r7.xyzx  
  248:   mov r6.xyz, l(0, 0, 0, 0)  
  249:   movc r2.xyzw, r1.wwww, r6.xyzw, r2.xyzw  
  250:  endif  
  251: else  
  252:  mov r7.xyz, l(0, 0, 0, 0)  
  253:  mov r2.xyzw, l(0.000000, 0.000000, 0.000000, 0.500000)  
  254:  mov r1.w, l(0)  
  255: endif  
  256: mov_sat r2.w, r2.w  
  257: mad r0.w, r2.w, l(-2.000000), l(3.000000)  
  258: mul r2.w, r2.w, r2.w  
  259: mul r0.w, r0.w, r2.w  
  260: add r2.xyz, -r7.xyzx, r2.xyzx  
  261: mad r2.xyz, r0.wwww, r2.xyzx, r7.xyzx  
  262: movc r2.xyz, r1.wwww, r5.xyzx, r2.xyzx  
  263: mul r2.xyz, r2.xyzx, l(0.150000, 0.200000, 0.250000, 0.000000)  

It's quite intimidating, isn't it? When I saw it for the first time (it was before I saw the shooting stars shader) I thought "what the heck is this?! It's impossible to reverse it!".

But here's the thing - if you have read the shooting stars post, you can easily recognize this pattern. It works very similar to the meteroids! More about the curve in a minute.

The snippet starts from sampling the stars cubemap (line 175) where the sampling direction is stored in r2.xyz. As you can see, at line 177 there is sample instruction for another cubemap. Unlike the 2015 pixel shader, B&W one has one more texture attached - 'noise cubemap' - its faces look similar to this:


Before we get to the curve, let's find the input for it. At first there is a dot product being calculated (line 184) between normalized skydome's normal vector (lines 178-180) and the Moon's light vector (lines 181-183) - essentialy NdotL.

Here is a visualization of the dot product (linear space):

The value used as as input to the "milkyway curve" function is obtained at line 185:

x = saturate( noise * 0.2 + Ndot );

And a visualization of such perturbed NdotL, also in linear space:


Let's go the milky way function right now! It's a bit more complicated than the shooting stars one. As I mentioned in the last post, we start by having a list of control points over x axis. A quick look at the assembly and we have them:
   // Control points (x-axis)  
   float controlPoint0 = 0.0;  
   float controlPoint1 = 0.184939;  
   float controlPoint2 = 0.239752;  
   float controlPoint3 = 0.294564;  
   float controlPoint4 = 0.497925;  

How do we know if the first control points is zero? Well, it's quite simple: there is no 'add' instruction at line 189.

According to the shooting stars post, the control points define a number of segments, now we need to find weights for them.

It's quite simple for the first segment. The weight is 0.949254:
  194: mul r5.xyz, r2.zzzz, l(0.949254, 0.949254, 0.949254, 0.000000)   
  195: mov r2.x, l(0.949254)   

Let's try to find them for the second and third segment:
  206:  mad r5.w, r5.w, l(-0.113726), l(0.949254)   
  207:  movc r5.xyz, r1.wwww, r5.wwww, r5.zzzz   
  208:  and r7.xyz, r1.wwww, l(0.949254, 0.949254, 0.949254, 0.000000)   
  209:  mov r6.x, l(0.835528)   
  ...  
  229:  mad r5.w, r5.w, l(0.015873), l(0.835528)   
  230:  movc r5.xyz, r1.wwww, r5.wwww, r5.xyzx   
  231:  movc r7.xyz, r1.wwww, l(0.835528, 0.835528, 0.835528, 0.000000), r7.xyzx   
  232:  mov r6.xyz, l(0.851401, 0.851401, 0.851401, 0.000000)   

This is exactly the point when I paused writing because something was not right here (one of the 'Hmmmm' moments). Look, it's not as simple as multiplying just by one weight. Also, where does values like -0.113726 and 0.015873 come from?

Then I realised these values are just differences between maximum possible values at each segment ( 0.835528 - 0.949254 = -0.113726  and  0.851401 - 0.835528 = 0.015873)! Kinda obvious (one of the 'Eureka' moments).

It turns out, that these values are not weights, they are just y-coordinates of points which form the curve!

This changes and simplifies so many things. First of all we can get rid of a weight from the function we used in the previous post:
 float getSmoothTransition(float cpLeft, float cpRight, float x)  
 {  
   return smoothstep( 0, 1, linstep(cpLeft, cpRight, x) );  
 }  

And we can write the milkyway function like so:
 float milkyway_curve( float x )  
 {  
   // Define a set of 2D points which form the curve
   // Of course, you can use a Point2D-like struct here
 
   // Control points (x-axis)
   float controlPoint0 = 0.0;  
   float controlPoint1 = 0.184939;  
   float controlPoint2 = 0.239752;  
   float controlPoint3 = 0.294564;  
   float controlPoint4 = 0.497925;  
     
   // Values at points (y-axis)  
   float value0 = 0.0;  
   float value1 = 0.949254;  
   float value2 = 0.835528;  
   float value3 = 0.851401;  
   float value4 = 0.0;  
     
   float function_value = 0.0;  
     
   [branch] if (x <= controlPoint4)  
   {  
     [branch] if (x <= controlPoint1)  
     {  
       float t = getSmoothTransition(controlPoint0, controlPoint1, x);  
       function_value = lerp(value0, value1, t);  
     }  
       
     [branch] if (x >= controlPoint1 && x <= controlPoint2)  
     {  
       float t = getSmoothTransition(controlPoint1, controlPoint2, x);  
       function_value = lerp(value1, value2, t);  
     }  
       
     [branch] if (x >= controlPoint2 && x <= controlPoint3)  
     {  
       float t = getSmoothTransition(controlPoint2, controlPoint3, x);  
       function_value = lerp(value2, value3, t);  
     }  
       
     [branch] if (x >= controlPoint3)  
     {  
       float t = getSmoothTransition(controlPoint3, controlPoint4, x);  
       function_value = lerp(value3, value4, t);       
     }      
   }  
     
   return function_value;  
 }  

This is a general purpose solution for any number of points which form a smooth curve and it also explains the origin of 'weird' values of control points - they probably used some sort of a visual editor to set the points.

Of course, the same idea applies to the shooting stars code.

Here is the graph of the function:
Milkyway function graph.
Red - value of the function,
Green - x-coords
Blue - y-coords
Yellow dots - control points


Okay, what next? At line 263 we multiply a value from the function by blue-ish color:
 263: mul r2.xyz, r2.xyzx, l(0.150000, 0.200000, 0.250000, 0.000000)  

But this is not the end! We just need to perform a gamma correction:
  263: mul r2.xyz, r2.xyzx, l(0.150000, 0.200000, 0.250000, 0.000000)  
  264: mad r2.xyz, r4.xyzx, l(3.000000, 3.000000, 3.000000, 0.000000), r2.xyzx  
  ...  
  269: log r2.xyz, r2.xyzx  
  270: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)  
  271: exp r2.xyz, r2.xyzx  


Fun stuff now: I assigned different colors to the control points on x-axis:
   float3 gradient0 = float3(1, 0, 0);  
   float3 gradient1 = float3(0, 1, 0);  
   float3 gradient2 = float3(0, 0, 1);  
   float3 gradient3 = float3(1, 1, 0);  
   float3 gradient4 = float3(0, 1, 1);  

And this is what I got:


That's pretty much all for the Milky Way.

At line 264, there is r4.xyz and what is there is...




4. Toussaint stars (bonus)

I know the title says 'the Milky Way' but I just couldn't resist to show briefly how the Toussaint stars are made. They are much more bright comparing to Novigrad, Skellige or Velen.

I explained how the '2015' stars are calculated in part 12, time for 2016 ones!

Actually, the most of the assembly involved is just:
  175: sample_indexable(texturecube)(float,float,float,float) r4.xyz, r2.xyzx, t0.xyzw, s0   
  176: mul r4.xyz, r4.xyzx, r4.xyzx   
  ...  
  264: mad r2.xyz, r4.xyzx, l(3.000000, 3.000000, 3.000000, 0.000000), r2.xyzx   
  ...   
  269: log r2.xyz, r2.xyzx   
  270: mul r2.xyz, r2.xyzx, l(2.200000, 2.200000, 2.200000, 0.000000)   
  271: exp r2.xyz, r2.xyzx   
  ...  
  302: add r0.z, -cb0[9].w, l(1.000000)  
  303: mul r2.xyz, r0.zzzz, r2.xyzx  
  304: add r2.xyz, r2.xyzx, r2.xyzx

In HLSL it can be written like so:
 float3 stars = texStars.Sample(sampler, starsDir).rgb;  
 stars *= stars;  
   
 float3 milkyway = milkyway_func(noisePerturbed) * float3(0.15, 0.20, 0.25);  
 float3 skyContribution = milkyway + 3.0 * stars;  
   
 // gamma correction  
 skyContribution = pow(skyContribution, 2.2);  
   
 // starsOpacity - 0.0 during the day (so stars and the Milky Way are not visible then), 1.0 during the night  
 float starsOpacity = 1.0 - cb0_v9.w;  
 skyContribution *= starsOpacity;  

 skyContribution *= 2;

So the stars themselves are just boosted by 3 (line 264) and then together with the Milky Way contribution by 2 (line 304), the oldschool way but it works great!
Of course, there is a bit more happening later (stars blinking using integer noise etc) but this is far beyond the scope of this post.



Conclusion

In this post I demystified how the Milky Way and stars are implemented in The Witcher 3: Blood & Wine.

Let's replace the original shader with the code we just figured out. The final frame looks like this:

while with the original shader the frame looks like so:

Not bad.

And as always, thanks for reading.

Monday, February 17, 2020

Reverse engineering the rendering of The Witcher 3, part 16 - shooting stars

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



In "The Witcher 3" there is a subtle yet nice detail in the sky - shooting stars. Interestingly, they don't seem to appear in "Blood & Wine" DLC.

Check out the video to see them in action:

Let's find out how this effect was achieved.

As you can notice, the head of a shooting star is much brighter than its tail. This is an important property we will take advantage of later.

The agenda for today is quite usual: I'm going to describe some general properties first, then cover geometry-related topics, and in the end I'll move to the pixel shader which has the most of the fun stuff.



1. General

Just a quick overview what's going on.

The shooting stars are drawn in forward pass, just after the skydome and the Sky & the Moon:


DrawIndexed(720) - skydome,
DrawIndexed(2160) - a sphere for the Sky / the Moon,
DrawIndexed(36) - irrelevant here, looks like the Sun's occlusion box (?)
DrawIndexed(12) - shooting star
DrawIndexedInstanced(1116, 1) - cirrus clouds

Similarly as cirrus clouds, each shooting star is being drawn twice in a row.

Before the first draw call

The result of the first draw call

The result of the second draw call

Also, like for many forward-pass elements in this game, the following blend state is being used:




2. Geometry

Geometry-wise, the first thing worth mentioning is that each shooting star is represented by a thin quad with texcoords provided. 4 vertices, 6 indices. It's the simplest quad you can imagine.

Zoom of a shooting star quad
Much larger zoom of a shooting star quad.
You can see wireframe view of line indicating two triangles.

Hey, but there is DrawIndexed(12) there! Does it mean we draw two shooting stars at the same time?

Yes.


In this particular frame one of the shooting stars is completely outside the view frustum.

Let's take a look at the the vertex shader assembly now:
 vs_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb1[9], immediateIndexed  
    dcl_constantbuffer cb2[3], immediateIndexed  
    dcl_constantbuffer cb12[193], immediateIndexed  
    dcl_input v0.xyz  
    dcl_input v1.xyzw  
    dcl_input v2.xy  
    dcl_input v3.xy  
    dcl_input v4.xy  
    dcl_input v5.xyz  
    dcl_input v6.x  
    dcl_input v7.x  
    dcl_output o0.xyzw  
    dcl_output o1.xyzw  
    dcl_output o2.xy  
    dcl_output o3.xyzw  
    dcl_output_siv o4.xyzw, position  
    dcl_temps 5  
   0: mov r0.xyz, v0.xyzx  
   1: mov r0.w, l(1.000000)  
   2: dp4 r1.x, r0.xyzw, cb2[0].xyzw  
   3: dp4 r1.y, r0.xyzw, cb2[1].xyzw  
   4: dp4 r1.z, r0.xyzw, cb2[2].xyzw  
   5: add r0.x, v2.x, v2.y  
   6: add r0.y, -v2.y, v2.x  
   7: add r2.xyz, -r1.zxyz, cb1[8].zxyz  
   8: dp3 r0.z, r2.xyzx, r2.xyzx  
   9: rsq r0.z, r0.z  
  10: mul r2.xyz, r0.zzzz, r2.xyzx  
  11: dp3 r0.z, v5.xyzx, v5.xyzx  
  12: rsq r0.z, r0.z  
  13: mul r3.xyz, r0.zzzz, v5.xyzx  
  14: mul r4.xyz, r2.xyzx, r3.yzxy  
  15: mad r2.xyz, r2.zxyz, r3.zxyz, -r4.xyzx  
  16: dp3 r0.z, r2.xyzx, r2.xyzx  
  17: rsq r0.z, r0.z  
  18: mul r2.xyz, r0.zzzz, r2.xyzx  
  19: mad r0.z, v7.x, v6.x, l(1.000000)  
  20: mul r3.xyz, r0.zzzz, r3.xyzx  
  21: mul r3.xyz, r3.xyzx, v3.xxxx  
  22: mul r2.xyz, r2.xyzx, v3.yyyy  
  23: mad r0.xzw, r3.xxyz, r0.xxxx, r1.xxyz  
  24: mad r0.xyz, r2.xyzx, r0.yyyy, r0.xzwx  
  25: mov r0.w, l(1.000000)  
  26: dp4 o4.x, r0.xyzw, cb1[0].xyzw  
  27: dp4 o4.y, r0.xyzw, cb1[1].xyzw  
  28: dp4 o4.z, r0.xyzw, cb1[2].xyzw  
  29: dp4 o4.w, r0.xyzw, cb1[3].xyzw  
  30: add r0.xyz, r0.xyzx, -cb12[0].xyzx  
  31: dp3 r0.w, r0.xyzx, r0.xyzx  
  32: sqrt r0.w, r0.w  
  33: div r0.xyz, r0.xyzx, r0.wwww  
  34: add r0.w, r0.w, -cb12[22].z  
  35: max r0.w, r0.w, l(0)  
  36: min r0.w, r0.w, cb12[42].z  
  37: dp3 r0.x, cb12[38].xyzx, r0.xyzx  
  38: mul r0.y, abs(r0.x), abs(r0.x)  
  39: mad_sat r1.x, r0.w, l(0.002000), l(-0.300000)  
  40: mul r0.y, r0.y, r1.x  
  41: lt r1.x, l(0), r0.x  
  42: movc r1.yzw, r1.xxxx, cb12[39].xxyz, cb12[41].xxyz  
  43: add r1.yzw, r1.yyzw, -cb12[40].xxyz  
  44: mad r1.yzw, r0.yyyy, r1.yyzw, cb12[40].xxyz  
  45: movc r2.xyz, r1.xxxx, cb12[45].xyzx, cb12[47].xyzx  
  46: add r2.xyz, r2.xyzx, -cb12[46].xyzx  
  47: mad o0.xyz, r0.yyyy, r2.xyzx, cb12[46].xyzx  
  48: ge r0.y, r0.w, cb12[48].y  
  49: if_nz r0.y  
  50:  mad r0.y, r0.z, cb12[22].z, cb12[0].z  
  51:  mul r0.z, r0.w, r0.z  
  52:  mul r0.z, r0.z, l(0.062500)  
  53:  mul r1.x, r0.w, cb12[43].x  
  54:  mul r1.x, r1.x, l(0.062500)  
  55:  add r0.x, r0.x, cb12[42].x  
  56:  add r2.x, cb12[42].x, l(1.000000)  
  57:  div_sat r0.x, r0.x, r2.x  
  58:  add r2.x, -cb12[43].z, cb12[43].y  
  59:  mad r0.x, r0.x, r2.x, cb12[43].z  
  60:  add r0.y, r0.y, cb12[42].y  
  61:  mul r2.x, r0.x, r0.y  
  62:  mul r0.z, r0.x, r0.z  
  63:  mad r3.xyzw, r0.zzzz, l(16.000000, 15.000000, 14.000000, 13.000000), r2.xxxx  
  64:  max r3.xyzw, r3.xyzw, l(0, 0, 0, 0)  
  65:  add r3.xyzw, r3.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  66:  div_sat r3.xyzw, r1.xxxx, r3.xyzw  
  67:  add r3.xyzw, -r3.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  68:  mul r2.y, r3.y, r3.x  
  69:  mul r2.y, r3.z, r2.y  
  70:  mul r2.y, r3.w, r2.y  
  71:  mad r3.xyzw, r0.zzzz, l(12.000000, 11.000000, 10.000000, 9.000000), r2.xxxx  
  72:  max r3.xyzw, r3.xyzw, l(0, 0, 0, 0)  
  73:  add r3.xyzw, r3.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  74:  div_sat r3.xyzw, r1.xxxx, r3.xyzw  
  75:  add r3.xyzw, -r3.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  76:  mul r2.y, r2.y, r3.x  
  77:  mul r2.y, r3.y, r2.y  
  78:  mul r2.y, r3.z, r2.y  
  79:  mul r2.y, r3.w, r2.y  
  80:  mad r3.xyzw, r0.zzzz, l(8.000000, 7.000000, 6.000000, 5.000000), r2.xxxx  
  81:  max r3.xyzw, r3.xyzw, l(0, 0, 0, 0)  
  82:  add r3.xyzw, r3.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  83:  div_sat r3.xyzw, r1.xxxx, r3.xyzw  
  84:  add r3.xyzw, -r3.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  85:  mul r2.y, r2.y, r3.x  
  86:  mul r2.y, r3.y, r2.y  
  87:  mul r2.y, r3.z, r2.y  
  88:  mul r2.y, r3.w, r2.y  
  89:  mad r2.zw, r0.zzzz, l(0.000000, 0.000000, 4.000000, 3.000000), r2.xxxx  
  90:  max r2.zw, r2.zzzw, l(0, 0, 0, 0)  
  91:  add r2.zw, r2.zzzw, l(0.000000, 0.000000, 1.000000, 1.000000)  
  92:  div_sat r2.zw, r1.xxxx, r2.zzzw  
  93:  add r2.zw, -r2.zzzw, l(0.000000, 0.000000, 1.000000, 1.000000)  
  94:  mul r2.y, r2.z, r2.y  
  95:  mul r2.y, r2.w, r2.y  
  96:  mad r2.x, r0.z, l(2.000000), r2.x  
  97:  max r2.x, r2.x, l(0)  
  98:  add r2.x, r2.x, l(1.000000)  
  99:  div_sat r2.x, r1.x, r2.x  
  100:  add r2.x, -r2.x, l(1.000000)  
  101:  mul r2.x, r2.x, r2.y  
  102:  mad r0.x, r0.y, r0.x, r0.z  
  103:  max r0.x, r0.x, l(0)  
  104:  add r0.x, r0.x, l(1.000000)  
  105:  div_sat r0.x, r1.x, r0.x  
  106:  add r0.x, -r0.x, l(1.000000)  
  107:  mad r0.x, -r2.x, r0.x, l(1.000000)  
  108:  add r0.y, r0.w, -cb12[48].y  
  109:  mul_sat r0.y, r0.y, cb12[48].z  
  110: else  
  111:  mov r0.xy, l(1.000000, 0.000000, 0.000000, 0.000000)  
  112: endif  
  113: log r0.x, r0.x  
  114: mul r0.z, r0.x, cb12[42].w  
  115: exp r0.z, r0.z  
  116: mul r0.z, r0.z, r0.y  
  117: mul r0.x, r0.x, cb12[48].x  
  118: exp r0.x, r0.x  
  119: mul o0.w, r0.x, r0.y  
  120: mad_sat r0.xy, r0.zzzz, cb12[189].xzxx, cb12[189].ywyy  
  121: add r2.xyz, -r1.yzwy, cb12[188].xyzx  
  122: mad r2.xyz, r0.xxxx, r2.xyzx, r1.yzwy  
  123: add r0.x, cb12[188].w, l(-1.000000)  
  124: mad r0.x, r0.y, r0.x, l(1.000000)  
  125: mul_sat r2.w, r0.x, r0.z  
  126: lt r0.x, l(0), cb12[192].x  
  127: if_nz r0.x  
  128:  mad_sat r0.xy, r0.zzzz, cb12[191].xzxx, cb12[191].ywyy  
  129:  add r3.xyz, -r1.yzwy, cb12[190].xyzx  
  130:  mad r1.xyz, r0.xxxx, r3.xyzx, r1.yzwy  
  131:  add r0.x, cb12[190].w, l(-1.000000)  
  132:  mad r0.x, r0.y, r0.x, l(1.000000)  
  133:  mul_sat r1.w, r0.x, r0.z  
  134:  add r0.xyzw, -r2.xyzw, r1.xyzw  
  135:  mad o1.xyzw, cb12[192].xxxx, r0.xyzw, r2.xyzw  
  136: else  
  137:  mov o1.xyzw, r2.xyzw  
  138: endif  
  139: mov o3.xyzw, v1.xyzw  
  140: mov o2.xy, v4.yxyy  
  141: ret  

One thing that immediately that can catch an attention is estimating a fog here (lines 30-138). Calculating it on a per-vertex basis makes sense for performance reasons. Also, we don't need such a fog precision - meteroids are usually above Geralt's head and they don't reach horizon.
Aerial params (rgb = color, a = influence) are saved to o0.xyzw whereas fog ones go to o1.xyzw.

o2.xy (line 140) is just texcoords.
o3.xyzw (line 139) is not relevant.

And now, a few words about calculating the world position. The vertex shaders performs billboarding. First of all, input data for billboards come from the vertex buffer - let's take a look at them.

The first one is Position:

As it was mentioned earlier, we have 2 quads here. 8 vertices, 12 indices.
But why the position is the same for each vertex? It's quite simple - this is the position of a center of a quad.

Next, each vertex has an offset from the center to the edge of a quad:

This means that every shooting star has a world space size of (400, 3) units. (XY plane, in The Witcher 3 Z-axis is up)

The last element every vertex has is a world-space direction unit vector which controls a movement of a shooting star:

Since the data come from the CPU side, it's hard to tell how they are calculated.

Now for the billboarding code. The idea is quite simple -first, an unit vector from quad centre to a camera is obtained:
   7: add r2.xyz, -r1.zxyz, cb1[8].zxyz  
   8: dp3 r0.z, r2.xyzx, r2.xyzx  
   9: rsq r0.z, r0.z  
  10: mul r2.xyz, r0.zzzz, r2.xyzx  


Then, an unit tangent vector which controls the movement of a shooting star is retrieved.
Given that this vector is already normalized from CPU side, this normalization is redundant.
  11: dp3 r0.z, v5.xyzx, v5.xyzx  
  12: rsq r0.z, r0.z  
  13: mul r3.xyz, r0.zzzz, v5.xyzx  


Having the two vectors, a cross product is used to determine bitangent vector which is perpendicular to the both input ones.
  14: mul r4.xyz, r2.xyzx, r3.yzxy  
  15: mad r2.xyz, r2.zxyz, r3.zxyz, -r4.xyzx  
  16: dp3 r0.z, r2.xyzx, r2.xyzx  
  17: rsq r0.z, r0.z  
  18: mul r2.xyz, r0.zzzz, r2.xyzx  


Now we have normalized tangent (r3.xyz) and bitangent (r2.xyz) vectors.
Let's introduce Xsize and Ysize, corresponding to TEXCOORD1 input element, so for instance (-200, 1.50).

The final calculation of a world-space position is:
  19: mad r0.z, v7.x, v6.x, l(1.000000)  
  20: mul r3.xyz, r0.zzzz, r3.xyzx  
  21: mul r3.xyz, r3.xyzx, v3.xxxx  
  22: mul r2.xyz, r2.xyzx, v3.yyyy  
  23: mad r0.xzw, r3.xxyz, r0.xxxx, r1.xxyz  
  24: mad r0.xyz, r2.xyzx, r0.yyyy, r0.xzwx  
  25: mov r0.w, l(1.000000)  


Given that r0.x, r0.y and r0.z are equal to 1.0, the final calculation simplifies to:

worldSpacePosition = quadCenter + tangent * Xsize + bitangent * Ysize

The last part is just multiplying the world-space position by a view-projection matrix in order to obtain SV_Position:
  26: dp4 o4.x, r0.xyzw, cb1[0].xyzw  
  27: dp4 o4.y, r0.xyzw, cb1[1].xyzw  
  28: dp4 o4.z, r0.xyzw, cb1[2].xyzw  
  29: dp4 o4.w, r0.xyzw, cb1[3].xyzw  



3. Pixel Shader

As mentioned in the "General" section, the following blend state is being used:

FinalColor = SrcColor * One + DestColor * (1.0 - SrcAlpha) =
FinalColor = SrcColor + DestColor * (1.0 - SrcAlpha)

where SrcColor and SrcAlpha are.respectively .rgb and .a components from the pixel shader whereas DestColor is .rgb color currently residing in the rendertarget.

The primary factor which controls the transparency here is SrcAlpha. Many of the forward shaders from this game calculate this as an opacity and apply it in the end like so:

return float4( color * opacity, opacity );

The shooting stars shader is not an exception here. Following this pattern, let's consider three cases when opacity is equal to 1.0, 0.1, and 0.0, respectively. But first, the output once again for easier comparison:



a) opacity = 1.0

FinalColor = color * opacity + DestColor * (1.0 - opacity) =
FinalColor = color = SrcColor




b) opacity = 0.1

FinalColor = color * opacity + DestColor * (1.0 - opacity) =
FinalColor = 0.1 * color + 0.9 * DestColor




c) opacity = 0.0

FinalColor = color * opacity + DestColor * (1.0 - opacity) = 
FinalColor = DestColor




The fundamental idea of this shader is modelling and using an opacity function opacity(x) which controls an opacity of a pixel along a shooting star. The main requirement is that opacity has to reach maximum values near the end of a star ("head") and has to smoothly fade out to 0.0 ("tail").

It will become clear later as we get through the pixel shader assembly:
 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb0[10], immediateIndexed  
    dcl_constantbuffer cb2[3], immediateIndexed  
    dcl_constantbuffer cb4[2], immediateIndexed  
    dcl_input_ps linear v0.xyzw  
    dcl_input_ps linear v1.xyzw  
    dcl_input_ps linear v2.y  
    dcl_input_ps linear v3.w  
    dcl_output o0.xyzw  
    dcl_temps 4  
   0: mov_sat r0.x, v2.y  
   1: ge r0.y, r0.x, l(0.052579)  
   2: ge r0.z, l(0.965679), r0.x  
   3: and r0.y, r0.z, r0.y  
   4: if_nz r0.y  
   5:  ge r0.y, l(0.878136), r0.x  
   6:  add r0.z, r0.x, l(-0.052579)  
   7:  mul r1.w, r0.z, l(1.211303)  
   8:  mov_sat r0.z, r1.w  
   9:  mad r0.w, r0.z, l(-2.000000), l(3.000000)  
  10:  mul r0.z, r0.z, r0.z  
  11:  mul r0.z, r0.z, r0.w  
  12:  mul r2.x, r0.z, l(0.084642)  
  13:  mov r1.yz, l(0.000000, 0.000000, 0.084642, 0.000000)  
  14:  movc r2.yzw, r0.yyyy, r1.yyzw, l(0.000000, 0.000000, 0.000000, 0.500000)  
  15:  not r0.z, r0.y  
  16:  if_z r0.y  
  17:   ge r0.y, l(0.924339), r0.x  
  18:   add r0.w, r0.x, l(-0.878136)  
  19:   mul r1.w, r0.w, l(21.643608)  
  20:   mov_sat r0.w, r1.w  
  21:   mad r3.x, r0.w, l(-2.000000), l(3.000000)  
  22:   mul r0.w, r0.w, r0.w  
  23:   mul r0.w, r0.w, r3.x  
  24:   mad r1.x, r0.w, l(0.889658), l(0.084642)  
  25:   mov r1.yz, l(0.000000, 0.084642, 0.974300, 0.000000)  
  26:   movc r2.xyzw, r0.yyyy, r1.xyzw, r2.xyzw  
  27:  else  
  28:   mov r2.y, l(0)  
  29:   mov r0.y, l(-1)  
  30:  endif  
  31:  not r0.w, r0.y  
  32:  and r0.z, r0.w, r0.z  
  33:  if_nz r0.z  
  34:   ge r0.y, r0.x, l(0.924339)  
  35:   add r0.x, r0.x, l(-0.924339)  
  36:   mul r1.w, r0.x, l(24.189651)  
  37:   mov_sat r0.x, r1.w  
  38:   mad r0.z, r0.x, l(-2.000000), l(3.000000)  
  39:   mul r0.x, r0.x, r0.x  
  40:   mul r0.x, r0.x, r0.z  
  41:   mad r1.x, r0.x, l(-0.974300), l(0.974300)  
  42:   mov r1.yz, l(0.000000, 0.974300, 0.000000, 0.000000)  
  43:   movc r2.xyzw, r0.yyyy, r1.xyzw, r2.xyzw  
  44:  endif  
  45: else  
  46:  mov r2.yzw, l(0.000000, 0.000000, 0.000000, 0.500000)  
  47:  mov r0.y, l(0)  
  48: endif  
  49: mov_sat r2.w, r2.w  
  50: mad r0.x, r2.w, l(-2.000000), l(3.000000)  
  51: mul r0.z, r2.w, r2.w  
  52: mul r0.x, r0.z, r0.x  
  53: add r0.z, -r2.y, r2.z  
  54: mad r0.x, r0.x, r0.z, r2.y  
  55: movc r0.x, r0.y, r2.x, r0.x  
  56: mad r0.y, cb4[1].x, -cb0[9].w, l(1.000000)  
  57: mul_sat r0.y, r0.y, v3.w  
  58: mul r0.x, r0.y, r0.x  
  59: mul r0.yzw, cb2[2].xxyz, cb4[0].xxxx  
  60: mul r0.x, r0.x, cb2[2].w  
  61: dp3 r1.x, l(0.333000, 0.555000, 0.222000, 0.000000), r0.yzwy  
  62: mad r1.xyz, r1.xxxx, v0.xyzx, -r0.yzwy  
  63: mad r0.yzw, v0.wwww, r1.xxyz, r0.yyzw  
  64: add r1.xyz, -r0.yzwy, v1.xyzx  
  65: mad r0.yzw, v1.wwww, r1.xxyz, r0.yyzw  
  66: mul o0.xyz, r0.xxxx, r0.yzwy  
  67: mov o0.w, r0.x  
  68: ret  

Generally, the shader is a bit (over)complicated and I had some tough time with understanding what's going on. For instance, where do the values like 1.211303, 21.643608, 24.189651 come from?

If we think about using the opacity function, we need one input. This is quite easy - texcoord ranged [0,1] will be helpful here (line 0) so we can apply the function on whole length of a meteroid.

The opacity function has 3 segments/ranges and they are defined using 4 control points:
   // current status: no idea how these are generated  
   const float controlPoint0 = 0.052579;  
   const float controlPoint1 = 0.878136;  
   const float controlPoint2 = 0.924339;  
   const float controlPoint3 = 0.965679;  

I have no idea how they were estimated/calculated. ( :

As you can see in the assembly, the first condition is just a check if the input value is in [controlPoint0 - controlPoint3] range. If not, the opacity is just 0.0.
   // Input for the opacity function
   float y = saturate(Input.Texcoords.y);  // r0.x
     
   // Value of opacity function.  
   // 0 - no change  
   // 1 - full color  
   float opacity = 0.0;  
     
   [branch]   
   if (y >= controlPoint0 && y <= controlPoint3)  
   {  
      ...  

Deciphering the following assembly snippet is essential if we want to find out how the opacity function works:
   6: add r0.z, r0.x, l(-0.052579)   
   7: mul r1.w, r0.z, l(1.211303)   
   8: mov_sat r0.z, r1.w   
   9: mad r0.w, r0.z, l(-2.000000), l(3.000000)   
  10: mul r0.z, r0.z, r0.z   
  11: mul r0.z, r0.z, r0.w   
  12: mul r2.x, r0.z, l(0.084642)   

In line 9 there are '-2.0' and '3.0' coefficents, which suggests the use of smoothstep function. Well, this is a good guess.

HLSL's smoothstep, with the prototype: ret smoothstep(min, max, x) always clamps x to [min-max] range. Assembly-wise, it subtracts min from the input value (which is r0.z at line 9) but there is nothing like that here. For max this implies multiplying the input value, but also, there is nothing like 'mul_sat' here. Instead, there is 'mov_sat'. This gives a clue that the smoothstep's min and max here are 0 and 1, respectively.

We know now that x should be in between [0, 1]. As stated earlier, there are 3 segments in the opacity function. This strongly suggests finding where are we in [segmentStart-segmentEnd].

Linstep function is the answer!
 float linstep(float min, float max, float v)  
 {  
   return ( (v-min) / (max-min) );  
 }  

For example, let's take the first segment: [0.052579 - 0.878136]. Subtraction is at line 6. If we replace the division to multiplication -> 1.0 / (0.878136 - 0.052579) = 1.0 / 0.825557 = ~1.211303.

The result of the smoothstep is in [0, 1] range. The multiplication at line 12 is a weight of a segment. Each segment has its weight which allows to control the maximum opacity in this particular segment.

This means, that for the first segment [0.052579 - 0.878136] its opacity ranges [0 - 0.084642].

A HLSL function which calculates an opacity for an arbitrary segment can be written like so:
 float getOpacityFunctionValue(float x, float cpLeft, float cpRight, float weight)  
 {  
   float val = smoothstep( 0, 1, linstep(cpLeft, cpRight, x) );  
   return val * weight;  
 }  

So the whole idea is to just call this function for a proper segment.

A quick look at the weights:
   const float weight0 = 0.084642;  
   const float weight1 = 0.889658;  
   const float weight2 = 0.974300; // note: weight0+weight1 = weight2  

Following the assembly, the opacity(x) function is calculated like so:
   float opacity = 0.0;

   [branch]   
   if (y >= controlPoint0 && y <= controlPoint3)  
   {  
     // Range of v: [0, weight0]  
     float v = getOpacityFunctionValue(y, controlPoint0, controlPoint1, weight0);  
     opacity = v;  
     
     [branch]  
     if ( y >= controlPoint1 )  
     {  
       // Range of v: [0, weight1]  
       float v = getOpacityFunctionValue(y, controlPoint1, controlPoint2, weight1);  
       opacity = weight0 + v;  
   
       [branch]  
       if (y >= controlPoint2)  
       {  
         // Range of v: [0, weight2]  
         float v = getOpacityFunctionValue(y, controlPoint2, controlPoint3, weight2);
         opacity = weight2 - v;          
       }  
     }  
   }  


Here is the graph of the opacity function. You can easily notice the boost of the opacity which indicates a 'head' of a shooting star:
The opacity function graph.
red - opacity value
green - control points
blue - weights

Once the opacity has been calculated, the rest is just final touches. There are extra multiplications: stars opacity, shooting star color and fog influence. As usual in TW3 shaders, you can also find some redundant multiplications by 1.0:
   // cb4_v1.x = 1.0  
   float starsOpacity = 1.0 - cb0_v9.w * cb4_v1.x;    
   opacity *= starsOpacity;  

   // Calculate color of a shooting star  
   // cb4_v0.x = 10.0
   // cb2_v2.rgb = (1.0, 1.0, 1.0)
   float3 color = cb2_v2.rgb * cb4_v0.x;
     
   // cb2_v2.w = 1  
   opacity *= cb2_v2.w;
     
   FogResult fr = { Input.FogParams, Input.AerialParams };  
   color = ApplyFog(fr, color);
     
   return float4( color*opacity, opacity);  
 }   



4. Summary

The major difficulty was the opacity function part. Once this is deciphered, all the rest is quite easy to find out.

I've mentioned earlier that the pixel shader is a bit overcomplicated. All we really care is just value of opacity(x) function, which is stored in r2.x (starting from line 49). However, the opacity function in the assembly produces three extra variables: minRange (r2.y), maxRange (r2.z) and value (r2.w). They are the parameters used to calculate opacity when opacity(x) is not used :

lerp( minRange, maxRange, smoothstep(0, 1, value) );

In fact, the final opacity value is obtained in the conditional move at line 55 - if input x value is in between [controlPoint0 - controlPoint3] range, this means that the opacity funcion is used, so r2.x is picked. Otherwise, if x is out of the range, opacity calculated from the r0.x, ergo, the equation above.

I debugged a few pixels outside of  [controlPoint0 - controlPoint3] range and the final opacity was always equal to zero. That's why I think the extra calculations just described are not necessary. Just opacity = 0.0 at the start of the shader works the same.

That's all for today :)
And as always, thanks for reading.

Saturday, July 6, 2019

HLSLexplorer 1.01 - UI improvements and D3D12 rendering backend

Hi,

A new version of HLSLexplorer, 1.01 is done and available to download from here (Google Drive). The executable is working on Windows 10 and was built against Windows 10 SDK 10.0.17763.0.
If you would to like to play with source, it's on GitHub.



Let's go briefly through the most important changes:

1) Recent Files List

Quite self-explanatory. I've been wasting too much time because of looking for a particular files so I did recent files list. Saved as recent.dat in HLSLexplorer's main directory. So far there is a limit of 8 files, but you can easily change it in source. There is also a fancy separator and an option to clear recents (no, it doesn't ask you to confirm).

2) Go to Line

I've always liked Go To Line dialog from Visual Studio. Simple and very intuitive. So, I did the same for HLSLexplorer, because why not. It's useful for shaders since compiler usually gives you hints which lines are worth to check for problems. Just click on text window which is of your interest (HLSL or any of three assembly outputs) and Go to Line will know where to look. Of course, Ctrl+G works.



3) D3D12 backend for rendering

HLSLexplorer has just received support for D3D12 renderer backend. Well, finally, one could say.
It doesn't mean that D3D11 is gone. Because what real-time Pixel Shader preview does is just rendering of fullscreen triangle, I came up with simple IRenderer interface which nicely wraps all needed functionality.

Despite D3D12 backend is still really basic, I still found it fun to write because I have started learning D3D12 not so long ago, so that was a good opportunity to learn something new.

The new backend currently works with 2D textures only and is very error-prone. F7 opens PS preview with D3D11, F8 opens one with D3D12.

D3D12 renderer supports pixel shaders for both Shader Model 5.0 and 6.0+. So yeah, you can now visualize wave intrinsics! 😎


4) Minor stuff

I got rid of all DirectXTK stuff since everything I needed was just loading textures.
Luckily, there are a standalone versions of DDSTextureLoader and WICTextureLoader, for both D3D11 and D3D12. You can grab them from DirectXTex github.

Real-Time Pixel Shader Preview informs now which API is used and what is the resolution of backbuffer.


Conclusion

These are the most important changes I introduced in the latest version of HLSLexplorer.
Feedback is always welcome :)


Thanks for reading. Take care!

Sunday, June 16, 2019

Reverse engineering the rendering of The Witcher 3, part 15 - fog

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


Fog can be implemented in many ways. However, times when we could apply simple distance-based fog and be happy with it are gone forever (probably). Living in a land of programmable shaders opened us doors to new, crazy, but most importantly - physically-correct and visually plausible solutions.

Current trend in fog rendering is probably based on compute shaders (see this presentation by BartÅ‚omiej WroÅ„ski for more details). 

Despite the mentioned presentation has appeared in 2014 and The Witcher 3 was released in 2015/2016 - fog in the last part of Geralt's adventures is completely screen-based, as typical postprocess.

Before we start our 15th reverse engineering session in this series, I must say that I've been trying to understand the fog in The Witcher 3 at least 5 times in the last year - and each time I failed. The assembly, as you'll see in a mnute is quite complex which makes process of creating readable HLSL fog shader I won't be ashamed of almost impossible.

However, I managed to found in the internet a fog shader which almost immediately caught my attention by similarity to The Witcher 3 in terms of variable names and general instructions order. That shader was not *exactly* the same as in the game so I had to work on it a little bit. My point is, most of HLSL you'll see here is not created/reversed by me with two exceptions. Keep that in mind.


Here is assembly of pixel shader for fog - it's worth noticing it's the same for whole game (2015 base and both DLCs):
 ps_5_0  
    dcl_globalFlags refactoringAllowed  
    dcl_constantbuffer cb3[2], immediateIndexed  
    dcl_constantbuffer cb12[214], immediateIndexed  
    dcl_resource_texture2d (float,float,float,float) t0  
    dcl_resource_texture2d (float,float,float,float) t1  
    dcl_resource_texture2d (float,float,float,float) t2  
    dcl_input_ps_siv v0.xy, position  
    dcl_output o0.xyzw  
    dcl_temps 7  
   0: ftou r0.xy, v0.xyxx  
   1: mov r0.zw, l(0, 0, 0, 0)  
   2: ld_indexable(texture2d)(float,float,float,float) r1.x, r0.xyww, t0.xyzw  
   3: mad r1.y, r1.x, cb12[22].x, cb12[22].y  
   4: lt r1.y, r1.y, l(1.000000)  
   5: if_nz r1.y  
   6:  utof r1.yz, r0.xxyx  
   7:  mul r2.xyzw, r1.zzzz, cb12[211].xyzw  
   8:  mad r2.xyzw, cb12[210].xyzw, r1.yyyy, r2.xyzw  
   9:  mad r1.xyzw, cb12[212].xyzw, r1.xxxx, r2.xyzw  
  10:  add r1.xyzw, r1.xyzw, cb12[213].xyzw  
  11:  div r1.xyz, r1.xyzx, r1.wwww  
  12:  ld_indexable(texture2d)(float,float,float,float) r2.xyz, r0.xyww, t1.xyzw  
  13:  ld_indexable(texture2d)(float,float,float,float) r0.x, r0.xyzw, t2.xyzw  
  14:  max r0.x, r0.x, cb3[1].x  
  15:  add r0.yzw, r1.xxyz, -cb12[0].xxyz  
  16:  dp3 r1.x, r0.yzwy, r0.yzwy  
  17:  sqrt r1.x, r1.x  
  18:  add r1.y, r1.x, -cb3[0].x  
  19:  add r1.zw, -cb3[0].xxxz, cb3[0].yyyw  
  20:  div_sat r1.y, r1.y, r1.z  
  21:  mad r1.y, r1.y, r1.w, cb3[0].z  
  22:  add r0.x, r0.x, l(-1.000000)  
  23:  mad r0.x, r1.y, r0.x, l(1.000000)  
  24:  div r0.yzw, r0.yyzw, r1.xxxx  
  25:  mad r1.y, r0.w, cb12[22].z, cb12[0].z  
  26:  add r1.x, r1.x, -cb12[22].z  
  27:  max r1.x, r1.x, l(0)  
  28:  min r1.x, r1.x, cb12[42].z  
  29:  mul r1.z, r0.w, r1.x  
  30:  mul r1.w, r1.x, cb12[43].x  
  31:  mul r1.zw, r1.zzzw, l(0.000000, 0.000000, 0.062500, 0.062500)  
  32:  dp3 r0.y, cb12[38].xyzx, r0.yzwy  
  33:  add r0.z, r0.y, cb12[42].x  
  34:  add r0.w, cb12[42].x, l(1.000000)  
  35:  div_sat r0.z, r0.z, r0.w  
  36:  add r0.w, -cb12[43].z, cb12[43].y  
  37:  mad r0.z, r0.z, r0.w, cb12[43].z  
  38:  mul r0.w, abs(r0.y), abs(r0.y)  
  39:  mad_sat r2.w, r1.x, l(0.002000), l(-0.300000)  
  40:  mul r0.w, r0.w, r2.w  
  41:  lt r0.y, l(0), r0.y  
  42:  movc r3.xyz, r0.yyyy, cb12[39].xyzx, cb12[41].xyzx  
  43:  add r3.xyz, r3.xyzx, -cb12[40].xyzx  
  44:  mad r3.xyz, r0.wwww, r3.xyzx, cb12[40].xyzx  
  45:  movc r4.xyz, r0.yyyy, cb12[45].xyzx, cb12[47].xyzx  
  46:  add r4.xyz, r4.xyzx, -cb12[46].xyzx  
  47:  mad r4.xyz, r0.wwww, r4.xyzx, cb12[46].xyzx  
  48:  ge r0.y, r1.x, cb12[48].y  
  49:  if_nz r0.y  
  50:   add r0.y, r1.y, cb12[42].y  
  51:   mul r0.w, r0.z, r0.y  
  52:   mul r1.y, r0.z, r1.z  
  53:   mad r5.xyzw, r1.yyyy, l(16.000000, 15.000000, 14.000000, 13.000000), r0.wwww  
  54:   max r5.xyzw, r5.xyzw, l(0, 0, 0, 0)  
  55:   add r5.xyzw, r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  56:   div_sat r5.xyzw, r1.wwww, r5.xyzw  
  57:   add r5.xyzw, -r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  58:   mul r1.z, r5.y, r5.x  
  59:   mul r1.z, r5.z, r1.z  
  60:   mul r1.z, r5.w, r1.z  
  61:   mad r5.xyzw, r1.yyyy, l(12.000000, 11.000000, 10.000000, 9.000000), r0.wwww  
  62:   max r5.xyzw, r5.xyzw, l(0, 0, 0, 0)  
  63:   add r5.xyzw, r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  64:   div_sat r5.xyzw, r1.wwww, r5.xyzw  
  65:   add r5.xyzw, -r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  66:   mul r1.z, r1.z, r5.x  
  67:   mul r1.z, r5.y, r1.z  
  68:   mul r1.z, r5.z, r1.z  
  69:   mul r1.z, r5.w, r1.z  
  70:   mad r5.xyzw, r1.yyyy, l(8.000000, 7.000000, 6.000000, 5.000000), r0.wwww  
  71:   max r5.xyzw, r5.xyzw, l(0, 0, 0, 0)  
  72:   add r5.xyzw, r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  73:   div_sat r5.xyzw, r1.wwww, r5.xyzw  
  74:   add r5.xyzw, -r5.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000)  
  75:   mul r1.z, r1.z, r5.x  
  76:   mul r1.z, r5.y, r1.z  
  77:   mul r1.z, r5.z, r1.z  
  78:   mul r1.z, r5.w, r1.z  
  79:   mad r5.xy, r1.yyyy, l(4.000000, 3.000000, 0.000000, 0.000000), r0.wwww  
  80:   max r5.xy, r5.xyxx, l(0, 0, 0, 0)  
  81:   add r5.xy, r5.xyxx, l(1.000000, 1.000000, 0.000000, 0.000000)  
  82:   div_sat r5.xy, r1.wwww, r5.xyxx  
  83:   add r5.xy, -r5.xyxx, l(1.000000, 1.000000, 0.000000, 0.000000)  
  84:   mul r1.z, r1.z, r5.x  
  85:   mul r1.z, r5.y, r1.z  
  86:   mad r0.w, r1.y, l(2.000000), r0.w  
  87:   max r0.w, r0.w, l(0)  
  88:   add r0.w, r0.w, l(1.000000)  
  89:   div_sat r0.w, r1.w, r0.w  
  90:   add r0.w, -r0.w, l(1.000000)  
  91:   mul r0.w, r0.w, r1.z  
  92:   mad r0.y, r0.y, r0.z, r1.y  
  93:   max r0.y, r0.y, l(0)  
  94:   add r0.y, r0.y, l(1.000000)  
  95:   div_sat r0.y, r1.w, r0.y  
  96:   add r0.y, -r0.y, l(1.000000)  
  97:   mad r0.y, -r0.w, r0.y, l(1.000000)  
  98:   add r0.z, r1.x, -cb12[48].y  
  99:   mul_sat r0.z, r0.z, cb12[48].z  
  100:  else  
  101:   mov r0.yz, l(0.000000, 1.000000, 0.000000, 0.000000)  
  102:  endif  
  103:  log r0.y, r0.y  
  104:  mul r0.w, r0.y, cb12[42].w  
  105:  exp r0.w, r0.w  
  106:  mul r0.y, r0.y, cb12[48].x  
  107:  exp r0.y, r0.y  
  108:  mul r0.yw, r0.yyyw, r0.zzzz  
  109:  mad_sat r1.xy, r0.wwww, cb12[189].xzxx, cb12[189].ywyy  
  110:  add r5.xyz, -r3.xyzx, cb12[188].xyzx  
  111:  mad r5.xyz, r1.xxxx, r5.xyzx, r3.xyzx  
  112:  add r0.z, cb12[188].w, l(-1.000000)  
  113:  mad r0.z, r1.y, r0.z, l(1.000000)  
  114:  mul_sat r5.w, r0.z, r0.w  
  115:  lt r0.z, l(0), cb12[192].x  
  116:  if_nz r0.z  
  117:   mad_sat r1.xy, r0.wwww, cb12[191].xzxx, cb12[191].ywyy  
  118:   add r6.xyz, -r3.xyzx, cb12[190].xyzx  
  119:   mad r3.xyz, r1.xxxx, r6.xyzx, r3.xyzx  
  120:   add r0.z, cb12[190].w, l(-1.000000)  
  121:   mad r0.z, r1.y, r0.z, l(1.000000)  
  122:   mul_sat r3.w, r0.z, r0.w  
  123:   add r1.xyzw, -r5.xyzw, r3.xyzw  
  124:   mad r5.xyzw, cb12[192].xxxx, r1.xyzw, r5.xyzw  
  125:  endif  
  126:  mul r0.z, r0.x, r5.w  
  127:  mul r0.x, r0.x, r0.y  
  128:  dp3 r0.y, l(0.333000, 0.555000, 0.222000, 0.000000), r2.xyzx  
  129:  mad r1.xyz, r0.yyyy, r4.xyzx, -r2.xyzx  
  130:  mad r0.xyw, r0.xxxx, r1.xyxz, r2.xyxz  
  131:  add r1.xyz, -r0.xywx, r5.xyzx  
  132:  mad r0.xyz, r0.zzzz, r1.xyzx, r0.xywx  
  133: else  
  134:  mov r0.xyz, l(0, 0, 0, 0)  
  135: endif  
  136: mov o0.xyz, r0.xyzx  
  137: mov o0.w, l(1.000000)  
  138: ret  
   

This shader is quite long, let's be honest. Probably too long for effective reverse engineering process ;)


Here is an example sunset scene with fog:


Let's take a look at inputs:

In terms of textures, we have depth buffer, Ambient Occlusion and HDR color buffer
Input depth buffer


Input ambient occlusion


Input HDR color buffer

... and the result of fog shader in this scene looks like this:

HDR texture after applying fog

Depth buffer is used to reconstruct world position. This is a common pattern in shaders of The Witcher 3.

Having ambient occlusion data (if enabled) allows us to darken fog. Very smart idea, seems maybe obvious but I've never thinked of it in that way. I'll go back to this aspect later.

The shader begins with determining if pixel is not on sky. In case when pixel lies on sky (depth == 1.0) - shader returns black color. If a pixel is on scene (depth < 1.0), we reconstruct world position using depth buffer (lines 7-11) and process with calculation of fog.

The fog pass takes place shortly after deferred shading process. You can see that some of forward-pass specific elements are missing yet. In this particular scene, deferred light volumes were applied and, after that, we rendered hair/face/eyes of Geralt.

The first thing to know about fog in The Witcher 3 is that it consists of two parts: "fog color" and "aerial color".
 struct FogResult  
 {  
    float4 paramsFog;     // RGB: color, A: influence  
    float4 paramsAerial;  // RGB: color, A: influence  
 };  

For each part, there are 3 colors provided: front, middle and back. So we have in constant buffer data such as "FogColorFront", "FogColorMiddle", "AerialColorBack" etc. See the inputs:



   // *** Inputs *** //  
   float3 FogSunDir = cb12_v38.xyz;  
   float3 FogColorFront = cb12_v39.xyz;  
   float3 FogColorMiddle = cb12_v40.xyz;  
   float3 FogColorBack = cb12_v41.xyz;  
     
   float4 FogBaseParams = cb12_v42;  
   float4 FogDensityParamsScene = cb12_v43;  
   float4 FogDensityParamsSky = cb12_v44;  
     
   float3 AerialColorFront = cb12_v45.xyz;  
   float3 AerialColorMiddle = cb12_v46.xyz;  
   float3 AerialColorBack = cb12_v47.xyz;  
   float4 AerialParams = cb12_v48; 


Before calculating final colors, we need to calculate some vectors and dot products. Shader has access to pixel's world position, camera position (cb12[0].xyz) and fog/light direction (cb12[38].xyz). This allows us to calculate dot product between view vector and fog direction.
   float3 frag_vec = fragPosWorldSpace.xyz - customCameraPos.xyz;  
   float frag_dist = length(frag_vec);  
     
   float3 frag_dir = frag_vec / frag_dist;  
   
   float dot_fragDirSunDir = dot(GlobalLightDirection.xyz, frag_dir);  


Calculating mixing gradient involves using square absolute dot product, and then again multiply the result with some distance-based parameter:
   float3 curr_col_fog;  
   float3 curr_col_aerial;  
   {  
     float _dot = dot_fragDirSunDir;  
   
     float _dd = _dot;  
     {  
       const float _distOffset = -150;  
       const float _distRange = 500;  
       const float _mul = 1.0 / _distRange;  
       const float _bias = _distOffset * _mul;  
   
       _dd = abs(_dd);  
       _dd *= _dd;  
       _dd *= saturate( frag_dist * _mul + _bias );  
     }  
   
     curr_col_fog = lerp( FogColorMiddle.xyz, (_dot>0.0f ? FogColorFront.xyz : FogColorBack.xyz), _dd );  
     curr_col_aerial = lerp( AerialColorMiddle.xyz, (_dot>0.0f ? AerialColorFront.xyz : AerialColorBack.xyz), _dd );  
   }  

This code block clearly gives us notion where the heck that 0.002 and -0.300 come from. As you can see, dot product between view and light vectors is responsible for choice between 'front' and 'back' colors. Clever!

Here is a visualization of final gradient (_dd).


Caculating of aerial/fog influence is much more complicated, though. As you can see, we have more parameters than just rgb colors. They include e.g. density of scene. We use ray marching (16 steps, that's why the loop can be unrolled) to determine amount of fog and scale factor:

Having a [camera--->world] vector, we can divide all its components by 16 - this will be our ray marching step. As you can see below, only .z component (height) is considered in caluclations (curr_pos_z_step).

You can read more about raymarched fog, for instance, here.

   float fog_amount = 1;  
   float fog_amount_scale = 0;  
   [branch]  
   if ( frag_dist >= AerialParams.y )  
   {  
     float curr_pos_z_base = (customCameraPos.z + FogBaseParams.y) * density_factor;  
     float curr_pos_z_step = frag_step.z * density_factor;  
   
     [unroll]  
     for ( int i=16; i>0; --i )  
     {  
       fog_amount *= 1 - saturate( density_sample_scale / (1 + max( 0.0, curr_pos_z_base + (i) * curr_pos_z_step ) ) );  
     }  
   
     fog_amount = 1 - fog_amount;  
     fog_amount_scale = saturate( (frag_dist - AerialParams.y) * AerialParams.z );  
   }  
   
   FogResult ret;  
   
   ret.paramsFog = float4 ( curr_col_fog, fog_amount_scale * pow( abs(fog_amount), final_exp_fog ) );  
   ret.paramsAerial = float4 ( curr_col_aerial, fog_amount_scale * pow( abs(fog_amount), final_exp_aerial ) );  

Amount of fog clearly depends on height (.z component), at the end fog amount is raised to fog/aerial power.

final_exp_fog and final_exp_aerial are from constant buffer and they allow to control how fog and aerial colors influences world as height raises.

Fog Override

The shader I found did not include this fragment of assembly:
  109:  mad_sat r1.xy, r0.wwww, cb12[189].xzxx, cb12[189].ywyy  
  110:  add r5.xyz, -r3.xyzx, cb12[188].xyzx  
  111:  mad r5.xyz, r1.xxxx, r5.xyzx, r3.xyzx  
  112:  add r0.z, l(-1.000000), cb12[188].w  
  113:  mad r0.z, r1.y, r0.z, l(1.000000)  
  114:  mul_sat r5.w, r0.w, r0.z  
  115:  lt r0.z, l(0.000000), cb12[192].x  
  116:  if_nz r0.z  
  117:   mad_sat r1.xy, r0.wwww, cb12[191].xzxx, cb12[191].ywyy  
  118:   add r6.xyz, -r3.xyzx, cb12[190].xyzx  
  119:   mad r3.xyz, r1.xxxx, r6.xyzx, r3.xyzx  
  120:   add r0.z, l(-1.000000), cb12[190].w  
  121:   mad r0.z, r1.y, r0.z, l(1.000000)  
  122:   mul_sat r3.w, r0.w, r0.z  
  123:   add r1.xyzw, -r5.xyzw, r3.xyzw  
  124:   mad r5.xyzw, cb12[192].xxxx, r1.xyzw, r5.xyzw  
  125:  endif   

From what I managed to understand this looks like a double override of fog color and influence:
For most of the time, there is only one override (cb12_v192.x is 0.0) but in this particular case - its value is ~0.22, so we perform second override.



 #ifdef OVERRIDE_FOG  
     
   // Override  
   float fog_influence = ret.paramsFog.w; // r0.w  
   
   float override1ColorScale = cb12_v189.x;  
   float override1ColorBias = cb12_v189.y;  
   float3 override1Color = cb12_v188.rgb;  
     
   float override1InfluenceScale = cb12_v189.z;  
   float override1InfluenceBias = cb12_v189.w;  
   float override1Influence = cb12_v188.w;  
     
   float override1ColorAmount = saturate(fog_influence * override1ColorScale + override1ColorBias);  
   float override1InfluenceAmount = saturate(fog_influence * override1InfluenceScale + override1InfluenceBias);    
     

   float4 paramsFogOverride;  
   paramsFogOverride.rgb = lerp(curr_col_fog, override1Color, override1ColorAmount ); // ***r5.xyz   
     
   float param1 = lerp(1.0, override1Influence, override1InfluenceAmount); // r0.x  
   paramsFogOverride.w = saturate(param1 * fog_influence ); // ** r5.w  
   
     
   const float extraFogOverride = cb12_v192.x;  
     
   [branch]   
   if (extraFogOverride > 0.0)  
   {  
     float override2ColorScale = cb12_v191.x;  
     float override2ColorBias = cb12_v191.y;  
     float3 override2Color = cb12_v190.rgb;  
     
     float override2InfluenceScale = cb12_v191.z;  
     float override2InfluenceBias = cb12_v191.w;  
     float override2Influence = cb12_v190.w;  
       
     float override2ColorAmount = saturate(fog_influence * override2ColorScale + override2ColorBias);  
     float override2InfluenceAmount = saturate(fog_influence * override2InfluenceScale + override2InfluenceBias);  
      

     float4 paramsFogOverride2;  
     paramsFogOverride2.rgb = lerp(curr_col_fog, override2Color, override2ColorAmount); // r3.xyz   
           
     float ov_param1 = lerp(1.0, override2Influence, override2InfluenceAmount); // r0.z  
     paramsFogOverride2.w = saturate(ov_param1 * fog_influence); // r3.w  
   
     paramsFogOverride = lerp(paramsFogOverride, paramsFogOverride2, extraFogOverride);  
   
   }  
   ret.paramsFog = paramsFogOverride;  
     
 #endif   

Here is our final scene without fog override (first image), single override (second image) and double override (third image, final result):




Adjusting ambient occlusion

The shader I found also did not use ambient occlusion at all. Let's take a look at AO texture again and code which is of our interest:

  13:  ld_indexable(texture2d)(float,float,float,float) r0.x, r0.xyzw, t2.xyzw  
  14:  max r0.x, r0.x, cb3[1].x  
  15:  add r0.yzw, r1.xxyz, -cb12[0].xxyz  
  16:  dp3 r1.x, r0.yzwy, r0.yzwy  
  17:  sqrt r1.x, r1.x  
  18:  add r1.y, r1.x, -cb3[0].x  
  19:  add r1.zw, -cb3[0].xxxz, cb3[0].yyyw  
  20:  div_sat r1.y, r1.y, r1.z  
  21:  mad r1.y, r1.y, r1.w, cb3[0].z  
  22:  add r0.x, r0.x, l(-1.000000)  
  23:  mad r0.x, r1.y, r0.x, l(1.000000)  

Maybe this scene is not the best example because there is no detail at the distant island. Anyway, let's take a look at constant buffer which is used for adjusting ambient occlusion value:


We start by loading AO from texture, and then we perform max instruction. In this scene cb3_v1.x is really high (0.96888) which pretty makes our AO very, very subtle.

The next portion of code calculates distance between camera and pixels' world position.

I believe the code sometimes speak for itself, so see HLSL which performs most of this adjusting:
 float AdjustAmbientOcclusion(in float inputAO, in float worldToCameraDistance)  
 {  
   // *** Inputs *** //  
   const float aoDistanceStart = cb3_v0.x;  
   const float aoDistanceEnd = cb3_v0.y;  
   const float aoStrengthStart = cb3_v0.z;  
   const float aoStrengthEnd = cb3_v0.w;  
      
   // * Adjust AO  
   float aoDistanceIntensity = linstep( aoDistanceStart, aoDistanceEnd, worldToCameraDistance );  
   float aoStrength = lerp(aoStrengthStart, aoStrengthEnd, aoDistanceIntensity);   
   float adjustedAO = lerp(1.0, inputAO, aoStrength);  
     
   return adjustedAO;   
 }  

The calculated camera-world distance is used for linstep function. We know this function already - it appeared in cirrus clouds shader from The Witcher 3.

As you can see, in constant buffer we have start/end distance values for AO. The output from linstep affects strength of AO (also from cbuffer) - and strength affects output value of AO.


A quick example: A pixel is far away, let's say the distance is equal to 500.

linstep returns 1.0;
aoStrength is equal to aoStrengthEnd;
This results in returning AO which is about 77% (end strength) of input value.

The input AO for this function was previously max-ed.

Combining all this together

Once we have color and influence for both fog color and aerial color, it's time for final combine.

We start by attenuation of influence using adjusted AO:

   ...
   FogResult fog = CalculateFog( worldPos, CameraPosition, fogStart, ao, false );  
      
   // Apply AO to influence  
   fog.paramsFog.w *= ao;  
   fog.paramsAerial.w *= ao;  
      
   // Mix fog with scene color  
   outColor = ApplyFog(fog, colorHDR);  


Okay, the whole magic happens in ApplyFog function:

 float3 ApplyFog(FogResult fog, float3 color)  
 {  
   const float3 LuminanceFactors = float3(0.333f, 0.555f, 0.222f);  
   
   float3 aerialColor = dot(LuminanceFactors, color) * fog.paramsAerial.xyz;  
   color = lerp(color, aerialColor, fog.paramsAerial.w);  
   color = lerp(color, fog.paramsFog.xyz, fog.paramsFog.w);  
    
   return color.xyz;  
 }  

At first we calculate "luminance" of pixels:

Then, we multiply it with aerial color:

Then, we combine HDR color with aerial one:


The last step is combining the intermediate result with fog color:


And that's all :)

Some debug screenshots:

Aerial influence

Aerial color

Fog influence

Fog color

Final scene without fog at all

Final scene with aerial fog only

Final scene - main fog only

Final scene with all fog - again, but for easier comparison

Summary

I believe you can get the most out of it if you take a look at the shader - it's here
I'm happy to say that this shader is exactly the same as original one - which makes me really happy :)

In general, final result strongly depends of values passed to shade This is not a "magic" solution which gives perfect color output but rather requires a lot of iterations and tweaking by artists to make the final result look decent. I think this can be long process, but once you do it, the final result is really convincing - just like this sunset scene.
Sky shader from The Witcher 3 also uses these fog calculations in order to make smooth color transition around horizon. However, for sky shader a different set of density coefficients is provided.

As a reminder - most of the shader (except of adjusting AO and overriding) was not created/reversed by me. All kudos and praises go to CD PROJEKT RED. Please support them, they are doing great job.

Thanks for reading!