
En el desarrollo de videojuegos, a menudo es necesario vincular algo al azar: en Unity hay su propio Random, mientras que paralelamente existe System.Random. Hace mucho tiempo, en uno de los proyectos, se creó la impresión de que ambos podían funcionar de manera diferente (aunque deberían tener una distribución uniforme).
En ese momento no nos adentramos en los detalles — fue suficiente con que la transición a System.Random solucionara todos los problemas. Ahora decidimos investigar más a fondo y realizar un pequeño estudio: qué tan "sesgados" o predecibles son los generadores de números aleatorios, y cuál elegir. Además, he escuchado opiniones contradictorias sobre su "honestidad" — intentaremos comprender cómo se relacionan los resultados reales con lo declarado.
Breve curso de introducción o qué son los generadores de números aleatorios.
Si ya estás familiarizado con los generadores de números aleatorios, puedes saltar directamente a la sección "Pruebas".
Los números aleatorios (NA) son una secuencia de números generada mediante algún proceso aleatorio (caótico), una fuente de entropía. Es decir, es una secuencia cuyos elementos no están relacionados entre sí por ninguna ley matemática — no existe una relación causal entre ellos.
Lo que genera los NA se llama generador de números aleatorios (GNA). A primera vista, todo parece elemental, pero si pasamos de la teoría a la práctica, en realidad implementar un algoritmo de programación para generar tal secuencia no es tan sencillo.
La razón radica en la falta de esa misma aleatoriedad en la electrónica de consumo moderna. Sin ella, los números aleatorios dejan de ser aleatorios, y su generador se convierte en una función ordinaria de argumentos previamente determinados. Para una serie de especialidades en el campo de TI, esto representa un serio problema (por ejemplo, en criptografía), mientras que para los demás hay una solución bastante aceptable.
Es necesario escribir un algoritmo que devuelva, aunque no sean números verdaderamente aleatorios, sí los más cercanos posibles a ellos — los llamados números pseudoaleatorios (PNA). En este caso, el algoritmo se llama generador de números pseudoaleatorios (GPA).
Hay varias formas de crear un GPA, pero para todos será relevante lo siguiente:
- La necesidad de inicialización previa.
El generador de números aleatorios (GNA) carece de una fuente de entropía, por lo que debe establecer un estado inicial antes de su uso. Este se define como un número (o vector) y se llama semilla (seed, random seed). A menudo, se utiliza un contador de ciclos del procesador o el equivalente numérico de la hora del sistema como semilla.
- Reproducibilidad de la secuencia.
El GNA es completamente determinista, por lo que la semilla establecida al inicializarlo determina de manera inequívoca toda la futura secuencia de números. Esto significa que un GNA determinado, inicializado con la misma semilla (en diferentes momentos, en diferentes programas, en diferentes dispositivos) generará la misma secuencia.
También es necesario conocer la distribución de probabilidad que caracteriza al GNA: qué números generará y con qué probabilidad. Por lo general, esto es ya sea una distribución normal (normal distribution) o una distribución uniforme (uniform distribution).

Distribución normal (a la izquierda) y distribución uniforme (a la derecha)
Supongamos que tenemos un dado justo con 24 caras. Si lo lanzamos, la probabilidad de que salga un uno será de 1/24 (al igual que la probabilidad de cualquier otro número). Si hacemos múltiples lanzamientos y anotamos los resultados, podemos notar que todas las caras salen aproximadamente con la misma frecuencia. En esencia, este dado puede considerarse un generador de números aleatorios con distribución uniforme.
¿Y si lanzamos 10 de estos dados y contamos la suma total de puntos? ¿Se mantendrá la uniformidad? No. La suma estará más cerca de 125 puntos, es decir, de un cierto valor promedio. Y como consecuencia, incluso antes de realizar el lanzamiento, podemos evaluar aproximadamente el resultado futuro.
La razón es que hay la mayor cantidad de combinaciones para obtener la suma promedio de puntos. Cuanto más lejos estemos de ella, menos combinaciones habrá; por lo tanto, menor será la probabilidad de que ocurra. Si visualizamos estos datos, recordarán vagamente la forma de una campana. Por eso, con cierta flexibilidad, el sistema de 10 dados se puede denominar un generador de números aleatorios con distribución normal.
Otro ejemplo, solo que en el plano: disparar a un objetivo. El tirador será el GNA que genera un par de números (x, y), que se representan en un gráfico.

Coincidamos en que la opción de la izquierda se asemeja más a la vida real: es un generador de números aleatorios con distribución normal. Pero si necesitamos esparcir estrellas en el cielo oscuro, la opción de la derecha, obtenida con un generador de números aleatorios con distribución uniforme, es la mejor. En general, elija el generador según la tarea planteada.
Ahora hablemos sobre la entropía de la secuencia de números aleatorios. Por ejemplo, hay una secuencia que comienza así:
89, 93, 33, 32, 82, 21, 4, 42, 11, 8, 60, 95, 53, 30, 42, 19, 34, 35, 62, 23, 44, 38, 74, 36, 52, 18, 58, 79, 65, 45, 99, 90, 82, 20, 41, 13, 88, 76, 82, 24, 5, 54, 72, 19, 80, 2, 74, 36, 71, 9, …
¿Qué tan aleatorios parecen estos números a primera vista? Comencemos verificando la distribución.

Parece cercano a ser uniforme, pero si leemos la secuencia de dos en dos e interpretamos esos números como coordenadas en un plano, esto es lo que obtenemos:

Se pueden observar patrones claramente. Dado que los datos en la secuencia están ordenados de cierta manera (es decir, tienen baja entropía), esto puede generar esa ‘tendencia’. Como mínimo, este generador de números aleatorios no es muy adecuado para la generación de coordenadas en un plano.
Otra secuencia:
42, 72, 17, 0, 30, 0, 15, 9, 47, 19, 35, 86, 40, 54, 97, 42, 69, 19, 20, 88, 4, 3, 67, 27, 42, 56, 17, 14, 20, 40, 80, 97, 1, 31, 69, 13, 88, 89, 76, 9, 4, 85, 17, 88, 70, 10, 42, 98, 96, 53, …
Aunque aquí todo parece bien incluso en el plano:

Veamos en tres dimensiones (leyendo de tres en tres números):

Y nuevamente aparecen los patrones. Crear una visualización en cuatro dimensiones ya no es posible. Pero los patrones pueden existir también en esta dimensión y en dimensiones mayores.
En la criptografía, donde se exigen los estándares más estrictos a los generadores de números aleatorios, esta situación es absolutamente inaceptable. Por lo tanto, se han desarrollado algoritmos especiales para evaluar su calidad, de los cuales no hablaremos ahora. El tema es amplio y podría ocupar un artículo por separado.
Pruebas
Si no sabemos algo con certeza, ¿cómo deberíamos actuar? ¿Vale la pena cruzar la calle si no sabemos qué señal ofrece permiso? Las consecuencias pueden ser diversas.
Lo mismo ocurre con el famoso random en Unity. Es bueno si la documentación proporciona los detalles necesarios, pero la historia mencionada al principio del artículo ocurrió precisamente por la falta de la información específica deseada.
Y sin saber cómo funciona la herramienta, no podrás aplicarla correctamente. En resumen, ha llegado el momento de comprobar y realizar un experimento para asegurarte al menos sobre la distribución.
La solución fue simple y efectiva: recopilar estadísticas, obtener datos objetivos y observar los resultados.
Tema de investigación
En Unity hay varias formas de generar números aleatorios; hemos probado cinco.
- System.Random.Next(). Genera enteros (integer) en un rango de valores dado.
- System.Random.NextDouble(). Genera números de doble precisión (double) en un rango de [0; 1).
- UnityEngine.Random.Range(). Genera números de simple precisión (float) en un rango de valores dado.
- UnityEngine.Random.value. Genera números de simple precisión (float) en un rango de [0; 1).
- Unity.Mathematics.Random.NextFloat(). Parte de la nueva biblioteca Unity.Mathematics. Genera números de simple precisión (float) en un rango de valores dado.
Casi en toda la documentación se indicó una distribución uniforme, excepto en UnityEngine.Random.value (donde no se especifica la distribución, pero por analogía con UnityEngine.Random.Range() también se esperaba uniforme) y en Unity.Mathematics.Random.NextFloat() (donde se basa en el algoritmo xorshift, por lo que nuevamente se debe esperar una distribución uniforme).
Por defecto, los resultados esperados eran los indicados en la documentación.
Metodología
Escribimos una pequeña aplicación que generaba secuencias de números aleatorios mediante cada uno de los métodos presentados y guardaba los resultados para su posterior procesamiento.
La longitud de cada secuencia es de 100,000 números.
El rango de valores de los números aleatorios es [0, 100).
Los datos se recopilaron de varias plataformas objetivo:
- Windows
— Unity v2018.3.14f1, modo Editor, Mono, .NET Standard 2.0 - macOS
— Unity v2018.3.14f1, modo Editor, Mono, .NET Standard 2.0
— Unity v5.6.4p4, modo Editor, Mono, .NET Standard 2.0 - Android
— Unity v2018.3.14f1, construcción para dispositivo, Mono, .NET Standard 2.0 - iOS
— Unity v2018.3.14f1, construcción para dispositivo, il2cpp, .NET Standard 2.0
Implementación
Tenemos varios métodos diferentes para generar números aleatorios. Para cada uno, escribiremos una clase envolvente que debe proporcionar:
- La posibilidad de establecer un rango de valores [min/max). Se especificará a través del constructor.
- Un método que retorna un número aleatorio. Elegiremos float como tipo, por ser más general.
- Nombre del método de generación para etiquetar resultados. Para mayor comodidad, devolveremos el nombre completo de la clase + el nombre del método utilizado para generar el número aleatorio.
Primero, declaremos una abstracción que se presentará a través de la interfaz IRandomGenerator:
namespace RandomDistribution
{
public interface IRandomGenerator
{
string Name { get; }
float Generate();
}
}Implementación de System.Random.Next()
Este método permite establecer un rango de valores, pero devuelve números enteros (integer), y necesitamos float. Se puede interpretar simplemente integer como float, o se puede ampliar el rango de valores en varios órdenes, compensándolos en cada generación del número aleatorio. Obtendremos algo así como punto fijo con una precisión de orden definida. Usaremos esta opción, ya que está más cerca del verdadero valor float.
using System;
namespace RandomDistribution
{
public class SystemIntegerRandomGenerator : IRandomGenerator
{
private const int DefaultFactor = 100000;
private readonly Random _generator = new Random();
private readonly int _min;
private readonly int _max;
private readonly int _factor;
public string Name => "System.Random.Next()";
public SystemIntegerRandomGenerator(float min, float max, int factor = DefaultFactor)
{
_min = (int)min * factor;
_max = (int)max * factor;
_factor = factor;
}
public float Generate() => (float)_generator.Next(_min, _max) / _factor;
}
}Implementación de System.Random.NextDouble()
Aquí hay un rango fijo de valores [0; 1). Para proyectarlo en el especificado en el constructor, utilizamos una simple aritmética: X * (max − min) + min.
using System;
namespace RandomDistribution
{
public class SystemDoubleRandomGenerator : IRandomGenerator
{
private readonly Random _generator = new Random();
private readonly double _factor;
private readonly float _min;
public string Name => "System.Random.NextDouble()";
public SystemDoubleRandomGenerator(float min, float max)
{
_factor = max - min;
_min = min;
}
public float Generate() => (float)(_generator.NextDouble() * _factor) + _min;
}
}Implementación de UnityEngine.Random.Range()
Este método de la clase estática UnityEngine.Random permite establecer un rango de valores y devuelve un número aleatorio de tipo float. No será necesario realizar conversiones adicionales.
using UnityEngine;
namespace RandomDistribution
{
public class UnityRandomRangeGenerator : IRandomGenerator
{
private readonly float _min;
private readonly float _max;
public string Name => "UnityEngine.Random.Range()";
public UnityRandomRangeGenerator(float min, float max)
{
_min = min;
_max = max;
}
public float Generate() => Random.Range(_min, _max);
}
}Implementación de UnityEngine.Random.value
La propiedad value de la clase estática UnityEngine.Random devuelve un número aleatorio de tipo float dentro de un rango fijo de valores [0; 1). Lo proyectamos al rango especificado de la misma manera que en la implementación de System.Random.NextDouble().
using UnityEngine;
namespace RandomDistribution
{
public class UnityRandomValueGenerator : IRandomGenerator
{
private readonly float _factor;
private readonly float _min;
public string Name => "UnityEngine.Random.value";
public UnityRandomValueGenerator(float min, float max)
{
_factor = max - min;
_min = min;
}
public float Generate() => (float)(Random.value * _factor) + _min;
}
}Implementación de Unity.Mathematics.Random.NextFloat()
El método NextFloat() de la clase Unity.Mathematics.Random devuelve un número de tipo float y permite establecer un rango de valores. La única consideración es que cada instancia de Unity.Mathematics.Random debe inicializarse con una semilla; de esta manera evitamos la generación de secuencias repetidas.
using Unity.Mathematics;
namespace RandomDistribution
{
public class UnityMathematicsRandomValueGenerator : IRandomGenerator
{
private Random _generator;
private readonly float _min;
private readonly float _max;
public string Name => "Unity.Mathematics.Random.NextFloat()";
public UnityMathematicsRandomValueGenerator(float min, float max)
{
_min = min;
_max = max;
_generator = new Random();
_generator.InitState(unchecked((uint)System.DateTime.Now.Ticks));
}
public float Generate() => _generator.NextFloat(_min, _max);
}
}Implementación de MainController
Varias implementaciones de IRandomGenerator están listas. A continuación, necesitamos generar secuencias y guardar el conjunto de datos resultante para su procesamiento. Para ello, crearemos en Unity una escena y un pequeño script MainController, que realizará todo el trabajo necesario y además se encargará de la interacción con la interfaz de usuario.
Estableceremos el tamaño del conjunto de datos y el rango de valores de números aleatorios, así como crearemos un método que devuelva un array de generadores configurados y listos para usar.
namespace RandomDistribution
{
public class MainController : MonoBehaviour
{
private const int DefaultDatasetSize = 100000;
public float MinValue = 0f;
public float MaxValue = 100f;
...
private IRandomGenerator[] CreateRandomGenerators()
{
return new IRandomGenerator[]
{
new SystemIntegerRandomGenerator(MinValue, MaxValue),
new SystemDoubleRandomGenerator(MinValue, MaxValue),
new UnityRandomRangeGenerator(MinValue, MaxValue),
new UnityRandomValueGenerator(MinValue, MaxValue),
new UnityMathematicsRandomValueGenerator(MinValue, MaxValue)
};
}
...
}
}Ahora formamos el conjunto de datos. En este caso, la generación de datos se combinará con el registro de resultados en un flujo de texto (en formato csv). Se asigna una columna separada para almacenar los valores de cada IRandomGenerator, y la primera fila contiene el nombre del generador.
namespace RandomDistribution
{
public class MainController : MonoBehaviour
{
...
private void GenerateCsvDataSet(TextWriter writer, int dataSetSize, params IRandomGenerator[] generators)
{
const char separator = ',';
int lastIdx = generators.Length - 1;
// escribir encabezado
for (int j = 0; j <= lastIdx; j++)
{
writer.Write(generators[j].Name);
if (j != lastIdx)
writer.Write(separator);
}
writer.WriteLine();
// escribir datos
for (int i = 0; i <= dataSetSize; i++)
{
for (int j = 0; j <= lastIdx; j++)
{
writer.Write(generators[j].Generate());
if (j != lastIdx)
writer.Write(separator);
}
if (i != dataSetSize)
writer.WriteLine();
}
}
...
}
}Solo queda llamar al método GenerateCsvDataSet y guardar el resultado en un archivo, o transferir los datos directamente a través de la red desde el dispositivo de origen al receptor. servidor.
namespace RandomDistribution
{
public class MainController : MonoBehaviour
{
...
public void GenerateCsvDataSet(string path, int dataSetSize, params IRandomGenerator[] generators)
{
using (var writer = File.CreateText(path))
{
GenerateCsvDataSet(writer, dataSetSize, generators);
}
}
public string GenerateCsvDataSet(int dataSetSize, params IRandomGenerator[] generators)
{
using (StringWriter writer = new StringWriter(CultureInfo.InvariantCulture))
{
GenerateCsvDataSet(writer, dataSetSize, generators);
return writer.ToString();
}
}
...
}
}Los fuentes del proyecto están en .
Resultados
No ocurrió ningún milagro. Lo que esperábamos, eso obtuvimos: en todos los casos una distribución uniforme sin indicios de conspiraciones. No veo sentido en adjuntar gráficos separados por plataformas, todos muestran resultados aproximadamente iguales.
La realidad es la siguiente:

Visualización de secuencias en el plano de los cinco métodos de generación:

Y visualización en 3D. Solo dejaré el resultado de System.Random.Next(), para no multiplicar el contenido idéntico.

La historia narrada en la introducción sobre la distribución normal de UnityEngine.Random no se repitió: o fue errónea desde el principio, o algo cambió en el motor desde entonces. Pero ahora estamos seguros.
Fuente: habr.com
