feat: fix WebGL visualiser with improved node rendering and new icon handling logic

This commit is contained in:
Ivan 2026-07-18 07:02:10 -05:00
parent 345a1d1f8a
commit ecbb47042f
No known key found for this signature in database
4 changed files with 249 additions and 27 deletions

Binary file not shown.

View file

@ -14,6 +14,12 @@ const ATLAS_COLS = 16;
const ATLAS_ROWS = 16;
const ATLAS_CAPACITY = ATLAS_COLS * ATLAS_ROWS;
/** Soft-edge AA width in UV radius units (1.0 = disc edge). Keep tight to avoid fuzzy blobs. */
export const NODE_EDGE_INNER = 0.96;
/** Border ring starts inside the disc (untextured and under glyphs). */
export const NODE_BORDER_INNER = 0.78;
export const NODE_BORDER_OUTER = 0.96;
const NODE_VS = `#version 300 es
layout(location=0) in vec2 a_corner;
layout(location=1) in vec2 a_center;
@ -54,20 +60,24 @@ out vec4 outColor;
void main() {
float d = length(v_uv);
if (d > 1.0) discard;
float edge = smoothstep(1.0, 0.82, d);
float rim = smoothstep(0.92, 1.0, d);
// Tight AA so nodes read as crisp discs, not soft fuzzy blobs.
float edge = smoothstep(1.0, ${NODE_EDGE_INNER.toFixed(2)}, d);
float border = smoothstep(${NODE_BORDER_INNER.toFixed(2)}, ${NODE_BORDER_OUTER.toFixed(2)}, d);
vec3 fill = v_color.rgb;
vec3 rim = fill * 0.55;
if (v_useTex > 0.5) {
vec2 local = v_uv * 0.5 + 0.5;
vec2 texUV = v_uvOrigin + local * u_cellUv;
vec4 tex = texture(u_atlas, texUV);
// Opaque RGB icons (and RGB canvases) may report a=0 in some uploads.
float texA = max(tex.a, 0.001);
float a = texA * edge * v_color.a;
if (a < 0.01) discard;
vec3 rgb = mix(tex.rgb, v_color.rgb, rim * 0.85);
float texA = clamp(tex.a, 0.0, 1.0);
// Colored disc from node color, optional glyph/logo from atlas on top.
vec3 rgb = mix(fill, tex.rgb, texA);
rgb = mix(rgb, rim, border * (1.0 - texA));
float a = edge * v_color.a;
if (a < 0.02) discard;
outColor = vec4(rgb, a);
} else {
vec3 rgb = mix(v_color.rgb * 0.92, v_color.rgb, 1.0 - rim);
vec3 rgb = mix(fill, rim, border);
outColor = vec4(rgb, v_color.a * edge);
}
}
@ -214,6 +224,64 @@ export function resolveVisualiserAssetUrl(url) {
return trimmed;
}
/**
* True when the asset is a solid-fill network-visualiser badge (colored disc + light glyph).
* Those should be converted to white-on-transparent glyphs so the shader can paint node color.
* @param {string} url
*/
export function isGlyphStyleVisualiserIcon(url) {
if (!url || typeof url !== "string") return false;
return /\/network-visualiser\//i.test(url);
}
/**
* Prepare atlas RGBA pixels for upload.
*
* - opaque: force non-empty pixels to a=255 (RGB PNG uploads)
* - glyph: keep near-white / bright pixels as white glyphs, clear the solid fill
*
* @param {Uint8ClampedArray|Uint8Array} data RGBA buffer (mutated)
* @param {"opaque"|"glyph"} mode
* @returns {{painted:number,glyphPixels:number}}
*/
export function prepareVisualiserIconPixels(data, mode = "opaque") {
let painted = 0;
let glyphPixels = 0;
if (!data || data.length < 4) return { painted: 0, glyphPixels: 0 };
const glyphMode = mode === "glyph";
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
if (!(r | g | b | a)) continue;
painted += 1;
if (glyphMode) {
// Stock badges: bright glyph on saturated fill. Keep bright pixels.
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
const maxc = Math.max(r, g, b);
const minc = Math.min(r, g, b);
const sat = maxc - minc;
const isGlyph = luma >= 185 || (luma >= 150 && sat < 40);
if (isGlyph) {
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255;
glyphPixels += 1;
} else {
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
data[i + 3] = 0;
}
} else {
data[i + 3] = 255;
}
}
return { painted, glyphPixels };
}
function createIconAtlas(gl) {
const width = ATLAS_COLS * ATLAS_CELL;
const height = ATLAS_ROWS * ATLAS_CELL;
@ -242,7 +310,7 @@ function createIconAtlas(gl) {
return nextSlot++;
}
function paintSlot(slot, source) {
function paintSlot(slot, source, url) {
if (!scratchCtx || !scratch) return false;
scratchCtx.save();
scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
@ -259,19 +327,26 @@ function createIconAtlas(gl) {
const dx = Math.floor((ATLAS_CELL - dw) / 2);
const dy = Math.floor((ATLAS_CELL - dh) / 2);
scratchCtx.drawImage(source, dx, dy, dw, dh);
// Force full opacity for RGB sources that leave alpha at 0 after upload.
const pixels = scratchCtx.getImageData(0, 0, ATLAS_CELL, ATLAS_CELL);
const data = pixels.data;
let painted = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i] | data[i + 1] | data[i + 2] | data[i + 3]) {
data[i + 3] = 255;
painted += 1;
const mode = isGlyphStyleVisualiserIcon(url) ? "glyph" : "opaque";
if (mode === "glyph") {
const backup = new Uint8ClampedArray(data);
const { painted, glyphPixels } = prepareVisualiserIconPixels(data, "glyph");
if (painted < 8) {
scratchCtx.restore();
return false;
}
if (glyphPixels < 8) {
data.set(backup);
prepareVisualiserIconPixels(data, "opaque");
}
} else {
const { painted } = prepareVisualiserIconPixels(data, "opaque");
if (painted < 8) {
scratchCtx.restore();
return false;
}
}
if (painted < 8) {
scratchCtx.restore();
return false;
}
scratchCtx.putImageData(pixels, 0, 0);
scratchCtx.restore();
@ -325,7 +400,7 @@ function createIconAtlas(gl) {
if (slot == null) return null;
const work = loadImageSource(url)
.then((img) => {
const ok = paintSlot(slot, img);
const ok = paintSlot(slot, img, url);
if (typeof img.close === "function") {
try {
img.close();

View file

@ -83,8 +83,9 @@ function colorFromNode(node) {
if (rgb) return rgb;
}
if (c && typeof c === "object") {
const border = c.border || c.background;
const rgb = hexToRgb01(border);
// Border is the vivid badge color; background is a pale tint for vis-network.
const fill = c.border || c.background;
const rgb = hexToRgb01(fill);
if (rgb) return rgb;
}
return null;
@ -121,12 +122,18 @@ function kindForNode(node) {
function sizeForNode(node, kind) {
const s = Number(node?.size);
if (Number.isFinite(s) && s > 0) {
return Math.max(10, Math.min(34, s * 0.55));
// Keep WebGL radii close to vis-network so glyphs stay readable.
return Math.max(18, Math.min(48, s * 0.9));
}
if (kind === KIND_ME) return 26;
if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 18;
if (kind === KIND_DISCOVERED) return 14;
return 16;
if (kind === KIND_ME) return 32;
if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 24;
if (kind === KIND_DISCOVERED) return 20;
return 22;
}
/** Exported for unit tests. */
export function webglNodeSizeFor(node, kind) {
return sizeForNode(node, kind == null ? kindForNode(node) : kind);
}
function imageForNode(node, kind) {

View file

@ -0,0 +1,140 @@
// SPDX-License-Identifier: 0BSD
/**
* Regression tests for WebGL visualiser node look:
* crisp discs, glyph-on-color badges, readable sizes, no soft fuzzy blobs.
*/
import { describe, expect, it } from "vitest";
import {
isGlyphStyleVisualiserIcon,
NODE_BORDER_INNER,
NODE_BORDER_OUTER,
NODE_EDGE_INNER,
prepareVisualiserIconPixels,
resolveVisualiserAssetUrl,
} from "@/js/networkVisualiserWebGL.js";
import {
graphToSceneRequest,
KIND_IFACE_ON,
KIND_ME,
KIND_PEER,
webglNodeSizeFor,
} from "@/js/networkVisualiserWebGLEngine.js";
describe("visualiser WebGL node look regressions", () => {
it("keeps disc soft-edge tight (no fuzzy blob AA)", () => {
expect(NODE_EDGE_INNER).toBeGreaterThanOrEqual(0.94);
expect(NODE_BORDER_INNER).toBeLessThan(NODE_BORDER_OUTER);
expect(NODE_BORDER_OUTER).toBeLessThanOrEqual(NODE_EDGE_INNER + 0.001);
});
it("treats network-visualiser badge PNGs as glyph-style icons", () => {
expect(isGlyphStyleVisualiserIcon("/assets/images/network-visualiser/user.png")).toBe(true);
expect(isGlyphStyleVisualiserIcon("/assets/images/network-visualiser/interface_connected.png")).toBe(
true
);
expect(isGlyphStyleVisualiserIcon("/assets/images/reticulum_logo_512.png")).toBe(false);
expect(isGlyphStyleVisualiserIcon("")).toBe(false);
});
it("extracts white glyph and clears solid fill from badge pixels", () => {
// 2x2: blue fill, white glyph, blue fill, empty
const data = new Uint8ClampedArray([
59,
130,
246,
255,
255,
255,
255,
255,
59,
130,
246,
255,
0,
0,
0,
0,
]);
const { painted, glyphPixels } = prepareVisualiserIconPixels(data, "glyph");
expect(painted).toBe(3);
expect(glyphPixels).toBe(1);
expect(data[0]).toBe(0);
expect(data[3]).toBe(0);
expect(data[4]).toBe(255);
expect(data[7]).toBe(255);
expect(data[8]).toBe(0);
expect(data[11]).toBe(0);
});
it("opaque mode forces alpha on RGB pixels for logo uploads", () => {
const data = new Uint8ClampedArray([10, 20, 30, 0, 0, 0, 0, 0]);
const { painted } = prepareVisualiserIconPixels(data, "opaque");
expect(painted).toBe(1);
expect(data[3]).toBe(255);
});
it("falls back to opaque when glyph extraction would wipe the icon", () => {
// All mid-saturation blue: no bright glyph pixels.
const data = new Uint8ClampedArray(16);
for (let i = 0; i < 16; i += 4) {
data[i] = 59;
data[i + 1] = 130;
data[i + 2] = 246;
data[i + 3] = 255;
}
const backup = new Uint8ClampedArray(data);
const first = prepareVisualiserIconPixels(data, "glyph");
expect(first.glyphPixels).toBe(0);
expect(first.painted).toBe(4);
// paintSlot restores the pre-glyph buffer before opaque fallback.
data.set(backup);
prepareVisualiserIconPixels(data, "opaque");
expect(data[3]).toBe(255);
expect(data[0]).toBe(59);
});
it("WebGL node sizes stay large enough for glyphs", () => {
expect(webglNodeSizeFor({ size: 25 }, KIND_PEER)).toBeGreaterThanOrEqual(18);
expect(webglNodeSizeFor({ size: 50 }, KIND_ME)).toBeGreaterThanOrEqual(30);
expect(webglNodeSizeFor({ size: 35 }, KIND_IFACE_ON)).toBeGreaterThanOrEqual(24);
// Must not shrink to the old fuzzy 0.55 scale (~13px for size 25).
expect(webglNodeSizeFor({ size: 25 }, KIND_PEER)).toBeGreaterThan(16);
});
it("graph scene colors use vivid border for disc fill", () => {
const req = graphToSceneRequest(
[
{
id: "peer",
group: "announce",
size: 25,
color: { border: "#3b82f6", background: "#eff6ff" },
},
{
id: "direct",
group: "announce",
size: 25,
color: { border: "#10b981", background: "#ecfdf5" },
},
],
[],
{ width: 100, height: 100, zoom: 1 }
);
expect(req.nodes[0].r).toBeCloseTo(0x3b / 255, 2);
expect(req.nodes[0].g).toBeCloseTo(0x82 / 255, 2);
expect(req.nodes[0].b).toBeCloseTo(0xf6 / 255, 2);
expect(req.nodes[1].r).toBeCloseTo(0x10 / 255, 2);
expect(req.nodes[1].g).toBeCloseTo(0xb9 / 255, 2);
// Pale backgrounds must not become the disc color (that looked washed out).
expect(req.nodes[0].r).toBeGreaterThan(0.15);
expect(req.nodes[0].size).toBeGreaterThanOrEqual(18);
});
it("resolveVisualiserAssetUrl keeps absolute asset paths resolvable", () => {
const url = resolveVisualiserAssetUrl("/assets/images/network-visualiser/user.png");
expect(url).toContain("/assets/images/network-visualiser/user.png");
});
});