Our First Render - sahilshahpatel/fluid-sim GitHub Wiki
By the end of this chapter you will have rendered your first frame to screen! This chapter is focused on the basics of creating, loading, and running a shader in WebGL.
Let's start by writing our shader code. Create a new file at glsl/render.glsl
with the following code:
#version 300 es
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
Make sure that the very first character of the file is that first #. This starter code should be at the top of every GLSL file you create from here on out. It basically tells the WebGL what version of GLSL you are using and sets the float precision as high as the GPU supports.
Ok, time for a crash course on GLSL. GLSL is a very C-like language. The first thing to add to your program is a main
function:
void main(void){
// Pass
}
All the shaders you write will be fragment shaders. You can think of fragment as a synonym for pixel (there are differences in general, but none for our purposes). The importance of this, however, is that fragment shaders are run after vertex shaders and recieve inputs from them. Now is a good time to take a closer look at glsl/basicVS.glsl
, the provided vertex shader. The important part here is the line out vec2 fragUV
. Let's decompose that:
-
out
is a keyword specifying an output. As a modifyer to a global variable in a vertex shader it means that that variable can be used as an input in a subsequent fragment shader. -
vec2
is a type specifier for 2D vectors. In GLSL all operators and functions are vectorized (e.g.vec2(1.0, 1.0) * 2.0 == vec2(2.0, 2.0)
). To access a specific component you can use the dot operator with formatsxyzw
orrgba
. Sovec2(1.0, 2.0).x == vec2(1.0, 2.0).r == 1.0
. You can also use the swizzle operator to access a "sub-vector" (e.g.vec3(1.0, 2.0, 3.0).xz == vec2(1.0, 3.0)
). -
fragUV
is the name of the variable.
One important piece of syntax this line hasn't shown us is that GLSL is strongly typed. This means that ints and floats are not automatically converted. The line vec2 v = vec2(1.0, 1.0) * 2;
, for example, would cause a syntax error because 2 is an integer and cannot be multiplied by a vector of floats. The one "exception" to this rule is in the vector constructors: there are overloads so that you can input integers instead of floats and have them converted. For consistency, however, we recommend always using floats (either 1.0
or 1.
will suffice to turn 1 from an integer to a float).
Ok, so we want to get the output of that vertex shader into our new fragment shader. To do so, insert the line in vec2 fragUV;
into your fragment shader.
(Optional) More info on fragment shader inputs
Inputs to fragment shaders are automatically interpolated. If we look at the
basicVS.glsl
file, we can see thatfragUV
is calculated based on the vertex position. While there would be four distinct vertex positions in our screen-wide rectangle, we want each pixel to receive its own position value. Luckily, the automatic interpolation takes care of us here!As a side note, WebGL's clip-space is the cube of space with coordinates from -1 to 1 in x, y, and z. In 2D textures we tend to use coordinates in the 0 to 1 range which is why the provided vertex shader modifies the vertex position before passing it on.
We now have an input to our fragment shader! The next important piece is to define the output. Fragment shaders are meant to output a color to the screen per pixel, so our output should be a vec4
. You may name it whatever you like, but we will use fragColor
. So our line will be out vec4 fragColor;
.
Last but not least we need to fill in our main
function! For now, set the output to some arbitary color, say red: vec4(1., 0., 0., 1.)
. Recall that the fourth component is alpha, or opacity. When outputting to screen we'll want this to be 1, which means "completely opaque". Also note that in WebGL our maximum value for a color is 1 rather than something like 255, for example.
Great! So we've created our shader, but how do we get it from that file into our FileSimRenderer
class? We will load all shaders in the init
method. You can see already how the basicVS.glsl
file was brought in. Let's do the same for render.glsl
:
- Add
fetchText('../glsl/render.glsl')
to the list input toPromise.all
- Add
renderSource
to the list input to.then
If your curious about how exactly this is working, look up the JavaScript Promise API. All you need to know here is that these two lines first fetch the text of the file as a string and then pass it on to be used in the .then
function.
Now that we have our shader's source code in a variable we need to compile it! We do so through WebGL and a provided util function loadShaderFromSource
. Since this shader is a fragment shader, pass in FRAGMENT_SHADER
(a provided constant) as the third parameter. This creates a shader object, but what we really want is a shader program which requires both a vertex and fragment shader which will be run together. Again we have a util function, createShaderProgram
to do this for you. The fragment shader object can be local, but the shader program is something we'll want to save. Do this by making it a field of the class (e.g. this.renderProgram = ...
).
It's best to get in the habit of error checking early, so add a quick check that the created shader program is not null. If it is, call reject
and return. (reject
is part of the Promise API. Again, we need not worry about it too much, but it's still best to include.)
We're getting really close to seeing our output! The final step is to fill in the render
function.
Quick aside: most WebGL code you'll find online uses the gl
variable as the WebGL rendering context. Since we're using classes it is actually this.gl
for us. To make our code more readable we often make the first line of any WebGL-related function let gl = this.gl;
. This way you can freely copy code from online without having to modify every use of gl
!
Ok. This is actually the most complicated section of this chapter. Luckily, we'll have to do a very similar process for every shader we want to run on the GPU. By understanding it well now we can copy and paste later and know what parts to modify! Let's begin.
-
We need to tell the GPU which shader program we are using. Right now we only have one, but we will eventually have many. In WebGL, the way to do this is by calling
gl.useProgram(<shaderProgram>)
. -
Now we need to tell the GPU what geometry to execute our shader on. Recall that for fluid simulations we only have two cases: either the full screen or just the boundary. We have provided you with the data necessary for each geometry -- you just have to use it!
gl.bindVertexArray(this.quad.vao); gl.enableVertexAttribArray(this.renderProgram.vertexPositionAttribute); gl.bindBuffer(gl.ARRAY_BUFFER, this.quad.buffer); gl.vertexAttribPointer(this.renderProgram.vertexPositionAttribute, this.quad.itemSize, gl.FLOAT, false, 0, 0);
(Optional) Understand the code
WebGL uses VertexArrayObjects to define geometry. If you look at the constructor you will find the initialization of this object. The first line tells the GPU to use the quad's VAO. The second line enables the vertex position to be passed to the vertex shader. The third line says to use the quad's data (the rectangle). The fourth line tells the GPU where to find vertex position data.
For very detailed information about each call you can look up the WebGL API documentation.
-
The GPU has our program locked and loaded, so all we need to do is pull the trigger. First we have to tell it to render to screen instead of a texture. This is actually redundant at the moment, but it will be important later on. Next we have to tell it the size of the expected output. Finally we tell it to draw the geometry we set it up with. This is done like so:
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, ...this.settings.renderResolution); gl.drawArrays(this.quad.glDrawEnum, 0, this.quad.nItems);
-
Let's talk running your code. Because we split our shaders into separate files and use
fetchText
we can't just open our HTML page on thefile://
protocol and have it work. Instead, we'll have to spin up a local HTTP server. The easiest way to do this is with python by runningpython -m http.server
from the folder whereindex.html
resides. You can do this many other ways as well, so feel free to find a workflow that works best for you. Now your website will be available atlocalhost:8000
(or some other port). Whatever the case, you will probably spin up the server when you start working. To make sure you see changes as you modify your code, don't forget to hard refresh your browser to avoid caching. On Google Chrome this means using Ctrl+Shift+R to refresh.
🎉 Ta-da! 🎉 You should now have a big red square on your screen. Let's make a small change to get something at least a bit dynamic on screen. In render.glsl
, change the color from being a constant to depending on fragUV
. For example: fragColor = vec4(fragUV, 0., 1.);
. Upon saving and reloading your page you should now see a more colorful rectangle like so:
Awesome! Feel free to stay here for a bit and play around with different ways of creating color. Can you make a blue circle that fades to black as it reaches the edges? You can do all of this just by using the fragUV
coordinates and some math!
In the next chapter we will be adding interactivity to our page, reading user mouse clicks and movement to draw on our canvas.