Faire de la projection orthogonale avec Canvas

Faire de la projection orthogonale avec Canvas

gamedev, fun, art, javascript, canvasPublié il y a un an

Sommaire

J'ai toujours voulu faire un jeu vidéo, mais avant de savoir coder les composants d'un jeu, il faut d'abord savoir comment afficher des éléments graphiques. Avec Canvas c'est assez simple, on a une toile et un crayon, on pose la mine à une position x, y et on trace un trait avec une épaisseur et une couleur. Mais ça c'est pour de la 2D, qu'en est-il de la 3D ? Toujours avec Canvas, on peut faire du WebGL, et le principe devient beaucoup plus abstrait. On parle de communication avec la carte graphique, de vertex, de mesh, de buffer, de shader etc …

Je me suis confronté à un constat, la 2D c'est simple à appréhender (une toile, un crayon), la 3D beaucoup moins. Mais peut-on faire de la 3D dans de la 2D ? En géométrie on appel ça: faire de la projection orthogonale ou l'art de l'illusion 3D sur un plan 2D.

Pour cela nous allons devoir réfléchir en 3D et contextualiser en 2D. Partons d'un cube, un cube possède 6 faces, 8 sommets, 12 arrêtes. Un point dans l'espace est représenté par un vecteur à 3 dimensions (x, y, z). Un trait possède donc 2 points et une face possède 4 traits, on parle de polygone. Enfin un cube possède 6 polygones.

Passons au code :

// Un point dans l'espace
class Vector3 {
    constructor(x, y, z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
}

// Une face
class Polygon {
    constructor(lignes) {
        this.lignes = lignes;
    }
}

Maintenant nous allons décrire notre cube, pour savoir quelles valeurs mettre pour nos 8 sommets, nous définissons nos axes X, Y et Z valant 1 et ayant pour l'origine le milieu du cube. C'est à dire que notre cube prendra au maximum une largeur, hauteur et profondeur de [-1, 1] (attention je ne parle pas de pixel d'écran, mais d'unité).

Donc un cube c'est …

// 8 sommets dans l'espace
const points = [
    new Vector3(-1, -1, -1), // gauche haut devant
    new Vector3(1, -1, -1), // droite haut devant
    new Vector3(1, 1, -1), // droite bas devant
    new Vector3(-1, 1, -1), // gauche bas devant
    new Vector3(-1, -1, 1), // gauche haut derrière
    new Vector3(1, -1, 1), // droite haut derrière
    new Vector3(1, 1, 1), // droite bas derrière
    new Vector3(-1, 1, 1), // gauche bas derrière
];

// 6 polygones
const cube = [
    new Polygon([points[0], points[1], points[2], points[3]]), // face devant
    new Polygon([points[4], points[5], points[6], points[7]]), // derrière
    new Polygon([points[0], points[1], points[5], points[4]]), // haut
    new Polygon([points[2], points[3], points[7], points[6]]), // bas
    new Polygon([points[0], points[3], points[7], points[4]]), // gauche
    new Polygon([points[1], points[2], points[6], points[5]]), // droite
];

Nous voilà avec la description d'un cube, passons au dessin. L'idée est de dessiner chaque point en fonction de sa position sur un plan et de les relier entres eux. Pour ce faire il nous faut des fonctions mathématique fx et fy à appliquer sur chaque valeur x et y (z n'existant pas en 2D) de chaque point.

Ces deux fonctions vont permettre de donner une taille à notre cube, elles peuvent également permettre de décaler le cube.

Allons-y en ajoutant la méthode draw à nos polygones…

class Polygon {
    constructor(lignes) {
        this.lignes = lignes;
    }

    draw(ctx, fx = null, fy = null) {
        // On commence à dessiner
        ctx.save();
        ctx.beginPath();

        // Si aucune fonction mathématique alors
        // pour chaque transformation de x on renvoi la valeur x du point
        // pareil pour y
        fx = fx || (v => v.x);
        fy = fy || (v => v.y);

        // On se déplace sur le premier point
        ctx.moveTo(fx(this.lignes[0]), fy(this.lignes[0]));

        // Puis on trace de point en point
        for (var i = 1; i < this.lignes.length; ++i) {
            ctx.lineTo(fx(this.lignes[i]), fy(this.lignes[i]));
        }

        // On ferme notre dessin
        ctx.closePath();
        ctx.stroke();
        ctx.restore();
    }
}

Il nous reste plus qu'à dessiner notre cube dans une balise Canvas.

const canvas = document.querySelector('canvas');
canvas.width = 400;
canvas.height = 400;

// On récupère un contexte de dessin 2D
const ctx = canvas.getContext('2d');
const size = canvas.width / 4;

// Nos fonctions mathématique vont faire un étirement (mise à l'échelle)
// de x et y d'un quart de la largeur du canvas
const fx = v => v.x * size;
const fy = v => v.y * size;

// On se déplace au milieu du canvas pour dessiner le cube
ctx.translate(canvas.width / 2, canvas.width / 2);

for (let polygon of cube) {
    polygon.draw(ctx, fx, fy);
}
class Vector3 {
    constructor(x, y, z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
}

class Polygon {
    constructor(lignes) {
        this.lignes = lignes;
    }

    draw(ctx, fx = null, fy = null) {
        ctx.save();
        ctx.beginPath();

        ctx.moveTo(fx(this.lignes[0]), fy(this.lignes[0]));
        for (var i = 1; i < this.lignes.length; ++i) {
            ctx.lineTo(fx(this.lignes[i]), fy(this.lignes[i]));
        }

        ctx.closePath();
        ctx.stroke();
        ctx.restore();
    }
}

const points = [
    new Vector3(-1, -1, -1),
    new Vector3(1, -1, -1),
    new Vector3(1, 1, -1),
    new Vector3(-1, 1, -1)
];

const cube = [
    new Polygon([points[0], points[1], points[2], points[3]])
];

const canvas = document.createElement('canvas');
canvas.style.display = 'block';
canvas.style.margin = 'auto';
canvas.width = 300;
canvas.height = 300;

element.appendChild(canvas);

const ctx = canvas.getContext('2d');
const size = canvas.width / 4;

const fx = v => v.x * size;
const fy = v => v.y * size;

ctx.translate(canvas.width / 2, canvas.width / 2);
for (let polygon of cube) {
    polygon.draw(ctx, fx, fy);
}

Je vous vois déjà lever un sourcil et vous dire - "mais ce n'est pas un cube, c'est juste un carré ?!". En effet la projection de notre cube fait un carré, car notre cube comme nous l'avons décrit est positionné parfaitement de face, ainsi on ne voit pas ses faces de cotés.

Donnons un peu de mouvement à notre cube. Pour déplacer un élément 2D il suffit de changer les coordonnées x et y. Pour de la 3D c'est plus compliqué. Déjà car il existe une 3éme dimension z, mais aussi parce que notre cube peut tourner, changeant également les coordonnées de chaque point.

Plus concrètement, pour bouger une forme 3D, nous devons lui appliquer une matrice de transformation. Je ne vais pas rentrer dans les détails, mais sachez qu'une matrice possède toutes les informations pour mettre à l'échelle, incliner et déplacer les coordonnées x, y et z d'un point dans l'espace.

Pour faire une rotation à notre cube, on peut trouver les 3 matrices pour x, y, et z sur wikipedia, mais avant toute chose nous avons besoin de décrire ce qu'est une matrice.

class Matrix3 {
    constructor(values) {
        this.values = [];

        // On transforme un tableau [a, b, c, d, e, f, g, h, i]
        // en matrice
        // [[a, b, c],
        //  [e, f, g],
        //  [g, h, i]]
        let index = 0;
        const size = 3; // Matrice de taille 3
        for (let i = 0; i < values.length; i++) {
            index = i % size === 0 && i !== 0 ? ++index : index;
            if (!this.values[index]) {
                this.values[index] = [];
            }

            this.values[index].push(values[i]);
        }
    }

    // Permet d'appliquer les transformation d'une matrice à une autre
    transform(matrice) {
        const values = [];

        // On multiplie chaque valeur de la matrice origine
        // aux valeurs de la nouvelle matrice
        for (let i = 0; i < this.values.length; i++) {
            for (let k = 0; k < matrice.values[i].length; k++) {
                let equation = 0;
                for (let j = 0; j < this.values[i].length; j++) {
                    equation += this.values[j][i] * matrice.values[k][j];
                }

                values.push(equation);
            }
        }

        return new Matrix3(values);
    }
}

Bien, on peut maintenant créer nos 3 matrices de rotation sur l'axe x, y et z

// On veut une rotation de 30° sur x, y et z
const degrees = 30;

// On doit convertir nos degrés en radian
const angle = (degrees * Math.PI) / 180;
const a = Math.cos(angle);
const b = Math.sin(angle);

// Matrice de rotation X
const matrixRx = new Matrix3([
    1, 0, 0,
    0, a, -b,
    0, b, a
]);

// Matrice de rotation Y
const matrixRy = new Matrix3([
    a, 0, b,
    0, 1, 0,
    -b, 0, a
]);

// Matrice de rotation Z
const matrixRz = new Matrix3([
    a, -b, 0,
    b, a, 0,
    0, 0, 1
]);

// On fusionne nos 3 matrices de rotation
const matrix = matrixRx.transform(matrixRy).transform(matrixRz);

Enfin, nous devons modifier nos polygones et nos lignes pour pouvoir appliquer la transformation matricielle.

class Vector3 {
    constructor(x, y, z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    transform(matrix) {
        const particulars = [this.x, this.y, this.z];
        const values = [];

        for (let i = 0; i < matrix.values.length; i++) {
            let equation = 0;
            for (let j = 0; j < matrix.values[i].length; j++) {
                equation += matrix.values[i][j] * particulars[j];
            }
            values.push(equation);
        }

        // On créé une nouvelle instance pour ne pas altérer les points d'origines
        return new Vector3(values[0], values[1], values[2]);
    }
}

class Polygon {
    constructor(lignes) {
        this.lignes = lignes;
    }

    draw(ctx, fx = null, fy = null) {
        ...
    }

    transform(matrix) {
        const points = this.lignes.map(ligne => ligne.transform(matrix));
        return new Polygon(points);
    }
}

Et pour finir on dessine notre cube 😎

const canvas = document.querySelector('canvas');
canvas.width = 400;
canvas.height = 400;

const ctx = canvas.getContext('2d');
const size = canvas.width / 4;
const fx = v => v.x * size;
const fy = v => v.y * size;

ctx.translate(canvas.width / 2, canvas.width / 2);

for (let polygon of cube) {
    // On applique la transformation matricielle de "matrix"
    // et on dessine le polygone
    polygon.transform(matrix).draw(ctx, fx, fy);
}
class Matrix3 {
    constructor(values) {
        this.values = [];

        let index = 0;
        for (let i = 0; i < values.length; i++) {
            index = i % 3 === 0 && i !== 0 ? ++index : index;
            if (!this.values[index]) {
                this.values[index] = [];
            }

            this.values[index].push(values[i]);
        }
    }

    transform(matrice) {
        const values = [];
        for (let i = 0; i < this.values.length; i++) {
            for (let k = 0; k < matrice.values[i].length; k++) {
                let equation = 0;
                for (let j = 0; j < this.values[i].length; j++) {
                    equation += this.values[j][i] * matrice.values[k][j];
                }
                values.push(equation);
            }
        }

        return new Matrix3(values);
    }
}

class Vector3 {
    constructor(x, y, z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    transform(matrix) {
        const particulars = [this.x, this.y, this.z];
        const values = [];

        for (let i = 0; i < matrix.values.length; i++) {
            let equation = 0;
            for (let j = 0; j < matrix.values[i].length; j++) {
                equation += matrix.values[i][j] * particulars[j];
            }
            values.push(equation);
        }

        return new Vector3(values[0], values[1], values[2]);
    }
}

class Polygon {
    constructor(lignes, color = 'black') {
        this.lignes = lignes;
        this.color = color;
    }

    draw(ctx, fx = null, fy = null) {
        ctx.save();
        ctx.beginPath();

        ctx.strokeStyle = this.color;

        ctx.moveTo(fx(this.lignes[0]), fy(this.lignes[0]));
        for (var i = 1; i < this.lignes.length; ++i) {
            ctx.lineTo(fx(this.lignes[i]), fy(this.lignes[i]));
        }

        ctx.closePath();
        ctx.stroke();
        ctx.restore();
    }

    transform(matrix) {
        const lignes = this.lignes.map(ligne => ligne.transform(matrix));

        return new Polygon(lignes, this.color);
    }
}

const points = [
    new Vector3(-1, -1, -1),
    new Vector3(1, -1, -1),
    new Vector3(1, 1, -1),
    new Vector3(-1, 1, -1),
    new Vector3(-1, -1, 1),
    new Vector3(1, -1, 1),
    new Vector3(1, 1, 1),
    new Vector3(-1, 1, 1),
];

const a = [
    new Vector3(0, 0, 0),
    new Vector3(1, 0, 0),
    new Vector3(0, 1, 0),
    new Vector3(0, 0, 1)
];

const cube = [
    new Polygon([points[0], points[1], points[2], points[3]]),
    new Polygon([points[4], points[5], points[6], points[7]]),
    new Polygon([points[0], points[1], points[5], points[4]]),
    new Polygon([points[2], points[3], points[7], points[6]]),
    new Polygon([points[0], points[3], points[7], points[4]]),
    new Polygon([points[1], points[2], points[6], points[5]])
];

const axes = [
    new Polygon([a[0], a[1]], '#F06'),
    new Polygon([a[0], a[2]], '#F90'),
    new Polygon([a[0], a[3]], '#09C'),
];

const canvas = document.createElement('canvas');
canvas.style.display = 'block';
canvas.style.margin = 'auto';
canvas.width = 300;
canvas.height = 300;

element.appendChild(canvas);

const ctx = canvas.getContext('2d');
const size = canvas.width / 4;

const fx = v => v.x * size;
const fy = v => v.y * size;

let angle = 0;
function drawCube() {
    angle = (angle + 1) % 360;

    const a = Math.cos(angle * Math.PI / 180);
    const b = Math.sin(angle * Math.PI / 180);

    const matrixRx = new Matrix3([
        1, 0, 0,
        0, a, -b,
        0, b, a
    ]);

    const matrixRy = new Matrix3([
        a, 0, b,
        0, 1, 0,
        -b, 0, a
    ]);

    const matrixRz = new Matrix3([
        a, -b, 0,
        b, a, 0,
        0, 0, 1
    ]);

    const matrix = matrixRx.transform(matrixRy).transform(matrixRz);

    ctx.clearRect(-150, -150, 300, 300);
    for (let polygon of cube) {
        polygon.transform(matrix).draw(ctx, fx, fy);
    }

    for (let polygon of axes) {
        polygon.transform(matrix).draw(ctx, fx, fy);
    }

    requestAnimationFrame(drawCube);
}

ctx.translate(canvas.width / 2, canvas.width / 2);
requestAnimationFrame(drawCube);

Oyeah ! sympa n'est-ce pas 🤩 ?

Vous voila avec une base permettant de dessiner des cubes, mais aussi des formes plus complexes. À vous de vous amuser avec !

Pour finir, je vous partage un article de David Rousset co-fondateur de BabylonJS, un moteur 3D en javascript, qui fait une introduction au monde de la 3D : Introduction au jargon de la 3D et à WebGL.

Commentaire(s) 

Ancun commentaire pour le moment.