Skip to content

Point cloud ​

Drag the galaxy to rotate it. The picker sets the number of stars, from ten thousand to a million, and the toggle switches between a 2D canvas and WebGL. The counter in the corner shows the frames per second.

Why not points3D().data() here ​

.data() returns an object per point with its rotated and projected coordinates, which is what an SVG join needs. At a million points that is millions of objects on every frame, and the garbage collector, not the drawing, sets the frame rate. The galaxy keeps its stars in one Float32Array instead, three numbers per star, built once:

ts
const positions = new Float32Array(3 * n);
positions[3 * i] = x;
positions[3 * i + 1] = y;
positions[3 * i + 2] = z;

One matrix, two renderers ​

Without a perspective, a rotation, a scale and an origin make one affine map, and one 4×4 matrix carries all of it. A camera3D holds the settings without any data, and its .matrix() returns that matrix, so the galaxy is turned with the same calls as any other shape:

ts
const camera = camera3D()
    .scale(SCALE)
    .origin({ x: WIDTH / 2, y: HEIGHT / 2 });
turnCamera(camera, spin + yaw, pitch, { tilt: TILT });
const matrix = camera.matrix();

Canvas: project into a buffer ​

projectPositions runs the matrix over the packed stars and writes screen x, screen y and depth into a buffer the figure allocates once. The canvas then adds the light of every star straight into the pixels of one ImageData and puts it on the canvas once per frame:

ts
projectPositions(positions.subarray(0, 3 * count), matrix, screen);

for (let i = 0; i < count; i += 1) {
    const x = Math.round(screen[3 * i] * dpr);
    const y = Math.round(screen[3 * i + 1] * dpr);
    const j = 4 * (y * width + x);
    pixels[j] += r;
    pixels[j + 1] += g;
    pixels[j + 2] += b;
}

context.putImageData(image, 0, 0);

A fillRect per star would read better, but at a hundred thousand stars it already takes about 19 milliseconds a frame, five times as long as the pixels. The projection is the cheap part either way.

WebGL: the GPU does the same multiply ​

WebGL takes the stars once, into a buffer on the GPU. Every frame only sends the matrix, sixteen numbers, and the vertex shader does what projectPositions does for every star at once. Given the size of the canvas, .matrix() maps straight to clip space, so the shader is one line:

glsl
gl_Position = u_matrix * vec4(a_position, 1.0);
ts
gl.uniformMatrix4fv(matrixLocation, false, camera.matrix({ width: WIDTH, height: HEIGHT }));
gl.drawArrays(gl.POINTS, 0, count);

Without a depth the matrix sets every z to 0. The galaxy has no use for the depth: both renderers add the light of the stars, which gives the same picture in any order, so nothing is sorted.

Two canvases ​

A canvas keeps the first kind of context it hands out, so one canvas cannot switch from 2d to webgl2. The figure holds one canvas per renderer and shows one of them. Both are sized for the device pixel ratio. The camera projects to CSS pixels: the canvas multiplies by the ratio, and WebGL hands the size in CSS pixels to .matrix().

The whole source ​

The figure lives in docs/examples/pointCloud.ts in the repository, the canvases, the controls and the loop it shares with the bar field in docs/examples/canvasFigure.ts. The figure, ready to copy:

ts
import { randomNormal, rgb } from 'd3';
import { camera3D, projectPositions } from '../../index';
import { HEIGHT, WIDTH, attribute, canvasFigure, linkProgram, squareAt } from './canvasFigure';
import type { Draw } from './canvasFigure';
import './pointCloud.css';

/*
 * A spiral galaxy of up to a million stars, far too many for an SVG element
 * per point. No star becomes an object: `.matrix()` hands the projection to
 * WebGL as a uniform, or to `projectPositions`, which projects the packed
 * coordinates into one reused buffer for a 2D canvas. Both renderers blend
 * the stars additively, so neither has to sort them by depth.
 */

const SCALE = 230;
const COUNTS = [10_000, 100_000, 1_000_000];
const ARMS = 3;
const TWIST = 5;
/** The camera looks down on the disk from this angle, in radians. */
const TILT = 0.6;
const POINT_SIZE = 1.5;
const BACKGROUND = '#07090f';
/** From the core outwards: one color per ring of the disk. */
const PALETTE = ['#ffd58a', '#ff9e6d', '#b4a7ff', '#6fa8ff'];

/** A spiral galaxy: packed x, y, z per star, and the ring that colors it. */
function galaxy(n: number): {
    positions: Float32Array<ArrayBuffer>;
    rings: Uint8Array<ArrayBuffer>;
} {
    const positions = new Float32Array(3 * n);
    const rings = new Uint8Array(n);
    const normal = randomNormal();

    // The arms take turns, so the first `count` stars are a galaxy of their own.
    for (let i = 0; i < n; i += 1) {
        const r = Math.random() ** 1.5;
        const angle = (i % ARMS) * ((2 * Math.PI) / ARMS) + r * TWIST;
        const spread = 0.03 + 0.09 * (1 - r);

        positions[3 * i] = r * Math.cos(angle) + normal() * spread;
        positions[3 * i + 1] = normal() * (0.015 + 0.05 * (1 - r));
        positions[3 * i + 2] = r * Math.sin(angle) + normal() * spread;
        rings[i] = Math.min(PALETTE.length - 1, Math.floor(r * PALETTE.length));
    }

    return { positions, rings };
}

/** Fewer stars glow brighter, so every count fills the disk alike. */
function alpha(count: number): number {
    return Math.min(0.8, 40 / Math.sqrt(count));
}

const VERTEX_SHADER = `
uniform mat4 u_matrix;
uniform float u_pointSize;
uniform vec3 u_palette[${PALETTE.length}];
attribute vec3 a_position;
attribute float a_ring;
varying vec3 v_color;

void main() {
    // The matrix projects to clip space.
    gl_Position = u_matrix * vec4(a_position, 1.0);
    gl_PointSize = u_pointSize;
    v_color = u_palette[int(a_ring)];
}`;

const FRAGMENT_SHADER = `
precision mediump float;
uniform float u_alpha;
varying vec3 v_color;

void main() {
    gl_FragColor = vec4(v_color, u_alpha);
}`;

/**
 * The 2D canvas: projects the stars into `screen` and adds their light
 * straight into the pixels of one `ImageData`. A `fillRect` per star costs
 * about five times as much, which a million stars cannot afford.
 */
function canvasRenderer(
    canvas: HTMLCanvasElement,
    positions: Float32Array,
    rings: Uint8Array,
    dpr: number,
    size: number
): Draw {
    const { width, height } = canvas;
    const context = canvas.getContext('2d')!;
    const image = context.createImageData(width, height);
    const pixels = image.data;
    const screen = new Float32Array(positions.length);
    const { r, g, b } = rgb(BACKGROUND);
    // One pixel as one number, little-endian like every browser platform.
    const sky = (255 << 24) | (b << 16) | (g << 8) | r;
    const colors = PALETTE.map((color) => rgb(color));

    return (count, camera) => {
        projectPositions(positions.subarray(0, 3 * count), camera.matrix(), screen);
        new Uint32Array(pixels.buffer).fill(sky);

        const a = alpha(count);
        // Whole numbers: a clamped array rounds every fraction it is handed, slowly.
        const light = Uint8Array.from(
            colors.flatMap((c) => [c.r, c.g, c.b]),
            (v) => v * a
        );

        for (let i = 0; i < count; i += 1) {
            const corner = squareAt(screen, i, dpr, size, width, height);
            if (corner < 0) {
                continue;
            }

            // The pixels clamp at 255, as the additive blending of WebGL does.
            const k = 3 * rings[i];
            for (let dy = 0; dy < size; dy += 1) {
                for (let j = 4 * (corner + dy * width), end = j + 4 * size; j < end; j += 4) {
                    pixels[j] += light[k];
                    pixels[j + 1] += light[k + 1];
                    pixels[j + 2] += light[k + 2];
                }
            }
        }

        context.putImageData(image, 0, 0);
    };
}

/** WebGL: the stars go to the GPU once, every frame only sends the matrix. */
function webglRenderer(
    gl: WebGL2RenderingContext,
    positions: Float32Array<ArrayBuffer>,
    rings: Uint8Array<ArrayBuffer>,
    size: number
): Draw {
    const program = linkProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER);
    attribute(gl, program, 'a_position', 3, gl.FLOAT)(positions);
    attribute(gl, program, 'a_ring', 1, gl.UNSIGNED_BYTE)(rings);

    const uniform = (name: string) => gl.getUniformLocation(program, name);
    const palette = PALETTE.flatMap((color) => {
        const { r, g, b } = rgb(color);
        return [r / 255, g / 255, b / 255];
    });
    const background = rgb(BACKGROUND);
    const matrixLocation = uniform('u_matrix');
    const alphaLocation = uniform('u_alpha');

    gl.uniform3fv(uniform('u_palette'), palette);
    gl.uniform1f(uniform('u_pointSize'), size);
    gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    gl.clearColor(background.r / 255, background.g / 255, background.b / 255, 1);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE);

    return (count, camera) => {
        gl.uniformMatrix4fv(matrixLocation, false, camera.matrix({ width: WIDTH, height: HEIGHT }));
        gl.uniform1f(alphaLocation, alpha(count));
        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.drawArrays(gl.POINTS, 0, count);
    };
}

/**
 * Renders the galaxy into `host` and returns the function that stops it.
 */
export function mountPointCloud(host: HTMLElement): () => void {
    const { positions, rings } = galaxy(COUNTS[COUNTS.length - 1]);

    return canvasFigure(host, {
        unit: 'stars',
        label: 'A spiral galaxy',
        counts: COUNTS,
        count: COUNTS[1],
        tilt: TILT,
        camera: camera3D()
            .scale(SCALE)
            .origin({ x: WIDTH / 2, y: HEIGHT / 2 }),
        renderers(canvas, gl, dpr) {
            // A star in device pixels, the same square in both renderers.
            const size = Math.max(1, Math.round(POINT_SIZE * dpr));
            return {
                canvas: canvasRenderer(canvas, positions, rings, dpr, size),
                webgl: gl ? webglRenderer(gl, positions, rings, size) : undefined
            };
        }
    });
}

Released under the BSD-3-Clause License. Sponsor d3-3d on GitHub Sponsors.