Files
visualizador_instanciados/src/renderer.ts
2026-02-13 16:39:41 -03:00

668 lines
23 KiB
TypeScript

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
}
`;
const SELECTED_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(1.0, 0.5, 0.0, 0.9); // orange for selected
}
`;
const NEIGHBOR_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(1.0, 0.9, 0.0, 0.8); // yellow for neighbors
}
`;
/* ── Types ──────────────────────────────────────────────── */
export interface RenderStats {
drawnCount: number;
mode: string;
zoom: number;
ptSize: number;
}
/* ── Constants ──────────────────────────────────────────── */
const WORLD_RADIUS = 4.0; // sphere world-space radius
const MAX_DRAW = 2_000_000; // max particles to draw per frame
/* ── Renderer ───────────────────────────────────────────── */
export class Renderer {
private gl: WebGL2RenderingContext;
private canvas: HTMLCanvasElement;
private program: WebGLProgram;
private lineProgram: WebGLProgram;
private selectedProgram: WebGLProgram;
private neighborProgram: WebGLProgram;
private vao: WebGLVertexArrayObject;
// Data
private leaves: Leaf[] = [];
private sorted: Float32Array = new Float32Array(0);
private nodeCount = 0;
private edgeCount = 0;
private neighborMap: Map<number, number[]> = new Map();
private sortedToVertexId: Uint32Array = new Uint32Array(0);
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
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 uCenterSelected: WebGLUniformLocation;
private uScaleSelected: WebGLUniformLocation;
private uPtSizeSelected: WebGLUniformLocation;
private uCenterNeighbor: WebGLUniformLocation;
private uScaleNeighbor: WebGLUniformLocation;
private uPtSizeNeighbor: WebGLUniformLocation;
private linesIbo: WebGLBuffer;
// Selection
private selectionIbo: WebGLBuffer;
private selectionCount = 0;
// Neighbors
private neighborIbo: WebGLBuffer;
private neighborCount = 0;
// 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);
this.selectedProgram = this.compileProgram(VERT, SELECTED_FRAG);
this.neighborProgram = this.compileProgram(VERT, NEIGHBOR_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")!;
this.uCenterSelected = gl.getUniformLocation(this.selectedProgram, "u_center")!;
this.uScaleSelected = gl.getUniformLocation(this.selectedProgram, "u_scale")!;
this.uPtSizeSelected = gl.getUniformLocation(this.selectedProgram, "u_ptSize")!;
this.uCenterNeighbor = gl.getUniformLocation(this.neighborProgram, "u_center")!;
this.uScaleNeighbor = gl.getUniformLocation(this.neighborProgram, "u_scale")!;
this.uPtSizeNeighbor = gl.getUniformLocation(this.neighborProgram, "u_ptSize")!;
// 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()!;
this.selectionIbo = gl.createBuffer()!;
this.neighborIbo = 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);
}
/**
* Load particles from pre-computed positions and edges, build quadtree, upload to GPU.
* vertexIds: original vertex IDs from CSV (parallel to xs/ys)
* edges: flat array of [srcVertexId, dstVertexId, ...]
* Call once at startup. Returns build time in ms.
*/
init(
xs: Float32Array,
ys: Float32Array,
vertexIds: Uint32Array,
edges: Uint32Array
): number {
const t0 = performance.now();
const gl = this.gl;
const count = xs.length;
const edgeCount = edges.length / 2;
this.nodeCount = count;
// Build quadtree (spatially sorts the array)
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
this.leaves = leaves;
this.sorted = sorted;
// 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 sorted index → vertex ID mapping for hover lookups
this.sortedToVertexId = new Uint32Array(count);
for (let i = 0; i < count; i++) {
this.sortedToVertexId[i] = vertexIds[order[i]];
}
// Build vertex ID → original input index mapping
const vertexIdToOriginal = new Map<number, number>();
for (let i = 0; i < count; i++) {
vertexIdToOriginal.set(vertexIds[i], i);
}
// Build original input index → sorted index mapping
// order[sortedIdx] = originalIdx, so invert it
const originalToSorted = new Uint32Array(count);
for (let i = 0; i < count; i++) {
originalToSorted[order[i]] = i;
}
// Remap edges from vertex IDs to sorted indices
const lineIndices = new Uint32Array(edgeCount * 2);
let validEdges = 0;
for (let i = 0; i < edgeCount; i++) {
const srcId = edges[i * 2];
const dstId = edges[i * 2 + 1];
const srcOrig = vertexIdToOriginal.get(srcId);
const dstOrig = vertexIdToOriginal.get(dstId);
if (srcOrig === undefined || dstOrig === undefined) continue;
lineIndices[validEdges * 2] = originalToSorted[srcOrig];
lineIndices[validEdges * 2 + 1] = originalToSorted[dstOrig];
validEdges++;
}
this.edgeCount = validEdges;
// Build per-node neighbor list from edges for selection queries
const neighborMap = new Map<number, number[]>();
for (let i = 0; i < validEdges; i++) {
const src = lineIndices[i * 2];
const dst = lineIndices[i * 2 + 1];
if (!neighborMap.has(src)) neighborMap.set(src, []);
neighborMap.get(src)!.push(dst);
if (!neighborMap.has(dst)) neighborMap.set(dst, []);
neighborMap.get(dst)!.push(src);
}
this.neighborMap = neighborMap;
// Build per-leaf edge index for efficient visible-only edge drawing
// Find which leaf each sorted index belongs to
const nodeToLeaf = new Uint32Array(count);
for (let li = 0; li < leaves.length; li++) {
const lf = leaves[li];
for (let j = lf.start; j < lf.end; j++) {
nodeToLeaf[j] = li;
}
}
// Count edges per leaf (by source node)
const leafEdgeCounts = new Uint32Array(leaves.length);
for (let i = 0; i < validEdges; i++) {
leafEdgeCounts[nodeToLeaf[lineIndices[i * 2]]]++;
}
// Compute prefix sums for edge offsets per leaf
const leafEdgeOffsets = new Uint32Array(leaves.length);
for (let i = 1; i < leaves.length; i++) {
leafEdgeOffsets[i] = leafEdgeOffsets[i - 1] + leafEdgeCounts[i - 1];
}
// Sort edges by source leaf into a new buffer
const sortedEdgeIndices = new Uint32Array(validEdges * 2);
const leafEdgeCurrent = new Uint32Array(leaves.length);
for (let i = 0; i < validEdges; i++) {
const leafIdx = nodeToLeaf[lineIndices[i * 2]];
const pos = leafEdgeOffsets[leafIdx] + leafEdgeCurrent[leafIdx];
sortedEdgeIndices[pos * 2] = lineIndices[i * 2];
sortedEdgeIndices[pos * 2 + 1] = lineIndices[i * 2 + 1];
leafEdgeCurrent[leafIdx]++;
}
this.leafEdgeStarts = leafEdgeOffsets;
this.leafEdgeCounts = leafEdgeCounts;
// Upload sorted edges to GPU
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, sortedEdgeIndices, 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;
}
getNodeCount(): number {
return this.nodeCount;
}
/**
* Get the original vertex ID for a given sorted index.
* Useful for looking up URI labels from the URI map.
*/
getVertexId(sortedIndex: number): number | undefined {
if (sortedIndex < 0 || sortedIndex >= this.sortedToVertexId.length) return undefined;
return this.sortedToVertexId[sortedIndex];
}
/**
* Convert screen coordinates (CSS pixels) to world coordinates.
*/
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const dpr = window.devicePixelRatio || 1;
const px = screenX * dpr;
const py = screenY * dpr;
const wx = this.cx + (px - this.canvas.width / 2) / this.zoom;
const wy = this.cy + (py - this.canvas.height / 2) / this.zoom;
return { x: wx, y: wy };
}
/**
* Find the node closest to the given screen position.
* Uses the quadtree to narrow down the search.
* Returns the node's world coordinates if found within the visual radius, or null.
*/
findNodeAt(screenX: number, screenY: number): { x: number; y: number } | null {
const result = this.findNodeIndexAt(screenX, screenY);
return result ? { x: result.x, y: result.y } : null;
}
/**
* Find the node closest to the given screen position.
* Returns the node's index and world coordinates if found, or null.
*/
findNodeIndexAt(screenX: number, screenY: number): { index: number; x: number; y: number } | null {
if (this.sorted.length === 0) return null;
const world = this.screenToWorld(screenX, screenY);
const wx = world.x;
const wy = world.y;
// Calculate the search radius in world units (based on point size on screen)
// We use a slightly larger radius for easier hovering
const dpr = window.devicePixelRatio || 1;
const ptSizeScreen = Math.max(1.0, Math.min(this.maxPtSize, WORLD_RADIUS * 2 * this.zoom));
const hitRadius = (ptSizeScreen / this.zoom / dpr) * 0.75; // world units
const hitRadiusSq = hitRadius * hitRadius;
let closestDist = Infinity;
let closestIndex = -1;
let closestX = 0;
let closestY = 0;
// Traverse all leaves and check if they intersect with the hit area
for (let i = 0; i < this.leaves.length; i++) {
const lf = this.leaves[i];
// Quick AABB check: does this leaf possibly contain points near our target?
if (
wx + hitRadius < lf.minX ||
wx - hitRadius > lf.maxX ||
wy + hitRadius < lf.minY ||
wy - hitRadius > lf.maxY
) {
continue; // Leaf is too far away
}
// Check all points in this leaf
for (let j = lf.start; j < lf.end; j++) {
const px = this.sorted[j * 2];
const py = this.sorted[j * 2 + 1];
const dx = px - wx;
const dy = py - wy;
const distSq = dx * dx + dy * dy;
if (distSq < hitRadiusSq && distSq < closestDist) {
closestDist = distSq;
closestIndex = j;
closestX = px;
closestY = py;
}
}
}
return closestIndex >= 0 ? { index: closestIndex, x: closestX, y: closestY } : null;
}
/**
* Update the selection buffer with the given set of node indices.
* Also computes neighbors of selected nodes.
* Call this whenever React's selection state changes.
*/
updateSelection(selectedIndices: Set<number>): void {
const gl = this.gl;
// Upload selected indices
const indices = new Uint32Array(selectedIndices);
this.selectionCount = indices.length;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionIbo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
// Compute neighbors of selected nodes (excluding already selected)
const neighborSet = new Set<number>();
for (const nodeIdx of selectedIndices) {
const nodeNeighbors = this.neighborMap.get(nodeIdx);
if (!nodeNeighbors) continue;
for (const n of nodeNeighbors) {
if (!selectedIndices.has(n)) {
neighborSet.add(n);
}
}
}
// Upload neighbor indices
const neighborIndices = new Uint32Array(neighborSet);
this.neighborCount = neighborIndices.length;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndices, gl.DYNAMIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
/**
* Get the coordinates of a node by its index.
*/
getNodeCoords(index: number): { x: number; y: number } | null {
if (index < 0 || index * 2 + 1 >= this.sorted.length) return null;
return {
x: this.sorted[index * 2],
y: this.sorted[index * 2 + 1],
};
}
/* ── 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 edgeCount = this.leafEdgeCounts[leafIdx];
if (edgeCount === 0) continue;
// Each edge is 2 indices (1 line segment)
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
const edgeStart = this.leafEdgeStarts[leafIdx];
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
// 6. Draw Neighbor Nodes (yellow) - drawn before selected so selected appears on top
if (this.neighborCount > 0) {
gl.useProgram(this.neighborProgram);
gl.uniform2f(this.uCenterNeighbor, this.cx, this.cy);
gl.uniform2f(this.uScaleNeighbor, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
gl.uniform1f(this.uPtSizeNeighbor, ptSize);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
gl.drawElements(gl.POINTS, this.neighborCount, gl.UNSIGNED_INT, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
// 7. Draw Selected Nodes on top (orange)
if (this.selectionCount > 0) {
gl.useProgram(this.selectedProgram);
gl.uniform2f(this.uCenterSelected, this.cx, this.cy);
gl.uniform2f(this.uScaleSelected, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
gl.uniform1f(this.uPtSizeSelected, ptSize);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionIbo);
gl.drawElements(gl.POINTS, this.selectionCount, gl.UNSIGNED_INT, 0);
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;
}
}