← Back to tech insights

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:

  1. Make code change
  2. Compile / package
  3. Connect hardware
  4. Upload
  5. 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:

  1. Circular light source shape — LEDs aren't square pixels; they're round dots
  2. Bloom/glow effect — bright LEDs emit light beyond their physical boundary
  3. Gamma response — LEDs behave non-linearly (gamma ≈ 2.2)
  4. Color mixing — adjacent bright LEDs blend their light
  5. 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

  1. Build the simulator first — every hour spent on the simulator saves 3 hours of hardware debugging
  2. Match the background color — always use #000000; the difference between dark gray and true black is huge on LEDs
  3. Glow matters for perception — without glow, the simulator looks flat and doesn't represent reality
  4. Gamma is not optional — omitting it will cause confusing quality differences between sim and hardware
  5. WebGL performance headroom — even for 512×256 matrices, a properly written WebGL renderer stays above 60fps

Explore J-Tech Pixel LED Upload Bridge: Portfolio | GitHub