February 19, 2026 · 8 min
Simulation vs Real Hardware: Designing LED Visualization Tools
Building a software simulator for LED hardware taught me more about good software design than the hardware itself did. Here's how I built a pixel-accurate LED preview tool.
Simulation vs Real Hardware: Designing LED Visualization Tools
TL;DR: A software simulator for LED hardware is not just a nice-to-have — it's a development multiplier. You can test 100% of your logic before touching real hardware. Here's how I built a pixel-accurate LED matrix simulator for J-Tech.
Why Simulate?
Every time you want to test something on real LED hardware, you go through a cycle:
- Make code change
- Compile / package
- Connect hardware
- Upload
- Observe
With a good simulator, steps 2–4 disappear. You test in real-time in your browser. This alone probably saved me 40+ hours across the project.
There are also scenarios where simulation is essential:
- Hardware isn't with you — working from a different location
- Hardware is broken — you can still develop and test
- Testing edge cases — extreme colors, boundary pixels, invalid inputs
- Demoing to stakeholders — no physical setup needed
What Makes a Good LED Simulator
A pixel grid in a div is not a LED simulator. Real LEDs have distinct visual characteristics:
- Circular light source shape — LEDs aren't square pixels; they're round dots
- Bloom/glow effect — bright LEDs emit light beyond their physical boundary
- Gamma response — LEDs behave non-linearly (gamma ≈ 2.2)
- Color mixing — adjacent bright LEDs blend their light
- Dark background — LEDs look completely different on black vs white backgrounds
Getting these right is what makes a simulator actually useful for predicting real-world appearance.
Implementation: Canvas-Based
I started with HTML Canvas for speed of implementation, then graduated to WebGL for performance on large matrices.
Canvas Version (64×32 and below)
class LEDCanvasSimulator {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
constructor(canvas: HTMLCanvasElement, private config: MatrixConfig) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.resize();
}
resize() {
// Scale canvas to fill container while maintaining pixel ratio
const pixelRatio = window.devicePixelRatio || 1;
const ledSize = 20; // pixels per LED in simulation
const spacing = 2; // gap between LEDs
this.canvas.width = (this.config.width * (ledSize + spacing)) * pixelRatio;
this.canvas.height = (this.config.height * (ledSize + spacing)) * pixelRatio;
this.ctx.scale(pixelRatio, pixelRatio);
this.ledSize = ledSize;
this.spacing = spacing;
}
render(rgbData: Uint8Array) {
const { ctx, config, ledSize, spacing } = this;
// Black background (critical for accurate LED appearance)
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
for (let y = 0; y < config.height; y++) {
for (let x = 0; x < config.width; x++) {
const idx = (y * config.width + x) * 3;
const r = rgbData[idx];
const g = rgbData[idx + 1];
const b = rgbData[idx + 2];
// Calculate LED center position
const cx = x * (ledSize + spacing) + ledSize / 2;
const cy = y * (ledSize + spacing) + ledSize / 2;
this.drawLED(cx, cy, ledSize / 2, r, g, b);
}
}
}
private drawLED(
cx: number, cy: number, radius: number,
r: number, g: number, b: number
) {
const { ctx } = this;
const brightness = (r + g + b) / (255 * 3);
// Draw LED glow (bloom effect)
if (brightness > 0.1) {
const glowRadius = radius * (1 + brightness * 1.5);
const glowGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowRadius);
glowGradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${brightness * 0.4})`);
glowGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = glowGradient;
ctx.beginPath();
ctx.arc(cx, cy, glowRadius, 0, Math.PI * 2);
ctx.fill();
}
// Draw LED core (circular gradient)
const coreGradient = ctx.createRadialGradient(
cx - radius * 0.2, cy - radius * 0.2, 0, // highlight offset
cx, cy, radius
);
// Bright center, darker edge (mimics real LED optics)
coreGradient.addColorStop(0, `rgb(${Math.min(255, r + 40)}, ${Math.min(255, g + 40)}, ${Math.min(255, b + 40)})`);
coreGradient.addColorStop(0.7, `rgb(${r}, ${g}, ${b})`);
coreGradient.addColorStop(1, `rgb(${Math.round(r * 0.6)}, ${Math.round(g * 0.6)}, ${Math.round(b * 0.6)})`);
ctx.fillStyle = coreGradient;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
}
}
WebGL Version (128×64 and above)
For large matrices, Canvas is too slow. A 128×64 matrix has 8,192 LEDs — drawing that at 60fps in Canvas is choppy. WebGL handles it easily:
// Fragment shader: LED simulation
precision mediump float;
uniform sampler2D u_pixels;
uniform vec2 u_resolution; // Canvas resolution
uniform vec2 u_matrixSize; // LED matrix dimensions
uniform float u_ledSize; // Size of each LED in pixels
uniform float u_ledSpacing; // Gap between LEDs
varying vec2 v_texCoord;
// Simulates LED glow falloff
float ledBrightness(vec2 pos, vec2 ledCenter, float radius) {
float dist = length(pos - ledCenter);
if (dist > radius * 2.5) return 0.0;
// Core brightness (sharp circular LED)
float core = 1.0 - smoothstep(0.0, radius, dist);
// Glow (soft falloff beyond LED edge)
float glow = 1.0 - smoothstep(radius, radius * 2.5, dist);
return core * 0.8 + glow * 0.2;
}
void main() {
vec2 pixelPos = v_texCoord * u_resolution;
float cellSize = u_ledSize + u_ledSpacing;
// Which LED are we nearest to?
vec2 ledCoord = floor(pixelPos / cellSize);
vec2 ledCenter = ledCoord * cellSize + u_ledSize / 2.0;
// Sample LED color from texture
vec2 uv = ledCoord / u_matrixSize;
vec3 ledColor = texture2D(u_pixels, uv).rgb;
// Apply LED brightness simulation
float brightness = ledBrightness(pixelPos, ledCenter, u_ledSize / 2.0);
// Gamma correction (gamma 2.2 → linear for display)
vec3 linearColor = pow(ledColor, vec3(2.2));
gl_FragColor = vec4(linearColor * brightness, 1.0);
}
Gamma Correction: A Crucial but Subtle Detail
LEDs are perceived by human eyes non-linearly. Without gamma correction, a value of 128 looks much brighter than 50% brightness.
// The gamma curve makes simulation match real hardware
const GAMMA_LUT = Array.from({ length: 256 }, (_, i) =>
Math.round(Math.pow(i / 255, 2.2) * 255)
);
// Apply before sending to hardware
function applyGamma(pixels: Uint8Array): Uint8Array {
return pixels.map(v => GAMMA_LUT[v]);
}
// But in the simulator, render BEFORE gamma to show what you set
// Apply gamma only in the hardware-upload code path
The simulator shows you what you intended (pre-gamma), and you can toggle a "hardware preview" mode that shows what it will actually look like on LEDs (post-gamma). This helps catch surprises.
Side-by-Side Comparison Features
The UI has three view modes:
| Mode | Description | |------|-------------| | Design View | Raw pixel colors, square grid — good for precise editing | | Simulator View | LED rendering with glow/bloom — what you'll see in person | | Hardware View | Gamma-corrected simulator — exact hardware output |
Switching between modes dynamically is trivial: same pixel data, different render shader.
Testing Animation Sequences
Beyond static frames, the simulator handles animations:
class AnimationPlayback {
private frames: Uint8Array[];
private currentFrame = 0;
private intervalId: number | null = null;
constructor(
private simulator: LEDCanvasSimulator | LEDWebGLSimulator,
private fps = 30
) {}
loadFrames(frames: Uint8Array[]) {
this.frames = frames;
this.currentFrame = 0;
}
play() {
this.intervalId = window.setInterval(() => {
this.simulator.render(this.frames[this.currentFrame]);
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
}, 1000 / this.fps);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}
Simulator vs Real Hardware: Where They Differ
The simulator is 90% accurate, but there are some unavoidable differences:
| Characteristic | Simulator | Real Hardware | |---------------|-----------|---------------| | Response time | Instant | ~16ms refresh | | Color accuracy | Monitor-dependent | LED-dependent | | Viewing angle | Always perfect | Degrades at angles | | Ambient light | Ignored | Major factor | | LED aging | Not modeled | Colors shift over time | | Physical brightness | Approximate | Varies by power supply |
For development, these differences are acceptable. For final calibration, always do a real hardware check.
Lessons Learned
- Build the simulator first — every hour spent on the simulator saves 3 hours of hardware debugging
- Match the background color — always use
#000000; the difference between dark gray and true black is huge on LEDs - Glow matters for perception — without glow, the simulator looks flat and doesn't represent reality
- Gamma is not optional — omitting it will cause confusing quality differences between sim and hardware
- WebGL performance headroom — even for 512×256 matrices, a properly written WebGL renderer stays above 60fps