Brutal Pico Race, sous le capot !
Brutal Pico Race utilise un système de rendu hybride, combinant différentes techniques pour la piste et pour les vaisseaux. Les sommets des polygones de la piste est calculé à la volée, avec une projection “pseudo 3D” comme les anciens jeux type Outrun pour les virages. Les vaisseaux ont un rendu basé sur une structure en voxel.
Dans cet article j’explique comment j’ai implémenté Brutal Pico Race. Je donne quelques détails technique sur le rendu dont le résultat est une bataille entre l’idée d’un jeu de jeu de course et les contraintes de Pico 8.
L’idée
Inspiration
L’idée de Brutal pico race est venu après que mon subconscient ai combiné différents éléments que j’ai découvert ici et la. La première pièce du puzzle vient du court métrage the running man tiré de Neo Tokyo (pas du film avec Arnold Schwarzenegger !). Le titre Sega Sunset de Lorn colle particulièrement bien à l’ambiance dans ce montage :
Travaux émergent de twitter
Avant de démarrer Brutal Pico Race, j’étais à la recherche d’un outil permettant à mes enfants de découvrir la programmation informatique. J’avais pico8 qui trainait dans les cartons, obtenu grace à un ensemble de logiciel contenant Voxatron, je pensais que c’était un bon début. Mais finalement, mes enfants n’ont pas accroché autant que moi ! En apprenant comment fonctionnait pico 8 j’ai découvert des projets très intéressant et une communauté très active, le piège était refermé !
Si je devais sélectionner uniquement 3 projets de cette année 2018, je garderais Zepton, Alone in pico 8 avec son postmortem très détaillé et Lands of Yocta. J’étais stupéfait ! Je me suis dit : On peut faire ça avec Pico 8 ?
Donc après avec découvert the running man du court métrage Neo Tokyo, et après avoir découvert des supers projets Pico 8 je me suis lancé dans la création de ce “demake” de The running man. Première étape la piste !
La piste
Structure de données
La piste est un tableau de “bloc de piste”. Chaque bloc de piste possède ses propres propriétés. Pour dessiner la piste, nous avons besoin de coordonnées x
,y
,z
, d’une propriété fx
pour les effets de virages, c
pour la couleur et t
pour le type de piste. La coordonnée x
a été utilisée dans les premiers prototypes, avant de choisir de modéliser les virages en pseudo 3D (accumulation de décalage). Donc fx
est la propriété utilisée pour simuler les virages, comme dans les vieux jeux de courses sur des machines pas assez performante pour calculer de vrais virages. Et c’est tout ! Dans Brutal Pico Race, un bloc de piste fait 64 unités de long, un choix arbitraire. Toutes ces informations sont initialisées dans la function init_all_road
disponible dans le code source (lien “code” sous la fenêtre pico 8).
Une piste de course est une grande ligne droite qui boucle.
Rendu Hybride
La piste est rendu du bloc de piste le plus loin au plus près par rapport à la caméra (Algorithme du peintre), avec une projection simple (weak perspective projection). On passe de coordonnées 3D à une projection 2D grâce à une division.
projection x = 3D X / 3D Z
projection y = 3D Y / 3D Z
Je n’utilise pas de matrice de projection dans ce cas, car cela est très couteux. Je tente de réduire au maximum de ce qui est nécessaire. La contre-partie est qu’il n’y a pas de rotation possible pour la piste. Mais cela permet quand même de projeter les 4 sommets d’un bloc de piste pour chaque bloc de piste et obtenir un polygone en 2D. A partir de la, on peut remplir ce polygone. Le remplissage était d’abord réalisé grâce au rasterizer de 163 tokens de @p01 qui est à la fois rapide et léger. J’ai ensuite utilisé un autre algorithme, celui de Freds72 car il permet d’avoir une précision sub-pixel. Au début du projet, j’utilisais une version adapté du rasterizer de Alone In Pico 8.
En appliquant une fonction cos ou sin aux coordonnées X et Y des blocs de piste en 3D et en appliquant une translation sur l’axe Z de la caméra, on obtient le résultat suivant (Notez le faible cout du CPU <5,4%):
Ci-dessous j’ai épurer le code de rendu de la piste pour se concentrer sur l’affichage de la piste, bloc par bloc. Je fais attention de ne jamais réaliser plus d’une fois la même chose, on voit que les sommets ne sont projetés qu’une seul fois chacun. On ne calcul que 2 sommets par bloc de piste. La première itération n’affiche rien, il faut attendre la deuxième pour avoir 4 sommets et ainsi un polygone à remplir.
function drawroad(cam,ird,p)
-- calculating road
local r,rn,v,vn,vz=
nil,nil
--render info
-- first partial offset
local irdn=ird+1
local irdlast=ird+nb_rb-1
-- ready to draw road
for i=irdlast,ird-1,-1 do
v,vn=road[i%#road+1],
road[(i+1)%#road+1]
vz,xl,xr,iy,ylgt=
v.z+flr(i/#road)*trklen,
v.x-56,v.x+56,v.y,
lerp(v.y,vn.y,0.5)
-- clip
dz=max(vz-cam.z,7)
prc=(cam.z+7-vz)/rdz
-- clipping
if dz==7 then
--interpolation
xl=lerp(xl,vn.x-56,prc)
xr=lerp(xr,vn.x+56,prc)
iy=lerp(v.y,vn.y,prc)
end
-- current part
fct=scale/dz
r={
x1=cx+fct*(xl-cam.x),
x2=cx+fct*(xr-cam.x),
y=cy-fct*(iy-cam.y),
}
-- drawing road
if rn~=nil then
-- drawing triangles
otri(r.x1,r.y,r.x2,r.y,rn.x1,rn.y,v.c)
otri(rn.x1,rn.y,rn.x2,rn.y,r.x2,r.y,v.c)
end
-- next
rn=r
end
end
Système Half-pipe
Maintenant que l’on peut afficher une ligne droite, sans rotation mais avec une gestion de l’altitude et qu’il n’y a pas de contrainte de forme pour le remplissage des polygones, on peut imager une amélioration de la piste en “goutière”. J’avais en tête cette forme en goutière grace au court métrage Neo Tokyo, où les vaisseaux sont collés sur les bords surélevés par la vitesse. Et, c’est chose possible ! En divisant un bloc de piste en 6 parties, avec chacune une altitude différente. Ces nouveaux sommets sont calculés à la volée à partir du point centrale du bloc de piste (X=0, Y=0, Z=le z de ce bloc de piste) en précisant les coordonnées X et Y du système de coordonnées du monde. Ces nouveaux sommets sont ensuite projetés en 2D et rempli et voila la magie :
Ensuite, je voulais voir jusqu’où Pico8 pouvait aller en ajoutant plein de bloc de piste de ce type. Donc j’ai complété le premier prototype en affichant une goutière complète. Et pour 30FPS, Pico8 s’en tire bien ! Je n’ai pas visé le 60 FPS car je voulais donner la possibilité de jouer à deux sur un écran splitté, donc avoir le CPU en dessous de 50%, même si il manque encore beaucoup de chose, était déjà très encourageant !
Le type de piste
La structure de donnée de la piste est un tableau de bloc de piste, avec pour chaque bloc des coordonnées, l’effet du virage, une couleur et un type. Alors, qu’est-ce que le type de bloc de piste ? Comme vous pouvez le voir en jouant, la piste n’est pas toujours dans la même configuration, des fois plates, des fois avec des virages relevés à gauche ou à droite ou encore les deux. C’est ca le type de piste. Le type de piste est un “bit field” qui précise quel morceau de la piste est activé.
Value |256|...|16|8|4|2|1|
---------------------+---+---+--+-+-+-+-+
2e élévation droite | | | | | | |X|
1ère élévation droite| | | | | |X| |
Piste normal, plate | | | | |X| | |
1ère élévation gauche| | | |X| | | |
2e élévation gauche | | | X| | | | |
Ligne de départ | X | | | | | | |
Par exemple, une piste avec une élévation gauche complète et un premier niveau d’élévation à droite a un type qui vaut 30 et ressemble à ceci :
Cette valeur est réglé automatiquement en fonction de l’effet du virage, mais il est possible de forcer la valeur. C’est le cas de la capture ci-dessus où la valeur par défaut est 4 et la route devrait être plate. J’avais prévu plus de type de route différents, mais j’ai malheureusement atteint le maximum de ce que pico 8 me permet de faire !
Le vaisseau
Données empilées
Les vaisseaux sont stockés directement dans l’éditeur de sprite. Ils sont composés de 3 couches, ainsi leur édition est facilité et amusante. Il n’y a plus qu’à les dessiner. Ils sont chargés dans un tableau à l’initialisation du jeu. L’idée vient de ce stockage vient de Zepton. On calcule l’ombre, qui correspond à la fusion des trois couches.
Vous pouvez regarder la fonction loadss
qui correspond au chargement des voxels. Il y a aussi quelques informations supplémentaires tirés de ce chargement, comme la largeur, la longueur et la masse du vaisseau calculées directement à partir du nombre de pixels. Vous pouvez aussi voir que l’ombre n’est constituée que d’une ligne sur deux, ce qui diminue le temps CPU à l’affichage.
-- loading a ship
function loadss(nbss,ss,sss)
local m,xmin,xmax,zmin,zmax=
0,8,0,8,0
for k=7,0,-1 do-- y
for j=0,7 do-- x
local shad=false
for i=2,0,-1 do
c=sget(8*i+j,k+nbss*8)
if c~=0 then
v,shad=
{x=j-4,y=i-1,z=4-k,
oz=4-k,c=c,pat=0},
c~=10
m+=1
add(ss,v)
end
end
--half z
if shad and k%2==0 then
v={x=j-4,y=0,z=4-k,
oz=0,c=9,pat=1}
add(sss,v)
xmin,xmax,zmin,zmax=
min(v.x,xmin),max(v.x,xmax),
min(v.z,zmin),max(v.z,zmax)
end
end
end
-- /5 because half_width*scale
local si=ship_info[nbss+1]
si.mass,si.width,si.height=
m,(xmax-xmin)/5,(zmax-zmin)/5
end
Rendu
Comme nous voyons tout le temps les vaisseaux de derrière pendant une course, on s’assure de charger les voxels dans le bon ordre de sorte qu’il n’y ai pas besoin de trier les éléments à l’affichage et ainsi minimiser le coût du rendu. La projection des éléments du monde en 3D à l’écran en 2D se fait de la même façon que la projection des sommets de la piste (weak perspective projection). La projection est appliqué pour chaque voxel.
Un autre point important nécessaire pour l’affichage des vaisseaux est la rotation, sur plusieurs axes. J’utilise les angles d’euler pour modéliser les diverses rotations. La rotation est la même pour chaque élément du vaisseau, alors il suffit de calculer la matrice de rotation une seule fois par image. Ensuite on l’applique pour chaque élément. Lors de l’affichage à deux joueurs en écran divisé, ce calcul n’est fait qu’une fois. Voici la fonction calcrotmat
, les notions de “pitch, yaw, roll” (tangage, lacet et roulis) ne sont peut être pas sur les axes standards.
function calcrotmat(rot)
--pitch x yaw y roll z
local cosa,sina,cosb,sinb,cosc,sinc=
cos(rot.z),sin(rot.z),
cos(-rot.y),sin(-rot.y),
cos(rot.x),sin(rot.x)
local axx,axy,axz=
cosa*cosb,
cosa*sinb*sinc-sina*cosc,
cosa*sinb*cosc+sina*sinc
local ayx,ayy,ayz=
sina*cosb,
sina*sinb*sinc+cosa*cosc,
sina*sinb*cosc-cosa*sinc
local azx,azy,azz=
-sinb,cosb*sinc,cosb*cosc
return {xx=axx,xy=axy,xz=axz,
yx=ayx,yy=ayy,yz=ayz,
zx=azx,zy=azy,zz=azz}
end
Une fois la matrice calculée, on l’applique au vaisseau. Ceci est réalisé une seule fois par image et par vaisseau dans la fonction _update.
function applyrot(ss,m,sort)
-- faster 0.02cpu but +30 tokens
local xx,xy,xz,
yx,yy,yz,
zx,zy,zz,rr=
m.xx,m.xy,m.xz,
m.yx,m.yy,m.yz,
m.zx,m.zy,m.zz,
{}
for v in all(ss) do
local r={}
r.x,r.y,r.z,
r.c,r.pat=
xx*v.x+xy*v.y+xz*v.z,
yx*v.x+yy*v.y+yz*v.z,
zx*v.x+zy*v.y+zz*v.z,
v.c,v.pat
r.key=r.z
add(rr,r)
end
-- z sort
if (sort) shellsort(rr)
return rr
end
Dans cette fonction, on passe la matrice de rotation sous la forme d’un tableau. Une astuce, pour limiter le coup de parcours de ce tableau à chaque itération de la boucle sur les éléments et de le faire une fois pour toute avant la boucle. On économise ainsi environ 2% de CPU.
Nous avons un vaisseau composé de voxels tournés dans le bon sens. Nous pouvons les projeter dans sur l’écran. Et comme je suis chanceux, j’obtiens ceci :
Sur cette capture du début de développement, on peut voir une sorte d’ombre orange. Dans l’idée il s’agissait de la projection de la lumière émise par le système de sustentation. Mais ce n’était pas très lisible, et après quelques essais et conseils sur twitter, j’ai finalement laissé tombé et j’ai une vrai ombre noire. Sur ce gif, on applique un tri sur l’axe Z sinon l’affichage serait erroné pendant la rotation. Ce tri a été conservé pour l’écran de menu, lors du choix du vaisseau, mais pas pendant la course où on voit le vaisseau de derrière.
Integration des vaisseaux sur la piste
Comme indiqué plus tôt, la piste est en fait un simple tableau, tout droit. Maintenant je vais ajouter que la goutière est plate ! L’intégration des vaisseaux est géré sur une piste plate, “dépliée”. Avant d’afficher la piste et les vaisseaux, le monde “déplié” est converti en monte “recourbé”, le vrai monde avec une piste en goutière. A ce moment, une rotation est appliquée sur l’axe Z pour faire en sorte que le vaisseau soit parallèle avec le morceau de la piste qu’il survol. J’utilise une interpolation linéaire pour passé de la valeur d’un angle de rotation à un autre pour avoir un mouvement plus fluide.
En fonction de l’endroit où se trouve le vaisseau sur la piste dépliée, une rotation est appliqué sur l’axe Z. On en profite pour appliquer aussi l’effet de la gravité, pour faire redescendre le vaisseau vers le plat de la piste. Et voila la magie.
Ces astuces sont la structure principale du rendu et tous les détails sont intégrés en tenant compte de ces particularités. Cela simplifie le code et permet d’économiser du CPU pour certaines autres fonction du jeu, comme la gestion des collisions qui est réalisée en 2D, sur la piste plate.
Voici un extrait de la fonction de conversion :
function convert_xflat_to_x(pos,decx,ytmp,rot,spdp,shipp)
local xflat,cumx,cumy,newz=
pos.xflat-decx,0,0,1
pos.x,pos.y=pos.xflat,0
-- for each road part
for stp in all(rd_info) do
if xflat > stp.fl then
-- startx,y
stx,sty=stp.sx,stp.sy
cumx,cumy=stx+stp.dx,sty+stp.dy
pos.x=decx+lerp(stx,cumx,(xflat-stp.fl)/stp.dst)
pos.y=lerp(sty,cumy,(xflat-stp.fl)/stp.dst)
newz=stp.rotz
break
end
end
-- keep rotation
if rot~=nil then
rot.z=lerp(rot.z,newz,0.25)
newz=rot.z
end
if pos.yflat then
pos.x=pos.x
+sin(1-newz)*pos.yflat
pos.y=ytmp+pos.y
+cos(newz)*pos.yflat
end
...
-- gravity
if spdp~=nil then
local gx=sin(newz)
spdp.x-=gx
end
end
Les autres vaisseaux, ceux que l’on essaye de rattraper et que l’on ne controle pas sont affichés de la même façon. La seule différence est liée à la façon dont les virages sont affichés, par accumulation de décalage comme les jeux rétro de courses type Outrun. Cette accumulation de décalage doit être prise en compte sur l’axe X. Si on l’oubli, les vaisseaux seront toujours affichés au milieu de l’écran, ce qui est problématique pendant les virages.
L’ordre est aussi un élément important. Dans ce type d’affichage, on stock les éléments à afficher directement dans le bloc de piste qui le contient. L’affichage consiste à dessiner chaque élément de route puis les éléments qu’elle contient, du plus loin au plus prêt. Il est nécessaire de trier uniquement si un bloc de piste contient plusieurs vaisseaux. Ceci limite fortement les opérations de tri, car les moments où il y a un maximum de vaisseau sur un seul bloc de piste ne sont pas si fréquents.
Ces mécanismes sont bien détaillées dans le lien à la fin de l’article, provenant du site code incomplete.
J’espère que vous avez aimé ces explications et qu’elles ont pu vous être utiles, si vous avez des questions vous pouvez laisser un commentaire ou vous pouvez me contacter sur le BBS de Lexaloffle.
Have fun !
Ressources: