Le Viewport — définir la zone de rendu et la passer au shader
Avant de dessiner quoi que ce soit, le GPU a besoin de savoir où il dessine sur la surface. C'est le rôle du MTLViewport : transformer les coordonnées Normalized Device Coordinates, de -1 à +1 en coordonnées pixels réelles sur ton drawable. Sans viewport explicite, Metal en applique un par défaut — ce qui fonctionne, mais dès que tu veux plusieurs vues, du letterboxing, ou passer la résolution à un shader, tu dois le contrôler toi-même.
Viewport coordinate system on Metal
La structure MTL::Viewport en C++
Le viewport est une simple structure dans metal-cpp — pas d'allocation, pas de release — c'est une valeur (comme double, float, NS::UInteger, …). Pas un objet Metal.
/// C++ — Exemple — défini dans Metal/MTLRenderCommandEncoder.hpp
struct Viewport
{
double originX; // coin haut-gauche en pixels
double originY; // idem
double width; // largeur en pixels
double height; // hauteur en pixels
double znear; // profondeur minimale [0.0 … 1.0]
double zfar; // profondeur maximale [0.0 … 1.0]
};
Normalized device coordinate system (NDC) on Metal
left-handed coordinate systemEn NDC, le centre de l'écran est (0,0). Les bords vont de -1 à +1 sur X et Y.
En NDC : Y monte. — comme en maths.
En pixels/viewport : Y descend. — convention écran standard — comme une image.
Le viewport fait la conversion entre les deux automatiquement. Tu n'as rien à inverser dans ton shader.
Systèmes de coordonnées
Petite paranthèse : Normalized 2D texture coordinate system (UV) on Metal
Stocker et mettre à jour le viewport
Dans le chapitre précédent se trouvait la fonction à implémenter obligatoirement drawableSizeWillChange() du protocole MTKViewDelegate.
Celle-ci prévient notre MTKView (vue) lorsque la fenêtre d'application change de taille. Tu en auras souvent besoin, nous créeons un pont ObjC++ ←→ C++
Notre écran contient 3024px × 1964px (width, height), lorsque la fenêtre de ton application change de taille, la fonction renvoit au maximum un CGSize (structure de 2 floats) de 3024.000000 × 1898.000000. La surface du dessinable (drawable) de la MTKView garde 66 pixels de hauteur au Top pour un total de 199 584 pixels. Si viewport plus grand → affichage tronqué!
/// Objective-C++ — AppViewController.mm — update:18/05/26
- (void)mtkView:(MTKView *)view drawableSizeWillChange:(CGSize)size
{
NSLog(@"drawable size: %.0f x %.0f", size.width, size.height); // print tes valeurs dans la console
_renderer->resizeViewWindow((NS::UInteger)size.width, (NS::UInteger)size.height);
}
Nous avons construit une fonction afin de remplir les données de notre viewport qui est appelé à chaque resize via resizeViewWindow(), notre équivalence C++ de la fonction drawableSizeWillChange().
On donne les valeurs de droite à gauche.
/// C++ — Renderer.cpp — update:18/05/26
void Renderer::updateViewportWindow(NS::UInteger width, NS::UInteger height)
{
m_viewport.originX = 0.0;
m_viewport.originY = 0.0;
m_viewport.width = (double)width; // transforme l'UInteger en double avant l'attribution (cast)
m_viewport.height = (double)height;
m_viewport.znear = 0.0; // toujours 0.0 sauf cas spéciaux
m_viewport.zfar = 1.0; // toujours 1.0 sauf cas spéciaux
}
znear et zfar ne sont pas les plans near/far de ta caméra — ce sont les bornes de remapping de la valeur de profondeur dans le depth buffer. En Metal, la convention est [0.0, 1.0]. Ne change pas ces valeurs sauf si tu fais du reverse-Z intentionnel.
/// C++ — Renderer.hpp — update:18/05/26
private:
void updateViewportWindow(NS::UInteger width, NS::UInteger height); // Prototype de notre fonction
void update(float delta);
MTL::PixelFormat m_depthPixelFormat;
MTL::Viewport m_viewport; // Une copie de la structure, membre de la classe
/// C++ — Renderer.cpp — update:18/05/26
void Renderer::resizeViewWindow(NS::UInteger width, NS::UInteger height)
{
updateViewportWindow(width, height); // Mise à jour des valeurs quand la fenêtre change
}
Lorsque la fenêtre est créé, drawableSizeWillChange() n'est pas appelé, on doit aussi appeler notre fonction dans le constructeur de notre classe principale par exemple :
/// C++ — Renderer.cpp — update:18/05/26
{
resizeViewWindow(width, height);
}
Appliquer le viewport dans ta fonction de dessin, ici draw()
Le viewport est appliqué sur le MTL::RenderCommandEncoder, une fois par pass, avant tout draw call :
/// C++ — Exemple — update:18/05/26
void Renderer::draw(MTK::View* view, double timeStamp, float delta)
{
NS::AutoreleasePool* autoreleasePool = NS::AutoreleasePool::alloc()->init();
MTL::CommandBuffer* commandBuffer = m_commandQueue->commandBuffer();
MTL::RenderPassDescriptor* renderPassDescriptor = view->currentRenderPassDescriptor();
MTL::RenderCommandEncoder* renderCommandEncoder = commandBuffer->renderCommandEncoder(renderPassDescriptor);
renderCommandEncoder->setViewport(m_viewport);
// …
renderCommandEncoder->setViewport(m_viewport); // si nécessaire
// …
renderCommandEncoder->endEncoding();
commandBuffer->presentDrawable(view->currentDrawable());
commandBuffer->commit();
autoreleasePool->release();
}
Passer la résolution au shader
Le viewport est un état CPU — le shader n'y a pas accès directement. Pour passer la résolution (utile pour les effets plein-écran, le SDF (texte), le ray marching, les coordonnées UV comme la texture de profondeur), tu peux créer une structure de données et tu la passes au shader via un buffer, une table d'argument (Metal 4) ou juste une donnée sans buffer.
1. Déclarer la donnée
/// C++ — Renderer.hpp — update:18/05/26
private:
simd::float2 m_windowSize; // après les membres MTL
1. Déclarer la structure partagée
Dans un header partagé entre C++ & MSL :
/// C++ — Renderer_shared.h — update:18/05/26 — partagé CPU & GPU
#ifndef Renderer_shared_h
#define Renderer_shared_h
#include <simd/simd.h>
struct WindowUniforms
{
simd::float2 windowSize;
};
#endif /* Renderer_shared_h */
simd::float2 est aligné sur 8 bytes côté CPU et float2 sur 8 bytes côté GPU. La structure est identique des deux côtés — c'est pour ça qu'on utilise simd:: plutôt que deux float séparés.
Créer le buffer côté C++ pour la structure uniquement
/// C++ — Renderer.hpp — update:18/05/26 — membre (m_) de la classe, (initialisation = une fois)
#include "../includes/SharedGPU/Renderer_shared.h"
// membre de la classe principale
private:
NS::SharedPtr<MTL::Buffer> m_windowSizeBuffer;
WindowUniforms m_windowUniforms;
/// C++ — Renderer.cpp — update:18/05/26 — dans le constructeur (allocation = une fois)
{
m_windowSizeBuffer = NS::TransferPtr(m_device->newBuffer(sizeof(WindowUniforms), MTL::ResourceStorageModeShared));
}
2. Remplir les données de largeur et de hauteur à jour
/// C++ — Renderer.cpp — update:18/05/26
void Renderer::resizeViewWindow(NS::UInteger width, NS::UInteger height)
{
// La donnée :
m_windowSize.x = (float)width;
m_windowSize.y = (float)height;
// x y
m_windowSize = { (float)width, (float)height }; // OU en une ligne
// La structure :
m_windowUniforms.windowSize.x = (float)width;
m_windowUniforms.windowSize.y = (float)height;
memcpy(m_windowSizeBuffer->contents(), &m_windowUniforms, sizeof(WindowUniforms));
updateViewportWindow(width, height);
} // On pourrait très bien effectuer cette action dans notre fonction updateViewportWindow()..
3. Recevoir la donnée dans le shader MSL (sans MTLBuffer)
/// C++ — Renderer.cpp — update:18/05/26
MTL::RenderCommandEncoder* renderCommandEncoder = commandBuffer->renderCommandEncoder(renderPassDescriptor);
renderCommandEncoder->setViewport(m_viewport);
renderCommandEncoder->setRenderPipelineState(m_renderPipelineState.get());
renderCommandEncoder->setFragmentBytes(&m_windowSize, sizeof(m_windowSize), 1);
renderCommandEncoder->drawPrimitives(MTL::PrimitiveTypeTriangle, NS::UInteger(0), NS::UInteger(3));
renderCommandEncoder->endEncoding();
Pourquoi il n'y a pas de buffer dans ce cas ? Metal en crée un temporaire pour nous… La méthode d'instance setFragmentBytes:length:atIndex: crée un tampon à partir d'octets et l'attribue à une entrée dans la table d'arguments du shader de fragments.
Important : Appelez cette méthode uniquement pour les données à usage unique inférieures à 4 Ko (4096 octets - 8 octets, on est large)
/// MSL — Radial.metal — update:18/05/26
#include <metal_stdlib>
#include "../includes/SharedGPU/Renderer_shared.h"
using namespace metal;
struct VertexOut
{
float4 position [[position]]; // pas besoin d'écriture du namespace simd:: dans les fichiers metal
float2 uv;
};
// vertex_id : index automatique du sommet (0, 1, 2) par Metal
vertex VertexOut vertex_main_radial(uint vid [[vertex_id]])
{
// Trois sommets en NDC — x ∈ [-1, 1], y ∈ [-1, 1] (appartient à)
// Centre de la fenêtre = (0, 0)
constexpr float2 positions[3] = {
{ -1.0, 3.0 }, // Top-Left
{ 3.0, -1.0 }, // Back-Right
{ -1.0, -1.0 }, // Back-Left
};
VertexOut out;
out.position = float4(positions[vid], 0.5, 1.0); // 3D + w
out.uv = (positions[vid] + 1.0) * 0.5; // [0, 1], 0,5 = centre pixel
return out;
}
fragment float4 fragment_main_radial(VertexOut in [[stage_in]],
constant float2& windowSize [[buffer(1)]])
{
float2 uv = in.uv; // coordonnée dans la texture (surface plane)
float aspect = windowSize.x / windowSize.y; // ~1.6, ~1.8 pour une fenêtre rectangulaire
float2 st = (uv - 0.5) * float2(aspect, 1.0); // centré, ratio correct (1/2 pixel)
// dégradé radial au centre
float r = length(st);
float c = smoothstep(0.5, 0.0, r);
return float4(c, c * 0.4, 1.0 - c, 1.0);
}
3. Recevoir le buffer dans le shader MSL (struct)
/// C++ — Renderer.cpp — update:18/05/26 — dans draw(), avant les draw calls
renderCommandEncoder->setViewport(m_viewport);
renderCommandEncoder->setRenderPipelineState(m_renderPipelineState.get());
renderCommandEncoder->setVertexBuffer(m_windowSizeBuffer.get(), 0, 1); // slot 1, uniforms
renderCommandEncoder->setFragmentBuffer(m_windowSizeBuffer.get(), 0, 1); // même buffer, même slot
/// MSL — FullScreen.metal — update:18/05/26 — d'abord un triangle prenant la ~moitié de l'écran
#include <metal_stdlib> // Toujours inclure
#include "../includes/SharedGPU/Renderer_shared.h"
using namespace metal; // Toujours inclure
struct VertexOut
{
float4 position [[position]];
};
vertex VertexOut vertex_main(uint vid [[vertex_id]],
constant WindowUniforms& uniforms [[buffer(1)]])
{
constexpr float2 positions[3] = {
{ -1.0, 1.0 },
{ 1.0, -1.0 },
{ -1.0, -1.0 },
};
VertexOut out;
out.position = float4(positions[vid], 0.0, 1.0);
return out;
}
fragment float4 fragment_main(VertexOut in [[stage_in]])
{
return float4(0.4, 1.0, 1.0, 1.0);
}
Un shader est un programme qui tourne sur le GPU. Le fichier a l'extension .metal et contient du MSL — Metal Shading Language, un dialecte de C++14.
Un seul triangle de 3 sommets avec des positions exagérées (-1, 3 et 3, -1) couvre l'intégralité du viewport après clipping. C'est plus efficace qu'un quad (2 triangles) et évite un artefact sur la diagonale. Technique standard dans les moteurs AAA.
Tu peux essayer les 2 shaders mais tu dois changer les noms des fonctions MSL dans buildPipeline(). (Le rendu sera identique excepté le dégradé radial).
Aperçu Metal 4
/// MSL — Exemple — update:18/05/26 — Metal 4 — ArgumentTable Alloc/Init
{
auto _residencySetDescriptor = NS::TransferPtr(MTL::ResidencySetDescriptor::alloc()->init());
_residencySetDescriptor->setLabel(MTLSTR("ResidencySetDescriptor")); // Facultatif
NS::Error* error = nullptr;
MTL::ResidencySet* _residencySet = device->newResidencySet(_residencySetDescriptor.get(), &error);
_residencySet->requestResidency(); // MTL::ResidencySet* ; serait en membre de l'objet.
_residencySet->addAllocation(m_windowSizeBuffer.get());
_residencySet->commit();
m_commandQueue->addResidencySet(_residencySet);
}
auto remplace ici NS::SharedPtr<MTL::ResidencySetDescriptor>.
/// MSL — Exemple — update:18/05/26 — Metal 4 — ArgumentTable
MTL4::ArgumentTable* _argumentTable->setAddress(m_windowSizeBuffer->gpuAddress(), 1);
Fournit un mécanisme pour gérer et fournir des liaisons de ressources pour les tampons, les textures, les états de l'échantillonneur et d'autres ressources Metal. C'est plus optimisé. On y reviendra dans les prochains chapitres.
Le pipeline de rendu
Un pipeline de rendu Metal se configure avant de dessiner quoi que ce soit. Il encode les règles du rendu dans un objet compilé une seule fois — le MTL::RenderPipelineState. Chaque frame l'utilise sans recompilation.
L'objectif est de définir comment le GPU doit traiter la géométrie, c'est également l'endroit où l'échantillon spécifie les shaders de sommets et de fragments.
Les shaders sont de petits programmes qui spécifient le fonctionnement du GPU, le pipeline désigne les shaders qui s'en charge.
Le MTLRenderPipelineDescriptor est le plan du pipeline. On le remplit, on compile, on garde le résultat.
Les shaders MSL communiquent avec le programme C++ via des tampons mémoire (buffers) lisibles et modifiables par le CPU.
Déclaration — notre configuration
/// C++ — Renderer.hpp — update:18/05/26
private:
void buildPipeline();
// …
NS::SharedPtr<MTL::RenderPipelineState> m_renderPipelineState;
buildPipeline()→ Notre fonction qui sera appelée une seule fois, afin de compiler les fonctions de shaders écritent en MSL.MTLRenderPipelineState→ L'objet Metal qui reçoit les règles de rendu déjà compilé.
/// C++ — Renderer.cpp — update:18/05/26
void Renderer::buildPipeline()
{
auto vertexFunction = NS::TransferPtr(m_shaderLibrary->newFunction(MTLSTR("vertex_main")));
auto fragmentFunction = NS::TransferPtr(m_shaderLibrary->newFunction(MTLSTR("fragment_main")));
auto renderPipelineDescriptor = NS::TransferPtr(MTL::RenderPipelineDescriptor::alloc()->init());
renderPipelineDescriptor->setVertexFunction(vertexFunction.get());
renderPipelineDescriptor->setFragmentFunction(fragmentFunction.get());
// Format de pixels qui doivent correspondrent à MTKView
renderPipelineDescriptor->colorAttachments()->object(0)->setPixelFormat(m_pixelFormat);
renderPipelineDescriptor->setDepthAttachmentPixelFormat(m_depthPixelFormat);
// Gestion erreur potentielle "error->localizedDescription()" contient le message d'erreur
NS::Error* error = nullptr;
m_renderPipelineState = NS::TransferPtr(m_device->newRenderPipelineState(renderPipelineDescriptor.get(), &error));
assert(!error && "Pipeline creation failed");
}
vertex_main→ Notre futur fonction MSL qui est compilée via la méthode newFunction() appartenant à la classe MTLLibrary.1ier & 2ième auto→ Remplace MTL::Function* ou NS::SharedPtr<MTL::Function> dans le cas d'utilisation des pointeurs intelligents depuis C++17. La valeur remplacée est la valeur renvoyée par la méthode qui compile les fonctions de shaders.3ième auto→ Remplace NS::SharedPtr<MTL::RenderPipelineDescriptor>.get()→ Récupère le pointeur intelligent qui libère sa mémoire automatiquement.setDepthAttachmentPixelFormat()→ N'est nécessaire que lorsque l'on déclare un format de pixel du type pour notre drawable (pour les projets avancé).m_device->newRenderPipelineState()→ Le protocole GPU utilise sa méthode/fonctionnalité pour que les données soient prêtes à être ajoutée en file d'attente pour le rendu.
/// C++ — Renderer.cpp — update:18/05/26 — dans le constructeur (allocation = une fois) — après le buffer
{
buildPipeline();
}
Les étapes que tu configures avant de dessiner le rendu 3D :
Données 3D (vertices)
│
▼
┌────────────────┐
│ Vertex Stage │ ← ton vertex shader (MSL) : transforme chaque sommet
└───────┬────────┘
│ positions en clip space
▼
┌────────────────┐
│ Rasterization │ ← GPU automatique : convertit triangles → fragments (pixels)
└───────┬────────┘
│ fragments (pixels potentiels)
▼
┌────────────────┐
│ Fragment Stage │ ← ton fragment shader (MSL) : calcule la couleur finale de chaque pixel
└───────┬────────┘
│
▼
Framebuffer (image finale à l'écran)
En blanc, la partie de pixels à utiliser.
En bleu, notre fragment shader - Notre écran possède 3024 × 1964 pixels, le résultat de la rastérisation sera bien plus précis.
Ce que Metal ajoute : le TBDR
Sur Apple Silicon, la rastérisation différée par tuile signifie que le GPU découpe le framebuffer en tuiles (typiquement 32×32 pixels), résout la visibilité (hidden surface removal) avant d'exécuter les fragment shaders. Résultat : les fragments cachés ne coûtent rien en compute.
/// MSL — FullScreen.metal — update:18/05/26
vertex VertexOut vertex_main(uint vid [[vertex_id]],
constant WindowUniforms& uniforms [[buffer(1)]])
{
// triangle plein-écran sans vertex buffer (3 sommets suffisent)
constexpr float2 positions[3] = {
{ -1.0, 3.0 },
{ 3.0, -1.0 },
{ -1.0, -1.0 },
};
VertexOut out;
out.position = float4(positions[vid], 0.0, 1.0);
return out;
}
GPU vs CPU
Le GPU n'est pas un CPU plus rapide — c'est une machine fondamentalement différente, conçue autour d'une idée unique : faire la même chose sur des milliers de données en même temps.
Un CPU est conçu pour le débit séquentiel : peu de cœurs (4-32), très rapides, avec des caches énormes et une logique de prédiction de branchement sophistiquée. Il excelle à exécuter du code complexe, avec beaucoup de conditions, de dépendances, de sauts.
Un GPU est l'inverse : des milliers de cœurs simples, moins rapides individuellement, mais capables d'exécuter le même programme sur des milliers d'éléments simultanément. C'est du SIMD — Single Instruction, Multiple Data.
Unité de mesure qui indique la puissance de calcul d'un processeur (CPU ou GPU). Il représente mille milliards (1 000 000 000 000) d'opérations en virgule flottante (calculs avec des nombres à virgule) par seconde
La puce M2 Pro offre une puissance de calcul graphique allant jusqu'à 5,7 TFLOPS pour la déclinaison d'entrée de gamme à 16 cœurs.
L'architecture interne du GPU Apple Silicon M2 Pro est un GPU tile-based deferred renderer (TBDR). C'est une architecture spécifique à Apple (et ARM Mali) qui change la façon d'optimiser le rendu.
/// C++ — Renderer.cpp — update:18/05/26 — remplaces les noms des fonctions MSL
void Renderer::buildPipeline()
{
auto vertexFunction = NS::TransferPtr(m_shaderLibrary->newFunction(MTLSTR("vertex_main_radial")));
auto fragmentFunction = NS::TransferPtr(m_shaderLibrary->newFunction(MTLSTR("fragment_main_radial")));
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");
}
Le Scissor — zone de rendu stricte
Le scissor est un rectangle en pixels qui coupe net tout ce qui est en dehors — là où le viewport transforme, le scissor coupe. Optionnel, mais utile pour les UIs, les shadow maps, le rendu partiel :
MTL::ScissorRect scissor;
scissor.x = 0; // Les 4 données de la structure Metal sont des NS::UInteger, pas de cast nécessaire
scissor.y = 0;
scissor.width = m_width;
scissor.height = m_height;
renderCommandEncoder->setScissorRect(scissor); // après setViewport()
Toujours dans cet ordre : setViewport → setScissorRect → setRenderPipelineState → draw calls. Changer le viewport ou le scissor après un draw call l'applique aux draws suivants seulement.
Points clés à retenir ;
- buffer(1)
→ l'index de slot du buffer. Le slot 0 est souvent réservé au vertex buffer. On commence les uniforms au slot 1 par convention. - Triangle plein-écran
→ 3 sommets, 0 vertex buffer, 1 draw call. Pattern standard pour les passes de post-processing et les shaders procéduraux. - ScissorRect
→ coordonnées en pixels entiers (NS::UInteger), doit rester dans les bornes du drawable — sinon validation error au runtime.