Raycasting GPU
Le raycasting est la technique la plus fondamentale du rendu 3D — avant le rasterizer, avant les GPUs modernes, c'est comme ça qu'on rendait des scènes. Aujourd'hui on l'utilise pour des effets impossibles avec le pipeline classique : ombres exactes, réflexions, intersection analytique avec des sphères parfaites. Et sur GPU, on peut lancer un rayon par pixel — en parallèle.
L'idée
Un rayon part de l'œil (la caméra) à travers chaque pixel de l'écran. Pour chaque rayon, on teste l'intersection avec les objets de la scène. Le premier objet touché détermine la couleur du pixel.
// Vision mentale :
//
// Écran (plan image)
// ┌─────────┐
// œil ──┼──→ rayon│──────────────→ ● sphère (hit)
// │ │
// œil ──┼──→ rayon│──────────────────────────→ (miss, fond)
// └─────────┘
//
// Un thread GPU = un pixel = un rayon
Construire le rayon depuis la caméra
C'est la partie la plus importante et la moins bien expliquée ailleurs. Il faut reconstruire la direction du rayon dans l'espace monde depuis les coordonnées pixel. Ton code utilise directement les vecteurs forward, right, up de la caméra — c'est l'approche la plus propre.
// Dans raycastKernel :
// 1. Convertir le pixel en coordonnées NDC centrées
float2 uv = (float2(gid) / float2(width, height)) * 2.0 - 1.0;
// ┌──────────────────────────────────────┐
// │ pixel (0,0) → uv (-1.0, -1.0) │
// │ pixel (512, 512) → uv ( 0.0, 0.0) │
// │ pixel (1024,1024) → uv (+1.0, +1.0) │
// └──────────────────────────────────────┘
// 2. Corriger l'aspect ratio — sans ça l'image est étirée
uv.x *= float(width) / float(height);
// Pour un écran 1920×1080 : aspect = 1.777
// Ça "élargit" l'espace horizontal pour compenser l'écran large
// 3. Reconstruire la direction dans l'espace monde
float3 rayDir = normalize(
cameraUniforms.forward // direction centrale de la caméra
+ uv.x * cameraUniforms.right // déplacer horizontalement
+ uv.y * cameraUniforms.up // déplacer verticalement
);
// Note : ce modèle correspond à un FOV implicite.
// Pour un FOV explicite (ex: 60°) : multiplier par tan(fov/2)
// rayDir = normalize(forward + uv.x * tan(fov/2) * right + ...)
forward + uv.x*right + uv.y*up n'a pas forcément une longueur de 1. Sans normalize(), les rayons vers les coins de l'écran seraient plus longs que ceux vers le centre — ils parcourraient plus de distance par unité de t, créant une distorsion sphérique ("fish-eye"). Le normalize() garantit que chaque rayon avance à la même vitesse.
Intersection rayon — sphère
La sphère est l'objet de référence du raycasting parce que son intersection avec un rayon se calcule analytiquement — une équation du second degré, zéro approximation.
Un rayon est défini par son origine O et sa direction normalisée d : tout point sur le rayon est P = O + t*d pour t ≥ 0. Une sphère de centre C et de rayon r est l'ensemble des points tels que |P - C|² = r². Substituer P donne :
// |O + t*d - C|² = r²
// |oc + t*d|² = r² où oc = O - C
// dot(oc,oc) + 2t*dot(oc,d) + t²*dot(d,d) = r²
//
// Comme d est normalisé : dot(d,d) = 1
// t² + 2t*dot(oc,d) + (dot(oc,oc) - r²) = 0
//
// Forme canonique at² + bt + c = 0 avec a=1 :
// b = dot(oc, d) ← projection de oc sur la direction
// c = dot(oc, oc) - r² ← distance² du centre moins r²
// discriminant h = b² - c
//
// h < 0 → pas d'intersection (rayon manque la sphère)
// h = 0 → tangent (un seul point de contact)
// h > 0 → deux intersections t0 et t1
// Ton intersectSphere — l'implémentation optimisée :
float intersectSphere(float3 rayOrigin, float3 rayDir, Sphere sphere)
{
float3 oc = rayOrigin - sphere.center;
float b = dot(oc, rayDir); // = b/2 (demi-discriminant)
float c = dot(oc, oc) - sphere.radius * sphere.radius;
float h = b * b - c; // discriminant
if (h < 0.0) return -1.0; // miss
float sqrtH = sqrt(h);
float t0 = -b - sqrtH; // intersection proche
float t1 = -b + sqrtH; // intersection lointaine
if (t0 > 0.001) return t0; // cas normal — on est dehors
if (t1 > 0.001) return t1; // on est DANS la sphère
return -1.0; // sphère derrière la caméra
}
// Pourquoi 0.001 et pas 0 ?
// "Acne" numérique : le point d'intersection calculé peut être
// légèrement à l'intérieur de la surface (erreurs float).
// Le rayon secondaire (ombre, réflexion) s'intersecterait alors
// avec la sphère elle-même → points noirs parasites.
// Le seuil de 0.001 (epsilon) évite ce cas.
Le kernel principal — trouver la sphère la plus proche
kernel void raycastKernel(
device const Sphere* spheres [[buffer(0)]],
device const RMDLCameraUniforms& cameraUniforms [[buffer(1)]],
device const uint32_t& sphereCount [[buffer(2)]],
texture2d<float, access::write> output [[texture(0)]],
uint2 gid [[thread_position_in_grid]])
{
if (gid.x >= output.get_width() || gid.y >= output.get_height()) return;
// Construction du rayon (expliqué plus haut)
float2 uv = (float2(gid) / float2(width, height)) * 2.0 - 1.0;
uv.x *= float(width) / float(height);
float3 rayDir = normalize(cameraUniforms.forward
+ uv.x * cameraUniforms.right
+ uv.y * cameraUniforms.up);
// Chercher la sphère la plus proche
float tClosest = 1e9;
int hitIdx = -1;
for (uint i = 0; i < sphereCount; i++) {
float t = intersectSphere(cameraUniforms.position, rayDir, spheres[i]);
if (t > 0.0 && t < tClosest) {
tClosest = t;
hitIdx = int(i);
}
}
// Calculer la couleur
float3 finalColor = float3(0.0); // fond noir
if (hitIdx >= 0) {
Sphere hit = spheres[hitIdx];
float3 hitPoint = cameraUniforms.position + rayDir * tClosest;
// Normale : du centre vers le point de surface
float3 N = normalize(hitPoint - hit.center);
// Éclairage Lambertien (diffus pur)
float3 L = normalize(float3(0.6, 1.0, 0.4));
float diff = max(dot(N, L), 0.0);
finalColor = hit.color * 0.15 + hit.color * diff; // ambient + diffuse
}
output.write(float4(finalColor, 1.0), gid);
}
La normale — pourquoi c'est si simple pour une sphère
La normale en un point de surface est le vecteur perpendiculaire à la surface en ce point. Pour une sphère, c'est trivial : la normale en tout point est le vecteur qui va du centre au point. C'est une propriété géométrique fondamentale des sphères.
float3 hitPoint = cameraUniforms.position + rayDir * tClosest;
float3 N = normalize(hitPoint - hit.center);
// └─────────────────────┘
// vecteur centre → surface
// sa longueur = radius, normalisé → unitaire
// Pour visualiser les normales directement (utile pour débugger) :
finalColor = N * 0.5 + 0.5; // remapper [-1,1] → [0,1]
// normal.x → rouge (gauche=noir, droite=rouge)
// normal.y → vert (bas=noir, haut=vert)
// normal.z → bleu (loin=noir, proche=bleu)
Éclairage Lambertien — la physique du diffus
Le modèle de Lambert dit que l'intensité lumineuse reçue par une surface est proportionnelle au cosinus de l'angle entre la normale et la direction de la lumière. C'est une loi physique réelle — une surface tenue à 90° de la lumière reçoit zéro énergie.
float3 L = normalize(float3(0.6, 1.0, 0.4)); // direction lumière (vers la lumière)
float diff = max(dot(N, L), 0.0);
// └───────────┘
// cosinus de l'angle N^L
// max(..., 0) : pas d'éclairage négatif (face cachée)
// Décomposition de la couleur finale :
float3 ambient = hit.color * 0.15; // lumière ambiante — évite le noir total
float3 diffuse = hit.color * diff; // lumière directionnelle
finalColor = ambient + diffuse;
// → zones éclairées : 0.15 + 1.0 = 1.15 (légèrement surexposé possible)
// → zones à 90° : 0.15 + 0.0 = 0.15 (sombre mais pas noir)
// → zones cachées : 0.15 + 0.0 = 0.15 (même chose — pas d'ombres portées)
Intégrer le raycast dans le pipeline Metal
Le raycast est un compute shader — il écrit dans une texture. Cette texture peut ensuite être affichée en fullscreen quad, ou composée avec le rendu raster.
// Renderer.cpp — initialisation
void Renderer::buildRaycastPipeline(MTL::Library* lib)
{
// PSO compute
MTL::ComputePipelineDescriptor* cpd =
MTL::ComputePipelineDescriptor::alloc()->init();
cpd->setComputeFunction(lib->newFunction(MTLSTR("raycastKernel")));
NS::Error* err = nullptr;
m_p_raycastPSO = m_p_device->newComputePipelineState(cpd, 0, nullptr, &err);
cpd->release();
// Texture de sortie — même taille que le drawable
NS::SharedPtr<MTL::TextureDescriptor> td =
NS::TransferPtr(MTL::TextureDescriptor::alloc()->init());
td->setTextureType(MTL::TextureType2D);
td->setPixelFormat(MTL::PixelFormatRGBA16Float);
td->setWidth(m_width);
td->setHeight(m_height);
td->setUsage(MTL::TextureUsageShaderWrite | MTL::TextureUsageShaderRead);
td->setStorageMode(MTL::StorageModePrivate);
m_p_raycastTex = m_p_device->newTexture(td.get());
// Buffer de sphères
m_p_sphereBuf = m_p_device->newBuffer(
sizeof(Sphere) * MAX_SPHERES,
MTL::ResourceStorageModeShared);
}
// draw() — passe compute :
void Renderer::encodeRaycast(MTL::CommandBuffer* cmd)
{
MTL::ComputeCommandEncoder* enc = cmd->computeCommandEncoder();
enc->setComputePipelineState(m_p_raycastPSO);
enc->setBuffer(m_p_sphereBuf, 0, 0);
enc->setBuffer(m_p_camUniBuf, 0, 1); // RMDLCameraUniforms
enc->setBuffer(m_p_sphereCount, 0, 2);
enc->setTexture(m_p_raycastTex, 0);
MTL::Size grid = MTL::Size::Make(m_width, m_height, 1);
MTL::Size tg = MTL::Size::Make(16, 16, 1); // 256 threads/groupe
enc->dispatchThreads(grid, tg);
enc->endEncoding();
}
Extensions — ce qu'on peut ajouter
Ombres portées
// Depuis le point d'impact, lancer un rayon vers la lumière
// Si ce rayon intersecte quelque chose avant la lumière → ombre
float3 shadowOrigin = hitPoint + N * 0.001f; // décaler pour éviter l'acne
float3 shadowDir = L;
bool inShadow = false;
for (uint i = 0; i < sphereCount; i++) {
float t = intersectSphere(shadowOrigin, shadowDir, spheres[i]);
if (t > 0.001f) { inShadow = true; break; }
}
float shadow = inShadow ? 0.0f : 1.0f;
finalColor = hit.color * 0.15 + hit.color * diff * shadow;
Réflexions
// Rebondir le rayon selon la normale — miroir parfait
float3 reflectDir = reflect(rayDir, N); // MSL natif
// Lancer un nouveau rayon depuis hitPoint + N*epsilon
// Limiter la profondeur de récursion (2-4 niveaux suffisent)
// Note : les shaders MSL ne sont PAS récursifs.
// On "déroule" la récursion manuellement avec une boucle :
float3 throughput = float3(1.0); // atténuation cumulée
float3 origin = cameraUniforms.position;
float3 dir = rayDir;
for (int bounce = 0; bounce < 4; bounce++) {
// ... intersection ...
if (hitIdx < 0) break;
throughput *= hit.color; // teinte de chaque rebond
origin = hitPoint + N * 0.001f;
dir = reflect(dir, N);
}
Anti-aliasing — supersampling
// Lancer N rayons par pixel avec un jitter sub-pixel
float3 color = float3(0.0);
const int N = 4; // 4 samples → qualité ×2 pour ×4 coût
for (int s = 0; s < N; s++) {
uint h = pcg(gid.x ^ (gid.y >> 16) ^ (uint)frame * 1337 + s);
float2 jitter = float2(
hashToFloat(h),
hashToFloat(pcg(h))
) - 0.5f;
float2 uv_j = ((float2(gid) + jitter) / float2(w,h)) * 2.0 - 1.0;
uv_j.x *= aspect;
float3 rayDir_j = normalize(cam.forward + uv_j.x*cam.right + uv_j.y*cam.up);
color += trace(cam.position, rayDir_j, spheres, sphereCount);
}
color /= float(N);
Raycasting vs Rasterization
| Rasterization | Raycasting | |
|---|---|---|
| Coût | O(triangles) — très rapide | O(pixels × objets) — coûteux |
| Ombres | Shadow maps — approximation | Exactes par construction |
| Réflexions | Cubemaps — approximation | Exactes, récursives |
| Formes | Triangles seulement | Analytique (sphères, plans, SDF…) |
| Utilisation | Jeux temps réel | Effets spéciaux, raytracing hybride |
Struct Sphere et alignas(16)
struct alignas(16) Sphere
{
float3 center; // 12 bytes
float radius; // 4 bytes → total 16 (aligné)
float3 color; // 12 bytes
float _pad; // 4 bytes de padding implicite → total 16
};
// alignas(16) : Metal exige que les membres de buffer soient alignés sur 16 bytes
// float3 fait 12 bytes — sans alignas, le compilateur peut placer color
// à l'offset 12, mais Metal attend 16 → données corrompues côté shader
// Alternative MSL — utiliser float4 pour éviter le problème :
struct Sphere {
float4 centerRadius; // xyz = center, w = radius — 16 bytes
float4 colorPad; // xyz = color, w = unused — 16 bytes
};
// Cette version ne nécessite pas alignas — elle est naturellement alignée