Errores GPU Shaders: Lo Que No Sabías

Un bug silencioso en el rendering de tiles: bgfx obliga a castear un entero de 8 bits a float para pasar datos por instancia al GPU, y cuando el shader lee ese valor, pierde información y dibuja sombras en direcciones equivocadas. El problema típico de trabajar contra las limitaciones del framework, donde convertís un tipo de dato sin pensar en las implicaciones binarias.

En 30 segundos

  • GPU instancing renderiza múltiples copias del mismo mesh en una sola batch, pero bgfx solo soporta floats para per-instance attributes, no enteros
  • Un bug en Blackshift forzaba casting de adjacency maps (8-bit ints que determinan dónde dibujar sombras) a floats, causando interpretación errónea de los bits
  • Los usuarios veían sombras en direcciones incorrectas, artifacts visuales en los bordes de los tiles y inconsistencia entre adyacentes
  • La solución: usar Uniform Buffer Objects (UBO), texturas para lookup, o restructurar el mesh para pasar datos de otra forma
  • La lección: el casting silencioso es uno de los mayores culpables de bugs de rendering difíciles de debuggear

Qué es GPU instancing y por qué es eficiente

GPU instancing es una técnica que te permite renderizar múltiples copias del mismo mesh en una sola llamada de draw. En lugar de decirle al GPU “dibujá este plano 10.000 veces” (10.000 draw calls), le decís “dibujá este plano 10.000 veces en estas transformaciones diferentes” (1 draw call). La diferencia de rendimiento es brutal.

Funciona así: pasás una lista de transformation matrices (una por instancia) al GPU, y el shader vertex procesa cada vértice de tu mesh aplicando la matriz correspondiente. El GPU sabe qué instancia está procesando ahora y puede leer datos específicos de esa instancia. Por eso se llama per-instance data — es información que varía de un renderizado a otro, pero no de vértice a vértice.

En Blackshift, los sand tiles usan instancing: tenés un único mesh plano subdividido que deformás con un vertex shader y luego sombreás en el fragment shader. El GPU renderiza miles de esos tiles en una sola batch (ponele que 5.000 tiles visibles). Sin instancing, serían 5.000 draw calls. Con instancing, es 1.

El pipeline de rendering de sand tiles

error renderizado GPU shaders diagrama explicativo

Cada sand tile es un mesh simple: un plano subdivided en una grilla (digamos 8×8 vértices). El vértex shader lee la posición de cada vértice y la deforma usando noise o altura data para que la superficie no se vea plana, sino con relieve, como arena real.

Después viene lo interesante. Los tiles se juntan los unos con los otros — hay bordes donde se tocan. En esos bordes, el fragment shader dibuja sombras pequeñas para que no se vea una juntura brusca. Para saber dónde dibujar esas sombras, el shader necesita saber cuáles de los 8 vecinos (norte, noreste, este, sureste, sur, suroeste, oeste, noroeste) tienen un tile adyacente.

Esa información se codifica en un entero de 8 bits: un bit por dirección vecina. Si el bit está en 1, hay un vecino en esa dirección. Si está en 0, no hay (es borde de mapa o agua). El shader lee ese valor y dibuja sombras solo en los bordes que dan a vecinos reales. Para más detalles técnicos, mirá cómo funciona la arquitectura del nuevo Claude.

Adjacency maps: estructura de datos de 8 bits

Una adjacency map es un número del 0 al 255 (un byte) donde cada bit representa una dirección vecina:

  • Bit 0 = Norte
  • Bit 1 = Noreste
  • Bit 2 = Este
  • Bit 3 = Sureste
  • Bit 4 = Sur
  • Bit 5 = Suroeste
  • Bit 6 = Oeste
  • Bit 7 = Noroeste

Ponele que un tile tiene vecinos al norte, noreste, este y sur. El valor sería binario 00011011 = decimal 27 (aunque en el ejemplo del bug real el valor era 238, que es 11101110 en binario = vecinos en todo menos noroeste, norte, sureste).

El shader fragment lee ese número y hace un AND bit a bit para cada dirección. Si el resultado es != 0, dibuja sombra en esa dirección. Simple, eficiente, y pasa 1 byte de data por instancia.

El problema: conversión int a float en instance buffers

Acá viene el problema (spoiler: es bocho). BGfx, el abstraction layer de rendering usado por Blackshift, no soporta enteros para per-instance vertex attributes. Solo soporta floats. Eso significa que si querés pasar un entero de 8 bits, tenés que castearlo a float antes de escribir en el instance buffer.

El engine hace exactamente eso: toma el entero adjacency value (ejemplo: 238), lo castea a float (238.0f), lo escribe en el buffer, y lo manda al GPU. El vertex shader lo recibe como un float, lo pasa al fragment shader sin convertir de vuelta. El fragment shader lo interpreta como float.

Ahora bien, este es el problema real: si algún shader (vertex, fragment, o código que prepara los buffers) hace operaciones que dependen de la representación binaria exacta del número, va a fallar. El float 238.0 es representable con precisión en punto flotante de 32 bits, pero si hay conversiones en el medio, truncados, rondeos, o si el shader asume que es un entero real, se puede romper.

El autor de Blackshift tuvo suerte — descubrió que en algunos casos, el shader estaba interpretando mal los bits de un float que pretendía ser un entero, causando que leyera sombras en direcciones que no debería. Ya lo cubrimos antes en requisitos de GPU para entrenar grandes modelos.

Síntomas y manifestación del bug

Los usuarios reportaban screenshots donde los sand tiles tenían sombras en los lugares equivocados. Algunas sombras aparecían donde no había vecino, otras desaparecían donde debería haber. El patrón era inconsistente — algunos tiles se veían bien, otros mal, sin un patrón obvio.

Eso es lo que hace este tipo de bugs tan molesto: no es un crash limpio. La aplicación corre perfecta, pero el rendering sale incorrecto. Y si no testeas con todas las configuraciones posibles (arquitecturas de GPU, drivers, precisión de punto flotante), puede no reproducirse en dev (ponele que la máquina del desarrollador tiene un driver más nuevo que maneja floats diferente, o el compilador de GLSL optimiza el código de forma distinta).

En este caso específico, el bug solo aparecía con ciertos valores de adjacency y probablemente solo en ciertas GPUs o versiones de drivers. El desarrollador tuvo que pensar qué hacía diferente cada tile que fallaba — y la respuesta estaba en los números de adjacency que recibía.

Técnicas de debugging para bugs de rendering

Si te topás con un bug visual como este, acá van las herramientas que funcionan:

Frame debuggers. RenderDoc (gratis, open source) y Nvidia PIX (gratis con CUDA Toolkit) son capaces de pausar la ejecución, inspeccionar cada draw call, ver qué texturas y buffers se usaron, qué valores tenían los vertex attributes, qué salida tuvo cada shader. Podés ver exactamente qué valor de adjacency recibió el shader de cada tile y compararla con el rendering final.

Print debugging en shaders. Algunos drivers soportan imageStore() a una storage buffer para “printear” valores directamente desde el shader. Es torpe, pero funciona cuando nada más te deja pistas.

Validación de buffers. Antes de renderizar, leé el instance buffer desde la CPU y imprimí los valores. ¿Son los que esperabas? ¿Se ve algún patrón en los valores que fallaban vs los que salían bien?

Comparación antes/después. Si tenés una rama old que funciona bien, comparála con la rama que tiene el bug. ¿Qué cambió? ¿El vertex shader? ¿Cómo se prepara el instance buffer? ¿El formato del per-instance data? Complementá con cómo ejecutar LLMs en tu GPU local.

En el caso de este bug, el desarrollador supo que el problema estaba en los adjacency values — probablemente porque debuggeó y vio que los valores en el buffer no se correspondían con el rendering, o que el shader los estaba interpretando mal.

Soluciones: alternativas para pasar datos enteros

Si necesitás pasar datos enteros al GPU (y el framework te obliga a castearlo a float), tenés varias opciones:

Uniform Buffer Objects (UBO). En lugar de per-instance attributes, usá un storage buffer que contenga todos los enteros para todas las instancias. El shader indexa en ese buffer usando gl_InstanceID. Más líneas de código, pero total control sobre la representación de datos.

Texturas para lookup. Empacá los enteros en una textura (un byte por píxel) y creá una textura de lookup. El shader accede a sampler2D usando el instance ID. Es un hack, pero funciona.

Bit packing en floats. Si necesitás meter el entero dentro de un float, usá bitfieldInsert() / bitfieldExtract() de GLSL 4.0+ (disponible en la mayoría de GPUs modernas). Estos built-ins empacan y desempacan bits sin riesgos de rondeo.

Restructurar el mesh. En lugar de pasar adjacency data por instancia, codificalo en los vértices del mesh. Cada vértice sabe quién es y puede calcular su propia información de sombra. Es menos eficiente (más vértices = más trabajo), pero elimina la complejidad del per-instance data.

Usar extensiones específicas de GPU. OpenGL 4.0+ tiene glVertexAttribIPointer() que declara un atributo como entero puro, sin conversión a float. Si el framework permite, úsalo. En WebGL necesitás EXT_gpu_shader4 o esperar a WebGL 2.0. Lo explicamos a fondo en modelos generativos con mayor demanda de GPU.

Errores comunes que comete la gente

Error #1: Asumir que float(entero) es seguro. Un entero de 32 bits puede no ser representable con exactitud como float si es muy grande. Para enteros < 2^24, está bien. Para mayores, hay riesgo de pérdida de precisión. Pero incluso para enteros "seguros" como 8 bits, si después hacés operaciones que asumen que es un entero (bit shifts, AND, OR), puede no funcionar.

Error #2: No testear con valores edge case. Si tenés un valor de adjacency que es 255 (todos los bits en 1) o 0 (ningún bit en 1), esos son casos especiales. A veces el bug solo aparece con estos valores extremos, y si tu testing inicial usa valores intermedios, no lo ves.

Error #3: No investigar el driver de la GPU. Un bug en rendering puede no reproducirse en tu máquina porque tu driver es más nuevo. Los usuarios reportan un bug, vos no lo ves localmente, y asumís que es un falso positivo. Siempre considerá que drivers viejos pueden compilar shaders diferente o tener bugs propios.

Preguntas Frecuentes

¿Por qué bgfx no soporta atributos enteros?

BGfx es un abstraction layer que se supone que funciona en múltiples plataformas (OpenGL, DirectX, Metal, WebGL). WebGL y algunas versiones viejas de OpenGL no tenían soporte para enteros en vertex attributes. BGfx eligió el denominador común: floats. Es una decisión de compatibilidad, no de capacidad.

¿Cómo distingo entre un bug de casting y un bug de lógica del shader?

Debuggeá el valor que recibe el shader (con print debugging o RenderDoc) y comparalo con lo que esperabas pasar. Si el valor está mal desde el buffer, es casting o preparación de datos. Si el valor es correcto pero el rendering sale mal, es lógica del shader.

¿GPU instancing siempre es más rápido que draw calls separados?

Sí, pero hay un trade-off. Si necesitás pasar mucho per-instance data (múltiples buffers, data gigante), el ancho de banda puede ser un cuello de botella. Para 5.000 tiles simples como en Blackshift, instancing es el camino correcto. Para 50 objetos complejos con 20 attributes cada uno, quizás sea un empate.

¿Cómo evito este bug en mi código?

No casteés tipos de datos sin pensar en la representación binaria. Si necesitás pasar un entero, o buscá una forma de hacerlo sin conversión (UBO, textura), o documentá que el casting es intencional y probá explícitamente con valores edge case. Y por favor, no asumasque float(int) es reversible — no siempre lo es.

¿Puedo reportar este tipo de bugs a bgfx?

BGfx es open source. Si encontrás un bug, reportalo en GitHub (github.com/bkaradzic/bgfx). Pero ten en cuenta que agregar soporte para enteros significaría sacrificar compatibilidad con plataformas viejas. La comunidad tiene que decidir si vale la pena.

Conclusión

Este bug es un recordatorio de que el rendering no es mágico — es transformaciones de bits, conversiones de tipos, y suposiciones sobre representación binaria. Cuando forcés un casting sin pensar, especialmente en el hot path (vertex attributes, shaders), te podés comer un bug que aparece en producción pero no en dev, que sale diferente en cada GPU, y que te consume horas debuggeando.

La lección: respetá los tipos de datos. Si el framework no te deja pasar un entero, no lo castees a float y esperes que funcione. Diseñá alrededor de la limitación: usá UBO, texturas, o restructurá tu mesh. Es más código, pero bugs menos raros. Y si ya tenés un bug visual que no entendés, agarrá RenderDoc, inspecciona los buffers frame por frame, y comparalos con lo que esperabas. Casi siempre el culpable está ahí.

Fuentes

Desplazarse hacia arriba