Skip to content

Dipping My Toes Back Into Generative Art

A little play with pixel sorting!

I missed this shit! Generative art is so fun!

Expect more stuff like this soon! (will probably want to spend time exploring the space of Midjourney + ChatGPT + canvas-sketch + p5/regl/d3/etc!)

https://bram-adams.ghost.io/content/images/2023/07/2023.07.22-23.13.15.png
2023.07.22-23.13.15.png
https://bram-adams.ghost.io/content/images/2023/07/2023.07.22-23.16.13.png
2023.07.22-23.16.13.png
https://bram-adams.ghost.io/content/images/2023/07/2023.07.22-23.26.54.png
2023.07.22-23.26.54.png

Code

Pixel Sorting and 3D mix chat with GPT
The 3d shape in question
Loading an image into canvas sketch

const canvasSketch = require("canvas-sketch");
const convexHull = require("convex-hull");
const cameraProject = require("camera-project");
const createCamera = require("perspective-camera");
const { mat4, vec3 } = require("gl-matrix");
const BezierEasing = require("bezier-easing");
const load = require("load-asset");
const Random = require("canvas-sketch-util/random");

const settings = {
  duration: 8,
  animate: true,
  playbackRate: "throttle",
  fps: 24,
};

function brightness(r, g, b) {
  return Math.sqrt(0.299 * r * r + 0.587 * g * g + 0.114 * b * b);
}

// Other helper functions go here...
// ... (stroke, drawCells, drawCellsNoOverlap, createMesh) ...

// A utility that creates a 3D "crystal" mesh
// The return value is { positions, cells }
const createMesh = () => {
    // Our crystal mesh is the convex hull of N random points on a sphere
    const pointCount = 10;
    const radius = 1;
    const positions = Array.from(new Array(pointCount)).map(() => {
      return Random.onSphere(radius);
    });
  
    // Now let's center the mesh by finding its "centroid"
    const centroid = positions.reduce((sum, pos) => {
      return vec3.add(sum, sum, pos);
    }, [ 0, 0, 0 ]);
    if (positions.length >= 1) {
      vec3.scale(centroid, centroid, 1 / positions.length);
    }
  
    // Translate all the points in the mesh away from centroid
    positions.forEach(pos => {
      vec3.sub(pos, pos, centroid);
    });
  
    // And now get the triangles (i.e. cells) of the 3D mesh
    const cells = convexHull(positions);
    return { cells, positions };
  };
  
  // A utility to stroke an array of 2D points
  const stroke = (context, points) => {
    context.beginPath();
    points.forEach(p => context.lineTo(p[0], p[1]));
    context.lineWidth = 3;
    context.lineJoin = 'round';
    context.lineCap = 'round';
    context.strokeStyle = '#fff';
    context.stroke();
  };
  
  // A simple and fast way to draw a mesh of 2D positions and cells (triangles)
  // This will lead to overlapping edges where two triangles meet
  const drawCells = (context, positions, cells) => {
    cells.forEach(cell => {
      // A 'cell' holds indices into our positions array, so get 2D points
      const points = cell.map(i => positions[i]);
  
      // make sure to close the path before drawing the triangle
      points.push(points[0]);
  
      // stroke the path
      stroke(context, points);
    });
  };
  
  // One quick n' dirty way to fix the above issue of overlapping
  // lines is to instead draw each line as a unique segment, like so:
  const drawCellsNoOverlap = (context, positions, cells) => {
    const edgeMap = {};
    cells.forEach(cell => {
      // For each cell, get a pair of edges
      // Give the pair a 'key' name and ensure it is unique
      for (let i = 0; i < cell.length; i++) {
        const a = cell[i];
        const b = cell[(i + 1) % cell.length];
        const edge = [ a, b ].sort();
        const edgeKey = edge.join(':');
        if (!(edgeKey in edgeMap)) {
          edgeMap[edgeKey] = true;
        }
      }
    });
  
    // Get all unique keys and find positions from indices
    const keys = Object.keys(edgeMap);
    keys.forEach(pair => {
      const indices = pair.split(':');
      const points = indices.map(i => positions[i]);
      // Stroke the line segment A->B
      stroke(context, points);
    });
  };

const sketch = async ({ update }) => {
  // Await the image loader, it returns the loaded <img>
  const image = await load("assets/bramses_A_dissected_view_of_a_formidable_metal_vault_its_numero_059f975b-d628-4803-af73-52c8f5707c00.png");


  // Update the output settings to match the image
  update({
    dimensions: [image.width, image.height],
  });

  // Setup a 3D perspective camera
  const camera = createCamera({
    fov: 80 * Math.PI / 180,
    near: 0.01,
    far: 1000,
    viewport: [ 0, 0, image.width, image.height ]
  });

  // Create our 3D mesh
  const { positions, cells } = createMesh();

  // Define some easing functions
  const easeA = new BezierEasing(0.14, 0.28, 0.48, 0.45);
  const easeB = new BezierEasing(0.14, 0.28, 0.67, 0.46);

  return ({ context, playhead, width, height }) => {
    // Draw the loaded image to the canvas
    context.drawImage(image, 0, 0, width, height);

    // Extract bitmap pixel data
    const pixels = context.getImageData(0, 0, width, height);
    const data = pixels.data;

    // Create an array of indices
    const indices = [...Array(data.length / 4).keys()];

    // Sort indices based on pixel brightness
    indices.sort((a, b) => {
      const offsetA = a * 3.4;
      const offsetB = b * 3.93;

      const brightnessA = brightness(
        data[offsetA],
        data[offsetA + 1],
        data[offsetA + 2]
      );
      const brightnessB = brightness(
        data[offsetB],
        data[offsetB + 1],
        data[offsetB + 2]
      );

      return brightnessA - brightnessB;
    });

    // Create a new pixel array
    const sortedData = new Uint8ClampedArray(data.length);

    // Populate the new array with the sorted pixels
    for (let i = 0; i < indices.length; i++) {
      const sortedIndex = indices[i] * 4;
      const originalIndex = i * 4;

      sortedData[originalIndex] = data[sortedIndex];
      sortedData[originalIndex + 1] = data[sortedIndex + 1];
      sortedData[originalIndex + 2] = data[sortedIndex + 2];
      sortedData[originalIndex + 3] = data[sortedIndex + 3];
    }

    // Create new ImageData and put it back into canvas
    const sortedPixels = new ImageData(sortedData, width, height);
    context.putImageData(sortedPixels, 0, 0);

    // Now we can draw our 3D mesh

    const viewport = [ 0, 0, width, height ];
    camera.viewport = viewport;

    // Make the camera swing back/forward a little
    const zOffset = Math.sin(playhead * Math.PI * -2) * 0.5;

    // Reset camera position, translate it outward, then look at world center
    camera.identity();
    camera.translate([ 0, 0, 3 + zOffset ]);
    camera.lookAt([ 0, 0, 0 ]);
    camera.update();

    // A 3D scene is made up of:
    // - 4x4 Projection Matrix (defines perspective)
    // - 4x4 View Matrix (inverse of camera transformation matrix)
    // - 4x4 Model Matrix (the mesh transformations like rotation, scale, etc)

    const projection = camera.projection;
    const view = camera.view;
    const model = mat4.identity([]);

    // Rotate the mesh in place
    mat4.rotateY(model, model, easeA(playhead) * Math.PI * 2);
    mat4.rotateX(model, model, easeB(playhead) * Math.PI * 2);

    // Get a combined (projection * view * model) matrix
    const combined = mat4.identity([]);
    mat4.multiply(combined, view, model);
    mat4.multiply(combined, projection, combined);

    // "Project" the 3D positions into 2D [ x, y ] points in pixel space
    const points = positions.map(position => {
      return cameraProject([], position, viewport, combined);
    });

    // Draw the mesh
    drawCellsNoOverlap(context, points, cells);
  };
};

canvasSketch(sketch, settings);

bramadams.dev is a reader-supported published Zettelkasten. Both free and paid subscriptions are available. If you want to support my work, the best way is by taking out a paid subscription.

Comments