Unity简单涂料实现

很多游戏有涂料绘制玩法,有时间了就来研究下,现在配合ai学习成本已经大大降低,但更多的应该是借助ai的便利加快学习速度,不能仅仅依赖ai制作而自己不懂原理。

具体效果如下:

具体实现方式是典型的 Ping-Pong RenderTexture(双缓冲画板)

创建两张 RenderTexture,分别作为画布 A 和画布 B。绘制时,以画布 A 中已有的颜料为基础,通过 Material 参数告诉笔刷 Shader 绘制位置、颜色、大小和透明度,再使用 Graphics.Blit 将新的绘制结果写入画布 B。绘制完成后交换 A 和 B的引用,使最新结果成为下一次绘制的基础。最后,将保存最新颜料结果的 RenderTexture 设置到地面上方的透明颜料材质中,与下方地面的原始贴图进行透明混合。

脚本比较简单,重点控制GPU绘制,涉及以下内容

  • RenderTexture(渲染纹理) :作为"画布"的核心载体,将 GPU 的渲染结果存储为纹理,供后续混合和显示。脚本中同时维护了 currentPaintscratchPaint 来实现双缓冲机制

  • Graphics.Blit(图像拷贝/绘制):这是 GPU 上最高效的像素处理命令。脚本用它把带有画笔 Shader 的材质应用到 RenderTexture 上,实现真正的 GPU 端绘画,完全避开 CPU 逐像素操作。

  • Shader 与 Material(着色器与材质) :通过 brushShader 创建运行时材质,并动态传入 _BrushUV_BrushRadiusUV_BrushColor 等参数来控制绘制的形状、大小、颜色和透明度。

  • Source-Over 混合模式(合成运算) :画笔 Shader 中的 Graphics.Blit 默认就是源覆盖目标,实现了"新画笔颜色覆盖旧颜色,同时保留透明边缘"的效果。

  • 坐标系转换(World to UV) :通过 transform.InverseTransformPointsurfaceSize,将三维世界坐标精确映射到 0~1 的 UV 空间,让画笔"粘"在物体表面。

    using System;
    using UnityEngine;

    namespace PaintWorldDemo
    {
    [DisallowMultipleComponent]
    public sealed class PaintCanvas : MonoBehaviour
    {
    [SerializeField] private Renderer targetRenderer;
    [SerializeField] private Shader brushShader;
    [SerializeField] private Vector2 surfaceSize = new Vector2(28f, 20f);
    [SerializeField, Range(256, 2048)] private int textureResolution = 1024;

    复制代码
          private Material brushMaterial;
          private Material runtimeSurfaceMaterial;
          private RenderTexture currentPaint;
          private RenderTexture scratchPaint;
          private int paintTextureProperty;
          private int stampSeed;
          private System.Random splatterRandom = new System.Random(7823);
    
          public Vector2 SurfaceSize => surfaceSize;
    
          public void Configure(Renderer renderer, Shader paintBrushShader, Vector2 size, int resolution)
          {
              targetRenderer = renderer;
              brushShader = paintBrushShader;
              surfaceSize = size;
              textureResolution = Mathf.Clamp(resolution, 256, 2048);
          }
    
          private void Awake()
          {
              EnsureInitialized();
          }
    
          private void EnsureInitialized()
          {
              if (currentPaint != null)
              {
                  return;
              }
    
              if (targetRenderer == null || brushShader == null)
              {
                  Debug.LogError("[PaintWorldDemo] PaintCanvas is missing its renderer or brush shader.", this);
                  enabled = false;
                  return;
              }
    
              currentPaint = CreatePaintTexture("PaintMap_Current");
              scratchPaint = CreatePaintTexture("PaintMap_Scratch");
              brushMaterial = new Material(brushShader)
              {
                  name = "PaintBrush_Runtime",
                  hideFlags = HideFlags.HideAndDontSave
              };
    
              runtimeSurfaceMaterial = targetRenderer.material;
              runtimeSurfaceMaterial.name = targetRenderer.sharedMaterial.name + " (Runtime)";
              paintTextureProperty = runtimeSurfaceMaterial.HasProperty("_PaintMap")
                  ? Shader.PropertyToID("_PaintMap")
                  : Shader.PropertyToID("_BaseMap");
    
              if (paintTextureProperty == Shader.PropertyToID("_BaseMap") && runtimeSurfaceMaterial.HasProperty("_BaseColor"))
              {
                  Color overlayTint = runtimeSurfaceMaterial.GetColor("_BaseColor");
                  runtimeSurfaceMaterial.SetColor("_BaseColor", new Color(1f, 1f, 1f, overlayTint.a));
              }
    
              ClearPaint();
          }
    
          private RenderTexture CreatePaintTexture(string textureName)
          {
              RenderTexture texture = new RenderTexture(
                  textureResolution,
                  textureResolution,
                  0,
                  RenderTextureFormat.ARGB32,
                  RenderTextureReadWrite.Linear)
              {
                  name = textureName,
                  filterMode = FilterMode.Bilinear,
                  wrapMode = TextureWrapMode.Clamp,
                  useMipMap = false,
                  autoGenerateMips = false,
                  hideFlags = HideFlags.HideAndDontSave
              };
              texture.Create();
              return texture;
          }
    
          public bool TryWorldToUV(Vector3 worldPosition, out Vector2 uv)
          {
              Vector3 local = transform.InverseTransformPoint(worldPosition);
              uv = new Vector2(
                  local.x / surfaceSize.x + 0.5f,
                  local.z / surfaceSize.y + 0.5f);
              return uv.x >= 0f && uv.x <= 1f && uv.y >= 0f && uv.y <= 1f;
          }
    
          public void PaintWorld(Vector3 worldPosition, Color color, float radius, float opacity = 1f, float softness = 0.18f)
          {
              EnsureInitialized();
              if (!enabled || !TryWorldToUV(worldPosition, out Vector2 uv))
              {
                  return;
              }
    
              float worldScaleX = Mathf.Max(0.0001f, transform.TransformVector(Vector3.right).magnitude);
              float worldScaleZ = Mathf.Max(0.0001f, transform.TransformVector(Vector3.forward).magnitude);
              Vector2 radiusUv = new Vector2(
                  Mathf.Max(0.0001f, radius / worldScaleX / surfaceSize.x),
                  Mathf.Max(0.0001f, radius / worldScaleZ / surfaceSize.y));
    
              brushMaterial.SetVector("_BrushUV", new Vector4(uv.x, uv.y, 0f, 0f));
              brushMaterial.SetVector("_BrushRadiusUV", new Vector4(radiusUv.x, radiusUv.y, 0f, 0f));
              brushMaterial.SetColor("_BrushColor", new Color(color.r, color.g, color.b, Mathf.Clamp01(opacity)));
              brushMaterial.SetFloat("_Softness", Mathf.Clamp(softness, 0.02f, 0.8f));
              brushMaterial.SetFloat("_Seed", ++stampSeed * 0.731f);
    
              Graphics.Blit(currentPaint, scratchPaint, brushMaterial);
              RenderTexture swap = currentPaint;
              currentPaint = scratchPaint;
              scratchPaint = swap;
              runtimeSurfaceMaterial.SetTexture(paintTextureProperty, currentPaint);
          }
    
          public void PaintSplatterWorld(Vector3 worldPosition, Color color, float radius)
          {
              PaintWorld(worldPosition, color, radius, 1f, 0.2f);
    
              int droplets = 5;
              for (int i = 0; i < droplets; i++)
              {
                  double angle = splatterRandom.NextDouble() * Math.PI * 2.0;
                  float distance = radius * Mathf.Lerp(0.72f, 1.48f, (float)splatterRandom.NextDouble());
                  float dropletRadius = radius * Mathf.Lerp(0.08f, 0.22f, (float)splatterRandom.NextDouble());
                  Vector3 offset = (
                      transform.right * (float)Math.Cos(angle) +
                      transform.forward * (float)Math.Sin(angle)) * distance;
                  PaintWorld(worldPosition + offset, color, dropletRadius, 0.9f, 0.35f);
              }
          }
    
          public void ClearPaint()
          {
              EnsureInitialized();
              if (currentPaint == null)
              {
                  return;
              }
    
              RenderTexture previous = RenderTexture.active;
              RenderTexture.active = currentPaint;
              GL.Clear(false, true, Color.clear);
              RenderTexture.active = scratchPaint;
              GL.Clear(false, true, Color.clear);
              RenderTexture.active = previous;
              runtimeSurfaceMaterial.SetTexture(paintTextureProperty, currentPaint);
          }
    
          private void OnDestroy()
          {
              ReleaseTexture(currentPaint);
              ReleaseTexture(scratchPaint);
    
              if (brushMaterial != null)
              {
                  Destroy(brushMaterial);
              }
    
              if (runtimeSurfaceMaterial != null)
              {
                  Destroy(runtimeSurfaceMaterial);
              }
          }
    
          private static void ReleaseTexture(RenderTexture texture)
          {
              if (texture == null)
              {
                  return;
              }
    
              texture.Release();
              Destroy(texture);
          }
      }

    }

shader文件也不复杂,有比较完整的注释信息,重点是理解输入和输出、噪声与混合

复制代码
Shader "Hidden/PaintWorldDemo/Brush"
{
    Properties
    {
        _MainTex ("Previous Paint", 2D) = "black" {}
    }

    SubShader
    {
        Cull Off
        ZWrite Off
        ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag
            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float4 _BrushUV;
            float4 _BrushRadiusUV;
            float4 _BrushColor;
            float _Softness;
            float _Seed;

            float Hash21(float2 p)
            {
                p = frac(p * float2(123.34, 456.21));
                p += dot(p, p + 45.32 + _Seed);
                return frac(p.x * p.y);
            }

            fixed4 frag(v2f_img i) : SV_Target
            {
                // ==================== 1. 获取背景与定位 ====================
                // 从屏幕上(_MainTex通常是当前屏幕快照)读取该像素原本的颜色
                float4 previous = tex2D(_MainTex, i.uv);
                // 将当前像素转换为"以画笔中心为原点、画笔半径为1"的局部坐标系
                // 结果含义:中心(0,0)、边缘(±1, ±1)、超出范围则为绝对值>1
                float2 normalizedDelta = (i.uv - _BrushUV.xy) / max(_BrushRadiusUV.xy, 0.00001);
                // 计算该像素距离画笔中心的距离(0~1以内代表在画笔内,>1则在外围)
                float distanceToCenter = length(normalizedDelta);
                
                // ==================== 2. 生成两种随机/噪点,模拟自然纹理 ====================
                // 计算程序化噪声,让画笔内部出现斑点/颗粒感(类似沙粒或墨点的不均匀分布)
                float coarseNoise = Hash21(floor(i.uv * 220.0) + _Seed);
                // 计算程序化噪声,让画笔内部产生柔和的纹理流动感(类似宣纸的纤维纹理或水彩的扩散纹路)
                float waveNoise = sin((i.uv.x * 31.0 + i.uv.y * 47.0 + _Seed) * 6.28318) * 0.5 + 0.5;
                // ==================== 3. 合成不规则边缘(核心效果) ====================
                // 计算逻辑:
                //   - 基础值 = distanceToCenter(完美的圆形距离)
                //   - 扰动值 = (coarseNoise * 0.65 + waveNoise * 0.35) 混合两种噪点,权重不同
                //   - 减去0.5让扰动值围绕0上下浮动,再乘以0.18控制扰动幅度
                //   - 最终结果:距离值被扭曲,导致画笔边缘出现锯齿状、颗粒状的不规则形状
                float irregularDistance = distanceToCenter + (coarseNoise * 0.65 + waveNoise * 0.35 - 0.5) * 0.18;
                // 计算覆盖强度(即透明度/alpha值):
                //   - smoothstep(1.0 - _Softness, 1.0, irregularDistance) 实现边缘羽化
                //   - _Softness 控制从完全不透明到完全透明之间的过渡带有多宽(0为硬边,1为全透明渐变)
                //   - 再用 1.0 减去,让内部为1(不透明),外部为0(完全透明)
                float coverage = 1.0 - smoothstep(1.0 - _Softness, 1.0, irregularDistance);
                // ==================== 4. 混合运算(Source-Over 合成模式) ====================
                // 计算混合后的alpha值:新像素覆盖旧像素,同时保留透明度
                coverage *= _BrushColor.a;
                // 预乘Alpha方式的颜色混合(避免半透明边缘出现黑边/白边)
                //   新颜色 = 画笔颜色 * 覆盖强度 + 旧颜色 * 旧透明度 * (1 - 覆盖强度)
                float mixedAlpha = coverage + previous.a * (1.0 - coverage);
                float3 premultipliedColor =
                    _BrushColor.rgb * coverage +
                    previous.rgb * previous.a * (1.0 - coverage);
                float3 mixedColor = premultipliedColor / max(mixedAlpha, 0.00001);
                return float4(mixedColor, mixedAlpha);
            }
            ENDCG
        }
    }
}

这类绘制游戏一般都会有常见的撤销系统,感觉实现也不复杂,无非是每次落笔前和落笔后复制一份缓存罢了。

相关推荐
刘步权4 小时前
KY-RTI分布仿真技术:第十三章 基于Unity的多语言三维态势仿真——以 Island Assault(夺岛突击)想定为例
unity·游戏引擎·仿真·态势·ky-rti·hla·rti
ellis19706 小时前
C#/Unity清理非托管资源
unity·c#
玖玥拾1 天前
Unity 3D 笔记(十四)Unity/C# Socket 网络笔记3
服务器·网络·unity·c#
Python私教1 天前
Godot图片怎么导入?PNG、压缩、Mipmap与Reimport一次讲清
游戏引擎·godot
玖玥拾1 天前
Unity 3D 笔记(十七)Unity/C# Socket 网络笔记6
服务器·网络·unity·c#
寒水馨1 天前
Linux下载、安装godot-4.7.1-stable(附安装包Godot_v4.7.1-stable_linux.x86_64.zip)
linux·游戏引擎·godot·游戏开发·2d游戏·3d游戏·godot engine
寒水馨1 天前
macOS下载、安装godot-4.7.1-stable(附安装包Godot_v4.7.1-stable_macos.universal.zip)
macos·3d·游戏引擎·godot·跨平台·游戏开发·2d
电子云与长程纠缠2 天前
UE中使用TGuardValue与TInlineComponentArray数据结构
开发语言·数据结构·学习·ue5·游戏引擎
玖玥拾2 天前
Unity 3D 笔记(十二)Unity/C# Socket 网络笔记1
网络·unity·c#