J'ai mis un peu de temps, mais voici enfin l'article sur ma gestion des ombres projetées en 2.5D. Accrochez-vous, c'est parti !
Re-voici un petit aperçu du rendu qu'on veut obtenir :
Pour le moment, je n'ai trouvé de solution "efficace" que pour faire des ombres en fonction d'une lumière directionnelle et dirigée pas trop à l'horizontale.
L'idée est de projeter les voxels du sprite 3D sur une image plane, représentant le sol, dans la direction de la lumière. On enregistre la hauteur du voxel qui a été projeté le plus haut (oui, on va réutiliser notre z-buffer pour ça). A la fin, au moment du rendu des sprites 3D eux-même, on saura si le pixel doit être éclairé par la lumière ou non suivant si, une fois projeté au sol, il est plus haut ou non que le voxel le plus haut projeté.
En effet, regardez ce shéma :
Sur la projection au sol, on retiendra une ombre de hauteur du point P2, donc le point P1 sera ombré et pas le point P2.
On ajoute en outre un petit facteur d'adoucissement pour un rendu plus agréable à l’œil.
Mais tout cela donne des ombres approximatives (les voxels ne remplissent pas toujours une forme sans trou au sol, on perd de l'information, etc). On applique donc un shader de flou sur l'image des ombres projetées et on peut aussi ajouter un petit anti-crénelage au moment du rendu en regardant la projection des voxels voisins et en faisant une moyenne pondérée.
Afin d'optimiser la chose, je calcul à l'avance la projection de chaque modèle de sprite 3D et il suffit après de just la dessiner avec le z-buffer sur l'écran de rendu.
![]()
Puisque la projection des ombres peut sortir de l'écran, il faut créer un écran de rendu des ombres légèrement plus grand que l'écran réel.
Maintenant que vous voyez l'idée, on va regarder le code un peu plus en détails.
Ombres locales
On commence par la méthode de Sprite3D qui permet de calculer la projection de l'ombre au sol en local (donc juste pour le sprite).
void GenerateAmbientShadow(sf::RenderTarget *a, Shader_pack *s, sf::Vector3f light)
{
const int W = heightmap.getSize().x + (int)((height+m_3Dpos.z) * fabs(light.x/light.z) + 1);
const int H = heightmap.getSize().y + (int)((height+m_3Dpos.z) * fabs(light.y/2.0/light.z) + 1)
+ (int)((height+m_3Dpos.z)*sqrt(3)/2.0 + 1);
const int X = std::min(0.f, - light.x * (height+m_3Dpos.z) / light.z);
const int Y = std::min(0.f, - light.y * (height+m_3Dpos.z) / light.z);
sf::Uint8 *shadow_map_pix = new sf::Uint8[W*H*4];
const sf::Uint8* local_heightmap = img_heightmap.getPixelsPtr();
for(unsigned int x = 0 ; x < heightmap.getSize().x ; x ++)
for(unsigned int y = 0 ; y < heightmap.getSize().y ; y ++)
{
unsigned int x_p = x;
if(getScale().x < 0)
x_p = heightmap.getSize().x - x - 1;
float c = local_heightmap[y*heightmap.getSize().x*4+x_p*4+2];
if(local_heightmap[y*heightmap.getSize().x*4+x_p*4+3] > 192)
{
c *= 255/(float)local_heightmap[y*heightmap.getSize().x*4+x_p*4+3];
float h = height * (float)c/255 + m_3Dpos.z;
sf::Vector2f p(x-X,y-Y);
p.y += h*sqrt(3)/2;
p.x -= light.x * h / light.z;
p.y -= light.y * h / 2.f / light.z;
p.x = (int)p.x;
p.y = (int)p.y;
if(p.x >= 0 && p.y >= 0 && p.x < W && p.y < H)
{
unsigned int n = p.y*W*4+p.x*4;
shadow_map_pix[n] = 0;
shadow_map_pix[n+1] = 0;
if(c > shadow_map_pix[n+2])
shadow_map_pix[n+2] = c;
shadow_map_pix[n+3] = 255;
}
}
}
shadow_map_img.create(W,H);
shadow_map_img.update(shadow_map_pix);
shadow_map.setTexture(shadow_map_img);
shadow_map.setPosition(sf::Vector2f(X,Y));
delete[] shadow_map_pix;
}
La première étape :
const int W = heightmap.getSize().x + (int)((height+m_3Dpos.z) * fabs(light.x/light.z) + 1);
const int H = heightmap.getSize().y + (int)((height+m_3Dpos.z) * fabs(light.y/2.0/light.z) + 1)
+ (int)((height+m_3Dpos.z)*sqrt(3)/2.0 + 1);
const int X = std::min(0.f, - light.x * (height+m_3Dpos.z) / light.z);
const int Y = std::min(0.f, - light.y * (height+m_3Dpos.z) / light.z);
Consiste simplement en le calcul de la largeur, de la hauteur et du décalage des coordonnées de la texture qui sera créée pour contenir la projection de l'ombre. On considère donc le pire des cas, en augmentant les dimensions de la texture de base avec la projection du voxel le plus haut possible dans la direction de la lumière.
On calcul en plus le décalage qui permettra de savoir où dessiner l'ombre par rapport au sprite 3D en fonction de sa nouvelle largeur et hauteur.
sf::Uint8 *shadow_map_pix = new sf::Uint8[W*H*4];
const sf::Uint8* local_heightmap = img_heightmap.getPixelsPtr();
Je crée un tableau de pixels qui contiendra les valeurs d'ombrage. Dans les faits, on enregistrera juste dans la composante rouge la hauteur du voxel projeté mais le fait d'avoir un tableau de pixels permet de créer rapidement une sf::Texture à la fin.
Je récupère en outre la liste des pixels de la heightmap du sprite 3D.
for(unsigned int x = 0 ; x < heightmap.getSize().x ; x ++)
for(unsigned int y = 0 ; y < heightmap.getSize().y ; y ++)
{
unsigned int x_p = x;
if(getScale().x < 0)
x_p = heightmap.getSize().x - x - 1;
float c = local_heightmap[y*heightmap.getSize().x*4+x_p*4+2];
[...]
}
On boucle sur la heightmap du sprite 3D afin de récupérer la hauteur de chaque voxel.
On vérifie si le sprite 3D a été retourné en x, si oui on doit parcourir la heightmap dans le sens contraire pour la largeur.
On récupère et on stocke dans c la couleur représentant la hauteur du voxel considéré.
if(local_heightmap[y*heightmap.getSize().x*4+x_p*4+3] > 192)
{
c *= 255/(float)local_heightmap[y*heightmap.getSize().x*4+x_p*4+3];
float h = height * (float)c/255 + m_3Dpos.z;
sf::Vector2f p(x-X,y-Y);
p.y += h*sqrt(3)/2;
p.x -= light.x * h / light.z;
p.y -= light.y * h / 2.f / light.z;
p.x = (int)p.x;
p.y = (int)p.y;
[...]
}
On vérifie d'abord si le voxel possède suffisant d'opacité, sinon il est considéré comme inexistant car il sert juste à l'anticrénelage du décor.
Ensuite on compense la hauteur du voxel qui peut être trompée de par sa semi-opacité dans la heightmap (si le pixel de la heightmap est en partie transparent à cause de l'anti-crénelage, il perd en plus en luminosité ; or on s'intéresse ici à la couleur originelle du pixel).
Et on calcul la véritable hauteur du voxel en fonction des paramètres du sprite 3D.
Après, on décale sa position en y qui est trompée par sa position en z comme je l'avais déjà expliqué dans mon précédent article.
Enfin, on projette le voxel sur le sol dans la direction de la lumière et on arrondi car un pixel a une position entière.
Pour la projection, j'utilise simplement la conservation des rapports entre les côtés de triangles semblables.
if(p.x >= 0 && p.y >= 0 && p.x < W && p.y < H)
{
unsigned int n = p.y*W*4+p.x*4;
shadow_map_pix[n] = 0;
shadow_map_pix[n+1] = 0;
if(c > shadow_map_pix[n+2])
shadow_map_pix[n+2] = c;
shadow_map_pix[n+3] = 255;
}
On s'assure que le voxel n'es pas projeté en dehors de l'image de l'ombre (ça ne devrait pas arriver, mais sait-on jamais).
Après, on calcule la position du pixel dans le tableau de l'image de l'ombre en fonction de la position de la projection et si le nouveau voxel projeté est plus haut que le précédent, on met à jour la couleur et donc la hauteur de la projection.
shadow_map_img.create(W,H);
shadow_map_img.update(shadow_map_pix);
shadow_map.setTexture(shadow_map_img);
shadow_map.setPosition(sf::Vector2f(X,Y));
delete[] shadow_map_pix;
Pour terminer, on crée la texture, on la remplit avec notre tableau de pixels et on met à jour le sprite qui l'affichera ; sans oublier de libérer la mémoire.
Ombres globales
Le calcul des ombres de façon globale se fait après aisément, il suffit de faire appel à une méthode comme suit pour chacun des sprites 3D affichés avant de faire les autres rendus :
void drawAmbientShadow(sf::RenderTarget *a, Shader_pack *s)
{
sf::RenderTexture &t = s->shadow_screen;
sf::View v = a->getView();
v.setSize(t.getSize().x,t.getSize().y);
t.setView(v);
s->heightmap_shader.setParameter("height_factor", height*fabs(getScale().y));
s->heightmap_shader.setParameter("z_pos", m_3Dpos.z);
s->heightmap_shader.setParameter("heightmap_screen", t.getTexture());
s->heightmap_shader.setParameter("screen_ratio", sf::Vector2f(1.f/t.getSize().x, 1.f/t.getSize().y));
sf::Sprite temp = shadow_map;
temp.move(getPosition());
t.draw(temp,&s->heightmap_shader);
t.display();
s->heightmap_shader.setParameter("heightmap_screen", s->heightmap_screen.getTexture());
s->heightmap_shader.setParameter("screen_ratio", sf::Vector2f(1.f/a->getSize().x, 1.f/a->getSize().y));
}
En gros, on affiche notre projection d'ombres sur l'écran des ombres avec un shader de z-buffer pour s'assurer d'avoir à chaque fois les voxels les plus hauts qui ombrent.
Une fois qu'on a affiché comme ça toutes nos ombres, on peut passer un petit shader de blur (voir
mon autre billet ici pour plus d'explications).
Ça nous donne quelque chose comme :
void drawAmbientShadow()
{
sf::View old_cam = screen.getView();
screen.setView(screen.getDefaultView());
shadow_screen.setView(shadow_screen.getDefaultView());
blur_shader.setParameter("direction_x", 1.0);
shadow_screen.draw(sf::Sprite (shadow_screen.getTexture()), &blur_shader);
shadow_screen.display();
blur_shader.setParameter("direction_x", 0);
shadow_screen.draw(sf::Sprite (shadow_screen.getTexture()), &blur_shader);
shadow_screen.display();
screen.setView(old_cam);
}
Shader d'éclairage
Dans le code C++, il n'y a pas de changement pour le rendu des sprites 3D si ce n'est que maintenant on passe en plus en paramètre au shader d'éclairage l'écran des ombres et son ratio.
Par contre, il y a du neuf dans le shader d'éclairage qui ressemble maintenant à ça :
uniform sampler2D diffuse;
uniform sampler2D normal;
uniform sampler2D heightmap;
uniform sampler2D heightmap_screen;
uniform sampler2D screen;
uniform sampler2D shadow_map;
uniform float NBR_LIGHTS;
uniform vec4 ambient_light;
uniform vec2 screen_ratio;
uniform vec2 shadow_ratio;
uniform vec2 image_ratio;
uniform float height_factor;
uniform float flipx;
varying vec3 vertex;
float GetShadow(vec2 decal, vec3 light_direction)
{
float h = (vertex.z+texture2D(heightmap, gl_TexCoord[0].xy + decal*image_ratio).b * height_factor);
vec2 pos_screen = gl_FragCoord.xy + decal;
pos_screen.x -= h*light_direction.x/light_direction.z;
pos_screen.y += h*light_direction.y/4.0/light_direction.z;
pos_screen.y -= h*sqrt(3.0)/2.0;
float shadow_color = texture2D(shadow_map, pos_screen*shadow_ratio + vec2(0.5,0.5) - shadow_ratio/screen_ratio/2.0).b;
return 1.0 - min(1.0,max(0.0, 1.0 -(h/500.0 - shadow_color+0.025)/0.025));
}
void main()
{
float alpha_old = 0.0;
vec3 v = vertex;
if( texture2D(heightmap, gl_TexCoord[0].xy)[3] > 0.2)
v.z += texture2D(heightmap, gl_TexCoord[0].xy)[2] * height_factor / texture2D(heightmap, gl_TexCoord[0].xy)[3];
else
v.z += texture2D(heightmap, gl_TexCoord[0].xy)[2] * height_factor;
v.y += sqrt(3.0) * v.z;
if(v.z/500.0 < texture2D(heightmap_screen, vec2(gl_FragCoord.xy*screen_ratio)).b)
alpha_old = texture2D(heightmap_screen, vec2(gl_FragCoord.xy*screen_ratio)).r;
if(alpha_old < 1.0)
{
vec3 direction = -1.0 + 2.0 * texture2D(normal, gl_TexCoord[0].xy).rgb;
direction.x *= flipx;
direction.yz = vec2(direction.y*0.5 + direction.z*0.5*sqrt(3.0),
direction.z*0.5 - direction.y*0.5*sqrt(3.0));
vec4 color = gl_Color * texture2D(diffuse, gl_TexCoord[0].xy) * ambient_light;
int i;
for(i = 0 ; i < int(NBR_LIGHTS) ; i = i+1)
{
float lighting = 0.0;
if(gl_LightSource[i].position.w == 0.0)
{
vec3 light_direction = -gl_LightSource[i].position.xyz;
light_direction.y *= 2.0;
lighting = max(0.0, dot(direction,normalize(light_direction)));
float f = 1.0;
lighting *= (GetShadow(vec2( 0, 0)*f,light_direction) * 4.0 +
GetShadow(vec2( 1, 0)*f,light_direction) * 2.0 +
GetShadow(vec2(-1, 0)*f,light_direction) * 2.0 +
GetShadow(vec2( 0, 1)*f,light_direction) * 2.0 +
GetShadow(vec2( 0,-1)*f,light_direction) * 2.0 +
GetShadow(vec2( 1, 1)*f,light_direction) * 1.0 +
GetShadow(vec2(-1, 1)*f,light_direction) * 1.0 +
GetShadow(vec2(-1,-1)*f,light_direction) * 1.0 +
GetShadow(vec2( 1,-1)*f,light_direction) * 1.0 )/16.0;
}
else
{
vec3 light_direction = gl_LightSource[i].position.xyz - v;
float dist = length(light_direction);
float attenuation = 1.0/( gl_LightSource[i].constantAttenuation +
dist*gl_LightSource[i].linearAttenuation +
dist*dist*gl_LightSource[i].quadraticAttenuation);
lighting = max(0.0, dot(direction,normalize(light_direction))) * attenuation;
}
lighting *= gl_LightSource[i].diffuse.a;
color.rgb += gl_Color.rgb * texture2D(diffuse, gl_TexCoord[0].xy).rgb * gl_LightSource[i].diffuse.rgb * lighting;
}
float a = gl_Color.a * texture2D(diffuse, gl_TexCoord[0].xy).a;
if(alpha_old != 0.0)
{
color *= a * min(1.0,max(0.0,1.0 - alpha_old));
color += texture2D(screen, vec2(gl_FragCoord.xy*screen_ratio)) * alpha_old;
color /= alpha_old + a * min(1.0,max(0.0,1.0 - alpha_old));
color.a = alpha_old + a * min(1.0,max(0.0,1.0 - alpha_old));
}
else
color.a = gl_Color.a * texture2D(diffuse, gl_TexCoord[0].xy).a;
gl_FragColor = color;
}
else
gl_FragColor = vec4(0.0,0.0,0.0,0.0);
}
Alors quoi de neuf ?
Pas grand chose si ce n'est l'apparition de la fonction float GetShadow(vec2 decal, vec3 light_direction) et :
float f = 1.0;
lighting *= (GetShadow(vec2( 0, 0)*f,light_direction) * 4.0 +
GetShadow(vec2( 1, 0)*f,light_direction) * 2.0 +
GetShadow(vec2(-1, 0)*f,light_direction) * 2.0 +
GetShadow(vec2( 0, 1)*f,light_direction) * 2.0 +
GetShadow(vec2( 0,-1)*f,light_direction) * 2.0 +
GetShadow(vec2( 1, 1)*f,light_direction) * 1.0 +
GetShadow(vec2(-1, 1)*f,light_direction) * 1.0 +
GetShadow(vec2(-1,-1)*f,light_direction) * 1.0 +
GetShadow(vec2( 1,-1)*f,light_direction) * 1.0 )/16.0;
En fait, GetShadow me permet de récupérer le taux d'éclairage du pixel en fonction de l'écran des ombres et je l'applique plusieurs fois en faisant une moyenne pondérée pour donner un effet de flou/anticrénelage.
Le f est juste là pour facilement manipuler ce taux de flou.
![]()
L'anticrénelage donne beaucoup plus beau, mais consomme énormément. Vous pouvez le diminuer en le réduisant à 5 points voir même le retirer.
Intéressons-nous à cette fonction GetShadow :
float GetShadow(vec2 decal, vec3 light_direction)
{
float h = (vertex.z+texture2D(heightmap, gl_TexCoord[0].xy + decal*image_ratio).b * height_factor);
vec2 pos_screen = gl_FragCoord.xy + decal;
pos_screen.x -= h*light_direction.x/light_direction.z;
pos_screen.y += h*light_direction.y/4.0/light_direction.z;
pos_screen.y -= h*sqrt(3.0)/2.0;
float shadow_color = texture2D(shadow_map, pos_screen*shadow_ratio + vec2(0.5,0.5) - shadow_ratio/screen_ratio/2.0).b;
return 1.0 - min(1.0,max(0.0, 1.0 -(h/500.0 - shadow_color+0.025)/0.025));
}
Je récupère la hauteur du voxel considéré ou du voxel voisin s'il y a décalage, je le projette sur le sol comme j'ai fait dans la génération de l'ombre et je récupère la valeur d'ombrage à cette position.
Ensuite j'assombris ou non suivant le voxel considéré s'il est plus haut ou non que l'ombre avec un petit adoucissement linéaire.
Et voilà, c'est tout !
Je rapelle le lien pour tester :
ShadersTest_demo.rar
Comme d'hab, n'hésitez pas à poser vos questions dans les commentaires.