← All WebGL effects

Lattice Net

Edges recomputed from distance every frame with nothing created or destroyed, so the mesh thickens where nodes gather.

particles-3dtechnicalcalmfuturistic2 draw calls · 90 instances · light · 1 context · automatic · looping
queued2 draw calls · 1 context
Variant

The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

361 lines · react + three
"use client";

import * as React from "react";
import * as THREE from "three";

/**
 * Lattice Net — the count never changes, only the distances do.
 *
 * A network background usually gives itself away by fading links in and
 * out. Something decides a connection should appear, ramps its opacity
 * up, and later ramps it down; the result reads as a slideshow of
 * networks rather than as one network moving, because a link that
 * fades has no reason for existing at the moment it does.
 *
 * Here nothing is created or destroyed. A fixed set of nodes drifts,
 * and an edge exists between two of them when they are closer than a
 * threshold — recomputed every frame from nothing but position. The
 * lattice thickens where nodes happen to gather and thins where they
 * spread, and the reason any particular line is there is visible on
 * screen: those two nodes are near each other. Brightness comes from
 * the same distance, so a link is faintest exactly when it is about to
 * stop being a link, and it never disappears in a jump.
 *
 * The pass is O(n²) and stays honest about it: this runs 150 nodes at
 * the widest setting, which is eleven thousand comparisons a frame and
 * nothing to a modern CPU. Ten times that would need a spatial grid,
 * and this file does not pretend otherwise.
 *
 * Single file, react and three only. No textures, no add-ons.
 */

type Variant = "subtle" | "default" | "playful";

const VARIANTS = {
  subtle: { driftRate: 0.05, nodeCount: 54, edgeOpacity: 0.3, spreadScale: 0.85 },
  default: { driftRate: 0.12, nodeCount: 90, edgeOpacity: 0.5, spreadScale: 1.2 },
  playful: { driftRate: 0.28, nodeCount: 150, edgeOpacity: 0.72, spreadScale: 1.75 },
} as const;

const PALETTES = {
  night: {
    void: [0.028, 0.033, 0.047],
    node: [0.55, 0.82, 0.95],
    edge: [0.24, 0.42, 0.72],
    css: "radial-gradient(115% 85% at 50% 45%, #131a2b 0%, #0a0e18 60%, #06080e 100%)",
  },
  day: {
    void: [0.945, 0.95, 0.96],
    node: [0.1, 0.28, 0.45],
    edge: [0.45, 0.56, 0.7],
    css: "radial-gradient(115% 85% at 50% 45%, #e9edf4 0%, #f2f4f8 60%, #f7f8fb 100%)",
  },
} as const;

const LINE_VERTEX = /* glsl */ `
  attribute float strength;
  varying float vStrength;
  void main() {
    vStrength = strength;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const LINE_FRAGMENT = /* glsl */ `
  uniform float uOpacity;
  uniform vec3  uEdge;
  varying float vStrength;
  void main() {
    // Brightness is the same distance the edge's existence depends on,
    // so a link is faintest just before it stops being one.
    gl_FragColor = vec4(uEdge, uOpacity * vStrength);
  }
`;

const NODE_VERTEX = /* glsl */ `
  uniform float uSize;
  void main() {
    vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
    gl_PointSize = uSize / max(-viewPosition.z, 0.1);
    gl_Position = projectionMatrix * viewPosition;
  }
`;

const NODE_FRAGMENT = /* glsl */ `
  uniform float uOpacity;
  uniform vec3  uNode;
  void main() {
    // A round point without a texture: discard outside the disc.
    vec2 fromCentre = gl_PointCoord - 0.5;
    float d = dot(fromCentre, fromCentre);
    if (d > 0.25) discard;
    float edge = 1.0 - smoothstep(0.16, 0.25, d);
    gl_FragColor = vec4(uNode, uOpacity * edge);
  }
`;

/** Relative luminance of a computed rgb() colour, 0–1. */
function luminanceOf(color: string): number {
  const parts = color.match(/[\d.]+/g);
  if (!parts || parts.length < 3) return 0.9;
  const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

export type LatticeNetProps = {
  variant?: Variant;
  height?: string;
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;
};

export function LatticeNet({
  variant = "default",
  height = "100%",
  className,
  style,
  children,
}: LatticeNetProps) {
  const hostRef = React.useRef<HTMLDivElement | null>(null);
  const cfg = VARIANTS[variant] ?? VARIANTS.default;

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const palette =
      luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
    host.style.background = palette.css;

    const reduced =
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({
        antialias: true,
        alpha: false,
        powerPreference: "low-power",
      });
    } catch {
      return; // No WebGL. The CSS still above is already painted.
    }

    const canvas = renderer.domElement;
    canvas.style.cssText =
      "position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.setClearColor(
      new THREE.Color(palette.void[0], palette.void[1], palette.void[2]),
      1
    );
    host.insertBefore(canvas, host.firstChild);

    const count = cfg.nodeCount;
    const spread = 4.2 * cfg.spreadScale;
    const linkDistance = (2.15 * spread) / Math.cbrt(count);
    // Bounded so the buffer is allocated once. A node with more than
    // eight near neighbours is in a clump nobody can read anyway.
    const maxEdges = count * 8;

    // A seeded generator rather than Math.random, so the lattice is the
    // same one every reload — which is what lets the still frame under
    // reduced motion be a fair picture of the effect rather than one
    // arbitrary draw out of infinitely many.
    let seed = 0x2f6e2b1;
    const random = () => {
      seed = (seed * 1664525 + 1013904223) >>> 0;
      return seed / 4294967296;
    };

    const home = new Float32Array(count * 3);
    const swing = new Float32Array(count * 3);
    const nodePositions = new Float32Array(count * 3);
    for (let i = 0; i < count; i++) {
      home[i * 3] = (random() - 0.5) * spread * 2;
      home[i * 3 + 1] = (random() - 0.5) * spread * 1.15;
      home[i * 3 + 2] = (random() - 0.5) * spread * 1.4;
      swing[i * 3] = random() * 6.283;
      swing[i * 3 + 1] = random() * 6.283;
      swing[i * 3 + 2] = 0.55 + random() * 0.9;
    }

    const nodeGeometry = new THREE.BufferGeometry();
    nodeGeometry.setAttribute("position", new THREE.BufferAttribute(nodePositions, 3));

    const edgePositions = new Float32Array(maxEdges * 2 * 3);
    const edgeStrength = new Float32Array(maxEdges * 2);
    const edgeGeometry = new THREE.BufferGeometry();
    edgeGeometry.setAttribute("position", new THREE.BufferAttribute(edgePositions, 3));
    edgeGeometry.setAttribute("strength", new THREE.BufferAttribute(edgeStrength, 1));

    const nodeMaterial = new THREE.ShaderMaterial({
      vertexShader: NODE_VERTEX,
      fragmentShader: NODE_FRAGMENT,
      uniforms: {
        uSize: { value: 26 },
        uOpacity: { value: Math.min(1, cfg.edgeOpacity + 0.24) },
        uNode: { value: new THREE.Vector3(...palette.node) },
      },
      transparent: true,
      depthWrite: false,
    });

    const edgeMaterial = new THREE.ShaderMaterial({
      vertexShader: LINE_VERTEX,
      fragmentShader: LINE_FRAGMENT,
      uniforms: {
        uOpacity: { value: 0.5 },
        uEdge: { value: new THREE.Vector3(...palette.edge) },
      },
      transparent: true,
      depthWrite: false,
    });
    edgeMaterial.uniforms.uOpacity.value = cfg.edgeOpacity;

    const scene = new THREE.Scene();
    scene.add(new THREE.Points(nodeGeometry, nodeMaterial));
    scene.add(new THREE.LineSegments(edgeGeometry, edgeMaterial));

    const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 60);
    camera.position.set(0, 0, 9.5);

    const resize = () => {
      const width = Math.max(1, host.clientWidth);
      const heightPx = Math.max(1, host.clientHeight);
      renderer.setSize(width, heightPx, false);
      camera.aspect = width / heightPx;
      camera.updateProjectionMatrix();
    };
    resize();

    const observer = new ResizeObserver(resize);
    observer.observe(host);

    /** Move the nodes, then let the edges follow from where they are. */
    const build = (t: number) => {
      for (let i = 0; i < count; i++) {
        const base = i * 3;
        const rate = swing[base + 2];
        nodePositions[base] = home[base] + Math.sin(t * rate + swing[base]) * 0.62;
        nodePositions[base + 1] =
          home[base + 1] + Math.sin(t * rate * 0.83 + swing[base + 1]) * 0.5;
        nodePositions[base + 2] =
          home[base + 2] + Math.cos(t * rate * 0.71 + swing[base]) * 0.44;
      }
      nodeGeometry.attributes.position.needsUpdate = true;

      let edges = 0;
      const limitSquared = linkDistance * linkDistance;
      for (let i = 0; i < count && edges < maxEdges; i++) {
        const a = i * 3;
        for (let j = i + 1; j < count && edges < maxEdges; j++) {
          const b = j * 3;
          const dx = nodePositions[a] - nodePositions[b];
          const dy = nodePositions[a + 1] - nodePositions[b + 1];
          const dz = nodePositions[a + 2] - nodePositions[b + 2];
          const squared = dx * dx + dy * dy + dz * dz;
          if (squared > limitSquared) continue;

          const near = 1 - Math.sqrt(squared) / linkDistance;
          const at = edges * 6;
          edgePositions[at] = nodePositions[a];
          edgePositions[at + 1] = nodePositions[a + 1];
          edgePositions[at + 2] = nodePositions[a + 2];
          edgePositions[at + 3] = nodePositions[b];
          edgePositions[at + 4] = nodePositions[b + 1];
          edgePositions[at + 5] = nodePositions[b + 2];
          edgeStrength[edges * 2] = near;
          edgeStrength[edges * 2 + 1] = near;
          edges++;
        }
      }
      edgeGeometry.attributes.position.needsUpdate = true;
      edgeGeometry.attributes.strength.needsUpdate = true;
      edgeGeometry.setDrawRange(0, edges * 2);
    };

    const draw = () => renderer.render(scene, camera);

    let frame = 0;
    let phase = 0;
    let last = 0;
    let running = false;

    const tick = (now: number) => {
      const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      phase += delta * cfg.driftRate * 6.0;
      build(phase);
      draw();
      frame = requestAnimationFrame(tick);
    };

    const start = () => {
      if (running) return;
      running = true;
      last = 0;
      frame = requestAnimationFrame(tick);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(frame);
    };

    const onLost = (event: Event) => {
      event.preventDefault();
      stop();
    };
    const onRestored = () => {
      resize();
      start();
    };
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);

    if (reduced) {
      // A network held still is still a network: every link in the
      // frame is there because those two nodes are close, which is the
      // only thing the effect ever claimed.
      build(2.2);
      draw();
    } else {
      start();
    }

    return () => {
      stop();
      observer.disconnect();
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      nodeGeometry.dispose();
      edgeGeometry.dispose();
      nodeMaterial.dispose();
      edgeMaterial.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
      canvas.remove();
    };
  }, [cfg]);

  return (
    <div
      ref={hostRef}
      className={className}
      style={{
        position: "relative",
        width: "100%",
        height,
        overflow: "hidden",
        background: PALETTES.night.css,
        ...style,
      }}
    >
      {children}
    </div>
  );
}

export default LatticeNet;

About this effect

For a panel that is about connection — infrastructure, regions, a service map, a team. A network background usually gives itself away by fading links in and out: something decides a connection should appear, ramps its opacity up, and later ramps it down, and the result reads as a slideshow of networks rather than one network moving, because a link that fades has no visible reason for existing at the moment it does. Here nothing is created or destroyed. A fixed set of nodes drifts, and an edge exists between two of them when they are closer than a threshold, recomputed every frame from nothing but position. The lattice thickens where nodes happen to gather and thins where they spread, and the reason any particular line is on screen is on screen: those two nodes are near each other. Brightness comes from that same distance, so a link is faintest exactly when it is about to stop being a link and never vanishes in a jump. The pass is O(n²) and the file says so rather than hiding it — 150 nodes at the widest setting is eleven thousand comparisons a frame and nothing to a modern CPU, while ten times that would need a spatial grid.

Infrastructure panelService mapTeam or network pageStatus backdrop

Related effects