Unity: Cómo hacer una herramienta de "Cubo de pintura"

Czhincksx

Buenas.

Una de las opciones que incorporará la app que estoy haciendo es el poder pintar un dibujo que venga con los contornos definidos. Es una app para niños así que lo más sencillo es que al seleccionar el color y pinchar sobre la textura que se quiera colorear, cambie el color del relleno (como el cubo de pintura del Paint).

Ahora mismo lo tengo resuelto de una forma, que aunque eficiente, lleva demasiado tiempo a la hora de componer la escena.

Básicamente lo que hago es dividir cada sección que se quiera colorear en diferentes sprites y con un collider, al pulsar sobre ellos cambiarle el color al sprite renderer. Como digo, funciona bien, pero me obliga a situar cada sprite por separado dentro de la escena, lo que podría ser un engorro, no sólo para crearlos sino también para encajarlos a la perfección.

Lo que me gustaría hacer es que el dibujo sea un único sprite y de alguna forma cambiar el color sólo de las zonas delimitadas por el contorno del dibujo. No sé ni por dónde empezar.

Clicspam

A mi se me ocurre una función recursiva que empiece en el pixel donde el usuario haga clic y que colorearía los pixeles colindantes de manera recursiva hasta rellenar todo el espacio.

funcionPintarRecursiva(pixelactual, colorseleccionado){
   if(color de pixel actual != negro){
      color del pixel actual = colorseleccionado;
      funcionPintarRecursiva(pixelesColindantes,colorSeleccionado);
   }
}

Es lo primero que se me ocurre, no se si funcionaría, pero seguramente alguien que lo haya hecho alguna vez sepa alguna forma mejor.

2
Czhincksx

Gracias. Es lo que había pensado, pero no sé si eso influiría mucho en el rendimiento. ¿No tendría que hacer una draw call por cada pixel?

Estaba pensando que pintar de negro también sería un problema, pero se me ha ocurrido la solución sobre la marcha XD que el negro no fuera negro (0,0,0) sino casi negro (1,1,1).

2 respuestas
Clicspam

#3 Lo de (1,1,1) es lo que pensé yo también, en cuanto al rendimiento, ahí es donde más dudas tengo. Quizás en areas muy grandes pueda dar problemas, pero la verdad es que no tengo ni idea xD

gonya707

Tengo entendido que en programas profesionales se ahce de esa manera recursiva asi que no tendras menos rendimiento del que podria tener el paint por ejemplo

sergilazaro

#3 Si lo he entendido bien, estarías editando una textura no? Mientras la editas vas usando SetPixel(), y no se hace definitivo (enviarlo a la gráfica) hasta que llamas a Apply(). Así que se va a seguir haciendo sólo un drawcall cuando se vaya a pintar por pantalla.

Sobre la eficiencia, la forma de hacerlo es la más rápida de programar. Si va lento lo mejoras, si no, lo dejas tal cual.

1
Czhincksx

Vale, muchas gracias a todos. Ya casi lo tengo. Sólo me falta averiguar cómo va el tema de la posición del ratón a las coordenadas de la textura, que supongo que será sencillo. Cuando lo tenga postearé la solución.

Edit: no es tan sencillo XD

Czhincksx

Vale me estoy volviendo loco. No sé cómo pintar el pixel exacto de la textura en la que he hecho click...

1 respuesta
The-Force

#8 http://docs.unity3d.com/Documentation/ScriptReference/RaycastHit-textureCoord.html

edit: no quote to the future

1 1 respuesta
Czhincksx

#9 Gracias. Lo estoy probando pero no me tira :(

1 respuesta
The-Force

#10 con tan poco informacion no se te puede ayudar xD Entiendes lo que tienes que hacer ?
Te da error? tienes algo de código que podamos ojear?

Charlie52

A ver si te vale esto... usando este script de la wiki de Unity y la he llamado de esta forma:

void Update () {
	if (Input.GetMouseButtonDown(0)) {
		RaycastHit hit;
		if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit)) {
			Texture2D tex = (Texture2D) renderer.material.mainTexture;
			Vector2 pixelUV = hit.textureCoord;
			pixelUV.x *= tex.width;
			pixelUV.y *= tex.height;
			tex.FloodFillArea((int)pixelUV.x, (int)pixelUV.y, Color.blue);
			tex.Apply();
		}
	}
}

Parece que funciona igual que con la misma herramienta que el Paint, pero no sé si es exactamente lo qué buscas: http://i.imgur.com/bfRFist.png

Un saludo!

1 3 respuestas
Czhincksx

El código que tengo ahora es éste:

void OnMouseDown() {
		Vector3 positionMouse = Camera.main.ScreenToWorldPoint(Input.mousePosition);
		SpriteRenderer renderer = this.GetComponent<SpriteRenderer>();
		Texture2D tex = renderer.sprite.texture;
		newTex = (Texture2D)GameObject.Instantiate(tex);

	Vector3 TexturePosition = renderer.transform.position;
	Vector3 position2 = Camera.main.WorldToScreenPoint(TexturePosition);


	Debug.Log(Camera.main.pixelHeight);
	Debug.Log(Camera.main.pixelWidth);


	float mult = Camera.main.pixelWidth / Camera.main.pixelHeight;
	Debug.Log (Camera.main.pixelWidth);
	float mult2 = 650/Camera.main.pixelWidth;

	int x = (int)((Input.mousePosition.x - position2.x)*3/mult*mult2 + tex.width/2);
	int y = (int)((Input.mousePosition.y - position2.y)*3/mult*mult2 + tex.height/2);

	Color color = Camera.main.GetComponent<PaintGameScript>().paintColor;
	fillTexture(x, y, color);
	newTex.Apply();

	renderer.sprite = Sprite.Create(newTex, renderer.sprite.rect, new Vector2(0.5f, 0.5f), 5f);

}

Que me sirve como aproximación para salir del paso, aunque intentaré que me funcione con lo que me decís. En mi programa tira porque la textura a colorear ocupa toda la pantalla y eso, pero preferiría arreglarlo en plan pixel perfect XD

Ahora la función de rellenar me está dando stackoverflow :/


void fillTexture(int x, int y, Color color){
	newTex.SetPixel(x, y, color);

	if(x > 0)
		if(newTex.GetPixel(x-1,y) != Color.black && 
		   newTex.GetPixel(x-1,y) != color)
		fillTexture(x-1,y,color);

	if(x < newTex.width - 1)
		if(newTex.GetPixel(x+1,y) != Color.black && 
		   newTex.GetPixel(x+1,y) != color)
		fillTexture(x+1,y,color);

	if(y > 0)
		if(newTex.GetPixel(x,y-1) != Color.black && 
		   newTex.GetPixel(x,y-1) != color)
			fillTexture(x,y-1,color);

	if(y < newTex.height - 1)
		if(newTex.GetPixel(x,y+1) != Color.black && 
		   newTex.GetPixel(x,y+1) != color)
			fillTexture(x,y+1,color);
}

Lo curioso es lo siguiente:

Si dejo sólo los ifs del eje X y comento los de la Y, funciona bien (coloreando una línea horizontal).

Si dejo uno de la X y los dos de la Y también funciona.

Si dejo uno de la Y y los dos de la X no funciona, y por lo tanto si dejo los cuatro tampoco.

1 respuesta
The-Force

#13 He estado probando el siguiente codigo modificado de #12 y tu funcion de llenado sin cambiarle nada y me funciona bien. Eso si la textura hay que ponerla en formato RGBA32.

     private Texture2D newTex;
    public Camera cam; //por algun motivo Camera.main no me iba asi que la referencio manualmente.

void OnMouseDown() { 
    RaycastHit hit;
    if (Physics.Raycast(cam.ScreenPointToRay(Input.mousePosition); out hit)) {
        newTex = (Texture2D)renderer.material.mainTexture;
        Vector2 pixelUV = hit.textureCoord;
        pixelUV.x *= newTex.width;
        pixelUV.y *= newTex.height;
        newTex = renderer.material.GetTexture(0) as Texture2D;
        fillTexture((int)pixelUV.x, (int)pixelUV.y, Color.blue);
        newTex.Apply();
    }   
}

Cuando modificas muchos pixeles de golpe es mejor extraer el array de pixeles con GetPixels, modificar este array y luego subirlo entero con SetPixels, es mucho mas eficiente.

El algoritmo de llenado que encontro #12 en la wiki hace esto, ademas de no usar un metodo recursivo sino iterativo.

1 1 respuesta
Czhincksx

#12, #14 Muchas gracias. Ahora me pondré a ello otra vez, que con toda esa información tiene que salir :)

Czhincksx

Nada, no me va :(

El problema creo que está en esta línea:

Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit)

No hay hit. ¿Qué tipo de textura usáis vosotros? Yo estoy usando Sprites en modo advanced. Además el collider es un Box Collider 2D. ¿Debería cambiar la textura y el collider?

1 respuesta
r2d2rigo

#16 y por que no divides cada parte "cerrada" del dibujo en un sprite y le aplicas el color de tintado? Dependiendo de la complejidad de los dibujos lo mismo hasta sales ganando.

1 respuesta
Clicspam

#17 Eso es como lo tiene ahora según entiendo de #1

Czhincksx

Exacto, lo que tengo ahora es eso.

Bueno el problema era que Physics.RayCast no funciona para Sprites. Para Sprites hay que usar Physics2D.Raycast. Lo hice y al terminar de escribir el código me encuentro que RayCastHit2D no tiene el atributo TextureCoord.

La otra solución que daban era crear un Game Object hijo y darle un collider 3D. Lo he hecho pero ahora resulta que el valor de hit.TextureCoord es siempre 0.0, 0.0.

if(Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit)){
				SpriteRenderer renderer = this.GetComponent<SpriteRenderer>();
				Texture2D tex = renderer.sprite.texture;
				newTex = (Texture2D)GameObject.Instantiate(tex);
				Vector2 pixelUV = hit.textureCoord;
				Debug.Log(hit.textureCoord);
//				pixelUV.x *= newTex.width;
//				pixelUV.y *= newTex.height;
//				newTex = renderer.material.GetTexture(0) as Texture2D;
//				fillTexture((int)pixelUV.x, (int)pixelUV.y, Color.blue);
//				newTex.Apply();
				renderer.sprite = Sprite.Create(newTex, renderer.sprite.rect, new Vector2(0.5f, 0.5f), 5f);
			}  

Edit: probando con el código que tenía antes que sacaba el punto aproximado la función de llenado de #12 va de maravilla.

Czhincksx

No sé cómo configurar el MeshCollider. Si al Game Object de la imagen le pongo un BoxCollider detecta el hit, pero como digo lo hace siempre para TextureCoord 0,0.

Para probarlo uso este código:


void Update() {
	if (!Input.GetMouseButton(0))
		return;
		
RaycastHit hit;
if (!Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit))
	return;

	
Debug.Log (hit.textureCoord);
}

1 respuesta
The-Force

#20 Para mesh collider necesitas una mesh y no tienes ninguna xD, usa Box o Plane, y asignale un material con su textura que igual es por no tenerle ninguno asignado.

Czhincksx

La lista de materiales me sale vacía. ¿De qué tipo debe ser para poder asignarlo?

Edit: Vale, physics material que tengo que crear.

A parte de eso, me aparece un cubo pero es muy, muy pequeño. No sé cómo ajustarlo al tamaño del Sprite :/

1 respuesta
The-Force

#22 A ver que te estas liando.

Si quieres hit con el raycast usa un box collider o un plane collider (Yo uso plane). Mesh collider requiere un mesh, que viene a ser un modelo 3D importado y no te hace falta para nada ahora mismo.

Luego al box o plane le añades un material nuevo, material de render, no fisico que tampoco te hace falta para nada, y a ese material le añades una textura cualquiera. Todo esto para probar si asignandole un material ya te da el hit.textureCoord. Igual no hace ni falta añadirle la textura al material, pero tu prueba de ambas maneras a ver.

Para ajustar el tamaño del box o plane simplemente lo escalas al tamaño del sprite o lo que uses como lienzo para dibujar.

Todo esto desde el editor, no desde script.

1 1 respuesta
Czhincksx

Bueno, muchísimas gracias a todos. Ya me funciona.

#23 He usado Cube en vez de Plane porque así no tenía que andar rotándolo.

Al final ha funcionado haciendo algo que antes no me dejaba hacer. Cambiando el size del Game Object hijo.

Además, por algún motivo la coordenadas que obtenía estaban invertidas. Al final el código queda así:


void Update() {
		if (!Input.GetMouseButton(0))
			return;

	RaycastHit hit;
	if (!Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit))
		return;

	SpriteRenderer renderer = this.GetComponent<SpriteRenderer>();
	Texture2D tex = renderer.sprite.texture;
	newTex = (Texture2D)GameObject.Instantiate(tex);
	Vector2 pixelUV = hit.textureCoord;
	pixelUV.x *= newTex.width;
	pixelUV.y *= newTex.height;
	pixelUV.x = newTex.width - pixelUV.x;
	pixelUV.y = newTex.height - pixelUV.y;

	Debug.Log(pixelUV.x+" "+pixelUV.y);

	Color color = Camera.main.GetComponent<PaintGameScript>().paintColor;

	newTex.FloodFillBorder((int)pixelUV.x, (int)pixelUV.y, color, Color.black);

//		for(int i = (int)pixelUV.x-10; i<(int)pixelUV.x+10; i++)
//			for(int j = (int)pixelUV.y-10; j<(int)pixelUV.y+10; j++)
//				newTex.SetPixel(i, j, Color.cyan);

	newTex.Apply();
	
	renderer.sprite = Sprite.Create(newTex, renderer.sprite.rect, new Vector2(0.5f, 0.5f), 5f);

}

El trozo comentado me ayudó a comprobar lo de las coordenadas invertidas.

En la jerarquía está el Dibujo con el Sprite renderer y el script, y de él cuelga un hijo game object vacío al que le he metido un Mesh Collider con el Mesh Cube y el resto vacío y con la transformada escalada para encajar con el sprite del padre.

Además, ocurre algo negativo y es que si encima de ese Mesh Collider tengo otros, como es el caso de la barra de colores, detecta el click sobre el Mesh y no sobre el box2d de la paleta. En principio no me afecta porque no tienen por qué superponerse pero en el futuro nunca se sabe.

Y bueno, si tengo que retocar alguna cosa en el futuro espero controlar algo más y no tardar un día para esta chorrada XD

Saludos!! :)

Usuarios habituales

  • Czhincksx
  • The-Force
  • Clicspam
  • r2d2rigo
  • Charlie52
  • sergilazaro
  • gonya707