Implementing The Stages

In the last section we decomposed the general GPGPU process into six bite sized concepts. Now, we will write code for five of the concepts, with the rasterization step being done automatically by the gpu. Of course we will organize the code into clear, modular, highly reusable functions. Like LEGOs we can then build our own worlds by assembling these functions according to our own designs.

We put the methods within the GPGPUtility class. A review of the constructor provides the context in which our functions are defined. Specifically, the functions will use the instance variables defined here such as the height, width and gl, the rendering context.


/**
 * Set of functions to facilitate the setup and execution of GPGPU tasks.
 *
 * @param {integer} width_  The width (x-dimension) of the problem domain.
 *                          Normalized to s in texture coordinates.
 * @param {integer} height_ The height (y-dimension) of the problem domain.
 *                          Normalized to t in texture coordinates.
 *
 * @param {WebGLContextAttributes} attributes_ A collection of boolean values to enable or disable various WebGL features.
 *                                             If unspecified, STANDARD_CONTEXT_ATTRIBUTES are used.
 *                                             @see STANDARD_CONTEXT_ATTRIBUTES
 *                                             @see{@link https://www.khronos.org/registry/webgl/specs/latest/1.0/#5.2}
 */
GPGPUtility = function (width_, height_, attributes_)
{
  var attributes;
  var canvas;
  /** @member {WebGLRenderingContext} gl The WebGL context associated with the canvas. */
  var gl;
  var canvasHeight, canvasWidth;
  var problemHeight, problemWidth;
  var standardVertexShader;
  var standardVertices;
  /** @member {Object} Non null if we enable OES_texture_float. */
  var textureFloat;
  ⋮
  ⋮ The code in the following paragraphs goes here.
  ⋮
  canvasHeight  = height_;
  problemHeight = canvasHeight;
  canvasWidth   = width_;
  problemWidth  = canvasWidth;
  attributes    = typeof attributes_ === 'undefined' ? ns.GPGPUtility.STANDARD_CONTEXT_ATTRIBUTES : attributes_;
  canvas        = this.makeGPCanvas(canvasWidth, canvasHeight);
  gl            = this.getGLContext();
  // Attempt to activate the extension, returns null if unavailable
  textureFloat  = gl.getExtension('OES_texture_float');
};

// Disable attributes unused in computations.
GPGPUtility.STANDARD_CONTEXT_ATTRIBUTES = { alpha: false, depth: false, antialias: false };
      

The Canvas

Even in this first step we see something different from normal WebGL. We create the canvas, but we don't attach the canvas to the DOM. You only need to attach the canvas to the DOM when you want to render it to the screen. Many purely computational problems will never be rendered to the screen.


/**
 * Create a canvas for computational use. Computations don't
 * require attachment to the DOM.
 *
 * @param {integer} width The width (x-dimension) of the problem domain.
 * @param {integer} height The height (y-dimension) of the problem domain.
 *
 * @returns {HTMLCanvasElement} A canvas with the given height and width.
 */
this.makeGPCanvas = function (width, height)
{
    var canvas;

    canvas        = document.createElement('canvas');
    canvas.width  = width;
    canvas.height = height;

    return canvas;
};
      

When we fetch the WebGL context we provide a set of attributes to disable WebGL features that we will not use. We disable a standard set of features if no explicit one is provided.


// Disable attributes unused in computations.
GPGPUtility.STANDARD_CONTEXT_ATTRIBUTES = { alpha: false, depth: false, antialias: false };
      

/**
 * Get a 3d context, webgl or experimental-webgl. The context presents a
 * javascript API that is used to draw into it. The webgl context API is
 * very similar to OpenGL for Embedded Systems, or OpenGL ES.
 *
 * @returns {WebGLRenderingContext} A manifestation of OpenGL ES in JavaScript.
 */
this.getGLContext      = function ()
{
  // Only fetch a gl context if we haven't already
  if(!gl)
  {
    gl             = canvas.getContext("webgl", attributes)
                     || canvas.getContext('experimental-webgl', attributes);
  }

  return gl;
};
      

The Geometry

Four vertices in a triangle strip to cover the canvas. x and y are normalized device coordinates. s and t are texture coordinates.

The simplest geometry that covers the canvas is a rectangle with one vertex at each corner. Luckily, in normalized device coordinates the corners of the canvas are at (-1, -1), (1, -1), (1, 1), and (-1, 1). Our vertex shader passes these coordinates on without any projection or other modifications.

Remember that the problem grid exactly matches the canvas pixels and the texture elements. This means that we also want to attach the texture at the canvas corners. This can be a bit confusing because texture coordinates are still another coordinate system to work with. The texture coordinates range from (0, 0), which we place on the (-1, -1) vertex to (1, 1), which we place on the (1, 1) vertex.


/**
 * Return a standard geometry with texture coordinates for GPGPU calculations.
 * A simple triangle strip containing four vertices for two triangles that
 * completely cover the canvas. The included texture coordinates range from
 * (0, 0) in the lower left corner to (1, 1) in the upper right corner.
 *
 * @returns {Float32Array} A set of points and textures suitable for a two triangle
 *                         triangle fan that forms a rectangle covering the canvas
 *                         drawing surface.
 */
this.getStandardGeometry = function ()
{
  // Sets of x,y,z(=0),s,t coordinates.
  return new Float32Array([-1.0,  1.0, 0.0, 0.0, 1.0,  // upper left
                           -1.0, -1.0, 0.0, 0.0, 0.0,  // lower left
                            1.0,  1.0, 0.0, 1.0, 1.0,  // upper right
                            1.0, -1.0, 0.0, 1.0, 0.0]);// lower right
};
      

Create A Texture

Normally textures contain pixel color data with eight bits for each of the red, green, blue, and alpha channels so the color for each pixel takes up one 32 bit word. With floating point textures we have a full 32 bit floating point number for each of the RGB and A channels. This allows us to easily store floating point numbers in a texture element. We will hijack this mechanism to store data for our calculations. As we will see, we have to be careful because floating point textures are an optional part of WebGL.

OES_texture_float is an OpenGL ES, and hence a WebGL, extension. This is an optional part of OpenGL that may not be present, and in any event will not be active unless activated with getExtension. If the extension is not available, getExtension returns null. Later we will see how to utilize the GPU even when floating point textures are not available.


// Non null if we enable OES_texture_float
var textureFloat;

// Attempt to activate the extension, returns null if unavailable
textureFloat = gl.getExtension('OES_texture_float');

/**
 * Check if floating point textures are available. This is an optional feature,
 * and even if present are usually not usable as a rendering target.
 */
this.isFloatingTexture = function()
{
  return textureFloat != null;
};
      

Now that we have enabled floating point textures, let's build one. We create the texture with createTexture, then bind it with bindTexture, which makes it the currently active texture.

We set several options to make textures more usable for holding computational data. Set both TEXTURE_MIN_FILTER and TEXTURE_MAG_FILTER to NEAREST to prevent any issue with the texture being smaller or larger then the geometry. These come into play when we map the texture to a geometry that is smaller or larger, respectively, than the texture. Remember though, that we keep the canvas, geometry and texture sizes in sync so we don not expect this to come into play. In any case, we never want interpolation between texture values.

If we read a value beyond the edge of the texture, we want the value from the edge. We set this up with both TEXTURE_WRAP_S and TEXTURE_WRAP_T set to CLAMP_TO_EDGE. Remember that s and t are the normalized texture coordinates. Again, with GPU computing we don't expect to actually use this feature.

texImage2D sets up the format, and optionally the data for the texture. This time we will setup an RGBA floating point texture. This means that each of the R,G,B and A channels contains a floating point number. For now we will only use one, but later we will see we can make some significant performance optimizations by making use of all four channels.


/**
 * Create a width x height texture of the given type for computation.
 * Width and height must be powers of two.
 *
 * @param {WebGLRenderingContext} The WebGL context for which we will create the texture.
 * @param {integer} width The width of the texture in pixels. Normalized to s in texture coordinates.
 * @param {integer} height The height of the texture in pixels. Normalized to t in texture coordinates.
 * @param {number} type A valid texture type. FLOAT, UNSIGNED_BYTE, etc.
 * @param {number[] | null} data Either texture data, or null to allocate the texture but leave the texels undefined.
 *
 * @returns {WebGLTexture} A reference to the created texture on the GPU.
 */
this.makeTexture = function (gl, width, height, type, data)
{
  var texture;

  // Create the texture
  texture = gl.createTexture();
  // Bind the texture so the following methods effect this texture.
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  // Pixel format and data for the texture
  gl.texImage2D(gl.TEXTURE_2D, // Target, matches bind above.
                0,             // Level of detail.
                gl.RGBA,       // Internal format.
                width,         // Width - related to s on textures.
                height,        // Height - related to t on textures.
                0,             // Always 0 in OpenGL ES.
                gl.RGBA,       // Format for each pixel.
                type,          // Data type for each chanel.
                data);         // Image data in the described format, or null.
  // Unbind the texture.
  gl.bindTexture(gl.TEXTURE_2D, null);

  return texture;
};
      

Output Texture

Normally the results from the fragment shader are drawn to the screen, and the value of gl_FragColor is the color of a pixel. We, however, capture gl_FragColor into a texture element (texel). We can even know which texel we are assigning a value to because we keep the size and position of the canvas, geometry, and textures aligned.

Because it is aligned with the canvas, the geometry of the texture is well defined.

The x coordinate of the texture, s, varies from 0 to 1 while the canvas ranges from zero to canvas.width. Similarly the y coordinate of the texture varies from 0 to 1 while the canvas varies from zero to canvas.height. This lets us know some important things about the texture.

The x spacing between texels, δs , and the y spacing, δt , are

δ s = 1 canvas.width
δ t = 1 canvas.height

Within the fragment shader we will have access to the texture coordinates, and from this we know exactly which texel we are writing to. Specifically,

i = floor canvas.width × s j = floor canvas.height × t

Finally, we get to setup the texture as our rendering target. OpenGL always renders to a framebuffer. We create our own framebuffer object (FBO), then bind it to make it the target of framebuffer operations such as rendering or attaching a texture for offscreen rendering to a texture as we want for GPGPU processing.


/**
 * Create and bind a framebuffer, then attach a texture.
 *
 * @param {WebGLRenderingContext} gl The WebGL context associated with the framebuffer and texture.
 * @param {WebGLTexture} texture The texture to be used as the buffer in this framebuffer object.
 *
 * @returns {WebGLFramebuffer} The framebuffer
 */
this.attachFrameBuffer = function (gl, texture)
{
    var frameBuffer;

    // Create a framebuffer
    frameBuffer = gl.createFramebuffer();
    // Make it the target for framebuffer operations - including rendering.
    gl.bindFramebuffer(gl.FRAMEBUFFER, frameBuffer);
    // Our texture is the target of rendering ops now.
    gl.framebufferTexture2D(gl.FRAMEBUFFER,       // The target is always a FRAMEBUFFER.
                            gl.COLOR_ATTACHMENT0, // We are providing the color buffer.
                            gl.TEXTURE_2D,        // This is a 2D image texture.
                            texture,              // The texture.
                            0);                   // 0, we aren't using MIPMAPs

    return frameBuffer;
};
      

To ensure that our framebuffer object is usable, we must also call checkFramebufferStatus. If this returns anything other than FRAMEBUFFER_COMPLETE then the framebuffer setup has failed. This is especially important because we have assumed that we can render to a floating point texture. On platforms where that is not allowed, this the the earliest point where that can be determined. Indeed, that is the most common cause for a framebuffer incomplete response.


/**
 * Check the framebuffer status. Return false if the framebuffer is not complete,
 * That is if it is not fully and correctly configured as required by the current
 * hardware. True indicates that the framebuffer is ready to be rendered to.
 *
 * @returns {boolean} True if the framebuffer is ready to be rendered to. False if not.
 */
this.frameBufferIsComplete = function ()
{
  var message;
  var status;
  var value;

  status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);

  switch (status)
  {
    case gl.FRAMEBUFFER_COMPLETE:
      value = true;
      break;
    case gl.FRAMEBUFFER_UNSUPPORTED:
      message = "Framebuffer is unsupported";
      value = false;
      break;
    case gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT:
      message = "Framebuffer incomplete attachment";
      value = false;
      break;
    case gl.FRAMEBUFFER_INCOMPLETE_DIMENSIONS:
      message = "Framebuffer incomplete (missmatched) dimensions";
      value = false;
      break;
    case gl.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:
      message = "Framebuffer incomplete missing attachment";
      value = false;
      break;
    default:
      message = "Unexpected framebuffer status: " + status;
      value = false;
  }
  return {isComplete: value, message: message};
};
      
Creative Commons License
This work is licensed under a Creative Commons Attribution 4.0 International License.