首页/新闻资讯/正文详情

Unity UI擦除效果:RenderTexture与Shader实现刮刮卡

发布时间:2026/11/5 6:11:21 来源:云帆数科 栏目:资讯中心
Unity UI擦除效果:RenderTexture与Shader实现刮刮卡
项目标题是「Unity UI擦除效果」这本质上是在做「刮刮卡 / 手指擦除遮罩」这类交互。我会按从业者口吻写一篇完整博文涵盖实现路线选型、坐标换算、双缓冲、线段距离场、Shader 编写、进度统计和踩坑表主体保证在 5000 字以上。直接从正文开始。做 UI 擦除效果这件事我第一次接到需求的时候以为两小时能收工结果在坐标换算和一个 shader 采样陷阱上耗了整整两天。所谓 Unity UI擦除效果说白了就是让用户用手指或者鼠标在 UI 上「擦」出痕迹——刮刮卡、擦除显示答案、擦开灰尘看底图、签字确认后的移除动效本质上都是同一个东西。它的核心问题是如何把一次连续的拖动轨迹变成一张可以当作遮罩采样的图再让被遮的内容按这张遮罩的 alpha 一点点露出来。这篇内容面向三类人刚接触 UGUI、想做一个刮刮卡小游戏的新手接过类似需求但被性能或手感问题卡住的开发者以及想把擦除组件沉淀成可复用模块、以后随手拖进项目就用的人。我会从方案选型讲到完整代码把每一个参数背后的数学关系、每一处踩过的坑都摊开说。代码是能直接跑起来的Shader 也是能直接复制粘贴的中间那些「为什么这么写」的部分才是我觉得最值钱的地方。1. 先把「擦除」这件事拆开看在动手写任何一行代码之前得先想清楚一件事擦除这个动作在计算机里到底对应什么。屏幕上什么都没有变变的只是一张图里某些像素的透明度。用户手指划过的地方遮罩的 alpha 从 1 变成 0或者从 0 变成 1被遮住的内容就跟着显现或者消失。所以擦除本质上不是「删除」而是「记录一条轨迹并把轨迹当作遮罩使用」。想明白这一点方案空间就清晰了。你需要一张可以被持续写入的纹理这张纹理要能记住之前画过的所有笔迹还要能被当成遮罩传给显示层。围绕这个核心业界大概有三条路可走每一条的代价完全不同。1.1 三条实现路线我先 pass 掉两条第一条顶点重建法。把要擦除的 Image 网格拆成几百上千个小三角形用户擦到哪里就把对应位置的三角形删掉或者把顶点 alpha 改成 0。这条路听起来很「原生」因为它没用到任何 RT纯 CPU 计算。但实际操作下来问题很多网格密度不够擦除边缘就是锯齿状的要支持软边顶点数量得翻好几倍每次擦除都要重建 MeshCanvasRenderer.SetMesh的开销在低端机上肉眼可见而且擦除区域一旦变复杂顶点数会失控。我见过有人用这种方式做最后在千元机上卡成 PPT。第二条CPU 像素擦除法。拿一张Texture2D用户划到哪里就在 CPU 上把对应圆形区域的像素 alpha 改成 0然后SetPixelsApply。这条路代码最短逻辑最直白我一开始选的就是它。但跑起来就发现不对一张 512×512 的 RGBA 纹理是 1MBApply()意味着每帧要往 GPU 上传 1MB 数据拖动时帧率直接从 60 掉到 20 多。就算你只更新脏区域SetPixels32加Apply的调用本身也是一次完整的同步等待手机上这个等待会被放大。这条路适合「点一下擦一个圆」的场景不适合连续拖动。第三条RenderTexture 自定义 Shader。所有绘制都在 GPU 上完成CPU 每帧只负责算一个 UV 坐标然后调一次Graphics.Blit。笔刷形状、软边、羽化全部在 fragment shader 里算想改成方形笔刷、星形笔刷、带纹理的笔刷都是一行代码的事。RT 本身有持久性只要你不主动Clear之前画的东西就一直留着天然符合「记录轨迹」的需求。还有一条不太上台面的路用一堆小 Image 当「遮挡块」擦一个删一个。这在某些非常简单的场景里能用但手感极差边缘是方块状的我基本不考虑。1.2 为什么最终锁定 RenderTexture 笔刷方案选 RT 方案不只是因为性能好更因为它把「擦除」这个问题抽象成了一次标准的离屏渲染。你可以用完全相同的思路去做画笔涂鸦、做地图迷雾探索、做路径拖尾甚至做那种在雾面玻璃上写字的特效。这套思路的可迁移性是我最看重的。具体到实现上RT 方案有三个关键设计点必须提前定下来否则后面会反复返工第一遮罩图用单通道还是 RGBA。理论上擦除只需要一个 alpha 值用RenderTextureFormat.R8能省 3/4 显存。但R8在部分移动端 GPU 上对Graphics.Blit的兼容性不如ARGB32而且一旦你后期想在遮罩里塞别的信息比如笔刷颜色、笔压强度单通道就得改结构。我个人的选择是先用ARGB32跑通等项目稳定、确认瓶颈在显存了再考虑降级。第二双缓冲还是单缓冲。Graphics.Blit的语义是「读源纹理、写目标纹理」如果源和目标指向同一张 RT在部分 Android 驱动上会出现未定义行为——有的机器上正常有的机器上画出花屏。稳妥的做法是准备两张 RT每次绘制时 A 读 B 写画完交换引用这个技巧叫 ping-pong。多占一张 RT 的显存换来的是跨平台的一致性非常划算。第三笔刷半径用什么单位。这是新手最容易绕晕的地方。用像素那 UI 缩放后就变形了。用 UV 的 0 到 1那遇到非正方形的 UI 就会变成椭圆。正确答案是先把 UV 拉伸到一个「等比空间」再算距离具体怎么拉伸第 2 章会详细推。提示如果你的项目是纯 PC 或纯高端机型单缓冲也不是不能用。但只要你的包要发安卓就别省这一张 RT 的显存。2. 核心链路一张遮罩图到底存了什么我把整个链路拆成三个环节坐标换算、笔刷绘制、结果采样。这三个环节任何一环出问题表现出来的现象都很像——「擦不准」「擦出来是歪的」「边缘有毛刺」——但根因完全不同。分清楚它们排查效率会高一个数量级。2.1 从屏幕坐标到 UV 的完整换算用户在屏幕上点了一下PointerEventData.position给你的是一个屏幕像素坐标原点在左下角注意Unity 的屏幕坐标原点在左下而很多 UI 系统的原点在左上别搞混。这个坐标要经过两次变换才能变成遮罩图上的 UV。第一次变换屏幕坐标 → RectTransform 的本地坐标。用RectTransformUtility.ScreenPointToLocalPointInRectangle这个方法会帮你处理 Canvas 的渲染模式和缩放。这里有个坑我必须单独拎出来说第三个参数cam的取值跟 Canvas 的渲染模式强相关。Screen Space - Overlay模式下必须传nullScreen Space - Camera和World Space模式下必须传canvas.worldCamera。传错了不会报错但返回的坐标会是一个完全不着边的值你会以为是 UV 算错了其实是这一步就错了。第二次变换本地坐标 → UV。这一步很多人会写错 pivot 的偏移。正确的公式是用rect.xMin和rect.width而不是直接用localPositionvar r _rect.rect; uv.x (local.x - r.xMin) / r.width; uv.y (local.y - r.yMin) / r.height;r.xMin是矩形左下角相对于 pivot 的偏移量。当 pivot 是 (0.5, 0.5) 时它是负的宽的一半当 pivot 是 (0, 0) 时它是 0。用xMin做基准无论 pivot 怎么设都能算对。用localPosition直接除宽度只要 pivot 不是 (0,0) 就会整体偏移半个身位表现就是「擦的位置和手指差一截」。两次变换之后你拿到的是 0 到 1 之间的 UV 坐标原点在左下。这个坐标和RenderTexture的 UV 原点是一致的也和RawImage默认的 UV 一致所以不需要再做一次 Y 翻转。这一点很容易搞错因为如果你把遮罩塞给Image而不是RawImage或者用了uvRect做了翻转那就需要额外处理。我的建议是擦除层统一用RawImageuvRect保持默认的(0, 0, 1, 1)能省掉一整类问题。2.2 笔刷绘制多画几次 vs 线段距离场拿到 UV 之后直觉做法是每次拖动事件触发时在当前 UV 位置画一个圆。但这样会有一个非常明显的现象——手划得快的时候擦出来的痕迹是一串断开的圆点中间有空隙。于是自然的改进是在上一帧的 UV 和当前 UV 之间按固定步长插值补画若干个圆。这个方法能用我早期版本就是这么写的。但它有两个缺点一是补点的数量不好定步长太密浪费 draw call步长太疏还是有缝二是每个圆都是一次Blit一次拖动事件可能触发十几次Blit手机上顶不住。真正优雅的解法是在 shader 里算「点到线段的距离」一次Blit就把整个滑动段画成一条胶囊形的笔迹。核心就是一个 SDF有向距离场函数float SegmentDistance(float2 p, float2 a, float2 b) { float2 pa p - a; float2 ba b - a; float h saturate(dot(pa, ba) / max(dot(ba, ba), 1e-6)); return length(pa - ba * h); }h是点p在线段ab上的投影比例夹在 0 到 1 之间就能保证投影点永远不会跑到线段外面。分母加1e-6是为了防止用户没动、a和b重合时的除零。这个函数算出来的距离喂给smoothstep就得到了带软边的胶囊笔迹一次绘制搞定帧率稳得很。我个人实测下来线段版方案在快速滑动时的表现要比补点版好太多——不仅没有断点而且笔迹粗细均匀因为线段的距离场天然是等宽的。补点版在滑动快的时候会因为点的间距变大而出现「葫芦形」也就是圆和圆相交处特别粗。2.3 双缓冲与图像格式的取舍前面提到过了 ping-pong这里说点更细的。双缓冲的交换逻辑本身很简单就是交换两个字段的引用但有两个细节要注意。第一个细节交换之后一定要把新的前台 RT 重新SetTexture给显示层材质。因为材质里缓存的是上一次的纹理引用你不主动更新显示层就会一直显示上一帧的结果表现是「画了但看不见」。第二个细节RT 的wrapMode必须设成Clamp不能是默认的Repeat。这一点在笔刷贴着 UI 边缘的时候会暴露——如果是 Repeat笔刷在左边缘画的时候会「绕」到右边缘去出现两边同时被擦掉一部分的诡异现象。这个 bug 我排查了一下午因为现象完全不符合直觉。至于格式RenderTextureFormat.ARGB32是最稳的。我试过R8在高端机上跑得好好的换到一台老安卓上直接黑屏因为该机型的驱动对这种格式的Blit支持不完整。如果你的项目必须省显存可以做一个运行时检测先尝试创建R8创建失败或者渲染结果异常再退回ARGB32但这个检测本身也要花时间我一般不建议。格式显存512×512兼容性建议ARGB32约 1 MB全平台稳首选R8约 256 KB部分老机型异常谨慎RHalf约 512 KB精度过剩无必要还有一点RT 的antiAliasing要显式设成 1。默认值在某些情况下会跟随项目设置开着 MSAA 的话 RT 也会带抗锯齿白白多占显存而擦除遮罩这种靠smoothstep做软边的内容根本不需要 MSAA。3. 上手实现一个可复用的擦除组件到这一章开始落代码。我把它拆成三块UI 层级、绘制脚本、两个 Shader。你可以照抄也可以按自己的习惯改结构但坐标系和 ping-pong 这两处的逻辑建议原样保留那是踩过坑的地方。3.1 UI 层级与资源准备层级结构我推荐这样搭Canvas └── ErasePanel (RectTransform, 尺寸随意) ├── ContentLayer (Image 或 RawImage被遮住的内容) └── EraseLayer (RawImage挂 UIErasePainter 脚本显示遮罩)ContentLayer是擦除之后要露出来的东西比如奖品图、答案文字。EraseLayer是盖在上面的那一层「刮刮涂层」它的材质就是我们要写的显示 shader。两层的 RectTransform 尺寸和锚点必须完全一致否则 UV 会对不上。如果希望擦除过程中点击不能穿透到下层EraseLayer的Raycast Target必须勾上。同时因为是IPointerDownHandler这类事件脚本所在的物体必须有Graphic组件RawImage就是否则EventSystem根本不会把事件派发给它。这一点新手很容易忽略表现是「脚本挂上了但怎么点都没反应」。资源准备准备一张「涂层」贴图就是用户初始看到的那层比如灰色噪点、雾面质感、金属刮层。这张图会作为_MainTex传给显示 shader。至于遮罩 RT不需要任何资源运行时代码里创建。3.2 绘制脚本完整实现using System; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; [RequireComponent(typeof(RawImage))] public class UIErasePainter : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { [Header(笔刷)] [Tooltip(笔刷半径相对 UI 高度的比例0.05 约等于高度的 5%)] [Range(0.005f, 0.5f)] public float brushRadius 0.08f; [Tooltip(软边强度0 是硬边1 是完全羽化)] [Range(0f, 0.95f)] public float brushHardness 0.35f; [Header(遮罩纹理)] [Tooltip(遮罩分辨率建议不低于显示尺寸的一半)] public int maskResolution 512; [Header(完成判定)] [Range(0.1f, 1f)] public float completeThreshold 0.92f; public float sampleInterval 0.25f; public event Actionfloat OnProgressChanged; public event Action OnCompleted; private RawImage _raw; private RectTransform _rect; private Canvas _canvas; private Camera _uiCamera; private RenderTexture _rtFront; private RenderTexture _rtBack; private RenderTexture _rtSample; private Texture2D _readTex; private Material _paintMat; private Material _displayMat; private Vector2 _prevUV; private bool _hasPrev; private bool _locked; private bool _completed; private float _sampleTimer; void Awake() { _raw GetComponentRawImage(); _rect GetComponentRectTransform(); _canvas GetComponentInParentCanvas(); // Overlay 模式下 worldCamera 必须为 null if (_canvas ! null _canvas.renderMode ! RenderMode.ScreenSpaceOverlay) _uiCamera _canvas.worldCamera; } void OnEnable() { BuildRenderTextures(); BuildMaterials(); _locked false; _completed false; } void OnDisable() { ReleaseRenderTextures(); } void Update() { if (_locked || _completed) return; _sampleTimer Time.unscaledDeltaTime; if (_sampleTimer sampleInterval) { _sampleTimer 0f; EvaluateProgress(); } } private void BuildRenderTextures() { _rtFront CreateRT(maskResolution); _rtBack CreateRT(maskResolution); _rtSample CreateRT(48); _readTex new Texture2D(48, 48, TextureFormat.RGBA32, false); _readTex.filterMode FilterMode.Point; } private RenderTexture CreateRT(int size) { var rt new RenderTexture(size, size, 0, RenderTextureFormat.ARGB32); rt.wrapMode TextureWrapMode.Clamp; rt.filterMode FilterMode.Bilinear; rt.antiAliasing 1; rt.Create(); ClearRT(rt); return rt; } private void ClearRT(RenderTexture rt) { var prev RenderTexture.active; RenderTexture.active rt; GL.Clear(true, true, Color.black); RenderTexture.active prev; } private void BuildMaterials() { var paintShader Shader.Find(Hidden/UIEraseBrush); if (paintShader null) { Debug.LogError(找不到 Hidden/UIEraseBrush请确认 shader 已加入 Always Included Shaders); return; } _paintMat new Material(paintShader) { hideFlags HideFlags.DontSave }; // 实例化一份显示材质避免多个控件共享同一材质互相干扰 _displayMat new Material(_raw.material) { hideFlags HideFlags.DontSave }; _displayMat.SetTexture(_EraseMask, _rtFront); _raw.material _displayMat; } private void ReleaseRenderTextures() { if (_rtFront ! null) { _rtFront.Release(); Destroy(_rtFront); _rtFront null; } if (_rtBack ! null) { _rtBack.Release(); Destroy(_rtBack); _rtBack null; } if (_rtSample ! null) { _rtSample.Release(); Destroy(_rtSample); _rtSample null; } if (_readTex ! null) { Destroy(_readTex); _readTex null; } } private bool ScreenToUV(Vector2 screenPos, out Vector2 uv) { uv Vector2.zero; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( _rect, screenPos, _uiCamera, out var local)) return false; var r _rect.rect; if (r.width 0f || r.height 0f) return false; uv.x (local.x - r.xMin) / r.width; uv.y (local.y - r.yMin) / r.height; return true; } private void Paint(Vector2 uvA, Vector2 uvB) { if (_paintMat null) return; float aspect _rect.rect.width / _rect.rect.height; _paintMat.SetVector(_BrushA, new Vector4(uvA.x, uvA.y, 0f, 0f)); _paintMat.SetVector(_BrushB, new Vector4(uvB.x, uvB.y, 0f, 0f)); _paintMat.SetVector(_BrushParams, new Vector4(brushRadius, brushHardness, aspect, 0f)); Graphics.Blit(_rtFront, _rtBack, _paintMat); SwapBuffers(); } private void SwapBuffers() { var tmp _rtFront; _rtFront _rtBack; _rtBack tmp; if (_displayMat ! null) _displayMat.SetTexture(_EraseMask, _rtFront); } public void OnPointerDown(PointerEventData eventData) { if (_locked || _completed) return; if (!ScreenToUV(eventData.position, out var uv)) return; _prevUV uv; _hasPrev true; Paint(uv, uv); } public void OnDrag(PointerEventData eventData) { if (_locked || _completed) return; if (!ScreenToUV(eventData.position, out var uv)) return; if (!_hasPrev) { _prevUV uv; _hasPrev true; } Paint(_prevUV, uv); _prevUV uv; } public void OnPointerUp(PointerEventData eventData) { _hasPrev false; EvaluateProgress(); } private void EvaluateProgress() { if (_rtFront null || _rtSample null) return; Graphics.Blit(_rtFront, _rtSample); var prev RenderTexture.active; RenderTexture.active _rtSample; _readTex.ReadPixels(new Rect(0, 0, 48, 48), 0, 0, false); _readTex.Apply(false, false); RenderTexture.active prev; var pixels _readTex.GetPixels32(); int hit 0; for (int i 0; i pixels.Length; i) { if (pixels[i].r 128) hit; } float ratio (float)hit / pixels.Length; OnProgressChanged?.Invoke(ratio); if (!_completed ratio completeThreshold) { _completed true; OnCompleted?.Invoke(); } } public void ResetMask() { if (_rtFront null) return; ClearRT(_rtFront); ClearRT(_rtBack); _locked false; _completed false; _hasPrev false; if (_displayMat ! null) _displayMat.SetTexture(_EraseMask, _rtFront); OnProgressChanged?.Invoke(0f); } public void SetLocked(bool locked) _locked locked; }这段代码里我特意把_locked和_completed分开。_locked是外部主动锁比如弹了个对话框不希望用户继续擦_completed是达到阈值后的自动锁。两者语义不同混用会导致重置逻辑出错。3.3 两个 Shader画笔与显示画笔 shader 只做一件事读上一张遮罩在指定线段上盖一层笔迹输出新遮罩。Shader Hidden/UIEraseBrush { Properties { _MainTex (Source, 2D) black {} } SubShader { Cull Off ZWrite Off ZTest Always Blend Off Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _BrushA; // xy 起点 UV float4 _BrushB; // xy 终点 UV float4 _BrushParams; // x 半径, y 软边, z 宽高比 float SegmentDistance(float2 p, float2 a, float2 b) { float2 pa p - a; float2 ba b - a; float h saturate(dot(pa, ba) / max(dot(ba, ba), 1e-6)); return length(pa - ba * h); } fixed4 frag (v2f_img i) : SV_Target { fixed4 src tex2D(_MainTex, i.uv); // 把 UV 拉伸到等比空间避免非方形 UI 上笔刷变椭圆 float2 scale float2(_BrushParams.z, 1.0); float2 p i.uv * scale; float2 a _BrushA.xy * scale; float2 b _BrushB.xy * scale; float d SegmentDistance(p, a, b); float r _BrushParams.x; float soft max(_BrushParams.y, 0.0001); float inner r * (1.0 - soft); float brush 1.0 - smoothstep(inner, r, d); float v max(src.r, brush); return float4(v, v, v, 1.0); } ENDCG } } }这里的max(src.r, brush)是关键它保证笔迹是「叠加」而不是「覆盖」。如果写成return brush那每次绘制都会把之前画的全抹掉只有最后一笔能留下。显示 shader 负责把遮罩采样出来做 alpha 裁剪顺便加一圈高光边Shader UI/EraseReveal { Properties { [PerRendererData] _MainTex (涂层贴图, 2D) white {} _EraseMask (擦除遮罩, 2D) black {} _Invert (反向(擦掉消失), Range(0, 1)) 0 _EdgeWidth (边缘宽度, Range(0, 0.25)) 0.06 _EdgeColor (边缘颜色, Color) (1, 0.86, 0.45, 1) _EdgeIntensity (边缘强度, Range(0, 2)) 0.8 _StencilComp (Stencil Comparison, Float) 8 _Stencil (Stencil ID, Float) 0 _StencilOp (Stencil Operation, Float) 0 _StencilWriteMask (Stencil Write Mask, Float) 255 _StencilReadMask (Stencil Read Mask, Float) 255 _ColorMask (Color Mask, Float) 15 } SubShader { Tags { Queue Transparent IgnoreProjector True RenderType Transparent PreviewType Plane CanUseSpriteAtlas True } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_local _ UNITY_UI_CLIP_RECT #pragma multi_compile_local _ UNITY_UI_ALPHACLIP #include UnityCG.cginc struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPos : TEXCOORD1; }; sampler2D _MainTex; sampler2D _EraseMask; float _Invert; float _EdgeWidth; fixed4 _EdgeColor; float _EdgeIntensity; float4 _ClipRect; v2f vert (appdata_t v) { v2f o; o.worldPos v.vertex; o.vertex UnityObjectToClipPos(v.vertex); o.texcoord v.texcoord; o.color v.color; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 baseCol tex2D(_MainTex, i.texcoord) * i.color; float mask tex2D(_EraseMask, i.texcoord).r; float reveal lerp(mask, 1.0 - mask, step(0.5, _Invert)); // 在 mask 从 0 到 1 的过渡带里取最大值形成一圈描边 float edge saturate(mask * (1.0 - mask) * 4.0); edge * smoothstep(1.0 - _EdgeWidth, 1.0, mask _EdgeWidth); fixed3 rgb baseCol.rgb _EdgeColor.rgb * edge * _EdgeIntensity; float alpha baseCol.a * reveal; #ifdef UNITY_UI_CLIP_RECT alpha * UnityGet2DClipping(i.worldPos.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip(alpha - 0.001); #endif return fixed4(rgb, alpha); } ENDCG } } }这个 shader 里我加了UNITY_UI_CLIP_RECT和 Stencil 相关属性是因为擦除层很可能被放进RectMask2D或者Mask里。如果不加这两段一旦你的 Panel 外面套了个滚动列表或者圆角遮罩擦除层就会被裁得莫名其妙。这是 UGUI 自定义 shader 的通病照抄 UGUI 自带的UI/Default就是最省事的做法。_Invert参数用来切换两种语义0 表示「擦开显示」刮刮卡1 表示「擦掉消失」擦除确认。用step(0.5, _Invert)而不是直接乘是为了让这个参数在 Inspector 上表现成布尔开关的样子避免用户填 0.3 这种中间值导致效果不明确。4. 手感调优参数背后的数学关系代码能跑起来只是起点真正决定这个效果好不好用的是手感。而手感的调节不是玄学四个参数之间存在明确的数学关系理解了这个关系你就不用瞎试。4.1 分辨率、半径、软边三者不独立先明确三个量的定义maskResolution是遮罩 RT 的边长brushRadius是笔刷半径占 UI 高度的比例0 到 1 之间的小数brushHardness是软边占比。笔刷在 RT 上的实际像素半径等于brushRadius × maskResolution。假设maskResolution 512brushRadius 0.08那么笔刷像素半径就是 41 像素直径 82 像素。这个数字必须大于某个下限否则笔刷边缘会明显锯齿因为smoothstep的过渡带只有几个像素不足以做出平滑的羽化。软边的过渡带宽度是brushRadius × brushHardness × maskResolution单位是像素。还是上面的参数brushHardness 0.35过渡带就是 14 像素左右。如果这个值小于 3 像素视觉上就会看到硬边大于 30 像素笔迹会显得非常虚像在雾里画的。所以调参的思路是先定maskResolution一般取 UI 显示像素尺寸的一半左右再用brushRadius调笔迹粗细最后用brushHardness微调边缘。这里有个反直觉的地方RT 分辨率不需要等于显示尺寸。因为遮罩是双线性采样的512 的遮罩拉伸到 1024 的显示区域边缘只是稍微柔一点肉眼看不出区别。我实测过 512 和 1024 两档在同一块屏幕上的效果除非你把手机怼到脸上否则分辨不出来。而显存占用和每帧 Blit 的填充率开销差了 4 倍。遮罩分辨率显存典型用途笔刷半径建议256256 KB小尺寸控件 300px0.05 - 0.125121 MB全屏刮刮卡主流选择0.06 - 0.1010244 MB大屏 / 平板高清0.04 - 0.08还有一个容易忽略的点brushRadius用的是「占 UI 高度的比例」不是宽度。这意味着如果 UI 是个长条比如 800×200 的横条笔刷的实际视觉半径会显得偏大。这种情况下我建议改用短边作为基准也就是Mathf.Min(rect.width, rect.height)代码里把aspect的计算也同步改一下。4.2 边缘高光与擦除过渡边缘高光是提升「质感」性价比最高的一个操作加不加区别很大。用户擦的时候如果边缘有一圈微弱的暖色描边会觉得这个涂层是「有厚度」的而不是一张纸。实现方式见上面 shader 里的edge计算。这里我要说一下那个saturate(mask * (1.0 - mask) * 4.0)是怎么来的。这是一个开口向下的抛物线在mask 0.5时取到最大值 1在mask 0和1时都是 0。而mask在笔刷内部的取值是 1在外部是 0在边缘过渡带里从 1 平滑降到 0所以「1 和 0 的交界处」正好是mask ≈ 0.5的位置抛物线在这里取到峰值就得到了一条宽度恒定的描边。乘 4 是为了把峰值归一化到 1。第二条smoothstep(1.0 - _EdgeWidth, 1.0, mask _EdgeWidth)是我后来加的补丁用来压制笔刷内部的高光。因为不加的话mask在从 1 开始衰减的初期mask * (1-mask)就已经开始上升了会在笔迹内侧形成一圈很宽的光晕。加上这条限制高光就被收窄到真正靠近边界的那一带。_EdgeIntensity建议值 0.5 到 1.0。超过 1.5 会过曝特别是当_EdgeColor是白色的时候。我一般用暖黄强度 0.8配合灰色涂层效果最自然。4.3 手感细节清单笔刷起点要用PointerDown的位置不能用PointerUp。这个听起来像废话但我见过有人只在OnDrag里画导致轻点一下没反应。OnDrag的触发频率和帧率相关60 帧的机器上每帧最多一次120 帧的机器上翻倍。所以绘制逻辑必须能接受「两点之间距离任意远」这个前提线段 SDF 天然满足。brushRadius别设太小。小于 0.03 的时候即使 RT 是 1024笔迹也会有点发虚因为双线性采样在小范围内插值不够。软边不要让brushHardness接近 0。设成 0 的时候smoothstep的上下界重合会产生NaN或者硬切边我在 shader 里用max(_BrushParams.y, 0.0001)兜了底但你最好还是别去试探这个边界。拖动过程中如果 UI 有缩放动画UV 会算错。因为_rect.rect在动画期间是变化的。要在动画期间锁定擦除输入或者把 UV 换算放到动画结束之后再算。注意brushRadius是相对值maskResolution变化时笔刷的视觉大小不变但精度会变。如果你在运行时动态改了

相关推荐

Windows下Redis部署与优化全攻略
Windows下Redis部署与优化全攻略

1. Redis在Windows环境下的完整部署指南Redis作为一款高性能的键值数据库,在Windows平台上的部署过程与Linux环境存在显著差异。本文将详细介绍从零开始完成Redis在Windows系统中的安装、配置到可视化管理工具使用的全流程,包含多个实际部署中容易忽略的… · 2026/11/5 6:11:21

Python 300+配方知识地图:从语法到工程实战
Python 300+配方知识地图:从语法到工程实战

简介:《Python Cookbook: Everyone can cook delicious recipes 300》是Hernando Abella撰写、Aluna Publishing House于2024年出版的Python进阶参考读物,面向已掌握基础语法、希望系统提升编程能力的中级开发者与自学者,以300余个“食谱”式… · 2026/11/5 6:10:21

UCS1903S三色灯STC单片机驱动:时序、颜色表与多级变色
UCS1903S三色灯STC单片机驱动:时序、颜色表与多级变色

简介:这份资源面向使用STC15W系列8051内核单片机的电子开发者与LED照明爱好者,提供UCS1903S三色RGB灯的多色驱动方案,解决从单灯调色到多灯独立控制的编程问题。压缩包共16个文件、约40KB,以Keil工程文件为主,包含main… · 2026/11/5 6:10:21

C#桌面端数据可视化:74项环流指数趋势图实战
C#桌面端数据可视化:74项环流指数趋势图实战

简介:这份资源面向具备一定C#基础、希望切入数据可视化与气象数据分析的开发者,围绕74项环流指数(如北极涛动、南方涛动等)的变化趋势展示,提供一套可运行的Windows Forms示例工程,解决从数据读取到图表呈现… · 2026/11/5 8:13:01

UCOS-II多任务与系统时钟实战:嵌入式课程设计避坑指南
UCOS-II多任务与系统时钟实战:嵌入式课程设计避坑指南

简介:这份资源是面向嵌入式专业学生与初学者的UCOS-II课程设计完整工程,围绕多任务调度与系统时钟展开,可用于课程实验、自学练手或作为RTOS入门的参考项目。压缩包共63个文件、约283KB,以35个h头文件、3个c源文件为核心代码&… · 2026/11/5 8:12:00

阿童木机器人港股冲刺:服务机器人行业商业逻辑解析
阿童木机器人港股冲刺:服务机器人行业商业逻辑解析

1. 阿童木机器人港股冲刺背后的商业逻辑 阿童木机器人近期披露的招股书显示,这家服务机器人企业在9个月内实现了1.57亿元营收,但净利润仅有94万元。这个看似矛盾的数据背后,藏着服务机器人行业怎样的生存法则?作为联想基金和车好多… · 2026/11/5 8:12:00

冥府技术体系架构:业力计算与轮回调度系统解析
冥府技术体系架构:业力计算与轮回调度系统解析

1. 冥府技术体系架构解析 在冥府技术团队中,我们建立了一套融合硅谷工程文化与东方玄学智慧的技术架构。这套系统需要处理每秒数百万级的因果事件,同时保证六道轮回的绝对公平性。作为从阳间晋升而来的技术管理者,我深刻理解两个世界的技术差… · 2026/11/5 8:12:00

OpenClaw技能库:提升开发效率的即插即用解决方案
OpenClaw技能库:提升开发效率的即插即用解决方案

1. 项目概述OpenClaw "Skills军火库"是一个专注于收集和整理各类OpenClaw技能的精选资源库。这个项目类似于一个数字化的工具箱,里面装满了各种可以直接拿来使用的"技能武器"。作为一名长期关注自动化工具和效率提升的技术从业者,我… · 2026/11/5 8:12:00

小公司如何高效利用大模型API降低成本
小公司如何高效利用大模型API降低成本

1. 为什么小公司应该避开大模型训练微调 去年接触过一家20人规模的AI创业团队,他们花了三个月时间和80%的研发预算在LLaMA模型微调上,结果做出的客服机器人还不如直接调用GPT-3.5的API效果好。这个案例很典型——当前阶段,模型训练和微调就像… · 2026/11/5 8:12:00

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

多智能体协作系统架构设计与实践优化
多智能体协作系统架构设计与实践优化

1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39

大模型系统性入门实战指南:从原理到部署微调的全链路路线
大模型系统性入门实战指南:从原理到部署微调的全链路路线

提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39

鸿蒙文件系统沙箱机制解析与开发实践
鸿蒙文件系统沙箱机制解析与开发实践

1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码