Creating a 3D Tetris Game for Dummies Like Me — II

Oleksii Koshkin
7 min readJun 25, 2023

--

Image provided by author

TL;DR: live demo or the advanced game + some shameless advertising wrapper.

Level: for beginners.
See detailed links and table of contents at the bottom of the article.

Nice. Let’s continue. Today I will try to display something in 3D.

First, I need to install ThreeJS. It is simple, just two commands, for ThreeJS and for typings:

npm i -S three
npm i -D @types/three

Well, now it’s time to use it. But first — a bit of theory.

From time to time I play with 3D modelling, mostly Blender, because it’s free and exists everywhere — Mac, Linux, Windows — and was therefore aware of the basic concepts. But I want to remind you of this in order to look like a guru.

Well, a 3D scene usually has–

  • scene, equipped with coordinates,
  • object(s),
  • camera,
  • lights.

Scene itself is a container. You put building blocks in there, set up the lights, place a camera somewhere (feel like James Cameron?), then run the magic and get the image. The name of the magic is rendering, so you need a special rendering software module that will do all the heavy calculations, and they are really heavy, you can prove it using the price of an NVidia graphics card.

This is how it looks like:

Of course, there are some details, I will return to them later. Anyway, you got the idea: you need a scene to put everything there.

I need to create a component that I’ll call scene: src/components/Scene.svelte.

Disclaimer: it will not contain a real game scene yet, but I will use it to start. For now, the component will be very simple:

<script lang="ts">
import { onMount } from "svelte";
import * as THREE from "three";
import type { TThreeFrame } from "./types";

let Frame: TThreeFrame;
let canvas;

onMount(() => {
initScene();
});

function initScene() {
Frame = {
scene: new THREE.Scene(),
renderer: new THREE.WebGLRenderer({ alpha: false, antialias: true }),
camera: new THREE.PerspectiveCamera(
75,
canvas.offsetWidth / canvas.offsetHeight,
0.1,
500
),
};

Frame.camera.zoom = 5;
Frame.camera.position.set(0, 0, 10);
Frame.camera.updateProjectionMatrix();

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(0, 14, 30);
light.castShadow = true;

const pointLight1 = new THREE.PointLight(0x888888ff, 0.8, 10);
pointLight1.position.set(-4, -4, -4);
pointLight1.castShadow = true;

const pointLight2 = new THREE.PointLight(0xff0088, 0.8, 20);
pointLight2.position.set(4, -3, -6);
pointLight2.castShadow = true;

Frame.scene.add(light);
Frame.scene.add(pointLight1);
Frame.scene.add(pointLight2);

Frame.renderer.shadowMap.enabled = true;
Frame.renderer.shadowMap.type = THREE.PCFSoftShadowMap;

Frame.renderer.setSize(canvas.offsetWidth, canvas.offsetHeight);

canvas.appendChild(Frame.renderer.domElement);

Frame.renderer.render(Frame.scene, Frame.camera);
}
</script>

<div id="scene" bind:this={canvas} />

<style>
#scene {
position: absolute;
top: 40px;
left: 40px;
right: 40px;
bottom: 40px;
}
</style>

and in App.svelte add the usage:

<script lang="ts">
import Scene from "./components/Scene.svelte";

</script>

<main>
<Scene/>
</main>

<style>
/* nothing yet here */
</style>

Ah, yes. Also add typings file –

// file: ./types.ts
import * as THREE from "three";

export type TThreeFrame = {
renderer: THREE.WebGLRenderer
scene: THREE.Scene
camera: THREE.OrthographicCamera | THREE.PerspectiveCamera
}

Because I will use the same structures all the time. Scene, camera, renderer.

Let’s see what’s going on:

The scene is active, but a bit dark. Let’s treat it like suspense. Tetris can be a horror…

Black square, well, almost. But I can’t afford plagiarism, so I’ll add something in there. Technically I did that already:

    Frame.scene.add(light);
Frame.scene.add(pointLight1);
Frame.scene.add(pointLight2);

Three light sources, one directional and two point. Let there be light!.. Sh-, plagiarism again. Well, the whole game will be a plagiarism, or rather a tribute, so don’t worry.

By the way, why here I see a black frame? Well, because of alpha

renderer: new THREE.WebGLRenderer({ 
alpha: false, // <-- this one
antialias: true
}),

Later I will remove it, and then the background will become transparent.

I will comment key points now.

camera: new THREE.PerspectiveCamera( // "standard" type of camera
75, // FoV, camera frustum vertical field of view
canvas.offsetWidth / canvas.offsetHeight, // aspect ratio - how "square" field of view (FoV) is
0.1, // near plane
500 // far plane
),

Frame.camera.zoom = 5;
Frame.camera.position.set(0, 0, 10); // put the camera on the z-axis at a distance of 10
Frame.camera.updateProjectionMatrix(); // recalculate camera params

The values here are almost default, and you can also use the default values, the differences will be minor.

The only really important thing here is canvas. This is a DOM container, a reference to the HTML canvas that ThreeJS will draw on. Remember, in markup section of component I wrote–

<div id="scene" bind:this={canvas} />

and on top of script section–

let canvas;

That’s it. This is the way (bind:this={canvas}) how Svelte stores refs to DOM elements.

Why do I need that canvas? To get the dimensions of my 3D scene container:

camera: new THREE.PerspectiveCamera(
...
canvas.offsetWidth / canvas.offsetHeight // <-- canvas
...
),
...
Frame.renderer.setSize(canvas.offsetWidth, canvas.offsetHeight); // <-- canvas

So I initialise the scene in onMount :

onMount(() => {
initScene();
});

Why not just in the component? Because Svelte assigns DOM reference only after component created, and before onMount canvas will be undefined.

What can I improve here? Resizing. I set the scene size and camera settings based on the initial size of the container, and when the user resizes the browser window, things get, erm, less than perfect.

Time to add an object! The simplest one, the cube. Return to my initScene function and add right after adding the lights:

const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1), // box
new THREE.MeshStandardMaterial({ color: 0xaaaaaa }) // simple gray material
);
cube.castShadow = true;
cube.receiveShadow = true;

Frame.scene.add(cube);

and check:

The scene with the square… uh… cube! Just a point of view.

Wow, it works!

To make my cube looks like a cube instead of square I will rotate it a bit (or also I could move a camera, but let’s keep things simple):

...
cube.receiveShadow = true;

cube.rotation.x = 1;
cube.rotation.y = -2;
cube.rotation.z = 3;
Now it looks like a cube, right? And pay attention to the lighting. This is why I added two colored lights.

Great success! That’s one small step for man, one giant leap for gamedev!

Formally, this is the end of the stage, but I will add two bonuses.

First one: axes helper. Just to see the axes, they are a bit surprising:

Axes. Pay attention: Z is directed to you, Y — to up, X — to right.

Only the x-axis has a “normal” DOM direction, the other two are a bit counterintuitive.

Frame.scene.add(cube);

const axesHelper = new THREE.AxesHelper(1);
Frame.scene.add(axesHelper);

The second — animation. I will add a very simple and naive animation implementation based on requestAnimationFrame .

First make cube ref available. Instead of const cube =... in initScene() declare it on top:

let canvas;
let cube; // <-- add a declaration here
...
// in initScene:
cube = new THREE.Mesh(... // <-- remove "const"

Second, add an animation function:

function animate() {
cube.rotation.x += 0.02;
cube.rotation.y += 0.02;
cube.rotation.z -= 0.05;
Frame.renderer?.render(Frame.scene, Frame.camera);
requestAnimationFrame(animate);
}

The last one: call that in onMount :

onMount(() => {
initScene();
animate(); // <-- add this
});

That’s it. We have endless loop and rotating cube.

Animation here is pretty dirty.

First, it depends on browser’s frame budget. If you open the project in Safari and detach you Mac from power supply, it will slowdown (depends on settings and battery/power plan, of course). Browser try to keep battery, and it will reduce the frame rate and performance. I will ignore that for now.

Second, I need to cleanup scene on destroy:

import { onMount, onDestroy } from "svelte"; // <- add "onDestroy"
...
let cube;
let animationReq; // <-- add a variable
...
// in animate()
animationReq = requestAnimationFrame(animate); // assign request to variable
...
...
onDestroy(() => {
cancelAnimationFrame(animationReq);
if (!Frame) {
return;
}

Frame.renderer.dispose(); // <-- single that has to be enough, but no
Frame.renderer.forceContextLoss();
Frame.renderer.domElement = null;
Frame.renderer = null;
});

On the time of writing (June 2023) just dispose() isn’t enough to cleanup so I perform some other (googled) actions.

So, the result of the second article: 3D scene with single object + simple animation.

Here is the GitHub repository with a snapshot of the stage.

To be continued…

--

--

Oleksii Koshkin

AWS certified Solution Architect at GlobalLogic/Hitachi