Unity实现陶艺之路

陶艺制作,如下图:

unity 地面_Mesh

最近接到了某历史博物馆的一个小项目,其中之一是允许游客利用触控屏模拟陶艺的制作。
经过两天的研究,将制作工程记录和总结如下:

程序总体流程:

  • 根据精细程度等需要动态生成模型
  • 根据用户操作动态调整模型顶点
  • 平滑接缝处的法线

一、动态生成

动态生成有很多种方式,为了性能考虑,除了必要的接缝(UV展开),这里尽量使用了共享顶点的方式,原因是:第一,可大幅度降低顶点数量,后期需要遍历顶点动态调整顶点位置时可提高效率,第二,共享顶点可使用unity自带的Mesh.RecalculateNormals方法高效平滑法线。
模型总体上分为“外底”、“外柱面”,“顶部”,“内柱面”、“内底”等几个部分。其中生成顶点时,还用到了几个小技巧:
1、外底的中心作为第一个顶点,内底的中心作为最后一个顶点,这样遍历所有顶点时,可以很容易排除这两个顶点,因为中心点是不需要进行动态调整的。
2、生成顶点时顺便生成三角形以及计算好UV,整个创建过程只需要一次遍历。
生成过程如下:

// 从生成外底开始
private void CreateBottom(float deltaAngle, List<Vector3> vertices, List<int> triangles, List<Vector2> uvs )
{
    // 外底中心点作为整个模型的第一个顶点
	vertices.Add(Vector3.zero);
	uvs.Add(new Vector2(0.25f, 0.25f));

	int index = 1;

	for (int i = 0; i < Details; ++i)
	{
		float angle = (float) i * deltaAngle;
		float cosAngle = Mathf.Cos(angle);
		float sinAngle = Mathf.Sin(angle);

		Vector3 v = new Vector3(Radius * cosAngle, 0, Radius * sinAngle);
		vertices.Add(v);

        //添加三角形
		triangles.Add( index );
		triangles.Add(( index >= Details ) ? 1 : index + 1 );
		triangles.Add(0);

        //计算UV
		Vector2 u = new Vector2(0.25f + 0.25f * cosAngle, 0.25f + 0.25f * sinAngle );
		uvs.Add(u);

		++index;
	}
}
// 创建外柱体
private void CreateOuter(float deltaAngle, List<Vector3> vertices, List<int> triangles, List<Vector2> uvs)
{
	for( int layer = 0; layer <= LayerCount; ++ layer )
	{
		float height = layer * LayerHeight;
		int vIndex = vertices.Count;
		int lastIndex = vIndex - Details - 1;
		int vIndexAddOne = vIndex + 1;
		int lastIndexAddOne = lastIndex + 1;

		float v = (((float)layer) / ((float)LayerCount)) * 0.4f + 0.5f;

		for ( int i = 0; i <= Details; ++ i )
		{
			float angle = ( i == Details ) ? 0 : i * deltaAngle;
			float cosAngle = Mathf.Cos(angle);
			float sinAngle = Mathf.Sin(angle);

			Vector3 vo = new Vector3(Radius * cosAngle, height, Radius * sinAngle);
			vertices.Add(vo);

			if (layer > 0 && i < Details )
			{
				triangles.Add( vIndex + i );
				triangles.Add( vIndexAddOne + i );
				triangles.Add( lastIndex + i );

				triangles.Add(lastIndex + i);
				triangles.Add( vIndexAddOne + i );
				triangles.Add(lastIndexAddOne + i );
			}

			float u = ((float) i ) / ((float)Details);
			Vector2 uv = new Vector2(u, v);
			uvs.Add( uv );
		}
	}
}
// 创建顶部
private void CreateTop(float deltaAngle, List<Vector3> vertices, List<int> triangles, List<Vector2> uvs)
{
	float inner = Radius - Thickness;

	for (int h = 0; h < 2; ++h)
	{
		float height = (LayerCount - h) * LayerHeight;

		int vIndex = vertices.Count;
		int lastIndex = vIndex - Details - 1;
		int vIndexAddOne = vIndex + 1;
		int lastIndexAddOne = lastIndex + 1;

		float v = 0.95f + h * 0.05f;

		for (int i = 0; i <= Details; ++i)
		{
			float angle = (i == Details) ? 0 : i * deltaAngle;
			float cosAngle = Mathf.Cos(angle);
			float sinAngle = Mathf.Sin(angle);

			Vector3 vo = new Vector3(inner * cosAngle, height, inner * sinAngle);
			vertices.Add(vo);

			if (i < Details)
			{
				triangles.Add(vIndex + i);
				triangles.Add(vIndexAddOne + i);
				triangles.Add(lastIndex + i);

				triangles.Add(lastIndex + i);
				triangles.Add(vIndexAddOne + i);
				triangles.Add(lastIndexAddOne + i);
			}

			float u = ((float)i) / ((float)Details);
			Vector2 uv = new Vector2(u, v);
			uvs.Add(uv);
		}
	}
}
// 创建内部柱面
private void CreateInner(float deltaAngle, List<Vector3> vertices, List<int> triangles, List<Vector2> uvs)
{
	float inner = Radius - Thickness;
	int count = LayerCount - 1;
	for (int layer = 0; layer < count; ++layer)
	{
		float height = ( LayerCount - layer - 1 ) * LayerHeight;
		int vIndex = vertices.Count;
		int lastIndex = vIndex - Details - 1;
		int vIndexAddOne = vIndex + 1;
		int lastIndexAddOne = lastIndex + 1;

		float v = 0.5f - ((((float)layer) / ((float)count )) * 0.5f );

		for (int i = 0; i <= Details; ++i)
		{
			float angle = (i == Details) ? 0 : i * deltaAngle;
			float cosAngle = Mathf.Cos(angle);
			float sinAngle = Mathf.Sin(angle);

			Vector3 vo = new Vector3(inner * cosAngle, height, inner * sinAngle);
			vertices.Add(vo);

			if (layer > 0 && i < Details)
			{
				triangles.Add(vIndex + i);
				triangles.Add(vIndexAddOne + i);
				triangles.Add(lastIndex + i);

				triangles.Add(lastIndex + i);
				triangles.Add(vIndexAddOne + i);
				triangles.Add(lastIndexAddOne + i);
			}

			float u = ((float)i) / ((float)Details) * 0.5f + 0.5f;
			Vector2 uv = new Vector2(u, v);
			uvs.Add(uv);
		}
	}
}
//创建内底
private void CreateInnerBottom(float deltaAngle, List<Vector3> vertices, List<int> triangles, List<Vector2> uvs)
{
	int index = vertices.Count;
	float inner = Radius - Thickness;

	for (int i = 0; i < Details; ++i)
	{
		float angle = (float)i * deltaAngle;
		float cosAngle = Mathf.Cos(angle);
		float sinAngle = Mathf.Sin(angle);

		Vector3 v = new Vector3(inner * cosAngle, LayerHeight, inner * sinAngle);
		vertices.Add(v);

		triangles.Add(index + Details);
		triangles.Add((i >= Details - 1) ? index : index + i + 1 );
		triangles.Add(index + i);

		Vector2 u = new Vector2(0.75f + 0.25f * cosAngle, 0.25f + 0.25f * sinAngle);
		uvs.Add(u);
	}

	vertices.Add(new Vector3(0, LayerHeight, 0));
	uvs.Add(new Vector2(0.75f, 0.25f));
}

创建好之后的效果图:

整体效果:

unity 地面_Mesh_02


底部效果:

unity 地面_unity 地面_03


顶部效果:

unity 地面_Unity_04

二、动态调整顶点

模型生成之后,接下来就是动态调整顶点位置了,流程是这样的:
1、使用射线检测来判断目标的位置。
2、判断方向。由于触控到模型的左边和触控到模型的右边,对模型顶点的方向调整是相反的,比如,点模型右边并且往右边拖动,就是要加粗目标,同样,点模型左边并且往左边拖动,也是要增粗,所以左边和右边正好相反。因此需要一个可靠的方法去判断目标点是“左边”还是“右边”。
3、判断目标点和顶点的距离,并且根据“影响的力度”对附近的顶点进行位移。
4、重新平滑一下法线。

具体代码如下:
因为我工作的电脑没有触摸屏,所以写了一个鼠标调整和触控调整兼容的方案:

private void Update()
{
    // 如果检测到触控用触控,否则用鼠标
	if( Input.touchCount == 1 )
	{
		Touch touch = Input.GetTouch(0);
		if (touch.phase == TouchPhase.Began)
		{
			if (Physics.Raycast(Camera.main.ScreenPointToRay(touch.position), out RaycastHit hit, 1000f, PotteryLayerMask))
			{
				targetWorldPos = hit.point;
				isShaping = true;
			}
			else
				isShaping = false;
		}
		else if ( isShaping && ( touch.phase == TouchPhase.Moved))
		{
			ShapeIt(touch.deltaPosition);
		}
		else if (touch.phase == TouchPhase.Ended)
			isShaping = false;
	}
	else
	{
		if (Input.GetMouseButtonDown(0))
		{
			lastScreenPos = Input.mousePosition;
			if (Physics.Raycast(Camera.main.ScreenPointToRay(lastScreenPos), out RaycastHit hit, 1000f, PotteryLayerMask))
			{
				targetWorldPos = hit.point;
				isShaping = true;
			}
			else
				isShaping = false;
		}
		else if (isShaping && Input.GetMouseButton(0))
		{
			Vector3 currPos = Input.mousePosition;
			ShapeIt(currPos - lastScreenPos);
			lastScreenPos = currPos;
		}
		else if (Input.GetMouseButtonUp(0))
			isShaping = false;
	}
    //调试时使用
	//ShowNormals();
}
// 调整位置
private void ShapeIt( Vector3 deltaPos )
{
	bool bHorizontal = false;
	bool bVertical = false;

	float dirRate = 0, scale = 0;
	if (deltaPos.x > 0.01f)
	{
		dirRate = IsInRight() ? 1f : -1f;
		bHorizontal = true;
	}
	else if (deltaPos.x < -0.01f)
	{
		dirRate = IsInRight() ? -1f : 1f;
		bHorizontal = true;
	}

	if (deltaPos.y > 0.02f)
	{
		scale = 0.001f;
		bVertical = true;
	}
	else if( deltaPos.y < -0.02f)
	{
		scale = -0.001f;
		bVertical = true;
	}

	if (bHorizontal)
	{
		Vector3 targetPos = transform.InverseTransformPoint(targetWorldPos);
		Vector3[] vertices = theMesh.vertices;
		int maxVerticesIndex = vertices.Length - 1;
		float Range = InfluenceLayer * LayerHeight;

		for (int i = 1; i < maxVerticesIndex; ++i)
		{
			float max, min;
			if (i < SplitIndex)
			{
				max = SqrMaxOuterRadius;
				min = SqrMinOuterRadius;
			}
			else
			{
				max = SqrMaxInnerRadius;
				min = SqrMinInnerRadius;
			}

			float dis = Mathf.Abs(targetPos.y - vertices[i].y);
			if (dis < Range)
			{
				Vector3 dir = vertices[i];
				dir.y = 0;
				if ((dirRate > 0 && dir.sqrMagnitude < max) || (dirRate < 0 && dir.sqrMagnitude > min))
					vertices[i] += dir.normalized * dirRate * TouchPower * (1f - dis / Range);
			}
		}

		theMesh.vertices = vertices;
		theMesh.RecalculateBounds();
		theMesh.RecalculateNormals();
		SmoothNormals();
	}

	if( bVertical)
	{
		Vector3 sc = transform.localScale;
		sc.y = Mathf.Clamp(sc.y + scale, 0.5f, 1.5f);
		transform.localScale = sc;
	}
}

上面提到如何判断点击点在模型的左边还是右边,这里用到了一个小技巧,方法是,将世界坐标下的目标点和模型位置同时变换到摄像机局部坐标下,然后就可以进行简单的判断它们x轴的大小了,这个方法很巧妙吧!!苦思冥想想出来的。。

private bool IsInRight()
{
	Transform camera = Camera.main.transform;
	Vector3 target = camera.InverseTransformPoint(targetWorldPos);
	Vector3 pottery = camera.InverseTransformPoint(transform.position);

	return target.x > pottery.x;
}

调整完顶点的位置,还没有结束,由于模型是有“接缝”的(并非所有的顶点都是共享,这是因为如果一个环形的结构顶点全部共享的话,就没有办法进行UV展开,所以要切断“环”,因此存在接缝)。
由于除了接缝之外的顶点全部是共享顶点,unity可以帮我们实现法线平滑,所以,我们只需要对接缝处的顶点进行法线平均即可。非常简单高效。

private void SmoothNormals()
{
	Vector3[] normals = theMesh.normals;

	int step = Details + 1;
	int start = step;
	int end = start + (LayerCount + 3) * step;

	for (int i = start; i < end; i += step)
	{
		int index1 = i;
		int index2 = i + Details;

		Vector3 normal = ((normals[index1] + normals[index2]) / 2f).normalized;
		normals[index1] = normal;
		normals[index2] = normal;
	}

	theMesh.normals = normals;
}

至此,整个流程就完成了。

三、封装和使用

unity 地面_陶瓷_05


详解如下:

  • Details:细节数量,指柱面水平的细节数量。
  • LayerCount:层数,至柱面纵向的细节数量。
  • LayerHeight:层高,每一层的尺寸
  • Radius:外径尺寸
  • Thickness:壁厚度
  • MinRadius:进行制作时可缩小到的最小半径
  • MaxRadius:进行制作时可缩小到的最大半径
  • InfluenceLayer:每次操作最大影响的层数
  • TouchPower:每次操作的“力度”,值越大,操作越灵敏。
  • Potteing:制作时使用的材质。
  • PotteryLayerMask:物体层掩码(射线检测用)

四、UV相关

程序生成模型时,对UV进行了展开,展开后的UV如下:

unity 地面_unity 地面_06


unity 地面_Mesh_07


由于内壁和内底应该都为白色,所以进行了UV重合。

制作完的效果图

整体效果:

unity 地面_unity 地面_08


底部效果

unity 地面_陶艺_09

升级版源码

在上述功能的基础上,优化了触控算法和动态调整算法,增加了持久化功能,支持将陶艺数据保存为json文件,或从json文件中加载。以方便从服务器上传或下载。