unity实战实现在unity3D模型上画线写字涂鸦效果
目录
【unity实战】实现在unity3D模型上画线写字涂鸦效果
[
王者杯·14天创作挑战营·第5期
10w+人浏览
1k人参与
](
)
前言
本文介绍在Unity中实现3D模型表面绘制的两种技术方案。
- 方案一使用GL绘制系统,通过IPointer接口获取用户输入,将绘制点转换为模型局部坐标并沿法线方向偏移以实现立体效果,支持多段线绘制和模型旋转功能。
- 方案二采用LineRenderer组件实现。
两种方案均需在物体上添加碰撞体,并确保场景中有EventSystem和相应的PhysicsRaycaster 射线检测组件。最终效果演示了在立方体模型表面的流畅绘制过程,通过左右方向键可实时旋转模型查看绘制结果。
方案一、使用GL绘制
参考文档:https://docs.unity3d.com/ScriptReference/GL.html
1、代码
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
public class DrawOnModel : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IPointerMoveHandler
{
public Color color = Color.red; // 设置绘制线条的颜色
public float thickness = 0.02f; // 设置线条的深度(沿法线方向的偏移量)
private List<Vector3> points = new List<Vector3>(); // 存储绘制点的局部坐标
private List<Vector3> normals = new List<Vector3>(); // 存储每个点对应的法线方向(局部坐标)
private List<int> splits = new List<int>(); // 存储线条分割点的索引,用于处理多段线(如抬起画笔后重新开始绘制)
// 静态材质变量,用于绘制线条
static Material lineMaterial;
private bool isDrawing = false; // 标记当前是否正在绘制
private void Update()
{
// 按左右方向键,旋转模型
if(Input.GetKey(KeyCode.LeftArrow))
{
transform.Rotate(0, 360 * Time.deltaTime, 0);
}
else if (Input.GetKey(KeyCode.RightArrow))
{
transform.Rotate(0, -360 * Time.deltaTime, 0);
}
}
// 创建线条材质
static void CreateLineMaterial()
{
if (!lineMaterial)
{
// 使用Unity内置的简单着色器
var shader = Shader.Find("Hidden/Internal-Colored");
lineMaterial = new Material(shader);
lineMaterial.hideFlags = HideFlags.HideAndDontSave;
// 开启Alpha混合以实现透明效果
lineMaterial.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
lineMaterial.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
// 关闭背面剔除以确保线条两面都可见
lineMaterial.SetInt("_Cull", (int)UnityEngine.Rendering.CullMode.Off);
// 关闭深度写入以避免深度冲突
lineMaterial.SetInt("_ZWrite", 0);
}
}
// Unity渲染回调函数,在每一帧渲染时调用
public void OnRenderObject()
{
if (points.Count < 2) return; // 至少需要2个点才能绘制
CreateLineMaterial();
// 应用材质
lineMaterial.SetPass(0);
// 保存当前矩阵状态,并将变换矩阵设置为当前物体的局部到世界矩阵
// 这样可以在物体局部空间中进行绘制
GL.PushMatrix();
GL.MultMatrix(transform.localToWorldMatrix);
// 开始绘制线段
GL.Begin(GL.LINES);
int index = 0; // 分割点索引计数器
for(int i = 1; i < points.Count; i++)
{
// 如果当前点是分割点,则跳过绘制(创建新线段)
if (index < splits.Count && i == splits[index])
{
index++;
continue;
}
GL.Color(color); // 设置线段颜色
// 计算起点:表面点 + 法线方向 * 深度
var from = points[i-1] + normals[i-1] * thickness;
GL.Vertex3(from.x, from.y, from.z); // 设置起点
// 计算终点:表面点 + 法线方向 * 深度
var to = points[i] + normals[i] * thickness;
GL.Vertex3(to.x, to.y, to.z); // 设置终点
}
GL.End(); // 结束绘制
GL.PopMatrix(); // 恢复矩阵状态
}
// 当鼠标/触摸按下时的回调函数
public void OnPointerDown(PointerEventData eventData)
{
isDrawing = true;
}
// 当鼠标/触摸移动时的回调函数
public void OnPointerMove(PointerEventData eventData)
{
if (!isDrawing) return; // 如果不是绘制状态则直接返回
// 如果射线没有击中模型,则分割当前线段
if (!eventData.pointerCurrentRaycast.isValid)
{
SplitPoints();
return;
}
// 将世界坐标转换为模型局部坐标并添加到点列表
var localPosition = transform.InverseTransformPoint(eventData.pointerCurrentRaycast.worldPosition);
points.Add(localPosition);
// 将世界法线转换为模型局部法线并添加到法线列表
var localNormal = transform.InverseTransformDirection(eventData.pointerCurrentRaycast.worldNormal);
normals.Add(localNormal);
}
// 当鼠标/触摸抬起时的回调函数
public void OnPointerUp(PointerEventData eventData)
{
isDrawing = false;
SplitPoints(); // 结束当前线段
}
// 分割点方法,用于标记新线段的开始
void SplitPoints()
{
// 确保分割点列表不为空且最后一个分割点不是当前点数量
// 这样可以避免重复添加分割点
if ((splits.Count == 0 && points.Count != 0) ||
(splits.Count > 0 && splits[splits.Count - 1] != points.Count))
{
splits.Add(points.Count); // 添加新的分割点
}
}
}
这里使用IPointerDownHandler
、IPointerMoveHandler
和IPointerUpHandler
接口,而没有使用传统IBeginDragHandler
、IDragHandler
和IEndDragHandler
拖拽接口的原因是,它们使用更灵敏的输入检测,响应更快,这样在绘制是才不会有延迟,不然绘制会断断续续。
2、在非UGUI(也就是2D3D环境)下执行UI事件接口
这里使用了UI事件监听接口,具体可以参考:
需要注意的是要在非UGUI(也就是2D3D环境)下执行UI事件接口,需要:
- 确保物体上有Collider2D或者Collider组件
- 场景中需要有EventSystem对象(新建UI时会自动创建)
- 主相机需要有Physics2DRaycaster或者PhysicsRaycaster 组件
3、挂载脚本
新增一个Cube,挂载脚本
4、效果
方案二、使用LineRenderer实现
使用GL API
的GL.LINES
有个致命的缺点,就是无法直接控制线条粗细,它在大多数平台上会固定为1像素宽。要实现真正可控制的线条粗细,我们需要放弃GL立即模式,转而使用其他的方法,比如使用LineRenderer
实现。
1、代码
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
public class DrawOnModelLineRenderer : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IPointerMoveHandler
{
public Color color = Color.red; // 设置绘制线条的颜色
public float thickness = 0.02f; // 设置线条的厚度
public float lineWidth = 0.1f; // 设置线条的宽度
private List<Vector3> points = new List<Vector3>(); // 存储绘制点的局部坐标
private List<Vector3> normals = new List<Vector3>(); // 存储每个点对应的法线方向(局部坐标)
private List<int> splits = new List<int>(); // 存储线条分割点的索引
private List<LineRenderer> lineRenderers = new List<LineRenderer>(); // 存储所有的LineRenderer
private bool isDrawing = false; // 标记当前是否正在绘制
private LineRenderer currentLineRenderer; // 当前正在绘制的LineRenderer
private void Update()
{
// 按左右方向键,旋转模型
if(Input.GetKey(KeyCode.LeftArrow))
{
transform.Rotate(0, 360 * Time.deltaTime, 0);
}
else if (Input.GetKey(KeyCode.RightArrow))
{
transform.Rotate(0, -360 * Time.deltaTime, 0);
}
}
// 创建新的LineRenderer
private LineRenderer CreateNewLineRenderer()
{
GameObject lineObj = new GameObject("Line");
lineObj.transform.SetParent(transform);
lineObj.transform.localPosition = Vector3.zero;
lineObj.transform.localRotation = Quaternion.identity;
lineObj.transform.localScale = Vector3.one;
LineRenderer lr = lineObj.AddComponent<LineRenderer>();
lr.material = new Material(Shader.Find("Sprites/Default"));
lr.startColor = color;
lr.endColor = color;
lr.startWidth = lineWidth;
lr.endWidth = lineWidth;
lr.useWorldSpace = false; // 使用局部空间
return lr;
}
// 当鼠标/触摸按下时的回调函数
public void OnPointerDown(PointerEventData eventData)
{
isDrawing = true;
// 创建新的LineRenderer
currentLineRenderer = CreateNewLineRenderer();
lineRenderers.Add(currentLineRenderer);
// 添加第一个点
if (eventData.pointerCurrentRaycast.isValid)
{
AddPoint(eventData);
}
}
// 当鼠标/触摸移动时的回调函数
public void OnPointerMove(PointerEventData eventData)
{
if (!isDrawing) return; // 如果不是绘制状态则直接返回
// 如果射线没有击中模型,则分割当前线段
if (!eventData.pointerCurrentRaycast.isValid)
{
SplitPoints();
return;
}
AddPoint(eventData);
UpdateLineRenderer();
}
// 添加点到当前线段
private void AddPoint(PointerEventData eventData)
{
// 将世界坐标转换为模型局部坐标并添加到点列表
var localPosition = transform.InverseTransformPoint(eventData.pointerCurrentRaycast.worldPosition);
points.Add(localPosition);
// 将世界法线转换为模型局部法线并添加到法线列表
var localNormal = transform.InverseTransformDirection(eventData.pointerCurrentRaycast.worldNormal);
normals.Add(localNormal);
}
// 更新当前LineRenderer的顶点
private void UpdateLineRenderer()
{
if (currentLineRenderer == null) return;
// 获取当前线段的所有点(从最后一个分割点开始到当前点)
int startIndex = splits.Count > 0 ? splits[splits.Count - 1] : 0;
int pointCount = points.Count - startIndex;
if (pointCount < 1) return;
// 创建偏移后的点数组(沿法线方向偏移)
Vector3[] offsetPoints = new Vector3[pointCount];
for (int i = 0; i < pointCount; i++)
{
int pointIndex = startIndex + i;
offsetPoints[i] = points[pointIndex] + normals[pointIndex] * thickness;
}
currentLineRenderer.positionCount = pointCount;
currentLineRenderer.SetPositions(offsetPoints);
}
// 当鼠标/触摸抬起时的回调函数
public void OnPointerUp(PointerEventData eventData)
{
isDrawing = false;
SplitPoints(); // 结束当前线段
currentLineRenderer = null;
}
// 分割点方法,用于标记新线段的开始
void SplitPoints()
{
// 确保分割点列表不为空且最后一个分割点不是当前点数量
if ((splits.Count == 0 && points.Count != 0) ||
(splits.Count > 0 && splits[splits.Count - 1] != points.Count))
{
splits.Add(points.Count); // 添加新的分割点
}
}
// 清除所有绘制的线条
public void ClearLines()
{
foreach (var lineRenderer in lineRenderers)
{
if (lineRenderer != null && lineRenderer.gameObject != null)
{
Destroy(lineRenderer.gameObject);
}
}
lineRenderers.Clear();
points.Clear();
normals.Clear();
splits.Clear();
currentLineRenderer = null;
}
}
其他设置和前面类似
2、效果
专栏推荐
地址 |
---|
完结
好了,我是向宇
,博客地址:
,如果学习过程中遇到任何问题,也欢迎你评论私信找我。
赠人玫瑰,手有余香!如果文章内容对你有所帮助,请不要吝啬你的点赞评论和关注
,你的每一次支持
都是我不断创作的最大动力。当然如果你发现了文章中存在错误
或者有更好的解决方法
,也欢迎评论私信告诉我哦!