Le Blit Pass — déplacer des octets sans dessiner
Un blit pass ne capture pas l'écran, ne diffère rien, et n'améliore aucun rendu. Il ne produit pas un seul pixel. C'est un moteur de copie : il déplace des octets d'une ressource GPU vers une autre.
L'intuition « capturer ce qui est à l'écran » n'est pas absurde pour autant — la capture d'écran est un cas d'usage réel du blit (voir plus bas). Mais c'est un cas parmi dix, pas la définition.
Blit = BLock Transfer
Le mot vient de BitBLT — Bit Block Transfer — une instruction du Xerox Alto en 1975 qui copiait un rectangle de pixels d'une zone mémoire vers une autre. Cinquante ans plus tard, le nom est resté pour désigner exactement la même chose : une copie mémoire exécutée par le GPU.
Deux idées à retenir :
Ce qu'un blit fait vraiment ;
- Aucun shader
→ tu n'écris ni vertex, ni fragment, ni kernel. C'est un moteur DMA dédié dans le silicium, piloté par des commandes, pas par du MSL. - Sur le timeline GPU
→ le blit est encodé dans le même MTL::CommandBuffer que tes passes de rendu. Il s'exécute dans l'ordre d'encodage, pas sur le CPU, pas en parallèle sauvage.
Le troisième type d'encodeur
Tu connais déjà deux des trois. Le blit est le dernier morceau :
| Encodeur | Créé par | Fonction GPU | Produit |
|---|---|---|---|
RenderCommandEncoder |
renderCommandEncoder() |
vertex + fragment | des pixels dans un attachment |
ComputeCommandEncoder |
computeCommandEncoder() |
kernel | des données dans un buffer / une texture |
BlitCommandEncoder |
blitCommandEncoder() |
aucune — moteur de copie | rien de neuf, juste des octets déplacés |
Le motif est identique aux deux autres : créer, encoder, terminer. Un seul encodeur peut être actif à la fois sur un command buffer donné.
/// C++ — n'importe où dans draw() — update:13/09/26
MTL::BlitCommandEncoder* blitCommandEncoder = commandBuffer->blitCommandEncoder();
blitCommandEncoder->setLabel(MTLSTR("Upload terrain")); // visible dans le GPU debugger Xcode
// ... une ou plusieurs commandes de copie ...
blitCommandEncoder->endEncoding(); // obligatoire avant d'ouvrir un autre encodeur
setLabel() ne coûte rien en release et te sauve trente minutes dans le Metal Frame Capture — les passes anonymes y sont indiscernables les unes des autres. Prends l'habitude sur les trois types d'encodeurs.
Ce que sait faire un blit encoder
Les opérations ;
- copyFromBuffer → Buffer
→ copie brute d'une plage d'octets. Le cas du staging (Shared → Private). - copyFromBuffer → Texture
→ upload d'une image décodée en RAM vers une texture GPU. Nécessite bytesPerRow et bytesPerImage. - copyFromTexture → Texture
→ copie région à région, avec slice et mip level. Les deux formats doivent être compatibles en taille de texel. - copyFromTexture → Buffer
→ le chemin de retour GPU → CPU. Screenshot, readback de depth, debug de G-Buffer. - generateMipmaps
→ génère toute la chaîne de mips depuis le niveau 0, en hardware. Une milliseconde pour du 1024×1024. - fillBuffer
→ remplit une plage avec une valeur 8 bits. La façon propre de remettre un compteur atomique à zéro entre deux frames. - optimizeContentsForGPUAccess
→ demande au driver de réorganiser le layout interne (swizzling, compression lossless) après une écriture CPU. - synchronizeResource
→ macOS / StorageModeManaged uniquement. Rapatrie la copie GPU vers la copie CPU. Inexistant sur Apple Silicon. - updateFence / waitForFence
→ synchronisation manuelle quand le hazard tracking automatique est désactivé (MTL::Heap untracked).
Cas 1 — Staging : Shared → Private
C'est le cas d'usage principal, et celui que le chapitre sur les formats de pixel annonçait sans l'expliquer.
Le problème : le CPU ne peut écrire que dans une ressource Shared (ou Managed). Le GPU, lui, préfère franchement le Private — layout interne optimal, compression lossless activable, aucune contrainte de cohérence avec le CPU. Le blit est le pont entre les deux.
/// C++ — Renderer.hpp — update:13/09/26
private:
NS::SharedPtr<MTL::Buffer> makePrivateBuffer(const void* data, size_t size,
MTL::CommandBuffer* commandBuffer);
/// C++ — Renderer.cpp — update:13/09/26
NS::SharedPtr<MTL::Buffer> Renderer::makePrivateBuffer(const void* data, size_t size,
MTL::CommandBuffer* commandBuffer)
{
// 1. buffer temporaire accessible CPU, rempli à la création
auto staging = NS::TransferPtr(m_device->newBuffer(data, size, MTL::ResourceStorageModeShared));
// 2. buffer final, VRAM optimisée, inaccessible au CPU
auto privateBuffer = NS::TransferPtr(m_device->newBuffer(size, MTL::ResourceStorageModePrivate));
// 3. le GPU recopie l'un dans l'autre
MTL::BlitCommandEncoder* blitCommandEncoder = commandBuffer->blitCommandEncoder();
blitCommandEncoder->setLabel(MTLSTR("Staging upload"));
blitCommandEncoder->copyFromBuffer(staging.get(), 0, privateBuffer.get(), 0, size);
blitCommandEncoder->endEncoding();
return privateBuffer;
}
Non. Un MTL::CommandBuffer retient toutes les ressources référencées par ses commandes jusqu'à la fin de son exécution. Le TransferPtr relâche ta référence à la sortie de la fonction, Metal garde la sienne. Le buffer est libéré une fois la copie réellement terminée.
Cette règle tombe si tu utilises un MTL::Heap en HazardTrackingModeUntracked — là c'est à toi de gérer la durée de vie.
Mémoire unifiée ≠ blit inutile. Sur Apple Silicon, CPU et GPU partagent la même RAM physique : un blit Shared→Private ne traverse aucun bus PCIe, contrairement à un GPU discret. Mais le gain reste réel — le driver peut réorganiser le layout (swizzling en tuiles), activer le Delta Color Compression, et cesser de garantir la cohérence des caches CPU. Pour une donnée écrite une fois et lue à chaque frame (mesh statique, atlas de texture), ça vaut le détour.
Pour une donnée réécrite à chaque frame par le CPU (uniformes, matrices de caméra), reste en Shared : le blit coûterait plus que le gain.
Cas 2 — generateMipmaps()
Une texture chargée depuis le disque n'a que son niveau 0. Sans mips, tout objet éloigné aliase violemment. La génération complète tient en une ligne :
/// C++ — au chargement d'une texture — update:13/09/26
auto _textureDescriptor = NS::TransferPtr(MTL::TextureDescriptor::alloc()->init());
_textureDescriptor->setPixelFormat(MTL::PixelFormatRGBA8Unorm_sRGB);
_textureDescriptor->setWidth(width);
_textureDescriptor->setHeight(height);
_textureDescriptor->setMipmapLevelCount(floor(log2(fmax(width, height))) + 1); // indispensable
_textureDescriptor->setUsage(MTL::TextureUsageShaderRead);
_textureDescriptor->setStorageMode(MTL::StorageModePrivate);
MTL::BlitCommandEncoder* blitCommandEncoder = commandBuffer->blitCommandEncoder();
blitCommandEncoder->generateMipmaps(m_albedo.get());
blitCommandEncoder->endEncoding();
Le GPU réduit chaque niveau par filtrage box successif, jusqu'au mip 1×1. Si setMipmapLevelCount() vaut 1 (la valeur par défaut), generateMipmaps() ne fait rien du tout — silencieusement. C'est l'erreur classique.
Cas 3 — Readback GPU → CPU
Ta texture de profondeur m_depth est en StorageModePrivate. Le CPU n'a donc aucun moyen de la lire : il n'existe pas de contents() sur une texture, et même s'il en existait, le mode Private l'interdirait. Le blit est le seul chemin.
/// C++ — Renderer.cpp — update:13/09/26 — lecture complète de la depth
const NS::UInteger width = m_depth->width();
const NS::UInteger height = m_depth->height();
const NS::UInteger bytesPerRow = width * sizeof(float); // Depth32Float → 4 octets
MTL::BlitCommandEncoder* blitCommandEncoder = commandBuffer->blitCommandEncoder();
blitCommandEncoder->copyFromTexture(m_depth.get(), 0, 0,
MTL::Origin(0, 0, 0),
MTL::Size(width, height, 1),
m_depthReadback.get(), 0, bytesPerRow, 0);
blitCommandEncoder->endEncoding();
L'ordre des paramètres : texture source, slice, mip level, origine, taille, buffer destination, offset, bytesPerRow, bytesPerImage. Le 0 final est valide pour une texture 2D — il n'y a qu'une seule « image ».
Sur macOS, une copie depuis ou vers une texture depth ou stencil impose que destinationOffset et destinationBytesPerRow soient des multiples de 256 octets. Une largeur de fenêtre de 1280 px × 4 octets = 5120, qui passe. Une largeur de 1279 px ne passe pas.
Active la Metal API Validation dans ton schéma Xcode : elle t'annonce l'erreur exacte au lieu de te laisser un buffer de zéros.
Pourquoi MouseDepthPicker n'a pas besoin de blit
C'est le point intéressant. Ton picking lit la depth sur le GPU — le kernel pickDepth échantillonne la texture Private sans problème, puisqu'il tourne côté GPU — et n'écrit qu'une structure de 32 octets dans un buffer déjà déclaré Shared :
/// C++ — Cursor3D.cpp — ta ligne actuelle
m_resultBuffer = NS::TransferPtr(device->newBuffer(sizeof(PickResult), MTL::ResourceStorageModeShared));
Le CPU peut donc appeler contents() directement. Aucune copie nécessaire. C'est exactement la bonne architecture : on fait faire la réduction au GPU (une texture entière → une struct), et on ne rapatrie que le résultat minuscule.
L'alternative naïve — blitter les 1280×720×4 octets de depth vers le CPU pour y lire un seul pixel — coûterait 3,5 Mo de transfert par frame.
Ton draw() appelle waitUntilCompleted() à chaque frame pour lire le résultat du picking. Le CPU s'arrête net et attend que le GPU ait fini — le pipelining CPU/GPU est perdu, tu payes la latence des deux en série.
Le remplacement propre est addCompletedHandler(), qui laisse le CPU continuer, et une lecture du picking décalée d'une frame. Un curseur en retard de 16 ms est invisible ; un frame time doublé, non.
/// C++ — Renderer.cpp — update:13/09/26 — draw(), à la place de waitUntilCompleted()
m_cursor3D.pick(commandBuffer, m_depth.get());
commandBuffer->addCompletedHandler([this](MTL::CommandBuffer*)
{
PickResult result = m_cursor3D.getResult(); // exécuté sur un thread Metal
m_cursorPosition3D.x = roundf(result.worldPosition.x);
m_cursorPosition3D.y = roundf(result.worldPosition.y);
m_cursorPosition3D.z = roundf(result.worldPosition.z);
});
commandBuffer->presentDrawable(view->currentDrawable());
commandBuffer->commit(); // plus de waitUntilCompleted()
Cas 4 — La capture d'écran
Voilà le cas qui correspond à l'intuition de départ. Un screenshot, c'est un blit du texture du drawable vers un buffer Shared, puis une écriture disque dans le completion handler.
Par défaut, framebufferOnly vaut true sur une MTK::View. La texture du drawable est alors utilisable uniquement comme attachment de rendu — impossible de la blitter ou de l'échantillonner. Metal refuse la commande.
// AppViewController.mm — à côté de colorPixelFormat
_mtkView.framebufferOnly = NO; // désactive une optimisation : ne le fais que si tu captures
/// C++ — Screenshot.hpp — update:13/09/26
class Screenshot
{
public:
void request() { m_requested = true; }
void capture(MTL::CommandBuffer* commandBuffer, MTL::Texture* source, MTL::Device* device);
private:
bool m_requested = false;
NS::SharedPtr<MTL::Buffer> m_readback;
};
/// C++ — Screenshot.cpp — update:13/09/26
void Screenshot::capture(MTL::CommandBuffer* commandBuffer, MTL::Texture* source, MTL::Device* device)
{
if (!m_requested)
return;
m_requested = false;
const NS::UInteger width = source->width();
const NS::UInteger height = source->height();
const NS::UInteger bytesPerPixel = 8; // RGBA16Float → 4 canaux × 2 octets
const NS::UInteger bytesPerRow = width * bytesPerPixel;
m_readback = NS::TransferPtr(device->newBuffer(bytesPerRow * height, MTL::ResourceStorageModeShared));
MTL::BlitCommandEncoder* blitCommandEncoder = commandBuffer->blitCommandEncoder();
blitCommandEncoder->setLabel(MTLSTR("Screenshot readback"));
blitCommandEncoder->copyFromTexture(source, 0, 0,
MTL::Origin(0, 0, 0),
MTL::Size(width, height, 1),
m_readback.get(), 0, bytesPerRow, bytesPerRow * height);
blitCommandEncoder->endEncoding();
MTL::Buffer* readback = m_readback.get();
commandBuffer->addCompletedHandler([readback, width, height](MTL::CommandBuffer*)
{
const uint16_t* pixels = static_cast<const uint16_t*>(readback->contents());
// half → 8 bits : tonemap + encodage sRGB, puis stbi_write_png sur un thread séparé
writePNG(pixels, width, height);
});
}
/// C++ — Renderer.cpp — update:13/09/26 — draw(), avant presentDrawable()
m_screenshot.capture(commandBuffer, view->currentDrawable()->texture(), m_device);
commandBuffer->presentDrawable(view->currentDrawable());
commandBuffer->commit();
Ta MTK::View est configurée en RGBA16Float avec un colorspace ExtendedLinearDisplayP3. Les octets que tu récupères sont donc des half linéaires pouvant dépasser 1.0, pas du sRGB 8 bits. Écrire ce buffer tel quel dans un PNG produit une image délavée et fausse — il faut tonemapper puis ré-encoder en gamma. C'est le prix de l'EDR.
Cas 5 — fillBuffer() : remettre un compteur à zéro
Dès que tu passes en rendu GPU-driven — le GPU décide lui-même combien de chunks dessiner et écrit le compte dans un buffer atomique — il faut remettre ce compteur à zéro au début de chaque frame. Le faire depuis le CPU impliquerait un buffer Shared et une synchronisation. Le blit le fait sur le timeline GPU, au bon moment :
/// C++ — Renderer.cpp — update:13/09/26 — draw(), tout au début
MTL::BlitCommandEncoder* blitCommandEncoder = commandBuffer->blitCommandEncoder();
blitCommandEncoder->setLabel(MTLSTR("Reset draw counters"));
blitCommandEncoder->fillBuffer(m_drawCountBuffer.get(), NS::Range(0, sizeof(uint32_t)), 0);
blitCommandEncoder->endEncoding();
Attention : la valeur est un octet, répété sur toute la plage. 0 donne bien un entier nul, mais 1 donnerait 0x01010101, soit 16 843 009. Seul 0 et 0xFF ont un sens évident.
Ordre d'exécution et hazards
Dans un même command buffer, les encodeurs s'exécutent dans l'ordre d'encodage. Metal insère automatiquement les barrières nécessaires entre eux : si ton blit écrit dans un buffer que la passe de rendu suivante lit, la dépendance est détectée et respectée. C'est le hazard tracking automatique.
// Ordre garanti — le render voit bien les données copiées
blit->copyFromBuffer(staging, 0, vertices, 0, size);
blit->endEncoding();
// ... renderCommandEncoder lit vertices → sûr, aucune barrière manuelle
Deux situations le désactivent : les ressources allouées dans un MTL::Heap créé en HazardTrackingModeUntracked, et les command buffers différents soumis en parallèle. Dans les deux cas, il te faut un MTL::Fence (intra-buffer) ou un MTL::Event (inter-buffer).
blit->updateFence(m_uploadFence.get());
// ... plus loin, dans un autre encodeur
computeCommandEncoder->waitForFence(m_uploadFence.get());
synchronizeResource() — la méthode que tu n'utiliseras jamais
Elle existe pour le StorageModeManaged, qui n'a de sens que sur les Mac Intel à GPU discret : la ressource y a deux copies physiques, une en RAM système et une en VRAM. synchronizeResource() rapatrie la version GPU vers la version CPU.
Sur Apple Silicon, la mémoire est unifiée, Managed n'existe pas, et appeler cette méthode sur une ressource Private est une erreur de validation. Tu la croiseras dans du vieux code d'exemple — ignore-la.
Metal 4 — le blit encoder disparaît
Dans la nouvelle API Metal 4, le MTL4ComputeCommandEncoder absorbe les trois anciens encodeurs : blit, compute et acceleration structure. Les commandes de copie, de fill et de génération de mipmaps deviennent des méthodes de l'encodeur de compute.
L'intérêt : moins d'encodeurs à créer, et des copies qui peuvent s'exécuter concurremment tant qu'elles écrivent dans des régions disjointes — avec une barrière intra-passe explicite quand il faut ordonner blit et dispatch.
Les types MTL::* classiques restent pleinement supportés et l'adoption est incrémentale : tu peux mélanger les deux. Le code de ce chapitre reste valide.
Tableau récapitulatif
| Méthode | Sens | Usage typique |
|---|---|---|
copyFromBuffer → Buffer |
GPU → GPU | Staging Shared → Private |
copyFromBuffer → Texture |
GPU → GPU | Upload d'image décodée, atlas de police |
copyFromTexture → Texture |
GPU → GPU | Copie de région, historique TAA, mip manuel |
copyFromTexture → Buffer |
GPU → CPU | Screenshot, readback de depth, debug |
generateMipmaps |
GPU | Chaîne de mips complète en hardware |
fillBuffer |
GPU | Reset de compteur atomique par frame |
optimizeContentsForGPUAccess |
GPU | Après écriture CPU sur texture Shared |
synchronizeResource |
GPU → CPU | Managed uniquement — Mac Intel |
Pièges classiques
Les erreurs à connaître ;
- endEncoding oublié
→ crash à l'ouverture de l'encodeur suivant. Un seul encodeur actif à la fois, toujours. - framebufferOnly = YES
→ toute copie depuis la texture du drawable est refusée. Le symptôme est une erreur de validation, pas une image noire. - Texture Memoryless
→ ne peut être ni source ni destination d'un blit. Elle n'existe que dans la tile memory, il n'y a rien à copier. - mipmapLevelCount = 1
→ generateMipmaps ne fait rien, sans erreur ni warning. Vérifie le descripteur. - bytesPerRow mal aligné
→ 256 octets exigés sur macOS pour les textures depth/stencil. Buffer de zéros silencieux sans la validation activée. - Formats incompatibles
→ une copie texture → texture exige des formats de même taille de texel. RGBA8Unorm vers BGRA8Unorm passe, RGBA8Unorm vers RGBA16Float non. - Depth32Float_Stencil8 vers buffer
→ il faut préciser le plan voulu via BlitOptionDepthFromDepthStencilResource ou BlitOptionStencilFromDepthStencilResource. - Régions qui se chevauchent
→ copier une zone sur elle-même avec recouvrement est un comportement indéfini. Passe par un buffer intermédiaire.
Guide des décisions
Donnée écrite une fois, lue chaque frame → Shared + blit vers Private
Donnée réécrite chaque frame par le CPU → Shared, pas de blit
Donnée produite et lue par le GPU seul → Private, jamais de blit
Résultat GPU minuscule à lire au CPU → buffer Shared écrit par le kernel (cf. Cursor3D)
Image complète à lire au CPU → copyFromTexture → Buffer + addCompletedHandler
Jamais → waitUntilCompleted() pour lire un résultat
Texture chargée depuis le disque → mipmapLevelCount > 1 + generateMipmaps
Compteur atomique GPU-driven → fillBuffer(0) en début de frame
La règle générale tient en une phrase : le blit est le seul pont entre ce que le CPU peut toucher et ce que le GPU préfère. Partout ailleurs, il est superflu.