En mi vida profesional utilizo Unity y hoy quiero hablar de esta propiedad llamada píxeles por unidad (Pixels per Unit) que encontramos cuando importamos una textura.

Para ello, primero hemos de conocer algunos conceptos.
El sistema de coordenadas para las unidades de mundo (world units) en Unity tiene el origen en el centro de la escena. La unidad de mundo, también llamada simplemente unidad, es una métrica de referencia que utiliza Unity para posicionar los objetos en la escena. Cuando modificas la posición local (transform.localPosition) o global (transform.position) de un objeto estás modificando la posición respecto a dicha unidad. Es decir, si sitúas un objeto en la posición (3, 0, 0) significa que lo estás posicionando a una distancia del origen en el eje X de 3 veces el tamaño de la unidad, y no lo estás moviendo en el eje Y ni en el eje Z. Pero ahora la pregunta es: ¿qué determina ese tamaño de la unidad? La respuesta es que depende de la cámara de la escena.
Unidades para cámara con proyección ortográfica
En la cámara ortográfica, el tamaño de la unidad viene dado por el atributo Size (llamado orthographicSize desde código).

Este atributo determina cuántas unidades existen entre el centro de la escena y la parte de arriba (o la parte de abajo) de la misma. De este modo, si tenemos un orthographicSize de 5, significa que nuestra escena tiene una altura total de 10 unidades (5 unidades hacia arriba desde el centro, y 5 unidades hacia abajo desde el centro). Por tanto, situar un objeto en la posición Y = 5 equivaldría a poner su pivote (normalmente, el centro del objeto) justo en el borde superior de la escena, y fijar la posición en Y = -5 lo situaría en el borde inferior de la escena.
A partir de este conocimiento y sabiendo también cuál es el aspect ratio (relación de aspecto, es decir, ratio entre anchura y altura), podemos deducir el número de unidades que hay en el eje X:
var unitsInY = Camera.main.orthographicSize * 2f;
var unitsInX = unitsInY * ((float)Screen.width / (float)Screen.height);
Unidades para cámara con proyección de perspectiva
Cuando tenemos una cámara con perspectiva lo que ve la cámara viene definido por una pirámide truncada (frustrum), por lo que a medida que nos alejamos de la posición de la cámara, ésta ve más contenido de la escena (de la misma manera que pasa con tus ojos). Por lo tanto, las unidades en altura ahora dependen de la distancia desde la cámara al objeto que te interesa, de acuerdo a la siguiente fórmula:
var unitsInY = 2.0f * distance * Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);
, donde fieldOfView es el ángulo de visión que especificamos para la cámara.
De nuevo, a dicha distancia, la cantidad de unidades en X dependerá de la relación de aspecto:
var unitsInX = unitsInY * ((float)Screen.width / (float)Screen.height);
Por tanto y volviendo al objetivo de esta entrada, los píxeles por unidad determinan una relación entre la resolución de una textura y las unidades de Unity. Por defecto, este valor está fijado a 100, por lo que si tenemos una textura de 100 píxeles de altura, esta imagen va a ocupar 1 unidad en el eje Y. Otro ejemplo: si nuestra cámara ortográfica tiene 10 unidades de altura en total e importamos una textura con 100 píxeles por unidad, ¿qué resolución en altura tiene que tener la imagen para que ocupe la escena completa? La respuesta es 1000 píxeles, ya que cada 100 píxeles corresponde a una unidad, y hay 10 veces 100 píxeles (1 unidad) en una imagen de 1000 píxeles). O expuesto con factores de conversión:
\(1000 pix\frac{1 u}{100 pix} = 10 u\)
Y ahora la siguiente pregunta es: ¿para qué puede servirnos saber todo esto?
Proyecto: seleccionar subtextura de una textura
Os voy a enseñar un ejemplo que recientemente hice para un juego de una jam. El problema es el siguiente: quiero mostrar una imagen al jugador y que el jugador pueda seleccionar un recuadro de esa imagen. Una vez el jugador ha seleccionado dicho recuadro, se muestra la subimagen seleccionada.

Hay dos formas de hacerlo: una forma fácil y directa, y otra más complicada que requiere una conversión intermedia. Explico también la segunda porque creo que ayuda a comprender mejor los factores de conversión y la relación entre píxeles en espacio de pantalla y píxeles en espacio de imagen.
Forma 1: Directa desde unidades del mundo a píxeles en imagen
Esta es la función a la que llamo cuando el usuario hace clic:
private Sprite GetSpriteFromTexture()
{
topLeftSelectionBox.Set(selectionBox.transform.localPosition.x - 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y + 0.5f * selectionBoxSizeY);
topRightSelectionBox.Set(selectionBox.transform.localPosition.x + 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y + 0.5f * selectionBoxSizeY);
bottomLeftSelectionBox.Set(selectionBox.transform.localPosition.x - 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y - 0.5f * selectionBoxSizeY);
bottomRightSelectionBox.Set(selectionBox.transform.localPosition.x + 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y - 0.5f * selectionBoxSizeY);
//Calculate vector in world units from the bottom left of the selection box to the bottom left of the picture
var initialPosInPicture = (bottomLeftSelectionBox - pictureBottomLeft);
var rectWidth = (topRightSelectionBox.x - topLeftSelectionBox.x);
var rectHeight = (topLeftSelectionBox.y - bottomLeftSelectionBox.y);
finalRect.Set(initialPosInPicture.x * picture.sprite.pixelsPerUnit / picture.transform.localScale.x,
initialPosInPicture.y * picture.sprite.pixelsPerUnit / picture.transform.localScale.y,
rectWidth * picture.sprite.pixelsPerUnit / picture.transform.localScale.x,
rectHeight * picture.sprite.pixelsPerUnit / picture.transform.localScale.y);
return Sprite.Create(picture.sprite.texture, finalRect, Vector2.zero);
}
Calculamos un vector que va desde el borde inferior izquierdo del recuadro de selección al borde inferior izquierdo de la imagen. Esto nos va a servir para saber cuál es el punto inicial de la imagen que queremos coger. A continuación, calculamos la anchura y altura del recuadro de selección. Todo esto está calculado en unidades de mundo. A la hora de crear el Rect que vamos a usar para seleccionar los píxeles concreto de la imagen en Sprite.Create, hacemos una conversión desde unidades de mundo a píxeles de imagen utilizando la variable pixelsPerUnit. Aquí tenemos que tener en cuenta la escala de la imagen: si normalmente una unidad representa 100 píxeles de una imagen, esa misma unidad representará 50 píxeles para una imagen que sea el doble de grande (habrá que recorrer el doble de unidades para recorrer los mismos píxeles). Y al contrario si la imagen es más pequeña. Por ello, dividimos por la escala de la imagen.
Forma 2: Indirecta desde unidades del mundo a píxeles en pantalla y desde píxeles en pantalla a píxeles en imagen
En este punto conviene aclarar la nomenclatura que voy a utilizar:
- Cuando hablo de píxeles en pantalla (o píxeles en el espacio de la pantalla), me refiero al número de píxeles que hay en la pantalla y que depende de la resolución de la misma. Si la resolución fuera 1920×1080, los píxeles por pantalla en anchura serían 1920 y los píxeles por pantalla en altura 1080. Esto se puede obtener desde código a través de Screen.width y Screen.height respectivamente.
- Por otro lado tenemos los píxeles de imagen (o píxeles en el espacio de la imagen). Con esto me refiero al número de píxeles de los que consta la imagen y dependen de la resolución de la misma. Una imagen de 500×200 píxeles tendría una anchura de 500 píxeles de ancho y 200 píxeles de alto.
Cuando estamos en la escena sólo trabajamos en unidades de mundo y en píxeles en espacio de pantalla. Sin embargo cuando queremos coger una subimagen a partir de una imagen, estaremos en espacio de imagen.
En esta forma de hacerlo, necesitamos factores de conversión para movernos entre distintos sistemas de referencia y éstos los calculamos en Start() del script que gestiona esta lógica:
void Start()
{
screenHeightInUnits = myCamera.orthographicSize * 2f;
screenWidthInUnits = screenHeightInUnits * Screen.width / Screen.height;
worldToPixelsUnitConversionFactor = (float)Screen.width / screenWidthInUnits;
pixelsInScreenToPixelsInTexture = new Vector2(picture.sprite.pixelsPerUnit / worldToPixelsUnitConversionFactor,
picture.sprite.pixelsPerUnit / worldToPixelsUnitConversionFactor);
pixelsInScreenToPixelsInTexture.x /= picture.transform.localScale.x;
pixelsInScreenToPixelsInTexture.y /= picture.transform.localScale.y;
}
Primero calculo las unidades en anchura y altura de la escena, y después calculo un factor de conversión que pueda usar más adelante para pasar de unidades a píxeles de pantalla. Fíjate que worldToPixelsUnitConversionFactor se podría haber calculado igualmente usando la altura en lugar de la anchura.
Pero hay que tener en cuenta que también existe una relación entre píxeles por unidad en pantalla y píxeles por unidad en la imagen. Este factor de conversión es el que calculo en pixelsInScreenToPixelsInTexture y aquí es donde entran en juego los píxeles por unidad de los que estamos hablando en esta entrada.
Para que lo entendamos mejor: worldToPixelsUnitConversionFactor representa cuántos píxeles de pantalla hay por unidad. Si por ejemplo este valor fuera 200, significaría que una unidad del mundo representaría 200 píxeles de pantalla. Supongamos ahora que el valor de píxeles por unidad de la imagen es 100. Con esto lo que estoy diciendo es que si recorro una unidad en el mundo, estaría recorriendo 200 píxeles en la pantalla y 100 píxeles en la imagen. Por tanto, el factor de conversión para pasar de píxeles en pantalla a píxeles de imagen es:
\(1 pp \frac{100 pi}{200 pp} = 0.5 pi\)
Aunque me adelante un poco, quiero aclarar ya por qué necesito esto. Nuestro recuadro de selección va a tener un tamaño en píxeles en pantalla. Pongamos que nuestro recuadro mide 200×200 píxeles en la pantalla, por lo que cogeremos 200×200 píxeles de la imagen. Sin embargo, cuando queremos coger una subimagen a partir de una imagen, lo tenemos que hacer en el espacio de la imagen. ¿A cuánto equivalen esos 200×200 píxeles en espacio de imagen? Para saberlo aplicamos el factor de conversión, y en este caso tendremos que coger 100×100 píxeles.
Lo único que no estamos teniendo en cuenta hasta ahora es que la imagen puede estar escalada. Si la imagen está escalada hacia arriba, es decir, la imagen es más grande, significa que cada unidad del mundo representa menos píxeles de la imagen. ¿Por qué? Siguiendo con los números anteriores, si la imagen normalmente ocupa 200 píxeles en la pantalla, moverme una unidad recorrerá la imagen entera; si escalamos la imagen al doble, ahora la imagen ocupa 400 píxeles en la pantalla, por lo que movernos ahora una unidad recorre sólo la mitad de la imagen en la pantalla. Y lo contrario ocurre si hacemos la imagen más pequeña. Es por eso que lo último que hacemos es dividir el factor de conversión entre la escala.
Pasamos ahora al método donde calculo qué porción de la imagen tengo que seleccionar y que se llamará cuando el jugador haga clic con el ratón:
Sprite GetSpriteFromTexture()
{
topLeftSelectionBox.Set(selectionBox.transform.localPosition.x - 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y + 0.5f * selectionBoxSizeY);
topRightSelectionBox.Set(selectionBox.transform.localPosition.x + 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y + 0.5f * selectionBoxSizeY);
bottomLeftSelectionBox.Set(selectionBox.transform.localPosition.x - 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y - 0.5f * selectionBoxSizeY);
bottomRightSelectionBox.Set(selectionBox.transform.localPosition.x + 0.5f * selectionBoxSizeX,
selectionBox.transform.localPosition.y - 0.5f * selectionBoxSizeY);
var initialPosInPicture = (bottomLeftSelectionBox - pictureBottomLeft) * worldToPixelsUnitConversionFactor;
var rectWidth = (topRightSelectionBox.x - topLeftSelectionBox.x) * worldToPixelsUnitConversionFactor;
var rectHeight = (topLeftSelectionBox.y - bottomLeftSelectionBox.y) * worldToPixelsUnitConversionFactor;
finalRect.Set(initialPosInPicture.x * pixelsInScreenToPixelsInTexture.x,
initialPosInPicture.y * pixelsInScreenToPixelsInTexture.y,
rectWidth * pixelsInScreenToPixelsInTexture.x,
rectHeight * pixelsInScreenToPixelsInTexture.y);
return Sprite.Create(picture.sprite.texture, finalRect, Vector2.zero);
}
Lo primero es calcular las posiciones (en unidades del mundo) de las cuatro esquinas del cuadro de selección. A continuación, calculamos cuántos píxeles de pantalla hay desde la esquina inferior izquierda del cuadro de selección a la esquina inferior izquierda de la imagen, para saber cuál es el punto de selección inicial. Después calculamos la altura y anchura del cuadro de selección en píxeles de pantalla.
La variable finalRect almacena el Rect que usaremos sobre la textura para seleccionar una porción de ésta en la función Sprite.Create(). Hay que tener en cuenta que este Rect tiene que estar definido en el espacio de la imagen y por tanto aquí es donde aplicamos el factor de conversión para pasar de píxeles de pantalla (lo que tenemos hasta ahora) a píxeles de la imagen.
Aquí puedes descargarte el código que gestiona esta lógica. Asimismo, aquí tienes una imagen de la escena para que puedas reproducirla fácilmente:

Comentarios