Create an outstanding personal website with a simple 3D animation

Nowadays in the field of software engineering, it's always a good thing to have a little edge when it comes to getting hired. Personally, I think that building your public image is a good investment and it’s always a good idea to make small projects on the side that can showcase your talent.

So let's create a personal website with a cool 3D animation in it. The foundation of all 3D stuff is WebGL but we won't be touching this because there is an awesome lib out there called ThreeJs that package all the tools we need to create a nice 3D experience.

The experience I want to craft is to have cubes spawning continually from the centre of the screen and flying all around the camera like that good old star wars hyperdrive jump effect:

Let us write down the basics of what we want to do and figure the maths behind it: We want to spawn cubes at a given point then move them along some cone shape targeting a random point at the base of the cone, and delete them once they go past the camera.

So the first thing we need is a way to get a random point on the circumference of a circle. One way of achieving this is to do like getting a random point in a circle but keeping the radius fixed. So in essence it boils down to:

const angle = gen.next() * Math.PI * 2;
return {
    x: Math.cos(angle) * radius,
    y: Math.sin(angle) * radius,
}

Okay so now to write the tests so I can have my definition:

describe("ThreeDEngine utilities tests", () => {

    describe("getRandomPointInCircle", () => {
        it("Should throw if the circle is of radius zero or lower", () => {
            expect(() => getRandomPointInCircle(new PerdictableRandom(0.5))(0, 1))
                .toThrow(new Error("Radius cannot be lower or equal than/to zero"));
            expect(() => getRandomPointInCircle(new PerdictableRandom(0.5))(-1, 1))
                .toThrow(new Error("Radius cannot be lower or equal than/to zero"));
            expect(() => getRandomPointInCircle(new PerdictableRandom(0.5))(1, -1))
                .toThrow(new Error("Radius cannot be lower or equal than/to zero"));
            expect(() => getRandomPointInCircle(new PerdictableRandom(0.5))(1, 0))
                .toThrow(new Error("Radius cannot be lower or equal than/to zero"));
        });

        it("Should give a random number in a circle", () => {
            const res = getRandomPointInCircle(new PerdictableRandom(0.5))(500, 700);
            const distance = Math.sqrt(Math.pow(res.x, 2) + Math.pow(res.y, 2));
            expect(distance >= 500 || distance <= -500).toBeTruthy();
            expect(distance <= 700 || distance >= -700).toBeTruthy();
            expect(res.x).toBe(-600);
        });
    })
});

And now with the code that implements that:

export const getRandomPointInCircle = (gen: RandomPort) => (radiusMin: number, radiusMax: number): Point => {
    if (radiusMin <= 0 || radiusMax <= 0) {
        throw new Error("Radius cannot be lower or equal than/to zero");
    }
    const radius = getRandomArbitrary(gen)(radiusMin, radiusMax);

    const angle = gen.next() * Math.PI * 2;
    return {
        x: Math.cos(angle) * radius,
        y: Math.sin(angle) * radius,
    }
}

export const getRandomArbitrary = (gen: RandomPort) => (min: number, max: number) => {
    return gen.next() * (max - min) + min;
}

Okay so now we have a random point on a circle. Now we need to create a unit vector from our spawn point to the chosen random point on the circle. That will give one cube its direction:

new Vector3(
    randomPointInCircle.x,
    randomPointInCircle.y,
    -spawnPoint.z
).normalize()

Well, that was easy! Note that this snippet assumes a direction along the z-axis. It's a good idea to wrap the creation of this vector in a function to be able to do the same along the three-axis.

Okay now that we know how to make cubes we can create a cube manager to spawn and delete when needed our cubes. First a spec:

describe("CubeSpawner.ts", () => {
    const staticOps: CubeManagerOptions =  {
        spawnPoint: new Vector3(0, 0, -1000),
        outOfBoundsX: (x) => x > 500,
        outOfBoundsY: (y) => y > 500,
        outOfBoundsZ: (z) => z > 500,
        intervalMS: 200,
        howManyPerBatch: 10,
        radiusMin: 300,
        radiusMax: 500,
        speed: 0.5,
        cubeFactory: () => new TestCube(),
        computeDirection: (randomPointInCircle: Point, spawnPoint: Vector3) => new Vector3(
            randomPointInCircle.x,
            randomPointInCircle.y,
            -spawnPoint.z
        ).normalize(),
        cubeNumberLimit: 6000,
    };
    let sut: CubeManager;
    let scene: TestScene;
    let random: PerdictableRandom;

    beforeEach(() => {
        random = new PerdictableRandom(0.4);
        scene = new TestScene();
        sut = new CubeManager(staticOps, scene, random);
    });

    it("Should not spawn cubes if delta was zero", () => {
        sut.update(0);
        expect(scene.getCubes().length).toStrictEqual(0);
    });

    it("Should not spawn cubes if cube limit has been reached - limit 1", () => {
        sut = new CubeManager({
            ...staticOps,
            cubeNumberLimit: 1,
        }, scene, random);
        sut.update(300);
        expect(scene.getCubes().length).toStrictEqual(1);
    });

    it("Should not spawn cubes if cube limit has been reached - limit 11", () => {
        sut = new CubeManager({
            ...staticOps,
            cubeNumberLimit: 11,
        }, scene, random);
        sut.update(500);
        expect(scene.getCubes().length).toStrictEqual(11);
    });

    it("Should spawn and move cubes if delta is positive", () => {
        sut.update(300);
        expect(scene.getCubes().length).toStrictEqual(10);
        scene.getCubes().forEach(c => {
            expect(c.getX()).toBe(-43.106580757242334);
            expect(c.getY()).toBe(31.318764157034103);
            expect(c.getZ()).toBe(-859.7824629117476);
        });
    });

    it("Should destroy cubes if they went beyond the position limit", () => {
        sut = new CubeManager({
            ...staticOps,
            spawnPoint: new Vector3(0, 0, 499),
            computeDirection: (_: Point, __: Vector3) => new Vector3(0, 0, 1),
        }, scene, random);
        sut.update(300);
        expect(scene.getCubes().length).toStrictEqual(0);
    });
});

We test the deletion of cubes out of bounds, that we don’t spawn more cubes than we should and some other edge cases.

And now for the concrete implementation:

export class CubeManager {
    private _cubes: HolyCube[];
    private _lastDelta: number;
    private _randomFn: (radiusMin: number, radiusMax: number) => Point;

    constructor(private _opts: CubeManagerOptions, private _scene: ScenePort, randomPort: RandomPort) {
        this._cubes = [];
        this._lastDelta = 0;
        this._randomFn = getRandomPointInCircle(randomPort)
    }

    update(deltaMs: number) {
        // Spawn new cubes
        this._lastDelta += deltaMs;
        const howManyCycles = Math.floor(this._lastDelta / this._opts.intervalMS);
        if (howManyCycles > 0) {
            this._lastDelta = 0;
            let howMany = this._opts.howManyPerBatch * howManyCycles;
            if (this._opts.cubeNumberLimit < this._cubes.length + howMany) {
                howMany = this._opts.cubeNumberLimit - this._cubes.length;
            }
            const cubesToAdd: HolyCube[] = [];
            for (let i = 0; i < howMany; i++) {
                const toAdd = this._opts.cubeFactory();
                toAdd.setPosition(this._opts.spawnPoint);
                toAdd.setDirection(this._opts.computeDirection(
                    this._randomFn(this._opts.radiusMin, this._opts.radiusMax),
                    this._opts.spawnPoint)
                    .normalize()
                );
                toAdd.setSpeed(this._opts.speed);
                cubesToAdd.push(toAdd);
                this._cubes.push(toAdd);
            }
            // batch add is way more efficient.
            if (cubesToAdd.length > 0) {
                this._scene.add(cubesToAdd);
            }
        }
        // Update all cubes
        for (let i = 0; i < this._cubes.length; i++) {
            this._cubes[i].update(deltaMs)
        }
        const toDelete: HolyCube[] = [];
        // Delete all cubes beyond limits
        this._cubes = this._cubes.filter(cube => {
            if (this._opts.outOfBoundsX(cube.getX())
                || this._opts.outOfBoundsY(cube.getY())
                || this._opts.outOfBoundsZ(cube.getZ())) {
                toDelete.push(cube);
                return false;
            }
            return true;
        });
        // batch delete is way more efficient.
        if (toDelete.length > 0) {
            this._scene.remove(toDelete)
        }
    }
}

You’ll notice there the old school for-loop. They are just faster that’s it. After it really boils down to personal preference. And we are done with the logic. Now to add ThreeJS sauce to it we can implement the HolyCube interface with some ThreeJS meshes:

export class ThreeJsCube implements HolyCube {
    private _direction: THREE.Vector3 | null = null;
    private _speed: number | null = null;

    constructor(private _mesh: THREE.Mesh) {
        this._mesh.rotation.x = Math.random() * Math.PI;
        this._mesh.rotation.y = Math.random() * Math.PI;
        this._mesh.rotation.z = Math.random() * Math.PI;
    }

    update(delta: number): void {
        if (!this._direction || !this._speed) {
            throw new Error("Direction and speed must be initialized.");
        }
        this._mesh.position.x += this._direction.x * this._speed * delta;
        this._mesh.position.y += this._direction.y * this._speed * delta;
        this._mesh.position.z += this._direction.z * this._speed * delta;
    }
}
view raw

And feed this to the ThreeJsScene adapter and we should see a nice result:

You’ll find all the code used here: https://gitlab.noukakis.ch/root/personnal-website. I’m always open to comments and critiques so if you think something can be improved let me know :)

22