Webgl Gems Standard I Final
Webgl Gems Standard I Final
WebGL GEMS is a tutorial book that was written to help readers get
familiar with WebGL through working source code examples ready for
practical application.
The book is written in 2 parts. The first larger part is all about
standard WebGL implementation. The second part which is much
shorter is dedicated to making 2D games in WebGL.
First part will discuss preliminary setup and everything you need to
know about loading and drawing 3D objects on the screen using
WebGL. We will cover programmable shaders and multiple shader
loading techniques, texture mapping, scene illumination using light
sources and camera movement to name a few. We'll also dive into a
detailed discussion on matrices and learn how to use a common
matrix library to accomplish common 3D transformations.
The rest of this chapter explains the content of this book from various
vantage points for those who are previewing it using Amazon "Look
Inside" feature. Also a few bits about me and my writing style to ease
into the rest of the content.
If you are ready to start learning, you can skip right ahead to table of
contents or Gem 1 - JavaScript Canvas which kicks off our
discussion on initializing WebGL.
2D. Wait what? Isn't WebGL a 3D graphics library? That much is true
but it would be unfair to write a WebGL book leaving out examples of
2D game creation. Platformer games are fun. And even though there
is a great multitude of them out there, they are often one of the first
choices for learning making video (or computer) games.
WebGL has been around since 2011 but there hasn't been any dense
tutorials that focus only on the important source code examples that
accomplish practical tasks. This book tries to fill that chasm.
This will help us generate realistic 3D scenery similar to the kind used
in modern computer and video games like Mario Kart 8, for example.
Aside from all that my goal was nothing more but to create a valuable
resource and a reference for those who are looking to learn how to
write WebGL applications for making 2D or 3D game engines that
work in web browsers.
For these reasons this book was strategically organized and split up
into separate sections called "gems." Each gem is a condensed bit of
knowledge and written out as a tutorial with complete, working source
code included. Yet, the book flows from one chapter to the next,
assuming that you've read the previous chapter before diving into the
next.
The first half of the book is based on several examples that gradually
build up to a basic 3D kart racing game called Platypus Kart. It's a
quirky name. I just thought it was kind of funny because male
platypus has venom in the back of their feet that could be used as a
metaphoric analogy for nitro injection!
1. 3D collision detection.
2. Setting up kart following camera view with LookAt vector.
3. Loading racing track into our game in Blender's PLY format.
Toward the end we will use what was learned throughout the book to
demonstrate simple principles behind building 2D platformer games.
This choice to give primary focus to source code and examples was
made simply because WebGL alone is a vast subject. It's best to
conserve page space for writing about what's promised by the book's
title. So many times we see a mismatch between content and the title
and I wanted to avoid doing that as much as possible. I am myself an
owner of several books that for one reason or another seem to follow
that unruly pattern.
This book is primarily targeted at someone who is somewhat familiar
with JavaScript, or at least looking to spend some serious time
learning it. When learning WebGL it is crucial to understand
JavaScript. In year 2017 JavaScript is the primary language for
building web applications. But if you don't know it yet, this book will
help you get there by (hopefully) not providing source code examples
that are too overwhelming. I've taken time to ensure that most of the
source code provided in this book is clean enough to read and
understand. An attempt was made to avoid convoluted or "spaghetti"
source code to improve the chances of getting lost while consuming.
I think when learning WebGL most readers will simply want examples
of working source code that demonstrate how to actually get things
done on a WebGL canvas followed by brief but dense and direct
explanations. The API is already fairly well-explained online by
Khronos Group and a multitude of tutorials written by 3D graphics
enthusiasts and independent game developers. We live in an age
where information on just about any technical topic is within 5
minute's reach.
Some of the subjects in this book are not directly related to WebGL.
But they are included in order to help you move forward with
developing your own game engine. Because of generally greater
difficulty they can often become roadblocks to making that happen. I
tried to make intelligent choices in determining what to include and
what to exclude.
I've been a tutorial writer and game developer since 2003 when I
wrote my first OpenGL tutorials. But even now years later while
writing this book I realize the monumental amount of knowledge it
takes to get anything reasonably interesting done on the screen in the
context of a 3D world.
Having said this, there is no way any single book volume can cover
everything you need to know to make interactive video games.
However, most of my effort while writing this book was spent on
wisely using its space to communicate as many subjects as possible
following the principle that if I am going to write about it, it should be
supplemented with practical examples and source code.
Book's GitHub
To gain access to all source code examples from this book navigate
to my WebGL project via my GitHub account page at:
github.com/gregsidelnikov/WebGLTutorials
In addition in this book you will see few references to actual URLs
with live demos where appropriate. The GitHub account contains
absolutely all source code from this book.
Independent Publishing
Namely:
Book's Evolution
I can't write a perfect book but hopefully it is a pretty good one. And
with everyone's help I can continue improving this volume. If you find
something is missing, or a piece of source code produces an error in
a particular browser (or anything of the sort) please consider
contacting me so I can take a note and put it on my to do list for next
edition. Writing and editing is hard work. And while I try my best to
provide a great reading experience for my readers, there are times
when I might still need your help.
Before We Begin
HTML:
<canvas id = "view"></canvas>
JavaScript:
var canvasId = "view";
var canvas = document.getElementById( canvasId );
var context = canvas.getContext('2d');
This is the important part: notice the '2d' string passed to getContext
method.
Nonetheless...
Downloading Resources
You can create a basic progress bar using native canvas shapes,
such as filled rectangles, so you don't have to wait for loading
graphics for the progress bar itself.
Once all resources have finished loading, only then we will move
forward to actually initializing our drawing loop for drawing a 3D
scene which requires fully loaded resources. Because we are working
in a browser and not a stand-alone native desktop (or Android, or
OSX application) the process can become quickly convoluted and
cause spaghetti code because resources are loaded asynchronously.
Throughout this book we will gradually explore one way of waiting for
all resources to be loaded in a nice and clean way.
But let's get back to initializing canvas. Part of this book is dedicated
to making a 2D platformer game. But canvas in 2D mode isn't ideal
for this purpose.
Initializing 3D Context
HTML:
<canvas id = "view"></canvas>
JavaScript:
var canvasId = "view";
var canvas = document.getElementById( canvasId );
var context = canvas.getContext('webgl');
But things aren't this easy in the world of competing web browser
vendors. When new libraries such as WebGL take time to adapt as
absolute standards, each browser of course has its own special
experimental string for initializing WebGL on canvas.
We must try to check for each one of them in ascending progression
to make sure we are offering complete support for old browsers... or
browsers missing an update. Below I provide a complete function for
initializing WebGL in any browser:
Source Code
function InitializeWebGL()
{
// Handle to canvas tag
var canvas = document.getElementById("gl");
// Available extensions
var extensions = null;
} else
console.log("Your browser doesn't support WebGL.");
} else
console.log("WebGL is supported, but disabled :-(");
}
Although we can easily start using WebGL functions now that the 3D
canvas is initialized, it really helps to understand the underlying
processes that take place from construction of 3D primitives to their
rasterization on the screen via Frame Buffer. Frame buffers are
memory locations associated with rendering graphics on the screen.
There are different types of shaders. Two most common ones are
Vertex and Fragment shaders. If you're coming from OpenGL
background, looking at this simplified pipeline diagram you will quickly
notice that WebGL does not support Geometry shaders.
Geometry shaders are simply missing from the WebGL pipeline by
design. But that's not so bad, because everything Geometry shaders
can do can be accomplished in some other way. Not a big loss.
The coordinates are calculated in vertex shader and the color of each
pixel is interpolated across the triangle surface in fragment shader
based on the information received from vertex shader.
Vertex shader is always executed first and only then the results are
passed on to the Fragment shader. This order is important, the
shaders are not executed simultaneously.
You can think of them as chained together to produce the final result
on the HTML canvas which ends up being the final rasterized image
that will be rendered to the frame buffer.
This is done this way to fix the refresh rate gap between the memory
writes and screen refresh rate. If you guided the GPU driver to write
directly to the video memory on the screen, you would see a
noticeable "tear" effect. But writing first to an off-screen buffer and
waiting until that process is completed first eliminates that side effect.
This is why in OpenGL has a function SwapBuffers which flips the two
surfaces after waiting to ensure the operations have finished
rendering to the surface first.
What kind of geometry can we draw using the WebGL pipeline? Dots,
lines and triangles for the most part is what you will be rendering on
the screen. The actual pixel data is calculated in the shader process
and finally sent to the screen.
We'll see how this is done later in the book. For now, just note that
the vertex shader only understands 3D vertex and color coordinates
and isn't concerned with actually drawing anything on the screen. And
the fragment shader takes care of the actual pixel (referred to as
fragment) to be drawn on the screen.
You will see here the new additions are Varyings and Uniforms.
Uniforms are sent into the shaders. And varyings are sent out.
These are the two data types specific to shader programming. You've
already heard of ints and floats before from standard programming
supported by pretty much almost every language you can think of, but
these new keywords are unique to GPU programming. They are
provided by the GLSL language.
Varyings are simply variables declared within each individual shader.
They can be used as helper flags or shader configuration utilities that
determine different aspects of your shader functionality.
Light intensity, or distance for example. Just like any regular variable
they can change throughout the lifecycle of your shader program
usually written in GLSL language.
In OpenGL and WebGL we're often dealing with vertex and other
information packed into data buffers. These data sets allocate
memory space for blocks of data that does not change. This data is
used for performance optimization.
To make use of uniforms they must first be bound to the shader inlet
mechanism, which is accomplished using WebGL functions we'll take
a look when we get to the source code.
The vertex and fragment shaders both have two virtual places for
input and output of the data. The vertices are literally passed into the
vertex shader through an inlet and come out on the other end through
an outlet into the inlet of fragment shader.
An attribute is for using in the vertex shader only. It's just another type
of a variable.
But let's take a look at these variables from another angle to get
some perspective (no pun intended.)
And that's what a varying is. It's an interpolated pixel. The GLSL
shader program's algorithm you will write will "close in" on that pixel.
But rendering will apply to entire primitive.
Looks like we've come full circle. We've taken a look at different types
of variables used in shaders. Let's draw a quick outline and wrap up
our discussion by adding a little more depth:
uniform
per primitive
Constant during entire draw call.
Like a const. Do not vary.
attribute
per vertex
Typically: positions, colors, normals, UVs …
May or may not change between calls.
varying
per pixel
Vary from pixel to pixel
Always changing on per-fragment operations in shader.
A uniform can be a texture map, for example. It does not change
during the entire draw call.
void main() {
gl_Position = Projection * View * Model * vec4(position, 1.0);
rgb = rgb_in;
}
The location parameters tells us which slot the buffers were packed in
before they are sent to this shader. They also tell us about the size of
the data. For example vec3 stands for a buffer containing 3 floating
point values which is enough to represent exactly one vertex
coordinate set (x, y and z). These values are passed directly from
your JavaScript program and will be shown later when we get to the
examples that draw basic primitives.
Also notice that we take in variable called rgb_in and its "out"
counterpart is reassigned to rgb. You can assign your own names
here. For clarity, I added "_in" for data that is coming into the shader
and I use just "rgb" for the data that is coming out. The logistics
behind this come purely from a personal choice and I recommend
using variable names that make the most sense to your own
programming style.
Uniforms are like constant variables. In this case they are Model,
View and Projection matrices (mat4 represents a 4x4 dimensional
array) passed into the shader from our program.
Within the main() function of the shader is where you write your
shader logic. It's a lot like a C program with additional keywords
(vec2, vec3, mat3, mat4, const, attribute, uniform, etc.) I stripped this
example down to its basic form but various GLSL versions (of which
there are quite a few) vary in minor syntax differences. I skipped core
version differences here. At this time, we're not concerned with that
because I don't want to overcomplicate the book.
We've determined that vertices from our program are passed to the
Vertex Shader. And from there, they are passed on to the Fragment
Shader. Together vertex and fragment shader pair creates a
representation of your rendered primitive in 3D space.
We'll continue our discussion about shaders, learn writing our own
and even loading them from a web address on the local web hosting
server (or localhost) in one of the following chapters. I briefly brought
it up here so we can get familiar with them.
We've already initialized the 3D canvas and talked a bit about theory
behind the WebGL pipeline. I think it's a good time to actually do
something physical on the screen.
Don't want to use jQuery? That's fine. You can use the following
construct. Just rewrite the window's default onload function as
follows. Remember that in JavaScript, thanks to a principle called
hoisting functions don't have to be defined first in order to be used.
And for this reason we can do something like this:
window.onload = InitializeWebGL;
// Execute this code only after DOM has finished loading completely
$(document).ready(function()
{
var canvas = document.getElementById('gl');
var gl = GetWebGLContext( canvas );
if ( !gl ) {
console.log('Failed to set up WebGL.');
} else {
// WebGL initialized!
gl.clearColor(1.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
}
});
If you supplied a buffer type that is not supported or does not exist,
the result of your gl "clear" operation will produce the following error:
INVALID_VALUE.
But it's the function gl.clear that is responsible for actually wiping the
screen clean with the selected color. It takes
gl.COLOR_BUFFER_BIT flag which says: clear the color component.
Color buffers are only one type of buffers in WebGL. And the flag
COLOR_BUFFER_BIT represents simple [r, g, b] set. The other two
flags are listed below.
COLOR_BUFFER_BIT
Only pixel color will be read / written
DEPTH_BUFFER_BIT
Operation will be performed on an off-screen depth buffer
STENCIL_BUFFER_BIT
Same as above, except for stencil buffer
If there is a pixel "A" rendered in that same spot on the screen, the
new incoming pixel "B" which happens to occupy the same space is
tested for whether it is farther away or closer to the camera than pixel
"A".
This process continues until all pixels in the scene are processed.
The 2D depth buffer is built for each pixel on the 2D screen. Depth
buffers are also often passed to shaders for aiding calculation for
creating various effects that require knowing distance between the
currently processed fragment and the camera.
However, that sounds good only until you realize that some polygons
that are translated and rotated to occupy exactly the same volume of
space cannot be always drawn using this technique without losing
visual integrity. It makes sense because, which object would you
choose to draw first if they shared the same pixel coordinates in 3D
space?
In fact, if two objects are drawn in a way where their pixels will share
exactly the same 3D location your program can experience
something referred to as Z-buffer fighting.
This is when the shader is struggling to decide which pixel out of the
two should be shown at that location. Often this creates a pixel
flickering effect, or long jagged-edge stripes of pixels seemingly
flipping on and off following a will unknown to us as shown in the
diagram below:
This happens not due to the fault in the depth algorithm itself but
because of the fact that floating-point operations on the CPU have
limitations. Unfortunately, there is not much we can do in this case.
Good news is that in most cases, we don't really need to.
If we avoid the depth buffer test, some of your polygons may appear
to be rendered in random order and intersect each other, creating
severe visual artifacts.
As long as large majority of fragments occupy a unique location in the
world coordinates, the z-buffer technique is more than efficient to
eliminate all issues with polygon ordering.
We'll deal with these cases in practice later on in the book. I just
wanted to show you this now before we move on, in order to get an
idea of how different types of off-screen buffers can be used in
WebGL.
So far we've had a great start. And I really don't want to get into
matrices this early on in the book. We haven't even looked at a
simple shader example! But, I am afraid that we have to talk about
them at this point. Once we get them out of the way we'll move on
with a solid foundation.
It's all tied together. If we avoid matrices now, we will not be able to
fully understand shader programming either. And it'd definitely be a
disservice to jump directly into the GLSL source code without any
knowledge of matrices whatsoever. So let's start.
Introduction to Matrices
We'll deal with some of these advanced subjects later in the book. As
a starting point, we need to determine some type of a building block.
A place to start understanding matrices without having to deal with
complex mathematical formulas. If this knowledge is skipped, it will
be easy to detract from getting a truly deepened understanding of
how they work. Which is useful.
When you drive a car, you don't really have to understand how the
engine works. Just press pedals and shift gears. It's all intuitive. But
when racing a sports car this knowledge will not get you ahead of
your competitors. Even cutting a corner a certain way depends on
your understanding of the vehicle you're driving, its torque and its
limitations. In sports car racing even texture or temperature of your
tires plays a role in creating a competitive strategy on the race track.
I guess you can either feel guilty about it and save time by simply
using matrices without that type of in-depth knowledge. Or, you can
learn to understand the basic principles behind them and get pretty
good at programming 3D graphics.
Let's take a look at one of the most commonly dealt matrix types first.
Common 3D Matrices
The camera view is the position from which the model (and the rest of
the 3D world) is being viewed from This view is mathematically
defined by FOV (field of view) or the angle of view, the near clipping
plane and the far clipping plane. You'll see seeing them a lot
throughout this book.
And finally, the projection is the camera's cone size and dimensions
that determine the final image on the screen for the currently
rendered frame of animation in your game or 3D program. The
projection is like a conglomerate of all the camera math combined. It
produces a perspective-correct image on the screen. Or if you
messed something up, a distorted one.
Some of the common matrices are the Model matrix, the View matrix
and Projection matrix. Together, they can be combined into what's
known as MVP (The Model View Projection matrix) which is derived
by simply multiplying them all together.
The MVP matrix is like the spinal cord of the whole system.
Conceptually, it's what gives you control over the data sent to the
GPU and puts the puzzle of the whole WebGL pipeline together. The
rest of this chapter will make an attempt to accomplish that with
simple explanations of each piece leading to gradual discovery of
how it all works.
For the said reasons above you will see the MVP matrix used a lot.
It's the basis for most shaders you'll ever write. It might appear
difficult to grasp at first. But later as you practice, you'll see that it can
be used as a tool to get things done within the world of geometric
operations, without having to think about them mathematically. But
even then, understanding them is still incredibly important if you want
to create your own graphically impressive effects.
Gradual Discovery
This transition from the brute force math to a more intuitive approach
can only happen by practicing, paying attention and memorizing the
effect these calculations have. If you truly enjoy programming 3D
graphics, eventually it'll sink in and become second nature.
But don't get discouraged if it doesn't make sense right away. These
calculations have come from over a decade of research. It is only
now it's put together into shader logic that is actually simplified. It's
just been abstracted. And simpler abstract models always come from
complexity. As we continue to learn more about 3D graphics one step
at a time it will begin to make a lot more sense.
Matrix Multiplication
So far we've only taken a very brief look at the tip of the iceberg and
got familiar with a few basic matrix types. We will continue exploring
this subject. But there is one more thing. There always is!
This operation is used so often in shaders. In fact, you will see the
following line a lot in almost any vertex shader you can possibly find:
void main() {
gl_Position = Projection * View * Model * vec4(position, 1.0);
}
This pattern can be found at the core of almost every vertex shader.
void main() {
gl_Position = MVP * vec4(position, 1.0);
}
Why are we doing it this way? I can explain this by drawing a simple
analogy. In the semi-ancient times some people largely believed that
the Sun rotated round the Earth. It was a natural conclusion to draw
from observation.
One time someone questioned that reality. And tried to prove that it is
the Earth that's spinning around the Sun. Against the strong
opposition of commonly accepted idea certain folks claimed it couldn't
be true because of what it appears like.
This reality also manifests itself into the world of GL camera system.
Of course in WebGL we are only imitating reality. But it inherits similar
properties. Compared to the example I've drawn our camera is a 3D
object represented by the Earth that has its own orbit and path of
movement. The Sun is also a 3D object in space.
Just like in reality where the Sun orbits the center of the galaxy, we
have two moving objects. Looking at the Sun from the Earth (our
camera) we are observing the Sun move. But it is only representative
of the result of the Earth (camera) moving. Our "camera" just
happened to be pointing at the Sun.
You can think about it in yet another way. When we are here on
Earth, looking at the Sun, we are the ones who are actually rotating
around the Sun. But to us it appears that the sunrise and sunset just
"happen" separately from that fact. Unless pointed out, we are
convinced that the Sun is moving toward or away from the horizon
line. It's a beautiful illusion.
And so, what happens then when we multiply Model and View
matrices? A combinative matrix is formed that creates a conglomerate
of mathematical equations encompassing both: the movement of the
model and the camera movement in world space relative to each
other.
This technique retains the Z value (how far it is from the camera) of
each fragment relative to the camera position. And if there are other
objects present that "block" that fragment from light hitting it, that
fragment will be shaded. When this process is done on the entire
screen buffer, it creates an effect as though objects in the scene are
casting shadows. Note, however, that this operation produces the
same result regardless of camera view's position and angle. All with
the help of ModelView matrix.
Shadow Casting
Imagine how long it would take us to actually write out each formula
behind these matrices for each model? Or how about for each
vertex? For each fragment? This symbolic, or rather abstract,
mathematical representation simplifies a complex problem.
Another example from the shadow casting vertex shader looks as
follows, where we are calculating the camera projection view as seen
from the light source (not the screen camera).
LightSpacePosition =
Later, down the WebGL pipeline in the fragment shader the shadow
casting algorithm combines the model coordinates with two cameras
to calculate the shadow effect for the currently rendered scene.
The Light camera creates its own view that we never have to see.
(Although, by rendering our off-screen buffer on the screen we could!)
It looks exactly as though we placed the camera at the location of the
sun. It stores that image in a separate, hidden buffer. Of course we
also have to create and prepare that buffer by allocating enough
memory.
If you can crack the idea behind of how the matrix multiplications
were implemented, you will be able to expand your knowledge and
gain creative power over bringing to life your own graphics effects
without feeling lost.
Basic Representation
Let's take a look at one of the most simple matrices first. The 3x1
matrix, representing the 3D coordinate of just 1 vertex in space:
000
0
0
0
This is very close to the bare minimum mental map for a Model
matrix. The Model matrix is represented by a mere 3 items. But here,
it doesn't have to be referring to a Model matrix specifically. Any 1x3
matrix looks this way.
Remember that the word matrix stands for womb in Latin? Why do
these 0's actually look like eggs placed into a grid basket you often
see at a supermarket store? Symbolic meaning is completely
irrelevant here. But I feel like we're giving birth to understanding how
they work.
Some matrix operations are still performed by your program and not
the video card. Regardless of these facts, we still must understand
how they work and what they do.
But the irony is that often those same authors never actually explain
what happens and what you as a WebGL programmed need to
understand in order to start using them in practice, create your own
shaders, and write meaningful algorithms by yourself (and not just
copy and paste the MVP matrix formula.)
Let's fill the gap and take one solid look at the subject once and for
all. From angles that matter.
Starfield Demo.
The inner workings of raw vertex-manipulation math calculations.
Starfield Demo
In this demo, I'll create everything from scratch. We won't even use
WebGL to demonstrate this. We need to go into this territory to gain a
deeper understanding of matrices.
As far as 3D graphics go, here we are concerned with only one thing.
And we can back track it all the way to our Model coordinates and the
camera view.
But how does Model and View matrices fit in exactly? The Model
matrix contains the X, Y, and Z coordinates of each star particle.
Essentially it's the 1x3 matrix we've already seen at the beginning of
this chapter. You can think of each star being represented by a single
vertex. Or as a very simplified version of a 3D model. Hey, it's
representative of just one vertex!
The View represent the position of our camera. Before I go into multi-
dimensional matrix representations, which is how you should be
thinking of matrices, and how you will naturally begin looking at them
after the information in this chapter sinks in completely, I will show
you a "bare bones" example using just the mathematical calculations.
In the next sub chapter you will find the complete source code from
starfield canvas program. There is also a web link to the working
example. It's a basic program that displays stars moving toward the
camera view creating the illusion of space travel.
The code is very basic here and the program itself is very short. It will
fit on about two pages of this book. The purpose of this demo is to
show that with just two types of vertex transformations (which are
translation and rotation) we can create a foundation on which to build
mathematical understanding of matrix functions.
The Star Class
First, we'll create a new Star class representing a single star using
JavaScript's class keyword. It's not entirely similar to class keyword in
languages such as C++ or Java, but it accomplishes a vaguely the
same task.
This class will contain the X, Y and Z coordinates of the star particle.
It will also have a constructor that will take care of initializing the
default (and random on X and Y axis) position of any given star.
The class will contain functions reset for initializing a star, project
which is the core 3D to 2D projection algorithm that demonstrates
what matrices are actually trying to accomplish, and the draw function
that will actually draw each star at its projected 2D coordinates.
Speaking of which, the Star class has two pairs of coordinates. One
represents the actual placement of a star vertex in 3D world space
using X, Y and Z coordinates.
But the class will also have x2d and y2d pair for separately storing
the actual pixel coordinates when the star is rendered on the flat
canvas view.
Finally, the class will also store the star's angle of rotation around Z
axis to demonstrate the basic trigonometry operations you'll often see
when dealing with 3D graphics.
The reset function provides default position values for the "starting
point" of a star on the X and Y axis respectively:
These calculations will create random values between -1.0 and 1.0
for each of the axis.
To finish initializing our star we simply push the star away from the
camera by a random value that falls somewhere between 0.0 and -
MAX_DEPTH. After fiddling around with the parameters I've chosen
MAX_DEPTH to be 10 units because visually it creates best results in
this scenario.
The x2d and y2d are the final rasterized coordinates in 2D space. Are
we not doing 3D graphics here? Yes, but the final pixel value is
always rasterized to a flat 2 dimensional rectangle. That's the whole
point of camera projection algorithm represented in the next method
of the Star class called render. And in just a moment we'll see how it
does that mathematically.
The code above will rotate the star around its Z axis.
In order to rotate a point around Z axis we need to perform operations
on X and Y axis. This is the standard trigonometric formula that can
be applied for rotating any vertex around an axis.
For example, swapping y coordinate with z and plugging that into the
formula above will rotate the point around Y axis. Swapping x
coordinate with z will rotate the point around X axis. In other words,
the point rotates around whichever axis is missing from the equation.
Changing the angle from positive to negative will rotate the point in an
opposite direction. The general idea remains the same. Here is the
pseudo code:
x = x * cos(angle) - y * sin(angle)
y = y * cos(angle) + x * sin(angle)
The angle here is the degree by which you wish the point to be
rotated per animation frame. Whenever you're rotating a 3D object's
vertex, you can be sure that behind all matrix operations this
calculation is taking place in the raw. Perhaps, optimized by a look-up
table.
In the star field demo we're rotating each star by 0.005 on each frame
of animation. Note that the JavaScript Math.sin and Math.cos
formulas take the angle in radians, not degrees.
There is just one more thing. Remember that our screen is wider than
it is taller. In other words, just this algorithm alone will produce a
somewhat skewed effect unless both width and height of our canvas
are the same. That's not the case here.
And for this reason we need to fix this weird effect by adjusting the X
coordinate and multiplying it by the screen width / height ratio:
Now let's move the star closer to the camera view by 0.0025 pixels
per frame.
Have you ever wondered how our eyes see light? The particle (or
wave?) enters through an opening in the pupil. But when these light
particles land on the back of our eye and hit the retina the image is
projected upside down. Our brain just has a magical way of reversing
that information.
Clipping Planes
There is still one important part missing. When the star's Z coordinate
reaches 0 and starts to increment in positive direction, our
perspective formula will interpret it in reverse. In other words, stars
that go >= 0 will start to appear as if they are moving away from us.
That's what happens when objects move past the threshold set by
the near clipping plane. When objects move beyond it, the results are
reversed. But this isn't what we need. In fact, we don't have to worry
about any vertex data that breaks beyond that limit.
In our program it is important to set these boundaries. The starfield
demo "cheats" a bit over this by eliminating all stars that go outside of
the screen boundary in any direction, which only roughly coincides
with them getting closer to the camera. And it also never draws any
stars at all outside of the -10.0 boundary. It only creates an
approximation of the clipping plane function.
But there is also a far clipping plane and it can extend out to 250, 500
and 1000 units or more. In our starfield demo it is only 10. It really
depends on how far of the geometry you wish to be seen in your
game world. And also on what a "unit" of space really means to your
camera.
I'm just glad we got these principles down now so it's easier to
understand their implementation throughout the rest of the book.
The final effect achieved of our starfield demo so far will appear
roughly as shown on the diagram below. Here I inverted the
background color. In the actual demo the background is black. It
looks better in a book this way and doesn't waste up black ink in print.
We will talk a lot more about camera and learn how to control it with
precision to do pretty much anything we could possibly need to in a
3D game, including mouse-controlled camera or creating a camera
that's always following an object in the world.
But let's get back to our demo for a moment. We figured out the star
transformations but need to finish writing the core program.
All of this is done by the remaining part of our JavaScript demo where
we set some basic default parameters, initialize 2000 stars, and use
JavaScripts timing function setInterval to execute a frame of
animation without a time delay (as soon as possible.)
You can also launch this demo at my Tigris Games website in your
browser:
http://www.tigrisgames.com/fx/starfield.php
Here you see in action the movement and rotation transformations
that are very common to matrix operations. And we've already taken
a look at their plain mathematical representation using basic
trigonometry in the source code of this demo explained in this
chapter.
Matrix Structure
3x2 Matrix
a b c
d e f
1x3 Matrix
x
y
z
I've rarely seen 3x2 matrix used to accomplish anything at all in 3D.
This is just an example.
On the other hand the 1x3 (or sometimes 1x4 and you will shortly see
why) is the Model view.
Then it is only natural that together with 1x3 (or even 1x4 sometimes)
the other most common types of matrix grids we'll be working with in
WebGL are shown below:
3x3 Matrix
a b c
d e f
g h i
ModelView or Projection Matrix
Non-homogeneous
Uncommon.
4x4 Matrix
a b c d
e f g h
i j k l
That's called homogeneous coordinates. You add one to the data set
of 3 in order to fit into another 4x4 matrix by convention to pad
calculations.
One good reason for the 4x4 format is because it falls neatly into
computer memory. As you know all computer memory is usually
organized by a power of 2 as the common denominator and it is not
by accident 4x4 is 16.
Sequential Order
First, let's take a look at the order of a simple 3x3 matrix represented
by a JavaScript array. I am using semi-pseudo code here to
demonstrate the principle.
mat[0][0] = [ a ]
mat[0][1] = [ b ]
mat[0][2] = [ c ]
mat[1][0] = [ d ]
mat[1][1] = [ e ]
mat[1][2] = [ f ]
mat[2][0] = [ g ]
mat[2][1] = [ h ]
mat[2][2] = [ i ]
This might seem like a good idea at first. But computer memory in
modern processors and GPUs is optimized for linear data. When
dealing with thousands or even millions of vertex data sets - and this
is not uncommon - we can run into performance issues.
var mat3 = [ a b c d e f g h i ]
And now let's organize it by simply visualizing a grid. This visually
imitates multi-dimensional array structure without having to use one.
And it's still something we can work with:
a b c
d e f
g h i
X 0 0
0 Y 0
0 0 Z
The values are represented diagonally for each column. This may not
appear natural at first. Why not just pack X, Y and Z into the first 3
values and save memory? But remember that a matrix is by definition
a multi-dimensional set of data formed this way to represent a
homogeneous set (We'll talk about this in just a moment.)
Homogeneous Coordinates
We've already talked that this is perfect for computer memory layout.
And naturally homogeneous coordinate systems provide extra space
for fitting into the same pattern. This is the reason we pad the matrix
grid with 0's even though we're never using them.
X 0 0 0
0 Y 0 0
0 0 Z 0
0 0 1 0
Of course we can get away with not using matrices at all. We can just
do raw calculations on the vertex or color data, similar to what we've
done in the starfield demo earlier. But it's how GPUs have evolved
and they expect to be fed matrices. It's just another reason why it's
important to understand them.
Row-Major and Column-Major Matrix Notations
These specific locations are often used for alpha (in addition to the
r,g,b) value when the matrix carries color information. Or it is simply
used to "pad" the data with value such as 0 or 1. Since multiplying by
1 or dividing by 1 produces the original result.
X 0 0 0 X 0 0 0
0 Y 0 0 0 Y 0 0
0 0 Z 0 0 0 Z 1
0 0 1 0 0 0 0 0
These are the most commonly used matrix layouts. They're nothing
more than logistical preference. Sometimes, different software
programs, matrix libraries and APIs differ in the assumed matrix
format.
This is a major (no pun intended) reason for why in WebGL shaders
we use uniform variable type to represent our matrices.
var view_matrix_4x4 =
[ x, 0, 0, 0,
0, y, 0, 0,
0, 0, z, 0,
0, 0, 1, 0 ];
Yes, we're using more data than we have to. But the overhead is
small compared to the benefits of increased ease working with the
vertex data in this format. Also, the GPUs are already optimized for
crunching data represented by these layouts by assuming them.
Common Cross-Matrix Operations
We've talked a lot about the physical layout of matrix data. Now it's
time to take a look at the actual operations we'll be implementing to
achieve various results.
Multiplication
a b c j k l
d e f x m n o
g h i p q r
That's the common idea. Simply multiply all items at the same
location in each matrix. The result is a new 3x3 matrix containing
cross-multiplied values.
Matrices are often multiplied by a single vertex. But the principle
stays the same, we just have less operations to perform. Let's try this
out with an actual data set that we'll come across in a real-world
scenario, where we're multiplying an identity matrix by some 3D
vertex containing only 3 values (it's x, y and z coordinate):
1 0 0 x = 1*x = x
0 1 0 x y = 1*y = y
0 0 1 z = 1*z = z
On the left hand side we have what is known as the identity matrix.
You may have heard of it from working with OpenGL. The identity
matrix consists of all 1's. After performing multiplication with the
identity matrix we retain the original value.
On the right hand side we have our Model matrix representing the 3D
object's X, Y and Z coordinates. Note that in most cases this
operation occurs on an entire 3D model. And this is why matrices are
efficient. This multiplication will happen for each vertex on the model
regardless of how many vertices a model consists of.
Now that was just the identity matrix. It's pretty boring. It doesn't do
anything.
Just for the purpose of practicing let's make our own matrix. First,
let's recall that the projection matrix can be represented by the math
we used for the starfield demo in the previous section of this chapter:
We will now, for the first time, factor in the FOV (field of view) into our
camera projection calculation. We are not changing anything. We're
looking at the same thing from a different angle. The tangent angle.
Tangents work with triangles that have a 90 degree angle. But our
camera viewing angle can be anything we want. How do we deal with
this problem?
Instead of relying on the screen width and height we will now rely on
the camera's viewing angle or FOV. It's just an intuitive way to base
our camera projection on.
1 / tan( fov/ 2)
Literally coming from this point of view we can construct the following
4x4 projection matrix:
Where following is true:
And of course… there is "one more thing" that went into this
calculation that we haven't talked about yet. The aspect ratio.
Perspective projection construction functions usually require the
following parameters to be supplied in order to create a unique
camera view:
Aspect Ratio:
The width / height of the screen represented by a single number.
Field of View:
The field of view angle. Common examples: 45, 90.
And finally, here is what our camera model looks like from above
looking down the Y axis.
In fact, the principles and math behind them are so simple. It's just a
matter of focusing on the right thing. If our patience is tested by them,
then how much more impatient would we be when we study
advanced subjects that require far more mental endurance than this?
We've spoken of this earlier in the book. Other than the projection
view matrix the two other common operations are translation and
rotation. Yet, another is a scale operation. It's used when you want
your camera to imitate the zoom effect often seen in film. We'll take a
look at all three in a moment.
Local and World Coordinate System
I'll start with rotation for one simple reason. When thinking about
transforming 3D coordinates in local space of a model, you must train
yourself to usually think about performing rotation first before
translation. Here order matters because changing it will produce
significantly varied results.
Importance of transformation order in WebGL, represented using a
visual diagram. How order of moving or rotating the model first affects
the final result. Part of model transformation tutorial diagram.
This is exactly the problem with translating and only then rotating,
when animating a model. Notice also that both translation and
rotation here take place in model's local space. Of course, we could
move the model in world space, and then rotate it in local space
without problem but this example is shown to demonstrate that there
is a difference when both operations take place in space local to the
model itself (the coordinate system which was used while
construction of the model when it was created in Blender around x=0,
y=0 and z=0 point).
However, rotating the sphere around its own center first and only then
moving it produces accurate results. Still, this is ambiguous. In other
words it's relative to what you wish to accomplish with your
transformations.It's just something to be mindful of.
Rotation Matrix
The rotation matrix consists of 3 separate matrices. Each axis has its
own rotation matrix. But scaling and translation of any object are
performed only using one matrix per operation inclusive of all axis at
the same time.
This means, every time you rotate an object you have to choose
which axis it will be rotated around first. Here order of operations also
matters and will produce different results if rotations are performed in
a specific sequence around each axis.
It might come as a surprise that if you've read all the way up to this
point in the book, you have already implemented (or seen
implementation of) the rotation matrix around Z axis. We performed
that operation using raw math in Starfield demo when we made the
incoming star particles rotate around Z.
We're already familiar with that fact that a rotation matrix uses the
common trigonometry formulas cos and sin, the formulas we've
already seen implemented in Starfield example. Now let's make
another metamorphosis. This time from our basic math to matrix
implementation.
Translation Matrix
Scale Matrix
You could definitely write your own matrix library containing all of
these operations. Matrices could be made up of linear arrays treated
as multidimensional in principle. And while it's a fun exercise that will
help you solidify your knowledge it also takes time.
This will help us with examples later in this book to actually get
something done in our WebGL JavaScript program as we're slowly
parsing through the theory and coming closer to that point.
There are quite a few JavaScript matrix libraries out there. Not all of
them produce optimal performance. Khronos Group, the creators of
OpenGL and WebGL recommend CanvasMatrix and not without a
good reason. CanvasMatrix is one of the fastest matrix libraries for
JavaScript. And that's what we will use. It also has simple syntax.
Exactly what we need to match the style of tutorials in this book.
http://stepheneb.github.io/webgl-matrix-benchmarks/
matrix_benchmark.html
Inside the rendering loop, however, we are free to modify the matrix
and send it dynamically into the shader. This gives us control over
translation and rotation of the model.
var model_Y_angle = 0;
if (!gl)
return;
model_Y_angle += 1.1;
gl.uniform1i(gl.getUniformLocation(
Shader.textureMapProgram, 'image'),0);
gl.uniformMatrix4fv(
gl.getUniformLocation(Shader.textureMapProgram, "Projection"),
false,
Projection.getAsFloat32Array());
ModelView.makeIdentity();
ModelView.scale(scale, scale, scale);
ModelView.rotate(model_Y_angle, 0, 1, 0);
ModelView.translate(x, y, z);
gl.uniformMatrix4fv(
gl.getUniformLocation(
Shader.textureMapProgram, "ModelView"),
false,
ModelView.getAsFloat32Array());
Just refer to this example for now whenever the time is right to start
translating your model in 3D space or rotate it around one of its 3
axis.
Conclusion
But there are also matrices for rotation, translation and scaling that
are of equal importance. These are responsible for object animation
in 3D space. Even complex frame-based animation, or 3D "bone"
based animation are all using these principles.
Let's consider one of the absolutely most basic vertex and fragment
shader pair, listed below:
We always start with the vertex shader. Recall from previous chapters
that values that appear in "out" variables will actually be intercepted
by the fragment shader and picked up as "in" values on the other end
in the pipeline.
Simple Vertex Shader
void main() {
gl_Position = Projection * View * Model * vec4(position, 1.0);
rgb = rgb_in;
}
Notice that main does not have a return value. It only takes specifying
a variable using the out keyword to ensure that it will be passed along
to the fragment shader once main() function finishes executing. Then,
simply assign the value from within the main function and you're
done.
For this reason, the out values are usually calculated as the last step
in the function. As long as the variable name is defined using the out
keyword you can be sure that it will be passed on to the next step in
the pipeline.
void main()
{
color = vec4(rgb.r, rgb.g, rgb.b, 1.0);
}
Here you will see that we're once again using an "out" variable color.
But this time the fragment shader sends it out to the actual drawing
buffer, usually created and bound in our WebGL JavaScript
application. This will be shown in the next chapter when we learn how
to load shaders from files.
Notice that vec3 value rgb that was received from vertex shader
contains 3 properties representing the color triplet: r, g, b. We can use
them individually and even perform mathematical operations on them
to somehow distort or change the original color. And this is how the
fragment shader gains control over the pixel data of each individual
fragment. But you could have use your own colors here, regardless of
what crossed over from the vertex shader in out variable.
For now, let's consider this simple example of using this shader pair
to actually draw something on the screen. In this case, we'll simply
draw a single point and later on use the same shader pair to draw
more complex shapes like lines and polygons.
Shaders In a String
If you ever worked with long multi-line strings in JavaScript using the
+ and " megalomania you will know that backticking is a heavenly
way of writing GLSL in JavaScript. If your browser supports
EcmaScript 6, then you should definitely adopt this style instead.
You can simply read the contents of each tag by ID that was assigned
to it and use that as the source string for your shader.
Initializing Shader Program
Writing shader source code and putting it into a string isn't enough.
We must also initialize our shaders. And then we must create a
shader program which combines the vertex and fragment pair. This
program must then be linked and selected.
Drawing a Point
For each material type you should have a separate shader. Later on
we'll take a look at how to create more striking 3D models. In this
simple example we're going to be using a very simple shader capable
of rendering points on the screen and determining their size.
if (!gl) {
console.log('Failed to set up WebGL.');
} else {
var fragment_shader = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // Red
}`;
else {
Note the addition of the vertex and fragment shaders and the
initialization process using the new InitializeShader function. If the
program is valid WebGL shader object, we passed the final
initialization test and ready to draw the vertex data. In this case it's
just a point.
The function takes the type of primitive to be rendered as. In this case
our shader supports a static point in the middle of the screen using
hard-coded coordinates [x=0, y=0, z=0]. This is also why we are
passing gl.POINTS rendering type to gl.drawArrays.
Other flags can be used to choose the type of primitive to draw from
the vertex data set:
POINTS
Draw points
LINES
Draw lines
LINE_STRIP
Line strips are vertices connected by lines
LINE_LOOP
A self-closing line sequence
TRIANGLES
Draws a triangle from a set of 3 vertices
TRIANGLE_STRIP
Can draw two triangles from a set of 4 vertices as a strip
TRIANGLE_FAN
Draws a triangle fan where first vertice is always the center
Each flag responds to the number of vertices passed into the vertex
shader. Each one assumes a minimum number of vertices. Ideally,
the number of all vertices supplied should be divisible by that number.
In this example we don't have to worry about that because we're
drawing a point.
However in other cases for example, if you only pass two vertices to
the shader and try to render it with TRIANGLES flag, nothing will be
displayed on the screen because a triangle primitive requires 3
vertices.
Contrary to this, if you switch the flag to LINES, a line will be drawn
between the two passed vertices. Depending on the flag type you can
produce different representations of your vertex data. We'll discuss
how to pass multiple vertices to our shader shortly.
But first, I think it's a good time to create a better and more
convenient way for managing multiple shaders. Eventually, we will
need to use more than one, and you simply don't want to hardcode
them without having some kind of a management system.
In this section we'll take a look at how we can organize our shader
programs a bit better. Once your WebGL application gets complex
enough you will want to implement some sort of a way to manage
your shader programs. This way it's easy to turn them on and off on
demand before performing the drawing operations. For example, for
switching between drawing different material types in your rendering
loop.
But there is one trick you can use. You can always write a shader
program that changes its rendering operation (using an if-statement
inside its main() function) based on a flag you pass to it as an
attribute variable.
But you have to be careful here because there are special cases
where this isn't always going to create massive performance gains.
This will depend on what you are trying to do, the number of materials
contained by your model composition and various other factors
determined by the design of your engine.
Let us create a JavaScript object that will contain a handle for each
shading program that will be available throughout our WebGL
application. This way we can always access the main Shader
manager class, and pull the program object out whenever we need to
use one.
constructor() {
But this is not all. We now need some kind of a mechanism that
initializes all of our shaders in one place so they are easier to
manage in the future as we continue adding more.
function CreateShaderPrograms( gl ) {
// Shader 1 - standardProgram
// Draw point at x = 0, y = 0, z = 0
var v = `void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1); gl_PointSize = 10.0;}`;
var f = `void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }`; // Red
// Shader 2 - standardProgram
// Draw a point at an arbitrary location,
// which is determined globally by the JavaScript application
// This is done via "a_Position" attribute
v = `attribute vec4 a_Position;
void main() {
gl_Position = a_Position; gl_PointSize = 10.0; }`;
f = `void main() {
gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0); }`; // Green
Notice that first shader will draw a red point. The second one a green
one. This way we can make a distinction when seeing them being
drawn on canvas. This is included in the source code demos that
comes with this book and fully tested in Chrome browser.
Here we are creating two shaders using vertex and fragment pairs as
usual. You will also spot the familiar InitializeShader function each
time we need to initialize our shaders. It returns a created shader
program based on the source code we pass to it via v and f
arguments. If you want to add even more of your own shaders, this is
the function to add them to.
Loading shaders from script tags has also the advantage of avoiding
the obscure string concatenation formats provided by JavaScript
where the backtick is not available. For example, earlier
implementations of JavaScript. Or maybe you simply don't want to
store shaders in strings.
In the following example let's create our shader using script tags. All
you have to do is open two separate script tags and type in the
source code for both vertex and fragment shaders.
I intentionally waited for this subject until now. We've just looked at
how to load shaders from strings. First, let's make our vertex shader:
As you can see this makes writing shader code a little easier. Just
type them in without using + or quote characters at the end of each
line.
To actually read the string data from script tags we have to use
JavaScript's innerHTML function. I am using the already-familiar
example from previous chapters. However, here I highlighted the
lines that actually read the shader source code and initialize it.
gl = GetWebGLContext( canvas );
if (!gl)
console.log('Failed to set up WebGL.');
var vs = document.getElementById("standard-vs").innerHTML;
var fs = document.getElementById("standard-frag").innerHTML;
gl.useProgram( Shader.standardProgram );
gl.drawArrays(gl.POINTS, 0, 1);
}
});
In the next chapter we'll take a look at how to create an even more
sophisticated shader initialization routine by loading shaders from
separate files on a web server.
After running this code we have just downloaded shader.txt file into
our application and its source code is now available via the msg
variable if our ajax call succeeds.
To solve this problem we can chain two Ajax requests one after the
other. Let's create a function that loads a pair of shaders into our
Shader object:
v = msg;
f = msg;
function CreateShaderPrograms( gl ) {
// Shader 1 - standardProgram
LoadShader( gl, Shader.standardProgram, "v1.txt", "f1.txt" );
// Shader 2 - globalDrawingProgram
LoadShader( gl, Shader.globalDrawingProgram, "v2.txt", "f2.txt");
Now this looks a lot more clean! Our shaders are moved out of our
source code and are located at a unique location on our web server
where they can be edited separately.
Things get a little more complicated when we're reminded that Ajax
calls are asynchronous. Unlike loading shaders in a C++ desktop
application where source code is available immediately after file
reading function returns, the Ajax call takes time to complete, even
after the function has been called.
Moreover, Ajax calls that are executed simultaneously are not
guaranteed to complete in the order they were executed. This
depends on the delays when making an HTTP request.
Dealing with these types of issues is the focus of this source code
provided below. Resource loader routines can take a small book in
themselves to write about. So I've written a compact piece of code
that accomplishes just what we need.
Let's create a "shader" folder in our project in the root directory and
prepare our shaders. I saved them to shader.vs and shader.frag
filenames that compose one shader program from a Vertex and
Fragment shader source code:
We can probably write a PHP script that reads all files from the
shader directory and builds an array of filenames that represent each.
But in this example, we'll simple store them in a custom Array as
shown in the following example.
The standard.vs and global.vs are the separate files containing vertex
shader source code written in GLSL from our previous examples.
Same goes for their .frag counterparts. We're simply storing them in
files now.
Updating LoadShader
Both a and b variables will link to the same shader program object
represented as a property of the global Shader object. It's just a
different way to refer to the same thing. But the significance here is
that by using the string we can simulate "pass by reference" to a
JavaScript function.
The rest of the mechanism that deals with preventing these errors is
explained below (or rather on the next page):
(Have you ever tried editing a book? The diagrams are unpredictably
jumpy and you have to figure out how to integrate them into the text
without breaking page flow.)
CreateShadersFromFile - This is our new function that reads
shaders from a location on the web. It will utilize our global shader
filename (shaders in example above) and shader program name
(shader_name array containing program names in string format so
we can pass them by reference to our updated LoadShader function)
arrays.
But there are a few new updates to the LoadShader function that
were added. Let's take a look at them now.
Well, in our new version, we will switch program variable with the
shader program that was passed to the function by name as a string.
The only thing we're changing in LoadShaders is that line. Now let's
take a look at what code we will replace that line with:
console.log("webGLResourcesLoaded():" +
"All webGL shaders have finished loading!");
if (!gl)
return;
Notice that this function is attached to the global window object. This
gives us the ability to call it from any callback function. A callback
function is one that is called after a process or an event has finished
executing.
This next function we will take a look at that will explain not only that,
but also how to map mouse coordinates to the WebGL coordinate
system. You see, they are not the same. On a WebGL canvas, the
x=0, y=0, z=0 falls right in the middle of the canvas.
Cache Busting
Let's say you updated your shader GLSL source code. You reload
your program, you even try CTRL-F5. Still you're not seeing the
results change. It looks and feels as though your previous version of
the shader is still at work. You might be right. The browser saved the
last version in cache and is still feeding it to you, even if the source
code of the original file has changed.
This will often happen when modifying shader source code. To fight
the problem, let's bust the cache. All we have to do is add a version
name to the file being loaded. Adding a ?v=1 to any file name will
signal the browser to grab a new version rather than looking in the
cache.
I highlighted the new parts. Notice the creation and inclusion of the
new cache_Bust variable.
By the way this technique is the same for loading texture images. I
am sure you can figure out how to add this functionality to our current
image loader as well by replicating the logic shown in this example.
Conclusion
It looks like in this chapter we have just figured out how to load our
shaders from either strings or from separate files stored on the web
server. We've also taken a look at how to load the latest version of
the source code by busting browser's cache system.
From this point on all future chapters and source code will assume
that we understand how that works and use these functions in
following examples and tutorials.
Having said this, let's do something interesting. Let's see how with
our newly loaded and initialized shader programs we can draw points
at arbitrary location on canvas. In particular, let's create a program
that draws a point on canvas that follows the mouse cursor.
This means that every time the mouse is found moving over the
canvas surface, it will generate an event and the anonymous
function( e ) will be called each time that is the case.
if (a_Position < 0)
console.log("Failed to get attribute pointer a_Position.");
else {
gl.drawArrays(gl.POINTS, 0, 1);
}
}
Because WebGL canvas, like OpenGL has a Cartesian coordinate
system and by default the 3D camera view is looking down the -Z
axis, points we draw at [0,0,0] position will appear in the middle of the
canvas.
In both OpenGL and WebGL software it's not uncommon for values to
be represented as ranges between 0.0f - 1.0f. Starting from the
middle of the screen, the canvas, no matter what size, is represented
as follows:
These 3 floating point values will be the ones we'll be passing to our
new shader program. For simplicity sake I called this shader program
globalDrawingProgram. It draws a point at an arbitrary location that
was passed to the shader via its attribute a_Position, defined within
the vertex shader itself. Its source code is displayed in its entirety
below:
void main() {
gl_Position = a_Position;
gl_PointSize = 10.0;
}
The fragment shader requires a 4f value (vec4) and here it's filled out
with static green color represented by RGB color as (0.0, 1.0, 0.0)
and extra placeholder so that the value won't have to be cast from
another type when it enters the shader. (Most common operations in
GLSL shaders use vec4 values.)
This is why a_Position within the vertex shader must be defined using
vec4 (not vec3, to match 3f coming in from vertexAttrib3f function.)
This way we're only concerned with passing XYZ values (3 variables)
into the shader from our JavaScript program. Within the shader it is
then automatically cast to a vec4 value which contains 4 values,
because it's just the value the shader takes by default.
This is just standard convention that has been traditionally kept from
early days of OpenGL (And even Direct3D) programming. The 4x4
arrays also conveniently represent 16 data parts, which is great news
for GPU processors whose machine instructions are designed to
operate on data divisible by8.
Gem 11 - 3D Transformations
We've gone through a lot so far and things didn't even start to look
very exciting yet. This is why I think this is a good place to cover
transformation and object movement in 3D space. This will help us to
finally draw our first triangle on the screen and prepare us for the
following chapters where 3D camera movement, rendering triangles,
texture mapping, lighting and shading techniques (such as shadow
mapping) will be discussed.
I want to write about some fundamentals first and then will go into
technical stuff like Transformations and Matrices. You don't really
need to know the math behind them. I remember learning about
transformations from other books and thinking that I don't really
understand the complex math behind them, presented in long
formulas. If you're someone who loves math, they will probably be fun
to delve into. But, we simply want to understand the principles behind
them.
Translating
An object can be moved (we will use the term translating from now on
because this term is more common among 3D programmers) on all 3
axis (X Y and Z) in either negative or positive direction.
An object can also be rotated around the 3 axis. The axis an object
can be rotated about are different from the axis it is translated on. An
object is usually rotated around its local coordinates and translated
along the world coordinates. But it can also be rotated in world
coordinates in certain cases. The two types of rotation (local vs world)
are often mistaken for one another which results in inaccurate object
rotation results.
Scaling
For this reason and for simplicity's sake in this chapter translation and
rotation will be demonstrated on a single triangle. Just think of it as an
object. The same rules apply.
Objects after all are made of triangles too, there are just more than
one triangle enclosing them. Also I should mention Object (or Model)
Composition is a different topic and will be covered in future chapters
just before we go into WebGL light model and how light works in
general; that way we will be able to see lit, rotating objects rather than
just plain boring polygons.
But let's get back to the basic principle at hand. If you can understand
this you will easily understand most other transformations. You'll see
that transformations that appear complex are simply rotating and
moving (on either local or world coordinate system) done in a certain
order.
If you're learning 3D graphics for the first time you've been probably
just thinking of rendering random triangles anywhere on the screen
by suggesting its coordinates. But what makes this triangle different is
that it has a center. This is of course, an imaginary center. But it
demonstrates the idea that 3D objects are usually constructed around
the center of their local coordinate system. This is why when you
open Blender, 3DS Max, Maya, Z-Brush, 3D Coat, or other similar
software, new objects appear exactly in the middle of the coordinate
system.
You see, when you build unique individual 3D models you have to
always make sure you build them around the logical center which is
located at X=0, Y=0, Z=0 in the object's local coordinate system. If
you don't follow this simple rule, your object will not rotate evenly. In
addition, later this can introduce a plentitude of other problems.
As you can see the body of the monster is built around the center.
And it's "standing" exactly at the point where all X, Y and Z
coordinates equal 0. That's because this is the imaginary point that
will be used for collision detection against the ground in the game.
This is the general rule behind building models and/or objects.
But what if the camera wasn't placed at the origin? What if we moved
it to a new location, and gave it a new viewing angle? That leads us
to conclusion that our 3D objects will be drawn with respect to the
camera position. Which creates another level of complexity for view
calculations.
When you move the camera (and this step should always be first,
before you move your objects or do any other transformations) you
are actually modifying the view by applying the Viewing
Transformation to the coordinate system.
It is crucial to understand that when you are moving the camera, what
happens is not the actual movement of camera in space. In fact, both
the camera movement and the model movement are tied together
into the same mathematical formula that gives us simultaneous
control over the camera and the object(s).
3D Transformations
Viewport Transformation
We've somewhat already done that in the shader chapters, but things
get a little more complex as we move forward. But we're not quite
there yet.
Any movement applied to the objects, for instance if you move your
monster model from one point to another, is achieved through the
modeling transformation; and combined with say, walking animation
(which has nothing to do with transformations) will result in a walking
monster! Both animation and modeling transformation put together
makes a monster appear to be walking.
Once you modified the coordinate system you can place objects on it
and they will appear as if they were transformed by whatever
operations were originally performed. You can even save the current
state of the coordinate system and then retrieve it later on after more
transformations are applied and then to use it as a starting point for
consequent transformations.
By using this technique you will see that you will be able to place
objects at any position and angle you want and you will even be able
to draw objects that rotate around other objects that also rotate
(simulating orbiting planets for example) around some other objects.
3D Transformation Order
Imagine that you first move the sphere left and then rotate it around
the Y axis. The outcome is that the sphere will be rotating around an
imaginary origin (located at [0, 0, 0]) as if it's a planet rotating around
the sun and not around its center because you first displaced the
sphere from its center and only then rotated it. Keep in mind that
rotation is performed around the center of the given coordinate
system.
On the other hand, anyone could just learn a function and remember
that "that function" rotates an object and "this function" moves an
object. That is also acceptable but you don't really learn anything
substantial about 3D graphics with that.
And now that we know how transformations work, we're ready to start
putting them into action. Let's write some code that demonstrates
principles covered in this chapter.
Recall how in Gem 8 we found out how to wait for shaders to load
before starting the render loop. We used a function we created
attached to the root window object called webGLResourcesLoaded.
The next step is creating buffers for storing both of our shaders. Each
shader must be then bound to its respective array buffer
gl.ARRAY_BUFFER and gl.ELEMENT_ARRAY_BUFFER flags help
us accomplish this.
Once the buffers are bound, we now need to link it to the actual data
stored in vertices and indices arrays. This is done by using
gl.bufferData function.
Finally, the buffer is unbound. We have to detach the buffer because
at this point we're done and we no longer need the buffer object. It
was used temporarily only to link up our vertex and index array data
to the buffer. Passing null as the second parameter to gl.bufferData
will unbind it:
// Create buffer objects for storing triangle vertex and index data
var vertexbuffer = gl.createBuffer();
var indexbuffer = gl.createBuffer();
// Bind and create enough room for our data on respective buffers
// Bind it to ARRAY_BUFFER
gl.bindBuffer(gl.ARRAY_BUFFER, vertexbuffer);
// Send our vertex data to the buffer using floating point array
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array(vertices), gl.STATIC_DRAW);
// We're done; now we have to unbind the buffer
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// Bind it to ELEMENT_ARRAY_BUFFER
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexbuffer);
// Send index (indices) data to this buffer
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,
new Uint16Array(indices), gl.STATIC_DRAW);
// We're done; unbind, we no longer need the buffer object
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
But now we have to repeat the process for each buffer and rebind
them to ARRAY_BUFFER and ELEMENT_ARARY_BUFFER:
// Bind our vertex and index buffers to their respective buffer types
gl.bindBuffer(gl.ARRAY_BUFFER, vertexbuffer);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexbuffer);
We're now ready to draw the bridge between our vertex arrays and
the shader. This will help us to pass data to the shader dynamically. If
it changes in our JavaScript program, it will be automatically updated
in the shader. This is accomplished by code that follows:
// Enable it
gl.enableVertexAttribArray(coords);
if (!gl)
return;
gl.clear(gl.COLOR_BUFFER_BIT);
// Draw triangle
gl.drawElements(gl.TRIANGLES, indices.length,
gl.UNSIGNED_SHORT,0);
});
However, chances are your first WebGL game is probably not going
to be Mario Kart 8. Instead you probably want ease into gradually
building your engine up increasing quality along the way. In this
sense, you will probably have to experiment with light using vertex
colors.
All this means is that I recommend using PLY format for that. PLY is
amazingly great for storing all possibly needed data for your 3D world
from vertices, texture coordinates, normal coordinates, and yes… the
r,g,b color for each vertex. PLY format can accomplish so much more
than OBJ, and surprisingly even the PLY format loader code is
simpler to write.
I wish I could continue talking about the rest of the subjects without
having to write any utility JavaScript code before we move forward.
But in order to save space in this book by not going on random
tangents about subjects that have little to do with WebGL, it appears
that it's one of those times where we have to do exactly just that.
All of this is the necessary "peeling of the onion" or most commonly
known among programmers as Yak shaving (I hope you have heard
this phrase before) process common to programming complex
modular systems. It means that we have to grasp a lot of seemingly
small or even unrelated concepts in order to progress onto following
subjects that depend on them. But by accomplishing one of the parts,
we're that much closer to making the whole work as intended.
This Vector class will later be used together with Segment class.
Together they will form a library that will help us work with various
vector data that's a little too common to both 3D graphics
programming and game programming. Unfortunately, there isn't a
way around this.
Even though in WebGL we are working with all 3 axis we can set up
our camera in such way that would visually represent a 2D plane. And
all standard 2D rules would still apply to it.
https://github.com/gregsidelnikov
For the reason stated above this chapter will introduce you to the
Vector library. Several principles covered by the methods of this class
can be easily transferred to represent and calculate 3D values by
simply adding an extra axis as one of the properties of the class and
renaming the class to something like Vector3D. But it's the logic of
the actual algorithms that matters.
Let's dive straight into the source code. And then I'll explain what's
happening here.
Vector Class
Let's create a class that we can reuse later in all future examples.
Vector Representation
Adding Vectors
The two examples add two different vectors in each case. So they are
distinct from one another. Here, aside from using origin-based
coordinates, they are both simply showing two random situations.
var a_x = 6;
var a_y = 2;
var b_x = -4;
var b_y = 5;
var c_x = a_x - b_x;
var c_y = a_y - b_y;
Subtracting Vectors
Multiply Vectors
Division
Cross Product
Left side. Starting out with vector a we can create a normal vector c.
Right side. Even after the original vector a is rotated, the normal still
points away at a 90 degree angle..
Dot Product
The Dot Product is a scalar value (not an angle) and it's calculated by
determining the angle between two vectors. In particular, whether that
angle is greater or less than 90 degrees. This technique is used to
figure out whether two vectors are headed in roughly the same
direction, or are pointing away from each other.
Left side. The degree between vector a and b is greater than 90.
This means two vectors are pointing in two radically different
directions or "away" from each other"
Right side. Less than 90 degrees between two vectors a and b. Two
vectors are pointing in roughly the same direction. The surface of the
polygons or segments they represent are facing the same side of a
plane.
By using Dot Product WebGL can figure out whether the camera
vector is pointing toward a polygon's face (surface) or away from it.
If we know that a given polygon points away from the camera we can
exclude it from our render list because it would be "facing away from
the camera." Such polygons are usually clipped away and not
rendered because they usually represent either the "inside" of a
concealed object facing away or any other object. It's the area that
we are guaranteed will never be visible to the camera.
In general, there are not that many operations that require a deep
knowledge of trigonometry when it comes to 3D graphics. You just
have to thoroughly understand what they are and what they're used
for. Eventually after a bit of practice it'll become like second nature.
While reading other technical books I noticed that I would often get
confused when source code was skipped and "..." was used instead
to make sure that no unnecessary page space was used to repeat
what was already said. I can definitely understand authors of those
books. However, that has the drawback of page to page browsing
that ultimately slows down the learning process. So I am going to
copy the previous source code in its entirety here. However, I will
highlight only what was changed so it's easier to process.
Please note that I am doing something else to the vertex data now. I
am initializing vertex and the new color array using native WebGL
object Float32Array. These are just like regular JavaScript arrays.
The convenience is that we can grab the byte size of each value via
Float32Array.BYTES_PER_ELEMENT property, wheres prior to this
we used gl.FLOAT.
This simply gives us the convenience to refer to the data size when
specifying vertex attribute pointers using vertexAttribPointer function.
The last two parameters of which are size of the data per vertex and
stride.
// Create buffer objects for storing triangle vertex and index data
var vertexbuffer = gl.createBuffer();
var colorbuffer = gl.createBuffer(); // New: also create color buffer
var indexbuffer = gl.createBuffer();
// Bind it to ELEMENT_ARRAY_BUFFER
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexbuffer);
// Send index (indices) data to this buffer
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,
new Uint16Array(indices), gl.STATIC_DRAW);
if (!gl)
return;
// Draw triangle
gl.drawElements(gl.TRIANGLES, indices.length,
gl.UNSIGNED_SHORT,0);
});
Most notably we're using a new shader here. We can no longer use
Shader.standardShader from previous example. It has been replaced
with Shader.vertexColorProgram which will be discussed in a bit.
We will now modify our shaders. Note, that we need an entirely new
shader here. Not our old standard.vs and standard.frag pair. We're
building on them, but if we modify these shaders with new code to
support vertex color, our previous demos will cease working. The
code will break because they don't pass a_Color attribute and these
shaders expect one.
vertex.vs
attribute vec4 a_Position;
attribute vec4 a_Color; // New: added vec4 attribute
varying vec4 color; // New: this will be passed to fragment shader
void main() {
gl_Position = a_Position;
color = a_Color; // New: pass it as varying color, not a_Color.
}
Just three new lines were added. a_Color is now passed together
with a_Position. I also created varying vec4 color. It is also specified
in the fragment shader:
vertex.frag
precision lowp float; // New: specify floating-point precision
varying vec4 color; // New: receive it from vertex shader
void main() {
gl_FragColor = color;
}
Use highp for vertex positions, mediump for texture coordinates and
lowp for colors.
And finally, putting this all together and running it in the browser will
produce the following result:
Here, each tip of our triangle is nicely interpolated between the colors
we specified in our colors array. Switch them to any color you'd like
and you will see a smooth transition between them. This is done on
the GPU by our fragment shader.
When you write your own demos using the base source code from
this book, let's make sure we're not skipping another important detail
when adding new shaders to our engine to avoid all kinds of
JavaScript build errors.
Because we're adding an entirely new shader, let's see which code
needs to be updated. Here I added new "vertex" string representing
vertex.js and vertex.frag pair to the shaders array. We now have 3
shaders!
Now let's give our new vertex color shader a name we can refer to by
in our program.
// Use our new vertex color shader program for rendering this triangle
gl.useProgram( Shader.vertexColorProgram );
And this is pretty much the entire process whenever you want to add
a new shader and start using it. Let's quickly review it.
We switched to loading shaders from URLs. Because we are now
automatically scanning the "shaders" directory in our project. All
shaders found in it will be automatically loaded into our Shader
object. We've also given them all a name so they're easy to select
and use.
Just make sure that each time you add a new shader you specify its
filename in shaders array, write the actual shadername.vs and
shadername.frag pair and drop it into "shaders" folder and create a
unique name you want to use by storing it in shader_name array.
That's it!
You could even create your own shader names as the Shader's
object properties algorithmically. Because alternatively to
object.property_name = 1 assignment in JavaScript, you can assign
properties to objects using object["property_name"] notation. This
language feature is excellent at automatically loading data from a
folder and storing it using variables that partially resemble those
filenames. I'll briefly discuss it here and then we'll move on. For
example:
object["program" + filename];
And what does this give us? We will never have to bother with
creating shader arrays manually by hand just because we wrote a
new shader. You'd simply drop your shader pair into the "shader"
folder and it would be automatically read from there (you can write a
PHP reader that scans the shaders folder) and then the variable
names will become auto-magically available from the object itself as
in Shader.programShaderfilename. But this is the task I leave to you.
If you get this done perhaps you can venture into my GitHub (http://
www.github.com/gregsidelnikov) account fork it or submit a pull
request from my WebGLTutorials project and then push it back.
A bit later in the book, I will demonstrate how this is achieved, but for
another task: loading textures. This is such an important technique
that practically solves a lot of frustration. And while at first you might
think this is too complex and unnecessary, when you get to feel those
practical benefits actually improve your workflow you will fall in love
with this method and not want to go back.
You can further optimize your engine because at this point it is still
pretty simple and malleable. But even now already it's still a nice way
of creating, loading and using new shaders because writing them
inside <SCRIPT> tags all the time is a management nightmare.
The shaders are automatically loaded from the URL using function
CreateShadersFromFile we wrote earlier. Let's recall it from a
previous chapter:
As you can see filenames are generated from our shaders array. We
never have to specify the pair manually, just its common filename
without the extension.
Thus, once our new shader is loaded at the last index [i] in the array,
we're ready to continue initialization process and start rendering our
object using it.
I won't spend much time and space talking about drawing multiple
primitives. In this case, triangles. Our current code is already easy to
expand to drawing multiple shapes. I'll only briefly show it here. This
step is necessary so we can move forward to creating more
advanced shapes in the next chapter.
// Triangle 1 vertices:
0.0, 0.5, 0.0, // Vertex A (x,y,z)
-0.5, -0.5, 0.0, // Vertex B (x,y,z)
0.5, -0.5, 0.0, // Vertex C (x,y,z)
// Triangle 2 vertices:
0.05 + 0.0, 0.75, 0.0, // Vertex A (x,y,z)
0.75 - 0.5, -0.75, 0.0, // Vertex B (x,y,z)
0.15 + 0.5, -0.75, 0.0, // Vertex C (x,y,z)
]);
Simply continue adding new values to each attribute array. The next
set of vertices in vertices array will define location of the second
triangle. Likewise, the colors array is also extended to provide vertex
colors for it.
Finally, there is one more thing to do. We now have to let the indices
array know that we have new data. Simply add new indices in
consequent order:
// Draw triangle
gl.drawElements(gl.TRIANGLES, indices.length,
gl.UNSIGNED_SHORT, 0);
Yes, but what's so controversial about it? Okay. Here it is. Soon as
the texture is loaded, we will save the loaded object in a variable
matching the name of the image, minus the extension name. And
attach it to window object. This way, it will become globally available.
For example: images like wood.png will become an object
window.wood and can be accessible throughout our WebGL
application in global scope via a simple variable called wood. (without
the optional and implicit window object.)
If you are still worried that some images, for example "class.png" or
"var.png" (for example) will be dropped into "textures" folder and
converted to window.class or window.var properties ("class" and "var"
are reserved JavaScript keywords and cannot be used as variable
identifier names) you can always programmatically append "img", or
"img_" to them so they become window.img_class and
window.img_var respectively and avoid clashing with reserved
names. We'll take a risk throughout this book and not do that and
manually avoid using these types of names. In return, we gain a
wonderful ability of simply saving our textures into "textures" folder
and never worry about loading any new textures by hand. This will
speed up the learning process and simplify WebGL programming in
general.
<?php
$dir = "textures/";
$return_array = array();
if (is_dir($dir)) {
if ($dh = opendir($dir)) {
while(($file = readdir($dh)) != false) {
if ($file == "." or $file == "..") {
// Skip upper directories
} else {
// Add the file to the array
$return_array[] = $file;
}
}
}
// Return list of files in JSON format
echo json_encode($return_array);
}
?>
An HTTP request to this script will grab a list of all images in the
"textures" directory and create new Texture objects from them. But
where will it store them? First, we have to create a new Texture object
in JavaScript.
Surprisingly, it's incredibly trivial. But then again, we're not doing
anything more than loading an image programmatically:
window.ResourceId = 0; // Index of currently loading image
window.TotalTextures = 0; // Number of textures in "textures" folder
window.Ltimer = null; // Timer that waits for all textures
this.filename = fn;
this.width = 0;
this.height = 0;
if (!SilentLoad) {
console.log("Loaded sprite (" + that.width +
"x" + that.height + ")" + fn);
}
window.ResourceId++; // increase resource counter
};
this.image.src = filename; // Assign resource to "src"
return this; // Return a link to loaded object
};
In WebGL we "bind" data to buffers and then when all operations are
finished we unbind again. This prepares the texture to be later used
in our shaders. You can choose only one texture at time when
rendering a model. The same is true for shaders. For this reason in
modern graphics we have to organize our 3D models by texture and
material type when rendering. Some 3D geometry may share the
same shader or texture. In this case no switch is required. We have
to be opportunistic here.
And finally, to put it all together, we will make an HTTP request and
load all images asynchronously. Notice the usage of new Texture
operation from above in both of the examples below.
You can do it with a plain JavaScript HTTP request, if you don't want
to depend on outside libraries like jQuery:
If you ever want to make a progress bar (which for most games you
will have to) you can use the resourceNumber variable taken from
length of the returned JSON object. This is the number of resources
equal to 100% progress bar length. You can take it from there.
Perhaps the most important part of the code is the line below. It
creates a new texture object on global window object using its
"appropriate name". Which is the filename without the extension.
window[appropriateName] =
new Texture("textures/" + window.LoadingFileName);
Or you can do it with the aid of jQuery, which is exactly the same
except we are invoking jQuery's ($'s) ajax method. Some
programmers prefer it this way because it's cross-browser and the
code looks somewhat more readable:
if (JSON.parse(msg) != undefined) {
The end result? Let's say you dropped rocks.png texture into
"textures" folder. That's all. From now on you can access it's image
data via window.rocks. It will point to a Texture object.
In this texture object you will have a property specifying a link to the
texture image in memory. To access it on a newly loaded texture refer
to it as window.rock.image And that's what we will pass to WebGL as
a target for the texture image. This texture image script simplifies a lot
of things we would otherwise have to do by hand.
My challenge for you is to use this example and rebuild its
mechanism to auto-magically load shaders as well and store them in
the main Shader object.
Then, from that point on you will have both shaders and textures: two
of the most important types of assets in 3D graphics (the other being,
the 3D model's vertices) loading automatically. Which is a really
heavenly feature for a 3D engine. I will show you how to load OBJ
and PLY models later in the book so we can display some reasonably
interesting geometry in our game world.
Appropriate Name
We will also wait until all images have finished loading. I'll update our
code a bit to adapt to this new texture loader. In which case an
additional function called TexturesLoaded will be executed just after
the shaders have finished loading.
Once this function is executed we can initialize our VBOs and VAOs
(Vertex Buffer and Arrays Objects) and pass our texture image data
to WebGL functions to bind them to arrays and pass them into the
shader itself via uniform sampler2D image. More on this in just a bit.
But there is one more thing. (There always is, isn't there?)
Before we can enter the main WebGL rendering loop, we first must
ensure that not only textures but also all shaders have finished
loading. We've already done this test, but only for shaders. Now that
we have included asynchronous texture loader, we must rebuild that
loader so it takes a look at both.
The source code below helps us accomplish that. I moved out the
shader test from the shader loader (not shown here.) And I replaced
that with a check for both the textures and shaders within the texture
loader itself.
The reasoning behind this change is that chances are textures will
take longer to load than shaders. In games we often have large lists
of textures that are over usually 1024x1024 and greater. It's probably
reasonable to believe that images will take longer to load from a URL
than loading and compiling shaders.
I highlighted the changed code. Again, for latest version of the source
code from this book, find WebGLTutorials repository on my GitHub
account: https://www.github.com/gregsidelnikov
function LoadTextures() {
console.log("window.TotalTextures = " +
window.TotalTextures);
http://localhost/tigrisgames.com/fx/textures/
All is well in vertex color kingdom. And vertex colors add a lot more
realism to our 3D objects. Texture mapping is a technique that lets us
stretch or "paste" a picture across the triangle. Much in the same way
as color was interpolated across the triangle's vertices, we're going to
interpolate pixel values taken from an image, or a texture.
Let's take our previous example and write a new shader that will
incorporate vertex colors and texture coordinates. We'll use it from
now on every time we need to draw texture-mapped surfaces.
In this section we will extend our vertex color shader by adding a new
attribute: a_Texture. You can name it anything you want. But in this
tutorial we will stick to the a_ naming format for our attributes just to
stay consistent and keep attributes organized.
The following shader pair will take additional texture coordinates from
our JavaScript program. I called it texture.vs and texture.frag
accordingly. I also added it to our shader manager program following
the same process described in the section where we created the
vertex color shader. In the shader manager our new shader will be
called textureMapProgram. And we will enable it by calling
Shader.use(Shader.textureMapProgram). But for now, here is its
source code:
texture.vs
precision mediump float;
attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec2 a_Texture; // New: added texture
texture.frag
precision mediump float;
void main() {
gl_FragColor = texture2D(image, vec2(texture.s, texture.t));
}
But what if you want to combine both the vertex color and texture
images? There is no problem doing that in a GLSL shader. The
solution? Simply multiply the vertex color by texture coordinate.
Until now we have drawn a simple triangle. It's okay that it's a simple
shape. All we were doing was learning how to draw the most basic
primitive using a shader and binding vertex data to an array that was
then passed onto the GPU.
We will use the cube and other simple 3D shapes throughout the rest
of the book to show off effects generated by shaders. But first we
have to generate them. We haven't gotten to the point where we can
load models from a file. So we will algorithmically create them here.
Because shaders affect the look of an object based on its angle, we
will also create a function that will help us rotate the said object using
mouse controls. We'll use this mechanic in all other demos in this
book. But that's in next chapter after this.
Later you can learn how to create more sophisticated level design
using software such as Blender, Maya, 3D coat and so on. But first,
let's create some objects
Cube
This time, just for fun, I will paint each side of the cube in a different
color using our pre-made vertex color shader.
I also started a new JavaScript file primitives.js and this is where our
vertex-building functions will reside from now on. If you want to grab a
cube, simply call makeCube() function and it will return the vertices
describing a cube. Other shapes can be added later.
function makeCube() {
return new Float32Array([
1.0,-1.0, 1.0, // triangle 1 of face 1
-1.0,-1.0, 1.0,
-1.0,-1.0,-1.0,
-1.0, 1.0,-1.0, // triangle 2 of face 1
-1.0, 1.0, 1.0,
1.0, 1.0, 1.0,
// ... 30 other vertices not shown here to preserve space
]
);
}
function makeCubeColors() {
return new Float32Array([
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 1.0, 0.0,
1.0, 0.0, 0.0,
1.0, 1.0, 0.0,
1.0, 0.0, 0.0,
// ... 30 other colors not shown here to preserve space
]
);
}
function makeCubeTextures() {
return new Float32Array([
1.0, 0.0,
1.0, 1.0,
0.0, 1.0,
1.0, 1.0,
0.0, 1.0,
0.0, 0.0,
// ... 30 other UV's not shown here to preserve space
]
);
}
Primitives.js
Blender can export your model data in both OBJ and PLY format. In
this section we will take a look at the PLY model loader source code.
This is great for testing shaders but this won't do us any good for
loading more complex 3D worlds created in modeling software such
as Blender. Blender can save models in OBJ and PLY formats. PLY is
better because there isn't a reasonably simple way to store vertex
color data in OBJ files.
The solution is to write a PLY file format loading function, parse the
vertex, normal, texture and color data and rebuild it as Float32Array
data sets so we can finally pass it into the VAO (Vertex Array Object)
just like we did in previous examples.
Note: when saving files in PLY format from Blender you need to
ensure the following is true:
Your model must have texture data. In order to texture map your
model in Blender, pull out a second view pane and press Ctrl-F10 to
show your Texture Map. Select the object in the original view (not in
the texture map pane). Then go to Edit Mode, select all vertices by
pressing A key, then press U to "unwrap" vertices to a texture map.
Select an appropriate Unwrap option from the pop-up menu. Adjust
texture map if needed. Resave the model.
If any of the rules above are not taken in consideration the LoadPLY
function will not work as expected and your model might lose data.
But usually it will not load at all. So make sure you have at least one
vector contain color by using Vector Paint mode on your object and
just briefly touching one of the edges of the model. Also make sure
the model is unwrapped onto a texture (it can be any image, even
empty one. Just unwrapping vertices is sufficient enough.)
// PLY object
function PLY() { this.object; }
var VAO_VertexIndex = 0;
var FaceIndex = 0;
We need to choose some sort of a folder where all of our PLY models
will be stored.
http://localhost/tigrisgames.com/fx/model/
Yours of course would be different.
The PLY format stores two lists. First, it stores the vertex list
containing every single edge point of your model. Then it provides a
similar list for each face. The face list only contains indices to the
vertices from the first list. This way if any vertices repeat, we don't
have to store a copy of that data in the face array. Simply refer to its
numeric index value which is just an integer.
ply
format ascii 1.0
comment Created by Blender 2.74 (sub 0) - www.blender.org, source
file: 'racingtrack.blend'
element vertex 3425
property float x
property float y
property float z
property float nx
property float ny
property float nz
property float s
property float t
property uchar red
property uchar green
property uchar blue
element face 1942
property list uchar uint vertex_indices
end_header
vertex data line 1
vertex data line 2
vertex data line 3
vertex data line N…
Face data line 1
Face data line 2
Face data line 3
Face data line N...
As you can see there is a whole lot more of vertex data (3425 lines)
than face (only 1942 lines) of data. When it comes to reading the
header I made the lines we are interested in bold.
What follows are two large lists. One is vertex data that comes first.
And faces list right below it.
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
if (xmlhttp.status == 200) {
var PLY_index = 0;
var arrayVertex,
arrayNormal,
arrayTexture,
arrayColor,
arrayIndex;
// Read vertices
// Read faces
} else {
// vertices
arrayVertex.push(vertices[a].x);
arrayVertex.push(vertices[a].y);
arrayVertex.push(vertices[a].z);
arrayVertex.push(vertices[b].x);
arrayVertex.push(vertices[b].y);
arrayVertex.push(vertices[b].z);
arrayVertex.push(vertices[c].x);
arrayVertex.push(vertices[c].y);
arrayVertex.push(vertices[c].z);
// normals
arrayNormal.push(vertices[a].nx);
arrayNormal.push(vertices[a].ny);
arrayNormal.push(vertices[a].nz);
arrayNormal.push(vertices[b].nx);
arrayNormal.push(vertices[b].ny);
arrayNormal.push(vertices[b].nz);
arrayNormal.push(vertices[c].nx);
arrayNormal.push(vertices[c].ny);
arrayNormal.push(vertices[c].nz);
// colors
arrayColor.push(vertices[a].r);
arrayColor.push(vertices[a].g);
arrayColor.push(vertices[a].b);
arrayColor.push(vertices[b].r);
arrayColor.push(vertices[b].g);
arrayColor.push(vertices[b].b);
arrayColor.push(vertices[c].r);
arrayColor.push(vertices[c].g);
arrayColor.push(vertices[c].b);
// uv
arrayTexture.push(vertices[a].u);
arrayTexture.push(vertices[a].v);
arrayTexture.push(vertices[b].u);
arrayTexture.push(vertices[b].v);
arrayTexture.push(vertices[c].u);
arrayTexture.push(vertices[c].v);
// index
arrayIndex.push(FaceIndex);
}
FaceIndex++;
}
PLY_index++;
Pseudo Code
LoadPLY {
while (lines)
{
if (HeaderIsRead)
{
ReadVertices();
ReadFaces();
WireVerticesToFaceIndices();
} else {
ReadHeader();
vertices = ReadVertexNumber();
faces = ReadFaceNumber();
InitializeStorageArrays();
}
}
return [
new Float32Array(arrayVertex),
new Float32Array(arrayNormal),
new Float32Array(arrayTexture),
new Float32Array(arrayColor),
new Uint16Array(arrayIndex)
];
}
}
First we open the racingtrack.ply file and read the header. Then we
collect the number of vertices and faces. We then initialize our
storage arrays. These are the arrays that will be returned from the
function. They contain vertex, texture, normal and color data.
Once header is read, we begin scanning the file for a list of all
vertices. This is the longest list in the file. The list of faces which
follows is a bit shorter. Once the vertex and face lists are read, we
have to rewire the data.
As described in the PLY file header, the data on each line comes in
the following format:
x y z nx ny nz u v r g b
The data is separated by " " space character. So when we read it, we
use JavaScript's split method to create an array from this string. Each
array entry will be readable at its proper index:
e[0] // x
e[1] // y
e[2] // z
e[3] // nx
e[4] // ny
e[5] // nz
e[6] // u
e[7] // v
e[8] // r
e[9] // g
e[10] // b
And so on.
When we're loading color r, g, b values from the PLY file a conversion
is required. Blender saves RGB colors in format ranging from 0-255
per color channel. But WebGL requires 0.0f - 1.0f. In order to remedy
this situation we need to divide the incoming values by 255.0f. This
will clamp the value to 0-1 range we're looking for.
And now we have to figure out how to deal with face data.
The list of faces specifies only indices of the vertices. So we take the
vertices from the list in the order they were read and count that as
their index value. We then go into the face list where each entry
contains a polygon face consisting of 3 vertices each (triangle.)
Face indices:
In fact, the values in the resulting file are seemingly juggled around at
various intervals. We do not need to worry about this as long as we
properly rewire the model according to the indices stored in face
object we just read.
We will associate the indices stored in faces with the vertices of that
index. (We cannot simply read all vertices in the order they appear in
the file and construct a model out of that data. We have to associate
them with their respective indices stored in the list of faces.)
Now that this is done, we begin to extract the rewired data that was
read and start packing it into separate arrays. Each array represents
vertex, normal, texture and color coordinates. They all except texture
array contain 3 entries. The texture contains only 2: u and v
coordinate per vertex.
Once the function finished its operation we simply return arrays that
were read. Then we convert them to their WebGL representation
using Float32Array and Uint16Array types. Yes, you guessed it.
These arrays is what we will pass to the GPU just in the same way as
we did this in all of our previous examples. This function successfully
constructs the model for us.
A Word Of Caution
Loading OBJ or PLY formats is not the ideal way of loading 3D model
data into your WebGL application. These formats are created
inherently for working with 3D modeling software.
Ideally, you want to load your data into your application in binary
format. Preferably, compressed. However doing that is not covered in
this book (eventually it will be covered in a future edition of this
book.)
Congratulations. You can now load a much more complex world into
your graphics engine. In fact, this is exactly what we will do in this
section. I created a simple racing track over the weekend while
writing this book just to test the loader.
Let's take a look at the results.
The process of loading the model is exactly the same as with our
previous demos in the book. The only difference is that now the
vertex data comes from our LoadPLY function. Which, I modified just
for this example (but it should still be updated to adapt to loading
multiple models asynchronously; at this time in the book it is not yet
necessary).
Let's plug it into our initialization routine and wire it into the rendering
loop.
I've modified our asset loader code and instead of checking for
shaders and textures, I am also checking for models to have fully
downloaded. Once all 3 types of assets are done initializing, we can
enter webGLResourcesLoaded function to set up our vertices.
To accomplish that the only other thing that I updated was the way we
get vertex, colors, texture uvs and normals coordinates (normals are
intentionally commented out here for now):
window.webGLResourcesLoaded = function() {
console.log("webGLResourcesLoaded():" +
"All WebGL resources finished loading!");
...
Now instead of getting these values from our own functions, or even
worse, write them all out by hand like we did with the cube, here they
were redirected to the RacingTrack object loaded from LoadPLY
function now in model.js. I won't show here these details. Really, they
would only clutter the page space.
window.ModelsLoaded = false;
function LoadModels() {
LoadPLY("racingtrack.ply");
window.RacingTrack = [
new Float32Array(arrayVertex),
new Float32Array(arrayColor),
new Float32Array(arrayTexture),
new Float32Array(arrayNormal),
new Uint16Array(arrayIndex)
];
To see the full source code of this example just take a look at how it is
all put together at the following URL demonstrating it in action:
http://www.tigrisgames.com/fx/index10.php
Open up the source code for this URL and you will see all of the
techniques we've implemented so far working together to render a
Mario Kart-style racing track. Now this is a lot more interesting than
just a cube.
Here you will notice that the PNG is really reversed. I did this after I
finished working on the texture. WebGL loads PNGs up side down.
You can probably write this into the texture loading code so you don't
have to flip your PNGs every time you're done designing them.
I'm not trying to leave this out due to lack of explanations but to
demonstrate principles and things you must be aware of when
designing your own WebGL graphics engine. For example, if the PNG
is not flipped (or you didn't know this could be an issue) you might
become puzzled as to why the loaded level looks wonky and think
that the problem is elsewhere.
Depth Test
It's probably worth mentioning that this is the first time in the book
where we need to worry about the Z axis depth test. Without it, some
of the polygons of the loaded model would "cut through" the others or
simply not drawn in the correct order.
gl.clear(gl.COLOR_BUFFER_BIT);
gl.clear(gl.DEPTH_BUFFER_BIT);
Here we let WebGL know that we want to enable the depth test.
Somewhere later in your rendering loop, we can call gl.clear
command with either COLOR_BUFFER_BIT or
DEPTH_BUFFER_BIT (or both.) This is because framebuffers can
contain either color or Z buffer information at the same time. Either
one can be cleared to a value specified by gl.clearColor or
gl.clearDepth. Here we don't really have to set clearDepth color.
While the depth test is enabled and now produces accurate polygon
rendering clearDepth function is unnecessary here. It is usually used
when we operate on the framebuffer's depth buffer directly from our
shaders to achieve a particular effect. In this case we are not, so the
function remains untouched.
Conclusion
In this chapter we will also create our own JavaScript Segment class
for working with planar data. This class will not be particularly tailored
to the problem we're trying to solve here. But it will be useful here.
This way we can keep things organized instead of just working with
random variable names.
Let's imagine for a moment that we're looking at our 3D world from
some point above the ground and looking straight down some
arbitrary angle. It doesn't matter which, our character can be facing
any direction and be located anywhere on the ground plane.
Let's focus on the XZ plane because this way we can isolate the
problem of movement in 3D and view it as a 2D problem first.
The ground plane XZ can be traversed in any direction by changing X
and Z coordinates. However, the problem is complicated by the fact
that our character can look in any particular direction while walking on
this plane.
Strafing
Default Direction
Segment or Line?
We will then use these utility functions to simulate a first person view
walking animation with keyboard controls and mouse look.
Again, this class for simplicity's sake will work only with 2-dimensional
data. Which will also become incredibly helpful for mastering 2D
games in WebGL. And it can be easily re-implemented for 3D data by
simply adding an axis. However, in this case it is more than sufficient
for the purpose at hand.
Let's take a look at the Segment class and a few utility functions now.
window.int_x = 0;
window.int_y = 0;
var DONT_INTERSECT = 0;
var COLLINEAR = 1;
var DO_INTERSECT = 2;
// b
var x3 = segment.x;
var y3 = segment.y;
var x4 = segment.x + segment.vecx;
var y4 = segment.y + segment.vecy;
a1 = y2 - y1;
b1 = x1 - x2;
c1 = (x2 * y1) - (x1 * y2);
if (denom == 0)
return COLLINEAR;
Let's briefly review this class. We will not only use it to create 360-
degree movement in our game world, but also in many other
situations.
Note that the Segment class has a dependency on the Vector class.
The Segment class contains these standard functions:
center is simply the center point of this segment located right on the
segment between its two endpoints.
Walking in 3D Space
I will simply call the new class MouseControls. In the same way as
what we've done for keyboard class - a global variable Mouse will be
used to instantiate it.
In JavaScript to track position of the mouse on the screen we have to
look at the event object returned from a mouse event such as
onmousemove. In jQuery it is shortened to just mousemove. I created
a file mouse.js that contains everything we need to detect current
location of the mouse every time the event is intercepted.
mouse.js
// Global variable for tracking clicked state
window.clicked = false;
this.Initialize = function(element)
{
// Intercept mouse move event and extract position
$(element).on("mousemove", function(event) {
window.clicked = true;
});
}
}
And now we can put this code into action by instantiating the mouse
controls object:
mouse.js (continued...)
// Initialize global mouse object
var Mouse = new MouseControls();
Now that the global object Mouse is initialized we can safely look up
mouse coordinates on any animation frame by looking at Mouse.x
and Mouse.y.
setInterval( function() {
if (window.clicked)
{
// Where was the mouse clicked on the screen?
var x = Mouse.x;
var y = Mouse.y;
}
}, 0);
Conclusion
http://www.tigrisgames.com/fx/index11.php
Just use the keyboard to move the view around. Of course in this
demo I quickly hooked up keyboard controls to the main camera view
that takes it position from our ModelView matrix. Whenever arrow
keys are pressed, the translate transform for the ModelView matrix
takes that in consideration because global x and y coordinates are
changed. The result is illusion of a moving camera.
I hope you enjoyed this mini chapter. That's really all there is to say
about mouse controls. We wouldn't want to spend any more time with
this. Let's move forward to our next subject.
After a while of doing that, I've come up with the following keys. I
chose some of the most commonly used keys in games: The arrow
keys and WSAD. Let's define them.
keyboard.js
// ASCII codes
var KEY_LEFT = 37;
var KEY_RIGHT = 39;
var KEY_UP = 38;
var KEY_DOWN = 40;
var KEY_W = 87;
var KEY_S = 83;
var KEY_A = 65;
var KEY_D = 68;
I started a new file to include to the engine we've been building from
the first page of this book.
I will also create a window.key object that will hold the global object
responsible for tracking a key press on any of the keys listed above.
In addition, we need to find a way of tracking the shift key.
keyboard.js (continued)
var isShift = false;
window.key = null;
Now that we have our basic data setup we need a way to initialize our
Keyboard class and assign it to the global object instance key.
keyboard.js (continued)
function InitializeKeyboard()
{
window.key = new Keyboard();
$(document).keydown(function(e) {
if (e.keyCode == 16) isShift = true;
if (e.keyCode == KEY_LEFT) { key.left = true; }
if (e.keyCode == KEY_RIGHT) { key.right = true; }
if (e.keyCode == KEY_UP) { key.up = true; }
if (e.keyCode == KEY_DOWN) { key.down = true; }
if (e.keyCode == KEY_W) { key.w = true; }
if (e.keyCode == KEY_S) { key.s = true; }
if (e.keyCode == KEY_A) { key.a = true; }
if (e.keyCode == KEY_D) { key.d = true; }
console.log(e.keyCode);
});
$(document).keyup(function(e) {
if (e.keyCode == 16) isShift = false;
if (e.keyCode == KEY_LEFT) { key.left = false; }
if (e.keyCode == KEY_RIGHT) { key.right = false; }
if (e.keyCode == KEY_UP) { key.up = false; }
if (e.keyCode == KEY_DOWN) { key.down = false; }
if (e.keyCode == KEY_W) { key.w = false; }
if (e.keyCode == KEY_S) { key.s = false; }
if (e.keyCode == KEY_A) { key.a = false; }
if (e.keyCode == KEY_D) { key.d = false; }
});
}
Here we are using jQuery to intercept the keydown and keyup events.
jQuery helps us accomplish this in a cross-browser way so we don't
have to worry about re-implementing this code for multiple browsers.
}, 0);
When you're moving left and shift key is pressed your character could
run instead of walking. I think the idea is pretty straightforward here.
Sometimes in games you need to track the key just once. This gives
the player control over how often to pick up or release an in-game
item. Or a similar action.
You can simulate a "single press" event by tracking the key's state.
Which is something unique to game development. It is not common
for web applications to do this because they are usually not designed
to run in a live real-time animation loop. But when it comes to games
we sometimes need to check whether the key has been pressed just
once.
Native JavaScript keyboard event system does not have an optimal
way of doing this because from the start it just hasn't been tailored for
real-time animation. We can in theory use a native event for this but it
was originally designed for typing and not controlling game
characters.
The idea is to track a single press of a key. If the key is still being held
on the next frame of animation, we do nothing even if the key's
"pressed" state is still set to true. This "tapping" mechanism can be
used for firing a laser gun at an interval defined by player and not
necessarily when a frame-based counter expires. Some games
require this type of action. Surprisingly, very often, as you may have
found out if you've tried game development at least for a brief period
of time. Let alone for over a decade.
All we're doing here is recreating the native JavaScript event and
adapting it to real-time animation. It can be implemented as follows.
setInterval(function() {
if (key.left && !key_leftPressed) {
// Todo: perform some in-game action: Fire a laser gun, etc.
key_leftPressed = true;
} else {
key_leftPressed = false; // release key to restart the cycle
}
}, 0);
This gives us better control over keyboard events for games over
traditional JavaScript approach.
From now on in all of our future demos and examples in the book we
will be using keyboard.js to provide keyboard controls for moving
things around. It's a pretty straightforward library.
Gem 23 - 3D Camera
Remember that every time we add a major feature to our engine, it's
probably a good idea to start a new class. This gives you ability to
organize a large number of objects in a modular way.
Ok, enough of theory! Let's see what the class looks like.
Let's go ahead and see all of the possible ways in which the camera
can be used. Once we explore this subject in detail you will be able to
create fly-by cameras, or achieve pretty much any camera placement
you can possibly imagine.
After some research you will end up with 4 different types of possible
camera operations which I am listing below. 3 of them are extrinsic.
The remaining one is intrinsic.
Intrinsic simply means that it relates to the format of the camera view
itself and neither camera's position in the world nor the position of the
objects in the world.
Projection (Intrinsic)
The view cone dimensions. This is where view frustum or the size of
the view cone is changed. Used for camera effects such as zoom or
projection adjustments that deal with the resolution and screen
dimensions.
World (Extrinsic)
The position and angles of the objects in the world are transformed.
Camera (Extrinsic)
The position and rotation angles of the camera are transformed.
Look-At (Extrinsic)
The camera position and angles are calculated based on a target
object. In this case the camera view "follows" an object represented
by a vertex point (x, y and z.) usually lying directly in object's center
or a place of interest on the object is arbitrarily chosen.
For example, you can make the camera follow the duck but focus on
its eye. This camera style is often used in racing games like Mario
Kart 8.
The LookAt camera is perhaps one of the most useful camera views.
It gives us ability to create a camera view from camera's position in
space, an up vector and a direction vector. This can be used for
implementing anything from car-chasing views to cinematic
animations.
Calculating the LookAt vector is non-trivial. But it's not very difficult
either once we understand what's involved. Let's take a look at this
diagram that represents a LookAt camera visually:
The LookAt camera is defined by 3 vectors pointing away from each
other and separated by exactly 90 degree angle. It basically
determines the camera's own local coordinate system. One of the
rays points directly at a "target" object. The other two simply define
the camera's "up" side. The up vector is usually computed in
camera's local space. For example { 0.0, 1.0, 0.0 }, where the Y
coordinate is pointing up by a unit vector of 1.0.
CanvasMatrix4.prototype.lookat2 =
// Cross product 2
var vF = s.cross( f ).unit();
var u = new Vector(vF.x, vF.y, vF.z);
this.m44 = 1.0;
}
Here we first store the values up, lookat and position. These are
simply passed into the function. The values are pretty straightforward.
But the calculation that follows may not be.
First, we need to subtract position from the look at vector to get a new
vector which will serve as the ray being projected from the camera's
"eye" point to some target object. This also determines the length of
the segment which will be used together with the up vector in the
following way:
We will calculate the cross product between this new vector (f) and
the up vector. And we'll then perform a second cross product
operation. But this time between the newly generated vector (s) and
and the direction vector (f).
Doing this will give us vectors that define a new camera matrix
pointing in the direction of the target object. The camera will be
pointing directly into the center of the given object.
Practical Implementation
From the very beginning, this book was not planned as a reference of
math formulas and long obscure mathematical derivations. Here we
are focused on practical game design principles and their
implementation with only brief source code explanations. Ones that
are just enough to get the point across and start making something
with it.
In that regard this section is perhaps the most exciting one so far.
This is where an implementation of a LookAt camera with keyboard
controls for recreating a racing game car chasing view will be
demonstrated.
I will use all of this software we've developed up to this point with our
new LookAt function to recreate a Mario Kart-like game in a future
chapter. It won't have impressive graphics but it will have a few solid
features:
But before we move forward with the racing kart demo, let's explore
WebGL light. It'll make our level design appear a bit more interesting
and less plain.
The same principles of light rendering apply to painted art. Here we're
looking at a diagram consisting of 3 abstract light components:
Of course in nature there aren't any different types of light. These are
all just abstract representations of it. Breaking down light into these
categories helps writing efficient algorithms that simulate light as
close as possible to reality.
Shaders provide simulation for each photon of light. But the process
is kind of backwards. We are given access to each fragment (pixel)
on the surface of a polygon. Which is only the result of a photon
hitting some sort of a surface. Surface quality (shiny, dull, etc) in
WebGL are created using materials. At first materials might appear
too simplistic for creating anything that looks real. After all, materials
are determined by the color of the object itself in combination with the
color of the light being shined on that object. Specular highlight helps
create illusion of either a plastic or metallic object. Or anything in
between, like water, candle wax or wood for example.
I can't help but to say "Let there be light, and there was light." Modern
WebGL can certainly imitate light very well. What I'm saying is,
adding light to your game is reminiscent of your game engine
experiencing a full rebirth. It's an overhaul that has drastic impact on
the final result. So don't get discouraged by making a sub-par engine
at first. Adding light to it might change your opinion of your own
engine fast. It makes that much of an impact.
Some light effects, such as blur and bloom are called "post-
processing" effects are surprisingly easy to implement using (relative
to the dramatic effect created by them) what's called a WebGL
Framebuffer Object. We'll take a look at this a bit later. Let's slow
down for now and start from the very beginning.
Model Specimen
You may have already seen other objects such as the "teapot" or the
"bunny" which help us test what light rendering looks like on complex
objects. Throughout this text here, we will take a look at how different
types of light affect our generic sphere object.
Other chapters in this book demonstrate how to load pretty much any
object from a PLY file that can be created in Blender. You can
manually load them to experiment with other models you find online
or with your own creations.
Starting Point
In the beginning, I will go over the types of light and toward the end of
this chapter, provide a slightly more technical explanation with source
code. This way we can gradually dive into the theory and supplement
it with actual WebGL examples. Then a complete source code for a
3D sphere model will be provided to demonstrate principles we've
just learned.
Sooner or later you will need to be able to load 3D models into your
WebGL application. Also, we will write a sphere-construction function
that takes a number of bands and other parameters such as its radius
to automatically generate a 3D sphere using WebGL vertex data. We
can then use this object to experiment with light sources and find out
how they work.
What Light Is
The second point however, is not entirely correct, but can be correct
at the same time -- let’s discover the theory of light a bit further to
understand what is meant by this statement. A side note on what is
known about the light by scientists will help…
There are two ways that light could be thought of as. There is a
theory of light particles described by PHOTONS. And there is a
theory of light being a WAVE of energy. In ancient Greece the light
was thought of as a stream of particles which travel in a straight line
and bounce off a wall in a way that any other physical objects do. The
fact that light couldn’t be seen was based on the idea that the light
particles are too small for the eye to see, traveled too fast for the eye
to notice them or that the eye was just seeing through them.
In the late 1600s it was proposed that the light was actually a wave of
energy and didn’t travel exactly in a straight line being a stream of
particles. By 1807 the theory of light waves was confirmed with an
experiment that demonstrated that the light, passed through a narrow
slit radiates additional light outward on the other side of the slit. So it
was proven that the light has to travel in a form of a wave in order to
spread itself that way, and not in a straight line. It is important to note
that a beam of light radiates outward at all times.
The theory of light was developed further by Albert Einstein in 1905.
He described the “photoelectric effect”. This theory described activity
of the ultraviolet light hitting a surface, emitting electrons off that
surface. This behavior was supported by an explanation that light was
made up of a stream of energy packets called PHOTONS.
The light that can be seen by the human eye is in general a mixture
of all kinds of different lights scattered and reflected against the
surroundings with different material properties. All physical matter is
made up of atoms. The mechanics of reflection of photons off
physical matter depends on various things such as the kind of atoms,
the amount of each kind and the arrangement of atoms in the object
that the photons are being reflected off.
Some photons are reflected and some are absorbed. When photons
are absorbed they are usually converted to heat. The defining factors
of the visual quality of a material lie within this matter absorption-and-
reflection concept. The color that the material reflects is observed as
that material's color. Also, the more light the material reflects the
more shiny it will appear to the viewer.
The visible light is contained within the wavelengths ranging from 390
nanometers to 720 nanometers in length. At 390 nanometers the
color is violet. A wavelength of 720 nanometers represents the red
color. Everything in between is considered the visible light and the
range itself is called the spectrum:
So I will only put emphasis on the most important ideas that will help
us understand light and light-programming better as it relates to
programming with OpenGL. This means that we need to think of light
in abstract form (ambient, specular and diffuse) to simplify the
process.
The following terms describe different types of light that you must
know when programming a 3D application which requires a light
source. It is important to understand what effect each of these types
of light create on the surface of rendered 3D objects.
These terms were created because certain effects that light produces
on the objects needed to be described in order to distill the complex
mathematical calculations of light. However, this doesn‘t mean that
these exact types of light actually exist in nature, we just think of them
as an abstraction of the effects that light can produce when cast on
different materials. These effects are more than sufficient for creating
incredibly realistic computer graphics.
Ambient Light
When sun rays pass through the window of a room they hit the walls
and are reflected and scattered into all different directions which
averagely brightens up the whole room. This visual quality is
described by ambient light.
Diffuse Light
A diffuse light of red color is cast onto a black object defining its 3D
shape.
Specular Light
Here, this light is not very shiny, and barely visible, but it is indeed
there. The shininess of the highlight can be dependent on object's
material properties.
We have just learned that from the point of the camera (the viewer of
the scene) specular light creates a highlighted area on the surface of
the viewed object known as specular highlight or specular reflection.
The intensity of the specular reflection is dependent on the material
the object is made of and the strength of the light source which
contains the specular light component.
Emissive light in OpenGL is the kind of light that emits energy, rather
than reflects it.
The specular light component of the light source is white. The center
of the specular reflection is white in the center, however as it spreads
off it merges with the green and red colors, augmenting on yellow
(which is green + red). Again, note that if there were no emissive light
applied to the sphere, it would have appeared like the sphere shown
under the section SPECULAR LIGHT above, all in red with a white
specular reflection.
The way WebGL shades polygons to simulate light and how light
properties are assigned to light sources and materials is explained in
the following part of this tutorial.
We still need to discuss a few points here before moving on. Light is
a complex subject and to avoid making many common mistakes
resulting in "Why didn't this work as expected?" situations we at the
very least need to cover global ambient light, shading models and
surface normals. (Especially surface normals.) I don't think without
these principles it would be any easier to understand how light in
WebGL actually works or easily avoid making mistakes.
GL_FLAT shading selected the computed color of just one vertex and
assigned it to all the pixel fragments generated by rasterizing a single
primitive. This produced models that exposed their rough edges. This
is especially true of low-poly models.
Flat shading doesn't look that great on low poly models. However, if
complexity of the geometry increases on the same model (let's say by
continuous subdivision process) Flat shading starts to look closer and
closer to Smooth shading. Let's take a look:
This is the wireframe of the same donut object. Let's shade both of
them using either flat or smooth shading technique:
Here you will instantly notice that the last two models on each row
look nearly identical in terms of how smoothly their surface is shaded.
So why use smooth shading at all? Can't we just increase the number
of polygons by subdivision and call it a day?
Of course, increasing geometry is a counterproductive tactic. The
more polygons your model contains the more taxing it will be to the
GPU. But smooth shading even on and after level-2 subdivision starts
to look acceptable without having to render hundreds or thousands
more polygons. And that's exactly the problem it is trying to solve.
The advantages are hard to miss here. Practically all of the smooth
shaded donuts in the second row look much better than its flat
shaded counterpart. Note that the object geometry itself has not
changed at all.
A few other things will be needed. For example, we need to pass the
position (in world space) and direction (a vector) of the light source
into the shader.
Usually 3D worlds and game levels that start out using this model are
first shaded completely by a balanced or dark hue of a particular
color. For evening scenes, it could be a dark blue or gray. For daylight
environment it could be a slightly brighter shade, but still on the
dimmer side than 100% color value describing the surface material in
original texture pixels.
Some pipeline techniques dim down the original texture a bit to avoid
calculating Ambient Light in the shader. These textures which are
usually saved in TGA format appear darker than you would expect in
their original format.
After setting up global ambient light the next step is usually setting up
the shading model. I can't think of a good reason for choosing Flat
Shading Model for your rendering. However, it can still be used for
cases where object is inherently defined by its rough edges or 90
degree angles. A crate for example. But generally this is not the most
commonly used model.
In most games smooth shading is the primary shading model for most
objects in the scene. It produces objects that smoothly interpolate
colors across vertices simulating a more realistic shadow effect.
Usually an algorithm such as Gouraud is used but other models exist
(Hello Phong shading) that produce results that while technically
higher-quality, barely distinct from one another, sometimes to the
point of irrelevance.
Here "flat", Gouraud and Phong shading models are shown in order
from left to right. The latter two are both forms of "smooth" shading
algorithms.
You can probably recognize these types of light rendering from video
games that came from various eras from DOS games that used flat
shading to N64 (Mario Kart) and modern hyper-realistic games. The
reason each game era looked the way it did was because each
shading method requires greater processing power.
Normal Vectors
WebGL doesn’t calculate normal vectors for you and this will be
mentioned again below when we get to the Computing Surface
Normal section. When making your own models programmatically by
an algorithm, you are responsible for creating normal vectors
yourself. But if you created your 3D model using modeling software
such as Blender, the normals are already included in either OBJ or
PLY files. PLY object loader is already discussed elsewhere in this
book. And because normal calculations are required to make any
feasible light rendering to work in WebGL, we will use the normal
data from our loaded PLY objects. These objects don't have to be
complex in order for us to experiment with light source shader we'll
take a look at in a bit. Blender can generate basic 3D shapes such as
cubes, spheres, cylinders, etc. with precalculated normals.
The second type of light properties is the one that describes the light
reflected by the material of an object’s surface. Here, the same RGB
triplet goes into defining a material color that goes into determining
the color of a light source. The resulting scene is a combination of
both.
Before I go into the shader code I've yet to explain a few other things.
This background theory will help us understand what shaders actually
do.
Traditional OpenGL allowed a maximum of 8 light sources in a scene
at once. Each of the light sources can be either enabled or disabled.
All of the 8 light sources are initially disabled, and are enabled with a
call to glEnable.
Once you've figured out writing a shader for one object, it can be
adapted for your entire scene. But in computer graphics a scene is
usually composed of objects that contain multiple types of materials.
For example, a boat can have wooden and metal parts. And objects
are usually separated by material type during the rendering process.
Alpha is also used to blend the lighted areas with the texture of a 3D
model. Texture-mapping will be explained in detail in this book. The
color format which describes the RGB values of color as well as the
alpha value is referred to as the RGBA format. We will come across
this value when setting up our texture maps. For now let's
concentrate on how to specify the full range of properties for a light
source.
In nature, objects are lit by the sun which emits light of white intensity.
White, as we know, is a combination of all colors. When this white
light hits the surface of an object, some wavelengths of light are
reflected and some are absorbed. The light that is reflected defines
the color of the object we're viewing.
However, try viewing the same red ball in a room with a blue light
bulb as the only light source and the ball will appear to be black
because there is no red color to reflect.
Defining Surface Material Properties
Polygon Winding
Theoretically a polygon has a back and front face. This requires a bit
more explanation.
Material Properties
The AMBIENT component defines the overall color of the object and
has the most effect when the object is not lit. The DIFFUSE
component has the most importance in defining the object’s color
when a light is emitted onto its surface.
While writing WebGL code for the first time plenty of times you will
run into quirky problems. And lack of normals is just one of them.
Either misconfigured or absent normal vectors are the cause of many
problems you'll run into.
This is not limited only to light calculations but others as well such as
bump mapping.
The parameters are vertex_t v[3]; which defines the 2 vectors that lie
on the polygon's plane and vertex_t normal[3]; which will hold the
resulting normal vector. Keep in mind that if you are using
counterclockwise winding (as this is the normally accepted default
behavior) you must specify the points of v[3] in counterclockwise
direction as well.
Pseudo code
// This is how a vertex is specified in the base code
typedef struct vertex_s
{
float x, y, z;
} vertex_t;
// b
b.x = v[1].x - v[2].x;
b.y = v[1].y - v[2].y;
b.z = v[1].z - v[2].z;
// normalize
normalize(normal);
}
The final step of this function is to normalize the resulting vector and
this is something I haven't talked about yet. Normalization of the
normal vector are not the same thing. A normal vector can be any
arbitrary length after calculation and usually is. Normalizing a vector
is more often referred to as making a unit vector. It means bringing its
length down to a unit length, which is achieved by dividing the vector
by its own length.
The normalized (unit) vector is often used in many ways to achieve
various effects in 3D graphics. For example, when dealing with 3D
cameras, the unit vector is required to accurately calculate a "LookAt"
(or "follow") camera view (the view that always points at a center of
some other object even if it's dynamically floating around the scene.)
To find the length of any vector, you take all of the coordinate
components (x, y and z) of that vector and square them. Add all of the
squared components and find the square root (caution: one of the
most expensive calculations in 3D graphics; if you can precalculate it
using a static lookup table you probably should) of that sum. This
sum will be the length of the vector.
Afterwards, divide each coordinate component of the vector by its
derived length and you will get a vector which points in the same
exact direction but of unit length. And the function normalize does
precisely that:
Pseudo code
// This is how a vector is specified in the base code
// The origin is assumed to be [0,0,0]
typedef struct vector_s
{
float x, y, z;
} vector_t;
// avoid division by 0
if (len == 0.0f)
len = 1.0f;
The lit color of a vertex is the sum of the material emission intensity,
the product of the material ambient reflectance and the lighting model
full-scene ambient intensity - and finally - the average contribution of
each light source in the scene.
Light is incredibly important. But truly, it is not just the light, but a
combination of various techniques that will produce impressive visual
quality for your 3D world creations. The rest of this book is dedicated
to trying to bring and tie it all together.
I'm using a wide canvas here of about 800 x 300 in dimension. Here
width / 2 is 400 and height 300. Both viewports take up a unique
rectangular space on the screen. It's really as simple as this. Calling
drawElements after setting a viewport will render output into that
area.
So far we've used the same texture.vs shader to render our game
world and objects in it. But it has one limitation. The objects are not
separate from one another. We're simply compounding them into the
scene at their default location of x=0, y=0 and z=0. In games, objects
rotate and move separately from one another.
Here, each model is rotated individually. In other words, it has its own
Model matrix. The same model is rendered using a 4 by 5 for-loop.
View camera was moved back to glance at the scene from a
distance.
Up until this point we've created several shaders for basic rendering.
Some of them did not have a camera view matrix. They were created
simply to demonstrate the rendering pipeline at work.
Now we're at a point where we will divide Model and View matrices
into separate entries. For simplicity's sake that is exactly what they
will be called in our shader. They will be represented by uniform
variables of type mat4 just like all other matrices we've worked with
so far.
The shader formula from the texture vertex shader that once was:
texture.vs
uniform mat4 Projection;
uniform mat4 ModelView;
void main()
{
gl_Position = Projection * ModelView * a_Position;
...
}
move.vs
uniform mat4 Projection;
uniform mat4 Model;
uniform mat4 View;
void main()
{
gl_Position = Projection * View * Model * a_Position;
...
}
Note: source code for all shaders explained in this book is available
via my GitHub account:
github.com/gregsidelnikov/WebGLTutorials/tree/master/shaders
Every once in a while you'll find yourself needing to pass some kind
of a dynamic value into the shader from your JavaScript rendering
loop. We've already done this with matrices. But what if you want to
send a floating point array? Then, instead of using uniformMatrix4fv
method we will use uniform3fv as shown below:
And in our light fragment shader we can apply this color to the entire
model:
void main() {
gl_FragColor =
vec4(rgb[0], rgb[1], rgb[2], 1) *
texture2D(image, vec2(texture.s, texture.t));
}
If you are reading this book in color this will look sort of like an Andy
Warhol painting.
We'll start very basic here by creating a vertex and fragment shader
pair for rendering a simple flat shaded scene. Later we will transform
this shader to support smooth-shaded Gouraud-style rendering.
We can pass the position and direction of the light into the shader
from our JavaScript program just like we did with other uniforms (in
particular when we passed the ModelView matrix to the shader.)
But just to simplify and at the same time demonstrate another way of
doing things, this time we will specify the light source data by hard-
coding it into the shader source itself. You can easily make these
values dynamic as well to create the illusion of a moving sun, for
example, but in this particular instance it is not necessary. We simply
want to create a basic scene lit by a single light source.
1. Directional Lighting
2. Point Lighting
Until now we've only rendered few basic textured models that were
loaded from PLY files. But we haven't setup any lighting models yet.
In order to get started we need to define a light source.
There are two basic shading methods that we will explore in this
chapter. That is the directional and point light techniques. They differ
only by two calculations and produce slightly different output.
Point light casts light rays from a location somewhere in the world
coordinates. Directional light uses a single vector to represent the
slope at which light shines on the scene and is often used to imitate
sunlight.
We will take the vertex and fragment shader (move.vs and move.frag)
pair and use it as a starting point to build our light shader on. First,
let's create a basic flat-shaded scene.
One thing to note here is that flat and smooth shading do not require
a different shader. This is because this effect is solely based on the
direction of the normals as part of the object's composition. Blender
allows smoothing the normals (by calculating them as an average at
each vertex) or leaving them in their default direction which is what
creates flat shaded effect. In fact, an object can be composed of a
combination of smooth and flat shaded normals. The point is there
isn't a magical setting you can turn on and off to enable either flat or
smooth shading. It is determined by object's composition itself.
So, the first thing we'll do is pass that information to our shader as an
array. We've passed uniform arrays to shaders before when we sent
Projection, View and Model matrices to the GPU in other examples in
this book. The same applies here. Except this time we're sending light
coordinates.
We can hardcode these values directly into the shader, but I'll follow
one of the examples we've already discussed where we passed
values via an RGB array. Here, we'll do the same except with two
new arrays containing light information: LightPosition and
LightDirection. In the same way LightColor will be passed into the
shader. In this case, we'll use a sharp white with just a hint of yellow
just to see how our new shader affects the lit model.
Model.makeIdentity();
Model.rotate(car_angle, 0, 1, 0);
Model.translate(0, 0, 0);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "rgb"), rgb);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightPosition"), LightPosition);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightDirection"), LightDirection);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightColor"), LightColor);
And here are our new shaders. I called the shader program
directionalProgram. And the GLSL file pair is directional.vs and
directional.frag. Let's take a look inside!
directional.vs
precision mediump float;
attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec2 a_Texture;
attribute vec4 a_Normal;
void main()
{
gl_Position = Projection * View * Model * a_Position;
texture = a_Texture;
}
Note also that in this shader we are finally accepting a new attribute.
The a_Normal in vec4 format will hold the normal vector for each
vertex.
gl_Position. As usual we calculate our vertex position based on matrix
modifications received from our JavaScript animation loop.
The dot product will help us determine the angle between the normal
vector and the LightDirection vector. This makes a lot of sense
because not all normals are directly facing the light source. Some are
slightly turned away (gray illumination). Others completely away
(absence of illumination).
The normals that more or less are facing the light source direction will
be the brightest. And this is the type of a calculation the dot product
operation allows us to do. GLSL shader has a native built-in method
"dot" which we are using here to perform it.
We want to grab the max value between the returned dot product and
0.0. Because negative values will be generated by normals that are
facing away from the light by clamping them to 0.0 we can simply
discard them.
Here we have chosen a default diffuse color for our model. This is the
color that will be used in absence of light (when normals are pointing
away from the light source). You can experiment with your own
values here. But this shade worked well when I was testing it. It
created a nice realistic shading effect very similar to real life lighting
on a rainy day.
directional.frag
precision mediump float;
void main() {
gl_FragColor =
color *
vec4(rgb[0], rgb[1], rgb[2], 1) *
texture2D(image, vec2(texture.s, texture.t));
}
A small detail may have escaped you while reading this chapter. Even
though we passed LightPosition into this shader, it was never actually
used. I intentionally kept it for our next example where we will create
a point light light source.
So what does it actually look like? I loaded the car model into our
existing engine so far and ran it through our new directionalProgram
shader. This is what it looks like:
Here you can see combination of texture, vertex color (yellow hood,
slightly seen from this angle here) and the directional light source
illumination. I positioned the light closer to the back of the car and
moved it a bit upward. The bottom of the car is completely dark, and
diffuse color of 0.5f 0.5f 0.5f is used to shade that area.
Granted, this is not the most impressive 3D model. But I had to create
something just for these examples. I went back into Blender and
added a door and a back hatch. Adding detail helps a bit with realism.
The variation of this model is depicted on following screenshot:
Here I modified the light source and made it more yellowish. The
model is still lacking wheels. But because car wheels are usually
rendered separately they are not yet included at this stage.
In the next section we'll discover how to add point light lights. There is
only one difference between point lights and directional light. That
is… we will be calculating the NdotL value (dot product performed on
Normal and Light Direction) per vertex. Point lights visually produce a
slightly different result and are normally not used for global
illumination. They usually represent car lights, candles and small light
bulbs in a dungeon. Or something.
point.vs
precision mediump float;
attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec2 a_Texture;
attribute vec4 a_Normal;
void main()
{
gl_Position = Projection * View * Model * a_Position;
vec3 calc_LightDirection =
normalize(LightPosition - vec3(vert_Position));
texture = a_Texture;
}
Here we will also first calculate the vertex position (this is different
from gl_Position) and store it in the vert_Position vec3 variable:
This is simply the vertex coordinate based on its Model matrix that
was passed into the shader. If we take this vertex location and
subtract it from the LightPosition vector we will arrive at the vector
describing the angle of the theoretical beam of light hitting the object
at that vertex location.
Note that we also have to normalize this vector for accurate results.
That's all we have to do. Instead of a directional angle (the same for
each vertex) we now have a slightly different value for any vertex on
our model that will be hit by this point light source.
point.frag
precision mediump float;
uniform sampler2D image;
varying vec4 color;
varying vec2 texture;
uniform mat4 Projection;
uniform mat4 Model;
uniform mat4 View;
void main() {
gl_FragColor = color * texture2D(image, vec2(texture.s, texture.t));
}
I created a quick Blender scene just for this demo. I put together a
few basic boxes using the cube tool and arranged and rotated them
at a slight angle throughout the entire scene.
Conclusion to 3D Topics
We've come very far from the very beginning where we initialized
canvas in 3D mode. But we have not by any means exhausted
everything there is about WebGL. So far, with this current knowledge
we can make games that look like PlayStation 2.
It's the same with this book. It's not that there isn't anything left to talk
about. I simply have to set a stopping point. We've covered most of
the fundamental subjects that will help you get started with WebGL
and begin making your own games.
I still have several subjects I want to cover which are currently still in
draft mode. If you're reading this, chances are I am already working
on the second edition of WebGL Gems. As an independent author I
have control over the amount of content and length of the book. But
as I mentioned, I simply had to stop somewhere so I can publish the
first edition.
Amazon publishing allows me to update both the Kindle and
Paperback version of this book. I believe that in about 2 more months
I may be able to release an even better, thicker and generally
upgraded version of this book. When this happens you will be
notified, whether you made your purchase via Amazon, my free
online programming newsletter or one of my websites. Write me a
message to see if you quality for a free upgrade:
[email protected] If you already purchased this book,
chances are you pretty much do.
I want to thank you for reading up to this point and I hope that the
material in this book has proven to be helpful. Until next edition of this
book comes out, keep experimenting, look up shaders written by
others and try making your own. We've covered enough material to
start making basic 3D games and that is way more than I originally
planned for this book.
But our adventure doesn't end here. To finalize the book, I wanted to
cover 2D techniques because 2D games are in high demand these
days. They are also generally easier to make. But how do we turn
WebGL - a library that was exclusively developed for rendering 3D
graphics - into a 2D game engine? This will be covered in the
concluding chapters of this book that follow.
Just like when we needed a Matrix library to deal with camera we will
need a Vector library which will enable us to do cross-vector
operations. A good one I found by doing a quick Google search was
vector.js. It is really the same exact library I would have written for
this book if I had to. It is minimalist, provides only the needed
functionality and uses good naming convention. The vector class is
simply named Vector.
Just like our 2D vector library from earlier in the book, we can also
get length of a 3D vector or derive a unit vector by dividing a given
vector by its length. This library provides all of this function. And in
this chapter we will see how to implement it to aid us in achieving
collision detection.
We will cast a ray from its origin in some direction and test it against
our level geometry. We will limit our collision algorithm to 3-vertex
polygons (or triangles) but it can also be used to test collision
against an infinite plane. Here, this part of the algorithm is depicted
visually:
But it's just one of the two parts of the same algorithm.
Did the ray intersect the plane shared by the triangle in question?
Does the determined collision point fall inside the triangle area?
function Vector(x, y, z) {
this.x = x || 0;
this.y = y || 0;
this.z = z || 0;
}
Vector.prototype = {
negative: function() {
return new Vector(-this.x, -this.y, -this.z);
},
add: function(v) {
if (v instanceof Vector)
return new Vector(this.x + v.x, this.y + v.y, this.z + v.z);
else
return new Vector(this.x + v, this.y + v, this.z + v);
},
subtract: function(v) {
if (v instanceof Vector)
return new Vector(this.x - v.x, this.y - v.y, this.z - v.z);
else
return new Vector(this.x - v, this.y - v, this.z - v);
},
multiply: function(v) {
if (v instanceof Vector)
return new Vector(this.x * v.x, this.y * v.y, this.z * v.z);
else
return new Vector(this.x * v, this.y * v, this.z * v);
},
divide: function(v) {
if (v instanceof Vector)
return new Vector(this.x / v.x, this.y / v.y, this.z / v.z);
else
return new Vector(this.x / v, this.y / v, this.z / v);
},
equals: function(v) {
return this.x == v.x && this.y == v.y && this.z == v.z;
},
dot: function(v) {
return this.x * v.x + this.y * v.y + this.z * v.z;
},
cross: function(v) {
return new Vector(
this.y * v.z - this.z * v.y,
this.z * v.x - this.x * v.z,
this.x * v.y - this.y * v.x
);
},
length: function() {
return Math.sqrt(this.dot(this));
},
unit: function() {
return this.divide(this.length());
},
min: function() {
return Math.min(Math.min(this.x, this.y), this.z);
},
max: function() {
return Math.max(Math.max(this.x, this.y), this.z);
},
toAngles: function() {
return {
theta: Math.atan2(this.z, this.x),
phi: Math.asin(this.y / this.length())
};
},
angleTo: function(a) {
return Math.acos(this.dot(a) / (this.length() * a.length()));
},
toArray: function(n) {
return [this.x, this.y, this.z].slice(0, n || 3);
},
clone: function() {
return new Vector(this.x, this.y, this.z);
},
init: function(x, y, z) {
this.x = x; this.y = y; this.z = z;
return this;
}
};
Vector.negative = function(a, b) {
b.x = -a.x; b.y = -a.y; b.z = -a.z;
return b;
};
Vector.add = function(a, b, c) {
if (b instanceof Vector)
{ c.x = a.x + b.x; c.y = a.y + b.y; c.z = a.z + b.z; }
else { c.x = a.x + b; c.y = a.y + b; c.z = a.z + b; }
return c;
};
Vector.subtract = function(a, b, c) {
if (b instanceof Vector)
{ c.x = a.x - b.x; c.y = a.y - b.y; c.z = a.z - b.z; }
else { c.x = a.x - b; c.y = a.y - b; c.z = a.z - b; }
return c;
};
Vector.multiply = function(a, b, c) {
if (b instanceof Vector)
{ c.x = a.x * b.x; c.y = a.y * b.y; c.z = a.z * b.z; }
else { c.x = a.x * b; c.y = a.y * b; c.z = a.z * b; }
return c;
};
Vector.divide = function(a, b, c) {
if (b instanceof Vector)
{ c.x = a.x / b.x; c.y = a.y / b.y; c.z = a.z / b.z; }
else { c.x = a.x / b; c.y = a.y / b; c.z = a.z / b; }
return c;
};
Vector.cross = function(a, b, c) {
c.x = a.y * b.z - a.z * b.y;
c.y = a.z * b.x - a.x * b.z;
c.z = a.x * b.y - a.y * b.x;
return c;
};
Vector.unit = function(a, b) {
var length = a.length();
b.x = a.x / length;
b.y = a.y / length;
b.z = a.z / length;
return b;
};
Vector.fromAngles = function(theta, phi) {
return new Vector(Math.cos(theta) * Math.cos(phi), Math.sin(phi),
Math.sin(theta) * Math.cos(phi));
};
Vector.randomDirection = function() {
return Vector.fromAngles(Math.random() * Math.PI * 2,
Math.asin(Math.random() * 2 - 1));
};
Vector.min = function(a, b) {
return new Vector(Math.min(a.x, b.x), Math.min(a.y, b.y),
Math.min(a.z, b.z));
};
Vector.max = function(a, b) {
return new Vector(Math.max(a.x, b.x), Math.max(a.y, b.y),
Math.max(a.z, b.z));
};
Vector.lerp = function(a, b, fraction) {
return b.subtract(a).multiply(fraction).add(a);
};
Vector.fromArray = function(a) {
return new Vector(a[0], a[1], a[2]);
};
Vector.angleBetween = function(a, b) {
return a.angleTo(b);
};
From now on this vector library will be included in all of our future
demos in the book that require vector calculations.
If you have a good grasp on DOT and CROSS products, and collision
detection, most of all of the other problems in general game
development deal with shaders and visual quality of your game. But
achieving something truly spectacular will always come from
combining multiple features.
function triangle_intersection(
V1, V2, V3, // Triangle vertices
O, // O = Ray origin
D) { // D = Ray direction
//NOT CULLING
if (det > -EPSILON && det < EPSILON) return 0;
inv_det = 1.0 / det;
t = e2.dot(Q) * inv_det;
// Collision detected!
if (t > EPSILON) {
// No collision
return 0;
}
V1, V2 and V3 are the triangle's vertices. O is origin of the segment.
D is its direction. There is no point in explaining the actual
mathematical derivation of the values here. It'd take way too many
pages that could otherwise be spent on writing about WebGL
functionality, which is what this book is about.
However, it's probably a good idea to go over the comments and see
what each line does. Most of the cross and dot product operations
solve the problem in an abstract way. I found that while it was easy to
look up explanations of each step by just doing an online search,
finding source code that worked accurately was a challenge.
Here the sphere is actually located at x=0, y=0.15 and z=0 which
places it slightly above the staircase. Now all we have to do is "drop"
it and ensure that it stays on top of the surface or can travel up and
down the stairs.
The rest of the available types of values are shown in source code
below. Of course, they simply represent each compositional part of
the model but vertices is the one we're looking for.
When we loaded the model all indices were placed in linear order. For
example indices in the example above points to an array that simply
lists all indices from 0 to length-1 of the array. And while
gl.drawElements function requires the indices array for drawing…
what we need here is to simply parse through geometry of the loaded
model. All we have to do is access its vertices array entry which is
stored in window.ref_arrayMDL[i][0] where i is the loaded model's
index. The staircase model in this demo was loaded first, so the entry
in question will be [0][0].
Each set of 3 vertices on the list from this array defines each triangle
in consequential order. Because our models are always loaded
triangulated, we can be sure that every 3 vertices in the set refer to
next triangle. This lets us easily loop through them.
Let's create a down vector from the sphere object, then loop through
the vertices of our "staircase" model and rebuild each triangle in it as
a set of 3 vertex variables v1, v2 and v3, as shown in the code below.
And another thing. The algorithm assumes that the model is static. In
other words, the ball will only collide with the staircase as long as it
remains in the same exact location it was originally loaded into.
Rotating the model will not rotate its vertices as they appear in the
vertex array at the time they were loaded from the PLY file. To fix this
problem they will have to be passed through the Model matrix
separately. That step is not shown in this demo.
I kept the lights on for this demo. So when the sphere moved from left
to right, its illumination changed a bit. It just made this example look
more interesting.
And that's that! I know all of what we've developed so far sounds
incredibly simple. But I cannot stress this enough. Collision detection
with camera follow algorithm (the LookAt camera) and some basic
lighting shading effects is already enough for making just about any
type of game possible. This is why I wanted to single out and focus
on these important concepts. It only took us roughly 250 pages to
implement them all. But in the end, these features gave us an
opportunity to start making our own games. In the next chapter, I will
put them all together to create a simple kart racing game. It wouldn't
be fair to provide explanations for all of these subjects without putting
the conglomerate together and showing you how powerful they
already are at getting us closer to making real games with WebGL.
Platypus Kart
When our friend was gone, taking his memory with him, we were
back to Doom, Need For Speed 1, Battle Island and few other games.
Ever since that time I've always been extremely interested in making
my own games. As times went on, I thought about what game I
should make. And apparently my old memories suggested I should
make a driving game.
I've been fascinated with Mario Kart 8 ever since it came out. In fact,
the reason I started writing this book was to get better at making my
own hyper-realistic shaders. If you can get that implemented and with
a little creativity, it would be possible to create worlds that look just
like graphics from MK8. They are bright, colorful and have glowy
lights (blur and/or bloom shader). In this section we will not be
concerned with graphics-quality aspects of a racing game. That can
always be added later in the creative stage when you construct your
own levels in Blender or some other software. But we'll do what we
can with what we've covered so far in this book.
But we now need to make the camera "chase" the car as it moves
and accelerates. In order to make this happen we can cast a ray from
the kart's current position in direction it is currently moving in. Velocity
is stored in a new class I created for this demo called Automobile:
function Automobile() {
this.x = 0; // Position in the world
this.y = 0;
this.z = 0;
this.velocity = 0; // Speed
this.angle = 0; // Angle of direction
}
The source code itself is not very complicated at all. Surprisingly so.
But clean code is always a result of focusing on and knowing how to
use specific principles purposefully. Code doesn't have to be
complicated.
Left and right arrow keys will control angle of direction of our car. Up
and down keys will control acceleration. On every frame, the car will
be moved forward along its angle of direction based on current
velocity. We can also add friction that works against velocity by
dampering it.
if (!gl)
return;
// Take controls
if (key.left) Kart.angle_velocity -= 0.005;
if (key.right) Kart.angle_velocity += 0.005;
if (key.up) Kart.velocity += 0.0001;
if (key.down) Kart.velocity -= 0.0001;
gl.clear(gl.COLOR_BUFFER_BIT);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.directionalProgram, "Projection"), false,
Projection.getAsFloat32Array());
View.lookat2(
Kart.x-1*dir_x, Kart.y+0.25, Kart.z-1*dir_z,
Kart.x, Kart.y+0.25, Kart.z,
0,1,0);
BindModel(1);
Model.makeIdentity();
Model.rotate(car_angle, 0, 1, 0);
Model.translate(0, 0, 0);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "rgb"), rgb);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightPosition"), LightPosition);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightDirection"), LightDirection);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightColor"), LightColor);
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.directionalProgram, "Model"), false,
Model.getAsFloat32Array());
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.directionalProgram, "View"), false,
View.getAsFloat32Array());
gl.drawElements(gl.TRIANGLES,
model_indices.length, gl.UNSIGNED_SHORT, 0);
// Draw Kart
BindModel(0);
Model.makeIdentity();
Model.translate(Kart.x, Kart.y, Kart.z);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "rgb"), rgb);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightPosition"), LightPosition);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightDirection"), LightDirection);
gl.uniform3fv(gl.getUniformLocation(
Shader.directionalProgram, "LightColor"), LightColor);
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.directionalProgram, "Model"), false,
Model.getAsFloat32Array());
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.directionalProgram, "View"), false,
View.getAsFloat32Array());
gl.drawElements(gl.TRIANGLES,
model_indices.length, gl.UNSIGNED_SHORT, 0);
window.collision = true;
We can control the "kart" which at this point is just a sphere. The
camera smoothly follows the direction in which it's going, while being
positioned directly behind it. This creates a Mario Kart-like racing
effect.
The only true problem with the current approach is having to collide
the sphere with all of the triangles in the level data. The map contains
simple geometry but it's still pretty large considering that
triangle_intersection function has to traverse each and single one of
the vertices contributing to the level's geometry.
Also note that because our shader already supports vertex color, the
road is nicely illuminated by an imaginary light source. To create this
effect I simply vertex-colored the lit area in Blender while creating the
racing track model. So, this isn't dynamic lighting. But if you think
about it, it doesn't have to be.
Gem 31 - 2D Games
What a journey this has been so far! At the beginning of this book we
started with creating a shader loading library. Then we learned how to
draw triangles on the screen and loaded images that were applied as
texture maps. We then loaded objects from a PLY file (with complete
source included!) and applied some lighting to our scene.
In this chapter we will also learn how to make our own font engine
where each letter is represented by a square in a larger sprite sheet
image. We will then take this concept and create frame-based
animation which can theoretically be used for making characters walk
across some sort of a 2D terrain.
First things first. And one of the techniques used in making 2D games
in 3D is switching on the Orthographic projection. This is a projection
that is used in architecture and engineering software for viewing a 3-
dimensional object without 3D perspective enabled. In some cases
this makes technical designing of an item (like a water pipe, for
example) more convenient. Some 3D modelers use orthographic
projection exclusively when designing their models. It's just a slightly
different way of thinking about and viewing your 3D scene.
Orthographic projections are perfect for implementing 2D-based
games. The Z axis is not completely gone. It's still there. It's just it's
absent from the rasterization part of the algorithm. And no matter how
far or close to the camera view our objects (sprites) will be drawn,
they will appear the same size (their original size) regardless where
they are on the Z axis.
Orthographic Projection
CanvasMatrix4.prototype.ortho =
function(left, right, bottom, top, near, far);
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.spriteProgram, "Projection"), false,
Projection.getAsFloat32Array());
This square will have texture coordinates (u and v) just like any other
object we've dealt with thus far. And that's how an image will be
mapped onto it. In the same way as before we will have to load and
enable some type of a texture for using as the source image for the
sprite being rendered. This texture can be either a single sprite or an
entire sprite sheet containing all animation frames.
In just a bit we will also explore sprite sheets which are basically large
textures containing a grid of sprites. This way we don't have to load
an image for each individual sprite object separately. Sprite sheets
usually consist of 8x8, 16x16, 32x32 (sometimes larger) grids
containing animation frames or a font's ASCII character table where
each letter is represented by a small square inside the image atlas.
Drawing Sprites
I started a new shader just for rendering sprites. This will be the
shader we'll turn on whenever we need to draw images on the
screen. Note that the orthographic projection can be easily combined
together with perspective-based rendering. The sprites will not be
drawn separately. They will be simply rendered on top of what's
already been rendered.
I called this shader pair sprite.vs and sprite.frag. In our shader library
I called it spriteProgram. Every time we need to display some sprites
we will turn this shader on.
sprite.vs
precision mediump float;
attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec2 a_Texture;
void main()
{
gl_Position = Projection * View * Model * a_Position;
color = a_Color;
texture = a_Texture;
}
Here, we're not really doing anything we haven't before. Looks like a
standard shader for rendering a regular 3D surface. We're only
geometrically limited to a square.
sprite.frag
precision mediump float;
void main() {
gl_FragColor =
vec4(rgb[0], rgb[1], rgb[2], 1) *
texture2D(image, vec2(texture.s, texture.t));
}
The rgb value is for tinting the sprite just in case we want to color it a
specific shade. By default white color will be passed to this value.
Scaling the sprite will be accessible via our Model matrix from our
JavaScript program, so it is not necessary implementing it here.
That's pretty much it. Let's try this out and draw 1,000 sprites on the
screen.
var View = new CanvasMatrix4();
View.makeIdentity();
BindModel(0);
gl.uniformMatrix4fv(gl.getUniformLocation(Shader.spriteProgram,
"View"), false, View.getAsFloat32Array());
gl.uniform3fv(gl.getUniformLocation(
Shader.spriteProgram, "rgb"), rgb);
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.spriteProgram, "Model"), false,
Model.getAsFloat32Array());
gl.drawElements(gl.TRIANGLES,
model_indices.length, gl.UNSIGNED_SHORT, 0);
}
}
Note that orthographic sprites are still essentially going to occupy the
same Z-buffer space with the rest of our geometry in the scene (if
any.) For this reason it's a good practice to always draw sprites as the
final step in the rendering pipeline. Turning off Z-buffer sorting
ensures that the sprites we're drawing will be always rendered on top
of existing view even if the sprites are technically "behind" any other
objects already drawn on the screen.
Stretching
To stretch the sprite, let's say, by 2 units, you would do this by using
scale function on the Model matrix as follows:
Scaling a sprite on Z axis makes little sense. For this reason we kept
the last parameter 1.0 which is its default value. It makes no
difference.
Rotating
Chances are, most of the time you will only be doing rotation on the Z
axis. It's the only axis that it makes sense to rotate sprites on. Unless,
you are looking to create some sort of an unusual effect in your
game.
Transparency
void main() {
gl_FragColor =
vec4(rgb[0], rgb[1], rgb[2], 1) *
texture2D(image, vec2(texture.s, texture.t));
if (gl_FragColor == Magenta)
discard;
}
We've loaded and displayed sprites. But it's just like displaying a 3D
object, which is something we've already done earlier in the book.
What are some of the more interesting things you can do with sprite
rendering in WebGL?
We can modify our sprite shader to work with the so called sprite
sheets. I'll demonstrate this principle by loading a set of ASCII
characters stored all in one texture. This will be our sprite sheet
where each little square contains a single letters.
We'll pass the location of the square where each particular letter is
located to the shader in terms of its row and column index on the
sprite atlas. Here is the new font image:
Dropping font.png into our textures folder will automatically generate
window.font.texture in our WebGL JavaScript engine.
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, font.texture);
gl.uniform1i(gl.getUniformLocation(
Shader.spritesheetProgram, 'image'), 0);
Before showing the new font character choosing shader, let's assume
that we will pass some new information to it so we can accurately
pick the character square for any given letter:
// Sheet's size (how many sprites across and/or down? 16 items here)
gl.uniform1f(gl.getUniformLocation(Shader.spritesheetProgram,
"sheet_size"), sheet_size);
This is just one of the letters from the entire set. But at least, we were
able to choose one from the sprite sheet by row and column.
Note that of course fonts are not the only things (and usually not
primary things) that sprite sheet shaders are used for. They are
commonly used for in-game character animation (birds flapping
wings, character walking cycles, etc.) So how does this shader
actually work? Let's take a look...
spritesheet.vs
precision mediump float;
attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec2 a_Texture;
uniform sampler2D image;
void main()
{
gl_Position = Projection * View * Model * a_Position;
color = a_Color;
texture = a_Texture;
}
spritesheet.frag
precision mediump float;
void main() {
gl_FragColor =
vec4(rgb[0], rgb[1], rgb[2], 1) *
texture2D(image, tex);
}
We can then use this parameter to choose the little square we're
looking for by multiplying stride by either column or row and adding
the new value to texture.s multiplied by stride and texture.t multiplied
by stride.
Printing Text
So we now have a way of printing any character from the font map.
But how do we actually write a string of text? Text is usually indexed
by ASCII character value. All we have to do is determine the numeric
ID of a character and perform a linear calculation to convert it to
column vs row coordinates. Knowing the number of sprites in the
sprite sheets across and down doing this becomes pretty trivial.
But there is one other thing we need to worry about first. Our sprite
sheet only choose characters by column and row. But ASCII codes
are linear. So conversion is waiting to happen here.
var c = gl_text.charCodeAt(i);
column = ascii[0];
row = ascii[1];
Here is the complete source code of the for-loop outputting our entire
string to our view:
// Which sprite from the spritesheet to display?
var column = 0.0;
var row = 1.0;
var c = gl_text.charCodeAt(i);
column = ascii[0];
row = ascii[1];
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.spritesheetProgram, "Model"), false,
Model.getAsFloat32Array());
gl.uniform3fv(gl.getUniformLocation(
Shader.spritesheetProgram, "rgb"), rgb);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "column"), column);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "row"), row);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "sheet_size"), sheet_size);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "sprite_size"), sprite_size);
And finally the results of these operations will output our string on the
screen:
Sprite Sheet Animation Engine
Font engines are fun. But we can take them one step further and
create a character animation engine based on the same principle.
Instead of letters, each sprite will represent a single frame of
animation.
Finally, our code which is similar to the text engine from earlier, will
look this way:
Model.makeIdentity();
Model.rotate(180, 0, 0, 1);
Model.rotate(180, 1, 0, 0);
Model.translate(0, 0, 0);
//Model.scale(0.1, 0.1, 0.1);
// Rewind animation
if (flame_index >= 59)
flame_index = 0;
var c = flame_index++;
var ascii = i2xy(flame_index, 16);
column = ascii[0];
row = ascii[1];
gl.uniformMatrix4fv(gl.getUniformLocation(
Shader.spritesheetProgram, "Model"), false,
Model.getAsFloat32Array());
gl.uniform3fv(gl.getUniformLocation(
Shader.spritesheetProgram, "rgb"), rgb);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "column"), column);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "row"), row);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "sheet_size"), sheet_size);
gl.uniform1f(gl.getUniformLocation(
Shader.spritesheetProgram, "sprite_size"), sprite_size);
gl.drawElements(gl.TRIANGLES, model_indices.length,
gl.UNSIGNED_SHORT, 0);
The flame animation is put into rotation until frame 59 at which point
the animation will rewind and start over. This technique can be
applied to pretty much any 2D sprite animation. From this point on an
artist can create sprite animations for walk or run cycles and place
them into each square. You could create animation arrays holding
frame index sequence, and every time the left key is pressed you
could choose "walk left" animation to display at your character
location in the game world. The same could be done for "walk right"
animation and so forth.
Conclusion
I really don't want to spend much time talking about sound. Because
this has nothing to do with WebGL itself. But in the context of game
development I think it is necessary.
var SFX_JEWEL1 = 0;
var SFX_JEWEL2 = 1;
...
var SFX_COINDROP= 20;
// Allocate sound buffer data
window.sfx = new Array(1000);
window.sound = new Array(1000);
if (window.sfx[__buffer_ID] == undefined)
return;
source.connect(this.context.destination);
source.start(0);
if (repeatSound)
source.loop = true;
}
this.available = false;
this.Initialize = function() {
var contextClass = (window.AudioContext ||
window.webkitAudioContext ||
window.mozAudioContext ||
window.oAudioContext ||
window.msAudioContext);
if (contextClass) {
this.available = true;
this.context = new contextClass();
LoadSfx();
} else {
this.available = false;
}
}
this.onError = function() {
console.log("Sound.load('" + filename_url + "')... Failed!"); }
this.load = function(__buffer_ID, filename_url) {
var request = new XMLHttpRequest();
request.open('GET', filename_url, true);
request.responseType = 'arraybuffer';
var that_v2 = this.that;
request.onload = function() {
that_v2.context.decodeAudioData(request.response,
function(theBuffer) {
window.sfx[__buffer_ID] = theBuffer;
console.log("Sound.load('mp3')... Ok!");
}, this.onError);
}
request.send();
}
}
function LoadSfx() {
console.log("LoadSfx()...");
Sound.load(0, website.url +
"/property_stealth/games/gemini/sfx/jewel9.mp3");
Sound.load(1, website.url +
"/property_stealth/games/gemini/sfx/jewel2.mp3");
Sound.load(2, website.url +
"/property_stealth/games/gemini/sfx/swoosh.mp3");
Sound.load(3, website.url +
"/property_stealth/games/gemini/sfx/plip1.mp3");
Sound.load(4, website.url +
"/property_stealth/games/gemini/sfx/plop1.mp3");
Sound.load(5, website.url +
"/property_stealth/games/gemini/sfx/soft_woosh.mp3");
Sound.load(6, website.url +
"/property_stealth/games/gemini/sfx/synth1.mp3");
Sound.load(7, website.url +
"/property_stealth/games/gemini/sfx/loaded.mp3");
Sound.load(8, website.url +
"/property_stealth/games/gemini/sfx/expl1.mp3");
Sound.load(9, website.url +
"/property_stealth/games/gemini/sfx/expl2.mp3");
Sound.load(10, website.url + "/property_stealth/games/gemini/sfx/
crunch.mp3");
Sound.load(11, website.url +
"/property_stealth/games/gemini/sfx/rocks.mp3");
Sound.load(12, website.url +
"/property_stealth/games/gemini/sfx/glass1.mp3");
Sound.load(13, website.url +
"/property_stealth/games/gemini/sfx/glass2.mp3");
Sound.load(14, website.url +
"/property_stealth/games/gemini/sfx/glass3.mp3");
Sound.load(15, website.url +
"/property_stealth/games/gemini/sfx/music.mp3");
Sound.load(16, website.url +
"/property_stealth/games/gemini/sfx/beep1.mp3");
Sound.load(17, website.url +
"/property_stealth/games/gemini/sfx/magick.mp3");
Sound.load(18, website.url +
"/property_stealth/games/gemini/sfx/levelcompleted.mp3");
Sound.load(19, website.url +
"/property_stealth/games/gemini/sfx/dice.mp3");
Sound.load(20, website.url +
"/property_stealth/games/gemini/sfx/coindrop.mp3");
Sound.load(21, website.url +
"/property_stealth/games/gemini/sfx/achievement.mp3");
Sound.load(22, website.url +
"/property_stealth/games/gemini/sfx/coinloot.mp3");
Sound.load(23, website.url +
"/property_stealth/games/gemini/sfx/coinpouch.mp3");
Sound.load(24, website.url +
"/property_stealth/games/gemini/sfx/mellow_wahwah.mp3");
Sound.load(25, website.url +
"/property_stealth/games/gemini/sfx/nevermind.mp3");
}
The long list of mp3 files would probably be replaced with your own
set. Note that each sound file has an index value between 0 and 25
for all 26 sounds loaded.
In order to start using this class it first needs to be initialized as
follows:
Afterwards, call the LoadSfx function before entering your main loop:
LoadSfx();
That's all it takes to initialize sound station and load mp3 files. From
this point on, you can play any sound you want from the loaded set
by calling the following method on the main Sound object:
Sound.play(SFX_COINDROP);
The book has come to an end and it looks like we've reached the
finish line. The purpose of WebGL Gems was to expose the reader to
a wide variety of principles involved not only in WebGL programming
but also game development.
I think the greatest choice that you will ever make will be between 1.
Writing your own code and 2. Using Unity or UE3 engine. My
personal approach is 1. simply because I want to understand how
everything actually works. Curiosity is essential for nurturing the
creative process.
Neither one is really better or worse than the other. But there are
(dis-)advantages to both.
If you want to write your own code, you'll understand everything from
ground up. This knowledge can also be useful when writing engines
in Unity and UE3. Especially when you need to script something but
don't know what a 3D vector is, for example.
But most people start game dev with 2D games. And it's all about
drawing sprites on the screen, rotating them, or drawing transparent
sprites. I think this is what I would recommend at this stage. Learn
how to load images in JavaScript and use <canvas> to draw them
either with native canvas image drawing functions or, of course with
WebGL.
Game development is difficult to learn unless you know what the key
areas of research are. For example, to make a 2D platformer, you
have to understand the basics of creating and drawing a 2D grid-like
map, keyboard controls and some basic 2D "sprite sheet" animation.
It is really all pretty basic.
So now you have drawn your characters and your game world. It's
time to implement collision detection. This is just a bunch of lines
intersecting other lines. Or rectangles and squares intersecting
circles, etc. There are already known algorithms for this. They are all
explained in my tutorial series on YouTube as well as in this book.
You can download the complete source code from the page. Current
documentation is not so great, but there are some interesting bits on
the homepage. Also, if you're going to make your own engine you'll
probably end up with about 90% of the same code as seen in
Mosaic.js or in this book anyways. You can take a look at it just to see
what it entails.
Hmm, one technique I learned that works really well is to break down
the process into smaller parts. For example, figure out how to draw
sprites (scaled, rotated and animated... or all at the same time). Now
that you got this part working, you can start making a simple game.
I also recommend creating a challenge, like "make Tetris game in 1
week". Choose small games first, like pac-man, tic-tac-toe and so on.
Start super small. Make the tiniest game possible with just few
graphical elements.