10m node viewer w/ quadtree
This commit is contained in:
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>10M Spheres – Quadtree WebGL2 Renderer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2564
package-lock.json
generated
Normal file
2564
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "react-vite-tailwind",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@webgpu/types": "^0.1.69",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "4.1.17",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "19.2.7",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
|
"tailwindcss": "4.1.17",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "7.2.4",
|
||||||
|
"vite-plugin-singlefile": "2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/App.tsx
Normal file
188
src/App.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Renderer } from "./renderer";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [status, setStatus] = useState("Generating 10M particles & building spatial index…");
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
fps: 0,
|
||||||
|
drawn: 0,
|
||||||
|
mode: "",
|
||||||
|
zoom: 0,
|
||||||
|
ptSize: 0,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
let renderer: Renderer;
|
||||||
|
try {
|
||||||
|
renderer = new Renderer(canvas);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build quadtree + upload (runs once, ~200ms)
|
||||||
|
const buildMs = renderer.init();
|
||||||
|
setStatus("");
|
||||||
|
console.log(`Init complete in ${buildMs.toFixed(0)}ms`);
|
||||||
|
|
||||||
|
// ── Input handling ──
|
||||||
|
let dragging = false;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
dragging = true;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
};
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
renderer.pan(e.clientX - lastX, e.clientY - lastY);
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
dragging = false;
|
||||||
|
};
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.9 : 1 / 0.9;
|
||||||
|
renderer.zoomAt(factor, e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("mousedown", onDown);
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
|
||||||
|
// ── Render loop ──
|
||||||
|
let frameCount = 0;
|
||||||
|
let lastTime = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const frame = () => {
|
||||||
|
const result = renderer.render();
|
||||||
|
frameCount++;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastTime >= 500) {
|
||||||
|
const fps = (frameCount / (now - lastTime)) * 1000;
|
||||||
|
setStats({
|
||||||
|
fps: Math.round(fps),
|
||||||
|
drawn: result.drawnCount,
|
||||||
|
mode: result.mode,
|
||||||
|
zoom: result.zoom,
|
||||||
|
ptSize: result.ptSize,
|
||||||
|
});
|
||||||
|
frameCount = 0;
|
||||||
|
lastTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
canvas.removeEventListener("mousedown", onDown);
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
canvas.removeEventListener("wheel", onWheel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ display: "block", width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{status && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "rgba(0,0,0,0.9)",
|
||||||
|
color: "#0f0",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error overlay */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "rgba(0,0,0,0.9)",
|
||||||
|
color: "#f44",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HUD */}
|
||||||
|
{!status && !error && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
background: "rgba(0,0,0,0.75)",
|
||||||
|
color: "#0f0",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
borderRadius: "4px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>FPS: {stats.fps}</div>
|
||||||
|
<div>Drawn: {stats.drawn.toLocaleString()} / 10,000,000</div>
|
||||||
|
<div>Mode: {stats.mode}</div>
|
||||||
|
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||||
|
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 10,
|
||||||
|
left: 10,
|
||||||
|
background: "rgba(0,0,0,0.75)",
|
||||||
|
color: "#888",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: "11px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Drag to pan · Scroll to zoom
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/index.css
Normal file
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
113
src/quadtree.ts
Normal file
113
src/quadtree.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Quadtree that spatially sorts a particle array in-place at build time.
|
||||||
|
* Stores only leaf index ranges [start, end) into the sorted array.
|
||||||
|
* NO per-frame methods — this is purely a build-time spatial index.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Leaf {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spatially sort particles using a quadtree and return
|
||||||
|
* the sorted array + leaf ranges.
|
||||||
|
*
|
||||||
|
* Takes raw Float32Arrays (no object allocation).
|
||||||
|
* Uses in-place partitioning (zero temporary arrays).
|
||||||
|
*/
|
||||||
|
export function buildSpatialIndex(
|
||||||
|
xs: Float32Array,
|
||||||
|
ys: Float32Array
|
||||||
|
): { sorted: Float32Array; leaves: Leaf[] } {
|
||||||
|
const n = xs.length;
|
||||||
|
const order = new Uint32Array(n);
|
||||||
|
for (let i = 0; i < n; i++) order[i] = i;
|
||||||
|
|
||||||
|
// Find bounds
|
||||||
|
let minX = Infinity,
|
||||||
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = xs[i], y = ys[i];
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaves: Leaf[] = [];
|
||||||
|
|
||||||
|
// In-place quicksort-style partitioning
|
||||||
|
function partition(
|
||||||
|
vals: Float32Array,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
mid: number
|
||||||
|
): number {
|
||||||
|
let lo = start,
|
||||||
|
hi = end - 1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
while (lo <= hi && vals[order[lo]] < mid) lo++;
|
||||||
|
while (lo <= hi && vals[order[hi]] >= mid) hi--;
|
||||||
|
if (lo < hi) {
|
||||||
|
const t = order[lo];
|
||||||
|
order[lo] = order[hi];
|
||||||
|
order[hi] = t;
|
||||||
|
lo++;
|
||||||
|
hi--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
bMinX: number,
|
||||||
|
bMinY: number,
|
||||||
|
bMaxX: number,
|
||||||
|
bMaxY: number,
|
||||||
|
depth: number
|
||||||
|
): void {
|
||||||
|
const count = end - start;
|
||||||
|
if (count <= 0) return;
|
||||||
|
|
||||||
|
// Leaf: stop subdividing
|
||||||
|
if (count <= 4096 || depth >= 12) {
|
||||||
|
leaves.push({ start, end, minX: bMinX, minY: bMinY, maxX: bMaxX, maxY: bMaxY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const midX = (bMinX + bMaxX) / 2;
|
||||||
|
const midY = (bMinY + bMaxY) / 2;
|
||||||
|
|
||||||
|
// Partition by X, then each half by Y
|
||||||
|
const splitX = partition(xs, start, end, midX);
|
||||||
|
const splitLeftY = partition(ys, start, splitX, midY);
|
||||||
|
const splitRightY = partition(ys, splitX, end, midY);
|
||||||
|
|
||||||
|
// BL, TL, BR, TR
|
||||||
|
build(start, splitLeftY, bMinX, bMinY, midX, midY, depth + 1);
|
||||||
|
build(splitLeftY, splitX, bMinX, midY, midX, bMaxY, depth + 1);
|
||||||
|
build(splitX, splitRightY, midX, bMinY, bMaxX, midY, depth + 1);
|
||||||
|
build(splitRightY, end, midX, midY, bMaxX, bMaxY, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
build(0, n, minX, minY, maxX, maxY, 0);
|
||||||
|
|
||||||
|
// Reorder particles to match tree layout
|
||||||
|
const sorted = new Float32Array(n * 2);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const src = order[i];
|
||||||
|
sorted[i * 2] = xs[src];
|
||||||
|
sorted[i * 2 + 1] = ys[src];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sorted, leaves };
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/utils/cn.ts
Normal file
6
src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path mapping */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
19
vite.config.ts
Normal file
19
vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss(), viteSingleFile()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user