{"id":450,"date":"2021-07-03T18:01:05","date_gmt":"2021-07-03T18:01:05","guid":{"rendered":"https:\/\/bronsonzgeb.com\/?p=450"},"modified":"2021-07-03T18:01:06","modified_gmt":"2021-07-03T18:01:06","slug":"a-simple-gpu-based-drawing-app-in-unity","status":"publish","type":"post","link":"https:\/\/bronsonzgeb.com\/index.php\/2021\/07\/03\/a-simple-gpu-based-drawing-app-in-unity\/","title":{"rendered":"A Simple GPU-based Drawing App in Unity"},"content":{"rendered":"\n<p>This week I&#8217;m escaping the complicated world of voxels for a bit to do something lighter. We&#8217;re building a simple GPU-based drawing app. What does that mean? A drawing app that runs entirely on the GPU using compute shaders. It&#8217;ll be just like Photoshop, except without all those complicated features like layers, undo, or a colour picker. Let&#8217;s go over the plan.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What&#8217;s the plan?<\/h2>\n\n\n\n<p>Our canvas will be a render texture that&#8217;s stretched across the screen. We&#8217;ll dispatch a compute shader kernel every frame, with a reference to our canvas and the current mouse position, among other things. We&#8217;ll dispatch one thread per pixel. So, each thread will compare its pixel position to the mouse position, taking into account the brush size. If it falls within the brush&#8217;s position and size, we&#8217;ll colour that pixel. So we&#8217;ll have a wonderfully parallel drawing app.<\/p>\n\n\n\n<p>Once we complete the first part, we&#8217;ll tackle simple brush position interpolation. The issue is the mouse can move a lot from one frame to the next. If we don&#8217;t interpolate the position, we&#8217;ll end up with several disconnected round brush strokes.<\/p>\n\n\n\n<p>Finally, I&#8217;ll demonstrate how we can add some GUI to control variables like the brush size. Let&#8217;s start.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Set up<\/h2>\n\n\n\n<p>Create a new compute shader. I called mine <code>Draw.compute<\/code> because I didn&#8217;t think too hard about a name. We&#8217;ll need two kernels, one to initialize the background and one to update every frame. We also need an <code>RWTexture&lt;float4&gt;<\/code> to act as our canvas. In case you forgot, that&#8217;s a writeable texture where each pixel is a <code>float4<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#pragma kernel Update\n#pragma kernel InitBackground\n\nRWTexture2D&lt;float4&gt; _Canvas;\n\n&#91;numthreads(8,8,1)]\nvoid InitBackground(uint3 id : SV_DispatchThreadID)\n{\n    _Canvas&#91;id.xy] = float4(0, 0, 0, 0);\n}\n\n&#91;numthreads(8,8,1)]\nvoid Update(uint3 id : SV_DispatchThreadID)\n{\n    \/\/TODO\n}<\/code><\/pre>\n\n\n\n<p>With the compute shader bones out of the way, let&#8217;s set up the C# script. Create a <code>MonoBehaviour<\/code> called <code>DrawManager.cs<\/code> to manage our compute shader. We&#8217;ll create a <code>RenderTexture<\/code> and assign it to canvas. We&#8217;ll also dispatch <code>InitBackground<\/code> on <code>Start<\/code> and <code>Update<\/code> on <code>Update<\/code>. We&#8217;ll also use <code>OnRenderImage<\/code> to display the canvas. The component has to be attached to a camera in the scene. The callback receives the camera&#8217;s frame buffer, and we&#8217;ll draw our canvas on top of it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public class DrawManager : MonoBehaviour\n{\n    &#91;SerializeField] ComputeShader _drawComputeShader;\n    RenderTexture _canvasRenderTexture;\n\n    void Start()\n    {\n        _canvasRenderTexture = new RenderTexture(Screen.width, Screen.height, 24);\n        _canvasRenderTexture.filterMode = FilterMode.Point;\n        _canvasRenderTexture.enableRandomWrite = true;\n        _canvasRenderTexture.Create();\n\n        int initBackgroundKernel = _drawComputeShader.FindKernel(\"InitBackground\");\n        _drawComputeShader.SetTexture(initBackgroundKernel, \"_Canvas\", _canvasRenderTexture);\n        _drawComputeShader.Dispatch(initBackgroundKernel, _canvasRenderTexture.width \/ 8,\n            _canvasRenderTexture.height \/ 8, 1);\n    }\n\n    void Update()\n    {\n            int updateKernel = _drawComputeShader.FindKernel(\"Update\");\n            _drawComputeShader.SetTexture(updateKernel, \"_Canvas\", _canvasRenderTexture);\n            _drawComputeShader.Dispatch(updateKernel, _canvasRenderTexture.width \/ 8,\n                _canvasRenderTexture.height \/ 8, 1);\n    }\n\n    void OnRenderImage(RenderTexture src, RenderTexture dest)\n    {\n        Graphics.Blit(_canvasRenderTexture, dest);\n    }\n}<\/code><\/pre>\n\n\n\n<p>With the boilerplate out of the way, we can start working.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Drawing<\/h2>\n\n\n\n<p>Let&#8217;s jump to the fun part and implement drawing right away. All we need to make this interactive is the current mouse position in the <code>Update<\/code> kernel. Add <code>float4 _MousePosition<\/code> and <code>bool _MouseDown<\/code> to the compute shader. We&#8217;ll use <code>_MouseDown<\/code> to prevent drawing when the mouse button is up. The <code>Update<\/code> kernel runs once per pixel, so the function must check if the current thread is close enough to the mouse position to be coloured. On that note, let&#8217;s add <code>float _BrushSize<\/code> to the compute shader as well.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>float4 _MousePosition;\nbool _MouseDown;\nfloat _BrushSize;\n\n&#91;numthreads(8,8,1)]\nvoid Update(uint3 id : SV_DispatchThreadID)\n{\n    if (!_MouseDown) return;\n\n    float2 pixelPos = id.xy;\n    float2 mousePos = _MousePosition.xy;\n    if (length(pixelPos - mousePos) &lt; _BrushSize)\n        _Canvas&#91;id.xy] = float4(1, 0, 0, 1);\n}<\/code><\/pre>\n\n\n\n<p>This block of code is everything newly added to the compute shader. As promised, in Update, we check the distance from the thread&#8217;s pixel to the mouse position and draw if necessary. Now you can draw pretty pictures like this.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"746\" src=\"https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/2021-07-02-13_21_18-Clipboard-1024x746.png\" alt=\"\" class=\"wp-image-447\" srcset=\"https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/2021-07-02-13_21_18-Clipboard-1024x746.png 1024w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/2021-07-02-13_21_18-Clipboard-300x218.png 300w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/2021-07-02-13_21_18-Clipboard-768x559.png 768w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/2021-07-02-13_21_18-Clipboard.png 1129w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p>Right now, if you draw slightly fast in the app, you&#8217;ll notice an issue. We have a series of dots rather than a single continuous stroke.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"739\" src=\"https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/DottedStroke-1024x739.png\" alt=\"\" class=\"wp-image-448\" srcset=\"https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/DottedStroke-1024x739.png 1024w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/DottedStroke-300x217.png 300w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/DottedStroke-768x555.png 768w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/DottedStroke.png 1126w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p>To fix that, we need the previous mouse position as well. Then, we&#8217;ll interpolate between the two points, drawing several points along the way. Let&#8217;s extract our brush into a function at the same time. I call the function <code>HardBrush<\/code>. In the future, we could follow the same template to create a soft brush, textured brush, patterned brush, etc. So in the previous version, we would check our position against a single mouse position. Now, we&#8217;re going to slowly sweep from the last position mouse to the new mouse position, checking our position against each point in the sweep.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>float4 HardBrush(float2 pixelPos, float4 currentColor, float4 brushColor, \n                 float brushSize, float2 previousMousePosition,\n                 float2 mousePosition, float strokeSmoothingInterval)\n{\n    for (float i = 0; i &lt; 1.0; i += strokeSmoothingInterval)\n    {\n        const float2 mousePos = lerp(previousMousePosition, mousePosition, i);\n        if (length(pixelPos - mousePos) &lt; brushSize)\n            return brushColor;\n    }\n\n    return currentColor;\n}<\/code><\/pre>\n\n\n\n<p>So the <code>HardBrush<\/code> function is the same as it was, but now with an added <code>for<\/code> loop. Let&#8217;s hook it up to the <code>Update<\/code> function.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>float4 _PreviousMousePosition;\nfloat _StrokeSmoothingInterval;\nfloat _BrushSize;\nfloat4 _BrushColour;\n\n&#91;numthreads(8,8,1)]\nvoid Update(uint3 id : SV_DispatchThreadID)\n{\n    if (!_MouseDown) return;\n    \n    _Canvas&#91;id.xy] = HardBrush(id.xy, _Canvas&#91;id.xy], _BrushColour, \n                               _BrushSize, _PreviousMousePosition,\n                               _MousePosition, _StrokeSmoothingInterval);\n}<\/code><\/pre>\n\n\n\n<p>You&#8217;ll notice there are new variables as well; they&#8217;re from the C# side. Later, I&#8217;ll share the entire C# side, but all of these variables are directly from the inspector.<\/p>\n\n\n\n<p>To finish, we&#8217;ll add a slider to control the brush size to demonstrate how to add GUI to our drawing app.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Tools GUI<\/h2>\n\n\n\n<p>First, add and position a slider in your scene. I&#8217;ll leave that part up to you. Next, create a new script to add to the slider called <code>BrushSizeSlider.cs<\/code>. We&#8217;ll use this script to keep track of whether the slider is currently in use. That way, we can stop drawing when manipulating the slider. Here&#8217;s the script.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>using UnityEngine;\nusing UnityEngine.EventSystems;\nusing UnityEngine.UI;\n\npublic class BrushSizeSlider : MonoBehaviour, IPointerUpHandler, IPointerDownHandler\n{\n    public bool isInUse;\n    public Slider slider;\n    \n    public void OnPointerUp(PointerEventData eventData)\n    {\n        isInUse = false;\n    }\n\n    public void OnPointerDown(PointerEventData eventData)\n    {\n        isInUse = true;\n    }\n}<\/code><\/pre>\n\n\n\n<p>So we set a flag when the slider is in use. Add a reference to the <code>BrushSizeSlider<\/code> in <code>DrawManager.cs<\/code> and check it on <code>Update<\/code>. If it&#8217;s in use, don&#8217;t dispatch the Update kernel. Also, set the min and max values on the slider to something reasonable, like 1 to 20. Finally, set the <code>_BrushSize<\/code> in the compute shader to the slider&#8217;s value. Now we have a simple GUI to control the brush size.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"698\" src=\"https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/BrushSizes-1024x698.png\" alt=\"\" class=\"wp-image-449\" srcset=\"https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/BrushSizes-1024x698.png 1024w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/BrushSizes-300x205.png 300w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/BrushSizes-768x524.png 768w, https:\/\/bronsonzgeb.com\/wp-content\/uploads\/2021\/07\/BrushSizes.png 1128w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p>Before wrapping up, here&#8217;s the entire <code>DrawManager.cs<\/code> file for reference.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>using UnityEngine;\n\npublic class DrawManager : MonoBehaviour\n{\n    &#91;SerializeField] ComputeShader _drawComputeShader;\n    &#91;SerializeField] Color _backgroundColour;\n    &#91;SerializeField] Color _brushColour;\n    &#91;SerializeField] float _brushSize = 10f;\n\n    &#91;SerializeField] BrushSizeSlider _brushSizeSlider;\n    &#91;SerializeField, Range(0.01f, 1)] float _strokeSmoothingInterval = 0.1f;\n    RenderTexture _canvasRenderTexture;\n\n    Vector4 _previousMousePosition;\n\n    void Start()\n    {\n        _brushSizeSlider.slider.SetValueWithoutNotify(_brushSize);\n\n        _canvasRenderTexture = new RenderTexture(Screen.width, Screen.height, 24);\n        _canvasRenderTexture.filterMode = FilterMode.Point;\n        _canvasRenderTexture.enableRandomWrite = true;\n        _canvasRenderTexture.Create();\n\n        int initBackgroundKernel = _drawComputeShader.FindKernel(\"InitBackground\");\n        _drawComputeShader.SetVector(\"_BackgroundColour\", _backgroundColour);\n        _drawComputeShader.SetTexture(initBackgroundKernel, \"_Canvas\", _canvasRenderTexture);\n        _drawComputeShader.SetFloat(\"_CanvasWidth\", _canvasRenderTexture.width);\n        _drawComputeShader.SetFloat(\"_CanvasHeight\", _canvasRenderTexture.height);\n        _drawComputeShader.GetKernelThreadGroupSizes(initBackgroundKernel,\n            out uint xGroupSize, out uint yGroupSize, out _);\n        _drawComputeShader.Dispatch(initBackgroundKernel,\n            Mathf.CeilToInt(_canvasRenderTexture.width \/ (float) xGroupSize),\n            Mathf.CeilToInt(_canvasRenderTexture.height \/ (float) yGroupSize),\n            1);\n        _drawComputeShader.Dispatch(initBackgroundKernel,\n            Mathf.CeilToInt(_canvasRenderTexture.width \/ (float) xGroupSize),\n            Mathf.CeilToInt(_canvasRenderTexture.height \/ (float) yGroupSize),\n            1);\n\n\n        _previousMousePosition = Input.mousePosition;\n    }\n\n    void Update()\n    {\n        if (!_brushSizeSlider.isInUse &amp;&amp; Input.GetMouseButton(0))\n        {\n            int updateKernel = _drawComputeShader.FindKernel(\"Update\");\n            _drawComputeShader.SetVector(\"_PreviousMousePosition\", _previousMousePosition);\n            _drawComputeShader.SetVector(\"_MousePosition\", Input.mousePosition);\n            _drawComputeShader.SetBool(\"_MouseDown\", Input.GetMouseButton(0));\n            _drawComputeShader.SetFloat(\"_BrushSize\", _brushSize);\n            _drawComputeShader.SetVector(\"_BrushColour\", _brushColour);\n            _drawComputeShader.SetFloat(\"_StrokeSmoothingInterval\", _strokeSmoothingInterval);\n            _drawComputeShader.SetTexture(updateKernel, \"_Canvas\", _canvasRenderTexture);\n            _drawComputeShader.SetFloat(\"_CanvasWidth\", _canvasRenderTexture.width);\n            _drawComputeShader.SetFloat(\"_CanvasHeight\", _canvasRenderTexture.height);\n\n            _drawComputeShader.GetKernelThreadGroupSizes(updateKernel,\n                out uint xGroupSize, out uint yGroupSize, out _);\n            _drawComputeShader.Dispatch(updateKernel,\n                Mathf.CeilToInt(_canvasRenderTexture.width \/ (float) xGroupSize),\n                Mathf.CeilToInt(_canvasRenderTexture.height \/ (float) yGroupSize),\n                1);\n        }\n\n        _previousMousePosition = Input.mousePosition;\n    }\n\n    void OnRenderImage(RenderTexture src, RenderTexture dest)\n    {\n        Graphics.Blit(_canvasRenderTexture, dest);\n    }\n\n    public void OnBrushSizeChanged(float newValue)\n    {\n        _brushSize = newValue;\n    }\n}<\/code><\/pre>\n\n\n\n<p>So there&#8217;s our simple drawing app. I hooked up some variables behind the scenes. I also added some bounds checks to make sure we don&#8217;t try to draw outside the canvas. I also added an early exit on the compute shader side, so I&#8217;ll share that entire file for reference. By the way, the Github project is linked at the end, it&#8217;s easier to read the code there if you can.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#pragma kernel Update\n#pragma kernel InitBackground\n\nRWTexture2D&lt;float4&gt; _Canvas;\nfloat _CanvasWidth;\nfloat _CanvasHeight;\nfloat4 _PreviousMousePosition;\nfloat4 _MousePosition;\nfloat _StrokeSmoothingInterval;\nbool _MouseDown;\nfloat _BrushSize;\nfloat4 _BrushColour;\nfloat4 _BackgroundColour;\n\nfloat4 HardBrush(float2 pixelPos, float4 currentColor, float4 brushColor, float brushSize, float2 previousMousePosition,\n                 float2 mousePosition, float strokeSmoothingInterval)\n{\n    for (float i = 0; i &lt; 1.0; i += strokeSmoothingInterval)\n    {\n        const float2 mousePos = lerp(previousMousePosition, mousePosition, i);\n        if (length(pixelPos - mousePos) &lt; brushSize)\n            return brushColor;\n    }\n\n    return currentColor;\n}\n\n&#91;numthreads(8,8,1)]\nvoid InitBackground(uint3 id : SV_DispatchThreadID)\n{\n    if (id.x &gt;= _CanvasWidth || id.y &gt;= _CanvasHeight)\n        return;\n\n    _Canvas&#91;id.xy] = _BackgroundColour;\n}\n\n&#91;numthreads(8,8,1)]\nvoid Update(uint3 id : SV_DispatchThreadID)\n{\n    if (!_MouseDown)\n        return;\n\n    if (id.x &gt;= _CanvasWidth || id.y &gt;= _CanvasHeight)\n        return;\n\n    _Canvas&#91;id.xy] = HardBrush(id.xy, _Canvas&#91;id.xy], _BrushColour, \n\t                       _BrushSize, _PreviousMousePosition, \n                               _MousePosition, _StrokeSmoothingInterval);\n}<\/code><\/pre>\n\n\n\n<p>The entire compute shader is under 40 lines of code, which is pretty cool if you consider that it&#8217;s the bulk of the functionality. The whole project is around 100 lines of code, which is also impressively small, I think.<\/p>\n\n\n\n<p><strong>See the finished project&nbsp;<\/strong><a rel=\"noreferrer noopener\" target=\"_blank\" href=\"https:\/\/github.com\/bzgeb\/UnitySimpleGPUDrawingApp\"><strong>here<\/strong><\/a><strong>&nbsp;on GitHub. If you&#8217;re enjoying my work,&nbsp;<\/strong><a rel=\"noreferrer noopener\" target=\"_blank\" href=\"https:\/\/bronsonzgeb.com\/index.php\/join-my-mailing-list\/\"><strong>join my mailing list<\/strong><\/a><strong>&nbsp;to be notified whenever a new post is released.<\/strong><\/p>\n","protected":false},"excerpt":{"rendered":"<p>This week I&#8217;m escaping the complicated world of voxels for a bit to do something lighter. We&#8217;re building a simple GPU-based drawing app. What does that mean? A drawing app that runs entirely on the GPU using compute shaders. It&#8217;ll be just like Photoshop, except without all those complicated features like layers, undo, or a [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":447,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[25,1],"tags":[26,36,5],"class_list":["post-450","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-compute-shaders","category-unity-programming","tag-compute-shaders","tag-drawing","tag-unity"],"_links":{"self":[{"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/posts\/450","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/comments?post=450"}],"version-history":[{"count":2,"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/posts\/450\/revisions"}],"predecessor-version":[{"id":452,"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/posts\/450\/revisions\/452"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/media\/447"}],"wp:attachment":[{"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/media?parent=450"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/categories?post=450"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/bronsonzgeb.com\/index.php\/wp-json\/wp\/v2\/tags?post=450"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}