¿Qué viene a la mente de un desarrollador de juegos independiente cuando se enfrenta a la necesidad de agregar una característica que no tiene idea sobre la implementación? Por supuesto, va a buscar rastros de aquellos que ya han recorrido este camino y se han molestado en escribir su experiencia. Así lo hice hace algún tiempo, comenzando a crear sombras en mi juego. Encontrar la información correcta, en forma de artículos, lecciones y guías, no fue difícil. Sin embargo, para mi sorpresa, descubrí que ninguna de las soluciones descritas simplemente me convenía. Por lo tanto, habiendo realizado el mío, decidí contarle al mundo sobre eso.
Vale la pena advertir de antemano que este texto no pretende ser una especie de guía de ultimátum o clase magistral. El método que utilicé puede no ser universal, lejos de ser el más efectivo y no cubre la tarea de crear sombras bidimensionales en su totalidad. Es más bien una historia sobre los trucos a los que un desarrollador inexperto tuvo que recurrir para lograr un resultado que satisfaga sus requisitos.
El resultado en sí mismo está ante ti:

Y los detalles del camino hacia su logro te están esperando bajo el corte.
Un poco sobre el juego en síDwarfinator es un juego de disparos de defensa lateral / desplazamiento lateral bidimensional desarrollado con un ojo puesto en los segmentos móviles y de escritorio. La jugabilidad consiste en la destrucción sistemática de las olas enemigas en dos modos alternos: defensa y persecución. La progresión de un jugador implica bombear un "tanque" mejorando y reemplazando varios elementos del mismo, como armas, motores y ruedas, así como elevando el nivel y aprendiendo habilidades activas y pasivas. La progresión del entorno implica un aumento constante en el número de mobs en la ola, la adición de nuevos tipos de enemigos a la ola a medida que avanzan a través de la ubicación y el cambio sucesivo de varias ubicaciones, cada una de las cuales tiene su propio conjunto de oponentes.
Declaración del problema.
Entonces, en el momento de la decisión de agregar sombras al juego, tenía:
- ubicación en forma de dos sprites, uno para mostrar detrás de mobs y otras entidades, el segundo para mostrar delante de ellos;

- mobs y objetos destructibles estáticos, constantemente animados y consistentes en sprites separados en una cantidad de unas pocas a unas pocas docenas;

- proyectiles, propios y enemigos, representados en la mayoría de los casos por un sprite o un sistema de partículas, en este último caso no se requería sombra;

- un tanque que consta de varias partes ensambladas de acuerdo con el mismo esquema que las turbas;

- paredes con varios estados fijos, que, nuevamente, son un conjunto de sprites separados.

Para todo esto, se necesitaban las sombras más simples, repitiendo los contornos del objeto y proyectados desde una única fuente de luz fija.
Al mismo tiempo, uno debe tener una actitud entusiasta hacia la productividad. Debido a los detalles del género y las peculiaridades de su implementación, la mayoría de los objetos que proyectan sombras se encuentran directamente en la pantalla en cualquier momento. Y su número total puede ser más de cien, si hablamos de entidades de juego, y un par de miles, si hablamos de sprites individuales.
Implementación
En realidad, el problema principal resultó ser que Dwarfinator, en términos generales, es un juego 2.5D. La gran mayoría de los objetos existen en el espacio bidimensional con los ejes X e Y, y el eje Z se usa extremadamente raramente. Visualmente, y en parte en el juego, el eje Y se usa para mostrar tanto la altura como la profundidad, dividiéndose de la misma manera en los ejes virtuales Y y Z. No era posible usar herramientas estándar de Unity en tal situación para crear sombras.
Pero, de hecho, no necesitaba una iluminación honesta, era suficiente para poder crear manualmente una sombra para cada objeto. Por lo tanto, lo más simple que se me ocurrió fue simplemente colocar una copia detrás de cada entidad, rotada en un espacio tridimensional para simular una ubicación en la superficie. Todos los sprites de esta pseudo-sombra se pusieron en negro, mientras que la estructura jerárquica del propietario de la sombra se conservó, lo que permitió que el mismo animador lo animara en sincronización con el propietario.
Tal animación sincrónica se parecía a esto:

Sin embargo, la sombra requería transparencia. La solución más simple era configurarlo para cada objeto de sombra. Pero tal implementación no parecía satisfactoria: los sprites se superponían entre sí, formando áreas menos transparentes en el sitio de superposición.
La captura de pantalla siguiente muestra cómo se ve la sombra de varios segmentos translúcidos. Los parámetros de distorsión de sombra utilizados también son visibles: la rotación a lo largo del eje X en -50 grados, la rotación a lo largo del eje Y en -140 grados, y la escala a lo largo del eje X, aumentó 1.3 veces en relación con el objeto padre.

Se hizo evidente que la transparencia debería imponerse a la sombra como un objeto sólido. El primer experimento sobre este tema fue colgar en la sombra de la cámara, renderizando esta sombra en RenderTexture, que luego se usó como material adjunto al padre de la sombra del Plano. Ya podía establecer la transparencia sin ningún problema. Las sombras mismas estaban fuera del marco para evitar la superposición de las áreas de captura de la cámara. El enfoque funcionó, pero resultó que ya un par de docenas de sombras causaron serios problemas de rendimiento, principalmente debido a la cantidad de cámaras en el escenario. Además, una serie de animaciones asumieron un movimiento significativo de sprites de mafia individuales dentro del marco de su objeto raíz, debido a lo cual se debe ubicar un área de cámara que excedería significativamente el tamaño de la imagen real en un momento determinado.
La solución se encontró rápidamente: si no puede dibujar cada sombra con una cámara separada, ¿por qué no dibujar todas las sombras con una cámara? Todo lo que tenía que hacer era colocar un área separada de la escena bajo la sombra, ligeramente más alta que el campo de visión de la cámara principal, dirigir una cámara adicional a esta área y mostrar su salida entre la ubicación y otras entidades.
A continuación puede ver un ejemplo de la salida de esta cámara:

La productividad de tal implementación sufrió mucho menos, por lo que la solución se consideró funcional y se aplicó a todos los mobs, objetos estáticos y proyectiles. Esto fue seguido por la ubicación del sprite. Era imposible usar un sprite en todos los objetos, ya que se implementó anteriormente. Usar una copia de un objeto como su sombra solo funciona bien siempre que el objeto sea completamente plano. Incluso al crear sombras para las turbas, se notó que los puntos de contacto con la superficie espaciados a lo largo de la tercera coordenada violan la corrección de la sombra en relación con estos puntos.
La siguiente captura de pantalla muestra un ejemplo de tal violación. El talón de la mafia se toma como el punto de contacto con la superficie, pero las sombras de los pies ya están más allá de los pies.

Y si en el caso de las patas del ogro aún puede cambiar ligeramente la posición de la sombra y enmascarar el problema, entonces, para varias docenas de troncos de árboles, no hay posibilidad. Todos los objetos de ubicación que se suponía que proyectaban una sombra deberían hacerse GameObject por separado. Esto es exactamente lo que hice al colocar copias de los objetos destructibles correspondientes en la ubicación prefabricada y deshabilitar los scripts que no se usan en esta posición. Al mismo tiempo, gracias a esto, se hizo posible incluirlos en la clasificación general de los objetos de la escena, y los proyectiles que volaban fuera de la ubicación ya no se dibujaban estrictamente sobre todos los objetos, sino que volaban entre ellos. Además, se hizo posible animar los propios objetos.
Pero entonces un nuevo problema me esperaba. Con sombras y docenas de nuevos objetos, el número máximo de GameObjects simultáneamente en el escenario, y con ellos los componentes Animator y SpriteRenderer, se duplicó. Cuando lancé toda la ola de mobs a la ubicación, que ascendía a unas 150 piezas, Profiler me mostró con reproche unos 40 ms, que solo fueron para renderizado y animación, y la velocidad de fotogramas en su conjunto varió alrededor de 10. Optimicé desesperadamente mis propios guiones, luchando por cada milisegundo, Pero eso no fue suficiente.
En la búsqueda de herramientas de optimización adicionales, me encontré con la vasta documentación y guías para el procesamiento por lotes dinámico.
Un poco más sobre el procesamiento por lotesEn resumen, el procesamiento por lotes es un mecanismo para minimizar el número de llamadas de extracción y, con ello, el tiempo que se dedica al momento de representar el marco en la interacción entre la CPU y la GPU. Cuando se usa en lugar de enviar cada elemento individualmente para la representación, los elementos similares se agrupan y dibujan juntos a la vez. En el caso de Unity, el motor en sí mismo intenta aprovechar al máximo este mecanismo y casi no se requiere ninguna acción adicional por parte del desarrollador.
Frame Debugger demostró que tengo, en el mejor de los casos, los detalles de cada objeto o mafia por separado. Después de haber creado sprites para el primer y segundo para el atlas, logré sombrear las sombras con solo unas pocas llamadas, pero los propietarios de estas sombras se negaron obstinadamente a luchar contra ellos mismos.
Los experimentos en una escena separada mostraron que los lotes dinámicos se rompen cuando los objetos tienen un componente SortingGroup, que utilicé para ordenar la visualización de entidades en la pantalla. Sin embargo, en teoría era posible prescindir de él, sin embargo, establecer los valores de clasificación para cada sprite y sistema de partículas en un objeto por separado podría resultar aún más costoso que la falta de procesamiento por lotes.
Pero algo me perseguía. El objeto de sombra, al ser un descendiente del objeto anfitrión en la escena real, técnicamente pertenecía al mismo grupo de clasificación, sin embargo, no hubo problemas con el sombreado dinámico de objetos de sombra. La única diferencia era que los objetos anfitriones fueron dibujados directamente en la pantalla por la cámara principal, y los objetos de sombra se renderizaron primero en RenderTexture.
Esta fue la trampa. La razón exacta de este comportamiento es desconocida para Internet, pero al renderizar las imágenes de la cámara en RenderTexture, SortingGroup ya no interrumpió el procesamiento por lotes. La decisión parecía muy extraña, ilógica y, en general, la más muleta. Pero al implementar el renderizado de entidades usando el mismo método que el renderizado de sombras, y habiendo obtenido, además de la capa de sombra, una capa de entidad, ya he logrado valores de rendimiento bastante aceptables.
La captura de pantalla siguiente muestra un ejemplo de representación de una capa de entidad.

Entonces, en general, la representación de una determinada entidad en la coordenada Y se ve así:
- La entidad se coloca en Y - 20;
- Una entidad representa una entidad observando esta coordenada en una RenderTexture para entidades;
- La sombra de la entidad se coloca en Y + 20;
- Una cámara dibuja la sombra de una entidad observando esta coordenada en una RenderTexture para sombras;
- La cámara principal dibuja el sprite de ubicación principal en la pantalla, el único elemento que actualmente se representa directamente en la pantalla;
- La cámara principal dibuja un plano en la pantalla con sombras de RenderTexture como material;
- La cámara principal dibuja un Plano en la pantalla con una RenderTextura de entidades como material.
Tal pastel de capas.
En la captura de pantalla a continuación, la cámara del editor está configurada en modo tridimensional para demostrar la ubicación de las capas entre sí.

Matices
Pero como resultó durante el proceso de replicar la decisión a otras entidades, el caso general no cubrió todos los escenarios posibles. Por ejemplo, había entidades que estaban a cierta altura en relación con la superficie, en particular, conchas y algunos caracteres de escena. Además, los proyectiles también tenían la capacidad de rotar dependiendo de la dirección de su movimiento en la pantalla, debido a lo cual, además de establecer el punto de intersección del objeto y su sombra, era necesario seleccionar la parte giratoria como un objeto secundario separado, para corregir la lógica de rotación del proyectil y su animación.
La siguiente captura de pantalla muestra un ejemplo de la rotación de conchas y sus sombras.

Los personajes voladores, como las turbas voladoras planificadas, también pueden moverse dentro de sus coordenadas Y virtuales, lo que requiere la creación de un mecanismo para calcular la posición de la sombra desde la posición de su propietario en el eje Y virtual.
El siguiente GIF muestra un ejemplo de mover un objeto en altura.

Otro caso que salió del concepto general fue un tanque. A diferencia de todas las otras entidades, el tanque tiene un tamaño muy sustancial a lo largo del eje Z virtual, y la implementación general de las sombras, como ya se mencionó, requiere que el objeto sea casi plano. La forma más fácil de evitar esto era dibujar manualmente formas de sombra para partes individuales del tanque, ya que podía colocar cualquier cosa en la capa de sombra.
Para la construcción correcta de sombras dibujadas a mano, tuve que armar un diseño de líneas basado en una captura de pantalla de una sombra existente, que se puede ver en la captura de pantalla a continuación.

Si escala y coloca esta estructura de tal manera que la parte superior estará en algún punto del objeto principal y la parte inferior estará en el punto de contacto con la superficie, la esquina derecha de la estructura mostrará el lugar donde debería estar el punto de sombra correspondiente. Habiendo proyectado varios puntos clave de esta manera, no es difícil construir toda la sombra sobre ellos.
Además, las partes individuales del tanque podrían tener diferentes alturas para unir partes secundarias, lo que, como en el caso de los personajes voladores y las turbas, requería un ajuste de la posición de las sombras de cada parte específica.
La siguiente captura de pantalla muestra el tanque, su ensamblaje de sombra y también está en forma de partes separadas.

Las sombras de las paredes resultaron ser un dolor aparte. En el momento del comienzo del trabajo en las sombras, las paredes eran de la misma naturaleza que los detalles del tanque: un objeto de varias docenas de sprites separados. Sin embargo, las paredes tenían varios estados controlados por el animador.
Pensando mucho sobre qué hacer con ellos, llegué a la conclusión de que el concepto de muros debe cambiarse. Como resultado, los muros se dividieron en secciones, cada una de las cuales tiene su propio conjunto de estados, su propio animador y su propia sombra. Esto permitió utilizar el mismo enfoque para crear sombras para mobs paralelos al eje X, como con los mobs, y para aquellas secciones que no se ajustaban a esta regla, tuvieron que idear algo propio. En algunos casos, tuve que crear mi propio animador para la sombra de la sección y establecer manualmente la posición de los sprites.
Por ejemplo, en el caso de la sección que se muestra en la captura de pantalla a continuación, la sombra se crea aplicando distorsión para cada registro individual en lugar de la sección completa.

Conclusión
Eso, de hecho, es todo. A pesar de todos los matices anteriores, la tarea original se completó por completo, y ahora mi proyecto cuenta con sombras bastante decentes, aunque de origen algo dudoso. Espero que, gracias a este artículo, para el próximo desarrollador independiente que me hizo una pregunta similar, Internet sea un poco más útil, si no como un ejemplo a seguir, al menos como un error de otra persona para su propio aprendizaje.