Skip to content

Commit 3764697

Browse files
committed
Network rendering (unfinished)
1 parent 692eaab commit 3764697

4 files changed

Lines changed: 638 additions & 11 deletions

File tree

bindsnet/network/network.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,13 @@ def enable_spike_history(self, layer_name: str, total_timesteps: int) -> dict:
679679
:param total_timesteps: Desired history length (typically the full run).
680680
:return: ``{'vbo', 'T', 'n', 'row'}`` for the owning widget/visual.
681681
"""
682+
# Reuse: a layer may be recorded by more than one widget (e.g. a RasterPlot
683+
# and a NetworkPlot both showing the same layer's spikes). Allocate the
684+
# CUDA/GL buffer once and hand back the existing handle on later calls.
685+
if layer_name in self._spike_history:
686+
h = self._spike_history[layer_name]
687+
return {'vbo': h['vbo'], 'T': h['T'], 'n': h['n'], 'row': h['row']}
688+
682689
layer = self.layers[layer_name]
683690
n = int(layer.n)
684691
batch = int(self.batch_size)
@@ -1049,6 +1056,16 @@ def step(self, input: Dict[str, torch.Tensor], t: int = None) -> None:
10491056
)
10501057
)
10511058

1059+
# Input.forward REBINDS self.s (`self.s = x`) rather than writing in
1060+
# place like LIFNodes (torch.ge(out=self.s)), so the spikes never reach
1061+
# the mapped history row above. Copy them in and repoint so the row holds
1062+
# this step's input spikes and downstream reads stay on the buffer.
1063+
if l in views and isinstance(self.layers[l], Input):
1064+
h = self._spike_history[l]
1065+
if t < h['T']:
1066+
views[l][t].copy_(self.layers[l].s)
1067+
self.layers[l].s = views[l][t]
1068+
10521069
# Fold this step's voltage into the recorded layer's running min/max
10531070
# while the row is still mapped (forward() just wrote it in place). GPU
10541071
# reductions only -- no host sync here; the widget syncs when it draws.

bindsnet/rendering/visuals.py

Lines changed: 331 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212

1313

1414
class _UnsetSamplerBufferFilter(logging.Filter):
15-
# RasterHistoryVisual / VoltageHistoryVisual deliberately never set their
16-
# samplerBuffer uniforms (`u_spikes`, `u_volts`): gloo has no samplerBuffer
17-
# support, so each defaults to texture unit 0, which we bind by hand. VisPy's
18-
# one-time program validation logs that as an "unset variable" -- drop just
19-
# those messages so they don't look like errors.
15+
# RasterHistoryVisual / VoltageHistoryVisual / NeuronCloudVisual deliberately
16+
# never set their samplerBuffer uniforms (`u_spikes`, `u_volts`, `u_fire`): gloo
17+
# has no samplerBuffer support, so each defaults to texture unit 0, which we bind
18+
# by hand. VisPy's one-time program validation logs that as an "unset variable"
19+
# -- drop just those messages so they don't look like errors.
20+
_UNSET = ('u_spikes', 'u_volts', 'u_fire')
21+
2022
def filter(self, record):
2123
msg = record.getMessage()
22-
return 'u_spikes' not in msg and 'u_volts' not in msg
24+
return not any(name in msg for name in self._UNSET)
2325

2426

2527
logging.getLogger('vispy').addFilter(_UnsetSamplerBufferFilter())
@@ -384,3 +386,326 @@ def __del__(self):
384386

385387

386388
FeatureMatrix = create_visual_node(FeatureMatrixVisual)
389+
390+
391+
# Neurons-as-circles, with firing read straight from the spike-history GL buffer.
392+
# Each neuron is one GL_POINTS vertex placed at a static layout position (a_pos).
393+
# Firing is pulled zero-copy from the SAME (T, batch, n) R8UI spike-history buffer
394+
# RasterHistoryVisual reads (see GUINetwork.enable_spike_history): the vertex shader
395+
# texelFetches this neuron's spike at the current timestep (and a few preceding ones)
396+
# to compute a fading "glow" intensity, and the fragment shader draws a filled disc
397+
# coloured between the layer's base colour and the fire colour. No per-frame copy:
398+
# the owning widget just updates the u_t uniform each draw.
399+
#
400+
# #version 140: gives texelFetch + usamplerBuffer while still allowing gl_FragColor
401+
# and the attribute/varying qualifiers vispy's transform Function emits (vispy
402+
# rewrites these to in/out to match the version automatically).
403+
_NEURON_VERT = """
404+
#version 140
405+
attribute vec2 a_pos; // neuron position in DATA coords (static layout)
406+
attribute float a_index; // neuron id within the layer (row index into u_fire)
407+
uniform usamplerBuffer u_fire; // R8UI spike history; left UNSET -> texture unit 0
408+
uniform int u_t; // current timestep
409+
uniform int u_T; // total timesteps in the buffer
410+
uniform int u_stride; // elements per timestep row (= batch*n)
411+
uniform int u_glow; // afterglow window (timesteps); >=1
412+
uniform float u_pointsize; // on-screen disc diameter in pixels
413+
varying float v_intensity; // 0..1 firing glow -> fragment
414+
void main() {
415+
gl_Position = $transform(vec4(a_pos, 0.0, 1.0));
416+
gl_PointSize = u_pointsize;
417+
418+
// Max spike over [t-u_glow+1, t] with linear falloff, so a spike stays visible
419+
// for a few frames even when draws are throttled (batch 0; time-major buffer).
420+
int idx = int(a_index);
421+
float inten = 0.0;
422+
for (int k = 0; k < u_glow; k++) {
423+
int tt = u_t - k;
424+
if (tt < 0 || tt >= u_T) continue;
425+
uint s = texelFetch(u_fire, tt * u_stride + idx).r;
426+
if (s != 0u) inten = max(inten, 1.0 - float(k) / float(u_glow));
427+
}
428+
v_intensity = inten;
429+
}
430+
"""
431+
432+
_NEURON_FRAG = """
433+
#version 140
434+
varying float v_intensity;
435+
uniform vec4 u_base; // resting colour
436+
uniform vec4 u_fire_color; // colour at full firing intensity
437+
void main() {
438+
// Round the square point sprite into a disc.
439+
vec2 d = gl_PointCoord - vec2(0.5);
440+
if (dot(d, d) > 0.25) discard;
441+
gl_FragColor = mix(u_base, u_fire_color, v_intensity);
442+
}
443+
"""
444+
445+
446+
class NeuronCloudVisual(Visual):
447+
def __init__(self, positions, indices, total_timesteps, row_stride, gl_buffer_id,
448+
base_color=(0.25, 0.25, 0.30, 1.0), fire_color=(1.0, 0.9, 0.2, 1.0),
449+
point_size=9.0, glow=8):
450+
self.n = int(len(positions))
451+
self.T = int(total_timesteps)
452+
self.stride = int(row_stride) # = batch * n; idx = time*stride + neuron
453+
self._gl_buffer_id = int(gl_buffer_id) # raw GL buffer with the spike history
454+
self._tbo_tex = None # GL texture viewing that buffer (lazy)
455+
Visual.__init__(self, vcode=_NEURON_VERT, fcode=_NEURON_FRAG)
456+
457+
self._pos = np.asarray(positions, dtype=np.float32)
458+
self._pos_vbo = gloo.VertexBuffer(self._pos)
459+
self._index_vbo = gloo.VertexBuffer(np.asarray(indices, dtype=np.float32))
460+
self.shared_program['a_pos'] = self._pos_vbo
461+
self.shared_program['a_index'] = self._index_vbo
462+
self.shared_program['u_t'] = 0
463+
self.shared_program['u_T'] = self.T
464+
self.shared_program['u_stride'] = self.stride
465+
self.shared_program['u_glow'] = max(1, int(glow))
466+
self.shared_program['u_pointsize'] = float(point_size)
467+
self.shared_program['u_base'] = base_color
468+
self.shared_program['u_fire_color'] = fire_color
469+
# NOTE: u_fire is deliberately never set (gloo has no samplerBuffer support); it
470+
# defaults to texture unit 0, which we bind by hand in _prepare_draw.
471+
self._draw_mode = 'points'
472+
self.set_gl_state('translucent', depth_test=False)
473+
474+
def set_time(self, t):
475+
self.shared_program['u_t'] = int(t)
476+
477+
def _create_tbo(self):
478+
# 1-D R8UI view of the spike-history buffer; glTexBuffer references it (no copy).
479+
tex = gl.glGenTextures(1)
480+
gl.glBindTexture(gl.GL_TEXTURE_BUFFER, tex)
481+
gl.glTexBuffer(gl.GL_TEXTURE_BUFFER, gl.GL_R8UI, self._gl_buffer_id)
482+
gl.glBindTexture(gl.GL_TEXTURE_BUFFER, 0)
483+
self._tbo_tex = tex
484+
485+
def _prepare_draw(self, view):
486+
if self._tbo_tex is None:
487+
self._create_tbo()
488+
# Round point sprites need program-controlled point size. Enable it here (raw
489+
# GL) so gl_PointSize from the vertex shader takes effect; harmless if already on.
490+
gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
491+
# Bind our buffer-texture to unit 0 (u_fire samples unit 0 by default). Same
492+
# immediate-bind trick RasterHistoryVisual uses; per-program flush keeps it live
493+
# through this visual's draw.
494+
gl.glActiveTexture(gl.GL_TEXTURE0)
495+
gl.glBindTexture(gl.GL_TEXTURE_BUFFER, self._tbo_tex)
496+
497+
def _prepare_transforms(self, view):
498+
view.view_program.vert['transform'] = view.transforms.get_transform()
499+
500+
def _compute_bounds(self, axis, view):
501+
if axis in (0, 1) and self.n:
502+
return (float(self._pos[:, axis].min()), float(self._pos[:, axis].max()))
503+
return None
504+
505+
# No __del__: the buffer texture is freed when the GL context is destroyed (see
506+
# the note on RasterHistoryVisual).
507+
508+
509+
NeuronCloud = create_visual_node(NeuronCloudVisual)
510+
511+
512+
# Synapses-as-lines. A single GL_LINES draw covers every selected synapse across all
513+
# connections: each segment is two vertices in `positions`, coloured per-vertex from
514+
# the synapse weight (`colors`). Forward edges are one straight segment; recurrent /
515+
# back edges are pre-tessellated into several short segments along a bowed curve by
516+
# the owning widget, so they still live in this one flat lines buffer. The colour
517+
# buffer is rebuildable via set_colors -- the hook for later weight-change rendering.
518+
_SYNAPSE_VERT = """
519+
#version 140
520+
attribute vec2 a_pos;
521+
attribute vec4 a_color;
522+
varying vec4 v_color;
523+
void main() {
524+
gl_Position = $transform(vec4(a_pos, 0.0, 1.0));
525+
v_color = a_color;
526+
}
527+
"""
528+
529+
_SYNAPSE_FRAG = """
530+
#version 140
531+
varying vec4 v_color;
532+
void main() { gl_FragColor = v_color; }
533+
"""
534+
535+
536+
class SynapseLinesVisual(Visual):
537+
def __init__(self, positions, colors):
538+
Visual.__init__(self, vcode=_SYNAPSE_VERT, fcode=_SYNAPSE_FRAG)
539+
self._pos = np.asarray(positions, dtype=np.float32)
540+
self._pos_vbo = gloo.VertexBuffer(self._pos)
541+
self._color_vbo = gloo.VertexBuffer(np.asarray(colors, dtype=np.float32))
542+
self.shared_program['a_pos'] = self._pos_vbo
543+
self.shared_program['a_color'] = self._color_vbo
544+
self._draw_mode = 'lines'
545+
self.set_gl_state('translucent', depth_test=False)
546+
547+
def set_colors(self, colors):
548+
# Goal-3 hook: re-drive per-vertex colour from refreshed weights. `colors` must
549+
# match the vertex count established at construction (2 verts per segment).
550+
self._color_vbo.set_data(np.asarray(colors, dtype=np.float32))
551+
self.update()
552+
553+
def _prepare_draw(self, view):
554+
pass
555+
556+
def _prepare_transforms(self, view):
557+
view.view_program.vert['transform'] = view.transforms.get_transform()
558+
559+
def _compute_bounds(self, axis, view):
560+
if axis in (0, 1) and len(self._pos):
561+
return (float(self._pos[:, axis].min()), float(self._pos[:, axis].max()))
562+
return None
563+
564+
565+
SynapseLines = create_visual_node(SynapseLinesVisual)
566+
567+
568+
# Cached synapse lines: the synapse geometry is STATIC, but a single SceneCanvas
569+
# clears and redraws every visual each frame, so plain SynapseLines re-pays its
570+
# (vertex-bound) line-draw cost on every frame -- ~24 ms with another plot on the
571+
# canvas (profiled). This visual draws the lines ONCE into an offscreen texture (an
572+
# FBO covering the network's data-space bounding box) and then, every frame, draws a
573+
# single camera-transformed textured quad over that bbox. Because the quad lives in
574+
# DATA coordinates, the scene camera pans/zooms it exactly like the lines would move,
575+
# so the expensive line pass NEVER re-runs on camera changes -- only when the colours
576+
# change (`set_colors`, the goal-3 weight-change hook). Trade-off: the cache is a
577+
# raster snapshot, so zooming far in shows texture pixelation (raise `max_side` or
578+
# call `refresh()` for a re-render at the current detail if that matters).
579+
_CACHED_LINE_VERT = """
580+
#version 120
581+
attribute vec2 a_pos;
582+
attribute vec4 a_color;
583+
uniform vec2 u_scale; // 2/(x1-x0), 2/(y1-y0): bbox -> clip, offscreen pass
584+
uniform vec2 u_offset; // (x0, y0)
585+
varying vec4 v_color;
586+
void main() {
587+
// Map the data-space bbox to clip space [-1, 1] directly (no matrix-convention
588+
// ambiguity): x0 -> -1, x1 -> +1, likewise y. The FBO viewport then puts x0,y0 at
589+
// texel (0, 0), matching the display quad's (0, 0) texcoord at corner (x0, y0).
590+
vec2 ndc = (a_pos - u_offset) * u_scale - 1.0;
591+
gl_Position = vec4(ndc, 0.0, 1.0);
592+
v_color = a_color;
593+
}
594+
"""
595+
596+
_CACHED_LINE_FRAG = """
597+
#version 120
598+
varying vec4 v_color;
599+
void main() { gl_FragColor = v_color; }
600+
"""
601+
602+
# Display quad: a textured rectangle spanning the bbox in data coords, positioned by
603+
# the scene transform (camera). vispy's default GLSL handles attribute/varying and the
604+
# $transform Function injection.
605+
_CACHED_QUAD_VERT = """
606+
attribute vec2 a_pos;
607+
attribute vec2 a_tex;
608+
varying vec2 v_tex;
609+
void main() {
610+
gl_Position = $transform(vec4(a_pos, 0.0, 1.0));
611+
v_tex = a_tex;
612+
}
613+
"""
614+
615+
_CACHED_QUAD_FRAG = """
616+
uniform sampler2D u_tex;
617+
varying vec2 v_tex;
618+
void main() { gl_FragColor = texture2D(u_tex, v_tex); }
619+
"""
620+
621+
622+
class CachedSynapseLinesVisual(Visual):
623+
def __init__(self, positions, colors, bbox, max_side=2048):
624+
Visual.__init__(self, vcode=_CACHED_QUAD_VERT, fcode=_CACHED_QUAD_FRAG)
625+
x0, y0, x1, y1 = (float(v) for v in bbox)
626+
# Guard against a degenerate (zero-area) bbox.
627+
if x1 <= x0:
628+
x1 = x0 + 1.0
629+
if y1 <= y0:
630+
y1 = y0 + 1.0
631+
self._bbox = (x0, y0, x1, y1)
632+
633+
# Offscreen resolution: longest side = max_side, other side by aspect.
634+
aspect = (x1 - x0) / (y1 - y0)
635+
if aspect >= 1.0:
636+
W, H = int(max_side), max(16, int(round(max_side / aspect)))
637+
else:
638+
W, H = max(16, int(round(max_side * aspect)), ), int(max_side)
639+
self._W, self._H = int(W), int(H)
640+
641+
# Offscreen line program (raw gloo; rendered into the FBO in refresh()).
642+
self._line_prog = gloo.Program(_CACHED_LINE_VERT, _CACHED_LINE_FRAG)
643+
self._line_prog['a_pos'] = gloo.VertexBuffer(np.asarray(positions, dtype=np.float32))
644+
self._line_color = gloo.VertexBuffer(np.asarray(colors, dtype=np.float32))
645+
self._line_prog['a_color'] = self._line_color
646+
self._line_prog['u_scale'] = (2.0 / (x1 - x0), 2.0 / (y1 - y0))
647+
self._line_prog['u_offset'] = (x0, y0)
648+
self._n_verts = int(len(positions))
649+
650+
self._tex = None # FBO colour texture (lazy; needs a GL context)
651+
self._fbo = None
652+
self.dirty = True # needs an offscreen render before the quad is meaningful
653+
654+
# Display quad over the bbox (data coords) with matching texcoords.
655+
corners = np.array([[x0, y0], [x1, y0], [x0, y1], [x1, y1]], dtype=np.float32)
656+
texco = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.float32)
657+
self.shared_program['a_pos'] = gloo.VertexBuffer(corners)
658+
self.shared_program['a_tex'] = gloo.VertexBuffer(texco)
659+
self._draw_mode = 'triangle_strip'
660+
self.set_gl_state('translucent', depth_test=False)
661+
662+
def refresh(self):
663+
# Render the (static) lines into the offscreen texture. Cheap to call when clean
664+
# via `if visual.dirty: visual.refresh()`; the owning widget gates it. Runs OUTSIDE
665+
# the scene draw (from the widget's render()), so the nested FBO pass doesn't
666+
# interleave with vispy's per-visual GLIR flush.
667+
if self._tex is None:
668+
self._tex = gloo.Texture2D(
669+
shape=(self._H, self._W, 4), format='rgba', interpolation='linear')
670+
self._fbo = gloo.FrameBuffer(color=self._tex)
671+
self.shared_program['u_tex'] = self._tex
672+
673+
with self._fbo:
674+
gloo.set_viewport(0, 0, self._W, self._H)
675+
gloo.set_state(blend=True, depth_test=False,
676+
blend_func=('src_alpha', 'one_minus_src_alpha'))
677+
gloo.clear(color=(0.0, 0.0, 0.0, 0.0))
678+
self._line_prog.draw('lines')
679+
# FrameBuffer.__exit__ rebinds the default FBO but does NOT restore the viewport,
680+
# so reset it to the full canvas; otherwise the next on-screen draw is squashed
681+
# into the FBO's (W, H) rect. (Belt-and-suspenders: vispy also resets it per draw.)
682+
canvas = getattr(self, 'canvas', None)
683+
if canvas is not None:
684+
w, h = canvas.physical_size
685+
gloo.set_viewport(0, 0, int(w), int(h))
686+
self.dirty = False
687+
688+
def set_colors(self, colors):
689+
# Goal-3 hook: re-drive per-vertex colour from refreshed weights, then mark the
690+
# cache stale so the next render() re-bakes the texture.
691+
self._line_color.set_data(np.asarray(colors, dtype=np.float32))
692+
self.dirty = True
693+
694+
def _prepare_draw(self, view):
695+
# If the texture isn't baked yet (first frame before the widget called refresh),
696+
# bake it now so the quad has something to sample.
697+
if self._tex is None:
698+
self.refresh()
699+
700+
def _prepare_transforms(self, view):
701+
view.view_program.vert['transform'] = view.transforms.get_transform()
702+
703+
def _compute_bounds(self, axis, view):
704+
if axis == 0:
705+
return (self._bbox[0], self._bbox[2])
706+
if axis == 1:
707+
return (self._bbox[1], self._bbox[3])
708+
return None
709+
710+
711+
CachedSynapseLines = create_visual_node(CachedSynapseLinesVisual)

0 commit comments

Comments
 (0)