rustum 发表于 2021-11-25 09:08

通过Unity实现一个简单的贴花效果

一、前言

在云艾尔登法环时,看到地面上的血迹时,发现某些地方脱离的地面,似乎是通过面片的方式实现的效果。但是同时某些,不过这种类型的血迹有道具的效果,估计是为了实现碰撞检测的功能才选择了面片的方式
不过场景中其他的战斗痕迹的效果似乎是通过贴花的方式实现的,贴花的方式多种多样。而在Unity中,有一种给官方文档提供代码的解决方案。这里就在这些代码的基础上做一个绘图的贴花效果,最终效果如图所示:



二、实现方式介绍

简单的来说就是通过发射一条射线与物体发生碰撞来获取物体的基本的信息,然后提取出碰撞处该物体的UV坐标点,然后进行一个计算得到物体对应Texture2D的像素信息,然后对这些像素进行一个颜色的替换,最后就可以得到一张贴花效果的Texture2D
这种方式的第一步就是需要通过发射一条射线,然后得到碰撞检测点的信息,这里用到的API为:


使用该API的返回结果是物体网格对应的UV坐标点,没有办法直接的去使用,需要先通过坐标转换,即通过UV坐标来获取到其Texture2D对应的像素点。在Unity中,我们知道UV坐标对应的范围为0到1,这样来说,只要将其与对应Texture2D的像素数量与UV坐标进行一个乘法计算就可以得到最后对应像素的下标位置
在得到检测位置的像素下表后,就可以根据被贴图的Texture2D的像素的宽高做一个计算,得到物体贴图的替换范围与下标,然后执行一遍遍历,对于所有替换的像素颜色一一对应,然后执行一个像素颜色的计算,做一个混合即可
三、实现过程

检测UV位置并替换像素颜色:
首先查阅Unity官方文档,得到射线检测UV坐标的代码,核心围绕RaycastHit 对应的API来得到检测的UV坐标并进行处理,代码如下:
public class ExampleClass : MonoBehaviour
{
    public Camera cam;

    void Start()
    {
      cam = GetComponent<Camera>();
    }

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

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

      Renderer rend = hit.transform.GetComponent<Renderer>();
      MeshCollider meshCollider = hit.collider as MeshCollider;

      if (rend == null || rend.sharedMaterial == null || rend.sharedMaterial.mainTexture == null || meshCollider == null)
            return;

      Texture2D tex = rend.material.mainTexture as Texture2D;
      Vector2 pixelUV = hit.textureCoord;
      pixelUV.x *= tex.width;
      pixelUV.y *= tex.height;

      tex.SetPixel((int)pixelUV.x, (int)pixelUV.y, Color.black);
      tex.Apply();
    }
}
然后在场景中创建一个Quad作为射线被检测的物体,但是同时需要注意,对于物体执行操作时,需要理一个细节,就是物体只有在挂载网格碰撞体时候,才能够获取到对应物体的UV信息,具体的细节在官方文档中也有提到,如图:


创建完成物体后,需要通过一个材质来赋予该物体一张贴图,用来作为像素替换的贴图,我这里用了一张白色的图片,但是注意,在使用该图片时候,注意修改该图片的导入设置中的 Read/Write Enabled为开启状态,这样才可以进行后续的修改:



如果你测试这段代码,可能发现在点击后并没有发生什么变化,因为这一段代码只会对一个像素点执行替换操作,运行效果看起来并不明显。为了提升显示效果,这里可以先做一个简单的计算,来设计一个像素块作为替换的基本单元,以便于结果的观察。而计算方式为通过这个像素点的下表位置来计算出一个大小合适的方格区域,定义一个Vector2的属性,命名为replaceRange,然后修改像素替换区域的代码:
    for (int i = 0; i < replaceRange.x; i++)
      {
            for (int j = 0; j < replaceRange.y; j++)
            {
                tex.SetPixel((int)pixelUV.x+i- (int)replaceRange.x/2, (int)pixelUV.y+j-(int)replaceRange.y/2, Color.black);
            }

      }
然后运行整个场景,如果脚本执行成功的话,就可以看到正确的显示效果:


修改替换信息为图片信息:
上面我们对于每一个像素的颜色值进行替换时,使用的是指定的颜色数字。接下来就需要进行一定的扩展,将信息的提取方式修改为图片提取的方式。
同样定义一个Texture2D属性命名为:coverTex,然后提取这张Texture2D的信息,并覆盖掉对应点击点的像素信息,这里定义一个Draw方法来单独的处理这件事情:
    public void Draw(Texture2D orginTex,Texture2D coverTex,Vector2 pixelUV)
    {
      for (int i = 0; i < coverTex.width; i++)
      {
            for (int j = 0; j < coverTex.height; j++)
            {
                Color colorOriginal = orginTex.GetPixel((int)pixelUV.x + i - (int)coverTex.width / 2, (int)pixelUV.y + j - (int)coverTex.height / 2);
                Color colorCover = <span class="p">.GetPixel(i, j);
                Color colorResult = colorCover * colorCover.a + (1 - colorCover.a) * colorOriginal;
                orginTex.SetPixel((int)pixelUV.x + i - (int)coverTex.width / 2, (int)pixelUV.y + j - (int)coverTex.height / 2, colorResult);
               
            }
      }
    }
注意,在上面的代码中,我们对于两个颜色值有一个简单的计算用来混合两个有透明通道的颜色值。假设颜色A要覆盖掉颜色B,这里使用的计算公式为:

[*]AA.a+(1-A.a)B
通过上面的公式,可以简单的处理两个颜色的a通道的覆盖结果,也许这种方式不是很准确,但是对于完全透明的像素或者完全不透明的像素的混合还是比较有效的,这样就很方便 的处理不规则形状的贴花
将上面的颜色快的方式替换掉,可以观察一下效果:



当我们使用到一张圆形的贴图后,我们就可以看到成功的执行了替换
运行时使用复制贴图:
如果我们直接使用本体的贴图来修改材质,就会发现本地的资源也被执行了修改,这样会造成下次进入游戏,整个贴图的状态也不会刷新。为了避免这个问题,可以在每次执行像素替换时,复制一份贴图来作为被贴画的材质贴图,不过这里就不进行演示,可以在自己的项目中,根据需要来决定是否执行该操作
修改帧检测断触问题:
上面的一个代码,有一个特点,就是可以通过连续的绘制来做出一些图案,有一些类似于江南白景图游戏中抽卡前的绘制效果,但是通过上面的代码来实现时,就会发现如果鼠标移动的过快,相邻的两个绘制点之间会产生空隙,如图所示:



为了解决这样一个问题,这里在每一帧执行绘制之后都缓存本帧的UV坐标,同时在绘制时与上一帧的UV坐标进行距离对比,如果超出一定的距离。就在中间执行插值的操作
同时为了保证性能,需要固定距离的执行插值操作,为了简化计算,将两帧坐标的距离分为X与Y方向分别进行判断,同时为了保证斜率,得到最大偏差的方向进行等距的插值,具体的逻辑代码为:
      if (!isClick)
      {
            Draw(tex, coverTex, pixelUV);
            catchPos = pixelUV;
            isClick = true;
      }
      else
      {

            if (Vector2.Distance(pixelUV, catchPos) < coverTex.width / 4)
            {
                Draw(tex, coverTex, pixelUV);
            }
            else
            {
                Vector2 pixelCatchUV = catchPos;
                float lerpNum=0;
                float interval = 1 / (Mathf.Max(Mathf.Abs(pixelUV.x - pixelCatchUV.x), Mathf.Abs(pixelUV.y - pixelCatchUV.y)) / (coverTex.width/4));
                while (lerpNum<=1)
                {
                  lerpNum += interval;
                  catchPos = Vector2.Lerp(pixelCatchUV, pixelUV, InterpolationCalculation(lerpNum));
                  Draw(tex, coverTex, catchPos);
                }
                catchPos = pixelUV;
                Draw(tex, coverTex, catchPos);
            }
      }
执行代码的显示结果为:



总结

从实现过程中面临的一些问题来看,这种贴画效果的实现限制条件很多,性能表现上也是比较差的,适合做一些局部的贴画效果实现,比如百景图的抽卡绘制的效果
而若想实现全局的效果,在UV平铺方面与贴图的缓存方面都有很大的挑战,还是建议尝试一下其他方式,最后,贴上完整的代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Test : MonoBehaviour
{
    public Camera cam;
    public Texture2D coverTex;

    private Texture2D catchTexture;
    private Vector2 catchPos;
    private bool isFirst=true;
    private bool isClick = false;

    void Awake()
    {
      Application.targetFrameRate = 200;
    }

    void Update()
    {
      if (Input.GetKeyDown(KeyCode.Space))
      {
            isFirst = true;
      }
      DrawSticker();

    }
   

    public void DrawSticker()
    {
      if (!Input.GetMouseButton(0))
      {
            isClick = false;
            return;
      }            
      RaycastHit hit;
      if (!Physics.Raycast(cam.ScreenPointToRay(Input.mousePosition), out hit))
            return;
      Renderer rend = hit.transform.GetComponent<Renderer>();
      MeshCollider meshCollider = hit.collider as MeshCollider;
      if (rend == null || rend.sharedMaterial == null || rend.sharedMaterial.mainTexture == null || meshCollider == null)
            return;
      if (isFirst)
      {
            if (catchTexture == null)
         {
                catchTexture = rend.material.mainTexture as Texture2D;
            }
            rend.material.mainTexture = Instantiate(catchTexture);
            isFirst = false;
      }
      Texture2D tex = rend.material.mainTexture as Texture2D;
      Vector2 pixelUV = hit.textureCoord;
      pixelUV.x *= tex.width;
      pixelUV.y *= tex.height;
      if (!isClick)
      {
            Draw(tex, coverTex, pixelUV);
            catchPos = pixelUV;
            isClick = true;
      }
      else
      {
            if (Vector2.Distance(pixelUV, catchPos) < coverTex.width / 4)
            {
                Draw(tex, coverTex, pixelUV);
            }
            else
            {
                Vector2 pixelCatchUV = catchPos;
                float lerpNum=0;
                float interval = 1 / (Mathf.Max(Mathf.Abs(pixelUV.x - pixelCatchUV.x), Mathf.Abs(pixelUV.y - pixelCatchUV.y)) / (coverTex.width/4));
                while (lerpNum<=1)
                {
                  lerpNum += interval;
                  catchPos = Vector2.Lerp(pixelCatchUV, pixelUV, InterpolationCalculation(lerpNum));
                  Draw(tex, coverTex, catchPos);
                }
                catchPos = pixelUV;
                Draw(tex, coverTex, catchPos);
            }
      }
      tex.Apply();
    }

    float InterpolationCalculation(float num)
    {
      return 3 * Mathf.Pow(num, 2) - 2 * Mathf.Pow(num, 3);
    }

    public void Draw(Texture2D orginTex,Texture2D coverTex,Vector2 pixelUV)
    {
      for (int i = 0; i < coverTex.width; i++)
      {
            for (int j = 0; j < coverTex.height; j++)
            {
                Color colorOriginal = orginTex.GetPixel((int)pixelUV.x + i - (int)coverTex.width / 2, (int)pixelUV.y + j - (int)coverTex.height / 2);
                Color colorCover = coverTex.GetPixel(i, j);
                Color colorResult = colorCover * colorCover.a + (1 - colorCover.a) * colorOriginal;

                orginTex.SetPixel((int)pixelUV.x + i - (int)coverTex.width / 2, (int)pixelUV.y + j - (int)coverTex.height / 2, colorResult);
               
            }
      }
    }

}
页: [1]
查看完整版本: 通过Unity实现一个简单的贴花效果