A lot of people have commented that
  1. graphics programming seems pretty cool, and
  2. it seems scary and intimidating and they don't know where to start.

This topic aims to help change that over a few posts by teaching some concepts about modern OpenGL, especially shader programming.

We'll be using ShaderToy for the first few examples so you can make something pretty before anything really scary sets in, which means you should probably make an account there now.

But let's start with the basics, some of which you probably already know. Many graphics-related tasks can be handled in an embarrassingly parallel way. This means that the job can be broken down into many parts, none of which really have to coordinate with one another and can work in parallel, pretty much ignoring what the other ones are doing. Even better, they're usually doing the same thing at the same time on each part of the job, just working with different input data. A Graphics Processing Unit, or "GPU", is a piece of hardware within your computer that allows these calculations to be done much faster than would otherwise be possible by providing many processing cores running in parallel (individually at a much slower speed than your CPU, but in bulk potentially much faster), but whose control-flow is locked together in groups. Traditionally, GPUs are also very fast at floating point arithmetic (useful for graphics applications), and not so good at integer arithmetic.

The OpenGL APIs provide a way to manipulate the internal state of the GPU and thereby control which data it works on. Older graphics cards only knew how to perform a fixed set of lighting operations on this data, called the "fixed function pipeline", but modern graphics cards are more programmable and allow you to write small programs, consisting of functions called "shaders", to control the lighting output yourself (and much much more)! While OpenGL can be accessed in many programming languages, the shaders themselves must be written in a special purpose language, called "GLSL" (for OpenGL Shading Language) which can be compiled into GPU instructions by your graphics card drivers.

GLSL programs are look like a specialized form of C (so it'll be helpful, but not strictly necessary, to have some familiarity with C-like languages, before we start), and are organized into several stages, each with its own shaders, and its own special-purpose inputs and outputs. Before we explain these stages, its important to understand the basic kinds of data and variables you'll be working with.

First, we have a subset of the traditional C data types:

  • bool​: conditional type, values may be either true or false
  • int​: a signed, two's complement, 32-bit integer
  • uint​: an unsigned 32-bit integer
  • float​: an IEEE-754 single-precision floating point number
  • double​: an IEEE-754 double-precision floating-point number

GPUs are very well optimized for floats, so use those wherever possible!


Second, we have vectors, which can be 2, 3, or 4 element sequences of those same types, which can be operated on in parallel.
  • bvecn​: a vector of booleans
  • ivecn​: a vector of signed integers
  • uvecn​: a vector of unsigned integers
  • vecn​: a vector of single-precision floating-point numbers
  • dvecn​: a vector of double-precision floating-point numbers


Vectors can be recombined using a technique called "swizzling". It looks a bit like accessing a struct member in C, but you can get a bit more fancy!

Code:

vec4 funVec = vec4(1.0,2.0,3.0,4.0);
float f = funVec.y; // This should be pretty obvious!
vec2 halfAsMuchFun = funVec.xy; // This should be pretty obvious too!
vec4 woahMagic = funVec.xzzy; // Like I said, magic!

In case it's not obvious from the code-snippet, after running this code, we would end up with f = 2.0, halfAsMuchFun = vec2(1.0,2.0), and woahMagic = vec4(1.0,3.0,3.0,2.0).

The vector components are named either x,y,z,w (for geometric coordinates, see footnote [1] if you're curious about w); s,t,p,q (for texture coordinates); or r,g,b,a (for colors - red, green, blue, alpha). It doesn't matter which ones you use where, as long as you don't mix them in a single swizzle! But everyone will appreciate it if you use the ones that fit the meaning of your code!

Vectors can be operated on in exactly the same way as scalars as long as the vectors are the same lengths, and the operation is performed between each pair of corresponding elements. So vec4(1,2,3,4)*vec4(1,2,3,4) would produce vec4(1,4,9,16).


Third, we have matrices, which are 2-dimensional structures, which once again can have dimensions from 2 to 4.

  • matnxm​: A matrix with n columns and m rows. OpenGL uses column-major matrices, which is standard for mathematics users. Example: mat3x4​.
  • matn​: A (square) matrix with n columns and n rows. Shorthand for matnxn

Matrices are always floats, unless you give them a dmat type, in which case you get a double matrix. Unlike vectors, you can't swizzle them, you use C-like array indexing instead (e.g. matrixName[0][0])! However if you apply only one index instead of two, you get a vector and can swizzle that (e.g. matrixName[0].xyzw)!


Finally, we have opaque types, such as samplers and images. Unlike the previous 3 sets of types, opaque types don't have a value which is directly accessible to you the programmer. Variables of an opaque type can only be accessed by passing them to a builtin function.

-------------------------------------------------------

Now that we understand the types we're working with, I'll explain the first two (and most important) types of shaders: vertex shaders, and fragment shaders.

A vertex shader, conceptually, transforms points (represented with vectors), and their associated attributes (for example colors, or texture coordinates, or other things), within a scene. Importantly nearly all scene elements to be drawn are represented by collections of vertices organized into triangles. Triangles are special because three points can always be formed into a flat triangle without worrying that it will have weird bends in the surface, or that the points will be tangled about each other. Triangles can be considered to be facing towards or away from the screen depending on the order the points are listed in (clockwise or counter-clockwise), but we won't worry about that too much for now.

A fragment shader, conceptually, takes the screen-space coordinates for pieces of a triangle (usually representing about 1 pixel's worth of the triangle), and returns a color to draw for that piece of the triangle! One reason why floating point values are used everywhere, is because given a triangle, the GPU can smoothly blend between the attributes and coordinates at the vertices, which will turn out to be very important!

There are other kinds of shaders, but now that we can turn points into colors, we have everything we need to get started! Go back to ShaderToy, and click "New Shader" at the top. Hopefully you're using a modern browser and you'll see some smoothly changing colors in a rectangle to the left,

and code like this:

Code:
void main(void)
{
   vec2 uv = gl_FragCoord.xy / iResolution.xy;
   gl_FragColor = vec4(uv,0.5+0.5*sin(iGlobalTime),1.0);
}

to the right. Noteably, there's only one shader here! The kind folks at ShaderToy have made our job even easier by making it so we only have to worry about our fragment shader, by drawing a simple rectangle for us to work from!

Click the arrow next to "Shader Inputs" above your code and you'll see a collection of variables labeled with comments. But they're all declared with the word "uniform"! A uniform input is one that is the same across all copies of the shader regardless of which portion of the data they're working on. The special data-specific variables are hidden from us here, but they're declared with either the in or the out keyword! You'll notice that our main function returns nothing and takes no function arguments! That's because everything we need comes to us as either a uniform variable, or an in variable, or is handed off to the next stage in an out variable. Names prefixed with gl_ are special system-provided inputs and outputs that you don't even have to declare for yourself! In this case gl_FragCoord is an input, and gl_FragColor is an output.

Let's click at the bottom in the black box labeled iChannel0. This will pick an input to the uniform sampler variable called iChannel0 from our inputs box. For now, choose one of the textures with rgba format (it will look like noise, but having an alpha channel will be fun). Once you've selected a texture, close the selection box and return to your code. Now that we have a texture, let's sample it! Below the declaration of uv, let's add a new line:

Code:

   vec4 sampledColor = texture2D(iChannel0, uv);

We've passed our sampler (which is an opaque type, remember?) for the noise texture into a function with a normalized 2d coordinate pair (meaning values between 0 and 1 - i.e. as a fraction of width/height), representing where in the image to draw our sample color. If we were compiling for a newer version of the shader language, the texture2D function might be called just texture instead, so if you run into errors on your own later, keep that in mind!

Now let's do something with that color! Replace the assignment to gl_FragColor with a new variable, so we'll have two color variables!

Code:

   vec4 timerColor = vec4(uv,0.5+0.5*sin(iGlobalTime),1.0);


Now what should we do with them? The simplest thing is an overlay using the alpha (transparency) from the textured image:

Code:

   gl_FragColor = mix(timerColor, sampledColor, sampledColor.a);

Here, we've used the "mix" function, which uses the standard alpha blending formula, taking in two colors, and an alpha parameter, and outputting a new mixed color.

This is a little too simple for our first shader, so let's change it again! Instead of assigning just a single color, let's add an if statement!

Code:

   if(uv.x > 0.25 && uv.x < 0.75){
      gl_FragColor = mix(timerColor, sampledColor, sampledColor.a);
   } else {
      gl_FragColor = sampledColor;
   }

Now we only mix in the center half of the screen, and otherwise just take the sampled color! But this brings up an important teaching moment: remember how I said that GPUs run in lock-step on each core? That means that if statements are bad for your performance (especially nested), because all of the cores have to step through the code for both branches together, even if some are ignoring the result of that branch! Designing your shaders to cleverly avoid if statements is an important skill for a GPU programmer (and has a fancy name called designing for "uniform flow control"). Violating this principle can have other negative repercussions, but we won't worry about them for today.



Play around with ShaderToy some more to get comfortable, and come back here to share your creations (and hopefully tune in for the next part of this tutorial). And don't forget to explore their gallery to see some of the awesome things people can do with only a fragment shader!




[1] GPUs allow us to work in Homogeneous coordinates, which allow us to handle cool transformations very easily. They'll be discussed more in a later post.
Wow, so this is really cool the things you can do. Here's a plasma I made:


This took a surprisingly small amount of code:

Code:
void main(void)
{
    float pi = 3.1415926535897;
   vec2 uv = gl_FragCoord.xy / iResolution.xy;
   
    float cx = uv.x+0.5*sin(iGlobalTime/5.0);
    float cy = uv.y+0.5*cos(iGlobalTime/3.0);
   
    float v = sin(sqrt(100.0*(cx*cx+cy*cy)));
    v += sin(uv.x*10.0+iGlobalTime);
    v += cos(uv.y*4.0+iGlobalTime);
   
    gl_FragColor = vec4(sin(v*pi), cos(v*pi), sin(v*pi + 0.5*pi*v), 1.0);
}


It's animated, so make sure to plug it into shadertoy! Math pilfered from here.

Changing the glFragColor = to this:

Code:
l_FragColor = vec4(sin(iGlobalTime*v*pi), cos(v*iGlobalTime*pi), sin(v*iGlobalTime*pi), 1.0);

makes some crazy noise!
Very cool! Glad to see someone getting use out of this already!


I made a winky-face:


Also, as a tip to make sharing easier, you can save your code to your account and share a link directly to your code. https://www.shadertoy.com/view/Xlf3Rl
This is really cool! Will all updates be OpenGL-specific or will there be some SDL and/or QT stuff later?

EDIT: I missed the second line of the post. Oops...
pimathbrainiac wrote:
This is really cool! Will all updates be OpenGL-specific or will there be some SDL and/or QT stuff later?

EDIT: I missed the second line of the post. Oops...


Thanks!

I'm gonna try to keep it pretty framework independent, because if you learn bare-bones OpenGL, you'll be able to get by pretty much anywhere (and because understanding what the graphics card is actually doing is important to writing good code). I might throw in some more general mathy/algorithmic tidbits down the road though, but that's getting lower-level, rather than higher. Shaun requested some CPU rendering algorithms, for example, which won't use anything but a little linear algebra and nifty data structures.


Anyway I was hoping to get the second leg of the tutorial up tonight, and we'll see how writing it up goes, but now that we're going to be moving beyond fragment shading, we'll need to run code somewhere other than ShaderToy, since they don't support loading geometry. I was debating between using WebGL and JavaScript, or Python, and I settled on Python, because it allows to use the full desktop-version of OpenGL instead of the embedded version used by web-browsers and phones, while also sticking very close to the traditional C API. The next part of the tutorial will work through setting up a desktop environment for graphics programming, and setting up the OpenGL state machine to let you load textures and shaders and geometry (oh my!). If you want to cheat and look ahead, the code we'll be building up in pieces in the tutorial is here (except for the textures part, which I'm still working on), but I suspect just browsing through the code will feel overwhelming and give you a bit of a feel for why people can feel intimidated by this area of programming. Breaking it all down into manageable pieces isn't too bad once you know where to start though, which is what I'm working on typing up now Smile

[edit]
And just for fun, here's a gif of shaun's plasma running in my test frame:
I've been playing around with other users code. Mostly playing with the variables and seeing what it does and how it affects the output, then tracking the variables throughout the code. It's pretty fascinating.
Comic: Glad to hear it!


Also, without further ado, here's part 2.1. This ended up being shorter than I was planning because bedtime. Hopefully I'll get to 2.2 pretty soon!


Today we're going to cover the fundamentals of interacting with the OpenGL state machine in order to draw things in your own program. We'll be using Python 2.7, with a few important libraries:

  • PyOpenGL, and PyOpenGL-Accelerate provide us with bindings to the native OpenGL libraries.
  • cyglfw3 is a binding for the "GLFW" library, which is used for communicating with the native windowing system to create a window to render in, and to ask the OS to create an OpenGL context.
  • numpy allows us to perform numerical computations on high-performance native arrays
  • and PIL can be used to load images.

You're welcome to use any other programming environment you want, as long as you can create an "OpenGL 3.3 Core Context". The calls to OpenGL itself will be virtually identical.

Let's start with a skeleton program with the following structure:

Code:
#!/usr/bin/env python

import os
import OpenGL
from OpenGL.GL import *

import cyglfw3 as glfw

import numpy
import time
import math
import os.path

RESOLUTION = [640, 480]

def main():
   startdir = os.getcwd()
   if not glfw.Init():
      exit()
   else:
      #glfw.Init is badle behaved with our working directory for some reason =(
      os.chdir(startdir)
   
   ##############
   # Code goes here
   ##############
   
   glfw.Terminate()

if __name__ == "__main__":
   main()



We're going to start by initializing our rendering window and the associated OpenGL context. A context contains all of the state associated with some viewable surface. First, we request version 3.3. Then we request the drivers enable forward compatibility, and disable backwards compatibility (the line about core profiles).

Code:

   glfw.WindowHint(glfw.CONTEXT_VERSION_MAJOR, 3)
   glfw.WindowHint(glfw.CONTEXT_VERSION_MINOR, 3)
   glfw.WindowHint(glfw.OPENGL_FORWARD_COMPAT, GL_TRUE)
   glfw.WindowHint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)


Next we need to actually request the creation of a window of the desired size to display our rendering:

Code:

   window = glfw.CreateWindow(RESOLUTION[0], RESOLUTION[1], 'OpenGL Tut')


The rest of our program is really contingent on the assumption that OS actually obliged us in creating a window, so let's make that explicit.

Code:

   if window:


Finally, we need to request that our window be set as the current OpenGL context. A single application can have multiple contexts, and only the current one is affected by OpenGL API calls.

Code:

      glfw.MakeContextCurrent(window)


If you run the code as it is now, you'll likely see a window appear and disappear almost instantaneously! This is because we have no render-loop yet! You'll also notice that we haven't actually interacted with OpenGL itself yet, only an abstraction layer for getting a context from the OS. Let's change that. First, we'll declare a method that takes a reference to our window, and throw in a dummy argument for later to pass in stuff to render when we finally figure out what that looks like.

Code:

def renderloop(window, renderables):

We want to loop until the user is ready to quit, and make sure we respond correctly to window-resizing events.

Code:

   while not glfw.WindowShouldClose(window):
      resolution = glfw.GetFrameBufferSize(window)


Next, we want to set the viewport:

Code:

      glViewport(0,0, resolution[0], resolution[1])

This tells OpenGL how screen coordinates map to pixel coordinates. We'll need some math to understand what's going on here, because the glViewport command is a bit weird. Essentially, we're defining the lower left and upper right corners of a pixel-space rectangle that we want to render into. But, it gets weirder than that, because the coordinates used for rendering calculations use a system called "normalized device coordinates", which map your viewport pixel rectangle onto a new rectangle with corners at (-1,-1) and (1,1). This is super convenient for a lot of applications where we want our display region to have some kind of mathematical symmetry, but it introduces a little weirdness into our usual 2-d model (for example, on the UV coordinates you used for your shaders in the last example), because we're used to (0,0) being a corner, and not in the middle of the screen. Figuring out the viewport you would want to use in that case is left as an exercise to the reader, and in fact is the one used in the current revision of the full demo program in the GitHub repo linked earlier in this thread, but I encourage you to get your hands dirty figuring it out now, because we'll need to do more math down the road, and you really want to warm up your mental calculator.


We want to clear the screen before we begin rendering a new frame (otherwise any pixels we skip rendering to might have weird residual data hanging around). We'll clear it to black, represented by RGB=(0,0,0) and an alpha=1. Remember we ignore alpha values when not blending, but it's good practice to match the semantics you want in case (in this case, full opacity black). The buffer we render to also stores scene depth information, in addition to colors, so we'll clear that as well, even though it won't be relevant for a while.

Code:

      glClearColor(0, 0, 0, 1)
      glClear(GL_COLOR_BUFFER_BIT, GL_DEPTH_BUFFER_BIT)


To make the rendering process smoother, glfw provides two buffers for us to render to. The one being displayed and the one being rendered to are separate, so we have to swap them after we finish rendering a frame.

Code:

      glfw.SwapBuffers(window)

We also need to give the event handler a chance to respond to user interaction, so we'll ask if anything has happened:

Code:

      glfw.PollEvents()


Now let's insert a call to this loop in our main(), immediately after the call to MakeContextCurrent. We have no idea what renderables will be, and we're not using it yet, so just pass None there for now.

Code:

      renderloop(window, [quadRender])


If you run the finished code now, you'll get a black frame that closes politely when you tell it to.


Next time: Loading your own shaders, understanding how to load and manipulate geometry, and actual rendering. Hopefully also some basic linear algebra for manipulating geometry.
Picking up where we left off, we have a couple tasks left before we're actually displaying anything:


  • We need to write, load, and configure our shader programs.
  • We need to load some geometry for display! Remember fragment shaders only render where is geometry to be sampled, so we need something to be able to draw.
  • We need to understand how draw calls actually work.
  • We have to remember to clean up when we're done, because OpenGL won't manage our resources for us.


Since for now we're only drawing a quadrilateral (composed of 2 triangles) over the whole screen, our vertex shader will be pretty simple. We just need to pass through our input vertices without changing them at all! The #version directive at the beginning lets the compiler know what version of the GLSL spec we're targeting. The layout qualifier preceding our declaration of in_position allows us to map data into our variable without needing to know what it's called, by specifying how we want our inputs ordered.

Code:

#version 330 core

layout(location = 0) in vec4 in_position;
void main(void)
{
   gl_Position = in_position;
}


Our fragment shader can be ported over from our ShaderToy experiments with ease. I'll use Shaun's plasma code as an example, since he was kind enough to share the code in this thread. We're starting here:

Code:

void main(void)
{
    float pi = 3.1415926535897;
   vec2 uv = gl_FragCoord.xy / iResolution.xy;
     
    float cx = uv.x+0.5*sin(iGlobalTime/5.0);
    float cy = uv.y+0.5*cos(iGlobalTime/3.0);
     
    float v = sin(sqrt(100.0*(cx*cx+cy*cy)));
    v += sin(uv.x*10.0+iGlobalTime);
    v += cos(uv.y*4.0+iGlobalTime);
     
    gl_FragColor = vec4(sin(v*pi), cos(v*pi), sin(v*pi + 0.5*pi*v), 1.0);
}

And there are only a few problems we need to address. We'll need to provide our own values of iResolution and iGlobalTime, because ShaderToy isn't here to do it for us. We also can't use gl_FragColor, because the newer GLSL version we're using here doesn't have builtin outputs for fragment shaders. All-in-all it's a pretty straightforward change:

Code:
#version 330 core
uniform vec2 iResolution;
uniform float iGlobalTime;

layout(location = 0) out vec4 fragColor;

void main(void)
{
   float pi = 3.1415926535897;
   vec2 uv = gl_FragCoord.xy / iResolution.xy;
   
   float cx = uv.x+0.5*sin(iGlobalTime/5.0);
   float cy = uv.y+0.5*cos(iGlobalTime/3.0);
   
   float v = sin(sqrt(100.0*(cx*cx+cy*cy)));
   v += sin(uv.x*10.0+iGlobalTime);
   v += cos(uv.y*4.0+iGlobalTime);
   
   fragColor = vec4(sin(v*pi), cos(v*pi), sin(v*pi + 0.5*pi*v), 1.0);
}

We've defined uniforms for iResolution and iGlobalTime, and we've defined our own output variable to replace the builtin gl_FragColor. Everything else is the same.

In the directory where you want to run your Python script, create nested folders for shaders/justaquad. Save your passthrough vertex shader as "supersimple.vert" in the new directory, and save your fragment shader as "anything_you_want.frag".

Since compiling shaders is a common task, let's create a helper routine to do it for us, taking in the source and a numeric identifier for the kind of shader we're dealing with (initially this will be either GL_VERTEX_SHADER or GL_FRAGMENT_SHADER):

Code:
def compileshader(src, kind):
   shader = glCreateShader(kind)

glCreateShader returns a numeric identifier that will serve as our reference to our shader from here on out. Next, we need the graphics drivers to take a copy of our source and feed it into the compiler:

Code:
   glShaderSource(shader, src)
   glCompileShader(shader)

As with any compilation process, we might have made some errors in our code, so we need to ask the drivers for a status code representing the result of compilation, and if the compilation failed, we can grab the error log and clean up. If everything was fine, we can return our handle to the shader.

Code:
   result = glGetShaderiv(shader, GL_COMPILE_STATUS)
   if not(result):
      infoLen = glGetShaderiv(shader, GL_INFO_LOG_LENGTH)
      infoLog = glGetShaderInfoLog(shader)
      print "Couldn't compile shader of kind %d with source:\n%s\n" % (kind, src)
      print "Received the following length-%d log message:\n\t%s" % (infoLen, infoLog)
      glDeleteShader(shader)
      shader = 0
   else:
      return shader


Now we have everything we need to start working! We'll read the shader sources using standard Python file I/O, and then use our compileshader routine straight-away.

Code:
def setupquaddemo(fragFile):
   vert_shader = 0
   with open(os.path.abspath("./shaders/justaquad/supersimple.vert"),'r') as vertH:
      shader_src = vertH.read()
      vert_shader = compileshader(shader_src, GL_VERTEX_SHADER)
   
   frag_shader = 0
   with open(os.path.abspath("./shaders/justaquad/%s" % fragFile),'r') as vertH:
      shader_src = vertH.read()
      frag_shader = compileshader(shader_src, GL_FRAGMENT_SHADER)


If both shaders compiled successfully, we can start linking them into a single program, otherwise we should just complain and give up:

Code:
   if (not vert_shader) or (not frag_shader):      
      if glIsShader(vert_shader): glDeleteShader(vert_shader)
      if glIsShader(frag_shader): glDeleteShader(frag_shader)
      print "Shader creation failed"
      exit()

   else:
      program = glCreateProgram()
      
      if not program:
         print 'glCreateProgram failed!'
         exit()
      else:

This code should be pretty straight forward, and presents a pattern that will continue to be used: "cleanup from any errors as safely as possible, otherwise proceed". In fact, we proceed to immediately use this pattern again: we'll attach both shaders to our new program, and attempt to link them, but as with compilation, linking might fail, so we should check the status and log message before using them:

Code:
         # attach shaders
         glAttachShader(program, vert_shader)
         glAttachShader(program, frag_shader)
         # Link the program
         glLinkProgram(program)
         # Check the link status
         linked = glGetProgramiv(program, GL_LINK_STATUS)
         if not linked:
            infoLen = glGetProgramiv(program, GL_INFO_LOG_LENGTH)
            infoLog = glGetProgramInfoLog(program);
            glDeleteProgram(program)
            print "Couldn't link program"
            print "Received the following length-%d log message:\n\t%s" % (infoLen, infoLog)
            exit()
         else:


Remember the description of how uniform inputs work from the first tutorial? We need to set them as parameters before calling a shader, and to do that we need to know where they are located. Thankfully, glGetUniformLocation let's us get a numeric identifier we can use to load uniforms for a particular program:

Code:
            iResolutionUniform = glGetUniformLocation(program, 'iResolution')
            iGlobalTimeUniform = glGetUniformLocation(program, 'iGlobalTime')


Now it's time to start setting up our geometry. There are two basic bits of information we need:

  • An array of points describing where the corners of our shape are,
  • and, an array of indices describing what order to traverse them in to form triangles


Let's put our vertices at (0,0), (1,0), (1,1), and (0,1), in that order, and load them into a buffer on the graphics card. OpenGL wants our data in 1d dimensional array format as input, and it figures out how to interpret the data based on the other information we provide.

Code:
            vertexData = numpy.array([
               0, 0,
               1, 0,
               1, 1,
               0, 1,
            ], numpy.float32)
            vboID = glGenBuffers(1)
            glBindBuffer(GL_ARRAY_BUFFER, vboID)
            glBufferData(GL_ARRAY_BUFFER,  4*len(vertexData), vertexData, GL_STATIC_DRAW)
            glBindBuffer(GL_ARRAY_BUFFER, 0)

Binding a buffer to the GL_ARRAY_BUFFER target signals an intent to use it for vertex data of some kind, and the use of GL_STATIC_DRAW tells the driver that we don't plan on updating the buffer contents, so that it can better optimize the storage. We must also pass a reference to the data itself, and the length of data in bytes (which is 4 times the number of elements, since we're using floats).


Now, we'll consider how we should index them. OpenGL, by convention considers triangles whose vertices are listed in counter-clockwise order to be facing forward. We can divide our rectangle into triangles along either diagonal, so long as we list the triangles in CCW order. This means there are 18 possible ways we could list indices to produce a correctly front-facing rectangle, given the order of vertices we listed above! We'll stick with the obvious one though: (0, 1, 2) for the first triangle, and (2, 3, 0) for the second. As before, OpenGL wants our data in 1d dimensional array format as input, and it figures out how to interpret the data based on the other information we provide.

Code:
            indexData = numpy.array([
               0,1,2,
               2,3,0
            ], numpy.byte)
            iboID = glGenBuffers(1)
            glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, iboID)
            glBufferData(GL_ELEMENT_ARRAY_BUFFER, len(indexData), indexData, GL_STATIC_DRAW)
            glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0)


Two important resources to consider when trying to write efficient OpenGL code are the overall number of API calls, and the overall number of state changes, since both can be quite expensive! We haven't been particularly careful so far about figuring out when calls and state changes (for example, we're updating the viewport every frame, whether it needs it or not); however, in some cases the API itself provides convenient ways of being thrifty with our calls. One way of doing this is by combining many state changes into a single call, for example, by using Vertex Array Objects, which I'm about to explain. When an attempt is made to draw, the initial inputs to the vertex shader are drawn from a set of pointers to buffers representing vertex attributes (e.g. position, color, etc). Since models typically have many attributes associated with a vertex, this results in a lot of calls to bind and unbind those buffers to the various attributes we care about. But Vertex Array Objects remember those configurations for us and automatically map them in for us when bound. So, before doing anything else, let's create a VAO for our use:

Code:

            vaoID = glGenVertexArrays(1)
            glBindVertexArray(vaoID)

Once the VAO is bound, any changes to the vertex attribute state are stored into that VAO, and can be recalled by binding it again later. Now we'll bind our buffer and use it to set the pointer to the first vertex attribute, with glVertexAttribPointer. When setting the pointer, we must also explicitly tell the drivers that our attributes use two elements per vertex, are floats, and that they're tightly packed together. Finally, we must use glEnableVertexAttribArray to explicitly enable the use of that attribute.

Code:
            glBindBuffer(GL_ARRAY_BUFFER, vboID)
            glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, None)
            glEnableVertexAttribArray(0)
            glBindBuffer(GL_ARRAY_BUFFER, 0)


We have to be careful to unbind when we're done making changes though, or we might accidentally overwrite our state at a later time:

Code:
            glBindVertexArray(0)


Now that our geometry is all set up, we can render it whenever we want by binding our VAO and our index buffer, and calling glDrawElements; however, without a shader program in use and data bound to the uniforms of that shader, the GPU won't actually know how to draw that data! Therefore, before we render, we should make sure to perform any necessary setup, and afterwards we should do a little teardown, to make sure our VAO and our buffers don't get overwritten by a careless mistake elsewhere. We'll bottle these up as subroutines that our rendering loop can use as callbacks:

Code:
            def preRender(time, resolution):
               glUseProgram(program)
               glUniform1f(iGlobalTimeUniform,float(time))
               glUniform2f(iResolutionUniform,float(resolution[0]),float(resolution[1]))
               
               glBindVertexArray(vaoID)
               glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, iboID)
               
            
            def render():
               glDrawElements(GL_TRIANGLES, len(indexData), GL_UNSIGNED_BYTE, None)
               
            def postRender():
               glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0)
               glBindVertexArray(0)


And for good measure we'll package up a cleanup routine to, because OpenGL doesn't know to free our resources until we give it permission to:

Code:
            def cleanup():
               glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0)
               glBindVertexArray(0)
               glDeleteBuffers(1,numpy.array(vboID))
               glDeleteBuffers(1,numpy.array(iboID))
               glDeleteVertexArrays(1,numpy.array(vaoID))
               glDeleteProgram(program)
               glDeleteShader(vert_shader)
               glDeleteShader(frag_shader)


Finally, before we hand things back to the main program, we'll use a nifty OpenGL feature to do one last sanity check. glValidateProgram allows us to setup a dummy render, and will tell us if it's even possible to run our shaders given the current OpenGL state, without the overhead of needing actually draw anything. As before, we'll check the status of the validation, and read any errors out of a log message before proceeding.

Code:
            preRender(0, RESOLUTION)
            glValidateProgram(program)
            validation = glGetProgramiv(program, GL_VALIDATE_STATUS)
            if not validation:
               infoLen = glGetProgramiv(program, GL_INFO_LOG_LENGTH)
               infoLog = glGetProgramInfoLog(program);
               postRender()
               cleanup()
               print "Couldn't validate program"
               print "Received the following length-%d log message:\n\t%s" % (infoLen, infoLog)
               exit()
            else:
               postRender()
               return (preRender, render, postRender, cleanup)


Now that we have all of our routines, it's time to go back and fill out some of the placeholder code we wrote earlier. Immediately after you make window the current context in main(), we should call setupquaddemo with the filename you used for your fragment shader, and split the resulting tuple: the rendering routines will get passed to the rendering loop as renderables, and the cleanup routine will be saved until the loop exits. So replace your existing call to renderloop as follows:

Code:
      quadFuncs = setupquaddemo("shaunplasma.frag")
      quadRender,quadCleanup = quadFuncs[:3],quadFuncs[3]
      
      renderloop(window, [quadRender])
      quadCleanup()


Now we need to know how to call these routines in the loop itself! We'll have to start a timer before the loop begins:

Code:
   startTime = time.time()


Next, in between clearing and swapping the buffers, it's time to actually draw something!

Code:
      # Render here
      for (pre,render,post) in renderables:
         pre(time.time() - startTime,resolution)
         render()
         post()


And bingo! All done!


Next time: Setting up textures for yourself, and a mathy interlude
There are at least two questions that you, my dear readers, are probably asking right now:

  • "Hey! My ShaderToy demo used textures, why didn't you show how to set those up yet?"
  • "Well this is all well and good, but when will I get to draw some 3d shapes?"

Fear not! The answers will come soon!
To learn about loading images for use with textures, let's learn about mandelbrot fractals, and use a texture to load in a color palette of your own design. Start by making 1x256 png texture, with some cool color pattern along the horizontal axis. Save it in a new textures folder, next to your shaders folder. Now we'll need to use PIL to load it into a format that Python can understand. Let's add PIL to our imports at the top of the file:

Code:
from PIL import Image


Now, before modifying any of our Python code, let's write our shader, so we can get a handle on what new pieces we'll need to connect to it. Informally, the Mandelbrot set is the set of complex numbers, c, such that the magnitude, |z|, of z[n+1] = z[n]^2 + c, is bounded as n -> ∞, given z[0] = 0. Since we can't evaluate this directly, we'll try to approximate it for bounded n. However we do know that if |z| > 2, then it will eventually go to infinity. Therefore for the purposes of our approximation, if at any point up to our choice of n, we find |z| > 2, then we'll exclude it from the approximate set, otherwise, we'll include it. However, just visualizing this binary relationship isn't particularly interesting, so we'll also keep track of how many iterations it took us to figure out what was going on, and use that value in our visualization.

The shader code starts out familiarly:

Code:

#version 330 core
uniform vec2 iResolution;
uniform float iGlobalTime;
uniform sampler1D iChannel0;

layout(location = 0) out vec4 fragColor;

void main() {
   vec2 uv = gl_FragCoord.xy / iResolution.xy;


Next we set up our variables for the fractal approximation:

Code:

   vec2 z, c;
   int maxIterations = 256;
   c = uv;
   z = c;


Next, we'll need to add a loop to iterate our formula for z[n], even though this is usually pretty bad practice for shaders, we need it in this case. But if you start cranking up maxIterations, you'll definitely notice the performance hit, even on a beefy machine.

Code:
   int i;
   for(i=0; i<maxIterations; i++) {
      float nX = pow(z.x, 2.0) - pow(z.y, 2.0) + c.x;
      float nY = (z.y * z.x + z.x * z.y) + c.y;
      
      if((pow(nX,2.0) + pow(nY, 2.0)) > 4.0) break;
      z.x = nX;
      z.y = nY;
   }


Finally, we'll choose a color for output. If the point was in the set, we'll color it black, otherwise we'll choose a color from our texture:

Code:
   fragColor = ((i == maxIterations) ? vec4(0.0, 0.0, 0.0, 1.0) : texture(iChannel0, float(i) / float(maxIterations)));
}


Now that we know where we're headed, it's time to work on the python side of things. To start, we'll define a convenience method to get the image in raw RGBA format from the filesystem:

Code:
def getRawImage(fname):
   img = Image.open(fname).convert("RGBA")
   return (img.size, img.tostring("raw","RGBA"))


Now we'll change our setupquaddemo routine a little to be more flexible:

Code:
def setupquaddemo(fragFile, texFile=None):
   texID = 0
   linearFiltering = 0
   if texFile:
      dims, pixels = getRawImage(texFile)


Now, we'll ask the drivers to generate an identifier for a texture (hopefully you're seeing a pattern by now as to how we interact with the OpenGL drivers):

Code:
      texID = glGenTextures(1)


Next (and, hopefully, as expected), we bind the texture and load data into it. We'll deal with 1D textures here, and leave 2D textures as an exercise for the reader, but the behavior is much the same:

Code:
      glBindTexture(GL_TEXTURE_1D, texID)
      glTexImage1D(GL_TEXTURE_1D, 0, GL_RGBA, dims[0], 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels)
      glBindTexture(GL_TEXTURE_1D, 0)

The parameters to glTexImage1D are fairly involved, but we are, among other things, passing information on the image format, what kind of texture we want to create, whether we want the texture to have a border. We also are specifying information about the mipmapping level, but since we don't have a use for that yet, don't worry about it Wink

We'll also create a Sampler Object (not quite the same as the sampler variables in our shader programs, but they're related) - these are used to control the parameters by which we sample colors from a texture. This one will clamp overflowing texture coordinates to the edge of our texture, and use linear interpolation for scaling:

Code:
      linearFiltering = glGenSamplers(1)
      glSamplerParameteri(linearFiltering, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
      glSamplerParameteri(linearFiltering, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
      glSamplerParameteri(linearFiltering, GL_TEXTURE_MIN_FILTER, GL_LINEAR)


The next change we need is to make sure we can pass our texture through to our sampler uniform, so we'll insert some code along with other calls to get glGetUniformLocation:

Code:
            iChannel0 = glGetUniformLocation(program, 'iChannel0')  if texID else -1

-1 is a special value for uniform locations - it means that the uniform doesn't exist, but will fail silently on attempts to set it, rather than generating a hard fail.

Now all we need to do is to update our preRender, postRender, and cleanup routines to deal with our texture and our Sampler Object. Here those changes are in order:

Code:
               if texID:
                  glUniform1i(iChannel0,0)
                  glActiveTexture(GL_TEXTURE0)
                  glBindTexture(GL_TEXTURE_1D, texID)
                  glBindSampler(0, linearFiltering)

There's a lot going on in this one: OpenGL support a number of "texture image units", that each handle the work of sampling a single texture. So we point our uniform at unit 0, and set that unit as the active texture before we perform our binding. This way when the texture binding happens, it is bound into that unit. Finally, we also bind our linearFiltering sampler into the same unit.

Thankfully unbinding and cleanup are still pretty straightforward:

Code:
               glBindSampler(0, 0)
               glBindTexture(GL_TEXTURE_1D, 0)


Code:

               glDeleteTextures(numpy.array(texID))
               glDeleteSamplers(1,numpy.array(linearFiltering))


Note that we didn't have to change our actual draw call at all! In fact, at this point, all you have to do is update your call to setupquaddemo in main(), and you're good to go:

Code:
setupquaddemo("yourfractalshadernamehere.frag","textures/yourcolorpalettehere.png")



Of course, much of the fun in fractals is exploring them, so I encourage you to fiddle around with your shader and see what you can come up with:



Math-y Interlude
Understanding a little bit about linear algebra is important. We're going to be dealing with a lot of vectors and matrices down the road. In general, a vector with n-elements represents a point in an n-dimensional space, and a matrix with (m x n)-elements represents a transformation from an n-dimensional space into an m-dimensional space. In the special case of square matrices (n x n), we can equivalently think of it as transforming between points in the same space (via swapping and scaling various axes). The way in which we apply matrices as transformations is by matrix multiplication , and multiplying matrices together allows us to compose their transformations. We like to think of vectors as tall skinny matrices (1 column, n rows), which allows us to treat all[1] products in the same way. The easiest way to explain matrix products is probably just to link to the wikipedia article, and suggest looking at the pictures. Once you feel like you have a handle on that, come back here for an explanation of the different spaces we'll be working in (or to ask questions if you don't quite get it, and want to actually work an example or two).

Given everything I've just said about the dimensions of a matrices and vectors relating to the dimension of the space we're working in, it will perhaps come as a surprise that computer graphics programmers frequently work with 4 x 4 matrices to represent our 3 dimensional world! Why is that? It's because using only 3-d transformation matrices, we can't represent all of the transformations we might want! For example, even though rotation and scaling (and mirroring) are fine, we can't translation ends up being vector addition rather than matrix multiplication, meaning it's hard to compose with other transformations. And more complex operations, such as a perspective projection, are unfathomably horrid and have no such simple representation. But working in 4 dimensions gives us an advantage: each point in our 3 dimensional world can now be associated with an entire line in 4 dimensions! This gives us the power to do all sorts of nifty things, like represent translations and perspective projections as matrices, just like any other transformation.


One last note:
We can configure our coordinate systems in different ways, in terms of how axes rotate into one another. There are two basic distinctions to draw.
"Left-handed" systems are so named because they represent assigning positive axes to the digits on your left hand, when your fingers are bent:

More importantly, left-handed systems are literally evil[2], and you should never use them.

Similarly, "right-handed" systems are so named because you can model them with your right hand:

Unlike left-handed systems, right-handed systems are the way things should work: OpenGL, and most of mathematics and physics follow right-hand rules.


[1] cross products are funny business, don't worry about them for now.
[2] It's true: our modern English word sinister is derived from the Latin word for "left-handed".



Next time: Actual 3d geometry!

(p.s. it will likely be a few weeks, because life is about to be extra hectic)
  
Register to Join the Conversation
Have your own thoughts to add to this or any other topic? Want to ask a question, offer a suggestion, share your own programs and projects, upload a file to the file archives, get help with calculator and computer programming, or simply chat with like-minded coders and tech and calculator enthusiasts via the site-wide AJAX SAX widget? Registration for a free Cemetech account only takes a minute.

» Go to Registration page
Page 1 of 1
» All times are UTC - 5 Hours
 
You cannot post new topics in this forum
You cannot reply to topics in this forum
You cannot edit your posts in this forum
You cannot delete your posts in this forum
You cannot vote in polls in this forum

 

Advertisement