< Volver 

Spray

2026-09-28

ParticleEmitters de Roblox dibujados en la GUI con ImageLabels. Cada partícula se calcula en forma cerrada desde un reloj, así que el efecto se puede pausar, adelantar y rebobinar, y con la misma semilla sale idéntico.

LuauRobloxUIVFX
Ir a vista rápida

Partículas de GUI calculadas desde un reloj.

Resumen. Spray es una librería de Luau que reproduce los ParticleEmitter de Roblox dentro de la interfaz gráfica. El efecto se configura sobre un emisor normal en el panel de propiedades y se dibuja con ImageLabels. La decisión que define todo el diseño es que nada se integra cuadro a cuadro: el estado de cada partícula es una función cerrada del reloj del efecto, de una semilla y del índice de la partícula. De ahí salen el rebobinado exacto, la ausencia de deriva con el framerate y la reproducibilidad, y también las restricciones del sistema, porque no se puede guardar historia. En rendimiento, las mediciones mostraron que el 83% del costo de un cuadro se va en escribir propiedades de instancias del motor, así que la optimización se concentró en escribir menos y no en calcular más rápido. Con el mismo efecto armado en las dos librerías, Spray gasta entre 1.3 y 1.8 veces menos CPU por cuadro que Emitter2D, y 2.9 veces menos al lanzar una ráfaga sobre un pool ya construido.

1. El problema

Un ParticleEmitter de Roblox solo dibuja si está dentro de una Part o de un Attachment, o sea, en el mundo 3D. Las interfaces de un juego usan partículas todo el tiempo (el brillo de un botón de compra, el confeti al abrir una recompensa, chispas sobre una barra que se llena) y la GUI no tiene ninguna primitiva para eso. Lo único disponible son GuiObjects, así que en la práctica una partícula de interfaz es un ImageLabel que alguien mueve, escala, rota y desvanece a mano.

El modelo clásico de sistema de partículas viene de Reeves [1]: cada partícula nace con atributos sorteados alrededor de una media, se actualiza en cada cuadro y se elimina cuando se le acaba la vida. El ParticleEmitter de Roblox sigue esa idea, y también Emitter2D, la librería de partículas para UI que más adelante sirve de referencia, que se configura con su propio conjunto de atributos y avanza sus partículas en cada cuadro. Cuando empecé Spray tenía tres requisitos claros.

El primero es de autoría. El efecto se arma en un ParticleEmitter real, en el panel de propiedades, igual que uno 3D, de modo que el emisor es la configuración y nadie tiene que aprenderse una tabla nueva (yo incluido). Lo que un emisor 3D no puede expresar (el área de emisión en pantalla, un multiplicador de tamaño, el límite de partículas) va en atributos con prefijo Spray sobre el mismo emisor.

El segundo es que el tiempo sea una entrada y no un efecto secundario. Un efecto tiene que poder pausarse, saltar a cualquier instante y reproducirse hacia atrás, y con una semilla fija tiene que dar exactamente las mismas partículas cada vez. Eso sirve para ajustar una curva de tamaño mirando un instante congelado, y para que una animación de interfaz se vea igual en cada reproducción.

El tercero es el costo. Los efectos de interfaz se quedan en pantalla mucho tiempo, a menudo varios a la vez, y muchas veces en un celular.

2. El efecto como función del reloj

Un Spray guarda una sola variable de estado, su reloj c, que cuenta segundos de efecto. Mientras está reproduciéndose, en cada cuadro hace c←c+τΔt, donde τ es el TimeScale del emisor, y todo lo que aparece en pantalla se evalúa a partir de ese número:

F(c)={R(Σ,s,i,c)|i∈K(c)}

Aquí Σ es una instantánea inmutable del emisor, s es la semilla, K(c) es el conjunto de llaves de las partículas que podrían estar vivas en c, y R devuelve la posición, el tamaño, la rotación, el color, la transparencia y la celda de flipbook de la partícula i en ese instante. Ninguna de las tres funciones guarda memoria entre cuadros.

La alternativa habitual es integrar: 𝐱n+1=𝐱n+𝐯nΔtn. Con eso, el estado después de n cuadros depende de la secuencia completa de Δt, así que la misma partícula termina en lugares un poco distintos a 30 y a 144 fps, y el error se acumula. Para mostrar el segundo 3.2 hay que simular desde cero, y para retroceder habría que guardar estados anteriores.

Con el estado en forma cerrada, 𝐱(c) no depende de cómo se llegó a c, y eso tiene tres consecuencias directas. No hay deriva por framerate, porque no se acumula nada. :SetTime(3.2) da exactamente el mismo cuadro que reproducir hasta 3.2 segundos. Y rebobinar consiste en evaluar la misma fórmula con un c menor, sin aproximación ni reconstrucción. La suite de pruebas comprueba las dos últimas de forma explícita: una prueba verifica que saltar y avanzar paso a paso llevan al mismo cuadro, y otra que al rebobinar aparece exactamente lo que se vio al avanzar.

El precio es que todo lo que dependa del pasado hay que eliminarlo o convertirlo en dato. Un :Emit(n) no crea partículas, sino que agrega una tupla (Tb,nb,b) con el instante, la cantidad y un índice creciente. LockedToPart = false quedó sin implementar a propósito, porque fijar las partículas al punto donde estaba el padre cuando nacieron obliga a recordar ese punto, y eso es historia. Como el pool de ImageLabels cuelga del GuiObject del emisor, Spray se comporta siempre como LockedToPart = true: si el marco se mueve con un tween, el efecto lo acompaña.

La semántica de reproducción sale del mismo modelo. Un :Emit() sobre un Spray en pausa reinicia la línea de tiempo (reloj en 0, lista de ráfagas vacía y una semilla nueva si UseRandom está activo) y empieza a reproducir. Sobre uno que ya se está reproduciendo, agrega la ráfaga en el instante actual del reloj, igual que ParticleEmitter:Emit() en 3D. :Forward() y :SetTime() solo mueven c y redibujan, sin cambiar si el Spray está en pausa o no.

3. Aleatoriedad sin estado

Un generador pseudoaleatorio común tiene estado: se le pide el siguiente número y avanza. Eso no funciona aquí, porque para dibujar el instante c hay que poder preguntar qué velocidad le tocó a la partícula 400 sin pasar por las 399 anteriores. Lo que hace falta es acceso aleatorio a la secuencia, que es la idea de los generadores basados en contador de Salmon et al. [3]: el número n se calcula directamente como una función biyectiva y bien mezclada de n.

Spray usa un mezclador al estilo de SplitMix [2], que suma la constante áurea y aplica dos rondas de xorshift y multiplicación. Con todas las operaciones sobre enteros de 32 bits, es decir, módulo 232:

z0=x+𝟶𝚡𝟿𝙴𝟹𝟽𝟽𝟿𝙱𝟿z1=(z0⊕︎(z0≫16))⋅𝟶𝚡𝟸𝟷𝙵𝟶𝙰𝙰𝙰𝙳z2=(z1⊕︎(z1≫15))⋅𝟶𝚡𝟽𝟹𝟻𝙰𝟸𝙳𝟿𝟽M(x)=z2⊕︎(z2≫15)

El mezclador se usa en dos niveles, uno por partícula y otro por propiedad:

stream⁡(s,i)=M(s⊕︎(i+1)φ1)u(s,i,k)=M(stream⁡(s,i)⊕︎(k+1)φ2)232∈[0,1)

con φ1=𝟶𝚡𝟿𝙴𝟹𝟽𝟽𝟿𝙱𝟷 y φ2=𝟶𝚡𝟾𝟻𝙴𝙱𝙲𝙰𝟽𝟽. Con los mismos (s,i,k) sale siempre el mismo número, en cualquier orden. φ1 es un primo muy cercano a 232/ϕ, la razón áurea escalada a 32 bits, que es la constante habitual del hashing multiplicativo, porque multiplicar por ella reparte los bits del índice por todo el ancho de la palabra.

3.1 Multiplicar 32 bits con doubles

Los números de Luau son double y tienen 53 bits de mantisa, mientras que el producto de dos enteros de 32 bits puede ocupar 64. Multiplicar directo pierde los bits bajos sin ningún error, y esos son justo los que el hash necesita. El síntoma es bastante traicionero, porque no sale ningún error: las partículas simplemente aparecen agrupadas en vez de repartidas, y uno se pone a buscar el bug en la física. Multiply32 parte cada operando en mitades de 16 bits, a=216ah+aℓ y b=216bh+bℓ, y usa que el término 232ahbh desaparece módulo 232:

ab≡ 216[(ahbℓ+aℓbh)mod216]+aℓbℓ(mod232)

Cada producto parcial es menor que 232 y la suma nunca pasa de 234, así que todo queda muy por debajo del límite de la mantisa.

3.2 Canales

El tercer argumento, k, es un canal. Cada propiedad que se sortea tiene el suyo: vida, velocidad, ángulo de dispersión, rotación inicial, velocidad de giro, las tres tiradas de envelope (tamaño, transparencia y squash), las dos coordenadas de la forma de emisión, el signo de ShapeInOut, la celda inicial del flipbook, su framerate y el flipbook aleatorio. Son catorce canales. Sin esa separación, cambiar el rango de Speed en el emisor alteraría el flujo completo de números, y con él la vida, el ángulo y la rotación de cada partícula. Con canales, al mover Speed lo único que cambia es la velocidad.

La primera mezcla depende solo de la semilla y de la partícula, así que el código la separa: Sampling.Stream(s, i) calcula stream⁡(s,i) una vez y Sampling.Unit(stream, k) hace la segunda mezcla por canal. Derivar una partícula con n tiradas pasa de costar 2n mezclas a n+1, más o menos la mitad, y los números resultantes son idénticos a los de antes de partir la función porque la cuenta es la misma.

El único número aleatorio “de verdad” que usa un Spray es la semilla, que se sortea con math.random al crear el Spray y cada vez que el efecto reinicia con UseRandom activo. Con UseRandom(false) se usa una semilla fija, y el efecto se repite partícula por partícula, hasta la celda de flipbook que muestra cada una.

4. Qué partículas existen en el instante c

4.1 El flujo continuo

La partícula i del flujo continuo nace en ti=i/r, donde r es el Rate. Como ninguna vive más de Lmax⁡ (el máximo del rango Lifetime), las únicas que pueden estar vivas en c forman una ventana de índices:

Kflujo(c)={i∈ℕ|⌈(c−Lmax⁡)r⌉≤i≤⌊cr⌋}

Recorrer esa ventana es todo el trabajo. No hay lista de partículas vivas, no se inserta ni se borra nada de ninguna estructura, y el costo por cuadro es proporcional a rLmax⁡ sin importar cuánto tiempo lleve corriendo el efecto.

La ventana usa Lmax⁡ y no la vida real de cada partícula porque tiene que ser correcta antes de saber qué vida le tocó a cada una. Las que ya murieron se descartan después, cuando su edad supera su vida. Ahí me encontré el primer problema de rendimiento serio. Una partícula que murió antes de tiempo seguía dentro de la ventana, y en cada cuadro pedía un slot, derivaba todas sus constantes (unas catorce tiradas de hash) y lo devolvía. Con ráfagas grandes se midieron 1403 derivaciones por cuadro cuando solo hacían falta 274. Ahora, si una partícula no tiene slot, primero se sortea solo su vida, y si ya murió se descarta ahí mismo, sin tocar el pool.

4.2 Recortar el Rate con la ley de Little

La población en equilibrio de un flujo continuo sigue la ley de Little [4], N=λW: la cantidad de elementos en un sistema es la tasa de llegada por el tiempo que se queda cada uno. Aquí eso da N≈rL‾≤rLmax⁡. Si el pool admite como máximo P partículas (SprayMaxParticles), despejar r de rLmax⁡≤P da el recorte que aplica Spray:

r′=min⁡(r,PLmax⁡)

Este recorte existe sobre todo porque los emisores que solo se usan para ráfagas suelen tener el Rate olvidado en un valor absurdo, que sin él llenaría el pool de partículas que nadie pidió.

4.3 Ráfagas y llaves

Cada ráfaga (Tb,nb,b) cubre la ventana [Tb, Tb+Lmax⁡) y se salta sin más trabajo cuando el reloj está fuera. La lista guarda las últimas 64. Sin ese límite, un emisor que dispara cada cuadro la haría crecer durante toda la sesión, y el costo es que si se llama :Emit() más de 64 veces dentro de una vida de partícula, las ráfagas más viejas desaparecen antes de tiempo. Para un flujo constante lo correcto es usar Rate.

Cada partícula se identifica con un entero, y el esquema evita colisiones sin ninguna coordinación entre el flujo y las ráfagas:

κflujo(i)=2iκráfaga(b,j)=2(bP+j)+1,0≤j<P

La paridad separa los dos orígenes, y el factor bP separa las ráfagas entre sí, porque :Emit() limita cada ráfaga a P partículas. Dos ráfagas lanzadas en el mismo instante nunca comparten llave, y como la llave también es el índice del hash, cada partícula tiene sus propios números.

4.4 Dibujar solo la ráfaga nueva

Mi primera versión de :Emit() redibujaba el emisor completo, no solo la ráfaga nueva, y eso costaba un cuadro extra por ráfaga. A 60 fps casi no se nota. A 10 fps, en cambio, las ráfagas caen cada dos o tres cuadros y el redibujo pesa más, lo que baja todavía más los fps. Era un costo que crecía justo cuando el juego ya iba lento, y eso explicaba por qué las mediciones daban números tan distintos entre corridas. Ahora :Emit() dibuja solo las partículas de la ráfaga nueva, porque todo lo demás ya está donde lo puso el reloj.

5. De dónde sale cada partícula

Con SprayEmissionSize en cero el emisor es un punto y no hay nada que calcular. Es el caso más común, y es también como se comporta un emisor 3D colgado de un Attachment. Con un área de semiejes a y b (fracciones del GuiObject), la forma se muestrea con dos tiradas u,v∈[0,1).

La caja con volumen es trivial: (x,y)=((2u−1)a, (2v−1)b). La caja por superficie tiene una trampa. Lo obvio sería elegir un borde al azar y después un punto en él, pero eso le da la misma probabilidad a un borde corto que a uno largo, y en un rectángulo alargado los lados cortos quedan con mucha más densidad. Spray camina el perímetro por longitud de arco: ℓ=u(2a+2b) recorre un borde horizontal y uno vertical, y v<0.5 decide si la partícula va al borde de enfrente. Así hay menos aritmética y la misma uniformidad.

Sphere, Cylinder y Disc colapsan en 2D a la misma elipse:

θ=2πu,ρ=v(x,y)=(ρacos⁡θ, ρbsin⁡θ)

La raíz es el paso que más se olvida. En un disco uniforme, la probabilidad de caer a distancia menor que r es proporcional al área, P(ρ≤r)=r2, así que por muestreo por transformada inversa ρ=F−1(v)=v. Si se usa v directamente, el centro queda amontonado y el borde vacío. Con ShapeStyle = Surface, ρ=1.

La dirección de salida tampoco es el radio. La normal hacia afuera de f(x,y)=x2/a2+y2/b2=1 es el gradiente,

∇f∝(xa2, yb2),

que coincide con el radio solo cuando a=b. En una elipse ancha la diferencia se ve a simple vista, porque las partículas saldrían torcidas respecto a la superficie. La caja no usa nada de esto, porque una cara emite a través de sí misma y no alejándose de su centro, así que su normal es la EmissionDirection del emisor. ShapeInOut = Inward invierte la normal, y en InAndOut la moneda se lanza con su propio canal para que la decisión se mantenga al rebobinar.

Por último, la dispersión es una rotación en el plano con un ángulo simétrico alrededor de la dirección base, donde α es el SpreadAngle (su semiángulo, así que 180 es el círculo completo):

θ=(2u−1)α,𝐝′=(cos⁡θ−sin⁡θsin⁡θcos⁡θ)𝐝

6. Movimiento en forma cerrada

Con aceleración constante 𝐚 y arrastre lineal k (el Drag), la velocidad cumple

d𝐯dt=𝐚−k𝐯

que tiene solución exacta. Definiendo la velocidad terminal 𝐯∞=𝐚/k y el “tiempo efectivo” σ(t)=(1−e−kt)/k:

𝐯(t)=𝐯∞+(𝐯0−𝐯∞)e−kt𝐩(t)=𝐯∞t+(𝐯0−𝐯∞)σ(t)

σ(t) vale casi t al principio y se satura en 1/k cuando la partícula ya alcanzó su velocidad terminal. En el límite sin arrastre la expresión se reduce al tiro parabólico de siempre,

limk→0⁡𝐩(t)=𝐯0t+12𝐚t2,

y como las dos ramas coinciden en el límite, el código usa la parabólica cuando |k|<10−4. Ese umbral no cambia el resultado, solo evita dividir por casi cero.

Hay un caso numérico más. Con un Drag negativo el exponente −kt se vuelve positivo y crece sin límite, exp devuelve infinito, infinito por cero da NaN, y un ImageLabel con posición NaN simplemente desaparece sin ningún error. Por eso el exponente se recorta a [−60,60]. Un Drag mal puesto se ve feo, pero se ve.

La función devuelve posición y velocidad en la misma pasada, porque comparten e−kt y los términos terminales, y porque la velocidad hace falta después para orientar la partícula.

7. Traducir un emisor 3D a la pantalla

Todo lo que necesita traducción de 3D a 2D se traduce en un solo lugar, Spec.Read, que produce una tabla congelada con table.freeze. EmissionDirection pasa de NormalId a un vector de pantalla, donde Front y Back (hacia la cámara y en contra) no son una dirección en un plano y caen a Top, que combinado con el SpreadAngle de 180 con que se suelen usar da el círculo completo. La Acceleration invierte su Y, porque en la GUI +y apunta hacia abajo, y así una gravedad escrita como (0,−10,0) sigue cayendo. Del SpreadAngle se usa solo la X, y cada secuencia se convierte en una curva plana (sección 8.1). Después de este paso, la simulación y el renderer solo ven números y tablas y nunca necesitan saber qué es un NormalId.

Los atributos se leen con verificación de tipo: si SprayScale tiene un string, se usa el valor por defecto en vez de pasar un nil a las cuentas, donde reventaría como un error confuso tres módulos más adelante. La instantánea se lee una sola vez, al construir el Spray, y :Cleanup() la descarta para que el siguiente uso lea el emisor otra vez.

7.1 Unidades

Un stud vale, por defecto, la altura en píxeles del GuiObject padre. Con eso el efecto escala con la UI en la que vive y no con la pantalla, y sobrevive un cambio de tamaño de ventana o un celular sin tocar nada. SprayUnit permite tomar el ancho, el lado menor, el mayor, o un stud por píxel. La posición final mezcla dos escalas a propósito:

X=oxW+px(t)ρsvspx=max⁡(S(τ),0)ρss

El primer término de X es el punto de nacimiento, guardado como fracción ox del ancho W del padre, porque SprayEmissionSize también está en fracciones, y así el área de emisión sigue al padre cuando cambia de tamaño. El segundo es el desplazamiento físico, en studs, convertido con ρ píxeles por stud y multiplicado por la escala de velocidad sv. El tamaño usa la escala de tamaño ss, y SprayScale multiplica las dos. ρ se recalcula en cada cuadro en vez de guardarse en la instantánea, porque el padre cambia de tamaño con el viewport y un valor guardado deja partículas del tamaño equivocado hasta el siguiente rebuild.

8. Apariencia

8.1 Curvas

Size, Transparency, Squash y Color son secuencias con hasta 20 keypoints que se leen por interpolación lineal. Para ubicar el tramo se usa un recorrido lineal en vez de búsqueda binaria, porque con 20 puntos como máximo el recorrido gana.

El detalle que importó fue otro. Pedirle .Keypoints a un NumberSequence arma una tabla nueva con objetos nuevos en cada acceso, y eran cuatro accesos por partícula por cuadro. Spec.Read convierte cada secuencia una sola vez en una tabla plana y congelada de {Time, Value, Envelope}. Ese cambio, por sí solo, bajó el costo de mantener 1000 partículas en pantalla de 9.2 a 7.5 ms.

8.2 Envelope

Roblox define el envelope como una banda de ±E alrededor del valor de la curva. Spray lo resuelve con una tirada ϵ∈[−1,1) que se sortea una vez, cuando nace la partícula, y queda fija toda su vida:

S(τ)=S‾(τ)+E(τ)ϵ,τ=edadvida

Esa es la diferencia entre que el envelope se lea como variación entre partículas (unas más grandes, otras más chicas, cada una consistente consigo misma) y que se lea como ruido que tiembla cuadro a cuadro. Hay tres tiradas independientes, para tamaño, transparencia y squash, y E(τ) también se interpola entre keypoints, así que una banda que se abre a mitad de la vida se abre suave.

8.3 Squash

El Squash de Roblox estira la partícula sobre su eje largo. Spray lo implementa conservando el área: con ℓ=1+|q|,

(w,h)={(s/ℓ, sℓ)q≥0(sℓ, s/ℓ)q<0⇒wh=s2

8.4 Orientación

Esta parte es la más fácil de invertir sin darse cuenta. En la GUI, +y apunta hacia abajo y la rotación positiva es en sentido horario. Con esas dos convenciones, rotar el eje local (0,1) por θ da (−sin⁡θ,cos⁡θ), e igualar eso a la dirección de la velocidad deja

θ∗=atan2⁡(−vx, vy)

que es el ángulo que pone el +y local de la partícula a lo largo del movimiento. Pero Roblox, en VelocityParallel, alinea el eje x local con la velocidad y no el y, así que la base real es θ∗−90°, y VelocityPerpendicular le suma otros 90°:

θ=θ0+ωt+θ∗−90°

Parece un detalle, pero un emisor de estelas se configura como VelocityParallel con Rotation = 90, y ese 90 es justamente lo que pone el eje largo sobre la velocidad. Si se tomara θ∗ como base, cada emisor de ese tipo saldría girado un cuarto de vuelta, con las estelas atravesadas al movimiento. Los dos modos FacingCamera se saltan todo esto, porque en 2D ya están mirando a la cámara.

8.5 Flipbooks

Con una grilla de g×g celdas (N=g2), la celda inicial n0, el framerate f de la partícula y la ventana w=⌊tf⌋, los cuatro modos quedan así:

  • OneShot: n=clamp⁡(n0+⌊τ(N−n0+1)⌋, 1, N)
  • Loop: n=1+(n0−1+w)modN
  • PingPong: n=1+min⁡(m, 2N−2−m), con m=(n0−1+w)mod(2N−2)
  • Random: n=1+⌊N⋅u(s,i, 14+32w)⌋

El clamp de OneShot atrapa el caso τ=1 exacto, que si no se pasaría una celda. PingPong es una onda triangular de periodo 2N−2, y el −2 evita que las celdas de los extremos se repitan dos veces al dar la vuelta.

El modo Random tiene su gracia. En vez de llamar a math.random, hashea la ventana de tiempo usando 14+32w como canal. Dentro de una misma ventana el resultado es constante, y al rebobinar sobre una partícula se ven exactamente las mismas celdas que la primera vez. El factor 32 solo separa las ventanas de los otros trece canales de la partícula.

9. Luz sin mezcla aditiva

La GUI de Roblox tiene un solo modo de mezcla, alpha normal, y no existe ningún modo aditivo que pedirle. LightEmission no se puede cumplir literalmente, así que se emula de dos maneras.

La primera es un tone mapping que siempre está activo. Con g el Brightness, se escala el color y, si algún canal pasa de 1 (p>1), se corrige así:

𝐜′=g𝐜p=maxi⁡ci′𝐜′′=(1−β)𝐜′p+β𝟏β=1−1p

La idea es que un canal que se sale del rango no tiene a dónde ir sino hacia el blanco. Se renormaliza para conservar el tono, y después se arrastra hacia blanco según cuánto se desbordó. Para un color con algún canal en 1, Brightness = 2 da β=0.5 y Brightness = 15 da β≈0.93, un núcleo casi blanco con un tinte apenas visible, que es como se ve una partícula aditiva saturada. Antes de esto, un brillo así había que dibujarlo ya quemado en la textura.

Brightness y LightEmission se mantienen separados a propósito. Amarrarlos es tentador, pero aplanaría todo lo que esté configurado brillante sin emitir luz: un destello de impacto con Brightness = 4 saldría como un color medio y plano en vez del crema quemado que le corresponde.

La segunda son capas de halo opcionales (SprayGlowLayers, de 1 a 8), copias de la partícula detrás del núcleo que crecen y se desvanecen en progresión geométrica:

γn=1+0.45n,αn=(1−T)⋅0.45n⋅EL

donde T es la transparencia de la partícula y EL su LightEmission, así que un emisor que no emite luz no recibe halo. Cada capa va un ZIndex más atrás que la anterior para no tapar el núcleo. LightInfluence, por su parte, interpola el color hacia 𝐜′′⊙𝐀, con 𝐀 el Lighting.Ambient, porque una ScreenGui no tiene iluminación de escena y la luz ambiente es el sustituto más razonable.

Ninguna de las dos técnicas hace que dos partículas superpuestas sumen su luz como lo haría la mezcla aditiva real, y nada disponible para un GuiObject lo permite. Sobre los paneles oscuros donde suelen ir estos efectos, la diferencia es pequeña.

10. El costo real está en escribirle al motor

10.1 El piso

Antes de optimizar nada medí lo que cuesta cada operación suelta, en un bucle:

Operación Costo aproximado
Escribir una propiedad de un ImageLabel 700 ns
Una tirada del hash (Sampling.Unit) 450 ns
Insertar en un set y vaciarlo 200 ns
Color3:Lerp 80 ns
Leer .Visible 80 ns
UDim2.fromOffset 50 ns
Leer .X y .Y de un Vector2 18 ns
Leer un Enum 8 ns

Esa tabla explica casi todo lo que vino después. Una partícula que cambia posición, tamaño, rotación, transparencia y color necesita cinco escrituras, y con cw≈0.7 μs eso son unos 3.5 μs por partícula por cuadro que ninguna librería basada en ImageLabels puede evitar. El costo de un cuadro se modela razonablemente como

Ccuadro≈N(wcw+ccalc)

con N partículas visibles y w escrituras por partícula. Quitando las escrituras, todo el cálculo de Spray para 1000 partículas cuesta 1.0 ms de un cuadro de 5.8 ms, es decir, las escrituras son el 83%.

10.2 Por qué ni buffers ni Actors

Con esa proporción, la ley de Amdahl [5] limita lo que se puede ganar acelerando el cálculo. Si una fracción f del trabajo es secuencial, repartir el resto entre n procesadores da como mucho

S(n)=1f+(1−f)/n

Las escrituras a instancias solo pueden hacerse en serie (un Actor puede calcular en paralelo, pero no modificar instancias), así que f≈0.83, y eso deja S(4)≈1.15 y un techo de S(∞)≈1.2, suponiendo un reparto perfecto y sin costo de sincronización. Las dos ideas sonaban bien, así que las medí antes de descartarlas. Un núcleo de cinemática sobre buffers corrió 2.7 veces más rápido que sobre tablas (0.031 contra 0.083 ms por 1000 partículas), pero eso ahorra 0.05 ms de 5.8, menos del 1%. Un prototipo con 4 Actors y 2000 partículas cada uno, calculando en paralelo y escribiendo después de task.synchronize(), empató con la versión en serie: una ronda la ganó el paralelo (30.7 contra 33.2 ms) y la otra el serie (36.2 contra 40.7 ms). Con un techo teórico de 15%, es razonable que la sincronización se lo coma entero.

10.3 No escribir

Si escribir es lo caro, lo que más rinde es escribir menos. El renderer guarda en cada slot lo último que le escribió a cada ImageLabel y se salta toda escritura que no cambie el valor. Comparar dos números cuesta casi nada frente a 700 ns, así que un emisor de color plano, sin giro o con tamaño fijo se ahorra varias escrituras por partícula en cada cuadro. La visibilidad también se lleva en esa caché, porque leer .Visible de vuelta es otra llamada al motor.

El color plano recibe un trato aparte: si todos los keypoints de Color son iguales, el color ya graduado (con el tone mapping de la sección 9) se calcula una sola vez al construir el renderer, y no se vuelve a leer la curva. En un efecto típico de UI, con color plano y sin giro, Spray escribe tres propiedades por partícula (posición, tamaño y transparencia) mientras Emitter2D escribe diez, y es ahí donde la diferencia entre las dos librerías crece más.

10.4 Cero asignaciones por cuadro

Las constantes de cada partícula (vida, velocidad inicial, tiradas de envelope, etc.) se escriben dentro de la tabla que ya tiene el slot en vez de reemplazarla, y la función que resuelve una partícula escribe en un único ParticleFrame que vive en el Spray. Con eso, recorrer 400 partículas no crea ninguna tabla. Sí se crean los UDim2 que piden Position y Size, y eso no hay forma de evitarlo.

Para saber qué slots quedaron sin dibujar, la primera versión vaciaba y volvía a llenar un set de llaves en cada cuadro, a unos 200 ns por partícula, más que varias de las cuentas. Ahora cada pasada de dibujo tiene un número, cada slot dibujado queda marcado con él, y al final se liberan los que tienen una marca vieja.

10.5 El pool y el prewarm

Cada Spray tiene un pool de slots, y un slot es el conjunto de ImageLabels de una partícula (uno por capa de halo) más sus constantes. Cuando una partícula muere, su slot se oculta y pasa a la lista de libres, listo para la siguiente. Acquire(k) devuelve el slot que ya tiene la llave k, o uno libre, o nada si se llegó a SprayMaxParticles. Un slot reciclado todavía trae las constantes de la partícula anterior, y la única señal de que hay que derivarlas de nuevo es que slot.Constants.Key no coincide con k. Todo el mecanismo de “esta partícula es nueva” es esa comparación, sin banderas ni eventos.

Reutilizar slots resuelve el costo de las ráfagas siguientes, pero no el de la primera. Crear un ImageLabel y dejarle puestas sus propiedades fijas (textura, ancla, modo de escala, ZIndex, recorte del flipbook) es casi todo lo que cuesta la primera ráfaga de un efecto, y en una ráfaga grande eso es un tirón visible justo en el momento en que el jugador presiona el botón. :Prewarm(n) construye esos slots por adelantado y los deja ocultos en la lista de libres, para que ese costo se pague donde no se note, como en una pantalla de carga. El parámetro es el total que debe tener el pool y no cuántos agregar, así que es idempotente, y nunca pasa de SprayMaxParticles. El atributo SprayPrewarm hace lo mismo cada vez que el Spray se construye, incluidos los rebuilds después de :Cleanup().

10.6 Un solo scheduler

Todos los Sprays del juego comparten una sola conexión a RenderStepped. Con una conexión por Spray, el motor recorrería una lista de conexiones para hacer lo mismo que un solo bucle sobre una tabla, y tener nueve efectos en pantalla a la vez es normal en una interfaz cargada. La conexión además solo existe mientras hay algo reproduciéndose, de modo que un Spray en pausa entre ráfagas no cuesta nada. Los módulos que corren en cada cuadro se compilan con --!native, y los Enum que se comparan por partícula se leen una sola vez al cargar el módulo.

11. Detalles del motor

Algunas decisiones del renderer no salen de ningún modelo, sino de cómo se comporta el motor, y se ven raras si no se conoce el porqué.

Un ImageLabel oculto descarta las asignaciones de ImageRectSize. Por eso Visible = true se pone antes que cualquier otra propiedad. Si no, una partícula de flipbook cuyo primer cuadro dibujado es también su primer cuadro visible se queda sin recorte y muestra la hoja de sprites completa. Como todo lo demás se escribe en ese mismo cuadro, nada llega a verse sin posicionar.

El motor también descarta un ImageRectSize asignado contra una textura que todavía no terminó de cargar, y no hay forma de preguntarle si la asignación quedó. La solución es reafirmar el recorte en cada cuadro en vez de confiar en el que se puso al construir el slot. Cuesta una escritura más por partícula, solo en emisores con flipbook. Queda un caso sin cubrir, que es el primer cuadro de la primera ráfaga con la textura todavía en camino, pero como los slots se reciclan, no puede volver a pasar.

El pool vive en un Frame propio de tamaño cero, anclado al centro del padre, y cada partícula se posiciona con un desplazamiento en píxeles desde ahí. Tener un marco propio evita que un UIListLayout en el padre intente acomodar las partículas, y el marco tiene Archivable = false para que clonar la UI no copie un pool viejo y para que un lugar guardado a mitad de una vista previa no lo conserve. Con SprayIgnoreClips, el marco sube por encima de cualquier ancestro con ClipsDescendants y en cada cuadro se reubica sobre el origen del emisor usando AbsolutePosition, restando el inset de la GUI cuando la ScreenGui lo respeta.

ZOffset se suma al ZIndex base, así que los emisores que comparten un padre se apilan como se configuraron. Bajo ZIndexBehavior.Sibling, sin embargo, ningún GuiObject puede quedar por encima de los hermanos de su padre, de modo que una partícula no sale de su propio subárbol con ningún ZIndex. Esa es una restricción del motor, y la documentación le dice al usuario que ponga el efecto bajo un padre que ya esté al frente.

Por último, :Destroy() le pone al objeto una metatabla que lanza un error en cualquier acceso. Una referencia que quedó viva por error falla en el lugar donde se usa, en vez de medio funcionar contra una tabla vacía y fallar en otro módulo.

Como el motor sigue avanzando cuadros en modo edición y Studio dibuja StarterGui sobre el viewport, el módulo Preview puede reproducir los emisores sin iniciar una prueba, directamente desde la barra de comandos. Es una herramienta de desarrollo que nada en el paquete requiere, así que nunca corre en el juego.

12. Evaluación

12.1 Método

La referencia es Emitter2D, otra librería de partículas para UI en Roblox. El mismo efecto se armó a mano en las dos, con la misma textura, dispersión, velocidad, curvas de tamaño y transparencia, gradiente de color, rotación, gravedad, arrastre y tamaño en pantalla. Spray mide en alturas del padre y Emitter2D en píxeles escalados, y de ahí sale el factor 1.5 entre los números de las dos configuraciones. El escenario son 10 emisores sobre marcos de 100 x 100 px.

Lo primero que aprendí fue que los tiempos absolutos no sirven. La misma prueba daba el doble de rápido o el doble de lento según el minuto, porque la máquina cambiaba de ritmo a mitad de corrida. Lo que sí se mantenía era la proporción entre las dos librerías cuando corrían una pegada a la otra, así que armé el benchmark alrededor de esa proporción. Hay tres pruebas:

  • En steady, se sostienen de 100 a 4000 partículas y se cronometra la actualización de cada librería por separado, con 15 cuadros de calentamiento y 120 de muestra. Cada cantidad corre en orden A B B A, y de cada librería se toma la mejor de sus dos medianas. Se repite con una versión simple del efecto (color plano, sin giro), más parecida a un efecto típico de UI.
  • En burst se mide cuánto tarda en quedar en pantalla una ráfaga de 200 partículas, en frío (sin ningún ImageLabel creado) y en tibio (reutilizando los de la ráfaga anterior), en 5 rondas. Spray la corre además sobre un pool preparado con :Prewarm(), algo que Emitter2D no tiene.
  • En live, las dos librerías corren con sus propios schedulers, relanzando ráfagas cada 0.25 s, durante 3 rondas alternando quién va primero, y cada una reporta su ronda mediana.

12.2 Punto de partida

Siendo honesto, la primera medición no daba para presumir. Con 1000 partículas en pantalla empataban (9.2 contra 9.4 ms), y con ráfagas seguidas Spray perdía, 22 contra 14 ms en una corrida y 67 contra 16 en otra. La variación entre esas dos corridas era el síntoma del redibujo completo en cada :Emit() (sección 4.4).

12.3 Resultados

Después de los cambios de las secciones 4, 8 y 10:

Prueba Spray Emitter2D Razón
1000 partículas en pantalla 6.4 ms 8.7 ms 1.36x
4000 partículas 25.6 ms 36.2 ms 1.41x
1000, color plano y sin giro 3.6 ms 6.0 ms 1.67x
4000, color plano y sin giro 15.5 ms 28.4 ms 1.83x
Ráfaga de 200 reutilizando el pool 1.6 ms 4.7 ms 2.94x
Ráfagas cada 0.25 s, ~1000 vivas 8.6 ms 11.0 ms 1.28x

El patrón va en la dirección que predice el modelo de costo de la sección 10, porque la ventaja es mayor donde Spray se ahorra más escrituras. En el efecto completo las cinco propiedades cambian en cada cuadro, así que la caché casi no ayuda, y Spray escribe cinco contra las diez de Emitter2D. En el efecto simple baja a tres contra diez, y la ventaja crece además con la cantidad de partículas, que es lo esperable cuando el término dominante es Nwcw.

12.4 Corrección

Las optimizaciones no podían cambiar el resultado, y no lo cambiaron: con la misma semilla, Spray produce exactamente las mismas partículas que antes de optimizar. La suite de 88 pruebas en TestEZ cubre el hash (rango, media cercana a 1/2, independencia entre canales, índices vecinos sin agruparse), la física (caída libre como v0t+at2/2, decaimiento exponencial con arrastre, convergencia a a/k, continuidad cuando k→0, valores finitos con arrastre negativo), la emisión (cono de dispersión, áreas de caja y elipse, Inward), el tamaño con squash que conserva el área, la orientación de estelas, los modos de flipbook, la traducción de atributos, la población del flujo continuo (r×L partículas vivas, sin pasar del pool) y las propiedades del reloj: saltar y avanzar paso a paso llevan al mismo cuadro, y rebobinar muestra lo mismo que se vio al avanzar.

13. Limitaciones y trabajo futuro

La limitación más visible es la luz. Las partículas superpuestas no suman, y algunas texturas de Roblox pensadas para mezcla aditiva (fire_main, fire_sparks, forcefield_glow) salen oscuras en la GUI porque no tienen un canal alfa real. ShapePartial y VelocityInheritance se leen pero todavía no se usan. LockedToPart = false no está soportado por la razón de la sección 2, y la historia de 64 ráfagas es un límite práctico para quien dispara :Emit() muy seguido.

La línea más prometedora es un renderer opcional sobre EditableImage, que tiene ImageCombineType.Add y por lo tanto mezcla aditiva de verdad. Una prueba en Studio midió lo siguiente. Limpiar un lienzo de 512 x 512 con WritePixelsBuffer cuesta 0.11 ms. Dibujar 1000 partículas con DrawImageTransformed cuesta unos 32 ms, igual con mezcla normal que aditiva, frente a unos 5.8 ms con ImageLabels. Esa llamada tampoco recibe color ni transparencia por partícula, y las texturas .dds que trae Roblox no cargan. Dibujar partícula por partícula con el motor es entonces unas cinco veces más lento, y solo tendría sentido para efectos chicos donde la mezcla aditiva importe más que la cantidad.

La opción que falta medir es un rasterizador propio. Spray escribiría los píxeles de todas las partículas en un buffer y haría un único WritePixelsBuffer por cuadro, de modo que el color, la transparencia y la suma aditiva pasarían a ser aritmética y la escritura al motor sería una llamada en vez de miles. Eso además cambia el análisis de la sección 10.2: si el dibujo deja de ser escritura de propiedades, f deja de ser 0.83 y el trabajo sí se puede repartir entre Actors, por ejemplo dividiendo el lienzo en franjas con un EditableImage por Actor para no compartir memoria. La arquitectura actual ya lo permite, porque la simulación no toca instancias y solo el renderer tendría que cambiar. La pregunta abierta es si Luau nativo puede mezclar unos 300 mil píxeles por cuadro (1000 partículas de 20 px) en pocos milisegundos. Si no puede, los ImageLabels siguen ganando.

14. Lo que me dejó el proyecto

Evaluar en vez de integrar es lo que hace posible rebobinar, y además simplificó casi todo lo demás: no hay listas de partículas vivas que mantener, el determinismo sale gratis y las pruebas pueden comparar cuadros exactos. La otra lección fue sobre rendimiento. Casi todo el tiempo se va en escribirle al motor, así que las mejoras que movieron los números fueron las que dejaron de hacer trabajo inútil, y hacer más rápidas las cuentas, como con los buffers, casi no cambió el total. Y eso solo lo vi cuando me puse a medir la proporción contra una referencia fija, antes de tocar el código.

Referencias

  1. W. T. Reeves. “Particle Systems: A Technique for Modeling a Class of Fuzzy Objects”. ACM Transactions on Graphics, 2(2), 1983.
  2. G. L. Steele Jr., D. Lea y C. H. Flood. “Fast Splittable Pseudorandom Number Generators”. OOPSLA, 2014.
  3. J. K. Salmon, M. A. Moraes, R. O. Dror y D. E. Shaw. “Parallel Random Numbers: As Easy as 1, 2, 3”. SC ’11, 2011.
  4. J. D. C. Little. “A Proof for the Queuing Formula: L = λW”. Operations Research, 9(3), 1961.
  5. G. M. Amdahl. “Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities”. AFIPS Spring Joint Computer Conference, 1967.

El código, la documentación de la API y el benchmark están en github.com/mastedore/spray. Spray se distribuye bajo la Mozilla Public License 2.0.

Este texto de Mastedore. Todos los derechos reservados.