Ray Tracing en DarkBASIC : pourquoi pas ?

Quelques temps après avoir redécouvert et un peu trier mes petits projets DarkBASIC, je suis tombé sur une vidéo de Sebastian Lague assez spéciale.

Sebastian Lague’s Coding Adventure: Ray Tracing

Dans cette vidéo, Sebastian essaye d’implémenter la technique de rendu lumineux appelée Ray Tracing sous Unity.

Au lieu de simuler des rayons partant des sources de lumière et calculer les rayons qui arrivent à la caméra, cette technique consiste à simuler des rayons partant de la caméra. Ces rayons vont rebondir sur les différents objets de la scène 3D jusqu’à éventuellement touché des sources lumineuses. On peut grâce à cette technique obtenir un éclairage beaucoup plus réaliste d’une scène 3D.

Me viens alors une idée qui me semble intéressante : pourquoi ne pas tester de faire ça en DarkBASIC ?

Les premières difficultés

Tout le long de la vidéo, Sebastian crée ce qu’on appelle des shaders. Les shaders sont des scripts dans un langage particulier qui seront exécutées par la carte graphique afin de gagner en vitesse d’exécution grâce à la parallélisation de l’exécution. En effet, les shaders vont pouvoir être exécuté pour chaque pixel de manière pratiquement instantané. Unity donne les outils pour cela.

Or en DarkBASIC, le concept de shaders n’existe pas. Il faut donc calculer avec le CPU la valeur de chaque pixel et envoyer une instruction pour pouvoir afficher les différents pixels.

Le premier test a alors consister à dessiner des pixels de couleur uniforme sur tout l’écran. Et là, la désillusion : 20 minutes pour dessiner une image noire 640×480 pixels ! Heureusement, quelques temps après, je découvre l’option (que dis-je… LA valeur) à changer pour obtenir une vitesse d’affichage plus raisonnable à une vingtaine de secondes pour une image 1920×1080. On ne va pas se mentir, cela reste horriblement long, mais je suis soulagé à ce moment-là.

Mathématiques en DarkBASIC

Pour implémenter la méthode du ray tracing, on a besoin de calculer les coordonnées de chacun des pixels de l’écran par rapport au monde 3D. En effet, les rayons partiront de la caméra et seront diriger vers le monde en passant par chacun des pixels. La caméra du moteur 3D possède plusieurs paramètres dont le FOV et des valeurs de distance de rendu. A partir de ces paramètres, de la résolution de l’écran et de formules trigonométriques, on est capable de calculer la position de chaque pixel de la caméra dans le référentiel 3D du monde, c’est-à-dire dans ses coordonnées.

En résumé :

  • A partir de la position et des angles de la caméra sur les trois axes XYZ, on calcule le référentiel de coordonnées local à la caméra
  • A partir de ce référentiel, et ce pour chacun des pixels de l’écran, on calcule la position du pixel dans le référentiel absolu
  • En soustrayant les coordonnées de chaque pixel par la position de la caméra, et en normalisant le résultat, on obtient le vecteur directionnel du rayon.

Une fois à cette étape, on peut ensuite calculer les collisions du rayon avec les différents éléments du jeu pour savoir à quelle couleur doit afficher chacun des pixels.

Mathématiques : les vecteurs

Unity permet de manipuler facilement des vecteurs et possède toutes les fonctions trigonométriques usuelles. DarkBASIC possède quant à lui des fonctions trigonométriques basiques mais où tous les angles sont attendus en degrés. Donc il faut pouvoir convertir les angles radians en degrés. Malheureusement, la valeur de PI n’est pas implémentée. Pas besoin d’avoir une valeur précise, nous allons utiliser une approximation par fraction pour avoir 10 décimales de Pi. Un petit aperçu des différences entre les implémentations Unity et DarkBASIC :

Cliquez pour voir le code

rem DarkBASIC (DBA)
width = 1920
height = 1080

camfov# = 3.14/2.905
camNearClipPlane# = 1.0
camFarClipPlane# = 3000.0
set camera fov camfov#
set camera range camNearClipPlane#,camFarClipPlane#
set camera view 0,0,width,height

dim bottomLeftLocal#(3)
PI# = 104348.0/33215.0
planeHeight# = camNearClipPlane# * TAN(camfov#*0.5*180.0/PI#) * 2
planeWidth# = (planeHeight#*width)/height
bottomLeftLocal#(1) = -1*planeWidth#/2.0
bottomLeftLocal#(2) = -1*planeHeight#/2.0
bottomLeftLocal#(3) = camNearClipPlane#
// Unity (C#)
Camera cam = Camera.main;

float planeHeight = cam.nearClipPlane * Tan(cam.fieldOfView * 0.5f * Deg2Rad) * 2;
float planeWidth = planeHeight * cam.aspect;

Vector3 bottomLeftLocal = new Vector3(-planeWidth / 2, -planeHeight / 2, cam.nearClipPlane);

De plus, le DarkBASIC n’a pas de concept de vecteur. Il permet de manipuler des tableaux cependant. On peut en créer de dimensions 3 ce qui va nous permettre de manipuler des pseudo-vecteurs.

Unity permet d’accéder directement à tous les paramètres de la caméra, et notamment aux vecteurs directionnels relatifs à la caméra (« devant », « en haut », et « à droite ») qui ne sont pas disponibles dans DarkBASIC, il faut donc les créer. On les rend disponibles dans trois matrices camright# camup# et camforward# via la fonction BuildLocalCoordinateSystem.

Cliquez ici pour voir le détail mathématique et la fonction BuildLocalCoordinateSystem

On souhaite déterminer le référentielle locale à la caméra. Pour cela nous devons trouver les coordonnées des vecteurs ‘DEVANT’, ‘HAUT’ et ‘DROITE’ relatif à la caméra, qui seront les vecteurs ‘Z’, ‘Y’ et ‘X’.

Sachant que :
– ‘a’ est l’angle de la caméra sur l’axe X
– ‘b’ est l’angle de la caméra sur l’axe Y
– ‘c’ est l’angle de la caméra sur l’axe Z

    \[ R_Z(c) =  \begin{bmatrix} \cos(c) & -\sin(c) & 0 \\ \sin(c) & \cos(c) & 0 \\ 0 & 0 & 1 \end{bmatrix} \]

    \[ R_Y(b) =  \begin{bmatrix} \cos(b) & 0 & \sin(b) \\ 0 & 1 & 0 \\ -\sin(b) & 0 & \cos(b) \end{bmatrix} \]

    \[ R_X(a) =  \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos(a) & -\sin(a) \\ 0 & \sin(a) & \cos(a) \end{bmatrix} \]

    \[ R_{ZYX} = R_Z \cdot R_Y \cdot R_X =  \begin{bmatrix} \cos(b)\cos(c) & \sin(a)\sin(b)\cos(c) - \cos(a)\sin(c) & \cos(a)\sin(b)\cos(c) + \sin(a)\sin(c) \\ \cos(b)\sin(c) & \sin(a)\sin(b)\sin(c) + \cos(a)\cos(c) & \cos(a)\sin(b)\sin(c) - \sin(a)\cos(c) \\ -\sin(b) & \sin(a)\cos(b) & \cos(a)\cos(b) \end{bmatrix} \]

On arrive donc à l’implémentation suivante de la fonction, qui crée le référentiel local :

function BuildLocalCoordinateSystem(cama#,camb#,camc#)
	rem On construit le vecteur 'forward' à partir d'une opération de multiplication de matrice, puis on normalise
	camforward#(1) = COS(cama#)*SIN(camb#)*COS(camc#)-SIN(cama#)*SIN(camc#)
	camforward#(2) = SIN(cama#)*(-1.0)*COS(camc#)-COS(cama#)*SIN(camb#)*SIN(camc#)
	camforward#(3) = COS(cama#)*COS(camb#)
	Norm# = norm(camforward#(1),camforward#(2),camforward#(3))
	camforward#(1) = camforward#(1)/Norm#
	camforward#(2) = camforward#(2)/Norm#
	camforward#(3) = camforward#(3)/Norm#
	
	rem Pareil pour le vecteur 'up'
	camup#(1) = SIN(cama#)*SIN(camb#)*COS(camc#)+COS(cama#)*SIN(camc#)
	camup#(2) = COS(cama#)*COS(camc#)-SIN(cama#)*SIN(camb#)*SIN(camc#)
	camup#(3) = SIN(cama#)*COS(camb#)
	Norm# = norm(camup#(1),camup#(2),camup#(3))
	camup#(1) = camup#(1)/Norm#
	camup#(2) = camup#(2)/Norm#
	camup#(3) = camup#(3)/Norm#
	
	rem Pareil pour le vecteur 'right'
	camright#(1) = COS(camb#)*COS(camc#)
	camright#(2) = (-1.0)*COS(camb#)*SIN(camc#)
	camright#(3) = (-1.0)*SIN(camb#)
	Norm# = norm(camright#(1),camright#(2),camright#(3))
	camright#(1) = camright#(1)/Norm#
	camright#(2) = camright#(2)/Norm#
	camright#(3) = camright#(3)/Norm#
endfunction

Dans sa vidéo, Sebastian crée des structures pour contenir toutes les données d’un rayon et donc les passer en argument ou de retour de fonctions. Tous ces éléments de programmation bénéficient des avantages de la programmation orientée objets du C# que ne possède pas le DarkBASIC.

Pour pallier ce problème, on va donc créer des variables globales qui seront accessibles par tout le programme. Ce n’est pas idéal et cela peut compliquer la compréhension, mais avec de la rigueur on peut obtenir le comportement voulu.

Cliquez pour voir le code

// Unity (C#)
Transform camT = cam.transform; // information de rotation et de 

for (int x = 0; x < debugPointCount.x; x++) {
	for (int y = 0; y < debugPointCount.y; y++) {
		float tx = x / (debugPointCount.x - 1f);
		float ty = y / (debugPointCount.y - 1f);

		Vector3 pointLocal = bottomLeftLocal + new Vector3(planeWidth * tx, planeHeight * ty);
		Vector3 point = camT.position + camT.right * pointLocal.x + camT.up * pointLocal.y + camT.forward * pointLocal.z;
		Vector3 dir = (point - camT.position).normalized;

		DrawPoint(point);
	}
}
rem DarkBASIC (DBA)

		dim cam#(3)
		cam#(1) = camera position x()
		cam#(2) = camera position y()
		cam#(3) = camera position z()
		cama# = camera angle x()
		camb# = camera angle y()
		camc# = camera angle z()
	
		rem Fonction qui permet de créer l'équivalent des informations contenus dans 'camT'
		BuildLocalCoordinateSystem(cama#,camb#,camc#)
		
		for x=1 to 10
			for y=1 to 10
				r=0
				g=0
				b=0
	
				rem On crée la sphère d'un ID fixe si elle n'existe pas
				id = (x-1)*10 + y
				if (object exist(id)=0)
					make object sphere id,0.1
					set object id,1,1,0
				endif
	
				rem On construit le rayon
				BuildDirectionRay(x,y,10,10,planeWidth#,planeHeight#)
				rem Vecteur direction dans le tableau dir#, le tableau pos# contient la position du pixel actuel dans le système de coordonnées absolu
	
				rem On place la sphère à la position du pixel
				position object id,pos#(1),pos#(2),pos#(3)
			next y
		next x

Avec la fonction BuildDirectionRay qui permet d’effectuer les opérations que Sebastian fait en trois lignes :

Cliquez pour voir le code

rem pos#() contient la position du départ du rayon
rem dir#() contient la direction normalisé du rayon
function BuildDirectionRay(i,j,width,height,planeWidth#,planeHeight#)
    tx# = (i-1) / (width-1.0)
    ty# = (height-j) / (height-1.0)

    rem Get world position of the view plane
    localPosX# = bottomLeftLocal#(1) + planeWidth#*tx#
    localPosY# = bottomLeftLocal#(2) + planeHeight#*ty#
    localPosZ# = bottomLeftLocal#(3)

    pos#(1) = cam#(1) + localPosX#*camright#(1) + localPosY#*camup#(1) + localPosZ#*camforward#(1)
    pos#(2) = cam#(2) + localPosX#*camright#(2) + localPosY#*camup#(2) + localPosZ#*camforward#(2)
    pos#(3) = cam#(3) + localPosX#*camright#(3) + localPosY#*camup#(3) + localPosZ#*camforward#(3)

    dir#(1) = pos#(1) - cam#(1)
    dir#(2) = pos#(2) - cam#(2)
    dir#(3) = pos#(3) - cam#(3)
	Norm# = norm(dir#(1),dir#(2),dir#(3))
    dir#(1) = dir#(1)/Norm#
    dir#(2) = dir#(2)/Norm#
    dir#(3) = dir#(3)/Norm#
endfunction

Cela fait beaucoup de travail, mais au moins cela fonctionne : les sphères apparaissent bien pile au niveau de l’écran.

On s’est reculé de quelques unités de mesures après avoir fait apparaître les sphères…

… qui sont exactement apparues aux délimitations de l’écran en fonction de la position et de l’angle de la caméra.

On peut également vérifier que le vecteur direction a une valeur cohérente en faisant correspondre la direction XYZ à la couleur RGB (donc la direction X affiche la couleur rouge, Y la couleur verte et Z la couleur bleue)

On voit du bleu quand on regarde vers la direction Z…

… du rouge vers la direction X…

… et du vert vers la direction Y !

La gestion des coordonnées, de la caméra et des calculs vectoriels de direction sont bons, on peut passer à la suite !

Mathématiques : résolution d’une équation polynôme du second degré

On continue le déroulé de la vidéo en créant une fonction qui permet de déterminer si le rayon rentre en collision avec une sphère. Cela revient à résoudre l’équation suivante :

    \[ \left\|\overrightarrow{Position} - \text{distance} \cdot \overrightarrow{Direction} \right\| = \text{rayon}^2 \]

– ‘Position’ est le vecteur représentant le point de départ du rayon
– ‘Direction’ est le vecteur directionnel normalisé du rayon
– ‘rayon’ est le rayon de la sphère
– ‘distance’ est la valeur inconnue que l’on souhaite déterminer

Après développement des calculs, on remplace ‘distance’ par ‘x’ et on obtient l’équation suivante :

    \[ \overrightarrow{Direction} \cdot \overrightarrow{Direction} \cdot x^2 \, + \, 2 \, (\overrightarrow{Position} \cdot \overrightarrow{Direction}) \cdot x \, + \, \overrightarrow{Position} \cdot \overrightarrow{Position} \, - \, \text{rayon}^2 \, = \, 0 \]

L’expression « le rayon entre en collision avec la sphère » correspond mathématiquement à « l’équation précédente à des solutions réelles ». On peut alors résoudre cette équation polynôme du second degré avec calcul du discriminant etc…

Les implémentations en DarkBASIC et en Unity ne sont pas très différentes. J’utilise encore une fois le principe de modification de variables globales, alors que Sebastian peut utiliser le passage de variable en créant une structure HitInfo dans laquelle il peut stocker tous les éléments nécessaires. Voici les implémentations :

Cliquez pour voir le code

// Unity (C#)
HitInfo RaySphere(Ray ray, float3 sphereCentre, float sphereRadius)
{
	HitInfo hitInfo = (HitInfo)0;
	float 3 offsetRayOrigin = ray.origin - sphereCentre;

	float a = dot(ray.dir, ray.dir);
	float b = 2 * dot(offsetRayOrigin, ray.dir);
	float c = dot(offsetRayOrigin, offsetRayOrigin) - sphereRadius * sphereRadius;

	float discriminant = b * b - 4 * a * c;

	if (discriminant >= 0) {
		float dst = (-b - sqrt(discriminant)) / (2 * a);

		if (dst >= 0) {
			hitInfo.didHit = true;
			hitInfo.dst = dst;
			hitInfo.hitPoint = ray.origin + ray.dir * dst;
			hitInfo.normal = normalize(hitInfo.hitPoint - sphereCentre);
		}
	}
	return hitInfo;
}
rem DarkBASIC (DBA)

function RaySphere(scx#,scy#,scz#,sradius#)
	hitpoint#(1) = 0.0
	hitpoint#(2) = 0.0
	hitpoint#(3) = 0.0
	hitnormal#(1) = 0.0
	hitnormal#(2) = 0.0
	hitnormal#(3) = 0.0

	dst# = -1.0

	rem Assuming that position 0,0,0 is at the center of the sphere
	rem Checking if the ray has hit the sphere is just solving the following equation :
	rem ||Pos + Dir * dst||^2 = sradius^2,
	rem where dst(=distance) being the value we want to calculate
	rem 
	rem    ||Pos + Dir*dst||^2 = sradius^2
	rem => (Pos + Dir*dst).(Pos + Dir*dst) = sradius^2
	rem => Pos.Pos + 2*Pos.(Dir*dst) + (Dir*dst).(Dir*dst) = sradius^2
	rem => Dir.Dir * dst^2 + 2*(Pos.Dir) * dst + Pos.Pos - sradius^2
	rem This is a quadratic polynomial equation (ax^2 + bx + c = 0) width
	rem a = Dir.Dir which here equals 1
	rem b = 2 * (Pos.Dir)
	rem c = Pos.Pos - sradius^2
	rem 
	rem Solutions if only discriminant is positive or equals 0

	oposx# = pos#(1) - scx#
	oposy# = pos#(2) - scy#
	oposz# = pos#(3) - scz#

	a# = dot(dir#(1),dir#(2),dir#(3),dir#(1),dir#(2),dir#(3))
	b# = 2*dot(oposx#,oposy#,oposz#,dir#(1),dir#(2),dir#(3))
	c# = dot(oposx#,oposy#,oposz#,oposx#,oposy#,oposz#) - sradius#*sradius#

	delta# = b#*b# + (-4.0)*a#*c#

	if (delta# >= 0)
		dst# = ((-1.0)*b# + (-1.0)*sqrt(delta#)) / (2.0*a#)

		if (dst# >= 0)
			hitpoint#(1) = pos#(1) + dst#*dir#(1)
			hitpoint#(2) = pos#(2) + dst#*dir#(2)
			hitpoint#(3) = pos#(3) + dst#*dir#(3)
			hitnormal#(1) = hitpoint#(1) - scx#
			hitnormal#(2) = hitpoint#(2) - scy#
			hitnormal#(3) = hitpoint#(3) - scz#
			Norm# = norm(hitnormal#(1),hitnormal#(2),hitnormal#(3))
			hitnormal#(1) = hitnormal#(1)/Norm#
			hitnormal#(2) = hitnormal#(2)/Norm#
			hitnormal#(3) = hitnormal#(3)/Norm#
		endif
	endif

endfunction dst#

On positionne une sphère de rayon 20 à la position (0,0,0) du monde avec le moteur 3D DarkBASIC.

Si on entre les coordonnées 0,0,0 et le rayon 20 dans la fonction RaySphere et qu’on affiche le résultat, on obtient ceci.

On peut alors faire correspondre plusieurs sphères d’une scène 3D à leurs informations de position du centre et de rayon, et rajouter la gestion de couleur du rayon. Pour cela, on fait en sorte de pouvoir retourner ce qu’on appelle le matériau de l’élément touché. Dans un premier temps, on ne renvoit que la couleur de la sphère pour pouvoir l’afficher directement, mais cela permettra de stocker tout un panel de paramètres utiles pour le RayTracing.
On parcourt toutes les sphères pour voir avec lesquelles le rayon rentre en collision et on garde en mémoire uniquement la sphère la plus proche du point de départ du rayon.

Visualisation de la scène 3D avec le moteur DarkBASIC
La sphère blanche est affichée en fil de fer intentionnellement.

Visualisation de la scène 3D avec le RayTracing et dessin programmé.
La sphère bleue est bien couverte par la sphère blanche.

Premières tentatives de RayTracing

Maintenant que le principe du tir des rayons est implémenté, nous pouvons maintenant implémenter du vrai Ray Tracing.

Lorsque un rayon de lumière rencontre une surface, elle est soit totalement absorbée par la surface qui rediffuse la lumière dans toutes les directions (c’est ce qu’on appelle la réflexion diffuse), soit renvoyée avec le même angle d’incidence (réflexion spéculaire).
On va se concentrer sur la réflexion diffuse : lorsqu’on fait le chemin inverse en partant de la caméra dans le cadre du raytracing, on va compter sur le fait que plusieurs rayons seront tirer pour chaque pixel. Lorsque l’on rencontre une sphère, on va considérer une direction aléatoire dans le même hémisphère que la normale à la surface de réflexion, et voir si on rencontre une source de lumière. On pourra faire plusieurs rebonds, et plusieurs rayons par pixels.

Sebastian travaille un certain temps pour avoir un générateur de nombre aléatoire, car le shaders ne permettent pas d’accéder à des fonctions de génération de nombres aléatoires. Ce n’est pas un problème pour nous, car nous travaillons avec le CPU et DarkBASIC met à disposition un générateur de nombres aléatoires. Le seul problème pour le moment est que DarkBASIC ne permet de générer que des nombres entiers, donc une petite fonction spéciale est nécessaire pour obtenir un générateur d’un nombre aléatoire entre 0 et 1.

Le gros problème s’est présenté dans la génération d’une direction aléatoire avec la distribution aléatoire normale. En effet, la fonction logirthme est utilisée pour générer un nombre en suivant la distribution normale. Hors, il n’y a pas de fonction logarithme disponible en DarkBASIC. Il faut donc ruser.

Encore des maths : approximation de l’exponentielle et du logarithme

Pour tester, j’ai utilisé ChatGPT pour qu’il me propose une technique pour approximer la valeur du logarithme. Après avoir fact-check la méthode appelée de Newton-Raphson, l’implémentation est simple mais nécessite d’avoir la valeur de l’exponentielle. Et là, nouveau problème : une implémentation de la fonction exponentielle existe en DarkBASIC mais n’a pas l’air de fonctionner (non, exponentielle de 1 n’est pas égal à 2…)

Je cherche alors une méthode pour approximer la valeur de exponentielle de 1, et je décide de me diriger vers les développements limités.

    \[ e^1 \approx 1 + \frac{1}{1!} + \frac{1}{2!} + \frac{1}{3!} + \frac{1}{4!} + ... \]

On faisant cette opération jusqu’à 1000, on peut considérer que l’on a une approximation d’exponentielle plutôt précise. A partir de là, on peut implémenter toutes les fonctions

Après du debugging long et compliqué (voir plus loin), j’obtiens finalement une implémentation de la fonction de génération de direction aléatoire.

Cliquez pour voir le code

// Unity (C#)
float RandomValueNormalDistribution(inout uint state)
{
	float theta = 2 * 3.1415926 * RandomValue(state);
	float rho = sqrt(-2 * log(RandomValue(state)));
	return rho * cos(theta);
}

float3 RandomDirection(inout uint state)
{
	float x = RandomValueNormalDistribution(state);
	float y = RandomValueNormalDistribution(state);
	float z = RandomValueNormalDistribution(state);
	return normalize(float3(x, y, z));
}
function ApproxEXP(n)
    e_approx# = 1.0
    for i=1 to n
        denom# = 1.0
        for factor=1 to i
            denom# = denom# * factor
        next factor
        e_approx# = e_approx# + 1.0/denom#
    next i
endfunction e_approx#

function Ln(x#)
    debugvalues#(4) = x#

    tolerance# = 0.0001
    max_iterations = 100
    y0# = 1.0
    y1# = 1.0

    E# = Exponential#(1)

    nb_iter = 0

    for i = 1 to max_iterations
        y1# = y0# - (((E#)^(y0#)) - x#) / ((E#)^(y0#))
        if abs(y1# - y0#) < tolerance#
            exit
        endif
        y0# = y1#
        nb_iter = nb_iter + 1
    next i

    debugvalues#(6) = nb_iter*1.0;

    debugvalues#(5) = y1#

endfunction y1#

function RandomValue(range)
    value# = RND(range)/((range+1)*1.0)
endfunction value#

function RandomValueNormalDistribution(range)
    theta# = 360.0 * RandomValue(range)
    debugvalues#(2) = theta#
    rho# = sqrt((-2.0) * Ln(RandomValue(range)))
    debugvalues#(3) = rho#
    
    result# = rho#*cos(theta#)
    debugvalues#(1) = result#
endfunction result#

Le debug : indispensable et pourtant très compliqué

Un petit mot rapide sur le debugging. La version du DarkBASIC que j’utilise et sur laquelle je suis le plus à l’aise ne permet pas de faire du debugging de manière efficace. Je n’ai pas moyen de mettre des points d’arrêts sur le code, ni de faire de l’affichage sur une console ou un fichier texte. Lors de mes sessions de résolution de bug sévères (typiquement, une division par zéro lorsqu’on rencontre une sphère), j’étais obligé d’afficher à l’écran les informations que je voulais voir. Mais même là, l’affiche de texte en même temps que l’affichage d’un rendu 3D ne fonctionne pas et le texte disparaît aussitôt qu’il est apparu ! Il a fallu donc :

  • Récupérer toutes les valeurs utiles pour le debugging à afficher dans un tableau dédié
  • Afficher les valeurs aux moments où cela peut potentiellement planter
  • Prendre une capture d’écran automatique au moment où le texte est affiché

Pour améliorer le confort de développement, je suis même allé jusqu’à développer une extension Visual Studio Code !

Les premiers rendus : victoire !

Après la souffrance du debug des fonctions de génération de directions aléatoires, on donne maintenant la possibilité aux sphères d’émettre ou non de la lumière. Lorsque les rayons rebondissent sur les sphères, ce rayon garde « en mémoire » la teinte des sphères rencontrées, et si le rayon rencontre finalement une source de lumière, alors on peut conclure que le pixel d’où vient le pixel sera lumineux avec la teinte des sphères qu’il a croisé.

Après de longues minutes de calcul (on en parle juste après), je suis quand même arrivé à obtenir les premiers rendus qui sont plutôt satisfaisant :

Visualisation de la scène 3D avec le moteur DarkBASIC (640×480)
La sphère lumineuse au fond est noire pour le moteur 3D.

Visualisation de la scène 3D avec le RayTracing (640×480)
Les 4 sphères sont peu lumineuse car l’image est très bruitée et de faible résolution.

Après de TRES LONGUES minutes, voici un rendu de plus haute qualité :

Visualisation de la scène 3D avec le moteur DarkBASIC (1920×1080)

Visualisation de la scène 3D avec RayTracing (1920×1080)
Là, on observe bien la sphère violette du sol et les trois sphères de couleur.
Sur ce rendu en particulier, la sphère bleue émet légèrement de la lumière.

Parlons des performances

J’ai passé un bon moment à comprendre les maths derrière le raytracing et la vidéo de Sebastian, et beaucoup de temps à trouver comment optimiser le plus possible les calculs (sans grand succès pour l’instant)

La différence de performance entre DarkBASIC et Unity est gigantesque :
– DarkBASIC : 45 minutes de calculs et 20 secondes d’affichage d’une seule frame 1920×1080 pour 5 rayons par pixels et 5 rebonds maximum par rayons
– Unity : plusieurs frames 1920×1080 par secondes avec 100 rayons par pixels et 30 rebonds. Moyennage de plusieurs frames pour pallier au problème d’image bruitée.

Cette différence est normale : le projet de Sebastian effectue pratiquement tous les calculs sur la carte graphique, donc il y a possibilité de paralléliser les calculs pour chacun des pixels, ce qui est totalement impossible en DarkBASIC. Sans même compter le fait que le moteur DarkBASIC date de 2000 donc est bien plus vieux que Unity, cela explique totalement la différence de performances.

On peut raccourcir le temps de calculs en faisant un rendu d’une frame 640×480 au lieu de 1920×1080. En effet, le moteur DarkBASIC n’a pas du tout été pensé pour une résolution aussi haute et est, par défaut, en 640×480. A cette résolution, le temps de calcul descend à 5 minutes. L’image est de moins bonne qualité, mais c’est largement suffisant pour pouvoir voir assez rapidement si les calculs effectués sont bons.

Il sera peut-être possible d’optimiser un peu les performances dans le futur. Vu que des adaptations ont été nécessaires pour pouvoir implémenter le projet en DarkBASIC, il est possible de changer un peu l’ordre dans lequel sont effectués les calculs pour gagner un peu de temps.

Ce qu’il reste à faire

Jusque là, on est arrivé à 18:47 de la vidéo de Sebastian. Il va rester à implémenter :

  • Lumière ambiante (optionnelle, cela ne va pas améliorer le rendu)
  • Rayon pondéré en cosinus : au lieu de faire rebondir le rayon dans une direction parfaite aléatoire, on va pondérer les probabilités de manière cosinusoïdale pour améliorer le rendu
  • Réflexion spéculaire : simuler des miroirs
  • Simulation de la brillance : on mélange de la réflexion spéculaire avec la réflexion diffuse pour simuler les surfaces lisses non miroir
  • Flou et mise au point : on simule la mise au point du focus en faisant légèrement varier de manière aléatoire le point d’origine et la direction pour chaque rayon d’un pixel

Cela va faire beaucoup de boulot avec de nombreux bugs à résoudre, mais j’ai hâte de voir ce que cela va donner !

La suite au prochain article !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Retour en haut