CS 424: Computer Graphics, Fall 2017
Lab 10: More Three.js
For this lab, you will create a simple game in three.js. I wanted a project that would use a raycaster, a skybox, and environment mapping. The part of the lab that introduces those things will be somewhat tutorial. For the game, the requirement is that the raycaster can be used to shoot at objects.
You will need a copy of the folder /classes/cs424/lab10-files. You will only need to work on lab10.html, but it depends on all the files in the "resources" folder.
This lab is due next Thursday (but I won't look for it until Friday afternoon). I will look for a file named "lab10.html". If you have made any changes to the resources folder, please turn in that entire folder along with your lab10.html.
Starting Point
You will edit the file lab10.html. That file already shows a full-window 3D scene, with a "gun" in the center of a square platform that lies in the xz-plane. There is an orange "ray" coming out of the barrel of the gun. The gun can pivot around on its base. The orientation of the gun is controlled by the position of the mouse. The vertical mouse coordinate controls the x-rotation of the gun, and the horizontal mouse coordinate controls the z-rotation of the gun. The left- and right-arrow keys, or alternatively the 'A' and 'D' keys, can be used to rotate the view around the y-axis; holding down the shift key will speed up the rotation.
It looks as though the whole scene is rotating except for the gun. But in fact, it's the gun that rotates, and the camera is a child of the gun so that it rotates along with the gun. Think of the arrow keys rotating the gun while you sit on a seat attached to it: It looks like the world is rotating around you, even though it's you who are rotating. My original intention was to keep the gun stationary and rotate the scene, but it turns out that skyboxes are not meant to be rotated.
In fact, it's actually the base of the gun that is rotating around the y-axis, and the ray is attached to base of the gun, not to the gun itself. So, the ray does not automatically pivot when the gun pivots. When the mouse moves and the gun pivots, the ray has to be adjusted by hand. The ray is a line with one vertex at (0,0,0). I modify the second vertex to account for the rotation. This is done in the doMouseMove function with the following code:
gun.rotation.set(rotX,0,rotZ); var rcMatrix = new THREE.Matrix4(); // The matrix representing the gun rotation, // so we can apply it to the ray direction. rcMatrix.makeRotationFromEuler(gun.rotation); // Get the rotation, as a matrix. rayVector = new THREE.Vector3(0,1,0); // Untransformed rayVector rayVector.applyMatrix4(rcMatrix); // Apply the rotation matrix ray.geometry.vertices[1].set(rayVector.x*100,rayVector.y*100,rayVector.z*100); ray.geometry.verticesNeedUpdate = true;
It's actually rayVector that I need, for aiming the raycaster. I originally added the ray to the scene just to make sure that I was aiming the raycaster correctly, but it seemed like a good idea to keep it.
CubeTexture and Skybox
You will surround the scene with a "skybox", which is a large cube with texture images to make it look like an environment. (You have to make sure thats it's not too big to be beyond the "far" distance of the camera.)
A skybox can be made using a cubemap texture, the same kind of texture that is used for environment mapping. A cubemap texture is made up of six images that represent the view in the positive-x, negative-x, positive-y, negative-y, positive-z, and negative-z directions. To work well, the images have to be carefully made. The folder resources/skybox contains the six images for an example from the three.js download. A copy of the three.js download is in /classes/cs424/three.js-r87, and you can find more cubmap textures in the subfolder examples/textures/cube. You can also find many downloadable examples at:
http://www.humus.name/index.php?page=Textures
In three.js, a cubemap texture is represented by an object of type THREE.CubeTexture, and there is a loader for loading such textures. I use it in this function, which you can add to the program:
/** * Creates a CubeTexture and starts loading the images. * filenames must be an array containing six strings that * give the names of the files for the positive x, negative x, * positive y, negative y, positive z, and negative z * images in that order. path, if present, is pre-pended * to each of the filenames to give the full path to the * files. No error checking is done here, and no callback * function is implemented. When the images are loaded, the * texture will appear on the objects on which it is used * in the next frame of the animation. */ function makeCubeTexture(filenames, path) { var URLs; if (path) { URLs = []; for (var i = 0; i < 6; i++) URLs.push( path + filenames[i] ); } else { URLs = filenames; } var loader = new THREE.CubeTextureLoader(); var texture = loader.load(URLs); return texture; }
A skybox uses a material that has its own custom shader. Here is code that you can add to createWorld() to make the skybox:
cubeTexture = makeCubeTexture( [ "px.jpg", "nx.jpg", "py.jpg", "ny.jpg", "pz.jpg", "nz.jpg" ], "resources/skybox/" ); var cubeShader = THREE.ShaderLib["cube"]; var cubeMaterial = new THREE.ShaderMaterial({ fragmentShader: cubeShader.fragmentShader, vertexShader: cubeShader.vertexShader, uniforms: cubeShader.uniforms, depthWrite: false, side: THREE.BackSide }); cubeMaterial.uniforms["tCube"].value = cubeTexture; var skybox = new THREE.Mesh( new THREE.BoxGeometry( 500, 500, 500 ), cubeMaterial ); scene.add(skybox);
Most of this was copied directly from a three.js example. You can use a different cube texture if you want. You just have to change the parameters to makeCubeTexture(). Note that the six strings in the filenames array must be in a particular order!
The variable cubeTexture might need to be a global variable so that you can use it to applying environment maps to new objects.
To apply a CubeTexture to a material, it is only necessary to assign it to the material's envMap property. For example,
mirrorMaterial = new THREE.MeshLambertMaterial() ({ envMap: cubeTexture, color: 0xffffff, // irrelevant if reflectivity is 1 reflectivity: 1 // this is the default });
You are required to use an environment map on some of the objects in your game, either objects you are shooting at or other objects that you add to the scene. (For example, you could have some reflective hemispheres on the gun platform.)
Raycaster
An object of type THREE.Raycaster can be used to find all the intersections of a ray with some set of objects. (A ray is half of an infinite line, with a point of origin and a direction) A raycaster is aimed by setting the origin and direction of the ray that uses it. (It is also possible to aim a ray based on the location where the user clicks the mouse; it that case it can be used to determine what object the user is clicking. For an example of this, see the raycaster input demo from Section 5.3.2 in the textbook.)
Add a raycaster as a global variable in the program. It can be created with the statement:
raycaster = new THREE.Raycaster( new THREE.Vector3(0,0,0), new THREE.Vector3(0,1,0) );
The parameters specify the origin and direction of the ray, in world coordinates. The raycaster can be re-aimed at any time by calling raycaster.set(origin,direction), where origin and direction are Vector3s given in world coordinates. The fact that the parameters have to be in world coordinates was a problem for me, since my rayVector is given in the local object coordinate system of the gun base, which is rotating around the y-axis. Fortunately, every Object3D has a method for transforming from local to world coordinates. In this program, I want the raycaster to point from (0,0,0) towards rayVector, and I can get the direction by transforming rayVector to world coordinates:
var transformedRayVec = rayVector.clone(); gunbase.localToWorld(transformedRayVec); raycaster.set(new THREE.Vector3(0,0,0), transformedRayVec);
(If the origin were not (0,0,0), I would have to transform that point as well, and get the direction by subtracting the transformed origin point from the transformed rayVector.)
The raycaster can be re-aimed every time you use it, or it can be re-aimed when the mouse moves and when the view is rotated. You will have to add code to the program to do this.
The file lab10-practice.html already has the raycaster implemented, and it contains an array of white rectangles for the raycaster to shoot at. When the ray hits a rectangle, it turns red, and when the ray moves on from that rectangle, it turns yellow. I wrote this as a test of my raycaster code — but it can be useful for practicing your aim.
To use a raycaster, you just have to give it some objects to test for intersection with the ray. You can give it either an array of Objects3Ds or a single object:
hitlist = raycaster.intersectObject( object ); or hitlist = raycaster.intersectObjects( objectArray );
In each case, you can add a second boolean parameter to tell the raycaster whether it should recursively check children of the specified object; the default is not to do so.
The return value, hitlist, is a possibly-empty array containing information about all the intersections that the raycaster's ray makes with the specified objects. The intersections are sorted in order of distance from the ray's origin. Each item in the array is a JavaScript object containing information about one intersection. Most likely, the only information that you need is hitlist[0].object, which is the Object3D that was hit first.
Most likely, once an object is hit, you want to remove it from the scene. If you are using an array of objects, you also want to remove it from that array. If you have a JavaScript array, objects, here is how to remove the ith element from that array:
objects.splice( i, 1 ); // (the second parameter is a 1)
Game
The remainder of the assignment is to make a game. You know a lot more about games than I do, so you can design the game yourself. The user has to be able to shoot at objects, probably moving objects, and something should happen if the user hits something. I'll mention a few things that you might use...
The game runs in a continual animation, so you never have to call render. The function updateForFrame() is called before each frame is rendered, and there is a "clock" which can tell how many seconds have elapsed since the game started. You can probably program a lot of the action of the game in this function. Event listening has already been set up for the mousedown, keydown, and mousemoved events, and there are functions to handle those events.
You might want to limit the time that the gun can continually fire, and in that case, you will want to make the ray invisible while the gun is not firing. A material in three.js has a boolean-valued property named visible that can be set to false to make the object to which the material is applied invisible. When you change a material, you have to set its needsUpdate property to true for the change to be applied. So, for example, you can turn off the ray with:
ray.material.visible = false; ray.material.needsUpdate = true;
Remember that JavaScript objects are "open" in the sense that you can add new properties to them. For example, if you want some Object3D, obj, to have a "speed", you can simply assign a value to obj.speed. This gives you a convenient way to keep track of the speed without making any additional global variables. (But be careful about using a property name that already exists.) As another example, if you want to keep track of how long an object has existed, you could store the object's creation time in a property.
You might want to display some information to the user (such as a score, or an elapsed time). The easiest way to do that is to use a fixed-position HTML element that sits on top of the canvas. In my program, I added the following element to the <body> of the HTML page:
<h3 style="position:fixed; top:5px; left:5px; padding:8px; margin: 0; z-index:10; background-color:#880000; color:white; border: thick solid black"> Remaining: <span id="remaining">0</span><br> Destroyed: <span id="destroyed">0</span> </h3>
The <span> elements are there so that I would be able to change their content while the program is running, using statements such as:
document.getElementById("destroyed").innerHTML = "" + destroyed;