A real-time 3D LED visualizer for a custom spiral LED installation, controlled via the Art-Net lighting protocol over UDP. Built with Electron and Three.js.
The physical installation consists of 12 strands of RGB LEDs arranged along spiral curves on a 3D form. This app loads a CAD model of the form, extracts the positions of each individual LED chip from the geometry, and renders them as an interactive 3D view. LED colors are driven live by Art-Net DMX data received over UDP, allowing any standard lighting controller or software (e.g. MadMapper, TouchDesigner, QLC+, a custom sender) to control the visualization in real time.
Because Art-Net requires receiving UDP packets — which browsers cannot do — the app runs as an Electron desktop application rather than a plain web page.
npm install
npm startOn Linux, --no-sandbox is passed automatically in the start script to work around a common Electron sandbox setup requirement.
| Input | Action |
|---|---|
| Left-drag | Orbit camera |
| Right-drag / two-finger drag | Pan |
| Scroll wheel | Zoom |
The status indicator in the bottom-left corner shows Art-Net: waiting… until the first DMX packet is received, then switches to Art-Net: live.
The app is split across three Electron layers following standard Electron security practice:
┌─────────────────────────────────────────┐
│ Main process (main.js) │
│ - Creates BrowserWindow │
│ - Binds UDP socket on port 6454 │
│ - Parses Art-Net packets │
│ - Builds full LED color array │
│ - Sends colors to renderer via IPC │
│ - Serves STL file to renderer via IPC │
└────────────────┬────────────────────────┘
│ IPC (contextBridge)
┌────────────────▼────────────────────────┐
│ Preload (preload.js) │
│ - Exposes window.artnet.readModelFile()│
│ - Exposes window.artnet.onLEDUpdate() │
└────────────────┬────────────────────────┘
│
┌────────────────▼────────────────────────┐
│ Renderer process (renderer.js) │
│ - Parses STL binary │
│ - Extracts 12×600 LED positions │
│ - Renders model + LED InstancedMesh │
│ - Updates LED colors on IPC events │
└─────────────────────────────────────────┘
contextIsolation: true and nodeIntegration: false are enforced; the renderer has no direct Node.js access.
The STL file is served to the renderer via IPC rather than fetch() because Electron's file:// security policy can block cross-file fetches depending on platform.
The installation has 7,200 LEDs total:
- 12 strands × 600 LEDs per strand
- Each strand is split into two physical strips of 300 LEDs each
- 4 Art-Net universes per strand → 48 universes total (universes 0–47)
- Each universe carries 150 LEDs = 450 DMX channels (RGB, 3 channels each)
Universe Strand UnivInStrand LEDs covered
──────── ────── ──────────── ────────────
0 0 0 0–149
1 0 1 150–299
2 0 2 300–449
3 0 3 450–599
4 1 0 (reversed, see below)
5 1 1
...
44 11 0
45 11 1
46 11 2
47 11 3
The LED strands are wired in a serpentine pattern: adjacent strands are connected end-to-end, so half of them run in the opposite direction relative to the Art-Net universe numbering. Odd-numbered strands (1, 3, 5, 7, 9, 11) are reversed.
For a reversed strand, the mapping from Art-Net channel position to physical LED position is:
ledInStrand = 599 − (univInStrand × 150 + ledIndexWithinUniverse)
For a forward strand:
ledInStrand = univInStrand × 150 + ledIndexWithinUniverse
This reversal is resolved entirely in the main process (buildLEDColors() in main.js) before the color data is sent to the renderer. The renderer receives a simple flat array indexed by global LED number and has no knowledge of the reversal.
globalLED = strand × 600 + ledInStrand (0–7199)
The renderer's InstancedMesh assigns instances in the same strand/LED order, so globalLED maps directly to an instance index offset by strand.
The model is stored as a binary STL file (model-cleaned.stl, ~4.2 MB, ~87,120 triangles). It was exported from Rhino and contains only the LED chip geometry — no separate structural mesh.
When the STL was exported from Rhino, the triangles were written out in object order: all triangles belonging to strand 0 first, then strand 1, and so on. Within each strand, all triangles for LED chip 0 come before LED chip 1, etc. This ordering is exploited to avoid any complex mesh segmentation.
The renderer scans consecutive triangle centroids and detects large positional jumps (> 0.5 mm by default, STRAND_JUMP_THRESHOLD). Each jump indicates the transition between one strand and the next. This reliably finds all 12 strand boundaries.
Within each strand's triangle block, the triangles are divided into exactly 600 equal groups using integer-rounded linear interpolation. The centroid of each group's triangle centroids becomes the position of that LED in 3D space. This approach is robust regardless of the exact number of triangles per LED chip (which varies slightly across the mesh).
triStart = strandTriangleOffset + round(led × strandTriCount / 600)
triEnd = strandTriangleOffset + round((led+1) × strandTriCount / 600)
ledPosition = average of centroids[triStart..triEnd]
All 7,200 LED positions are rendered as a single THREE.InstancedMesh using BoxGeometry. The box size is auto-calculated as 70% of the measured spacing between the first two LEDs of the first strand. Instance colors are set via setColorAt(), which Three.js maps to the instanceColor buffer attribute used by the GPU instancing shader.
The same STL geometry is also rendered as a semi-transparent dark background mesh (MeshStandardMaterial, opacity 0.25) to show the physical form.
| File | Purpose |
|---|---|
main.js |
Electron main process: window creation, UDP Art-Net receiver, LED color mapping, IPC handlers |
preload.js |
Electron preload: exposes window.artnet API to renderer via contextBridge |
index.html |
HTML shell; sets up Three.js ES module importmap |
renderer.js |
Renderer process: STL parsing, LED extraction, Three.js scene, color updates |
model-cleaned.stl |
Binary STL of the LED chip geometry, exported from Rhino |
package.json |
npm metadata; electron and three as dependencies |
If the LED extraction or addressing doesn't look correct after connecting a controller, two constants at the top of their respective files can be adjusted:
renderer.js
STRAND_JUMP_THRESHOLD(default0.5mm) — increase if strands aren't being separated correctly; decrease if a single strand is being split in two.
main.js
- The reversed-strand logic assumes odd strands are reversed. If your wiring starts on the other end, change
(strand % 2) === 1to(strand % 2) === 0.