463 lines
19 KiB
Text
463 lines
19 KiB
Text
[#_lesson_22_into_3d]
|
||
== Leçon 22 : Passer à la 3D
|
||
|
||
image::learn/22-3d.png[La leçon terminée, 480]
|
||
|
||
Tout ce que vous avez dessiné jusqu'ici était plat. Les sprites, le texte, les
|
||
boîtes et les lignes vivent tous sur l'overlay, qui est une plaque de verre à
|
||
l'avant de la fenêtre, avec les mêmes coordonnées qu'à la leçon un. Cette leçon
|
||
met un monde derrière ce verre : un endroit qui a de la profondeur, où une
|
||
chose peut être plus près ou plus loin, et où une caméra décide de ce que vous
|
||
en voyez.
|
||
|
||
À la fin, vous aurez une boîte qui flotte dans l'espace et que vous pouvez
|
||
tourner avec les touches fléchées, avec les nombres de ses angles affichés dans
|
||
le coin par le même `overlayPrint` que celui de votre tout premier jour. Ce
|
||
dernier point compte plus qu'il n'en a l'air, et une section lui est
|
||
consacrée.
|
||
|
||
[#_a_scene_a_box_and_a_light]
|
||
=== Une scène, une boîte et une lumière
|
||
|
||
Créez un dossier, mettez-y un fichier appelé `spin.singe`, et tapez ceci.
|
||
|
||
[source,lua]
|
||
----
|
||
sceneEnable(true)
|
||
sceneSetBackground(18, 20, 34)
|
||
sceneSetAmbient(35, 35, 45)
|
||
|
||
local paint = materialNew()
|
||
materialSetColor(paint, 215, 95, 60)
|
||
materialSetRoughness(paint, 0.6)
|
||
|
||
local box = nodeNew()
|
||
nodeSetMesh(box, meshBox(2, 2, 2), paint)
|
||
|
||
local sun = lightNew(LIGHT_DIRECTIONAL)
|
||
nodeSetPosition(sun, 4, 6, 5)
|
||
nodeLookAt(sun, 0, 0, 0)
|
||
|
||
|
||
function onOverlayUpdate()
|
||
nodeRotate(box, 0, 0.5, 0)
|
||
return OVERLAY_UPDATED
|
||
end
|
||
----
|
||
|
||
Lancez-le avec `Singe -R spin`. Une boîte orange tourne lentement sur un fond
|
||
bleu foncé, éclairée d'en haut et de la droite, avec une face claire et les
|
||
autres qui s'enfoncent dans l'ombre.
|
||
|
||
Il n'y a pas de caméra dans ce script. Singe vous a placé en `(0, 0, 5)`, à
|
||
regarder le milieu du monde, parce qu'une scène sans caméra doit bien être vue
|
||
de quelque part. Vous allez remplacer ça par votre propre caméra dans un
|
||
instant.
|
||
|
||
Si la fenêtre reste noire, lisez la console. Sur une machine dont la carte
|
||
graphique ne sait pas dessiner une scène 3D, `sceneEnable` arrête le jeu avec un
|
||
message qui le dit, et il n'y a rien d'autre à faire que de suivre la leçon
|
||
ailleurs. Les jeux 2D continuent de marcher sur une telle machine ; la 3D,
|
||
non.
|
||
|
||
[#_what_just_happened_18]
|
||
=== Ce qui vient de se passer
|
||
|
||
Les trois appels du haut mettent en place le monde dans son ensemble.
|
||
|
||
[source,lua]
|
||
----
|
||
sceneEnable(true)
|
||
sceneSetBackground(18, 20, 34)
|
||
sceneSetAmbient(35, 35, 45)
|
||
----
|
||
|
||
`sceneEnable(true)` allume la couche 3D. C'est le seul appel que fait chaque
|
||
jeu en 3D, et tant que vous ne l'avez pas fait, rien de ce que vous construisez
|
||
dans la scène n'est dessiné.
|
||
|
||
`sceneSetBackground` est la couleur avec laquelle la scène est effacée à chaque
|
||
image, rouge, vert et bleu de `0` à `255`, comme les couleurs marchent depuis
|
||
la leçon neuf. Il y a un quatrième nombre que vous pouvez lui donner pour la
|
||
transparence, et c'est ainsi qu'un jeu qui lit de la vidéo met un objet 3D
|
||
devant le film. Laissez-le de côté et le fond est opaque.
|
||
|
||
`sceneSetAmbient` est un peu de lumière qui vient de toutes les directions à la
|
||
fois. Son travail est d'empêcher les côtés qu'aucune lampe n'atteint d'être
|
||
complètement noirs. Baissez-la plus tard à `0, 0, 0` et voyez à quel point
|
||
l'image devient dure.
|
||
|
||
[source,lua]
|
||
----
|
||
local paint = materialNew()
|
||
materialSetColor(paint, 215, 95, 60)
|
||
materialSetRoughness(paint, 0.6)
|
||
----
|
||
|
||
Un *matériau*, c'est l'aspect d'une surface : sa couleur, à quel point elle
|
||
brille, si elle porte une image. `materialNew` en crée un et renvoie un nombre
|
||
qui le représente, le même genre de handle que celui que `spriteLoad` vous a
|
||
donné à la leçon neuf. Gardez-le dans une variable, parce que tous les appels
|
||
suivants en ont besoin.
|
||
|
||
`materialSetRoughness` va de `0`, un miroir avec un petit reflet dur, à `1`,
|
||
une surface mate comme de la craie sans aucun reflet. `0.6`, c'est de la
|
||
peinture sur du bois. Il existe une douzaine d'autres appels `materialSet`, et
|
||
la prochaine leçon en utilise quelques-uns ; la section Matériau de la
|
||
référence les liste tous.
|
||
|
||
[source,lua]
|
||
----
|
||
local box = nodeNew()
|
||
nodeSetMesh(box, meshBox(2, 2, 2), paint)
|
||
----
|
||
|
||
C'est le plus important. Un *nœud* est un endroit dans le monde. Il a une
|
||
position, une rotation et une échelle, et à lui seul il ne dessine rien du
|
||
tout : c'est un point auquel on peut attacher des choses. Tout dans une scène
|
||
3D est un nœud, y compris les lumières et la caméra.
|
||
|
||
`nodeNew` en crée un vide au milieu du monde. `nodeSetMesh` y accroche deux
|
||
choses : un *maillage*, qui est la forme, et le matériau, qui est l'aspect de
|
||
cette forme. `meshBox(2, 2, 2)` construit une boîte de deux unités de large,
|
||
deux de haut et deux de profondeur, et renvoie un handle vers elle, comme
|
||
`materialNew`.
|
||
|
||
Les maillages et les matériaux gagnent à être partagés. Un seul handle de
|
||
`meshBox` peut servir à cent nœuds, et c'est comme ça qu'on dessine cent
|
||
caisses à peu de frais. Fabriquer cent boîtes séparées qui se trouvent avoir la
|
||
même taille, c'est la façon débutante de rendre une scène lente.
|
||
|
||
[source,lua]
|
||
----
|
||
local sun = lightNew(LIGHT_DIRECTIONAL)
|
||
nodeSetPosition(sun, 4, 6, 5)
|
||
nodeLookAt(sun, 0, 0, 0)
|
||
----
|
||
|
||
`lightNew` crée aussi un nœud -- un nœud qui porte une lumière -- et renvoie
|
||
son handle de nœud, si bien que vous le placez et l'orientez avec les mêmes
|
||
appels que pour tout le reste. `LIGHT_DIRECTIONAL` est un soleil : il est si
|
||
loin que seule compte la direction dans laquelle il brille, et c'est pour ça
|
||
que la position ne compte ici que parce que `nodeLookAt` s'en sert pour
|
||
calculer la direction.
|
||
|
||
Retirez ces trois lignes et relancez. La boîte devient presque noire, parce
|
||
que la seule lumière qui reste est la faible lumière ambiante. La lumière,
|
||
c'est toute la leçon vingt-trois, et voici un premier avant-goût de la
|
||
déception la plus courante en 3D : le modèle s'est bien chargé, et la scène est
|
||
noire parce que rien ne l'éclaire.
|
||
|
||
[source,lua]
|
||
----
|
||
nodeRotate(box, 0, 0.5, 0)
|
||
----
|
||
|
||
`nodeRotate` tourne un nœud d'un certain nombre de degrés autour de chacun de
|
||
ses trois axes à lui, en plus de la rotation qu'il avait déjà. Un demi-degré
|
||
par image, à une soixantaine d'images par seconde, ça fait un tour toutes les
|
||
douze secondes. C'est le même truc qu'à la leçon deux : un petit changement,
|
||
appliqué à chaque image, se voit comme un mouvement.
|
||
|
||
[#_which_way_is_which]
|
||
=== Quel axe va où
|
||
|
||
Trois nombres décrivent un endroit dans la scène, et vous devez savoir ce que
|
||
chacun veut dire avant de pouvoir mettre quoi que ce soit où que ce soit.
|
||
|
||
* *X* va vers la droite. Un X plus grand est plus à droite.
|
||
* *Y* va vers le haut. Un Y plus grand est plus haut. Le sol d'une scène est en
|
||
général à `y = 0`.
|
||
* *Z* vient vers vous, en sortant de l'écran. Un Z plus grand est plus près de
|
||
la caméra par défaut, et *le Z négatif est la direction dans laquelle
|
||
regarde la caméra*.
|
||
|
||
Ce dernier point piège tout le monde. Ce que vous voulez devant la caméra va
|
||
en Z négatif. Ce n'est pas une bizarrerie de Singe : glTF et Blender utilisent
|
||
la même disposition, donc un modèle exporté depuis Blender arrive tourné dans
|
||
le sens qu'attend Singe.
|
||
|
||
Ne me croyez pas sur parole. Ajoutez cette ligne après la ligne
|
||
`nodeSetMesh`, enregistrez, et regardez :
|
||
|
||
[source,lua]
|
||
----
|
||
nodeSetPosition(box, 3, 1, 0)
|
||
----
|
||
|
||
La boîte saute vers la droite et vers le haut. `nodeSetPosition` place un nœud
|
||
à un endroit exact, et remplace la position qu'avait le nœud. Il existe un
|
||
`nodeMove(node, dx, dy, dz)` qui va avec et qui déplace plutôt un nœud d'une
|
||
certaine quantité, le long de ses propres axes, si bien que
|
||
`nodeMove(ship, 0, 0, -0.1)` fait avancer un vaisseau dans la direction où il
|
||
se trouve pointé, quelle qu'elle soit.
|
||
|
||
Essayez ensuite `nodeSetPosition(box, 0, 0, -20)`. La boîte rapetisse au
|
||
loin : c'est le Z négatif qui fait son travail. Puis essayez `(0, 0, 20)` : la
|
||
boîte disparaît, parce que vous l'avez mise derrière la caméra.
|
||
|
||
Deux appels de plus complètent la série.
|
||
|
||
[source,lua]
|
||
----
|
||
nodeSetRotation(box, 0, 45, 0)
|
||
nodeSetScale(box, 0.5)
|
||
----
|
||
|
||
`nodeSetRotation` règle directement la rotation en degrés, là où `nodeRotate`
|
||
y ajoute. Les trois nombres sont autour de X, autour de Y et autour de Z :
|
||
tourner autour de Y, c'est ce que fait une personne debout sur le sol quand
|
||
elle se tourne pour regarder ailleurs, et c'est la rotation dont vous vous
|
||
servirez le plus. `nodeSetScale` rend le nœud plus grand ou plus petit, soit avec un
|
||
seul nombre pour les trois axes, soit avec trois, un pour chacun.
|
||
|
||
Retirez ces deux expériences avant de continuer. La suite de la leçon part du
|
||
script tel que vous l'avez tapé au début.
|
||
|
||
[#_your_own_camera]
|
||
=== Votre propre caméra
|
||
|
||
La vue par défaut est une politesse, pas une fonctionnalité. Mettez une caméra.
|
||
|
||
[source,lua]
|
||
----
|
||
local camera = nodeNew()
|
||
nodeSetPosition(camera, 0, 2, 7)
|
||
nodeLookAt(camera, 0, 0, 0)
|
||
cameraSet(camera)
|
||
cameraSetPerspective(60, 0.1, 100)
|
||
----
|
||
|
||
Mettez ça sous la lumière, avant votre `onOverlayUpdate`, et lancez. Vous
|
||
regardez maintenant la boîte légèrement de haut, depuis deux unités plus haut
|
||
et sept unités en arrière.
|
||
|
||
Une caméra est un nœud comme les autres. `cameraSet` indique au moteur depuis
|
||
quel nœud regarder, et la scène est dessinée depuis la position de ce nœud, en
|
||
regardant le long de son propre Z négatif. Orienter une caméra à la main avec
|
||
`nodeSetRotation` serait une torture, alors `nodeLookAt` le fait pour vous :
|
||
donnez-lui un point du monde et elle tourne le nœud pour lui faire face.
|
||
|
||
`cameraSetPerspective`, c'est l'objectif. Le premier nombre est le champ de
|
||
vision en degrés, de haut en bas : la quantité de monde qui est tassée dans la
|
||
hauteur de la fenêtre. Un petit nombre comme `35` donne un téléobjectif qui
|
||
aplatit tout ; un grand comme `90` donne un grand-angle qui fait paraître la
|
||
pièce immense et bomber les coins. `60` est une valeur par défaut confortable.
|
||
Les deux autres nombres sont la distance la plus proche et la plus lointaine
|
||
qui sont dessinées, et ce qui se trouve hors de cette plage n'est pas dessiné
|
||
du tout.
|
||
|
||
Comme la caméra est un nœud ordinaire, tout ce que vous avez appris sur les
|
||
nœuds marche avec elle. Plus tard, vous accrocherez une caméra sous une
|
||
voiture qui roule et vous aurez une caméra de poursuite pour rien.
|
||
|
||
[#_turning_it_with_the_arrow_keys]
|
||
=== La tourner avec les touches fléchées
|
||
|
||
Lire le système de coordonnées sur une page est une mauvaise façon de
|
||
l'apprendre. Y déplacer quelque chose en est une bonne. Remplacez votre
|
||
`onOverlayUpdate` par ceci, et ajoutez les deux nouveaux callbacks et les
|
||
quatre variables au-dessus.
|
||
|
||
[source,lua]
|
||
----
|
||
local turnLeft = false
|
||
local turnRight = false
|
||
local tiltUp = false
|
||
local tiltDown = false
|
||
----
|
||
|
||
Celles-ci vont tout en haut du fichier, au-dessus de tout le reste.
|
||
|
||
[source,lua]
|
||
----
|
||
function onInputPressed(what)
|
||
if what == SWITCH_LEFT then
|
||
turnLeft = true
|
||
elseif what == SWITCH_RIGHT then
|
||
turnRight = true
|
||
elseif what == SWITCH_UP then
|
||
tiltUp = true
|
||
elseif what == SWITCH_DOWN then
|
||
tiltDown = true
|
||
end
|
||
end
|
||
|
||
|
||
function onInputReleased(what)
|
||
if what == SWITCH_LEFT then
|
||
turnLeft = false
|
||
elseif what == SWITCH_RIGHT then
|
||
turnRight = false
|
||
elseif what == SWITCH_UP then
|
||
tiltUp = false
|
||
elseif what == SWITCH_DOWN then
|
||
tiltDown = false
|
||
end
|
||
end
|
||
|
||
|
||
function onOverlayUpdate()
|
||
if turnLeft then
|
||
nodeRotate(box, 0, -1.5, 0)
|
||
end
|
||
if turnRight then
|
||
nodeRotate(box, 0, 1.5, 0)
|
||
end
|
||
if tiltUp then
|
||
nodeRotate(box, -1.5, 0, 0)
|
||
end
|
||
if tiltDown then
|
||
nodeRotate(box, 1.5, 0, 0)
|
||
end
|
||
|
||
return OVERLAY_UPDATED
|
||
end
|
||
----
|
||
|
||
`onInputPressed` et `onInputReleased` sont des callbacks, comme
|
||
`onOverlayUpdate` : vous les écrivez, et Singe les appelle quand une commande
|
||
s'enfonce ou remonte. Un appui arrive une fois, pas en boucle tant que la
|
||
touche est tenue, et c'est pour ça que chacun règle une variable et que le
|
||
callback de dessin fait le travail. Ce schéma -- l'appui lève un drapeau,
|
||
l'image lit le drapeau -- est la façon d'obtenir « tant que la touche est
|
||
tenue » d'un moteur qui ne vous parle que des changements.
|
||
|
||
Les noms en `SWITCH_` viennent du moteur lui-même, donc il n'y a rien à
|
||
charger pour s'en servir. Ce ne sont pas non plus des touches. Ils sont ce
|
||
qu'une commande *veut dire*, et `controls.cfg` décide quelle touche, quel
|
||
bouton ou quelle direction de stick produit chacun d'eux. Aujourd'hui, vos
|
||
touches fléchées envoient `SWITCH_LEFT` et compagnie ; le stick d'une manette
|
||
envoie la même chose, et votre script ne voit jamais la différence.
|
||
|
||
Les signes sont choisis pour que chaque flèche déplace la face que vous
|
||
regardez dans la direction où vous avez appuyé. Tourner autour de Y d'un angle
|
||
positif fait pivoter cette face proche vers la droite, donc la flèche gauche
|
||
demande un angle négatif. Si vous trouvez ça à l'envers, inversez les signes ;
|
||
ce sont vos commandes, pas celles du moteur.
|
||
|
||
[#_the_overlay_is_still_on_top]
|
||
=== L'overlay est toujours au-dessus
|
||
|
||
Ajoutez ces lignes à `onOverlayUpdate`, au-dessus du `return`.
|
||
|
||
[source,lua]
|
||
----
|
||
local pitch, yaw = nodeGetRotation(box)
|
||
|
||
overlayClear()
|
||
overlayPrint(2, 2, "Arrow keys turn the box.")
|
||
overlayPrint(2, 4, "yaw " .. math.floor(yaw) .. " pitch " .. math.floor(pitch))
|
||
----
|
||
|
||
Rien là-dedans n'est nouveau, sauf ce par-dessus quoi c'est affiché.
|
||
`overlayPrint` est l'appel de la leçon un, qui compte en cellules de caractère
|
||
et n'a besoin d'aucune police chargée, et il dessine par-dessus une scène 3D
|
||
sans qu'on lui en ait rien dit.
|
||
|
||
C'est ainsi que les choses sont disposées, et ça mérite d'être dit
|
||
clairement : Singe dessine d'abord la vidéo, puis la scène 3D par-dessus, puis
|
||
l'overlay par-dessus le tout. Votre score, vos vies, votre minuterie, votre
|
||
réticule et vos sous-titres relèvent tous de l'overlay, exactement comme dans
|
||
la deuxième partie. Un jeu en 3D garde son score dans le coin avec les mêmes
|
||
cinq lignes qu'un jeu en 2D.
|
||
|
||
`nodeGetRotation` renvoie trois nombres, les angles autour de X, de Y et de Z,
|
||
et cette ligne garde les deux premiers dans `pitch` et `yaw`. Ce sont leurs
|
||
noms habituels en anglais : pitch, le tangage, c'est le nez qui monte et qui
|
||
descend ; yaw, le lacet, c'est tourner à gauche et à droite. `math.floor` jette
|
||
la partie décimale pour que les nombres arrêtent de clignoter.
|
||
|
||
Regardez le yaw pendant que vous tenez la flèche gauche. Il descend au-delà de
|
||
`-180` et revient par `180`, parce que les angles sont donnés comme le plus
|
||
petit tour qui mène là et non comme un total cumulé. Si votre jeu a besoin de
|
||
savoir combien de fois le joueur a tourné sur lui-même, comptez-le vous-même.
|
||
|
||
[#_try_it_22]
|
||
=== Essayez
|
||
|
||
. *Déplacez la caméra.* Changez `nodeSetPosition(camera, 0, 2, 7)` en
|
||
`(0, 8, 7)`, puis en `(7, 2, 7)`, puis en `(0, 0.2, 7)`. À chaque fois, le
|
||
`nodeLookAt` garde la boîte au centre, si bien que vous obtenez un autre
|
||
angle sur la même scène pour un seul nombre.
|
||
. *Changez l'objectif.* Essayez `cameraSetPerspective(30, 0.1, 100)`, puis
|
||
`(90, 0.1, 100)`, sans bouger la caméra. Un jeu paraît rapide avec un champ
|
||
de vision large et majestueux avec un champ étroit, et c'est le réglage
|
||
d'ambiance le moins cher qui soit.
|
||
. *Ajoutez une deuxième boîte.* Copiez les deux lignes qui ont créé la boîte,
|
||
renommez la variable en `mark`, et donnez-lui
|
||
`nodeSetPosition(mark, 3, 0, 0)` et `nodeSetScale(mark, 0.3)`. Vous avez
|
||
maintenant un repère pour juger la première boîte. Mettez-la tour à tour en
|
||
`(0, 3, 0)` et en `(0, 0, -3)` et dites à voix haute quel axe est lequel.
|
||
. *Partagez le maillage.* Au lieu d'appeler `meshBox` deux fois, gardez le
|
||
premier dans une variable et donnez le même handle aux deux nœuds. Rien n'a
|
||
l'air différent. C'est tout l'intérêt : une forme, deux endroits.
|
||
. *Accrochez la caméra à la boîte.* Gardez la deuxième boîte de l'étape
|
||
précédente, sinon il ne restera rien par rapport à quoi bouger. Puis cherchez
|
||
`nodeSetParent` dans la référence et accrochez la caméra sous la première
|
||
boîte. Tenez une touche fléchée : la caméra suit la rotation de la boîte,
|
||
donc la boîte reste immobile et le reste de la scène défile. Vous êtes à une
|
||
ligne d'une vue à la première personne, et c'est aussi comme ça que vous
|
||
découvrez qu'une caméra de poursuite demande un parent plutôt que de
|
||
l'arithmétique.
|
||
|
||
[#_break_it_on_purpose_21]
|
||
=== Cassez-le exprès
|
||
|
||
Faites une faute de frappe dans le nom du matériau. Changez la ligne
|
||
`nodeSetMesh` pour qu'elle utilise `pain` au lieu de `paint` :
|
||
|
||
[source,lua]
|
||
----
|
||
nodeSetMesh(box, meshBox(2, 2, 2), pain)
|
||
----
|
||
|
||
Singe s'arrête avant que le jeu démarre et affiche quelque chose comme :
|
||
|
||
----
|
||
15:nodeSetMesh: Argument 3 must be a number.
|
||
----
|
||
|
||
C'est une erreur d'une autre forme que celle de la leçon un, et elle vient du
|
||
moteur plutôt que de Lua. Le nombre au début, c'est la ligne. Le nom qui suit,
|
||
c'est l'appel qui s'est plaint. La plainte elle-même, c'est que la troisième
|
||
chose que vous lui avez passée n'était pas un nombre.
|
||
|
||
La raison mérite d'être suivie, parce qu'elle explique toute une famille
|
||
d'erreurs. `pain` est une variable à laquelle on n'a jamais donné de valeur, et
|
||
en Lua une variable vide vaut `nil` -- la valeur qui veut dire « rien ici ».
|
||
Passer `nil` à un appel qui attend un handle de matériau donne exactement ça.
|
||
Quand vous voyez « must be a number » venant d'un appel du moteur, cherchez un
|
||
nom de variable mal orthographié sur cette ligne, ou un handle que vous avez
|
||
oublié de garder.
|
||
|
||
[#_what_you_learned_22]
|
||
=== Ce que vous avez appris
|
||
|
||
* `sceneEnable(true)` allume la couche 3D, et rien n'est dessiné en 3D tant que
|
||
vous ne l'avez pas appelé.
|
||
* Un nœud est un endroit dans le monde, avec une position, une rotation et une
|
||
échelle. Les maillages, les lumières et les caméras s'accrochent tous à des
|
||
nœuds.
|
||
* Un maillage est la forme et un matériau est l'aspect, et chacun peut être
|
||
partagé par autant de nœuds que vous voulez.
|
||
* X va à droite, Y va en haut, et le Z négatif est la direction où regarde la
|
||
caméra.
|
||
* `nodeSetPosition` et `nodeSetRotation` règlent une valeur directement ;
|
||
`nodeMove` et `nodeRotate` la changent d'une certaine quantité.
|
||
* N'importe quel nœud peut être la caméra. `nodeLookAt` l'oriente et
|
||
`cameraSet` la choisit.
|
||
* Le champ de vision de `cameraSetPerspective` change la sensation que donne la
|
||
scène sans rien déplacer.
|
||
* Une scène sans lumière est noire, et c'est la première chose à vérifier
|
||
quand rien n'apparaît.
|
||
* L'overlay est toujours là, toujours au-dessus, et c'est toujours là que va
|
||
votre score.
|
||
|
||
[#_next_time_22]
|
||
=== La prochaine fois
|
||
|
||
Une boîte fait un piètre dragon. La prochaine leçon charge un vrai modèle --
|
||
un modèle que le moteur a déjà extrait dans votre dossier de travail, donc il
|
||
n'y a rien à télécharger ni rien à dessiner -- le pose sur le sol, et l'éclaire
|
||
correctement. C'est la leçon vingt-trois.
|