Better Know Canvas

Substrate Bezier

by Fernando Serboncini
January 2022

Our next effect is a general Substrate variant. The idea is to build an initial bezier path that goes across the screen, and then let the substrate algorithm run on top of that. We also want to generate more random palettes.

The substrate Cracks are supposed to leave perpendicular to the bezier curve, so we are both going to render the bezier curve on the main ctx, but also set up grid points with the correct angle.

Setup

We start where we left off last time.

const {gridRaystep, normal, Color} =
  await import("https://canvas.rocks/js/extend.js");

const ctx = canvas.getContext("2d");
const W = canvas.width = 1920;
const H = canvas.height = 1080;

const EMPTY = Infinity;
const INVALID = null;

class Substrate {
  constructor(ctx) {
    this.ctx = ctx;
    this.angleVariance = 0.025;
    this.maxActiveCracks = 128;
    this.totalCracks = 0;
    this.maxTotalCracks = 12000;
    this.cracks = [];
    this.width = this.ctx.canvas.width;
    this.height = this.ctx.canvas.height;

    this.grid = new Array(this.width * this.height);
    for (let i = 0; i < this.width * this.height; ++i) {
      this.grid[i] = EMPTY;
    }

    this.colors = null;
    this.lineColor = '#000000';
  }

  clear(bgColor) {
    this.ctx.reset();
    this.ctx.fillStyle = bgColor;
    this.ctx.fillRect(0, 0, this.width, this.height);
  }

  begin({random = 0, start = 0, mask = null}) {
    if (mask !== null) {
      const add = mask.applyMask(this);
      for (const a of add) {
        this.cracks.push(new Crack(this, ...a));
        this.totalCracks++;
      }
    }

    let k = 0;
    while (k < random) {
      const x = Math.random() * this.width;
      const y = Math.random() * this.height;
      if (this.get(x, y) !== EMPTY) continue;

      this.set(x, y, Math.random() * Math.TAU);
      k++;
    }

    for (let k = 0; k < start; ++k) {
      this.newCrack();
    }
  }

  newCrack() {
    if (this.cracks.length >= this.maxActiveCracks) return;
    if (this.maxTotalCracks > 0 && this.totalCracks >= this.maxTotalCracks) {
      return;
    }

    let x = 0;
    let y = 0;

    let found = false;
    for (let i = 0; i < this.width * this.height; ++i) {
      x = Math.random() * this.width;
      y = Math.random() * this.height;
      const p = this.get(x, y);
      if (p != EMPTY && p != INVALID) {
        found = true;
        break;
      }
    }
    if (!found) return;

    const dir = Math.sign(Math.random() - 0.5);
    const variance = this.angleVariance * normal();
    const angle = this.get(x, y) + dir * ((Math.TAU / 4) + variance);

    this.cracks.push(new Crack(this, x, y, angle));
    this.totalCracks++;
  }

  update() {
    this.cracks.filterIn(c => {
      if (!c.move()) {
        this.newCrack();
        this.newCrack();
        return false;
      }
      return true;
    });
    return this.cracks.length > 0;
  }

  getColor() {
    if (this.colors === null) return null;
    return this.colors[Math.floor(Math.random() * this.colors.length)];
  }

  get(x, y) {
    x = Math.floor(x);
    y = Math.floor(y);
    if (x < 0 || x >= this.width || y < 0 || y >= this.height) return INVALID;
    return this.grid[x + y * this.width];
  }

  set(x, y, v) {
    x = Math.floor(x);
    y = Math.floor(y);
    if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
    this.grid[x + y * this.width] = v;
  }
}

class Crack {
  constructor(ss, x, y, angle) {
    this.ss = ss;
    this.angle = angle;
    this.pos = gridRaystep({x, y}, this.angle);
    if (this.ss.get(this.pos.x, this.pos.y) === INVALID) {
      this.pos = null;
    }
    this.mod = 0.5 * Math.random();
    this.color = this.ss.getColor();
  }

  move() {
    if (this.pos === null) return false;
    const oldpos = this.pos;
    this.pos = gridRaystep(oldpos, this.angle);

    if (this.color !== null) {
      this.paintRegion();
    }

    for (let i = 0 ; i < 2; ++i) {
      this.ss.ctx.fillStyle = this.ss.lineColor;
      this.ss.ctx.fillRect(
        this.pos.x + 0.33 * normal(),
        this.pos.y + 0.33 * normal(),
        1, 1);
    }

    const delta = {
      x: Math.floor(this.pos.x) - Math.floor(oldpos.x),
      y: Math.floor(this.pos.y) - Math.floor(oldpos.y),
    };
    for (let dx = 0; dx <= Math.abs(delta.x); ++dx) {
      for (let dy = 0; dy <= Math.abs(delta.y); ++dy) {
        const v = this.ss.get(
          oldpos.x + Math.sign(delta.x) * dx,
          oldpos.y + Math.sign(delta.y) * dy);
        if (v === INVALID || (v !== EMPTY && v != this.angle)) return false;
      }
    }

    this.ss.set(this.pos.x, this.pos.y, this.angle);
    return true;
  }

  paintRegion() {
    let r = {...this.pos};
    while (true) {
      r = gridRaystep(r, this.angle + Math.TAU / 4);
      const v = this.ss.get(r.x, r.y);
      if (v === INVALID || v != EMPTY) break;
    }

    this.mod = Math.clamp(this.mod + 0.05 * normal(), 0, 1.0);

    const t = {
      x: this.pos.x + (r.x - this.pos.x) * this.mod,
      y: this.pos.y + (r.y - this.pos.y) * this.mod
    };

    const grad = this.ss.ctx.createLinearGradient(
      this.pos.x, this.pos.y, t.x, t.y);
    const S = 5;
    for (let i = 0; i < S; ++i) {
      const f = i / (S - 1);
      const a = 0.25 * ((1 - f) ** 0.25);
      grad.addColorStop(f, this.color.alpha(a));
    }
    this.ss.ctx.strokeStyle = grad;

    this.ss.ctx.lineWidth = 2;
    this.ss.ctx.beginPath();
    this.ss.ctx.moveTo(this.pos.x, this.pos.y);
    this.ss.ctx.lineTo(t.x, t.y);
    this.ss.ctx.stroke();
  }
}

class Mask {
  constructor(ctx) {
    this.octx = ctx;
    this.width = ctx.canvas.width;
    this.height = ctx.canvas.height;
    this.ofc = new OffscreenCanvas(this.width, this.height);
    this.ctx = this.ofc.getContext("2d");
    this.ctx.fillStyle = "#000";
    this.ctx.fillRect(0, 0, this.width, this.height);
    this.ctx.fillStyle = "#FFF";

    this.toAdd = [];
  }

  applyMask(ss) {
    const im = this.ctx.getImageData(0, 0, this.width, this.height).data;

    for (let y = 0; y < this.height; ++y) {
      for (let x = 0; x < this.width; ++x) {
        const p = (x + y * this.width) * 4;
        const c = im[p];
        if (c <= 5) {
          ss.set(x, y, INVALID);
        } else if (c >= 250) {
          ss.set(x, y, EMPTY);
        } else if (c >= 10 && c <= 245) {
          ss.set(x, y, Math.TAU * (c - 10) / 235);
        }
      }
    }

    return this.toAdd;
  }

  line(x0, y0, x1, y1) {
    const ang = Math.atan2(y1 - y0, x1 - x0);
    ctx.beginPath();
    ctx.moveTo(x0, y0);
    ctx.lineTo(x1, y1);
    ctx.stroke();
    this.toAdd.push([x0, y0, ang]);
  }

  poly(...points) {
    this.ctx.fillStyle = "#FFF";
    this.ctx.strokeStyle = '#FFF';
    this.ctx.beginPath();
    for (let i = 0; i < points.length; i += 2) {
      const n = (i + 2) % points.length;
      this.ctx.lineTo(points[i], points[i+1]);
      this.line(points[i], points[i+1], points[n], points[n+1]);
    }
    this.ctx.closePath();
    this.ctx.fill();
    this.ctx.stroke();
  }
}

function basicEffect() {
  const ss = new Substrate(ctx);
  ss.clear('#FFFFFF');
  const colors = Color('#000000').steps(256, '#FFFF00');
  for (let i = 0; i < colors.length; ++i) {
    const f = i / (colors.length - 1);
    colors[i] = colors[i].luminance(f ** 1.2)
      .saturate(2 * Math.abs(Math.sin(f * 7)))
      .rotate(-40 + 50 * (Math.cos(f * 5)))
      .multiply(Color('#FFFF0050'))
  }
  ss.colors = colors;
  ss.lineColor = '#3B2618';

  ss.begin();
  return () => ss.update();
}

function prism() {
  const ss = new Substrate(ctx);
  const colors = Color('#000000').steps(16, '#00AAFF');
  for (let i = 0; i < colors.length; ++i) {
    const f = i / (colors.length - 1);
    colors[i] = colors[i]
      .luminance((f * 0.9) ** 1.2)
      .saturate(4 * Math.abs(Math.sin(f * 7)))
      .multiply(Color('#FF00FF40'))
  }
  ss.colors = colors;
  ss.clear('#FFFFFF');
  ss.lineColor = '#323E51AA';
  ss.maxTotalCracks = 4000;
  ss.maxActiveCracks = 64;

  ctx.strokeStyle = '#323E51';
  ctx.lineCap = "round";
  ctx.lineWidth = 4;

  const m = new Mask(ctx);
  m.poly(960, 88, 1177, 273, 1177, 549, 1177, 825,
    960, 993, 742, 825, 742, 549, 742, 273);
  ss.begin({mask: m});

  return () => ss.update();
}

const update = prism();

function frame() {
  if (update()) {
    requestAnimationFrame(frame);
  }
}
frame();

And create a new effect.

function bezierPath() {
  const ss = new Substrate(ctx);

  return () => ss.update();
}

const update = bezierPath();

We are also going to need a function to calculate the bezier curve. This is also one of those generic functions that could use its own tutorial someday. For now, we are simply going to import it from our support library.

What we need to know it: this bezier(t, coefs) function evaluates a 1D bezier curve with coefficients coefs at point t.

const {gridRaystep, normal, Color, bezier} =
  await import("https://canvas.rocks/js/extend.js");

Bezier path

We start by setting up the basic parameters of the effect.

  ss.maxTotalCracks = 10000;
  ss.maxActiveCracks = 400;
  ss.angleVariance = 0;

The first thing we will do is generate a color palette for the effect. This time, we are also going to random select hues.

We pick a random hue color and then choose differents levels of luminance for the gradient (from 0.05 to 0.8) and the lines (0.025). For the background with choose a very bright (0.9) complement color.

  const col = Color(`hsv(${Math.random() * 360}, 50, 75)`);
  ss.colors = Color(col.luminance(0.05)).steps(32, col.luminance(0.8));
  ss.lineColor = ctx.strokeStyle = col.luminance(0.025);
  ss.clear(col.makeComplement().luminance(0.9));

For the control points of our bezier curve, we want some randomness, but we also want curves that extend through the whole screen. For this, we split the screen in 4 columns (at point 240, 960, 1680, and 1920) and make sure that each of the 4 points stay inside those columns.

The 1st and 4th points are actually part of the curve, but the 2nd and 3rd are simply control points (they define the initial and end slope), so we allow them to be outside the screen on the y axis.

  const c = [
    [240 * Math.random(), Math.random() * H],
    [240 + 720 * Math.random(), H * (-0.25 + 1.5 * Math.random())],
    [960 + 720 * Math.random(), H * (-0.25 + 1.5 * Math.random())],
    [1680 + 240 * Math.random(), Math.random() * H]];

The first thing we need to do is find a bunch of points inside the bezier curve. The number of sample points is arbirtrary, but it must be big enough not only to have a nice curve on screen, but also to have enough sample points on the grid.

Also, remember that our bezier() function is 1D, so we need to calculate it separate for the x and y axis.

  const points = [];
  const TOTAL = 40000;
  for (let t = 0; t < TOTAL; ++t) {
    const f = t / TOTAL;
    const x = bezier(f, [c[0][0], c[1][0], c[2][0], c[3][0]]);
    const y = bezier(f, [c[0][1], c[1][1], c[2][1], c[3][1]]);
  }

Because we are going to both render and use them to set up the angle of Cracks, we need to calculate the derivative at each point as well. The derivative of a Bezier curve with control points is at that point.


    const dx = 4 * (bezier(f, [c[1][0], c[2][0], c[3][0]]) -
      bezier(f, [c[0][0], c[1][0], c[2][0]]));
    const dy = 4 * (bezier(f, [c[1][1], c[2][1], c[3][1]]) -
      bezier(f, [c[0][1], c[1][1], c[2][1]]));

    points.push([x, y, dx, dy]);

We need to render the curve on the main ctx, this is simply connecting the points we already calculated.

  ctx.save();
  ctx.strokeStyle = "#000";
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (const p of points) {
    ctx.lineTo(p[0], p[1]);
  }
  ctx.stroke();
  ctx.restore();

Finally, we need to build a mask with the correct angles over the bezier curve. First, let’s create an empty Mask where all the points are valid.

  const m = new Mask(ctx);
  m.ctx.fillRect(0, 0, W, H);

  ss.begin({mask: m, start: 32});

For each point, we calculate the angle based on the derivative and store it as the red channel in the range .


  for (const p of points) {
    const ang = (Math.atan2(p[3], p[2]) + Math.TAU) % Math.TAU;
    const r = Math.round(235 * (ang / Math.TAU) + 10);
    m.ctx.fillStyle = `rgb(${r}, 0, 0)`;
    m.ctx.fillRect(Math.floor(p[0]), Math.floor(p[1]), 1, 1);
  }

And that’s it! Up next, projections.