Chapitre 11 · Shapes

Des figures 2D colorées!

Quadrilatères, triangles et notre premier cube.

Dans le précédent chapitre nous avons affiché un triangle recouvrant toute la surface de notre fenêtre d'application, ce qui veut dire que nous pouvons déjà créer pas mal de choses.

L'idée :

Dans ce chapitre tu vas construire une bibliothèque de meshes (rappel, une texture est projetée sur un mesh), ce qui peut t'ouvrir quelques portes.

Structure du projet attendue

MSLearn/
├── MSLearn.entitlements
├── Application/
│   ├── main.m
│   ├── AppViewController.h
│   ├── AppViewController.mm
│   └── macOS/
│       ├── macOSInfo.plist
│       └── AppIcon.icns        ← si tu as déjà exploré l'onglet setup
├── Frameworks/
│   ├── metal-cpp/
│   └── metal-cpp-extensions/
├── Renderer/
│   ├── Mesh.cpp
│   ├── Mesh.hpp
│   ├── Renderer.cpp
│   └── Renderer.hpp
├── Shaders/
│   ├── Fullscreen.metal
│   └── Shapes.metal/           ← Ajoute
├── includes/
│   └── SharedGPU/
│       ├── Renderer_shared.h
│       └── Mesh_shared.h       ← Ajoute (normalement tu l'as déjà)
└── Makefile                    ← toujours optionnel

Tu as vu que pour créer des vertices, tu dois les déclarer comme étant des nombres à virgules flottantes (dans un vecteur float2 ou float4) et les stocker dans tableau, pour finalement afficher le rendu en 3D et avec une composante w. Ça se passait dans le shader, maintenant tu vas envoyer ces vertices via un buffer, comme les uniforms (taille fenêtre).

/// C++ — Mesh_shared.h — update:18/05/26 — partagé CPU & GPU
#ifndef Mesh_shared_h
#define Mesh_shared_h

#include <simd/simd.h>

struct alignas(16) VertexIn2DMesh // des informations pour un point dans l'espace 2D + couleur RGBA
{
    simd::float2 position;
    simd::float4 color;
};

#endif /* Mesh_shared_h */

Dans le chapitre précédent, le vertex shader retourne une coordonnée de texture pour une couleur unie stockée dans un float2 {x, y} ou {u, v}. Les données pour remplir le vecteur que le GPU attend a été directement déclaré en MSL dans le fragment shader.

Tout ce que tu vois à l'écran est triangulaire.

VertexIn2DMesh
Notre structure doit être connue par le code MSL & le code C++, on pourrait l'écrire dans 2 fichiers, 2 fois au total. Plaçons là plutôt dans un fichier partagé, le header partagé Mesh_shared.h sans oublier de l'inclure à nos deux fichiers et mesh dès qu'on en a besoin.

Buffers & mémoire GPU

Un MTL::Buffer (MTLBuffer) est un bloc de mémoire partagé entre le CPU et le GPU.
Sur Apple Silicon, c'est littéralement la même RAM physique — pas de transfert de bus, pas de copie PCIe.

Créer un buffer

/// C++ — Renderer.hpp — update:18/05/26
// #include <simd/simd.h> // Inclus dans Mesh_shared.h
#include "../includes/SharedGPU/Mesh_shared.h"

private:
    void buildGeometry();
    void buildPipeline();
    
    NS::SharedPtr<MTL::Buffer>    m_vertexBuffer; // NS::SharedPtr → destruction automatique.
    NS::SharedPtr<MTL::Buffer>    m_vertexBufferQuad;

Faisons un petit point ;

  1. buildGeometry() → au lieu de créer ces fonctions, tu pourrais exécuter son contenu dans le constructeur car c'est juste de l'initialisation/construction, la géométrie fait partie de ton objet/classe.
  2. buildPipeline() → construit le PSO (Pipeline State Object) : associe les fonctions vertex/fragment au format de pixel du drawable.

On va construire un triangle, puis on construira un carré comme suit :

En commençant par le haut gauche, ordre d'enroulement (winding order), et continuer en sens horaire (clockwise).

0 1 BL BR TR TL

Face 0 Top-Left > Top-Right > Bottom-Left

Face 1 Top-Right > Bottom-Right > Bottom-Left

/// C++ — Renderer.cpp — update:18/05/26 — Constructeur — buildGeometry → buildPipeline → newBuffer
{
    buildGeometry();                    // ← Ajoute
    buildPipeline();                    // ← Appelle de ces fonctions une fois, dans l'ordre
}
/// C++ — Renderer.cpp — update:18/05/26
void Renderer::buildPipeline() // ← Notre fonction qui construit le pipeline
{   // Change les noms selon tes fonctions MSL
    auto vertexFunction = NS::TransferPtr(m_shaderLibrary->newFunction(MTLSTR("vertex_main_shapes")));
    auto fragmentFunction = NS::TransferPtr(m_shaderLibrary->newFunction(MTLSTR("fragment_main_shapes")));

    auto renderPipelineDescriptor = NS::TransferPtr(MTL::RenderPipelineDescriptor::alloc()->init());
    renderPipelineDescriptor->setVertexFunction(vertexFunction.get());
    renderPipelineDescriptor->setFragmentFunction(fragmentFunction.get());
    renderPipelineDescriptor->colorAttachments()->object(0)->setPixelFormat(m_pixelFormat);
    renderPipelineDescriptor->setDepthAttachmentPixelFormat(m_depthPixelFormat);

    NS::Error* error = nullptr;
    m_renderPipelineState  = NS::TransferPtr(m_device->newRenderPipelineState(renderPipelineDescriptor.get(), &error));
    assert(!error && "Pipeline creation failed");
}

void Renderer::buildGeometry()
{                          //     R     G     B     Alpha (transparence -> 1.0f = maximum = opaque)
    const simd::float4 red   = { 1.0f, 0.0f, 0.0f, 1.0f };
    const simd::float4 green = { 0.0f, 1.0f, 0.0f, 1.0f };
    const simd::float4 blue  = { 0.0f, 0.0f, 1.0f, 1.0f };
    const simd::float4 white = { 1.0f, 1.0f, 1.0f, 1.0f };
    const simd::float4 black = { 0.0f, 0.0f, 0.0f, 1.0f };

    const VertexIn2DMesh triangle[] = {
        { {-0.89f,  0.89f }, blue  }, // Top-Left
        { { 0.0f,   0.89f }, red   }, // Top-Right
        { {-0.89f, -0.89f }, green }, // Bottom-Left
    };

    const VertexIn2DMesh square[] = {
        { {-0.5f,  0.5f }, red   },   // TL
        { { 0.5f,  0.5f }, green },   // TR
        { {-0.5f, -0.5f }, blue  },   // BL
        { { 0.5f,  0.5f }, green },   // TR
        { { 0.5f, -0.5f }, black },   // BR
        { {-0.5f, -0.5f }, blue  },   // BL
    };
    // Buffers initialisés avec data
    m_vertexBuffer = NS::TransferPtr(m_device->newBuffer(triangle, sizeof(triangle), MTL::ResourceStorageModeShared));
    m_vertexBufferQuad = NS::TransferPtr(m_device->newBuffer(square, sizeof(square), MTL::ResourceStorageModeShared));
}
/// C++ — Exemple — update:18/05/26
// Données CPU (rendent un carré 2D)
4 vertex (points) dans un espace 3D
                        x,     y,    x,    y,     x,    y,     x,    y,    x,    y,    x,    y
float vertices[] = { -1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 0.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f };

// Buffer de vertices — données statiques (Private = GPU seul)
MTL::Buffer* _vertexBuffer = device->newBuffer(
    vertices,         // pointeur vers le tableau ([], array) des données CPU ci-dessus
    sizeof(vertices), // taille en bytes (ici 48 octets → sizeof(float) = 4 bytes * 12 valeurs)
    MTL::ResourceStorageModeShared);

Mémoire GPU sur Apple Silicon

Sur architecture mémoire unifiée, il n'y a pas de VRAM séparée. CPU et GPU accèdent au même pool de RAM. Metal expose trois modes de stockage pour les buffers :

// Shared : CPU et GPU peuvent lire/écrire → idéal pour données dynamiques
MTL::ResourceStorageModeShared
// Private : GPU seulement → performances maximales, pas d'accès CPU direct
MTL::ResourceStorageModePrivate
// Managed : (Intel/discret) copies explicites CPU ↔ GPU — inutile sur M-series
MTL::ResourceStorageModeManaged
Sur M2 Pro
Utilise presque toujours Shared pour les buffers que le CPU met à jour chaque frame (uniforms, positions de caméra), et Private pour les données statiques (mesh, textures baked). Le mode Managed n'a aucun intérêt sur Apple Silicon.
/// C++ — Renderer.cpp — update:18/05/26
renderCommandEncoder->setRenderPipelineState(m_renderPipelineState.get());

renderCommandEncoder->setVertexBuffer(m_vertexBufferQuad.get(), 0, 0);
renderCommandEncoder->drawPrimitives(MTL::PrimitiveTypeTriangle, NS::UInteger(0), NS::UInteger(6));

renderCommandEncoder->setVertexBuffer(m_vertexBuffer.get(), 0, 0);
renderCommandEncoder->drawPrimitives(MTL::PrimitiveTypeTriangle, NS::UInteger(0), NS::UInteger(3));

Le carré a été envoyé à la file d'attente en premier, il sera donc en arrière plan par rapport au triangle.

Don't Panic !!

  1. setRenderPipelineState() → méthode appartenant à l'encoder.
  2. setVertexBuffer(buffer, 0, 0) → buffer[0] dans le code MSL
  3. drawPrimitives() → prototype Metal → retourne void = la méthode ne renvoit rien, elle dessine. → MTL:: = namespace → RenderCommandEncoder = le nom de la classe (Metal).
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/*                                        +       +          */
/*      File: Shapes.metal            +++     +++            */
/*                                        +       +          */
/*      By: laboitederemdal                +       +         */
/*                                       +           +       */
/*      Created: 21/04/2026 13:55:51      + + + + + +        */
/*      Updated: 18/05/2026                                  */
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#include <metal_stdlib>

#include "../includes/SharedGPU/Mesh_shared.h"

using namespace metal;

struct VertexOut2DMesh
{
    float4 position [[position]];
    float4 color;
};

vertex VertexOut2DMesh vertex_main_shapes(uint                     vid      [[vertex_id]],
                                          constant VertexIn2DMesh* vertices [[buffer(0)]])
{
    VertexOut2DMesh out;
    out.position = float4(vertices[vid].position, 0.0, 1.0);
    out.color    = vertices[vid].color;
    return out;
}

fragment float4 fragment_main_shapes(VertexOut2DMesh in [[stage_in]])
{
    return in.color;
}
⬡CheckPoint Compilation — change les valeurs de la géométrie

Pour une fenêtre (window) de 2560 x 1440 pixels et les coordonnées en local space ci-dessous, le GPU fait ces calculs automatiquement pour positionner le point :

\( \text{NDC} = \frac{\text{clip}}{w} = \frac{(0.0,0.33,0.0)}{1.0} = (0.0,0.33,0.0) \)

\( x_{\text{px}} = \frac{(x_{\text{NDC}} + 1)}{2} \times \text{width} = \frac{(0.0 + 1)}{2} \times 2560 = 1280 \text{ px} \)

\( y_{\text{px}} = \frac{(1 - y_{\text{NDC}})}{2} \times \text{height} = \frac{(1 - 0.33)}{2} \times 1440 = 482 \text{ px} \)

Y est inversé — en NDC +1 = haut, −1 = bas, mais en pixels 0 = haut, 1440 = bas. D'où le (1 - y).

w = 1.0 — sans matrice de projection, la division par w ne change rien. Dès que tu utilises une projectionMatrix, w devient −z_view et là la perspective entre en jeu — les objets lointains rapetissent.

/// C++ — Exemple — update:18/05/26
{ 0.0f, 0.33f }, { 0.3f, 0.7f, 0.3f, 1.0f } // vert pastel
/// MSL — Exemple — update:18/05/26
// vertices[index] → le sommet, la fn est invoquée en parallèle pour chaque sommet par le GPU.
// Ici vid/index = 1  = 2ième vertex → On récupère la première valeur d'un tableau [] via l'index 0.
// .position → x = 0, y = 0.33, z = 0
// [[position]] → Clip Space
out.position = float4(vertices[vid].position, 0.0, 1.0);
// float4 out.position = float4(0, 0.33, 0, 1); → xyzw
Interpolation par rastérisation (interpolation barycentrique)
C'est le pipeline qui, lors de la rastérisation, s'intercale automatiquement entre ton vertex shader et ton fragment shader et pour chaque fragment/pixel à l'intérieur du triangle, il calcule ses coordonnées barycentriques (α, β, γ) — trois poids liés à la position géométrique du pixel par rapport aux trois sommets « ce pixel est à X% du sommet rouge, Y% du vert, Z% du bleu » — et interpole la couleur : \( C = \alpha C_A + \beta C_B + \gamma C_C \) $$\alpha + \beta + \gamma = 1, \quad \alpha,\beta,\gamma \geq 0$$

Quelques 'meshes' utiles pour la suite du projet :

/// C++ — Mesh.hpp — update:18/05/26
#ifndef Mesh_hpp
#define Mesh_hpp

#include <vector>

#include <Metal/Metal.hpp>

#include "../includes/SharedGPU/Mesh_shared.h"

struct Mesh3D
{
    MTL::Buffer*   vertices;
    MTL::Buffer*   indices;
    uint32_t       numIndices;
    MTL::IndexType indexType;
    MTL::Winding   winding;
};

namespace meshes
{
NS::SharedPtr<MTL::Buffer> squareMesh(MTL::Device* device, const simd::float4 color, float horizontalScale = 1.0f, float verticalScale = 1.0f);
NS::SharedPtr<MTL::Buffer> triangleEquilateralMesh(MTL::Device* device, const simd::float4 color, float scale = 1.0f);
NS::SharedPtr<MTL::Buffer> triangleRectangleMesh(MTL::Device* device, const simd::float4 color, float scale = 3.f);
NS::SharedPtr<MTL::Buffer> circleMesh(MTL::Device* device, float radius, int segments);
NS::SharedPtr<MTL::Buffer> trunkWoodMesh(MTL::Device* device, float width, float height);
NS::SharedPtr<MTL::Buffer> cloudMesh(MTL::Device* device, float width, float height, float corner);
NS::SharedPtr<MTL::Buffer> groundMesh(MTL::Device* device);

Mesh3D cubeMesh(float size, MTL::Device* device);
Mesh3D newWindowQuad(MTL::Device* device, float horizontalScale = 1.0f, float verticalScale = 1.0f);
Mesh3D cubeMeshWithoutTexture(float size, MTL::Device* device);
void   releaseMesh(Mesh3D* mesh);
}

#endif /* Mesh_hpp */
/// C++ — Mesh_shared.h — update:18/05/26
struct alignas(16) VertexIn2DMesh
{
    simd::float2 position;
    simd::float4 color;
};

struct alignas(16) VertexInWithNormal
{
    simd::float4 position; // tout ce qui nous intéresse c'est d'envoyer des nombres au GPU
    simd::float4 normal; // color/normal/texCoord sont du même type, si tu redéfinis
    simd::float4 texcoord; // une structure plutôt que de t'adapter au nom → boilerplate
};

struct alignas(16) VertexIn3DMesh
{
    simd::float4 position;
    simd::float4 texcoord;
};

static_assert(sizeof(VertexIn2DMesh) == 32);
static_assert(sizeof(VertexInWithNormal) == 48);
static_assert(sizeof(VertexIn3DMesh) == 32);
En informatique, le boilerplate désigne des sections de code standardisées, répétitives et réutilisables, nécessaires pour démarrer un projet sans réécrire les mêmes bases (structure HTML, connexion base de données, authentification). C'est une structure préconfigurée — souvent sous forme de dépôt GitHub — qui permet de gagner un temps considérable et d'adopter de bonnes pratiques dès le lancement.
/// C++ — Mesh.cpp — update:18/05/26
#include "Mesh.hpp"

namespace meshes
{
NS::SharedPtr<MTL::Buffer> triangleEquilateralMesh(MTL::Device* device, const simd::float4 color, float scale)
{
    const simd::float4 red   = { 1.0f, 0.0f, 0.0f, 1.0f };
    const simd::float4 green = { 0.0f, 1.0f, 0.0f, 1.0f };
    const simd::float4 blue  = { 0.0f, 0.0f, 1.0f, 1.0f };
    if (scale > 0.16f)
    {
        const VertexIn2DMesh triangle[] = {
            { {  0.0f,  scale }, red   }, // TT
            { { scale, -scale }, blue  }, // BR
            { {-scale, -scale }, green },
        };
        return NS::TransferPtr(device->newBuffer(triangle, sizeof(triangle), MTL::ResourceStorageModeShared));
    }
    else
    {
        const VertexIn2DMesh triangle[] = {
            { {  0.0f,  scale }, color }, // TT
            { { scale, -scale }, color }, // BR
            { {-scale, -scale }, color },
        };
        return NS::TransferPtr(device->newBuffer(triangle, sizeof(triangle), MTL::ResourceStorageModeShared));
    }
}
/// C++ — Mesh.cpp — update:18/05/26
NS::SharedPtr<MTL::Buffer> triangleRectangleMesh(MTL::Device* device, const simd::float4 color, float scale)
{
    const VertexIn2DMesh triangle[] = {
        { {    -scale, scale * 3 }, color }, // TL
        { { scale * 3,    -scale }, color }, // BR
        { {    -scale,    -scale }, color },
    };
    
    return NS::TransferPtr(device->newBuffer(triangle, sizeof(triangle), MTL::ResourceStorageModeShared));
}
/// C++ — Mesh.cpp — update:18/05/26
NS::SharedPtr<MTL::Buffer> groundMesh(MTL::Device* device)
{
    const simd::float4 greenGrass = { 0.2f, 0.55f, 0.15f, 1.0f };
    const VertexIn2DMesh verts[] = {
        { {  -3.f, -0.23f }, greenGrass }, // TL
        { { 3.23f, -0.23f }, greenGrass }, // TR
        { {  -3.f, -3.23f }, greenGrass }, // BR
    };
    return NS::TransferPtr(device->newBuffer(verts, sizeof(verts), MTL::ResourceStorageModeShared));
}
/// C++ — Mesh.cpp — update:18/05/26
NS::SharedPtr<MTL::Buffer> squareMesh(MTL::Device* device, const simd::float4 color, float horizontalScale, float verticalScale)
{
    const VertexIn2DMesh square[] = {
        { {-horizontalScale,  verticalScale }, color },  // TL
        { { horizontalScale,  verticalScale }, color },  // TR
        { {-horizontalScale, -verticalScale }, color },  // BL
        { { horizontalScale,  verticalScale }, color },  // TR
        { { horizontalScale, -verticalScale }, color },  // BR
        { {-horizontalScale, -verticalScale }, color },  // BL
    };
    
    return NS::TransferPtr(device->newBuffer(square, sizeof(square), MTL::ResourceStorageModeShared));
}
/// C++ — Mesh.cpp — update:18/05/26
NS::SharedPtr<MTL::Buffer> circleMesh(MTL::Device* device, float radius, int segments)
{
    const simd::float4 yellow = { 1.0f, 0.9f, 0.0f, 1.0f };
    
    std::vector<VertexIn2DMesh> verts;
    verts.reserve(segments * 3);
    
    for (int i = 0; i < segments; i++)
    {
        float a0 = (float)i       / segments * M_PI * 2.f;
        float a1 = (float)(i + 1) / segments * M_PI * 2.f;
        
        verts.push_back({ { 0.f, 0.f },                             yellow });
        verts.push_back({ { cosf(a0) * radius, sinf(a0) * radius }, yellow }); // clockwise
        verts.push_back({ { cosf(a1) * radius, sinf(a1) * radius }, yellow });
    }
    return NS::TransferPtr(device->newBuffer(verts.data(), verts.size() * sizeof(VertexIn2DMesh), MTL::ResourceStorageModeShared));
}
/// C++ — Mesh.cpp — update:18/05/26
NS::SharedPtr<MTL::Buffer> cloudMesh(MTL::Device* device, float w, float h, float corner)
{
    const simd::float4 c = { 0.85f, 0.85f, 0.85f, 1.0f };
    const float x = w, y = h, k = corner;
    // rectangle principal + 4 coins en triangle vide = rect avec coins coupés
    const VertexIn2DMesh verts[] = {
        // rect central large
        { {-x + k, y }, c }, { { x - k,  y }, c }, { { x - k, -y }, c },
        { {-x + k, y }, c }, { { x - k, -y }, c }, { {-x + k, -y }, c },
        // rect central haut
        { {-x, y - k }, c }, { { x,  y - k }, c },   { { x, -(y - k) }, c },
        { {-x, y - k }, c }, { { x, -(y - k) }, c }, { {-x, -(y - k) }, c },
    };
    return NS::TransferPtr(device->newBuffer(verts, sizeof(verts), MTL::ResourceStorageModeShared));
}
sizeof(…)

→ sizeof(VertexIn2DMesh) nous renvoie la taille d'une copie de la structure.

→ sizeof(vertices) nous renvoie la taille de toutes les vertices déclarées.

vertices = pluriel, vertex = singulier (point/sommet)

Découvrons les meshes 3D.

Normales — direction perpendiculaire d’une surface

En géométrie et en rendu 3D, une normale est un vecteur perpendiculaire à une surface. Dans un moteur graphique, les normales servent principalement à calculer l’éclairage.

Sans normales, une surface ne “sait” pas dans quelle direction elle regarde. Le GPU ne peut donc pas déterminer si elle reçoit beaucoup ou peu de lumière.

Une normale est généralement normalisée :

\[ ||\vec{n}|| = 1 \]

Cela signifie que sa longueur vaut exactement 1. Les calculs de lumière deviennent alors stables et prévisibles.

Normales dans un cube

left-handed coordinate system +X +Y +Z (0,0,0) (0,0, 1 ) (-1,-1,0) (1,-1,0) (1,1,0) (-1,1,0) (-1,-1,1) (1,-1,1) (1,1,1) (-1,1,1)

Dans un mesh, chaque vertex contient généralement :

   Position                 Normale (v⃗)      Texture Coordinates (UV)
{ -s, +s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 0.f, 1.f, 0.f, 0.f },
{ +s, +s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 1.f, 1.f, 0.f, 0.f },
{ +s, -s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 1.f, 0.f, 0.f, 0.f },
{ -s, -s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 0.f, 0.f, 0.f, 0.f },

\[ s = 1 \qquad \vec{v} = \begin{pmatrix} 0 \\ 0 \\ 1 \end{pmatrix} \]

La face regarde vers +Z → Face arrière

Tous les vertices de cette face utilisent la même normale. Le rendu produit donc une face plate : flat shading.

Si chaque triangle possède une normale constante : Flat shading

Si les normales sont interpolées entre les vertices : Smooth shading

Le GPU interpole automatiquement les normales dans le rasterizer.

Pour une sphère, les normales pointent généralement depuis le centre :

\[ \vec{n} = \frac{\vec{p}}{||\vec{p}||} \]

où \( \vec{p} \) est la position du vertex.

Une normale ne doit pas être transformée exactement comme une position.

Pour une position :

\[ \vec{p} ' = M \cdot \vec{p} \]

Mais pour une normale :

\[ \vec{n}' = (M^{-1})^T \cdot \vec{n} \]

/// C++ — Mesh.cpp — update:18/05/26 — Right-Handed ! — Convention non-standard
Mesh3D cubeMeshWithoutTexture(float size, MTL::Device* device)
{
    const float s = size * 0.5f;
    
    VertexIn3DMesh vertices[] = {
        //   Positions          Normals
        // Face arrière Z+   x     y     z
        { { -s, -s, +s, 1.f }, { 0.f,  0.f,  1.f, 0.f } }, // BR
        { { +s, -s, +s, 1.f }, { 0.f,  0.f,  1.f, 0.f } }, // BL
        { { +s, +s, +s, 1.f }, { 0.f,  0.f,  1.f, 0.f } }, // TL 
        { { -s, +s, +s, 1.f }, { 0.f,  0.f,  1.f, 0.f } }, // TR
        
        { { +s, -s, +s, 1.f }, { 1.f,  0.f,  0.f, 0.f } },
        { { +s, -s, -s, 1.f }, { 1.f,  0.f,  0.f, 0.f } },
        { { +s, +s, -s, 1.f }, { 1.f,  0.f,  0.f, 0.f } },
        { { +s, +s, +s, 1.f }, { 1.f,  0.f,  0.f, 0.f } },
        
        { { +s, -s, -s, 1.f }, { 0.f,  0.f, -1.f, 0.f } },
        { { -s, -s, -s, 1.f }, { 0.f,  0.f, -1.f, 0.f } },
        { { -s, +s, -s, 1.f }, { 0.f,  0.f, -1.f, 0.f } },
        { { +s, +s, -s, 1.f }, { 0.f,  0.f, -1.f, 0.f } },
        
        { { -s, -s, -s, 1.f }, { -1.f, 0.f,  0.f, 0.f } },
        { { -s, -s, +s, 1.f }, { -1.f, 0.f,  0.f, 0.f } },
        { { -s, +s, +s, 1.f }, { -1.f, 0.f,  0.f, 0.f } },
        { { -s, +s, -s, 1.f }, { -1.f, 0.f,  0.f, 0.f } },
        
        { { -s, +s, +s, 1.f }, { 0.f,  1.f,  0.f, 0.f } },
        { { +s, +s, +s, 1.f }, { 0.f,  1.f,  0.f, 0.f } },
        { { +s, +s, -s, 1.f }, { 0.f,  1.f,  0.f, 0.f } },
        { { -s, +s, -s, 1.f }, { 0.f,  1.f,  0.f, 0.f } },
        
        { { -s, -s, -s, 1.f }, { 0.f, -1.f,  0.f, 0.f } },
        { { +s, -s, -s, 1.f }, { 0.f, -1.f,  0.f, 0.f } },
        { { +s, -s, +s, 1.f }, { 0.f, -1.f,  0.f, 0.f } },
        { { -s, -s, +s, 1.f }, { 0.f, -1.f,  0.f, 0.f } },
    };
    
    uint16_t indices[] = {
        0,  1,  2,  2,  3,  0, /* front */
        4,  5,  6,  6,  7,  4, /* right */
        8,  9, 10, 10, 11,  8, /* back */
        12, 13, 14, 14, 15, 12, /* left */
        16, 17, 18, 18, 19, 16, /* top */
        20, 21, 22, 22, 23, 20, /* bottom */
    };
    
    Mesh3D cubeMesh
    {
        .vertices  = device->newBuffer(sizeof(vertices), MTL::ResourceStorageModeShared),
        .indices   = device->newBuffer(sizeof(indices), MTL::ResourceStorageModeShared),
        .numIndices = sizeof(indices) / sizeof(indices[0]),
        .indexType  = MTL::IndexTypeUInt16,
        .winding    = MTL::WindingCounterClockwise // pour exemple, règle de la main droite
    };
    
    memcpy(cubeMesh.vertices->contents(), vertices, sizeof(vertices));
    memcpy(cubeMesh.indices->contents(),  indices,  sizeof(indices));
    
    return cubeMesh;
}

Winding order et normales

L’ordre des sommets détermine l’orientation d’un triangle.

Avec un winding counter-clockwise :

\[ \vec{n} = (\vec{b} - \vec{a}) \times (\vec{c} - \vec{a}) \]

Le produit vectoriel donne une normale orientée selon la règle de la main droite. Si l’ordre des vertices est inversé :

On utilisera la technique pour des rendus spéciaux.

Le winding en 2D sans depth test n'a aucun effet — le back-face culling est désactivé par défaut sur un pipeline sans MTLDepthStencilStates
/// C++ — Mesh.cpp — update:18/05/26
Mesh3D cubeMesh(float size, MTL::Device* device) // Cube avec le centre xyz (0, 0, 0), identity matrix
{
    const float s = size * 0.5f;

    VertexInWithNormal vertices[] = {
        // Position                  Normale (v⃗)       Texture Coordinates (UV)
        // Face avant Z-       (vue de l'extérieur → -Z)
        { { -s, +s, -s, 1.f }, { 0.f, 0.f, -1.f, 0.f }, { 0.f, 0.f, 0.f, 0.f } }, // TL
        { { +s, +s, -s, 1.f }, { 0.f, 0.f, -1.f, 0.f }, { 1.f, 0.f, 0.f, 0.f } }, // TR
        { { +s, -s, -s, 1.f }, { 0.f, 0.f, -1.f, 0.f }, { 1.f, 1.f, 0.f, 0.f } }, // BR
        { { -s, -s, -s, 1.f }, { 0.f, 0.f, -1.f, 0.f }, { 0.f, 1.f, 0.f, 0.f } }, // BL
        // Face arrière Z+        x    y    z
        { { +s, +s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 0.f, 0.f, 0.f, 0.f } }, // TL
        { { -s, +s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 1.f, 0.f, 0.f, 0.f } }, // TR
        { { -s, -s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 1.f, 1.f, 0.f, 0.f } }, // BR
        { { +s, -s, +s, 1.f }, { 0.f, 0.f, 1.f, 0.f }, { 0.f, 1.f, 0.f, 0.f } }, // BL
        // Face droite X+
        { { +s, +s, -s, 1.f }, { 1.f, 0.f, 0.f, 0.f }, { 0.f, 0.f, 0.f, 0.f } }, // TL
        { { +s, +s, +s, 1.f }, { 1.f, 0.f, 0.f, 0.f }, { 1.f, 0.f, 0.f, 0.f } }, // TR
        { { +s, -s, +s, 1.f }, { 1.f, 0.f, 0.f, 0.f }, { 1.f, 1.f, 0.f, 0.f } }, // BR
        { { +s, -s, -s, 1.f }, { 1.f, 0.f, 0.f, 0.f }, { 0.f, 1.f, 0.f, 0.f } }, // BL
        // Face gauche X-
        { { -s, +s, +s, 1.f }, { -1.f, 0.f, 0.f, 0.f }, { 0.f, 0.f, 0.f, 0.f } }, // TL
        { { -s, +s, -s, 1.f }, { -1.f, 0.f, 0.f, 0.f }, { 1.f, 0.f, 0.f, 0.f } }, // TR
        { { -s, -s, -s, 1.f }, { -1.f, 0.f, 0.f, 0.f }, { 1.f, 1.f, 0.f, 0.f } }, // BR
        { { -s, -s, +s, 1.f }, { -1.f, 0.f, 0.f, 0.f }, { 0.f, 1.f, 0.f, 0.f } }, // BL
        // Face dessus Y+
        { { -s, +s, +s, 1.f }, { 0.f, 1.f, 0.f, 0.f }, { 0.f, 0.f, 0.f, 0.f } }, // TL
        { { +s, +s, +s, 1.f }, { 0.f, 1.f, 0.f, 0.f }, { 1.f, 0.f, 0.f, 0.f } }, // TR
        { { +s, +s, -s, 1.f }, { 0.f, 1.f, 0.f, 0.f }, { 1.f, 1.f, 0.f, 0.f } }, // BR
        { { -s, +s, -s, 1.f }, { 0.f, 1.f, 0.f, 0.f }, { 0.f, 1.f, 0.f, 0.f } }, // BL
        // Face dessous Y-
        { { -s, -s, -s, 1.f }, { 0.f, -1.f, 0.f, 0.f }, { 0.f, 0.f, 0.f, 0.f } }, // TL
        { { +s, -s, -s, 1.f }, { 0.f, -1.f, 0.f, 0.f }, { 1.f, 0.f, 0.f, 0.f } }, // TR
        { { +s, -s, +s, 1.f }, { 0.f, -1.f, 0.f, 0.f }, { 1.f, 1.f, 0.f, 0.f } }, // BR
        { { -s, -s, +s, 1.f }, { 0.f, -1.f, 0.f, 0.f }, { 0.f, 1.f, 0.f, 0.f } }, // BL
    };

    uint16_t indices[] = {
         0,  1,  2,  2,  3,  0, /* front  */
         4,  5,  6,  6,  7,  4, /* back   */
         8,  9, 10, 10, 11,  8, /* right  */
        12, 13, 14, 14, 15, 12, /* left   */
        16, 17, 18, 18, 19, 16, /* top    */
        20, 21, 22, 22, 23, 20, /* bottom */
    };

    Mesh3D cubeMesh
    {
        .vertices   = device->newBuffer(sizeof(vertices), MTL::ResourceStorageModeShared),
        .indices    = device->newBuffer(sizeof(indices),  MTL::ResourceStorageModeShared),
        .numIndices = sizeof(indices) / sizeof(indices[0]),
        .indexType  = MTL::IndexTypeUInt16,
        .winding    = MTL::WindingClockwise  // ← CW vu de l'extérieur
    };                                      // MTLWindingCounterClockwise vu de l'intérieur
    
    memcpy(cubeMesh.vertices->contents(), vertices, sizeof(vertices));
    memcpy(cubeMesh.indices->contents(),  indices,  sizeof(indices));
    
    return cubeMesh;
}
BACK LEFT BACK RIGHT TOP RIGHT TOP LEFT

Top-Left > Top-Right > Bottom-Right > Bottom-Left

/// C++ — Mesh.cpp — update:18/05/26
Mesh3D newWindowQuad(MTL::Device* device, float horizontalScale, float verticalScale)
{
    const float hs = horizontalScale / 2;
    const float vs = verticalScale / 2;
    
    std::vector<VertexIn3DMesh> vertexIn
    {
        VertexIn3DMesh {
            .position = simd::float4{ -1.f * hs, 1.f * vs, 0.0, 1.0 },
            .texcoord = simd::float4{ 0.0, 0.0 }
        },
        VertexIn3DMesh {
            .position = simd::float4{ -1.f * hs, -1.f * vs, 0.0, 1.0 },
            .texcoord = simd::float4{ 0.0, 1.0 }
        },
        VertexIn3DMesh {
            .position = simd::float4{ +1.f * hs, -1.f * vs, 0.0, 1.0 },
            .texcoord = simd::float4{ 1.0, 1.0 }
        },
        VertexIn3DMesh {
            .position = simd::float4{ +1.f * hs, +1.f * vs, 0.0, 1.0 },
            .texcoord = simd::float4{ 1.0, 0.0 }
        }
    };
    
    std::vector<uint16_t> indexData
    {
        0, 1, 2,
        2, 3, 0
    };
    
    Mesh3D mesh
    {
        .vertices = device->newBuffer(vertexIn.size() * sizeof(VertexIn3DMesh), MTL::ResourceStorageModeShared),
        .indices = device->newBuffer(indexData.size() * sizeof(uint16_t), MTL::ResourceStorageModeShared),
        .numIndices = (uint32_t)indexData.size(),
        .indexType = MTL::IndexTypeUInt16,
        .winding = MTL::WindingCounterClockwise
    };
    
    memcpy(mesh.vertices->contents(), vertexIn.data(),  vertexIn.size()  * sizeof(VertexIn3DMesh));
    memcpy(mesh.indices->contents(),  indexData.data(), indexData.size() * sizeof(uint16_t));
    
    return mesh;
}
/// C++ — Mesh.cpp — update:18/05/26
void releaseMesh(Mesh3D* mesh)
{
    mesh->vertices->release();
    mesh->indices->release();
    mesh->vertices = nullptr;
    mesh->indices = nullptr;
}
} // namespace meshes
Principes appliqués :
  1. TL / TR / BR / BL uniquement (face par face)
  2. chaque face = 4 sommets indépendants (pas de partage → normales propres)
  3. winding CW vu de l’extérieur
  4. Z+ = arrière écran (Metal LH classique) — Left-handed — Z non flippé
  5. indices cohérents par groupe de 4
/// C++ — Renderer.hpp — update:18/05/26
#include "Mesh.hpp"
⬡CheckPoint Compilation — tu peux recompiler le triangle

Indices & optimisation

Un cube a 8 vertices mais 12 triangles (6 faces × 2 triangles). Sans indices : 36 vertices à envoyer au GPU

Chaque coin du cube est partagé par 3 faces — sans les indices tu envoies le même vertex 3 fois :

/// C++ — Exemple — update:18/05/26 — Indices/Index des vectices
// Sans indices — vertex dupliqués
{ -0.5, -0.5,  0.5 }, // coin 0 — face avant
{ -0.5, -0.5,  0.5 }, // coin 0 — face gauche   (même données)
{ -0.5, -0.5,  0.5 }, // coin 0 — face bas      (même données)

// Avec indices — 8 vertices uniques + 36 indices
vertices[0] = { -0.5, -0.5,  0.5 }; // stocké une seule fois

indices = { 0, 1, 2,  // triangle 1 face avant
            0, 2, 3,  // triangle 2 face avant
            0, 3, 4,  // triangle 1 face gauche
            ... };

Le gain mémoire sur un cube est modeste, mais sur un mesh complexe (personnage, terrain) avec des milliers de vertices partagés c'est critique — et le GPU peut aussi réutiliser son post-transform cache : si le même index apparaît deux fois, le vertex shader n'est pas réexécuté.