Sunday, 4 November 2018

RGB/HSV in HLSL 8

Andrew (K-Be) emailed me this week with a problem he found in my HLSL code for HCL-to-RGB conversion. Here's the original code:

  float HCLgamma = 3;
  float HCLy0 = 100;
  float HCLmaxL = 0.530454533953517; // == exp(HCLgamma / HCLy0) - 0.5
  float PI = 3.1415926536;
 
  float3 HCLtoRGB(in float3 HCL)
  {
    float3 RGB = 0;
    if (HCL.z != 0)
    {
      float H = HCL.x;
      float C = HCL.y;
      float L = HCL.z * HCLmaxL;
      float Q = exp((1 - C / (2 * L)) * (HCLgamma / HCLy0));
      float U = (2 * L - C) / (2 * Q - 1);
      float V = C / Q;
      // PROBLEM HERE...
      float T = tan((H + min(frac(2 * H) / 4, frac(-2 * H) / 8)) * PI * 2);
      H *= 6;
      if (H <= 1)
      {
        RGB.r = 1;
        RGB.g = T / (1 + T);
      }
      else if (H <= 2)
      {
        RGB.r = (1 + T) / T;
        RGB.g = 1;
      }
      else if (H <= 3)
      {
        RGB.g = 1;
        RGB.b = 1 + T;
      }
      else if (H <= 4)
      {
        RGB.g = 1 / (1 + T);
        RGB.b = 1;
      }
      else if (H <= 5)
      {
        RGB.r = -1 / T;
        RGB.b = 1;
      }
      else
      {
        RGB.r = 1;
        RGB.b = -T;
      }
      RGB = RGB * V + U;
    }
    return RGB;
  }

Note the calculation of 'T'. Let's split that expression in two:

      float A = H + min(frac(2 * H) / 4, frac(-2 * H) / 8);
      float T = tan(A * PI * 2);

We can see that 'T' will tend to infinity when 'A' approaches 0.25 or 0.75. A bit of careful graphing of 'H' against 'A' suggests that this only occurs when the input hue approaches 1/6 or 2/3 respectively, so we can put extra checks in the sextant clauses:

      float A = (H + min(frac(2 * H) / 4, frac(-2 * H) / 8)) * PI * 2;
      float T;
      H *= 6;
      if (H <= 0.999)
      {
        T = tan(A);
        RGB.r = 1;
        RGB.g = T / (1 + T);
      }
      else if (H <= 1.001)
      {
        RGB.r = 1;
        RGB.g = 1;
      }
      else if (H <= 2)
      {
        T = tan(A);
        RGB.r = (1 + T) / T;
        RGB.g = 1;
      }
      else if (H <= 3)
      {
        T = tan(A);
        RGB.g = 1;
        RGB.b = 1 + T;
      }
      else if (H <= 3.999)
      {
        T = tan(A);
        RGB.g = 1 / (1 + T);
        RGB.b = 1;
      }
      else if (H <= 4.001)
      {
        RGB.g = 0;
        RGB.b = 1;
      }
      else if (H <= 5)
      {
        T = tan(A);
        RGB.r = -1 / T;
        RGB.b = 1;
      }
      else
      {
        T = tan(A);
        RGB.r = 1;
        RGB.b = -T;
      }

Of course, if you're confident that your platform won't throw too much of a wobbly when computing 'tan' of half pi et al, you can hoist the calculation of 'T' to its declaration before the 'if' clauses. You never know your luck: your shader compiler might to that for you!

Having said all that, the more I read about the HCL colour space, the less I'm convinced it's actually worthwhile.