Unity实现陶艺之路
陶艺制作,如下图:
最近接到了某历史博物馆的一个小项目,其中之一是允许游客利用触控屏模拟陶艺的制作。
经过两天的研究,将制作工程记录和总结如下:
程序总体流程:
- 根据精细程度等需要动态生成模型
- 根据用户操作动态调整模型顶点
- 平滑接缝处的法线
一、动态生成
动态生成有很多种方式,为了性能考虑,除了必要的接缝(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));
}
创建好之后的效果图:
整体效果:
底部效果:
顶部效果:
二、动态调整顶点
模型生成之后,接下来就是动态调整顶点位置了,流程是这样的:
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;
}
至此,整个流程就完成了。
三、封装和使用
详解如下:
- Details:细节数量,指柱面水平的细节数量。
- LayerCount:层数,至柱面纵向的细节数量。
- LayerHeight:层高,每一层的尺寸
- Radius:外径尺寸
- Thickness:壁厚度
- MinRadius:进行制作时可缩小到的最小半径
- MaxRadius:进行制作时可缩小到的最大半径
- InfluenceLayer:每次操作最大影响的层数
- TouchPower:每次操作的“力度”,值越大,操作越灵敏。
- Potteing:制作时使用的材质。
- PotteryLayerMask:物体层掩码(射线检测用)
四、UV相关
程序生成模型时,对UV进行了展开,展开后的UV如下:
由于内壁和内底应该都为白色,所以进行了UV重合。
制作完的效果图
整体效果:
底部效果
升级版源码
在上述功能的基础上,优化了触控算法和动态调整算法,增加了持久化功能,支持将陶艺数据保存为json文件,或从json文件中加载。以方便从服务器上传或下载。