Annexe R · Raycasting

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 + ...)
Pourquoi normalize() est crucial
La combinaison linéaire 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
Sample Apple correspondant
↗ Performing Calculations on a GPU — la base du compute shader. Le raycasting complet avec BVH est couvert dans ↗ Metal 4 — Raytracing avec BVH.