# 黑板效果
在 Unity 中实现黑板的基本功能,包括画线和擦除。
由于 Unity 有更新间隔再加上输入设备也有回报率,我们能获得的只有移动轨迹中的离散点。画线的基本思想就是将这些离散的点连成一条线。连线的方法有很多种,其中最简单的做法就是将每帧获得的点与上一帧获得的点连成直线。最后再加上简单的边缘平滑就可以做到画线的效果了。
技术上使用 CSharp + RenderTexture + ComputeShader
# 代码
# 使用 Compute Shader 计算像素并画线
- 边缘平滑使用 smoothstep
- 用线性插值实现颜色平滑过度
- 这一帧的终点就是下一帧的起点,这会使线段中间那些点进行两次线性插值,从而出现凸起。为了消除这个凸起,可以在中间点进行线性插值时降低边缘平滑的长度
#pragma kernel CSMain | |
// 目标 RT | |
RWTexture2D<float4> _DrawingTex; | |
// 原贴图 RT | |
RWTexture2D<float4> _SourceTex; | |
// 局部更新的偏移量 | |
uint2 _Offset; | |
// 前二帧的坐标 | |
float2 _StartUV; | |
// 这一帧的坐标 | |
float2 _EndUV; | |
// 笔触半径 | |
float _BrushRadius; | |
// 笔触颜色 | |
float4 _BrushColor; | |
// 边缘平滑 | |
float _EdgeSoftness; | |
// 是否为擦除模式 | |
bool _IsEraser; | |
// 简单的边缘平滑 | |
float capsuleAA(float2 p, float2 a, float2 b) | |
{ | |
float2 ap = p - a; | |
float2 ab = b - a; | |
// 计算投影比例 | |
float h = saturate(dot(ap, ab) / dot(ab, ab)); | |
// 计算点到线段的最短距离 | |
float d = length(ap - ab * h); | |
// smoothstep(min, max, x) | |
//x < min 时 返回 0 | |
//x > max 时 返回 1 | |
//x 处于 [min, max] 时,返回 0 到 1 的平滑过渡 | |
float aa; | |
if (length(ap) < _BrushRadius + _EdgeSoftness) | |
{ | |
aa = 1 - smoothstep(_BrushRadius, _BrushRadius + _EdgeSoftness * 0.6, d); | |
} | |
else | |
{ | |
aa = 1 - smoothstep(_BrushRadius, _BrushRadius + _EdgeSoftness, d); | |
aa = aa * aa; | |
} | |
return aa; | |
} | |
// 着色 | |
void Draw(uint2 pixelPos, float aa) | |
{ | |
if (_IsEraser) | |
{ | |
_DrawingTex[pixelPos] = lerp(_DrawingTex[pixelPos], _SourceTex[pixelPos], aa); | |
} | |
else | |
{ | |
_DrawingTex[pixelPos] = lerp(_DrawingTex[pixelPos], _BrushColor, aa); | |
} | |
} | |
[numthreads(8, 8, 1)] | |
void CSMain(uint3 id : SV_DispatchThreadID) | |
{ | |
uint2 pixelPos = id.xy + _Offset; | |
uint width, height; | |
_DrawingTex.GetDimensions(width, height); | |
// 防止溢出贴图范围 | |
if (pixelPos.x >= width || pixelPos.y >= height) | |
{ | |
return; | |
} | |
float2 p = float2(pixelPos); | |
float2 a = _StartUV * float2(width, height); | |
float2 b = _EndUV * float2(width, height); | |
float aa = capsuleAA(p, a, b); | |
Draw(pixelPos, aa); | |
} |
# CSharp 实现参数传递
- 创建 RenderTexture 贴图
- 计算包围盒:仅更新线段周围的像素,这样可以降低性能消耗
using System; | |
using UnityEngine; | |
namespace Default | |
{ | |
/// <summary> | |
/// BlackboardWriterCS | |
/// </summary> | |
public class BlackboardWriter : MonoBehaviour | |
{ | |
[Tooltip("计算着色器")] | |
public ComputeShader blackboardCompute; | |
[Tooltip("材质")] | |
public Material blackboardMaterial; | |
[Tooltip("RT 大小")] | |
public int textureSize = 2048; | |
[Tooltip("笔刷颜色")] | |
public Color brushColor = Color.white; | |
[Tooltip("笔刷大小")] | |
public float brushRadius = 10; | |
[Tooltip("橡皮大小")] | |
public float eraserRadius = 30; | |
[Tooltip("边缘平滑")] | |
public float edgeSoftness = 4; | |
private Texture sourceMap; | |
private RenderTexture drawingRT; | |
private RenderTexture sourceRT; | |
private int kernelIndex; | |
private Vector2 startUV; | |
private bool isFirstFrame = true; | |
private void Start() | |
{ | |
drawingRT = new RenderTexture | |
( | |
textureSize, | |
textureSize, | |
0, | |
RenderTextureFormat.ARGB32, | |
RenderTextureReadWrite.sRGB | |
); | |
drawingRT.filterMode = FilterMode.Point; | |
drawingRT.enableRandomWrite = true; | |
drawingRT.useMipMap = false; | |
drawingRT.autoGenerateMips = false; | |
drawingRT.Create(); | |
sourceRT = new RenderTexture | |
( | |
textureSize, | |
textureSize, | |
0, | |
RenderTextureFormat.ARGB32, | |
RenderTextureReadWrite.sRGB | |
); | |
sourceRT.filterMode = FilterMode.Point; | |
sourceRT.enableRandomWrite = true; | |
sourceRT.useMipMap = false; | |
sourceRT.autoGenerateMips = false; | |
sourceRT.Create(); | |
sourceMap = blackboardMaterial.GetTexture("_BaseMap"); | |
// 将源贴图内容复制到 RT | |
// Blit 会触发一次 GPU 绘制 | |
// Texture -> 采样 -> shader -> RenderTexture | |
Graphics.Blit(sourceMap, drawingRT); | |
// CopyTexture 会直接复制像素,有严格的格式限制 | |
// Texture -> 直接显存复制 -> Texture | |
Graphics.CopyTexture(drawingRT, sourceRT); | |
kernelIndex = blackboardCompute.FindKernel("CSMain"); | |
blackboardCompute.SetTexture(kernelIndex, "_DrawingTex", drawingRT); | |
blackboardCompute.SetTexture(kernelIndex, "_SourceTex", sourceRT); | |
blackboardMaterial.SetTexture("_BaseMap", drawingRT); | |
} | |
private void Update() | |
{ | |
// 左键画,右键擦除 | |
if (Input.GetMouseButton(0) || Input.GetMouseButton(1)) | |
{ | |
HandleInput(); | |
} | |
else if (Input.GetMouseButtonUp(0) || Input.GetMouseButtonUp(1)) | |
{ | |
isFirstFrame = true; | |
} | |
} | |
private void OnDestroy() | |
{ | |
blackboardMaterial.SetTexture("_BaseMap", sourceMap); | |
} | |
private void HandleInput() | |
{ | |
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); | |
if (Physics.Raycast(ray, out RaycastHit hit)) | |
{ | |
if (hit.collider.gameObject == this.gameObject) | |
{ | |
Vector2 endUV = hit.textureCoord; | |
if (isFirstFrame) | |
{ | |
startUV = endUV; | |
isFirstFrame = false; | |
} | |
DrawArea(startUV, endUV); | |
this.startUV = endUV; | |
} | |
} | |
else | |
{ | |
isFirstFrame = true; | |
} | |
} | |
/// <summary> | |
/// DrawArea | |
/// </summary> | |
/// <param name="startUV"></param> | |
/// <param name="endUV"></param> | |
private void DrawArea(Vector2 startUV, Vector2 endUV) | |
{ | |
int texX = drawingRT.width; | |
int texY = drawingRT.height; | |
Vector2 a; | |
Vector2 b; | |
a.x = startUV.x * texX; | |
a.y = startUV.y * texY; | |
b.x = endUV.x * texX; | |
b.y = endUV.y * texY; | |
float offset; | |
if (Input.GetMouseButton(0)) | |
{ | |
offset = brushRadius + edgeSoftness + 1; | |
blackboardCompute.SetFloat("_BrushRadius", brushRadius); | |
blackboardCompute.SetBool("_IsEraser", false); | |
} | |
else | |
{ | |
offset = eraserRadius + edgeSoftness + 1; | |
blackboardCompute.SetFloat("_BrushRadius", eraserRadius); | |
blackboardCompute.SetBool("_IsEraser", true); | |
} | |
// 计算包围盒 | |
float minX = Mathf.Clamp(MathF.Min(a.x, b.x) - offset, 0, texX); | |
float minY = Mathf.Clamp(MathF.Min(a.y, b.y) - offset, 0, texY); | |
float maxX = Mathf.Clamp(MathF.Max(a.x, b.x) + offset, 0, texX); | |
float maxY = Mathf.Clamp(MathF.Max(a.y, b.y) + offset, 0, texY); | |
float rectWidth = maxX - minX; | |
float rectHeight = maxY - minY; | |
blackboardCompute.SetInts("_Offset", new int[] { (int)minX, (int)minY }); | |
blackboardCompute.SetVector("_StartUV", startUV); | |
blackboardCompute.SetVector("_EndUV", endUV); | |
blackboardCompute.SetVector("_BrushColor", brushColor); | |
blackboardCompute.SetFloat("_EdgeSoftness", edgeSoftness); | |
// 只需要覆盖 rectWidth * rectHeight 区域的线程组 | |
int groupsX = Mathf.CeilToInt(rectWidth / 8f); | |
int groupsY = Mathf.CeilToInt(rectHeight / 8f); | |
blackboardCompute.Dispatch(kernelIndex, groupsX, groupsY, 1); | |
} | |
} | |
} |
# 效果
- brushRadius = 10
- edgeSoftness = 4
![img]()
