447 lines
18 KiB
Text
447 lines
18 KiB
Text
[#_lesson_22_into_3d]
|
|
== Lección 22: Hacia el 3D
|
|
|
|
image::learn/22-3d.png[La lección terminada, 480]
|
|
|
|
Todo lo que has dibujado hasta ahora ha sido plano. Los sprites, el texto, las
|
|
cajas y las líneas viven en el overlay, que es una lámina de cristal en la parte
|
|
delantera de la ventana con las mismas coordenadas que tenía en la lección uno.
|
|
Esta lección pone un mundo detrás de ese cristal: un sitio con profundidad,
|
|
donde una cosa puede estar más cerca o más lejos, y donde una cámara decide qué
|
|
ves de él.
|
|
|
|
Al final tendrás una caja flotando en el espacio que puedes girar con las teclas
|
|
de flecha, y los números de sus ángulos impresos en la esquina con el mismo
|
|
`overlayPrint` que usaste el primer día. Esa última parte importa más de lo que
|
|
parece, y tiene su propia sección.
|
|
|
|
[#_a_scene_a_box_and_a_light]
|
|
=== Una escena, una caja y una luz
|
|
|
|
Crea una carpeta, pon dentro un archivo llamado `spin.singe` y escribe esto.
|
|
|
|
[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
|
|
----
|
|
|
|
Ejecútalo con `Singe -R spin`. Una caja naranja gira despacio sobre un fondo
|
|
azul oscuro, iluminada desde arriba y desde la derecha, con una cara brillante y
|
|
las demás perdiéndose en la sombra.
|
|
|
|
En ese script no hay ninguna cámara. Singe te ha puesto en `(0, 0, 5)` mirando
|
|
al centro del mundo, porque una escena sin cámara tiene que verse desde algún
|
|
sitio. Dentro de un momento lo sustituirás por tu propia cámara.
|
|
|
|
Si la ventana se queda negra, lee la consola. En una máquina cuyos gráficos no
|
|
pueden dibujar una escena 3D, `sceneEnable` detiene el juego con un mensaje que
|
|
lo dice, y no hay nada que hacer salvo seguir la lección en otro sitio. Los
|
|
juegos 2D siguen funcionando en una máquina así; el 3D no.
|
|
|
|
[#_what_just_happened_18]
|
|
=== Qué acaba de pasar
|
|
|
|
Las tres llamadas de arriba preparan el mundo en su conjunto.
|
|
|
|
[source,lua]
|
|
----
|
|
sceneEnable(true)
|
|
sceneSetBackground(18, 20, 34)
|
|
sceneSetAmbient(35, 35, 45)
|
|
----
|
|
|
|
`sceneEnable(true)` enciende la capa 3D. Es la única llamada que hacen todos los
|
|
juegos 3D, y hasta que la hagas no se dibuja nada de lo que construyas en la
|
|
escena.
|
|
|
|
`sceneSetBackground` es el color con el que se borra la escena en cada
|
|
fotograma, rojo, verde y azul de `0` a `255`, igual que han funcionado los
|
|
colores desde la lección nueve. Hay un cuarto número que puedes darle para la
|
|
transparencia, que es como un juego que reproduce vídeo pone un objeto 3D
|
|
delante de la película. Si no lo pones, el fondo es opaco.
|
|
|
|
`sceneSetAmbient` es un poco de luz que llega de todas las direcciones a la vez.
|
|
Su trabajo es evitar que los lados a los que no llega ninguna lámpara queden
|
|
completamente negros. Más adelante bájala a `0, 0, 0` y mira lo dura que se
|
|
vuelve la imagen.
|
|
|
|
[source,lua]
|
|
----
|
|
local paint = materialNew()
|
|
materialSetColor(paint, 215, 95, 60)
|
|
materialSetRoughness(paint, 0.6)
|
|
----
|
|
|
|
Un *material* es el aspecto de una superficie: su color, lo brillante que es, si
|
|
lleva una imagen encima. `materialNew` crea uno y te devuelve un número que lo
|
|
representa, el mismo tipo de handle que te dio `spriteLoad` en la lección nueve.
|
|
Guárdalo en una variable, porque todas las llamadas posteriores lo necesitan.
|
|
|
|
`materialSetRoughness` va de `0`, un espejo con un reflejo diminuto y duro, a
|
|
`1`, una superficie mate como de tiza sin ningún reflejo. `0.6` es pintura sobre
|
|
madera. Hay una docena más de llamadas `materialSet`, y la próxima lección usa
|
|
algunas; la sección Material de la referencia las enumera todas.
|
|
|
|
[source,lua]
|
|
----
|
|
local box = nodeNew()
|
|
nodeSetMesh(box, meshBox(2, 2, 2), paint)
|
|
----
|
|
|
|
Esta es la importante. Un *nodo* es un sitio en el mundo. Tiene una posición,
|
|
una rotación y una escala, y por sí solo no dibuja absolutamente nada: es un
|
|
punto al que se le pueden enganchar cosas. Todo en una escena 3D es un nodo,
|
|
incluidas las luces y la cámara.
|
|
|
|
`nodeNew` crea uno vacío en el centro del mundo. `nodeSetMesh` le cuelga dos
|
|
cosas: una *malla*, que es la forma, y el material, que es el aspecto de esa
|
|
forma. `meshBox(2, 2, 2)` construye una caja de dos unidades de ancho, dos de
|
|
alto y dos de fondo, y te devuelve un handle a ella igual que hizo
|
|
`materialNew`.
|
|
|
|
Vale la pena compartir las mallas y los materiales. Un mismo handle de `meshBox`
|
|
lo pueden usar cien nodos, y así es como se dibujan cien cajas sin que cueste
|
|
caro. Hacer cien cajas separadas que resultan tener el mismo tamaño es la forma
|
|
de principiante de volver lenta una escena.
|
|
|
|
[source,lua]
|
|
----
|
|
local sun = lightNew(LIGHT_DIRECTIONAL)
|
|
nodeSetPosition(sun, 4, 6, 5)
|
|
nodeLookAt(sun, 0, 0, 0)
|
|
----
|
|
|
|
`lightNew` también crea un nodo -- uno que lleva una luz -- y te devuelve su
|
|
handle de nodo, así que lo colocas y lo apuntas con las mismas llamadas que usas
|
|
para todo lo demás. `LIGHT_DIRECTIONAL` es un sol: está tan lejos que solo
|
|
cuenta la dirección en la que brilla, y por eso aquí la posición solo importa
|
|
porque `nodeLookAt` la usa para calcular la dirección.
|
|
|
|
Quita esas tres líneas y vuelve a ejecutarlo. La caja se queda casi negra,
|
|
porque la única luz que queda es la tenue luz ambiente. La lección veintitrés
|
|
trata entera de la luz, y este es tu primer contacto con la decepción más común
|
|
del 3D: el modelo se ha cargado bien, y la escena está negra porque no le da
|
|
ninguna luz.
|
|
|
|
[source,lua]
|
|
----
|
|
nodeRotate(box, 0, 0.5, 0)
|
|
----
|
|
|
|
`nodeRotate` gira un nodo tantos grados alrededor de cada uno de sus propios
|
|
tres ejes, además de la rotación que ya tuviera. Medio grado por fotograma, a
|
|
unos sesenta fotogramas por segundo, es una vuelta cada doce segundos. Es el
|
|
mismo truco de la lección dos: un cambio pequeño, aplicado en cada fotograma, se
|
|
ve como movimiento.
|
|
|
|
[#_which_way_is_which]
|
|
=== Hacia dónde va cada eje
|
|
|
|
Tres números describen un sitio en la escena, y tienes que saber qué significa
|
|
cada uno antes de poder poner nada en ningún sitio.
|
|
|
|
* *X* va hacia la derecha. Una X mayor está más a la derecha.
|
|
* *Y* va hacia arriba. Una Y mayor está más alta. El suelo de una escena suele
|
|
ser `y = 0`.
|
|
* *Z* va hacia ti, saliendo de la pantalla. Una Z mayor está más cerca de la
|
|
cámara por defecto, y *la Z negativa es la dirección hacia la que mira la
|
|
cámara*.
|
|
|
|
Esa última pilla a todo el mundo. Las cosas que quieres delante de la cámara van
|
|
en Z negativa. No es una manía de Singe: glTF y Blender usan la misma
|
|
disposición, así que un modelo exportado desde Blender llega mirando hacia donde
|
|
Singe espera.
|
|
|
|
No te fíes de mi palabra en nada de esto. Añade esta línea después de la línea
|
|
`nodeSetMesh`, guarda y mira:
|
|
|
|
[source,lua]
|
|
----
|
|
nodeSetPosition(box, 3, 1, 0)
|
|
----
|
|
|
|
La caja salta hacia la derecha y hacia arriba. `nodeSetPosition` pone un nodo en
|
|
un sitio exacto, y sustituye la posición que tuviera el nodo. Hay un
|
|
`nodeMove(node, dx, dy, dz)` a juego que, en cambio, desplaza un nodo una
|
|
cantidad, a lo largo de sus propios ejes, así que `nodeMove(ship, 0, 0, -0.1)`
|
|
lleva una nave hacia delante apunte hacia donde apunte.
|
|
|
|
Prueba después `nodeSetPosition(box, 0, 0, -20)`. La caja se encoge en la
|
|
distancia, que es la Z negativa haciendo su trabajo. Luego prueba `(0, 0, 20)`:
|
|
la caja desaparece, porque la has puesto detrás de la cámara.
|
|
|
|
Dos llamadas más completan el conjunto.
|
|
|
|
[source,lua]
|
|
----
|
|
nodeSetRotation(box, 0, 45, 0)
|
|
nodeSetScale(box, 0.5)
|
|
----
|
|
|
|
`nodeSetRotation` fija la rotación directamente en grados, mientras que
|
|
`nodeRotate` le suma. Los tres números son alrededor de X, alrededor de Y y
|
|
alrededor de Z: girar alrededor de Y es lo que hace una persona de pie en el
|
|
suelo cuando se gira para mirar hacia otro lado, y es el que más vas a usar.
|
|
`nodeSetScale` hace el nodo más grande o más pequeño, con un número para los
|
|
tres ejes o con tres, uno para cada uno.
|
|
|
|
Quita esos dos experimentos antes de seguir. El resto de la lección parte del
|
|
script tal como lo escribiste al principio.
|
|
|
|
[#_your_own_camera]
|
|
=== Tu propia cámara
|
|
|
|
La vista por defecto es una cortesía, no una característica. Pon una cámara.
|
|
|
|
[source,lua]
|
|
----
|
|
local camera = nodeNew()
|
|
nodeSetPosition(camera, 0, 2, 7)
|
|
nodeLookAt(camera, 0, 0, 0)
|
|
cameraSet(camera)
|
|
cameraSetPerspective(60, 0.1, 100)
|
|
----
|
|
|
|
Ponlo debajo de la luz, antes de tu `onOverlayUpdate`, y ejecútalo. Ahora miras
|
|
la caja un poco desde arriba, desde dos unidades más arriba y siete más atrás.
|
|
|
|
Una cámara es un nodo como cualquier otro. `cameraSet` le dice al motor desde
|
|
qué nodo mirar, y la escena se dibuja desde la posición de ese nodo mirando a lo
|
|
largo de la Z negativa del propio nodo. Apuntar una cámara a mano con
|
|
`nodeSetRotation` sería un suplicio, así que `nodeLookAt` lo hace por ti: dale
|
|
un punto del mundo y gira el nodo para que lo mire.
|
|
|
|
`cameraSetPerspective` es el objetivo. El primer número es el campo de visión en
|
|
grados, de arriba abajo: cuánto mundo se mete en la altura de la ventana. Un
|
|
número pequeño como `35` es un teleobjetivo que lo aplana todo; uno grande como
|
|
`90` es un gran angular que hace que la habitación parezca enorme y que las
|
|
esquinas se abomben. `60` es un valor por defecto cómodo. Los otros dos números
|
|
son las distancias más cercana y más lejana que se dibujan, y lo que queda fuera
|
|
de ese rango no se dibuja en absoluto.
|
|
|
|
Como la cámara es un nodo normal, todo lo que has aprendido sobre los nodos
|
|
funciona con ella. Más adelante colgarás una cámara debajo de un coche en
|
|
movimiento y tendrás una cámara de persecución gratis.
|
|
|
|
[#_turning_it_with_the_arrow_keys]
|
|
=== Girarla con las teclas de flecha
|
|
|
|
Leer el sistema de coordenadas en una página es una mala forma de aprenderlo.
|
|
Mover algo dentro de él es una buena. Sustituye tu `onOverlayUpdate` por esto, y
|
|
añade encima los dos callbacks nuevos y las cuatro variables.
|
|
|
|
[source,lua]
|
|
----
|
|
local turnLeft = false
|
|
local turnRight = false
|
|
local tiltUp = false
|
|
local tiltDown = false
|
|
----
|
|
|
|
Esas van al principio del todo del archivo, encima de todo lo demás.
|
|
|
|
[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` y `onInputReleased` son callbacks, como `onOverlayUpdate`: los
|
|
escribes tú, y Singe los llama cuando un control se pulsa o se vuelve a soltar.
|
|
Una pulsación llega una sola vez, no una y otra vez mientras se mantiene la
|
|
tecla, y por eso cada uno pone una variable y el callback de dibujo hace el
|
|
trabajo. Ese patrón -- la pulsación pone un indicador, el fotograma lee el
|
|
indicador -- es como sacas «mientras se mantiene la tecla» de un motor que solo
|
|
te cuenta los cambios.
|
|
|
|
Los nombres `SWITCH_` vienen del propio motor, así que no hay que cargar nada
|
|
para usarlos. Tampoco son teclas. Son lo que *significa* un control, y
|
|
`controls.cfg` decide qué tecla, botón o dirección del stick produce cada uno.
|
|
Hoy tus teclas de flecha envían `SWITCH_LEFT` y compañía; el stick de un mando
|
|
envía lo mismo, y tu script nunca nota la diferencia.
|
|
|
|
Los signos están elegidos para que cada flecha mueva la cara que estás mirando
|
|
en la dirección que has pulsado. Girar alrededor de Y un ángulo positivo lleva
|
|
esa cara cercana hacia la derecha, así que la flecha izquierda pide uno
|
|
negativo. Si te parece al revés, cambia los signos; son tus controles, no los
|
|
del motor.
|
|
|
|
[#_the_overlay_is_still_on_top]
|
|
=== El overlay sigue encima
|
|
|
|
Añade estas líneas a `onOverlayUpdate`, encima del `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))
|
|
----
|
|
|
|
Ahí no hay nada nuevo salvo aquello sobre lo que se imprime. `overlayPrint` es
|
|
la llamada de la lección uno, que cuenta en celdas de carácter y no necesita
|
|
ninguna fuente cargada, y está dibujando encima de una escena 3D sin que nadie
|
|
le haya dicho nada.
|
|
|
|
Así es como está montado, y vale la pena decirlo claramente: Singe dibuja
|
|
primero el vídeo, luego la escena 3D encima, y luego el overlay encima de eso.
|
|
Tu puntuación, tus vidas, tu cronómetro, tu punto de mira y tus subtítulos son
|
|
todo trabajo del overlay, exactamente igual que en la segunda parte. Un juego 3D
|
|
pone su puntuación en la esquina con las mismas cinco líneas que usa un juego
|
|
2D.
|
|
|
|
`nodeGetRotation` devuelve tres números, los ángulos alrededor de X, de Y y de
|
|
Z, y esta línea guarda los dos primeros en `pitch` y `yaw`. Son sus nombres
|
|
habituales (en español, cabeceo y guiñada): pitch es el morro que sube y baja, y
|
|
yaw es girar a izquierda y derecha. `math.floor` tira los decimales para que los
|
|
números dejen de parpadear.
|
|
|
|
Mira el yaw mientras mantienes pulsada la flecha izquierda. Baja más allá de
|
|
`-180` y vuelve a aparecer por `180`, porque los ángulos se dan como el giro más
|
|
corto que te lleva ahí y no como un total acumulado. Si tu juego necesita saber
|
|
cuántas vueltas ha dado el jugador, cuéntalas tú.
|
|
|
|
[#_try_it_22]
|
|
=== Pruébalo
|
|
|
|
. *Mueve la cámara.* Cambia `nodeSetPosition(camera, 0, 2, 7)` por
|
|
`(0, 8, 7)`, luego por `(7, 2, 7)` y luego por `(0, 0.2, 7)`. Cada vez, el
|
|
`nodeLookAt` mantiene la caja centrada, así que consigues otro ángulo de la
|
|
misma escena cambiando un número.
|
|
. *Cambia el objetivo.* Prueba `cameraSetPerspective(30, 0.1, 100)`, luego
|
|
`(90, 0.1, 100)`, sin mover la cámara. Un juego parece rápido con un campo de
|
|
visión amplio y majestuoso con uno estrecho, y este es el regulador de
|
|
ambiente más barato que existe.
|
|
. *Pon una segunda caja.* Copia las dos líneas que crearon la caja, cambia el
|
|
nombre de la variable a `mark` y dale `nodeSetPosition(mark, 3, 0, 0)` y
|
|
`nodeSetScale(mark, 0.3)`. Ahora tienes una referencia con la que juzgar la
|
|
primera caja. Muévela a `(0, 3, 0)` y luego a `(0, 0, -3)` y di en voz alta
|
|
qué eje es cuál.
|
|
. *Comparte la malla.* En lugar de llamar dos veces a `meshBox`, guarda la
|
|
primera en una variable y dale el mismo handle a los dos nodos. No se ve nada
|
|
distinto. De eso se trata: una forma, dos sitios.
|
|
. *Cuelga la cámara de la caja.* Conserva la segunda caja del paso anterior, o
|
|
no quedará nada con lo que comparar el movimiento. Luego busca
|
|
`nodeSetParent` en la referencia y cuelga la cámara debajo de la primera caja.
|
|
Mantén pulsada una tecla de flecha: la cámara acompaña la rotación de la caja,
|
|
así que la caja se queda quieta y el resto de la escena pasa girando. Eso está
|
|
a una línea de una vista en primera persona, y también es como descubres que
|
|
una cámara de persecución quiere un padre y no aritmética.
|
|
|
|
[#_break_it_on_purpose_21]
|
|
=== Rómpelo a propósito
|
|
|
|
Escribe mal el nombre del material. Cambia la línea `nodeSetMesh` para que use
|
|
`pain` en lugar de `paint`:
|
|
|
|
[source,lua]
|
|
----
|
|
nodeSetMesh(box, meshBox(2, 2, 2), pain)
|
|
----
|
|
|
|
Singe se detiene antes de que arranque el juego e imprime algo así:
|
|
|
|
----
|
|
15:nodeSetMesh: Argument 3 must be a number.
|
|
----
|
|
|
|
Este error tiene una forma distinta del de la lección uno, y viene del motor y
|
|
no de Lua. El número del principio es la línea. El nombre que va detrás es la
|
|
llamada que se ha quejado. La queja en sí es que la tercera cosa que le has
|
|
dado no era un número.
|
|
|
|
Vale la pena seguir el motivo, porque explica toda una familia de errores.
|
|
`pain` es una variable a la que nunca se le ha dado un valor, y en Lua una
|
|
variable vacía es `nil` -- el valor que significa «aquí no hay nada». Pasarle
|
|
`nil` a una llamada que quiere un handle de material da exactamente esto. Cuando
|
|
veas «must be a number» en una llamada del motor, busca en esa línea un nombre
|
|
de variable mal escrito, o un handle que te olvidaste de guardar.
|
|
|
|
[#_what_you_learned_22]
|
|
=== Lo que has aprendido
|
|
|
|
* `sceneEnable(true)` enciende la capa 3D, y no se dibuja nada en 3D hasta que
|
|
la llamas.
|
|
* Un nodo es un sitio en el mundo con una posición, una rotación y una escala.
|
|
Las mallas, las luces y las cámaras cuelgan todas de nodos.
|
|
* Una malla es la forma y un material es el aspecto, y cualquier número de nodos
|
|
puede compartir una de cada.
|
|
* X es la derecha, Y es arriba, y la Z negativa es hacia donde mira la cámara.
|
|
* `nodeSetPosition` y `nodeSetRotation` fijan un valor directamente; `nodeMove`
|
|
y `nodeRotate` lo cambian en una cantidad.
|
|
* Cualquier nodo puede ser la cámara. `nodeLookAt` la apunta y `cameraSet` la
|
|
elige.
|
|
* El campo de visión de `cameraSetPerspective` cambia cómo se siente la escena
|
|
sin mover nada.
|
|
* Una escena sin luz está negra, y es lo primero que hay que comprobar cuando no
|
|
aparece nada.
|
|
* El overlay sigue ahí, sigue encima y sigue siendo donde va tu puntuación.
|
|
|
|
[#_next_time_22]
|
|
=== La próxima vez
|
|
|
|
Como dragón, una caja deja bastante que desear. La próxima lección carga un
|
|
modelo de verdad -- uno que el motor ya desempaquetó en tu carpeta de trabajo,
|
|
así que no hay nada que descargar ni nada que dibujar -- lo coloca en el suelo y
|
|
lo ilumina como es debido. Esa es la lección veintitrés.
|