The simplest shape

Everything is made of points. Two points could be connected with line segment. Line segment is the simplest possible shape, let`s start our journey into computer graphics with it.

Let's start unwrapping levels of abstraction. When computers were slow and there were no graphic cards, just some linear space in RAM was used as display memory. We would start with just two colors represented as booleans.

Commodore 64 supported display resolution of only 320x200 pixels and 16 colors with crazy limitations of simultaneous usage of this rich palette. There were techniques to bypass limits by updating palette while the screen was updating. We would start with 16x16 screen with just two colors.

Let`s allocate 256 (16*16) bits of memory.

Here is our pure block of memory filled with zeros. You could change it by clicking around.

If we want to draw a horizontal line in row starting at from left side with length of px we would do something like this (numbers are zero based, top-left corner have coordinate 0,0):


for(var x=$fromX1; x < $fromX1+$len1; x++){ memory[x + $row1*16] = 1; }

Check what happens when line is wider than the row — it overflows to the next line because all video memory is a linear array. We could add some checks and conditions to prevent this, but for learning basics — it is totally fine.

For vertical line it would be a bit different, but still very simple code:


for(var y=$fromX1; y < $fromX1+$len1; y++){ memory[y*16 + $row1] = 1; }

But how to draw a line with some starting and ending point? We have some options: we could go all in into trigonometry, we could calculate dx, dy, take largest value, and itterate from 0 to dLargest, and increase current x\y with dx*i, dy*i. Lets try to draw a line with this approach:

From A(, ), to B(,)


var dx = $Bx-$Ax, dy = $By-$Ay, largest = Math.abs(Math.abs(dx) > Math.abs(dy) ? dx : dy); for(var i = 0; i < largest; i++){ memory[ Math.floor($Ax+dx/largest*i) + Math.floor(($Ay+dy/largest*i))*16] = 1; }

It works fine. We handle corner cases when Bx-Ax < 0, and when By-Ay, by taking absolute value. When I did line drawing first time — I took a different approach — calculated the length of result line, then iterate from 0 to that value and get some weirdly distributed points. For line you could avoid that effect by approach that we used, but for circles you would need to have more robust approach.

Thickness.

One pixel lines are good for drawing some schematics, but for doing real graphics we need to have lines with various thickness. To achieve this we could use the previous algorithm, but draw a series of perpendicular pixels instead of just one.

Thickness:


var dx = $Bx-$Ax, dy = $By-$Ay, largest = Math.abs(Math.abs(dx) > Math.abs(dy) ? dx : dy); for(var i = 0; i < largest; i+=$sx){ for(var j = -$thickness/2; j < $thickness/2; j+=$sy) { var x = ( $Ax + dx / largest * i ) + dy/largest*j; var y = ( ( $Ay + dy / largest * i ) ) - dx/largest*j; memory[Math.round(x) + Math.round(y) * 32] = 1; } }

What a crappy line we get when tried to just draw thickness amount of pixels perpendicular to the line. You could make it look better by decreasing Step X ()\Step Y () values. We should have something better than that. And we have, but we would cover that approach a bit later when we would draw line with shader. Or you could check the article about drawing a triangle now. That approach draws each pixel only one time. Each line could be made out of two triangles that could be filled with ~approach from triangle article.

Antialiasing.

Computers become faster and that old lines become too ugly. Now we want to draw some cool looking lines on our brand new high resolution displays!

I took this algorithm directly from this web site.


var x0 = $x0, y0 = $y0, x1 = $x1, y1 = $y1; var dx = abs(x1-x0), sx = x0<x1?1:-1, dy = abs(y1-y0), sy = y0<y1?1:-1, err = dx-dy, e2, x2; var ed = dx+dy == 0 ? 1 : hypot(dx,dy); for(;;){ memory[x0+y0*64] = 1+4*abs(err-dx+dy)/ed|0; e2 = err; x2 = x0; if(2*e2 >= -dx){ if (x0 == x1) break; if (e2+dy < ed) { memory[ x0 + y0 * 64 ] = 1+4 * ( e2 + dy ) / ed|0; } err -= dy; x0 += sx; } if (2*e2 <= dy) { if( y0 == y1 ) break; if( dx - e2 < ed ) { memory[ x2+sx + y0 * 64 ] = 1+4 * ( dx - e2 ) / ed|0; } err += dx; y0 += sy; } }

I do not like how it works, lets try another one from Let`s implement Xiaolin Wu's algorithm from wikipedia.


var x0 = $x0, y0 = $y0, x1 = $x1, y1 = $y1; function plot(x, y, c) { memory[ x + y * 64 ] = 1 + c * 4 | 0 } // fractional part of x function fpart(x) { return x - floor( x ) } function rfpart(x) { return 1 - fpart( x ) } var steep = abs(y1 - y0) > abs(x1 - x0) if( steep ){ [x0,y0] = [y0, x0]; [x1,y1] = [y1, x1]; } if( x0 > x1 ){ [x0,x1] = [x1, x0]; [y0,y1] = [y1, y0]; } var dx = x1 - x0, dy = y1 - y0; var gradient = dx === 0 ? 1 : dy / dx; // handle first endpoint var xend = floor(x0), yend = y0 + gradient * (xend - x0), xgap = 1 - (x0 - xend), xpxl1 = xend, // this will be used in the main loop ypxl1 = floor(yend); if( steep ){ plot(ypxl1, xpxl1, rfpart(yend) * xgap) plot(ypxl1+1, xpxl1, fpart(yend) * xgap) } else { plot(xpxl1, ypxl1 , rfpart(yend) * xgap) plot(xpxl1, ypxl1+1, fpart(yend) * xgap) } var intery = yend + gradient // first y-intersection for the main loop // handle second endpoint xend = ceil(x1) yend = y1 + gradient * (xend - x1) xgap = 1 - (xend - x1) var xpxl2 = xend //this will be used in the main loop var ypxl2 = floor(yend) if( steep ){ plot(ypxl2 , xpxl2, rfpart(yend) * xgap) plot(ypxl2+1, xpxl2, fpart(yend) * xgap) } else { plot(xpxl2, ypxl2, rfpart(yend) * xgap) plot(xpxl2, ypxl2+1, fpart(yend) * xgap) } var x; // main loop if( steep ){ for (x = xpxl1 + 1; x< xpxl2; x++) { plot( floor( intery ), x, rfpart( intery ) ) plot( floor( intery ) + 1, x, fpart( intery ) ) intery += gradient } } else { for (x = xpxl1 + 1; x< xpxl2; x++) { plot( x, floor( intery ), rfpart( intery ) ) plot( x, floor( intery ) + 1, fpart( intery ) ) intery += gradient } }

This one is huge, but pretty fast and result looks much better.

Today.

Now we are using special graphic units that could fill gazillions of triangles each frame. This lead to a new approach of drawing a line as a rectangle made from two triangles.

var canvas = document.getElementById('glLine');
// We would work with webgl context
var gl = canvas.getContext('webgl');
// In WebGL\OpenGL we are drawing everything with shaders
// We have vertex shader to deal with geometry transformation:
var vertexShaderSource = `
    attribute vec2 position;
    void main() {
      // Convert from pixels to draw space [0 512] x to [-1 1
      // [0 256] y to [-1 to 1]
      vec2 clipSpace = (position / vec2(512.0, 256.0)) * 2.0 - 1.0;
      gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
    }
  `;
// Fragment shader is a function that would be called for each
// drawing pixel. here we would just return the blue color
var fragmentShaderSource = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(0.2, 0.4, 0.8, 1.0); // Blue color
    }
  `;

function createShader(gl, type, source) {
  var shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  return shader;
}

// Now we need to compile both shaders. It is real compilation to
// binary code that would be run on your GPU!
var vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
var fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);

// Program is the drawing pipeline. We are attaching both shaders to it
var program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

// Allocate buffer for geometry data
var buffer = gl.createBuffer();

gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

// allocate Float32Array with 6 vetor 2 (12 floats)
var v = new Float32Array(12);

// find `position` variable in shader (return number of input)
var positionLocation = gl.getAttribLocation(program, 'position');
// activate it. By default inputs are disabled
gl.enableVertexAttribArray(positionLocation);
// explain how to read data from that input. In our case we are passing
// float vec2 as points positions, so each vec2 take 2 floats
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

// for each draw we call (when you change A, B positions) this method is called
function updateLine(Ax, Ay, Bx, By, thickness) {
  // here we are doing calculation of two triangles
  var dx = Bx - Ax;
  var dy = By - Ay;
  var length = Math.sqrt(dx * dx + dy * dy);

  // perpendicular offset
  var px = (-dy / length) * (thickness / 2);
  var py = (dx / length) * (thickness / 2);
  
  var p = 0;
  // First triangle: top-right, top-left, bottom-right
  v[p++] = Ax + px; v[p++] = Ay + py;
  v[p++] = Ax - px; v[p++] = Ay - py;
  v[p++] = Bx + px; v[p++] = By + py;

  // Second triangle: bottom-right, top-left, bottom-left
  v[p++] = Bx + px; v[p++] = By + py;
  v[p++] = Ax - px; v[p++] = Ay - py;
  v[p++] = Bx - px; v[p++] = By - py;
  
  // In above description I think about A position as top-center
  // B as bottom center

  // Now we copy our array from CPU to GPU memory
  gl.bufferData(gl.ARRAY_BUFFER, v, gl.DYNAMIC_DRAW);
  
  // Clear the screen
  gl.clear(gl.COLOR_BUFFER_BIT);
  
  // And finally draw our array of geometry!
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

Line from A(, ), to B(, ) with thickness

With WebGL it looks like much more code, but mostly it is initialization code. You could move it to some helper functions, or use libraries like Three.js, TWGL.js, ogl.js

The main benefit of using WebGl is that you are only calculating the geometry on CPU, all pixel processing is made on dedicated graphical unit. You could run complex code for each pixel to calculate fractals, glass diffraction, waves distortion, pass position of viewport camera and it would cost nothing in terms of CPU load.