Unity筆刷技術的突破與實踐洞察:從SetPixels到ComputeShader
在 Unity 開發筆刷系統的過程中,我發現網路上對於「筆刷原理」的深度討論相當稀少。許多開發者習慣直接使用引擎內建工具,很少去探究背後的底層機制。事實上,唯有真正掌握筆刷的運作原理,才能突破現有工具的限制,打造完全符合自身需求的自訂筆刷。這也是我撰寫這篇文章的初衷 — —…
Unity筆刷技術的突破與實踐洞察:從SetPixels到ComputeShader

在 Unity 開發筆刷系統的過程中,我發現網路上對於「筆刷原理」的深度討論相當稀少。許多開發者習慣直接使用引擎內建工具,很少去探究背後的底層機制。事實上,唯有真正掌握筆刷的運作原理,才能突破現有工具的限制,打造完全符合自身需求的自訂筆刷。這也是我撰寫這篇文章的初衷 — — 希望分享我的技術探索與實踐心得,幫助更多人理解並實現屬於自己的筆刷系統。
SetPixels32筆刷:從CPU搬資料到GPU
先從最簡單的SetPixels32說起。相信有做過戰爭迷霧經驗的人應該對他很熟悉,或許有些人會認為使用Graphics.CopyTexture效能更好,但因為筆刷通常需要支援任意大小,這類方法其實並不適合。
下面這段程式碼,就是典型的 SetPixels32 實作:遍歷筆刷覆蓋範圍內的每個像素,根據距離與權重計算顏色,最後一次性 Apply 到 GPU。
var destColor = new Color32(0, 0, 0, 0);
for (int rowIndex = startRowIndex; rowIndex<=endRowIndex;
++rowIndex)
{
for (int columnIndex = startColumnIndex;
columnIndex<=endColumnIndex; ++columnIndex)
{
Vector2 gridCenter = new Vector2(
worldMin.x + columnIndex * gridSize.x+
halfGridSize.x,
worldMin.y + rowIndex * gridSize.y+
halfGridSize.y);
int index = columnIndex + rowIndex * mapWidth;
float sqrDistance = (brushCenter - gridCenter).
sqrMagnitude;
sqrDistance = Mathf.Clamp(sqrDistance, sqrhalfBrushInnerSize, sqrhalfBrushSize);
float weightRatio = 1.0f - (sqrDistance - sqrhalfBrushInnerSize) /
(sqrhalfBrushSize - sqrhalfBrushInnerSize);
destColor = editColorBuffer[index];
ProcessPixelColor(weightRatio, speed * 0.033f, isAdd, sqrDistance, sqrhalfBrushSize,
in mouseDelta, ref destColor);
editColorBuffer[index] = destColor;
}
}
editTexture.SetPixels32(editColorBuffer);
editTexture.Apply();
這種做法的原理很簡單:就是用 CPU 逐像素處理整個筆刷區塊,處理完後再一次把整張貼圖資料送回 GPU。這會造成大量的 CPU → GPU 資料傳輸,貼圖越大或同時處理多張時,效能瓶頸就會非常明顯。對於需要即時回饋、手感細膩的筆刷操作來說,這種方法往往無法滿足需求。
Fragment Shader 筆刷:運算搬到 GPU
在瞭解 SetPixels32 的效能瓶頸後,另一個筆刷優化方式,就是將像素處理的工作從 CPU 搬到 GPU,也就是利用 Fragment Shader 來實現筆刷效果,這樣可以大幅減輕 CPU 傳資料到GPU的負擔,讓每個像素的運算都由 GPU 高效並行處理。
下面這段 Shader 代碼,就是將筆刷範圍內像素的權重與顏色計算,全部交由 Fragment Shader 處理:
bool GetBltTextureColorAndBrushWeight(in VertexOutput input,
out half brushWeight, out half4 bltTextureColor)
{
half2 dsetUV = input.screenPosition.xy / input.screenPosition.w;
bltTextureColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex,
input.screenPosition.xy / input.screenPosition.w);
uint rowIndex = _TextureArrayIndex / 2;
uint columnIndex = _TextureArrayIndex % 2;
float2 wordPosition2D = float2((columnIndex + dsetUV.x) * _BrushInfo.z,
(rowIndex + dsetUV.y) * _BrushInfo.w);
float distance = length(wordPosition2D - _BrushTransform.xy);
if (distance > _BrushTransform.w)
return false;
distance = clamp(distance, _BrushTransform.z, _BrushTransform.w);
brushWeight = (1.0 - (distance - _BrushTransform.z) / _BrushInfo.y) *
unity_DeltaTime.z * _BrushInfo.x * sc_weightApplyRatio;
return true;
}
這段程式主要做了兩件事:
- 根據螢幕座標計算當前像素在世界空間的位置,進而判斷它是否落在筆刷半徑範圍內。
- 若在範圍內,計算權重(brushWeight),用於決定筆刷對該像素的影響程度。
優點
- 完全在 GPU 處理:不再需要將像素資料從 CPU 傳回 GPU,效能大幅提升,特別適合高解析度或多重筆刷操作。
- 即時反應:能提供更即時、更細膩的筆刷手感。
缺點
- 需要遍歷整張 RenderTexture:即使筆刷只覆蓋一小塊範圍,Fragment Shader 仍然會處理整張貼圖,造成不必要的運算浪費。
- Input/Output RenderTarget 限制:在處理時,輸入和輸出 RenderTarget 不能是同一張貼圖,所以每次繪製都必須先將原本的內容拷貝(Blt)到另一張 RenderTexture,再進行處理,這會增加一次額外的 GPU Pass。
- 多重目標更複雜:如果要同時對 TextureArray 進行筆刷操作,管理和效率問題會更加明顯。
PS:Unity官方的Terrain的筆刷也是採用這種方式,可以參考這個連結:
[embed][com.unity.terrain-tools/Shaders/PaintTexture.shader at master ·… Mirrored from UPM, not affiliated with Unity Technologies.] 📦 The Terrain Tools package adds additional Terrain…github.com](https://github.com/needle-mirror/com.unity.terrain-tools/blob/master/Shaders/PaintTexture.shader)
Compute Shader 筆刷:OnePass 高效處理,TextureArray 也難不倒
在理解了 Fragment Shader 在 Input/Output RenderTarget 上的限制後,我們進一步發揮 Compute Shader 的特性,有效解決了這些瓶頸:
- Input 可以等於 Output 直接在同一張 RenderTexture 上進行讀寫,無需額外 Blit 拷貝,大幅減少資源消耗與延遲。
- 只需處理筆刷範圍像素 不必遍歷整張 RenderTexture,而是精準針對筆刷影響範圍進行運算,效能提升顯著。
- 完整支援 TextureArray 可以直接對 TextureArray 進行讀寫,輕鬆應對多層貼圖的高階需求,彈性與擴展性更高。
這些優勢讓 Compute Shader 筆刷在效能與功能上,都遠超傳統 Fragment Shader 筆刷,實現真正的 OnePass 高效繪製。
以下這段 Compute Shader 代碼正好展現了這些特點:
- Input 就是 Output:直接在目標貼圖上進行讀寫。
- 只處理局部範圍:僅針對筆刷覆蓋的 Rect 區域進行運算。
[numthreads(8, 8, 1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
if ((id.x >= _BrushPixelsColumnCount) ||
(id.y >= _BrushPixelsRowCount))
return;
TextureIndex textureIndex;
half brushWeight;
if (!GetDestPixelWeight(id.xy, textureIndex, brushWeight))
return;
SetEditTextureValue(textureIndex, half4(_TintColor,
saturate(GetEditTextureValue(textureIndex).a + brushWeight)));
}
如上所示,Compute Shader 只針對筆刷影響範圍(由 _BrushPixelsColumnCount、_BrushPixelsRowCount 控制)進行運算,並且直接在目標貼圖(可能是 TextureArray)上讀寫,充分發揮 OnePass 與高效的優勢。其中,TextureIndex 指的是 TextureArray 的索引。
這邊再整理一下三個方案比較:
SetPixels32 筆刷
- 優點:實作簡單,容易理解。
- 缺點:大量 CPU→GPU 資料傳輸,效能瓶頸明顯。
Fragment Shader 筆刷
- 優點:運算全部交給 GPU,效能提升,即時反應佳。
- 缺點:必須遍歷整張貼圖,運算浪費;Input/Output RenderTarget 有限制。
Compute Shader 筆刷
- 優點:只處理筆刷影響範圍,Input 就是 Output,支援 TextureArray,效能與彈性最佳。
- 缺點:實作較複雜,需要理解 GPU 並行運算。
在這三種方案的比較中,Compute Shader 筆刷展現的並不只是效能上的優勢,而是一種更 乾淨、直接、優雅 的解法。 它讓筆刷不再是資料搬運與反覆拷貝,而是 GPU 在畫布上即時創作的過程。
結論:這樣的轉變,帶來的不是單純的「更快」,而是一種全新的體驗:
- 美術能感受到筆刷如同真實畫筆般的即時回饋。
- 工程師能以更簡潔的邏輯,實現複雜的筆刷需求。
- 專案開發則獲得一個能應對大場景、多層貼圖的高效解法。
메타데이터
- post_id
- 09be5a2b185c
- slug
- unity筆刷技術的突破與實踐洞察-從setpixels到computeshader-09be5a2b185c
- url
- https://medium.com/@pioneering_catawba_cheetah_996/unity%E7%AD%86%E5%88%B7%E6%8A%80%E8%A1%93%E7%9A%84%E7%AA%81%E7%A0%B4%E8%88%87%E5%AF%A6%E8%B8%90%E6%B4%9E%E5%AF%9F-%E5%BE%9Esetpixels%E5%88%B0computeshader-09be5a2b185c
- canonical_url
- https://medium.com/@pioneering_catawba_cheetah_996/unity%E7%AD%86%E5%88%B7%E6%8A%80%E8%A1%93%E7%9A%84%E7%AA%81%E7%A0%B4%E8%88%87%E5%AF%A6%E8%B8%90%E6%B4%9E%E5%AF%9F-%E5%BE%9Esetpixels%E5%88%B0computeshader-09be5a2b185c
- author_url
- https://medium.com/@pioneering_catawba_cheetah_996
- status
- ok
- fetched_at
- 2026-06-26 08:21:59