- ShapeUp nació a partir del reto de Wheel Reinvention Jam de “volver a mirar el software existente desde una nueva perspectiva”, y terminó convertido en un modelador 3D con demo ejecutable en el navegador y exportación a
.obj - La clave para poder crear una herramienta 3D en una semana fue usar ray marched SDF, que permitió implementar más rápido que con un renderizador basado en triángulos una escena con color, sombras suaves y ambient occlusion
- La implementación se mantuvo simple, centrada en un solo archivo en C, y el modelo guarda hasta 100 Shapes en un arreglo estático para reducir la carga de gestión de memoria
- raylib ayudó a abrir rápidamente una ventana OpenGL, pero por su API centrada en
int, la falta de validación de parámetros, la dependencia de GLFW y las limitaciones de raygui, fue necesario usar OpenGL directamente o rehacer funciones - El resultado final fue de 2024 líneas de C y 250 líneas de GLSL, unas 2300 líneas en total, con soporte para abrir y guardar archivos, ejecución en varias plataformas y exportación a
.obj
Cómo ShapeUp se convirtió en un modelador 3D
- Wheel Reinvention Jam fue un evento de programación de una semana enfocado en volver a examinar sistemas de software existentes desde una nueva perspectiva
- El objetivo inicial surgió de la frustración con lo lento del compilador de TypeScript, y era crear un subconjunto de TypeScript más rápido que
tsc- Parecía posible tomando como punto de partida el parser de TypeScript de
esbuildoBun - Pero la demo exitosa se reducía a “un comando de terminal termina antes que otro”, algo poco atractivo visualmente, así que al final cambió de rumbo hacia 3D
- Parecía posible tomando como punto de partida el parser de TypeScript de
- ShapeUp fue creado como un modelador 3D para editar formas con el mouse
- Ya existía experiencia previa escribiendo shaders SDF, pero modelar modificando código directamente no resultaba natural
- La meta era permitir la edición de formas basada en SDF usando el mouse
Por qué SDF hizo posible un proyecto de una semana
- La base de renderizado de ShapeUp es ray marched signed distance fields (SDFs)
- Una escena SDF podía implementarse más rápido que un renderizador basado en triángulos, incluso incluyendo color, sombras suaves y ambient occlusion
- Un caso de Inigo Quilez creando en una sola sesión un personaje estilo Pixar con SDF sirvió como referencia de dirección técnica
- ShapeUp aborda el modelado con SDF manipulando formas directamente en vez de editar código
Implementación en C y estructura de datos
- ShapeUp fue escrito en C y usa raylib para crear la ventana OpenGL
- Se eligió C por su compilación rápida, una sintaxis que no oculta comportamientos complejos, familiaridad y la posibilidad de compilar tanto a nativo como a WebAssembly
- El modelo está compuesto por un conjunto de estructuras
Shape- Cada Shape tiene posición, tamaño, ángulo, radio de borde, nivel de blob, color, eje de espejo y una bandera de sustracción
- La lista de Shapes se administra con un arreglo estático en lugar de asignación dinámica
MAX_SHAPE_COUNTes 100- El estado se maneja con
Shape shapes[MAX_SHAPE_COUNT],shape_countyselected_shape - Este enfoque elimina la posibilidad de fallos de asignación y fugas de memoria
- El límite de 100 Shapes no fue un gran problema en el uso real
- Como no hubo tiempo suficiente para optimizar el renderizador, el framerate caía antes de llegar a 100
- Con más tiempo, la idea era dividir el modelo en ladrillos pequeños y hacer ray marching dentro de cada uno
Cómo se usa la memoria
- ShapeUp usa asignación dinámica de memoria solo en 3 lugares
- Guardado: asignación de un buffer capaz de contener el documento completo
- Exportación a
.OBJ: asignación de un buffer para contener todos los vértices - Generación del shader GLSL: asignación de un buffer para el código fuente del shader
- En cada caso solo se llama a
freeuna vez, al final de la función - También se podría haber hecho
mallocde cada Shape y guardar punteros en un arreglo dinámico, pero este proyecto no necesitaba esa estructura - C ofrecía la ventaja de permitir controlar directamente la disposición de la memoria
- Si hubiera hecho falta un arreglo dinámico o un hashmap, se podían usar herramientas como
stb_ds.h
Cómo se implementó la UI
- La UI está implementada con el enfoque immediate mode user interface (IMGUI)
- IMGUI tiene la ventaja de facilitar el debugging y de permitir definir la posición de los elementos con un lenguaje de programación real, en lugar de CSS, constraints o SwiftUI
- El elemento enfocado y las acciones del mouse se rastrean con un enum
Control- Estados de interacción como posición, tamaño, ángulo, color, mover, rotar, escalar, rotación de cámara y nivel de blob se expresan como valores del enum
focused_controlymouse_actioncontienen el estado actual de la UI
Los puntos donde raylib y raygui se quedaron cortos
- raylib fue útil para abrir rápidamente una ventana OpenGL, pero con el tiempo terminó incluyendo factores que ralentizaron el desarrollo
- La parte más incómoda de la API de raylib fue sobre todo la falta de información de tipos
- Incluso donde se esperaba un tipo enum, se usaba
int, así que no había verificación de tipos por parte del compilador - El significado de los parámetros tampoco quedaba claro solo con la firma de la función
- Por ejemplo, en
IsGestureDetected(unsigned int gesture),gestureparece el ID de un gesto registrado, pero en realidad es un enumGesture - Como la documentación se centra en los archivos de cabecera, para saber qué
intera realmente un enum había que revisar la implementación
- Incluso donde se esperaba un tipo enum, se usaba
- El diseño sin validación básica de parámetros también agravó los problemas
LoadFileData(const char *fileName, int * dataSize)provoca un segfault sidataSizeesNULL- La cabecera no indica que
dataSizesea un parámetro de salida ni que no pueda ser null - La falta de validación dificultaba rastrear problemas simples y, en algunos casos, podía producir comportamientos incorrectos en silencio
- En el manejo de dependencias también hubo diferencias frente a lo esperado
- Existían problemas de GLFW que raylib no evitaba ni para los que enviaba parches
- Para el usuario final, importa más que las funciones de raylib trabajen bien que el detalle interno de cómo se crea la ventana
- La librería de UI raygui tenía demasiadas restricciones para usarla en este proyecto
- No podía mostrar números de punto flotante, así que hubo que crear manualmente campos de texto para
float - No podía manejar el enrutamiento de eventos del mouse en elementos superpuestos o recortados
- No soportaba esquinas redondeadas, algo común en interfaces
- Era difícil estilizarla de una forma agradable visualmente
- No podía mostrar números de punto flotante, así que hubo que crear manualmente campos de texto para
- Los bugs también interfirieron con el flujo de desarrollo
- Un bug en las herramientas de raygui impidió cambiar la fuente predeterminada, que estaba demasiado estilizada
- Funciones de dibujo como
DrawCircle(...)no comparten vértices entre triángulos, así que si la matriz actual tenía escala o rotación aparecían huecos de píxeles por errores de punto flotante
- Durante un tiempo se reportaron varios problemas descubiertos, pero la mayoría se cerraron como “wont fix”, así que después se dejó de reportarlos
- La salida fue usar funciones de OpenGL directamente o implementar desde cero lo necesario
- En adelante, el plan es usar sokol en lugar de raylib
Las cuatro cosas que había que terminar en 6 días
- ShapeUp tenía que completar en 6 días cuatro partes grandes
- Interfaz de usuario: gizmos 3D, atajos de teclado, barra lateral y game controller
- Generador de shaders GLSL y renderizador por ray marching
- Selección con mouse basada en GPU
- Marching cubes para exportación
- La dificultad no estaba tanto en cada función en sí, sino en mantener las prioridades
- Los problemas difíciles o que tomaban mucho tiempo se evitaban cambiando el diseño, o se resolvían con soluciones simples que funcionaban en el 90% de los casos
- A veces la solución aparecía después de dejar una función pendiente por un día
- La forma de trabajar consistía en mantener siempre un modelador 3D funcional e irlo mejorando gradualmente hasta donde el tiempo alcanzara
- Se compara con construir no una pirámide que solo queda completa al final, sino una pequeña pirámide completa en cada etapa, incluso si el trabajo se detiene antes
Resultado final
- Al terminar la semana, ShapeUp ya podía crear modelos 3D con sentido y exportarlos como archivos
.obj - Funciona en varias plataformas y también soporta abrir y guardar archivos
- El tamaño del código es de 2024 líneas de C y 250 líneas de GLSL
- Que un modelador 3D razonablemente útil pudiera hacerse con unas 2300 líneas fue un resultado llamativo
- El proyecto en sí es relativamente simple, pero fueron clave el criterio para elegir qué construir, el conocimiento para poder hacerlo y la disciplina para terminarlo en una semana
1 comentarios
Opiniones de Hacker News
Pienso exactamente lo mismo que el autor sobre las limitaciones de Raylib. Estoy haciendo un juego estilo tower defense que empecé con Raylib, y me estoy topando con muchas de esas mismas limitaciones y otros problemas.
Por ejemplo, el cambio a pantalla completa no se comporta de forma consistente entre plataformas, no se pueden enumerar los modos de pantalla, es difícil activar o desactivar funciones de renderizado en tiempo de ejecución, hay problemas para guardar shaders compilados, etc.
Aun así, agradezco el trabajo que Ray le ha dedicado a esta biblioteca y pienso seguir apoyándolo. Raylib es excelente para crear prototipos rápido, pero ir más allá no es fácil a menos que aceptes restricciones fuertes.
Sin duda aprendí cosas, pero a estas alturas el desarrollo ya avanzó demasiado como para reemplazar todo el código relacionado con Raylib por algo como SDL.
Things Unexpectedly Named After People: https://notes.rolandcrosby.com/posts/unexpectedly-eponymous/
La estrategia actual es simplemente usar modo ventana sin bordes y fingir que la pantalla completa real no existe.
Ahora mi mayor problema es el manejo de fuentes y renderizado de texto. Creo que voy a tener que cambiar de fuentes TTF a fuentes bitmap precocinadas, pero eso probablemente sea bastante doloroso cuando más adelante tenga que localizar.
Las dos funciones que más extraño después de venir de Love2D son renderizar fácilmente texto con varios colores, y recortar y repetir o teselar texturas con facilidad. En Raylib hay que partir el texto a mano según el markup de color, aplicar offsets de ancho y luego llamar a la función de dibujo para cada fragmento, teniendo en cuenta también los saltos de línea.
Si dibujo mucho texto en pantalla, los FPS también parecen caer bastante; puede que se esté rompiendo el batching de llamadas de dibujo del texto. Antes había una función para dibujar texturas en mosaico, pero por alguna razón la quitaron.
Algo que siempre me ha molestado en Wasm y en gráficos 3D/2D en el navegador es que suelen aparecer pequeños problemas como el scrolling. Vean el ejemplo “Background scrolling & parallax” aquí: https://www.raylib.com/examples.html
Lo probé en varios dispositivos y, si mis ojos no me engañan, claramente no es scrolling suave. Me cuesta creer que en 2024 el scrolling 2D suave todavía no sea un problema resuelto.
“Guardo los Shape en un arreglo asignado estáticamente. No hay fallas de asignación, no hay fugas, no hay adornos innecesarios. Es hermoso. El límite de 100 Shape en realidad no fue una restricción. Como casi no hubo tiempo para optimizar el renderer, la tasa de cuadros habría caído antes de llegar a 100”.
Es el mejor ejemplo que he visto recientemente de evitar la optimización prematura.
Es un artículo realmente interesante, y me gustó que hablara de varias decisiones, como la forma de manejar la memoria y los problemas que encontró con raylib. Justo estoy repasando C al entrar en la segunda parte de Crafting Interpreters, así que fue bueno recordar en qué es bueno C.
La demo en tiempo real del video es realmente buena. Dejando de lado crear la app, si yo lo hubiera intentado, creo que ni siquiera habría podido hacer ese video en una semana.
Hace mucho tiempo trabajé en un sistema operativo para teléfonos de escritorio. Solo tenía 64K de RAM, así que no había gestión dinámica de memoria en absoluto, y usábamos muchas variables estáticas para que el compilador ubicara todo en tiempo de compilación.
Es fácil olvidar que muchas aplicaciones quizá no necesiten gestión dinámica de memoria en absoluto. Muchas veces basta con asignar algunos búferes de tamaño fijo y manejar limpiamente los casos excepcionales cuando esos búferes se llenan.
En ese contexto, C en realidad es mucho más seguro. No hay fugas de memoria, y lo único de lo que hay que preocuparse es el desbordamiento de búfer. Si todas las variables están asignadas estáticamente, se puede manejar teniendo cuidado con
sizeof.No digo que Rust y Go no sean excelentes opciones hoy, pero el viejo y modesto C todavía funciona bien y no tiene por qué ser una pesadilla complicada.
Me salgo un poco del tema, pero me da gusto ver por primera vez una interfaz WebAssembly en la que el texto no se ve borroso. De verdad es la primera vez.
Si ampliamos la mirada a programas y a algunos sistemas operativos, por ejemplo incluso Windows, en los últimos años se volvió una tendencia común y una configuración predeterminada cierto método de rasterización de texto, lo que generó un problema generalizado.
Por desgracia, los usuarios muchas veces no pueden desactivar el antialiasing para obtener texto nítido, y en los raros casos en que existe la opción, el antialiasing sigue aplicándose a interfaces como los menús.
Me encantan este tipo de proyectos. Todavía me gusta el bajo nivel de C. Ahora uso mucho Rust y Elixir/Erlang, pero a menudo extraño la simplicidad y explicitud de C.
Por eso también uso mucho Zig; es un lenguaje que conserva mucho de la filosofía de C y a la vez la mejora bastante bien.
Estoy totalmente de acuerdo con su apreciación sobre C. En especial la parte de que “la sintaxis no oculta comportamientos complejos. Es lo bastante simple como para no tener que estar consultándola todo el tiempo”; y, más aún, cuando sí hace falta buscar algo sobre C, es muy fácil y provechoso.
Un lenguaje simple y antiguo tiene sus ventajas.
Si asignas cada Shape por separado con
mallocy guardas esos punteros en un arreglo dinámico, sin duda puedes complicarte más la vida. Se dice algo como que, si usas un lenguaje como C#, te obliga a esa estructura de asignación, pero me pregunto qué impide usar en C# un arreglo fijo de structs como el que el autor usó en C.Ojalá alguien continúe con este proyecto. Con unos meses más de pulido, para ciertos usos podría convertirse en una alternativa seria a Blender o FreeCAD, y la curva de aprendizaje parece mucho más suave.
Sin embargo, como en el fondo funciona con SDF, la experiencia de modelado y los datos que se guardan son distintos de una malla tradicional basada en triángulos, vértices, etc.
Convertir un SDF en malla es posible con métodos como marching cubes, pero es muy probable que esos datos igual haya que limpiarlos después en aplicaciones tipo Blender.
Si el renderer también está basado en SDF, los SDF son excelentes. Aunque la mayoría no lo está.
Perdón si ya lo sabías.