JavaScript Game Tutorial - Space Invaders Part 7 - 3D Renderer

This is the seventh part in a series about creating a Space Invaders clone in JavaScript. It is highly recommended to start from the beginning as each part builds directly upon the previous.

Space Invaders Tutorial Series
Part 0 Beginner JavaScript Game Tutorial For Professional Use
Part 1 Math Classes and Game Entity Structure
Part 2 User Input
Part 3 Enemy Behavior
Part 4 Collision Detection and Projectiles
Part 5 Sprites and User Interface
Part 6 Optimization
Part 7 3D Renderer
Part 8 Events and Audio

Introduction

In Part 6, we optimized the memory usage to improve performance by limiting the impact of the garbage collector. This was a very behind-the-scenes modification that had no visual impact to the game at all. In this part, we are doing the exact opposite and making a drastic change to the visuals by creating a 3D Renderer.

Thanks to the fundamental modular design set in Part 0, we can easily swap out the 2D renderer for a 3D renderer with only a slight change to any other part of the code.

We will be using Three.js as the 3D graphics library. If you are not already familiar with using Three.js, I have written a getting started tutorial that teaches everything you need to know for what we will be doing today.

As in the getting started tutorial, we need to include the three.js and GLTFLoader.js files in the HTML.

<script src="js/three.js"></script>
<script src="js/loaders/GLTFLoader.js"></script>


Goals

The goals for this part are:

  • Add a 3D renderer object using Three.js.
  • Minor updates to the Game object to use the 3D renderer.


Renderer (2D)

The UI portion of the renderer doesn't change at all. For the sake of brevity in this tutorial, I will just expose the updateUI() function from the existing 2D renderer and call it from the 3D renderer. You could also move the UI code into the 3D renderer directly, which would allow creating the UI in 3D, for instance.

return {
    render: _render,
    updateUI: _updateUI
};


Blender

As in the Three.js Getting Started Tutorial, we will load all the 3D game assets from a GLTF file that you can download here. In the Blender file there is a 3D Mesh for each of the different Game Entities as well as the scenery to display. These act the same way as Sprites do in the 2D renderer.

Additionally, there is a hidden mesh called the GameFieldMesh. This defines the origin of where the Game Entities will be placed in the 3D scene. As Entities are created, their corresponding Meshes will be added as children of the GameFieldMesh.

As of this writing, lights don't export from Blender using GLTF, as the exporter is still under active development. So, for this project, we will be hard coding the lights. It's not ideal, but it won't be long before this is a non-issue.

Blender SceneGraph

The Blender scene I created for this demonstration has every Mesh and Animation named so they can be easily referenced by name in the code. Every mesh is named in the format "<Object>Mesh" and the animations associated with it named in the format "<Object>Mesh.<Animation>". For instance, the Enemy of rank 0 has a Mesh named "Enemy0Mesh" with an idle animation named "Enemy0Mesh.Idle".


Renderer3D

The 3D renderer acts as the glue between our game code and the Three.js scene graph. It translates the state changes from the Game Entities into Mesh updates. For that reason, most of the code is simply loading and keeping track of the 3D assets.

The render loop is very simple. All it has to do is go through the entities and update the position of the corresponding mesh and handle the animation. One of the things we have to deal with is the translation of coordinate systems. In the game logic, (0, 0) is the upper left of the screen. In 3D, (0, 0, 0) is the center of the world, making y=0 the ground plane. We have to handle that discrepancy when we update the Mesh positions.

To make the connection between the Game Entities and the Three.js Meshes, we have to create public addEntity() and removeEntity() functions. These will be called by the game object when new Entities are created or destroyed. These functions maintain the entityMeshMap data structure that will be used the Mesh objects with the updated information every frame.

It is important to add the 3D renderer above the 2D renderer, otherwise it won't be able to get the drawing context.

Renderer3D
Property Type Description
_glRenderer THREE.WebGLRenderer the Three.js renderer for a WebGL context
_scene THREE.Object3D the entire Three.js scene graph
_gltf Object the GLTF import
_animationMixers Array an array of animations that can be reference by name
_playerMesh THREE.Mesh the mesh for the Player entity
_playerExplosionMesh THREE.Mesh the mesh for the PlayerExplosion entity
_enemyMeshes Array the meshes for the different ranks of Enemy entities
_enemyExplosionMeshes Array the meshes for the different ranks of EnemyExplosion entities
_entityMeshMap Object a map of Entity's to the corresponding Mesh
_projectileColors Object the colors for the two types of projectiles
_enemyIdleAnimations Array the names of the idle animations for the different ranks of Enemy entities
Function Return Type Description
_getAnimation( name ) THREE.AnimationMixer returns the AnimationMixer with name. undefined if it doesn't exist
_addEntity( entity ) none create a Mesh for the entity and store an Entity-Mesh pair for later use
_removeEntity( entity ) none removes the Entity-Mesh pair with the given entity
_loadedGLTF( gltf ) none callback for the GLTF loader on successful loading
_init() none initialize the scene and load the GLTF file
_getMeshFromEntity( entity ) THREE.Mesh returns the Mesh from the Entity-Mesh pair with entity
_render( dt ) none render the scene


//
// Renderer3D
//
var renderer3d = (function () {

    var _glRenderer,
        _scene,
        _gltf,
        _animationMixers = [],
        _playerMesh,
        _playerExplosionMesh,
        _enemyMeshes = [],
        _enemyExplosionMeshes = [],
        _entityMeshMap = [];

    var _projectileColors = { "player": "#C4D06A",
                               "enemy": "#60c360" };

    var _enemyIdleAnimations = ["Enemy0Mesh.Idle",
                                "Enemy1Mesh.Idle",
                                "Enemy2Mesh.Idle",
                                "Enemy3Mesh.Idle",
                                "Enemy4Mesh.Idle"];

    //
    // Gets the animation from the loaded GLTF file with name.
    //
    function _getAnimation(name) {
        var anim = _gltf.animations.find(function (value) {
            return value.name === name;
        });

        return anim;
    }

    //
    // Add an Entity-Mesh pair for the entity.
    //
    function _addEntity(entity) {

        var geometry, material, mesh, animation;

        if (entity instanceof Player) {
            mesh = _playerMesh.clone();
        }

        else if (entity instanceof PlayerExplosion) {
            mesh = _playerExplosionMesh.clone();
            animation = _getAnimation('PlayerExplosionMesh.Idle');
        }

        else if (entity instanceof Enemy) {
            mesh = _enemyMeshes[entity.rank].clone();
            animation = _getAnimation(_enemyIdleAnimations[entity.rank]);
        }
        else if (entity instanceof Explosion) {
            mesh = _enemyExplosionMeshes[entity.rank].clone();
        }

        else if (entity instanceof Projectile) {
            geometry = new THREE.BoxBufferGeometry(entity.width, entity.height, 1);
            material = new THREE.MeshStandardMaterial({ color: _projectileColors[entity.type] });
            mesh = new THREE.Mesh(geometry, material);
            mesh.castShadow = true;
        }

        // Default Geometry
        else {
            geometry = new THREE.BoxBufferGeometry(entity.width, entity.height, 1);
            material = new THREE.MeshStandardMaterial({ color: 'red' });
            mesh = new THREE.Mesh(geometry, material);
            mesh.castShadow = true;
        }

        // If there is an animation, create an AnimationMixer for it
        if (animation) {
            var mixer = new THREE.AnimationMixer(mesh);
            mixer.clipAction(animation).setDuration(1).play();

            _animationMixers.push(mixer);
        }

        // Add the mesh to the scene
        _gameFieldMesh.add(mesh);

        // Create an Entity-Mesh pair
        _entityMeshMap.push({ entity: entity, mesh: mesh });
    }

    //
    // Remove the Entity-Mesh pair associated with the entity.
    //
    function _removeEntity(entity) {
        for (var i = _entityMeshMap.length - 1; i >= 0; i--) {
            if (_entityMeshMap[i].entity === entity) {
                _gameFieldMesh.remove(_entityMeshMap[i].mesh);
                _entityMeshMap.slice(i, 1);
                break;
            }
        }
    }

    //
    // Load the GLTF assets and finish creating the scene.
    //
    function _loadedGLTF(gltf) {
        var divWidth = _glRenderer.domElement.offsetWidth;
        var divHeight = _glRenderer.domElement.offsetHeight;

        _gltf = gltf;

        // Add the scene and set everything to have shadows.
        _scene.add(_gltf.scene);
        _gltf.scene.traverse(function (child) {
            if (child instanceof THREE.Mesh) {
                child.castShadow = true;
                child.receiveShadow = true;
            }
        });

        // Load Camera, update its aspect ratio
        _camera = gltf.cameras[0];
        _camera.aspect = divWidth / divHeight;
        _camera.updateProjectionMatrix();

        // GameField Mesh
        _gameFieldMesh = _gltf.scene.getObjectByName('GameFieldMesh');

        // Player Mesh
        _playerMesh = _gltf.scene.getObjectByName('PlayerMesh');
        _playerExplosionMesh = _gltf.scene.getObjectByName('PlayerExplosionMesh');

        // Enemy Meshes
        _enemyMeshes[0] = _gltf.scene.getObjectByName('Enemy0Mesh');
        _enemyMeshes[1] = _gltf.scene.getObjectByName('Enemy1Mesh');
        _enemyMeshes[2] = _gltf.scene.getObjectByName('Enemy2Mesh');
        _enemyMeshes[3] = _gltf.scene.getObjectByName('Enemy3Mesh');
        _enemyMeshes[4] = _gltf.scene.getObjectByName('Enemy4Mesh');

        // Enemy Explosion Meshes
        _enemyExplosionMeshes[0] = _gltf.scene.getObjectByName('EnemyExplosion0Mesh');
        _enemyExplosionMeshes[1] = _gltf.scene.getObjectByName('EnemyExplosion1Mesh');
        _enemyExplosionMeshes[2] = _gltf.scene.getObjectByName('EnemyExplosion2Mesh');
        _enemyExplosionMeshes[3] = _gltf.scene.getObjectByName('EnemyExplosion3Mesh');
        _enemyExplosionMeshes[4] = _gltf.scene.getObjectByName('EnemyExplosion4Mesh');

        // Create the lighting
        var aLight = new THREE.AmbientLight(0x555555);
        _scene.add(aLight);

        var lightTarget = new THREE.Object3D();
        lightTarget.position.set(0, 0, -200);
        _scene.add(lightTarget);

        var light = new THREE.SpotLight(0xffffff, 1, 500, Math.PI / 3, 0.1, 2);
        light.position.set(0, 100, 30);
        light.target = lightTarget;
        light.power = 10;
        light.castShadow = true;
        light.shadow.mapSize.width = 1024;
        light.shadow.mapSize.height = 1024;
        _scene.add(light);
    }

    //
    // Initialize the renderer. Loads all the 3D assets.
    //
    function _init() {
        var canvas = document.getElementById('game-layer');

        // Create a scene
        _scene = new THREE.Scene();
        _scene.background = new THREE.Color(0x000000);
        _scene.fog = new THREE.FogExp2(0x000000, 0.0005);

        // Setup WebGLRenderer
        _glRenderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
        _glRenderer.setPixelRatio(window.devicePixelRatio);
        _glRenderer.shadowMap.enabled = true;
        _glRenderer.shadowMap.type = THREE.PCFSoftShadowMap;

        // Load the GLTF file
        var loader = new THREE.GLTFLoader();
        loader.load('assets/models/invaders3d.glb',
                        _loadedGLTF,   // Success
                        undefined,     // Progress
                        function (e) { // Error
                            console.error(e);
                        } );
    }

    //
    // Return the Mesh associated with the entity. Returns undefined if no mesh is found.
    //
    function _getMeshFromEntity(entity) {
        for (var i = _entityMeshMap.length - 1; i >= 0; i--) {
            if (_entityMeshMap[i].entity === entity) {
                return _entityMeshMap[i].mesh;
            }
        }

        return undefined;
    }

    //
    // Update the scenegraph and render the current frame.
    //
    function _render(dt) {
        var i, entity, entities = game.entities();

        // Update Meshes for Entities
        for (i = entities.length - 1; i >= 0; i--) {
            entity = entities[i];
            entity.time += dt;

            var mesh = _getMeshFromEntity(entity);
            if (mesh) {
                // Account for the change in coordinate system.
                mesh.position.set(entity.position.x,
                                  -entity.position.y + game.gameFieldRect().height,
                                  0);
            }
        }

        // Call to the original renderer to still handle UI updates.
        renderer.updateUI();

        // Update any animations
        for (i = _animationMixers.length - 1; i >= 0; i--) {
            _animationMixers[i].update(dt);
        }

        // Render the 3D scene
        _glRenderer.render(_scene, _camera);
    }

    // Initialize the renderer
    _init();

    return {
        addEntity: _addEntity,
        removeEntity: _removeEntity,
        render: _render
    };
})();


Game

As mentioned above, the Game object has to be updated to address the renderer3d object. Any time an Entity is created or destroyed, renderer3d.addEntity() or renderer3d.removeEntity() is called, this includes when the start() function is called. And, of course, we have to call renderer3d.render() in the update() function.

function _addEntity(entity) {

    // Update the 3D renderer.
    renderer3d.addEntity(entity);

    ...
}

function _removeEntities(entities) {
    if( !entities ) return;

    // Update the 3D renderer.
    for( var i=entities.length-1; i>=0; i--) {
        renderer3d.removeEntity(entities[i]);
    }

    ...
}

// Don't forget to call _removeEntities before clearing _entities in _start().
function _start() {

    _removeEntities(_entities);

    _lastFrameTime = 0;
    _entities = [];

    ...
}

// Lastly, we need to update the render call in _update().
function _update(time) {

    ...

    // Render the frame
    //renderer.render(dt);
    renderer3d.render(dt);

    ...
}


Score
Invaders


How To Play
Move: Arrow Keys / Tap Sides
Shoot: Spacebar / Tap Middle
High Scores
1. 0
2. 0
3. 0
4. 0
5. 0
6. 0
7. 0
8. 0
9. 0
10. 0
 


Conclusion

I hope this exercise has demonstrated how important it is to have a modular design of even a small game engine like this. Without it, a change this large would have been a nightmare. But having set up that solid foundation, it is very easy to change and grow this engine as your needs advance.

Switching from a 2D renderer to a 3D renderer in a 2D game is fairly trivial on a programming level. The complexity is in the asset creation. Creating full 3D gameplay adds a significant amount of complexity to the core gameplay code. Collisions, physics and AI all become more complicated.

If you want to go further into 3D programming, you may want to see my guide on Web 3D Graphics Programming for Beginners, which is basically a list of things you need to learn to get into serious 3D programming.

These books by David H. Eberly are extremely detailed textbooks on every aspect of 3D engines. If you really want to become an expert, I highly recommend you pick these up.

In the next part:

  • Part 8. Add an Event System and Audio.

I have packaged the code for the full tutorial series for anyone interested in downloading it.

Question or Comment?