Cacomania: A classic 3D tunnel effect in TypeScript

Cacomania

A classic 3D tunnel effect in TypeScript

Guido Krömer - 7. September 2013 - Tags: , , , ,

Several months ago I wrote a short article about TypeScript. During a boring Sunday afternoon I wanted to play a little bit with TypeScript again, so I ported a classic 3D tunnel effect, shown at the screen shot below, to TypeScript. This effect is archived by creating two look up tables. The tables store the pre calculated data for each pixel of the screen/canvas. The first table contains the angle of the current pixel relative to the screen center, the second one the distance to the screen center. The look up tables use typed arrays for fast access and efficient storing. Since TypeScript must be compiled to Java Script I make use of Grunt for building and minifying the code. Hence, the tutorial is a three in one covering TypeScript, typed arrays and Grunt.

Classic 3D Tunnel effect in TypeScript.

If you want to read more about those classic tunnel effect there are two excellent tutorials by Ben Ryves and Lode Vandevenne.

A TypeScript crash course

If you have never been in touch with TypeScript you should take a look at GameManager class below. Some unnecessary methods has been removed for better understanding the basic syntax. The type of a field, variable, parameter or return value can be defined, the language has some primitive types like string, number or boolean others are classes. If a strict typed one is not necessary any can be used instead of declaring a variable or function without a type. An array of type X can be defined by appending the array brackets to the type. The class constructor is simply called constructor and works as expected.

Some language features of TypeScript like Generics or Modules are not covered by this small tutorial.

class GameManager {
    private lastTimeStamp: number;
    private delta: number;
    private ctx: CanvasRenderingContext2D;
    private gameObjects: GameObject[];
    private initialized: boolean = false;
    public screen: GameScreen;

    constructor() {
        this.lastTimeStamp = new Date().getTime();
        this.delta = 0;
        this.gameObjects = [];
    }

    public add(gameObject: GameObject) {
        this.initialized && gameObject.init(this);
        this.gameObjects.push(gameObject);
    }

    …
}

Some negative aspects of TypeScript

Like each language this one has some negative aspects, too. It does only support two access modifiers public and private, therefore the gameManager property in the GameObject class can not be declared as protected. Another point to mention is the lack of abstract classes and methods. The GameObject class below could have been implemented as interface, but this is a really simple class without much functionality. Turning a more complex abstract class into an interface could lead to code duplication.

class GameObject {
    public gameManager: GameManager;
 
    public init(gameManager: GameManager) {
        this.gameManager = gameManager;
    }
 
    public update(delta: number) { }
 
    public draw(ctx: CanvasRenderingContext2D) { }
}

The class structure

Writing readable OOP code for the web is really easy with the use of TypeScript, I took some drawing and timing management code from my "Speeding up canvas drawing by scaling it with CSS3" article and ported it to TypeScript. This game management class is the base for drawing and animating the tunnel effect. A GameManager class holds game objects inheriting from the GameObject class and calls their draw() and update() methods at the right time.

UML Class diagramm displaying the typescript gamemanager.
The diagram above show the GameManager and the abstract GameObject class.

The UML class diagram below shows the whole class structure of the tunnel effect app. In addition to some less important classes like the FPSDisplay, which displays the frames per second in the upper left corner of the screen, there are two really important classes LookUpTable and Tunnel. The LookUpTable calculates the look up tables needed for drawing the tunnel and saves them into typed arrays. The angle and magnitude calculations, needed for building the look up tables, are performed with the use of the Vector2 class. The class Tunnel uses the look up table's computed by LookUpTable for drawing the tunnel effect.
UML Class diagramm of the TypeScript classic 3D tunnel effect.

Building the lookup table

The class, listed at the end of this paragraph, creates the lookup tables needed for archiving the tunnel effect. Three look up tables are needed, two for the tunnel effect and a third one which stores the depth for increasing the depth effect by darkening the tunnel to its center. Each table has its own ArrayBuffer which has the same size. The size is equal to the screen size, if the screen size is 640x480 pixel large, each buffer would consume 307200 bytes. A ArrayBuffer cannot be manipulated directly, a DataView is needed for reading and writing data to a buffer. This why a DataView is needed for each ArrayBuffer. The values stored into the look up tables are bytes which can be accessed with setUint8(index, value) and getUint8(index). Other primitives like a Float or Int32 could be although accessed, but those data types are not needed here.

Since the values are bytes the angle has to be converted from radians to a byte representation with the formula 256/(Math.PI * 2), off curse this one could be simplified, but it is easier to read than 128/π. All values, the magnitude and the angle are relative to the screen's center, therefore the center has to be computed first. The maxMagnitude variable holds a value for converting the depth value, needed for depth effect by darkening the tunnel, to a 8 bit value. The magnitude used here is the distance from the screen's center to the upper left corner.

After those precalculations, the buffers get filled by looping over each pixel of the screen. The calculated index can be used for each buffer. The large number (18.000) gets divided by the magnitude of the current pixel for determining a value for the distance look up table. Through this division a repeating effect of the texture in the depth gets archived, without this the tunnel would look rather like a wormhole.

class LookUpTable extends GameObject {
    private bufferAngle: ArrayBuffer = null;
    private bufferDistance: ArrayBuffer = null;
    private bufferDepth: ArrayBuffer = null;
    public dataViewAngle: DataView = null;
    public dataViewDistance: DataView = null;
    public dataViewDepth: DataView = null;
 
    public init(gameManager: GameManager) {
        super.init(gameManager);

        var width: number = gameManager.screen.width;
        var height: number = gameManager.screen.height;
        var radToByte: number = 256/(Math.PI * 2);
        var center: Vector2 = new Vector2(width / 2, height / 2);
        var maxMagnitude: number = 256/(center.sub(new Vector2())).magnitude();

        this.bufferAngle = new ArrayBuffer(width * height);
        this.dataViewAngle = new DataView(this.bufferAngle);
        this.bufferDistance = new ArrayBuffer(width * height);
        this.dataViewDistance = new DataView(this.bufferDistance);
        this.bufferDepth = new ArrayBuffer(width * height);
        this.dataViewDepth = new DataView(this.bufferDepth);

        for (var x: number = 0; x < width; x++) {
            for (var y: number = 0; y < height; y++) {
                var vector: Vector2 = center.sub(new Vector2 (x, y));
                var index: number = x + y * width;
                var mag: number = vector.magnitude();

                this.dataViewAngle.setUint8(index, vector.angle()  * radToByte);
                this.dataViewDistance.setUint8(index, 18000 / mag);
                this.dataViewDepth.setUint8(index, mag * maxMagnitude);
            }
        }
    }
}

The look up tables can be visualised, you might notice that without the division of a large number by the magnitude the bufferDistance lut would look like the bufferDepth lut.

3D Tunnel look up table angle view. The bufferAngle lut.

3D Tunnel look up table distance view. The bufferDistance lut.

3D Tunnel look up table depth only. The bufferDepth lut.

3D Tunnel look up table complete view. A combined view of the three look up tables.

Drawing the tunnel

The class Tunnel performs the drawing operation, it uses the look up tables provided by the LookUpTable class for manipulating a ImageData which gets drawn to the canvas context, which is passed as parameter to the draw() method. The trick is to determine which pixel of the given texture has to be drawn at the current pixel. This is archived by looping over each pixel of the screen and taking two values from the look up tables as texture coordinates, the color of the particular pixel gets retrieved by calling getColorAt(x, y) and will be stored at the current screen position with the use of setPixel().

Both methods setPixel() and getColorAt(x, y) are required because a canvas context does not provide pixel manipulation. The only way to do such low-level drawing is to perform the operations on a ImageData object and writes the whole object back to the canvas context with the putImageData() method. getColorAt(x, y) reads the color at the given coordinate from the texture's ImageData object and returns it as an array. setPixel(x, y, r, g, b, a) sets the color at the given coordinate of the canvas rendering context's ImageData object.

The angle is used as height component and the distance as width component. The animation effect is obtained by adding the elapsed time to both components. To ensure that the values are still in the bounds of the given texture a modulo with the size of the texture is used.

class Tunnel extends GameObject {
    …

    public update(delta: number) { 
        this.doDraw = delta != 0;
        this.elapsed += delta * 10;
    }

    public draw(ctx: CanvasRenderingContext2D) { 
        if (!this.doDraw) {
            return;
        }

        if (!this.imageData) {
            this.imageData = ctx.getImageData(0, 0, this.gameManager.screen.width, this.gameManager.screen.height);
        }

        var lutAngle: DataView = this.lookUpTable.dataViewAngle;
        var lutDistance: DataView = this.lookUpTable.dataViewDistance;
        var lutDepth: DataView = this.lookUpTable.dataViewDepth;
        var elapsed: number = Math.ceil(this.elapsed);

        var width: number = this.gameManager.screen.width;
        for (var y: number = this.gameManager.screen.height - 1; y >= 0; y--) {
            var yIndex: number = y * width;
            for (var x: number = width - 1; x >= 0; x--) {
                var index: number = x + yIndex;

                var tx: number = (lutDistance.getUint8(index)) << 1;
                var ty: number = (lutAngle.getUint8(index)) << 1;
                tx = (tx + elapsed) % this.texture.height;
                ty = (ty + elapsed) % this.texture.width;

                var color: number[] = this.getColorAt(tx, ty);

                this.setPixel(x, y, color[0], color[1], color[2], lutDepth.getUint8(index));
            }
        }

        ctx.putImageData(this.imageData, 0, 0);
    }

    private setPixel(x: number, y: number, r: number, g: number, b: number, a: number) {
        var index: number = (x + y * this.imageData.width) << 2;
        this.imageData.data[index] = r;
        this.imageData.data[index+1] = g;
        this.imageData.data[index+2] = b;
        this.imageData.data[index+3] = a;
    }

    private getColorAt(x: number, y: number): number[] {
        var index: number = (x + y * this.texture.width) << 2;
        return [
            this.texture.data[index],
            this.texture.data[index+1],
            this.texture.data[index+2]
        ];
    }

    …
}

Building with Grunt

TypeScript has to be compiled to JavaScript first, once the compiler is installed a simple tsc GameManager.ts GameScreen.ts GameObject.ts… would compile the project. After compilation, all single JavaScript files has to be merged and could be minified, too. Instead of writing a bash script or something Grunt can be used for running those repetitive tasks.

There are various Grunt modules for common tasks like coping, renaming, minifying, compiling… . The main tasks needed by this project are compiling the TypeScript code, minifying the generate JavaScript code and the style sheet.

Installing Grunt

nodejs has to be installed first, after this Grunt can be installed with root privileges. This node.js module needs to be installed as root because the grunt command has to be in the system path. The module is called grunt-cli and can be installed with the npm package manager: npm install -g grunt-cli.

Since Grunt is installed, the modules needed for building the project has to be installed:

npm install grunt-contrib-clean --save-dev
npm install grunt-typescript --save-dev
npm install grunt-contrib-uglify --save-dev
npm install grunt-rename --save-dev
npm install grunt-contrib-cssmin --save-dev
npm install grunt-contrib-copy --save-dev
npm install grunt-contrib-watch --save-dev

package.json

For running Grunt, a minimal package.json file in the project root directory is needed. This file contains some meta data about the project and the dependencies of the project. Here is a nice interactive description of a more complete package.json file if you want to read more about this topic.

{
  "name": "3D-Tunnel-Demo",
  "version": "1.0.0",
  "author": "Guido Krömer ",
  "devDependencies": {
    "grunt": "~0.4.1",
    "grunt-typescript": "~0.2.3",
    "grunt-contrib-uglify": "~0.2.2",
    "grunt-contrib-clean": "~0.5.0",
    "grunt-rename": "~0.1.2",
    "grunt-contrib-cssmin": "~0.6.1",
    "grunt-contrib-copy": "~0.4.1",
    "grunt-contrib-watch": "~0.5.1"
  }
}

Gruntfile.js

The last puzzle part is the Gruntfile.js which gets executed by Grunt. A Grunt file contains out of three parts, loading the modules, registering the tasks and configuring the tasks. Initially the required modules has to be loaded using grunt.loadNpmTasks(), after that with grunt.registerTask() at least one task has to be defined, the first parameter is the name of the task and the second one is an ordered array with the names of the modules which are associated with the current task. The default task gets executed if no task has been provided as parameter when calling Grunt. The last part is performed by grunt.initConfig(), each module has to be configured here. A module can be used for several sub tasks, the uglify task has two sub tasks, one can be used by the debug tasks and the other one is used by the default task. The uglify debug task generates a beautified code with white space the production task removes unnecessary white spaces. . A sub tasks can be specified during registration of a task by appending the name of the sub task to the module like 'uglify:prod'.

The projects Gruntfile.js has two tasks, one for generating a production code and another task for creating a debug friendly code without minification. Each task starts with the compilation of the TypeScript source files, but the debug task skips the minification of the JavaScript code and the style sheet and renames or copies the uncompressed files.

module.exports = function (grunt) {
  grunt.loadNpmTasks('grunt-typescript');
  grunt.loadNpmTasks('grunt-rename');
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-contrib-cssmin');
  grunt.loadNpmTasks('grunt-contrib-watch');
  grunt.loadNpmTasks('grunt-contrib-copy');

  grunt.registerTask('debug', ['typescript', 'rename:debug', 'clean', 'copy:debug']);
  grunt.registerTask('default', ['typescript', 'uglify:prod', 'cssmin', 'clean']);

  grunt.initConfig({
    typescript: {
      base: {
        src: ['lib/GameManager.ts', 
              'lib/GameScreen.ts', 
              'lib/GameObject.ts', 
              'lib/FPSDisplay.ts', 
              'lib/LookUpTable.ts', 
              'lib/LookUpTableRender.ts', 
              'lib/Vector.ts', 
              'lib/Tunnel.ts', 
              'lib/ImageDataLoader.ts'],
        dest: 'temp/compiled.js',
        target: 'ES3',
        options: {
            module: 'commonjs',
        }
      }
    },
    uglify: {
      prod: {
        options: {
          'beautify': false,
          'no-mangle-functions': false,
          'report': 'min'
        },
        files: {
          'js/compiled.min.js': ['temp/compiled.js']
        }
      },
      debug: {
        options: {
          'beautify': true,
          'no-mangle-functions': true
        },
        files: {
          'js/compiled.min.js': ['temp/compiled.js']
        }
      }
    },
    clean: ['temp/'],
    rename: {
      debug: {
        src: 'temp/compiled.js',
        dest: 'js/compiled.min.js'
      },
    },
    copy: {
      debug: {
        src: 'css/style.css',
        dest: 'css/style.min.css',
      },
    },
    watch: {
      scripts: {
        files: ['lib/*.ts', 'style/*.css'],
        tasks: ['default'],
      },
    },
    cssmin: {
      minify: {
        expand: true,
        cwd: '',
        src: ['css/*.css', 'css/!*.min.css'],
        dest: '',
        ext: '.min.css'
      }
    }
  });
};

Excecuting Grunt

Running the default task only needs a simple call of grunt in the terminal, if a task like debug should be executed the name of the task has to be provided as first parameter like grunt debug.

Grunt watch

During development executing Grunt after each change can be really annoying, with the grunt-contrib-watch module a defined tasks gets executed automatically when a file change on one of the specified types was detected. This saves a lot of time and let you forget about writing TypeScript code since the compilation happens auto-magically without switching to the console and executing Grunt.

For running Grunt in watch mode just enter grunt watch in the console.

Conclusion

The code as available at GitHub and a full screen demo is available, too.

Please tell me if you liked or disliked this tiny tutorial by leaving a comment.