目录

unity实战实现在unity3D模型上画线写字涂鸦效果

【unity实战】实现在unity3D模型上画线写字涂鸦效果

[https://csdnimg.cn/release/blogv2/dist/pc/img/activeVector.png 王者杯·14天创作挑战营·第5期 10w+人浏览 1k人参与

https://csdnimg.cn/release/blogv2/dist/pc/img/arrowright-line-White.png]( )

前言

本文介绍在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); // 添加新的分割点
        }
    }
}

这里使用IPointerDownHandlerIPointerMoveHandlerIPointerUpHandler接口,而没有使用传统IBeginDragHandlerIDragHandlerIEndDragHandler拖拽接口的原因是,它们使用更灵敏的输入检测,响应更快,这样在绘制是才不会有延迟,不然绘制会断断续续。

2、在非UGUI(也就是2D3D环境)下执行UI事件接口

这里使用了UI事件监听接口,具体可以参考:

需要注意的是要在非UGUI(也就是2D3D环境)下执行UI事件接口,需要:

  • 确保物体上有Collider2D或者Collider组件
  • 场景中需要有EventSystem对象(新建UI时会自动创建)
  • 主相机需要有Physics2DRaycaster或者PhysicsRaycaster 组件
3、挂载脚本

新增一个Cube,挂载脚本
https://i-blog.csdnimg.cn/direct/5bc4cebd5c3e4f80b300e1660ade3a6d.png

4、效果

https://i-blog.csdnimg.cn/direct/734ce4fc3ef3487a8b78ed327716e9fb.gif

方案二、使用LineRenderer实现

使用GL APIGL.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、效果

https://i-blog.csdnimg.cn/direct/a36b7418aaf4404d86442d94106ff072.gif


专栏推荐

地址

完结

好了,我是向宇,博客地址: ,如果学习过程中遇到任何问题,也欢迎你评论私信找我。

赠人玫瑰,手有余香!如果文章内容对你有所帮助,请不要吝啬你的点赞评论和关注,你的每一次支持都是我不断创作的最大动力。当然如果你发现了文章中存在错误或者有更好的解决方法,也欢迎评论私信告诉我哦!
https://img-blog.csdnimg.cn/direct/4a8db123e30a4f86a0a183c963769343.gif#pic_center