Source code of plot #061 back to plot

Download full working sketch as 061.tar.gz.
Unzip, then start a local web server and load the page in a browser.

import * as E from "./lib/env.js"
import {caption} from "./lib/own/caption.js"
import {mulberry32, setRandomGenerator, rand, randn_bm, rand_select} from "./lib/own/random.js"
import * as G from "./lib/own/geo2.js"
import {kdTree} from "./lib/thirdparty/kdTree.js"

// Declarations below instruct build plugin to copy static files to runtime dir
// STATIC lib/texture.png

const pw = 1480;    // Paper width
const ph = 2100;    // Paper height
const w = 1050;     // Drawing width
const h = 1480;     // Drawing height

let seed = Math.round(Math.random() * 65535);
seed = 13878;

void setup();

async function setup() {
  E.initEnv(w, h, pw, ph);
  console.log(`Seed: ${seed}`);
  E.info("Seed: " + seed);
  setRandomGenerator(mulberry32(seed));

  const startTime = performance.now();
  await E.spin();
  await draw();
  const elapsed = performance.now() - startTime;
  console.log(`Drawn in ${elapsed} msec`);
}

async function draw() {

  caption((w+pw)/2, (h+ph)/2, "spiraline", "116a", seed);

  const kdt = new kdTree([], (a, b) => Math.sqrt((a.x-b.x)**2 + (a.y-b.y)**2), ["x", "y"]);
  const proj = new PerspectiveProjector(Math.PI * 0.2, new G.Vec3(0, 0, 12), w, h);

  // Technical params for generation/rendering
  const nSpiralSegs = 500;
  const minSelfOcclusionDist = 50;
  const kdtNearestMaxNodes = 500;
  const occlusionBatchSize = 5;
  const sparseSegLen = 2;
  const minPathLength = 8;

  // Composition params
  const nSpirals = 200;
  const spiralHeight = 5;
  const avgRadius = 2.5;
  const radVar = 5;
  const climbs = [0.5, 1];
  const occlusionRadius = 6;

  // Generate spirals
  const spirals = [];
  for (let i = 0; i < nSpirals; ++i) {
    const angle = 2 * Math.PI * rand();
    const btmRad = randn_bm(avgRadius - radVar, avgRadius + radVar);
    const climb = rand_select(climbs);
    const pts3D = getSpiralPoints(angle, btmRad, spiralHeight, climb, nSpiralSegs);
    const ptsProj = pts3D.map(pt => proj.project(pt));
    const spiral = {pts3D, ptsProj};
    spirals.push(spiral);
    for (let j = 0; j < ptsProj.length; ++j) {
      const pt = ptsProj[j];
      pt.spiral = spiral;
      pt.ixInSpiral = j;
      kdt.insert(pt);
    }
  }

  // Render with occlusion
  for (let i = 0; i < spirals.length; ++i) {
    if ((i%occlusionBatchSize) == 0) await E.spin();
    const spiral = spirals[i];
    let visiblePath = [];
    let currLen = 0;
    for (const pt of spiral.ptsProj) {
      let hasOccluder = false;
      const neighbors = kdt.nearest(pt, kdtNearestMaxNodes, occlusionRadius);
      for (const nb of neighbors) {
        const npt = nb[0];
        if (npt.spiral === spiral && Math.abs(npt.ixInSpiral-pt.ixInSpiral) < minSelfOcclusionDist)
          continue;
        if (nb[0].z < pt.z) {
          hasOccluder = true;
          break;
        }
      }
      if (hasOccluder) {
        // TODO: integrate actual path length
        if (currLen > minPathLength) makeSparseAndAdd(visiblePath);
        visiblePath = [];
        currLen = 0;
      }
      else {
        if (visiblePath.length > 0) currLen += G.dist2(visiblePath[visiblePath.length-1], pt);
        visiblePath.push(pt);
      }
    }
    if (currLen > minPathLength) makeSparseAndAdd(visiblePath);
  }

  function makeSparseAndAdd(pts) {
    const pts2 = sparser(pts, sparseSegLen);
    E.addPath(pts2);
  }
}

function getSpiralPoints(btmAngle, btmRadius, height, climb, nSegs) {
  const points = [];
  for (let i = 0; i <= nSegs; ++i) {
    const t = i / nSegs; // [0, 1]
    // If climb is 1, two full twists. More climb means less twists.
    const twistAngle = t * 2 * Math.PI / climb;
    const x = btmRadius * Math.sin(btmAngle + twistAngle);
    const z = btmRadius * Math.cos(btmAngle + twistAngle);
    const y = (t - 0.5) * height;
    points.push(new G.Vec3(x, y, z));
  }
  return points;
}

function sparser(pts, minLen) {
  const res = [pts[0]];
  let travel = 0;
  for (let i = 1; i < pts.length; ++i) {
    travel += G.dist2(pts[i], pts[i-1]);
    if (travel >= minLen || i == pts.length - 1) {
      res.push(pts[i]);
      travel = 0;
    }
  }
  return res;
}


class PerspectiveProjector {

  /**
   * Initializes a new projector.
   * @param {Number} fov Field of view
   * @param {G.Vec3} camPos The camera's position. Camera always points at origin.
   * @param {Number} canvasWidth Projection canvas width.
   * @param {Number} canvasHeight Projection canvas height.
   */
  constructor(fov, camPos, canvasWidth, canvasHeight) {
    this.fov = fov;
    this.fovFactor = Math.tan(fov / 2);
    this.camPos = camPos;
    this.canvasWidth = canvasWidth;
    this.canvasHeight = canvasHeight;
    this.aspect = canvasWidth / canvasHeight;
  }

  /**
   * Projects a 3D point onto the canvas.
   * @param {G.Vec3} pt The point to project.
   * @param {G.Vec3} res The vector to receive the result. X and Y are canvas coordinates; Z is pt's distance from the camera.
   * @returns {G.Vec3} The vector that was passed in as 'res', updated with the projection.
   */
  project(pt, res = new G.Vec3()) {
    const distance = Math.sqrt(
      (pt.x - this.camPos.x) ** 2 +
      (pt.y - this.camPos.y) ** 2 +
      (pt.z - this.camPos.z) ** 2
    );
    const x = (pt.x - this.camPos.x) / (pt.z - this.camPos.z) / this.fovFactor * this.aspect;
    const y = (pt.y - this.camPos.y) / (pt.z - this.camPos.z) / this.fovFactor;
    res.set((x + 1) * this.canvasWidth / 2, (1 - y) * this.canvasHeight / 2, distance);
    return res;
  }
}