akinuri
akinuri

Reputation: 12027

Draw saturation/brightness gradient

I'm trying to draw the following gradient image in canvas, but there's a problem in the right bottom.

Desired effect:

enter image description here

Current output:

enter image description here

I'm probably missing something really simple here.

function color(r, g, b) {
  var args = Array.prototype.slice.call(arguments);
  if (args.length == 1) {
    args.push(args[0]);
    args.push(args[0]);
  } else if (args.length != 3 && args.length != 4) {
    return;
  }
  return "rgb(" + args.join() + ")";
}

function drawPixel(x, y, fill) {
  var fill = fill || "black";
  context.beginPath();
  context.rect(x, y, 1, 1);
  context.fillStyle = fill;
  context.fill();
  context.closePath();
}

var canvas = document.getElementById("primary");
var context = canvas.getContext("2d");

canvas.width = 256;
canvas.height = 256;

for (var x = 0; x < canvas.width; x++) {
  for (var y = 0; y < canvas.height; y++) {
    var r = 255 - y;
    var g = 255 - x - y;
    var b = 255 - x - y;
    drawPixel(x, y, color(r, g, b));
  }
}
#primary {
    display: block;
    border: 1px solid gray;
}
<canvas id="primary"></canvas>

JSFiddle

Upvotes: 4

Views: 3108

Answers (3)

Ruukas
Ruukas

Reputation: 21

I had to do this with OpenGL, and Blindman67's answer was the only resource I found. In the end, I did it by drawing 3 rectangles on top of each other.

  1. All white
  2. Transparent red to opaque red, horizontally
  3. Transparent black to opaque black, vertically

Resulting Gradient

Upvotes: 2

akinuri
akinuri

Reputation: 12027

Update: In the previous example, I've only created the gradient for red. I can also use the same method to create green and blue gradients after a little modification, but I can't use it to create gradients for random hues. Red, Green, and Blue are easy because while the one channel is 255, other two have the same value. For a random hue, e.g. 140°, that is not the case. H=140translates to rgb(0,255,85). Red and Blue can't have equal values. This requires a different and a more complicated calculation.

Blindman67's answer solves this problem. Using built-in gradients, you can easily create gradients for any random hue: jsfiddle. But being a very curious person, I wanted to do it the hard way anyway, and this is it:

(Compared to Blindman67's, it's very slow...)

JSFiddle

function drawPixel(x, y, fillArray) {
  fill = "rgb(" + fillArray.join() + ")" || "black";
  context.beginPath();
  context.rect(x, y, 1, 1);
  context.fillStyle = fill;
  context.fill();
}

var canvas = document.getElementById("primary");
var context = canvas.getContext("2d");

var grad1 = [ [255, 255, 255], [0, 0, 0] ]; // brightness

fillPrimary([255, 0, 0]); // initial hue = 0 (red)

$("#secondary").on("input", function() {
  var hue = parseInt(this.value, 10);
  var clr = hsl2rgb(hue, 100, 50);
  fillPrimary(clr);
});

function fillPrimary(rgb) {
  var grad2 = [ [255, 255, 255], rgb ]; // saturation
  for (var x = 0; x < canvas.width; x++) {
    for (var y = 0; y < canvas.height; y++) {

      var grad1Change = [
        grad1[0][0] - grad1[1][0],
        grad1[0][1] - grad1[1][1],
        grad1[0][2] - grad1[1][2],
      ];
      var currentGrad1Color = [
        grad1[0][0] - (grad1Change[0] * y / 255),
        grad1[0][1] - (grad1Change[1] * y / 255),
        grad1[0][2] - (grad1Change[2] * y / 255)
      ];

      var grad2Change = [
        grad2[0][0] - grad2[1][0],
        grad2[0][1] - grad2[1][1],
        grad2[0][2] - grad2[1][2],
      ];
      var currentGrad2Color = [
        grad2[0][0] - (grad2Change[0] * x / 255),
        grad2[0][1] - (grad2Change[1] * x / 255),
        grad2[0][2] - (grad2Change[2] * x / 255)
      ];

      var multiplied = [
        Math.floor(currentGrad1Color[0] * currentGrad2Color[0] / 255),
        Math.floor(currentGrad1Color[1] * currentGrad2Color[1] / 255),
        Math.floor(currentGrad1Color[2] * currentGrad2Color[2] / 255),
      ];

      drawPixel(x, y, multiplied);
    }
  }
}

function hsl2rgb(h, s, l) {
  h /= 360;
  s /= 100;
  l /= 100;
  var r, g, b;
  if (s == 0) {
    r = g = b = l;
  } else {
    var hue2rgb = function hue2rgb(p, q, t) {
      if (t < 0) t += 1;
      if (t > 1) t -= 1;
      if (t < 1 / 6) return p + (q - p) * 6 * t;
      if (t < 1 / 2) return q;
      if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
      return p;
    }
    var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    var p = 2 * l - q;
    r = hue2rgb(p, q, h + 1 / 3);
    g = hue2rgb(p, q, h);
    b = hue2rgb(p, q, h - 1 / 3);
  }
  return [
    Math.round(r * 255),
    Math.round(g * 255),
    Math.round(b * 255),
  ];
}
#primary {
  display: block;
  border: 1px solid gray;
}
#secondary {
  width: 256px;
  height: 15px;
  margin-top: 15px;
  outline: 0;
  display: block;
  border: 1px solid gray;
  box-sizing: border-box;
  -webkit-appearance: none;
  background-image: linear-gradient(to right, red 0%, yellow 16.66%, lime 33.33%, cyan 50%, blue 66.66%, violet 83.33%, red 100%);
}
#secondary::-webkit-slider-thumb {
  -webkit-appearance: none;
  height: 25px;
  width: 10px;
  border-radius: 10px;
  background-color: rgb(230, 230, 230);
  border: 1px solid gray;
  box-shadow: inset 0 0 2px rgba(255, 255, 255, 1), 0 0 2px rgba(255, 255, 255, 1);
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<canvas id="primary" width="256" height="256"></canvas>
<input type="range" min="0" max="360" step="1" value="0" id="secondary" />


Okay, so I've figured out what the problem is. While the vertical range is always between [0,255], horizontal range is between [0,r]. So g and b can't be greater than r (Duh!).

enter image description here

function color(r, g, b) {
  var args = Array.prototype.slice.call(arguments);
  if (args.length == 1) {
    args.push(args[0]);
    args.push(args[0]);
  } else if (args.length != 3 && args.length != 4) {
    return;
  }
  return "rgb(" + args.join() + ")";
}

function drawPixel(x, y, fill) {
  var fill = fill || "black";
  context.beginPath();
  context.rect(x, y, 1, 1);
  context.fillStyle = fill;
  context.fill();
  context.closePath();
}

var canvas = document.getElementById("primary");
var context = canvas.getContext("2d");

canvas.width = 256;
canvas.height = 256;

for (var x = 0; x < canvas.width; x++) {
  for (var y = 0; y < canvas.height; y++) {
    var r = 255 - y;
    var g = b = r - Math.floor((x / 255) * r); // tada!
    drawPixel(x, y, color(r, g, b));
  }
}
#primary {
    display: block;
    border: 1px solid gray;
}
<canvas id="primary"></canvas>

Upvotes: 1

Blindman67
Blindman67

Reputation: 54089

Using gradients.

You can get the GPU to do most of the processing for you.The 2D composite operation multiply effectively multiplies two colours for each pixel. So for each channel and each pixel colChanDest = Math.floor(colChanDest * (colChanSrc / 255)) is done via the massively parallel processing power of the GPU, rather than a lowly shared thread running on a single core (JavaScript execution context).

The two gradients

  1. One is the background White to black from top to bottom

    var gradB = ctx.createLinearGradient(0,0,0,255); gradB.addColorStop(0,"white"); gradB.addColorStop(1,"black");

  2. The other is the Hue that fades from transparent to opaque from left to right

    var swatchHue var col = "rgba(0,0,0,0)" var gradC = ctx.createLinearGradient(0,0,255,0); gradC.addColorStop(0,``hsla(${hueValue},100%,50%,0)``); gradC.addColorStop(1,``hsla(${hueValue},100%,50%,1)``);

Note the above strings quote are not rendering correctly on SO so I just doubled them to show, use a single quote as done in the demo snippet.

Rendering

Then layer the two, background (gray scale) first, then with composite operation "multiply"

ctx.fillStyle = gradB;
ctx.fillRect(0,0,255,255);
ctx.fillStyle = gradC;
ctx.globalCompositeOperation = "multiply";
ctx.fillRect(0,0,255,255);
ctx.globalCompositeOperation = "source-over";

Only works for Hue

It is important that the color (hue) is a pure colour value, you can not use a random rgb value. If you have a selected rgb value you need to extract the hue value from the rgb.

The following function will convert a RGB value to a HSL colour

function rgbToLSH(red, green, blue, result = {}){
    value hue, sat, lum, min, max, dif, r, g, b;
    r = red/255;
    g = green/255;
    b = blue/255;
    min = Math.min(r,g,b);
    max = Math.max(r,g,b);
    lum = (min+max)/2;
    if(min === max){
        hue = 0;
        sat = 0;
    }else{
        dif = max - min;
        sat = lum > 0.5 ? dif / (2 - max - min) : dif / (max + min);
        switch (max) {
        case r:
            hue = (g - b) / dif;
            break;
        case g:
            hue = 2 + ((b - r) / dif);
            break;
        case b:
            hue = 4 + ((r - g) / dif);
            break;
        }
        hue *= 60;
        if (hue < 0) {
            hue += 360;
        }        
    }
    result.lum = lum * 255;
    result.sat = sat * 255;
    result.hue = hue;
    return result;
}

Put it all together

The example renders a swatch for a random red, green, blue value every 3 second.

Note that this example uses Balel so that it will work on IE

var canvas = document.createElement("canvas");
canvas.width = canvas.height = 255;
var ctx = canvas.getContext("2d");
document.body.appendChild(canvas);

function drawSwatch(r, g, b) {
  var col = rgbToLSH(r, g, b);
  var gradB = ctx.createLinearGradient(0, 0, 0, 255);
  gradB.addColorStop(0, "white");
  gradB.addColorStop(1, "black");
  var gradC = ctx.createLinearGradient(0, 0, 255, 0);
  gradC.addColorStop(0, `hsla(${Math.floor(col.hue)},100%,50%,0)`);
  gradC.addColorStop(1, `hsla(${Math.floor(col.hue)},100%,50%,1)`);

  ctx.fillStyle = gradB;
  ctx.fillRect(0, 0, 255, 255);
  ctx.fillStyle = gradC;
  ctx.globalCompositeOperation = "multiply";
  ctx.fillRect(0, 0, 255, 255);
  ctx.globalCompositeOperation = "source-over";
}

function rgbToLSH(red, green, blue, result = {}) {
  var hue, sat, lum, min, max, dif, r, g, b;
  r = red / 255;
  g = green / 255;
  b = blue / 255;
  min = Math.min(r, g, b);
  max = Math.max(r, g, b);
  lum = (min + max) / 2;
  if (min === max) {
    hue = 0;
    sat = 0;
  } else {
    dif = max - min;
    sat = lum > 0.5 ? dif / (2 - max - min) : dif / (max + min);
    switch (max) {
      case r:
        hue = (g - b) / dif;
        break;
      case g:
        hue = 2 + ((b - r) / dif);
        break;
      case b:
        hue = 4 + ((r - g) / dif);
        break;
    }
    hue *= 60;
    if (hue < 0) {
      hue += 360;
    }
  }
  result.lum = lum * 255;
  result.sat = sat * 255;
  result.hue = hue;
  return result;
}

function drawRandomSwatch() {
  drawSwatch(Math.random() * 255, Math.random() * 255, Math.random() * 255);
  setTimeout(drawRandomSwatch, 3000);
}
drawRandomSwatch();

To calculate the colour from the x and y coordinates you need the calculated Hue then the saturation and value to get the hsv colour (NOTE hsl and hsv are different colour models)

// saturation and value are clamped to prevent rounding errors creating wrong colour
var rgbArray = hsv_to_rgb(
    hue, // as used to create the swatch
    Math.max(0, Math.min(1, x / 255)),   
    Math.max(0, Math.min(1, 1 - y / 255))
);

Function to get r,g,b values for h,s,v colour.

/* Function taken from datGUI.js 
   Web site https://workshop.chromeexperiments.com/examples/gui/#1--Basic-Usage
   // h 0-360, s 0-1, and v 0-1
*/

function hsv_to_rgb(h, s, v) {
  var hi = Math.floor(h / 60) % 6;
  var f = h / 60 - Math.floor(h / 60);
  var p = v * (1.0 - s);
  var q = v * (1.0 - f * s);
  var t = v * (1.0 - (1.0 - f) * s);
  var c = [
    [v, t, p],
    [q, v, p],
    [p, v, t],
    [p, q, v],
    [t, p, v],
    [v, p, q]
  ][hi];
  return {
    r: c[0] * 255,
    g: c[1] * 255,
    b: c[2] * 255
  };
}

Upvotes: 7

Related Questions