Hung-Yi's LogoHung-Yi’s Journal

A Beautiful Pseudo-3D Neural Network Animation Using Zdog

A neural-network-like visualisation of a randomly connected network with neuron firing effects made with Zdog and D3.js.

Zdog is a pseudo-3D engine that makes it easier and faster to draw 3D-like graphics in a browser using JavaScript. It takes shapes defined in 3D space and projects them onto 2D space in a HTML canvas, without having to calculate any lighting. This makes it easy to reason with and potentially much faster than other in-browser 3D rendering libraries, given the right circumstances. It’s perfect for small, simple and clean visualizations and illustrations with minimal interactivity.

In this animation, we:

  1. Randomly generate nodes and links with some minor tweaks to create an organic-looking structure;
  2. Run D3.js d3-force-3d layouting1 on the graph to bring connected nodes together and push less connected nodes apart (this step makes the graph less “messy” and more visually digestible to human eyes);
  3. Animate a neural-firing blink on randomly selected links and nodes to liven things up.

The full JavaScript2 follows:

import * as Zdog from "https://cdn.skypack.dev/zdog";
import {
  forceSimulation,
  forceLink,
  forceManyBody
} from "https://cdn.skypack.dev/d3-force-3d";

const YELLOW = '#EBCB8B';
const GREY_LIGHT = '#D0D6E1';
const BLINK_DURATION = 70;
let TICKS = 0;

class Node {
  constructor(id, { x, y, z }) {
    this.id = id;
    this.position = { x, y, z };
  }

  // This interface ensures D3 can work with us
  get x() { return this.position.x }
  set x(v) { this.position.x = v }
  get y() { return this.position.y }
  set y(v) { this.position.y = v }
  get z() { return this.position.z }
  set z(v) { this.position.z = v }

  blink() {
    this._blinkT0 = TICKS;
  }

  render() {
    if (!this._renderable) {
      this._renderable = new Zdog.Ellipse({
        addTo: ILLO,
        diameter: 0,
        stroke: 1 + Math.random() * 3,
        color: GREY_LIGHT + 'dd',
      });
    }

    this._renderable.translate = { ...this.position };

    this.renderBlink();
  }

  renderBlink() {
    const blinkProgress = (TICKS - this._blinkT0) / BLINK_DURATION;
    if (blinkProgress <= 1) {
      const alpha = Zdog.easeInOut(blinkProgress, 3);
      if (!this._blinkRenderable) {
        this._blinkRenderable = new Zdog.Ellipse({
          addTo: this._renderable,
          diameter: 0,
          stroke: this._renderable.stroke,
          color: YELLOW + '00',
        });
      }
      this._blinkRenderable.color = YELLOW + alphaToOpacity(1 - alpha);
      this._blinkRenderable.diameter = alpha * this._renderable.stroke * 7;
      this._blinkRenderable.stroke = alpha * this._renderable.stroke * 7;
    } else {
      if (this._blinkRenderable) {
        this._renderable.removeChild(this._blinkRenderable);
        this._blinkRenderable = null;
      }
    }
  }
}

class Link {
  constructor(sourceId, targetId) {
    this.id = `${sourceId}->${targetId}`;
    this.source = NODE_MAP.get(sourceId);
    this.target = NODE_MAP.get(targetId);
  }

  blink() {
    this._blinkT0 = TICKS;
    this.source.blink();
    this.target.blink();
  }

  render() {
    if (!this._renderable) {
      this._renderable = new Zdog.Shape({
        addTo: ILLO,
        path: [
          { ...this.source.position },
          { ...this.target.position },
        ],
        stroke: 1,
        color: GREY_LIGHT + '16',
      });
    }

    this.renderBlink();

    this._renderable.path = [
      { ...this.source.position },
      { ...this.target.position },
    ];
    this._renderable.updatePath();
  }

  renderBlink() {
    const blinkProgress = (TICKS - this._blinkT0) / BLINK_DURATION;
    if (blinkProgress <= 1) {
      // Fade in and out with easing
      const alpha = (0.5-Math.abs(Zdog.easeInOut(blinkProgress, 3)-0.5))/2;

      // If nothing rendered for the blink effect, create the shape
      if (!this._blinkRenderable) {
        this._blinkRenderable = new Zdog.Shape({
          addTo: this._renderable,
          path: [
            { ...this.source.position },
            { ...this.target.position },
          ],
          stroke: 3,
          color: YELLOW + '00',
        });
      }

      // Update colors for blink effect and main Link shape
      this._blinkRenderable.color = YELLOW + alphaToOpacity(alpha);
      this._renderable.color = YELLOW + alphaToOpacity(0.2 + alpha)
    } else {
      // If not blinking or blinking done, remove the blink effect shape
      if (this._blinkRenderable) {
        this._renderable.removeChild(this._blinkRenderable);
        this._blinkRenderable = null;
        this._renderable.color = GREY_LIGHT + '16';
      }
    }
  }
}

const ILLO = new Zdog.Illustration({
  element: '#zdog-canvas',
  dragRotate: true,
  resize: true,
  onResize: function(width, height) {
    let minSize = Math.min( width, height );
    this.zoom = minSize / 380;
  },
});

// Generate random nodes and space them out reasonably randomly
const NODES = new Array(450)
  .fill(null)
  .map((_,  i) => new Node(
    i,
    {
      x: Math.random()*400 - 200,
      y: Math.random()*400 - 200,
      z: Math.random()*400 - 200
    }
  ));

// Easier ways to access nodes quickly
const NODE_MAP = new Map(NODES.map(n => [n.id, n]));
const NODE_IDS = Array.from(NODE_MAP.keys());

// Generate links with some "clustering" structure
const LINKS = new Array(150).fill(null).flatMap(() => {
  // Choose a random start node
  const startNodeId = NODE_IDS[Math.floor(Math.random() * NODE_IDS.length)];
  // Link the start node to one or more end nodes.
  // One outgoing link will be way more likely than
  // 10+ outgoing links by using Math.pow distribution
  return new Array(Math.ceil(Math.pow(Math.random() * 1.8, 4)))
    .fill(null)
    .map(() => new Link(
      startNodeId,
      NODE_IDS[Math.floor(Math.random() * NODE_IDS.length)]
    ));
});

// Apply 3d force-directed layout using D3.js
forceSimulation(NODES, 3)
  .force("link", forceLink(LINKS).id(d => d.id))
  .force("charge", forceManyBody());

// Update & render
function animate() {
  // Each update is one tick
  TICKS++;

  // Update the rendering for nodes and links
  NODES.forEach(node => node.render());
  LINKS.forEach(link => {
    link.render();

    // Small chance to randomly blink each link
    if (Math.random() > 0.9995) {
      link.blink();
    }
  });

  // Rotate the whole illustration slowly
  ILLO.rotate.y += 0.0004;
  ILLO.rotate.x += 0.0008;
  ILLO.rotate.z += 0.0006;

  ILLO.updateRenderGraph();
  requestAnimationFrame(animate);
}
animate();

// Converts an alpha value [0,1] to a hex string
// for appending to a hex color string
function alphaToOpacity(alpha) {
  return Math.floor(alpha * 256).toString(16).padStart(2, '0')
}

Footnotes:

1

Specifically, we’re using force-directed graph drawing which uses spring-like physics to lay out vertices and edges in a nicer way than just dumping them all on screen randomly.

2

No TypeScript this time, as I wanted a quick-and-dirty proof of concept.