10m node viewer w/ quadtree
This commit is contained in:
412
src/renderer.ts
Normal file
412
src/renderer.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { buildSpatialIndex, type Leaf } from "./quadtree";
|
||||
|
||||
/* ── Shaders ────────────────────────────────────────────── */
|
||||
|
||||
const VERT = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 a_pos;
|
||||
uniform vec2 u_center;
|
||||
uniform vec2 u_scale;
|
||||
uniform float u_ptSize;
|
||||
void main() {
|
||||
gl_Position = vec4((a_pos - u_center) * u_scale, 0.0, 1.0);
|
||||
gl_PointSize = u_ptSize;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision mediump float;
|
||||
out vec4 o;
|
||||
void main() {
|
||||
vec2 c = gl_PointCoord * 2.0 - 1.0;
|
||||
if (dot(c, c) > 1.0) discard;
|
||||
o = vec4(0.3, 0.55, 1.0, 0.5);
|
||||
}
|
||||
`;
|
||||
|
||||
const LINE_FRAG = `#version 300 es
|
||||
precision mediump float;
|
||||
out vec4 o;
|
||||
void main() {
|
||||
o = vec4(0.3, 0.55, 1.0, 0.15); // faint lines
|
||||
}
|
||||
`;
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────── */
|
||||
|
||||
export interface RenderStats {
|
||||
drawnCount: number;
|
||||
mode: string;
|
||||
zoom: number;
|
||||
ptSize: number;
|
||||
}
|
||||
|
||||
/* ── Constants ──────────────────────────────────────────── */
|
||||
|
||||
const COUNT = 10_000_000;
|
||||
const EXTENT = 30_000; // particles span [-15000, 15000]
|
||||
const WORLD_RADIUS = 4.0; // sphere world-space radius
|
||||
const MAX_DRAW = 600_000; // max particles to draw per frame
|
||||
|
||||
/* ── Renderer ───────────────────────────────────────────── */
|
||||
|
||||
export class Renderer {
|
||||
private gl: WebGL2RenderingContext;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private program: WebGLProgram;
|
||||
private lineProgram: WebGLProgram;
|
||||
private vao: WebGLVertexArrayObject;
|
||||
|
||||
// Data
|
||||
private leaves: Leaf[] = [];
|
||||
private maxPtSize = 256;
|
||||
|
||||
// Multi-draw extension
|
||||
private multiDrawExt: any = null;
|
||||
private visibleLeafIndices: Uint32Array = new Uint32Array(0);
|
||||
private startsArray: Int32Array = new Int32Array(0);
|
||||
private countsArray: Int32Array = new Int32Array(0);
|
||||
|
||||
// Uniform locations
|
||||
private uCenter: WebGLUniformLocation;
|
||||
private uScale: WebGLUniformLocation;
|
||||
private uPtSize: WebGLUniformLocation;
|
||||
|
||||
private uCenterLine: WebGLUniformLocation;
|
||||
private uScaleLine: WebGLUniformLocation;
|
||||
|
||||
private linesIbo: WebGLBuffer;
|
||||
|
||||
// Camera state
|
||||
private cx = 0;
|
||||
private cy = 0;
|
||||
private zoom = 0.06; // pixels per world unit (starts zoomed out to see everything)
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false });
|
||||
if (!gl) throw new Error("WebGL2 not supported");
|
||||
this.gl = gl;
|
||||
|
||||
this.multiDrawExt = gl.getExtension('WEBGL_multi_draw');
|
||||
|
||||
// Compile programs
|
||||
this.program = this.compileProgram(VERT, FRAG);
|
||||
this.lineProgram = this.compileProgram(VERT, LINE_FRAG);
|
||||
|
||||
gl.useProgram(this.program);
|
||||
|
||||
this.uCenter = gl.getUniformLocation(this.program, "u_center")!;
|
||||
this.uScale = gl.getUniformLocation(this.program, "u_scale")!;
|
||||
this.uPtSize = gl.getUniformLocation(this.program, "u_ptSize")!;
|
||||
|
||||
this.uCenterLine = gl.getUniformLocation(this.lineProgram, "u_center")!;
|
||||
this.uScaleLine = gl.getUniformLocation(this.lineProgram, "u_scale")!;
|
||||
|
||||
// Query hardware max point size
|
||||
const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array;
|
||||
this.maxPtSize = range[1] || 256;
|
||||
|
||||
// Create VAO + VBO (empty for now)
|
||||
this.vao = gl.createVertexArray()!;
|
||||
gl.bindVertexArray(this.vao);
|
||||
const vbo = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
|
||||
// We forced a_pos to location 0 in compileProgram
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
this.linesIbo = gl.createBuffer()!;
|
||||
|
||||
// Blending
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.clearColor(0.02, 0.02, 0.05, 1.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 10M random particles, build quadtree, upload to GPU.
|
||||
* Call once at startup. Returns build time in ms.
|
||||
*/
|
||||
init(): number {
|
||||
const t0 = performance.now();
|
||||
const gl = this.gl;
|
||||
|
||||
// Generate random positions as typed arrays (no object allocation)
|
||||
const xs = new Float32Array(COUNT);
|
||||
const ys = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
xs[i] = (Math.random() - 0.5) * EXTENT;
|
||||
ys[i] = (Math.random() - 0.5) * EXTENT;
|
||||
}
|
||||
|
||||
// Build quadtree (spatially sorts the array)
|
||||
const { sorted, leaves } = buildSpatialIndex(xs, ys);
|
||||
this.leaves = leaves;
|
||||
|
||||
// Pre-allocate arrays for render loop (zero-allocation rendering)
|
||||
this.visibleLeafIndices = new Uint32Array(leaves.length);
|
||||
this.startsArray = new Int32Array(leaves.length);
|
||||
this.countsArray = new Int32Array(leaves.length);
|
||||
|
||||
// Upload sorted particles to GPU as STATIC VBO (never changes)
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
// Build relationships (lines)
|
||||
const lineIndices = new Uint32Array(COUNT * 4);
|
||||
for (let j = 0; j < leaves.length; j++) {
|
||||
const lf = leaves[j];
|
||||
const leafSize = lf.end - lf.start;
|
||||
if (leafSize <= 1) continue;
|
||||
|
||||
for (let i = lf.start; i < lf.end; i++) {
|
||||
const x = sorted[i * 2];
|
||||
const y = sorted[i * 2 + 1];
|
||||
|
||||
let best1 = -1, best2 = -1;
|
||||
let dist1 = Infinity, dist2 = Infinity;
|
||||
|
||||
// Find closest 2 points within a small local sliding window (spatially coherent)
|
||||
const windowSize = Math.min(12, leafSize - 1);
|
||||
for (let w = 1; w <= windowSize; w++) {
|
||||
const candidate = lf.start + ((i - lf.start + w) % leafSize);
|
||||
const cx = sorted[candidate * 2];
|
||||
const cy = sorted[candidate * 2 + 1];
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const d2 = dx*dx + dy*dy;
|
||||
|
||||
if (d2 < dist1) {
|
||||
dist2 = dist1;
|
||||
best2 = best1;
|
||||
dist1 = d2;
|
||||
best1 = candidate;
|
||||
} else if (d2 < dist2) {
|
||||
dist2 = d2;
|
||||
best2 = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if (best1 === -1) best1 = lf.start + ((i - lf.start + 1) % leafSize);
|
||||
if (best2 === -1) best2 = lf.start + ((i - lf.start + 2) % leafSize);
|
||||
|
||||
const base = i * 4;
|
||||
lineIndices[base + 0] = i;
|
||||
lineIndices[base + 1] = best1;
|
||||
lineIndices[base + 2] = i;
|
||||
lineIndices[base + 3] = best2;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload lines to GPU (NOT bound to VAO)
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, lineIndices, gl.STATIC_DRAW);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||
|
||||
return performance.now() - t0;
|
||||
}
|
||||
|
||||
/* ── Camera ───────────────────────────────────────────── */
|
||||
|
||||
pan(dx: number, dy: number): void {
|
||||
// dx, dy in CSS pixels; convert to world units
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.cx -= (dx * dpr) / this.zoom;
|
||||
this.cy -= (dy * dpr) / this.zoom;
|
||||
}
|
||||
|
||||
zoomAt(factor: number, screenX: number, screenY: number): void {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const px = screenX * dpr;
|
||||
const py = screenY * dpr;
|
||||
|
||||
// World position under cursor before zoom
|
||||
const wx = this.cx + (px - this.canvas.width / 2) / this.zoom;
|
||||
const wy = this.cy + (py - this.canvas.height / 2) / this.zoom;
|
||||
|
||||
this.zoom *= factor;
|
||||
this.zoom = Math.max(1e-4, Math.min(1e7, this.zoom));
|
||||
|
||||
// Adjust pan so the same world point stays under cursor
|
||||
this.cx = wx - (px - this.canvas.width / 2) / this.zoom;
|
||||
this.cy = wy - (py - this.canvas.height / 2) / this.zoom;
|
||||
}
|
||||
|
||||
getZoom(): number {
|
||||
return this.zoom;
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────────────────────── */
|
||||
|
||||
render(): RenderStats {
|
||||
const gl = this.gl;
|
||||
const canvas = this.canvas;
|
||||
|
||||
// Resize
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cw = (canvas.clientWidth * dpr) | 0;
|
||||
const ch = (canvas.clientHeight * dpr) | 0;
|
||||
if (canvas.width !== cw || canvas.height !== ch) {
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
}
|
||||
|
||||
gl.viewport(0, 0, cw, ch);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.bindVertexArray(this.vao);
|
||||
|
||||
// Uniforms: transform world coords to NDC
|
||||
gl.useProgram(this.program);
|
||||
gl.uniform2f(this.uCenter, this.cx, this.cy);
|
||||
gl.uniform2f(
|
||||
this.uScale,
|
||||
(this.zoom * 2) / cw,
|
||||
(-this.zoom * 2) / ch
|
||||
);
|
||||
|
||||
// Point size: world diameter → screen pixels, clamped
|
||||
const ptSize = Math.max(
|
||||
1.0,
|
||||
Math.min(this.maxPtSize, WORLD_RADIUS * 2 * this.zoom)
|
||||
);
|
||||
gl.uniform1f(this.uPtSize, ptSize);
|
||||
|
||||
// Frustum bounding box
|
||||
const viewW = cw / this.zoom;
|
||||
const viewH = ch / this.zoom;
|
||||
const vMinX = this.cx - viewW / 2;
|
||||
const vMaxX = this.cx + viewW / 2;
|
||||
const vMinY = this.cy - viewH / 2;
|
||||
const vMaxY = this.cy + viewH / 2;
|
||||
|
||||
let visibleCount = 0;
|
||||
let totalVisibleParticles = 0;
|
||||
|
||||
// 1. Find all visible leaves and total particles inside frustum
|
||||
for (let i = 0; i < this.leaves.length; i++) {
|
||||
const lf = this.leaves[i];
|
||||
if (
|
||||
lf.maxX < vMinX ||
|
||||
lf.minX > vMaxX ||
|
||||
lf.maxY < vMinY ||
|
||||
lf.minY > vMaxY
|
||||
)
|
||||
continue;
|
||||
|
||||
this.visibleLeafIndices[visibleCount++] = i;
|
||||
totalVisibleParticles += (lf.end - lf.start);
|
||||
}
|
||||
|
||||
// 2. Calculate dynamic sampling ratio based ONLY on visible particles
|
||||
const ratio = Math.min(1.0, MAX_DRAW / Math.max(1, totalVisibleParticles));
|
||||
|
||||
let drawnCount = 0;
|
||||
|
||||
// 3. Prepare index/count arrays for drawing
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const leafIdx = this.visibleLeafIndices[i];
|
||||
const lf = this.leaves[leafIdx];
|
||||
const leafTotal = lf.end - lf.start;
|
||||
|
||||
// Since the leaf is randomly unordered internally, taking the first N points
|
||||
// is a perfect uniform spatial sample of this leaf.
|
||||
const drawCount = Math.max(1, Math.floor(leafTotal * ratio));
|
||||
|
||||
this.startsArray[i] = lf.start;
|
||||
this.countsArray[i] = drawCount;
|
||||
drawnCount += drawCount;
|
||||
}
|
||||
|
||||
// 4. Draw Points!
|
||||
if (visibleCount > 0) {
|
||||
if (this.multiDrawExt) {
|
||||
this.multiDrawExt.multiDrawArraysWEBGL(
|
||||
gl.POINTS,
|
||||
this.startsArray, 0,
|
||||
this.countsArray, 0,
|
||||
visibleCount
|
||||
);
|
||||
} else {
|
||||
// Fallback: batch contiguous runs to minimize draw calls
|
||||
let currentStart = this.startsArray[0];
|
||||
let currentCount = this.countsArray[0];
|
||||
for (let i = 1; i < visibleCount; i++) {
|
||||
if (currentStart + currentCount === this.startsArray[i]) {
|
||||
currentCount += this.countsArray[i]; // Merge contiguous
|
||||
} else {
|
||||
gl.drawArrays(gl.POINTS, currentStart, currentCount);
|
||||
currentStart = this.startsArray[i];
|
||||
currentCount = this.countsArray[i];
|
||||
}
|
||||
}
|
||||
gl.drawArrays(gl.POINTS, currentStart, currentCount);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
|
||||
if (totalVisibleParticles < 20000 && visibleCount > 0) {
|
||||
gl.useProgram(this.lineProgram);
|
||||
gl.uniform2f(this.uCenterLine, this.cx, this.cy);
|
||||
gl.uniform2f(this.uScaleLine, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
||||
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const leafIdx = this.visibleLeafIndices[i];
|
||||
const lf = this.leaves[leafIdx];
|
||||
const leafTotal = lf.end - lf.start;
|
||||
if (leafTotal <= 1) continue;
|
||||
// Each node has 4 indices (2 lines)
|
||||
// Offset is in bytes: start index * 4 (indices per point) * 4 (bytes per uint32)
|
||||
gl.drawElements(gl.LINES, leafTotal * 4, gl.UNSIGNED_INT, lf.start * 16);
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||
}
|
||||
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
const mode = ratio === 1.0
|
||||
? '100% visible nodes'
|
||||
: ((ratio * 100).toFixed(1) + '% of visible nodes');
|
||||
|
||||
return { drawnCount, mode, zoom: this.zoom, ptSize };
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────── */
|
||||
|
||||
private compileProgram(vSrc: string, fSrc: string): WebGLProgram {
|
||||
const gl = this.gl;
|
||||
const vs = gl.createShader(gl.VERTEX_SHADER)!;
|
||||
gl.shaderSource(vs, vSrc);
|
||||
gl.compileShader(vs);
|
||||
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS))
|
||||
throw new Error("VS: " + gl.getShaderInfoLog(vs));
|
||||
|
||||
const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
|
||||
gl.shaderSource(fs, fSrc);
|
||||
gl.compileShader(fs);
|
||||
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS))
|
||||
throw new Error("FS: " + gl.getShaderInfoLog(fs));
|
||||
|
||||
const prog = gl.createProgram()!;
|
||||
gl.attachShader(prog, vs);
|
||||
gl.attachShader(prog, fs);
|
||||
|
||||
// Force a_pos to location 0 before linking so both programs match VAO
|
||||
gl.bindAttribLocation(prog, 0, "a_pos");
|
||||
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
|
||||
throw new Error("Link: " + gl.getProgramInfoLog(prog));
|
||||
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
return prog;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user