Gráficos 3D en el STM32F103

imagen

Una breve historia sobre cómo empujar lo no editable y mostrar gráficos tridimensionales en tiempo real utilizando un controlador que no tiene velocidad ni memoria para esto.

En 2017 (a juzgar por la fecha de modificación del archivo), decidí cambiar de controladores AVR a STM32 más potentes. Naturalmente, el primer controlador fue el ampliamente publicitado F103. No es menos natural que se rechazara el uso de tableros de depuración listos para usar a favor de la fabricación de uno desde cero de acuerdo con sus requisitos. Por extraño que parezca, casi no había jambas (excepto que UART1 debería llevarse a un conector normal, y no con muletas en el cableado).

En comparación con AVR, las características de la piedra son bastante decentes: reloj de 72 MHz (en la práctica, puede overclockear a 100 MHz, o incluso más, ¡pero bajo su propio riesgo y riesgo!), 20 kB de RAM y 64 kB de flash. Además, una tonelada de periféricos, cuando se usa, el problema principal es no tener miedo de esta abundancia y darse cuenta de que no es necesario palear los diez registros para comenzar, es suficiente establecer tres bits en los correctos. Al menos hasta que quieras algo extraño.

Cuando la primera euforia de la posesión de tal poder pasó, surgió un deseo de sondear sus límites. Como ejemplo efectivo, elegí el cálculo de gráficos tridimensionales con todas estas matrices, iluminación, modelos poligonales y un buffer Z con una pantalla de 320x240 en el controlador ili9341. Los dos problemas más obvios a resolver son la velocidad y el volumen. Un tamaño de pantalla de 320x240 a 16 bits por color proporciona 150 kB por fotograma. Pero la RAM total que tenemos es de solo 20 kB ... Y estos 150 kB deben transferirse a la pantalla al menos 10 veces por segundo, es decir, el tipo de cambio debe ser de al menos 1.5 MB / so 12 MB / s, que ya parece una carga significativa en el núcleo. Afortunadamente, en este controlador hay un módulo RAP (acceso directo a memoria, también conocido como Acceso directo a memoria, DMA), que permite no cargar el núcleo con operaciones de transfusión de vacío a vacío.Es decir, puede preparar el búfer, decirle al módulo "¡aquí tiene el búfer de datos, funciona!", Y en este momento preparar los datos para la próxima transferencia. Y teniendo en cuenta la capacidad de la pantalla para recibir datos en una secuencia, emerge el siguiente algoritmo: se resalta el búfer frontal, desde el cual el DMA transfiere datos a la pantalla, el búfer posterior en el que se realiza el renderizado y el búfer Z utilizado para cortar en profundidad. Los buffers son una sola fila (o columna, lo que sea) de la pantalla. Y en lugar de 150 kB, solo necesitamos 1920 bytes (320 píxeles por línea * 3 buffers * 2 bytes por punto), que se adapta perfectamente a la memoria. El segundo truco se basa en el hecho de que el cálculo de las matrices de transformación y las coordenadas de los vértices no se pueden realizar para cada fila, de lo contrario, la imagen se distorsionará de las formas más extrañas y su velocidad es desventajosa. En cambio, los cálculos "externos",es decir, la multiplicación de las matrices de transformación y su aplicación a los vértices se recalcula en cada cuadro, y luego se convierte en una representación intermedia, que está optimizada para renderizarse en una imagen de 320x1.

Por razones de gamberros, la biblioteca se parecerá a OpenGL desde el exterior. Al igual que en OpenGL original, el renderizado comienza con la formación de la matriz de transformación: al borrar glLoadIdentity () se crea la unidad de matriz actual, luego un conjunto de transformaciones glRotateXY (...), glTranslate (...), cada una de las cuales se multiplica por la matriz actual. Dado que estos cálculos se realizarán solo una vez por cuadro, no hay requisitos especiales para la velocidad, puede hacerlo con flotadores simples, sin perversiones con números de punto fijo. La matriz en sí es una matriz de flotante [4] [4], asignada a una matriz unidimensional de flotante [16]; de hecho, este método generalmente se usa para matrices dinámicas, pero también puede obtener un pequeño beneficio de las matrices estáticas. Otro truco estándar: en lugar de calcular constantemente senos y cosenos, que son muchos en las matrices de rotación,cuente de antemano y escríbalos en la tableta. Para hacer esto, divida el círculo completo en 256 partes, calcule el valor del seno para cada uno y vuélvalo a la matriz sin_table []. Bueno, cualquiera de la escuela puede obtener el coseno del seno. Vale la pena señalar que las funciones de rotación toman un ángulo no en radianes, sino en fracciones de una revolución completa, después de la reducción al rango [0 ... 255]. Sin embargo, se han implementado funciones "honestas" que realizan la conversión de ángulo a lóbulos debajo del capó.realizando conversión de ángulo a lóbulos debajo del capó.realizando conversión de ángulo a lóbulos debajo del capó.

Cuando la matriz está lista, puede comenzar a dibujar las primitivas. En general, en los gráficos tridimensionales hay tres tipos de primitivas: un punto, una línea y un triángulo. Pero si nos interesan los modelos poligonales, solo se debe prestar atención al triángulo. Su "representación" se produce en la función glDrawTriangle () o glDrawTriangleV (). La palabra "renderizado" está entre comillas porque no se produce renderizado en esta etapa. Simplemente multiplicamos todos los puntos de la primitiva por la matriz de transformación, y luego extraemos de ellos las fórmulas analíticas de los bordes y = ky * x + por, que nos permiten encontrar las intersecciones de los tres bordes del triángulo con la línea de salida actual. Descartamos uno de ellos, ya que no se encuentra en el intervalo entre los vértices, sino en su continuación.Es decir, para dibujar un marco, solo necesita pasar por todas las líneas y para cada pintura el área entre los puntos de intersección. Pero si aplica este algoritmo "de frente", cada primitiva se superpondrá a las que se dibujaron anteriormente. Necesitamos considerar la coordenada Z (profundidad) para que los triángulos se crucen bellamente. En lugar de simplemente imprimir punto por punto, consideraremos su coordenada Z y, en comparación con la coordenada Z almacenada en el búfer de profundidad, la salida (actualizando el búfer Z) o la ignoraremos. Y para calcular la coordenada Z de cada punto de la línea que nos interesa, usamos la misma fórmula de línea recta z = kz * y + bz calculada por los mismos dos puntos de intersección con aristas. Como resultado, el objeto de la estructura triangular "semi-terminada" glTriangle consiste en tres coordenadas X de los vértices (no tiene sentido almacenar las coordenadas Y y Z, se calcularán) y k,b coeficientes directos, bueno, color al montón. Aquí, en contraste con el cálculo de las matrices de transformación, la velocidad es crítica, por lo que ya usamos números de punto fijo. Además, si para el término b, la misma precisión es suficiente para las coordenadas (2 bytes), entonces la precisión del factor k, cuanto mayor sea mejor, entonces tomamos 4 bytes. Pero no es flotante, ya que trabajar con enteros es aún más rápido, incluso con el mismo tamaño.

Entonces, al llamar a un montón de glDrawTriangle (), preparamos una serie de triángulos semiacabados. En mi implementación, los triángulos se deducen uno a la vez mediante llamadas a funciones explícitas. De hecho, sería lógico tener una matriz de triángulos con las direcciones de los vértices, pero aquí decidí no complicarme. De todos modos, la función de representación está escrita por robots, y no les importa si deben completar una matriz constante o escribir trescientas llamadas idénticas. Es hora de traducir los productos semiacabados de los triángulos en una hermosa imagen en la pantalla. Para hacer esto, se llama a la función glSwapBuffers (). Como se describió anteriormente, recorre las líneas de la pantalla, busca cada punto de intersección con todos los triángulos y dibuja segmentos de acuerdo con el filtrado por profundidad. Después de representar cada línea, debe enviar esta línea a la pantalla. Para hacer esto, se inicia DMA, que indica la dirección de la cadena y su tamaño.Mientras tanto, DMA funciona, puede cambiar a otro búfer y representar la siguiente línea. Lo principal es no olvidarse de esperar el final de la transferencia si de repente terminó de renderizar antes. Para visualizar la relación de velocidades, agregué la inclusión de un LED rojo después del final del renderizado y apagado después de completar la espera DMA. Resulta algo así como PWM, que ajusta el brillo en función de la latencia. Teóricamente, en lugar de una espera "tonta", se podrían usar interrupciones de DMA, pero luego no podría usarlas, y el algoritmo se habría vuelto mucho más complicado. Para un programa de demostración, esto es redundante.Para visualizar la relación de velocidades, agregué la inclusión de un LED rojo después del final del renderizado y apagado después de completar la espera DMA. Resulta algo así como PWM, que ajusta el brillo en función de la latencia. Teóricamente, en lugar de una espera "tonta", se podrían usar interrupciones de DMA, pero luego no podría usarlas, y el algoritmo se habría vuelto mucho más complicado. Para un programa de demostración, esto es redundante.Para visualizar la relación de velocidades, agregué la inclusión de un LED rojo después del final del renderizado y apagado después de completar la espera DMA. Resulta algo así como PWM, que ajusta el brillo en función de la latencia. Teóricamente, en lugar de una espera "tonta", podrían usarse interrupciones de DMA, pero luego no podría usarlas, y el algoritmo se habría vuelto mucho más complicado. Para un programa de demostración, esto es redundante.

El resultado de los procedimientos anteriores fue una imagen giratoria de tres planos de intersección de diferentes colores, y con una velocidad bastante decente: el brillo del LED rojo es bastante alto, lo que indica un gran margen en el rendimiento del kernel.

Bueno, si el núcleo está inactivo, debe cargarlo. Y lo cargaremos con mejores modelos. Sin embargo, no olvide que la memoria sigue siendo muy limitada, por lo que el controlador no extraerá demasiados polígonos físicamente. El cálculo más simple mostró que después de restar la memoria en el búfer de línea y similares, había un lugar para 378 triángulos. Como la práctica ha demostrado, los modelos del antiguo pero interesante juego gótico son perfectos para este tamaño. En realidad, los modelos de una serpiente y una mosca de sangre fueron sacados de allí (y ya al momento de escribir este artículo y un glocoor, haciendo alarde de KDPV), después de lo cual el controlador se quedó sin memoria flash. Pero los modelos de juegos no están destinados a ser utilizados por un microcontrolador.

Digamos que contienen animación, texturas y similares, lo que no nos es útil y no cabe en la memoria. Afortunadamente, blender permite no solo guardarlos en * .obj, que es más fácil de analizar, sino también reducir la cantidad de polígonos si es necesario. Además, con la ayuda de un simple programa auto-escrito obj2arr * .obj, los archivos se ordenan en coordenadas, a partir de las cuales se forma un archivo * .h para su inclusión directa en el firmware.

Pero por ahora, los modelos se ven como simples manchas rizadas. En el modelo de prueba, esto no nos molestó, ya que todas las caras fueron pintadas en sus propios colores, pero no prescriben los mismos colores a cada polígono del modelo. No, por supuesto, puedes pintar una mosca en colores aleatorios, pero comprobé que se verá bastante inesperadamente. Especialmente cuando los colores también cambian en cada cuadro ... En su lugar, aplique otra gota de magia vectorial y agregue iluminación.

El cálculo de la iluminación en su versión primitiva consiste en calcular el producto escalar de la normalidad y la dirección de la fuente de luz, seguido de la multiplicación por el color "nativo" de la cara.
Ahora tenemos tres modelos: dos del juego y una prueba, desde la que comenzamos. Para cambiarlos, utilizaremos uno de los dos botones soldados en el tablero. Al mismo tiempo, puede agregar control sobre el procesador. Ya tenemos un control: un LED rojo asociado con la latencia DMA. Y el segundo LED verde parpadeará con cada actualización de cuadro, para poder estimar la velocidad de cuadro. A simple vista, era de unos 15 fps.


En general, estoy satisfecho con el resultado: es bueno implementar algo que es fundamentalmente imposible de resolver de frente. Por supuesto, todavía hay mucho para optimizar y mejorar, pero no tiene mucho sentido. Objetivamente, el controlador para gráficos tridimensionales es débil y ni siquiera se trata de velocidad, sino de RAM. Sin embargo, como cualquier muestra de demoscene, este proyecto es valioso no por el resultado, sino por el proceso.

Si alguien está interesado de repente, el código fuente está disponible aquí .

All Articles