Remote logic waveform display using WebGL

Logic analyser display using WebGL

In a previous post, I used hardware-accelerated graphics to display oscilloscope waveforms in a browser; the next step is to plot logic waveforms, as would normally be displayed on a logic analyser.

We could potentially use exactly the same techniques as before, but this becomes quite inefficient when displaying a large number of logic channels. A real-time display needs a fast redraw-rate, which means maximising the work done by the Graphics Processor (GPU), since it can perform parallel calculations much faster than the Central Processor (CPU), even when running on a low-cost PC, tablet or mobile phone.

Looking at the above graphic, you can see that a major computation step is converting each 16-bit sample value into 16 ‘high’ or ‘low’ trace values. The trace has 20,000 samples, so 320,000 such calculations are needed, which is still quite do-able on the CPU, but it is not unusual for the sample count to be a million or more, which can result in a very sluggish display, making it difficult to scroll through the data, when looking for a specific data pattern.

Fortunately WebGL version 2 supports a technique called ‘instanced rendering’, which we can use to generate 16 or more trace values from a single data word. So we can feed the GPU with a block of raw sample data, and it will split that word into bits, and draw the appropriate vectors on the display, applying the necessary scaling, offsets and colouring.

Shader programming

Graphics Processor (GPU) programming is generally known as shader programming because the main computational elements are the ‘vertex’ and ‘fragment’ shaders, that take in a stream of data, and output a stream of pixels.

To optimise the shader operation, all the attributes for all the lines are usually stored in a single buffer, which is passed to the shaders as a single block; this allows the shaders to work at maximum speed, without having to interact with the main CPU.

In addition to this data, there are ‘uniform’ variables, that allow slowly-changing data values to be passed from the CPU to the GPU, for example specifying the scale, offset and colour of the individual traces.

Instanced rendering

Traditionally there is a one-to-one relationship between the incoming vertex attributes, and an object plotted on the screen; for example, the attributes may specify a line in 3 dimensions (2 points, each with z, y and z dimensions) and the shaders will draw that line on the display, by colouring in the necessary pixels.

Instanced rendering (in WebGL v2) allows a single set of vertex attributes to generate several copies (‘instances’) of an object. For example, one set of attributes can generate 100 identical objects, which is much more efficient than using adding 100 sets of attributes to the buffer.

At first sight, it is difficult to understand how this can be of use; the instances share the same coordinate values, so won’t they all be plotted on top of each other? The answer is yes, they will all be at the same location, unless we use the ‘instance number’ provided by the shader to space them out. So if I’m creating 16 instances from one sample value, they will be numbered 0 to 15. This allows me to assign a specific y-value to each instance, ensuring they are stacked in the display without overlap – and I can also use the instance number as a mask to select the appropriate bit-value to be plotted:

// Simplified vertex shader code

// Array with input data
in float a_data;

// Array with y scale & offset values
uniform vec2 u_scoffs[MAX_CHANS];

// Number of data points
uniform int u_npoints;

// Main shader operation
void main(void) {
    // Convert sample value from float to integer
    int d = int(a_data);

    // Get data bit for this instance (this trace)
    bool hi = (d & (1<<gl_InstanceID)) != 0;

    // Get x-position from vertex num, normalised to +/- 1.0
    float x = float(gl_VertexID) * (2.0/float(u_npoints)) - 1.0;

    // Get y-position from scale & offset array
    float y = u_scoffs[gl_InstanceID][1];

    // Adjust y-value if bit is high
    if (hi)
        y += u_scoffs[gl_InstanceID][0];

    // Set xyz position of this point
    gl_Position = vec4(x, y, 0, 1);

    // Set colour of this point (red if high)
    v_colour = hi ? vec4(0.8, 0.2, 0.2, 1.0) : vec4(0.3, 0.3, 0.3, 1.0);

A notable feature is that I’ve specified the input data as being 32-bit floating-point numbers, when the incoming data samples are really 16-bit integers. This is because I had difficulty in persuading the shader code to compile with 16 or 32-bit integer inputs; only floating-point numbers seem to be acceptable for the vertex attributes. There is evidence on the Web to suggest that this is a feature of the shader hardware, and not a shortcoming of the compiler, but regardless of the reason, I’m doing the conversion to and from floating-point values, until I can be sure that integer attributes won’t cause problems.

The Javascript command to draw the traces is:

gl.drawArraysInstanced(gl.LINE_STRIP, 0, trace_data.length, num_traces);

The lines are plotted in ‘strip’ mode, so plotting 20,000 line segments requires 20,001 coordinate points, as the end of one line segment is the same as the start of the next line. This has the consequence of making all the rising and falling edges non-vertical, as seen below.

Zoomed-in analyser trace

This display is highly unconventional; logic analysers normally only draw vertical & horizontal lines. However, I quite like this feature, since it acts as a reminder of the limitations due to the sample rate; if a pulse is shown as a triangle, it only changed state for one sample-period.


We need a way of adding annotation to the WebGL display, for example the trace identification boxes on the left-hand side. It is possible to draw these using WebGL, but the process is a bit complex; it is much easier to just overlay a second canvas with a 2D rendering context, and use that API to superimpose lines & text onto the WebGL image, e.g.

      .container {
         position: relative;
      #graph_canvas {
         position: relative;
         left: 0; top: 0; z-index: 1;
      #text_canvas {
         position: absolute;
         left: 0; top: 0; z-index: 10;
     <div class="container">
         <canvas id="graph_canvas"></canvas>
         <canvas id="text_canvas"></canvas>

The text canvas will remain transparent until we draw on it, for example:

// Get 2D context
var text_ctx = text_canvas.getContext("2d");

// Clear the text canvas
function text_clear() {
    text_ctx.clearRect(0, 0, text_ctx.canvas.width, text_ctx.canvas.height);
// Draw a text box, given bottom-left corner & size
function text_box(x, y, w, h, txt) {
    text_ctx.font = h + 'px sans-serif';
    text_ctx.textBaseline = "bottom";
    text_ctx.rect(x-1, y-h-1, w+2, h);
    text_ctx.fillText(txt, x, y, w);


An analyser trace may have a million or more 16-bit samples, so we need the ability to magnify an area of interest. In theory I could extract the relevant section of the data, and feed it to the shader as a smaller buffer, but the main thrust of this project is to let the shader do all the hard work, so I always give it the full set of samples, and a two ‘uniform’ variables are used to specify an offset into the data, and the number of samples to be displayed.

bool hi = (d & (1<<gl_InstanceID)) != 0;
float x = float(gl_VertexID-u_disp_oset) * (2.0/float(u_disp_nsamp)) - 1.0;
float y = u_scoffs[gl_InstanceID][1];
if (hi)
    y += u_scoffs[gl_InstanceID][0];
gl_Position = vec4(x, y, 0, 1);

To calculate the x-position, the VertexID is used, which is an incrementing counter that keeps track of how many samples have been processed; this is combined with disp_oset and disp_nsamp which are the offset (in samples) of the start of the data to be displayed, and the number of samples to be displayed. The resulting x-value is normalised, i.e. scaled to within -1.0 and +1.0 for all the pixels that should be displayed; any pixels outside that range will be suppressed.

The y-position of a ‘0’ bit is derived from the offset array for each channel; for a ‘1’ bit, a scale value is added on. This allows the main Javascript code to control the position & amplitude of each trace, ensuring they don’t overlap.

16-channel display

Number of channels

I’ve imposed an arbitrary maximum of 16 channels (i.e. 16 traces in the display); the GPU is capable of plotting many more than that, but there is a limitation due to the fact that we’re sending floating-point values to the vertex shader; this has a 24-bit mantissa, so if we feed in values with 24 or more bits, there is the risk that the floating-point value will be an approximation of the sample data, which is useless for this application, since the precise value of each bit is important. So take care when increasing the number of channels beyond 16; make sure the data being plotted is the same as the data being gathered.

The web page includes selection boxes to set the number of channels, and zoom level. The Javascript code automatically adjusts the scale & offset values for each channel; you can resize the window to anything convenient, and they will automatically fill the given canvas size.

8 channel display with zoom

Browser compatibility

The code should run on relatively modern browsers that support WebGL v2, regardless of operating system; I have had success running Chrome 87 and Firefox 61 on Windows 7 & 10, Linux and Android.

With regard to Apple products, older browsers such as Safari 10 don’t work at all, and I’ve had no success with an iPhone 8. However, Safari 14 does work if you specifically enable WebGL 2 in the developer ‘experimental features’, and Chrome on a Macbook Pro runs fine.

Running the code

There is a single HTML file webgl_logic.html, which has all the page formatting, Javascript, and GLSL v2 shader code; it is available on Github here.

When loaded into a suitable browser, it will display 16 waveforms, representing 20,000 samples with a 16-bit binary value incrementing from zero. The display can be animated once by clicking ‘single’, or continuously using ‘run’. You can adjust the zoom level at any time by clicking the selection box, or pressing ‘+’ or ‘-‘ on the keyboard. The offset of the zoomed area is modified by clicking on the display area, then using the left and right-arrow keys.

To experiment with larger data sets, you can increase the NSAMP value in the Javascript code. Don’t forget that by default every data sample generates 16 traces, so 1 million samples means the GPU will be generating 16 million vectors; the speed with which it can do this will depend on the specification of your hardware, and size of the display window, but you can get reasonable performance on a typical office PC; an expensive graphics card isn’t necessary.

Copyright (c) Jeremy P Bentham 2021. Please credit this blog if you use the information or software in it.

Remote oscilloscope display using WebGL

Using WebGL for a remote oscilloscope display

In a previous post, I gathered analog data on the Raspberry Pi, and used OpenGL to plot a fast oscilloscope-type graphic. However, in many cases it is inconvenient to have a local display; it would be much better to send the data over the network, to be displayed remotely.

Separating the data-gathering from the data-display has many other advantages. Modern PCs can draw graphics at very high speed, so offloading this task from the Pi makes a lot of sense; it is also possible to view the real-time image on a tablet or mobile phone, removing the need for bulky equipment and cabling.

If you want to display logic waveforms instead of analogue traces, see my other post.

WebGL programming

WebGL works within a Web browser, to provide hardware-accelerated graphics, similar to OpenGL. It is essentially OpenGL ES, with a Javascript wrapper for compatibility with other browser code.

If you are a newcomer to OpenGL, I suggest you read one of the many tutorials on the Web; there are also ‘live’ interactive sites, where you can experiment with code, and immediately see the result.

There are two fundamental approaches to using WebGL graphics; you can treat it as a simple graphics card, and issue sequential commands to individually draw the display items you want, but this approach can be quite slow, as the repated handovers between Javascript and OpenGL take a significant amount of time.

Alternatively, you can pre-prepare the object to be drawn as an array of 3D data points (known as ‘vertex attributes’), then issue a single call to OpenGL to render those points. This is much faster, as the graphics hardware can work at top speed processing the points.

WebGL vertex processing

Each vertex attribute can have multiple dimensions; I’m using 3 dimensions for each point. The x and y values are normalised to +/- 1.0, so the bottom left corner of the graph is -1, -1, and the top right is +1.0, +1.0. The shaders have a very powerful matrix-arithmetic capability, so it is easy to transform these values into a user-defined scale, but I’ve found this can be very confusing, so I’ve kept the normalised defaults.

The z-value is used to indicate which trace is being drawn; the background grid is z = zero, the first trace is z = 1.0, and so on.

The vertex shader must be instructed what to do with the array of vertices. My previous Raspberry Pi code used ‘strip’ mode (GL_LINE_STRIP), which meant that the vertices form a single continuous polyline, the end of one line segment being used as the start of the next. This had the advantage of minimising the amount of data needed, but meant that I had to perform some z-value tricks to handle the transition from the end of one trace to the start of the next.

To avoid that problem, in this project I’ve used ‘line’ mode (gl.LINES) whereby each line is drawn individually, with a pair of vertices to mark its start & end. This almost doubles the amount of data we have to send to the shader, but does simplify the code.

WebGL versions

Modern browsers support can support WebGL version 1.0 and 2.0. The former is based on OpenGL ES 2.0, the latter OpenGL ES 3.0. To add to the confusion, the GLSL shader language is version 1 for OpenGL ES 2, and version 2 for OpenGL ES 3.

Why does this matter? Unfortunately there are significant differences between the two shader language versions; you can’t create a single source-file that works with both. Many of the examples on the Web don’t specify which version they are targeting, but it is quite easy to tell: if some variables are defined using ‘in’ and ‘out’, then the language is GLSL v2, e.g.

// GLSL v2 definitions:
  in vec3 a_coords;
  out vec4 v_colour;

// GLSL v1 definitions:
  attribute vec3 a_coords;
  varying vec4 v_colour;

I have provided both versions, with a boolean Javascript constant (WEBGL2) to switch between them.

Shader programming

The core of our application is the shader code, that processes the attributes. We are using two shaders; one to convert the vertices into ‘fragment’ data, and the other to convert the fragments into pixels. Since the shader programs are quite short, they can be included as ‘template literal’ strings in the Javascript code. These begin and end with a back-tick (instead of a single or double quote character) and can have multiple lines, with the possibility of variable substitution. The vertex shader code is

vert_code = `#version 300 es
    #define MAX_CHANS ${MAX_CHANS}
    in vec3 a_coords;
    out vec4 v_colour;
    uniform vec4 u_colours[MAX_CHANS];
    uniform vec2 u_scoffs[MAX_CHANS];
    vec2 scoff;
    void main(void) {
        int zint = int(a_coords.z);
        scoff = u_scoffs[zint];
        gl_Position = vec4(a_coords.x, a_coords.y*scoff.x + scoff.y, 0, 1);
        v_colour = u_colours[zint];

The 300 ES version definition must be on the first line, or it will be rejected.

The strange-looking MAX_CHANS definition takes the value of a Javascript constant, and creates a matching GLSL definition.

The z-value of each vertex is used to determine the drawing colour by indexing into a ‘uniform’ array, i.e. an array that has been preset in advance by the Javascript code. The z-value is also used to determine the y-value scale and offset (‘scoff’), obtained from another uniform array. So the magnitude and offset of each trace can be individually controlled, by changing the constants loaded into the ‘scoff’ array.

The fragment shader doesn’t do much; it just copies the colour of the fragment into the colour of the pixel:

frag_code = `#version 300 es
    precision mediump float;
    in vec4 v_colour;
    out vec4 o_colour;
    void main() {
        o_colour = v_colour;

These programs need to be compiled before use, and there is some simple Javascript code to do this, but that raises the question: if there is an error, how is it displayed, since we’re in a browser? I’ve taken an easy way out and thrown an exception:

// Compile a shader
function compile_shader(typ, source) {
    var s = gl.createShader(typ);
    gl.shaderSource(s, source);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
        throw "Could not compile " +
              (typ==gl.VERTEX_SHADER ? "vertex" : "fragment") +
              " shader:\n\n"+gl.getShaderInfoLog(s);

The error messages are sometimes a bit simplistic, and you may need to be a bit creative when working out what is wrong, so it is recommended that you do frequent re-compiles, to quickly identify any issues.

The shader code and Javascript is all in a single HTML file, that can be directly loaded into a browser from the filesystem; on startup, it displays 2 channels of static test data, to prove that WebGL is working.


The GLSL shader code, and the associated Javascript program, are encapsulated in a single HTML file, webgl_graphics.html. It defines the ‘canvas’ area that will house the the WebGL graphic, and some buttons and selectors for the user to control the display, with a preformatted text line to display status information.

  <canvas id = "graph_canvas"></canvas>
  <button id="single_btn"   onclick="run_single(this)">Single</button>
  <button id="run_stop_btn" onclick="run_stop(this)"  >Run</button>
  <select id="sel_nchans" onchange="sel_nchans()"></select>
  <select id="sel_srce"   onchange="sel_srce()"  ></select>
  <pre id="status" style="font-size: 14px; margin: 8px"></pre>

I’ve kept the HTML simple, since the primary focus of this project is to demonstrate the high-speed graphics; there is plenty of scope for adding decoration, style sheets etc. to make a better-looking display.

Data acquisition

The Javascript program must repeatedly load in the data to be displayed, to create an animated graphic. There are security safeguards on the extent to which a browser can load files from a user’s hard disk; it is much easier if the files are loaded from a Web server. So I’m running a Python-based Web server (‘cherrypy’) to provide the HTML & data files. This works equally well on a Windows or Linux PC, as on a Raspberry Pi, so it is possible to test the WebGL rendering on any convenient machine, using simulated data.

In a previous post, I used DMA to stream data in from an analog-to-digital converter (ADC); the output is comma-delimited (CSV) data, sent to a Linux first-in-first-out (FIFO) buffer. There is a single text line, containing floating-point text strings, with interleaved channels, so if there are 2 channels, and channel 1 has a value floating around zero, channel 2 has a value around 1, then the data might look like:

0.000,1.000,0.001,1.000,0.000,1.001 ..and so on until..<LF>

There is an easy way to load in this data using Javascript:

    // Decode CSV string into floating-point array
    function csv_decode(s) {
        data = s.trim().split(',');
        return => parseFloat(x));

For simplicity, it is assumed that there is no whitespace padding between the values; if the file has been generated by an external application (e.g. spreadsheet) some extra string processing will be needed.

The total number of data values is split between the channels, so if there are 1000 samples and 2 channels, each channel has 500 samples.

Web server

The cherrypy Web server is Python-based, and is easy to install. We need to provide a small Python configuration program, which has a class definition, with methods for the resources that are provided, for example:

# Oscilloscope-type ADC data display
class Grapher(object):

    # Index: show oscilloscope display
    def index(self):
        return cherrypy.lib.static.serve_file(directory + "/webgl_graph.html")

if __name__ == '__main__':
    cherrypy.config.update({"server.socket_port": portnum, "server.socket_host": ""})
    conf = {
        '/': {
            'tools.staticdir.root': os.path.abspath(os.getcwd())
    cherrypy.quickstart(Grapher(), '/', conf)

The default index page is defined as webgl_graph.html in the current directory, and the socket_host definition allows that page to be accessed by any system on the network.

When this page is loaded from the Web server, it shows a simulated 2-channel display, and the status line reports the address and port number of the server.

Graph page loaded from Web server

Hitting the ‘single’ button will load 1000 simulated samples in 2 channels from /sim. The server code is:

    def sim(self):
        global nresults
        cherrypy.response.headers['Content-Type'] = 'text/plain'
        data = npoints * [0]
        for c in range(0, npoints, nchans):
            data[c] = (math.sin((nresults*2 + c) / 20.0) + 1.2) * ymax / 4.0
            if nchans > 1:
                data[c+1] = (math.cos((nresults*2 + c) / 200.0) + 0.8) * data[c]
                data[c+1] += random.random() / 4.0
        nresults += 1
        rsp = ",".join([("%1.3f" % d) for d in data])
        return rsp

The lower channel is a pure sine wave, the upper is amplitude-modulated with added random noise, resulting in the following display:

Display of simulated data

The above tests will work with the web server & client running on any Linux or Windows PC. Loading real-time data from a hardware source (such as an ADC) is done using a Linux FIFO; the Javascript code is the same as reading from a file, but read cycle will produce new data each time:

    # FIFO data source
    def fifo(self):
        cherrypy.response.headers['Content-Type'] = 'text/plain'
            f = open(fifo_name, "r")
            rsp = f.readline()
            rsp = "No data"
        return rsp

Running the code

Install the cherrypy server using:

# For Python 2
pip install cherrypy
# ..or for Python 3 on the Raspberry Pi
pip3 install cherrypy

Fetch the files and webgl_graph.html from github and load them into any spare directory. Run the server file using Python or Python3, and point your browser at the port 8080 of the server, e.g.

If all is well you should see the displays I’ve given above. If access is denied, you may have a firewall issue; if the HTML content is displayed, but the WebGL content isn’t, then check that WebGL is available on the browser, and perhaps set the Javascript WEBGL2 variable false, in order to try using version 1.

On the Raspberry Pi, my previous ADC streaming project can be used to feed real-time data into the FIFO, for example using the following command-line in one console:

# Stream 1000 samples from 2 ADC channels, 10000 samples/sec
sudo rpi_adc_stream -r 10000 -s /tmp/adc.fifo -n 1000 -i 2

Then run the Web server in a second console. If ADC channel 1 is fed with a modulated 1 kHz signal, and channel 2 is the 100 Hz sine-wave modulation, the display might look like:

Display of 2 ADC channels

The data transfer method (continuously re-reading a file on the server) isn’t the most efficient, and does impose an upper limit on the rate at which data can be fetched from the Raspberry Pi. It would probably be better to use an alternative technique such as WebSockets, as I’ve previously explored here.

Copyright (c) Jeremy P Bentham 2021. Please credit this blog if you use the information or software in it.