Contents
前言:畫猜玩家的去路
每隔一段時間,俺都要變成群友 sena 的形狀,無端開始回想大山裏的往事,然後進行無病呻吟。而現在時候已到,俺要開始例行公事了——
俺回想起俺第一次抓起畫筆、參加畫猜,那居然已經是在遙遠的 2023 年初。彼時 sena 的一句 👉🏻威逼利誘 居然給我成就了一個持續了三年、從未間斷的高雅愛好。
4000 多張 (高雅?) 作品,是俺和群友們孳孳不倦地研究美少女的足跡,而每週六的晚上是俺最珍惜的時光,,,


另一方面,雖然道中坎坷,俺也在磕磕絆絆地繼續着繪畫的學習。今年 3 月,一位群友告訴我,著名業界賣課專家 👉🏻Krenz 即將開辦一個爲期 6 週的人物速寫訓練營,學費約 400 RMB。俺打開教學大綱一看:
- 抓型
- 衣服褶皺
- 面部
- 手腳
- 頭髮
- 綜合
好傢伙,畫猜玩家的痛点遭到迎頭痛擊。啃輪子老師,妳是不是在我身上裝了監控?如果不是,那妳是怎麼知道俺不會畫這些的??
除了繪畫技巧,該課程的最大價值其實是爲畫畫不得勁的群友們提供的緊急心肺復甦服務:
環境,會比意志力更能幫妳動筆 💪🏻
① 3 billon 群友 play 畫猜
② 每週固定活動
③ 真人監督制度自己一個人畫畫是非常孤獨的,而一群人一起畫畫將是非常狂熱的!
就衝着情緒價值這塊,俺也要 shut up and take my money!!
Sena 模式發病完畢。由於這門課到直到目前還沒結束,還輪不到我來發表高論——所以這篇文章的主題暫且放在俺的戰損老數位板上 😘
戰損老數位板
俺的數位板是一塊繪王 Q620M,原價 500 人民幣,於 2023 年 6 月使用亞馬遜積分零元購入手,替換掉了當時的 Wacao CTL-672。繪王 Q620M 的配置放在當時是遙遙領先的存在,停泊航母的 15 吋面板,多達 8 個自定義按鍵,附帶一個暈頭轉向的滾輪,還支持 2.4G 無線連結。畫筆是一支 8192 級壓感的 PW500,支持傾斜角度識別。

俺覺得這個滾輪是整個板子的點睛之筆。畫布的縮放旋轉等等原本需要組合按鍵和手勢的操作,有了滾輪之後,用一根指尖就能完成。區區一點點硬件改進,造就了用一次爽一次的用戶體驗。俺覺得俺再也回不去用 Wacao 了。
唯一美中不足的是,Q620M 附帶的畫筆並沒有採用第一梯隊的硬筆技術。下筆時,筆尖的伸縮略爲明顯,雖說也不影響使用,但是卻失去了一點高級感,相比於 Wacao 屬於是反向升級了。
之後俺從繪王淘寶店得知,近期銷售的 Q620M 出廠即自帶 PW517 硬筆,俺的 Q620M 不幸屬於老版本。爲了彌補這個遺憾,俺又斥巨資網購了一支 PW517 回來,沒想到它根本就不兼容俺的板子。再次諮詢繪王淘寶店,答曰要返廠刷固件 😅

看到這裏可能就有群友要問了:妳不是很董逆向嗎?妳 IDA 開起來,幫他們改改固件豈不是輕輕鬆鬆?妳說得很對,但是俺花了 500 多買了個板子,還要無償幫他修固件 bug?那我還用不用畫畫了?
先將就着畫吧!於是俺把 PW517 收進了抽屜,用軟綿綿的 PW500 就這麼畫了三年。現在回頭看,俺當時是真有耐心啊,大學生時間就是多,居然能畫出這種畫:

……用色的準確性還有待提升。但是現在的俺別說畫背景了,給角色上色都費勁。俺最近的畫是這樣的:

所以,論勤奮這點俺還是遠遠比不上群友 @limiao,她每一張圖都能從頭畫到尾,從塑造到上色,十年如一日潛心鑽研各種實踐。要是俺有這股子鑽勁,俺還是一個區區的畫猜玩家嗎??
問題的出現
爲了繼續提高畫猜水平,俺參加了上述的速寫(慢寫)班開始練習。但是最近俺的 Q620M 出了一點狀況,在少數情況下,畫筆筆尖的定位會漂移,突然在畫布上拉出一條直線:

經過觀察,這種現象多出現於塗抹的時候,因此俺覺得其原因大概率是筆壞了,中概率是繪王通病,小概率是板子壞了。可是板子要是真壞了俺也沒法立刻換屆,只能繼續連任。此時俺突然想起了「981 首長健康工程」,這是全世界最先進的首長醫療網路,而牠們的做法也是力大磚飛:零件壞了?換!
俺又想起了那支塵封了三年的 PW517 畫筆,備用的零件竟然就在我身邊!馬上進行一個緊急提取……
一通操作過後,不出所料,配型失敗了,還出現了排異反應。具體表現爲板子會把懸停的筆尖識別成按下,然而筆尖真按下後,輸出的壓感數值反而會隨着力道增加而變小,最用力的時候會直接識別爲懸停。
畫筆操作和壓感輸出的關係、301 院 ICU 的觀察結論如下圖所示(
這個問題看似玄之又玄,但是俺卻胸有成竹,因爲俺知道著名開源數位板驅動 OpenTabletDriver 提供了直接分析數位板讀數的 debug 工具。這點數據格式上的問題,俺相信用進口藥肯定能輕鬆秒殺。
打開調試器,揮舞一下老畫筆,收集得正常情況下數據格式如圖:
靜止的鼠標指針代表畫筆不在探測範圍內,此時數位板沒有上報任何讀數。
移動的鼠標指針代表畫筆懸停和移動,此時數位板開始上報讀數,但由於此時筆尖並未接觸表面,壓感讀數 (Pressure) 爲 0。
而後,鼠標指針上的圓圈代表筆尖已經按下,此時可以觀察到 Pressure 的變化,下筆由輕到重帶來的壓感讀數變化的範圍是 0~8191。
右邊的 Raw Tablet Data 的各個 byte 含義如圖所示:

其中:
- 第一個 byte (🟧橙色) 爲狀態。
00代表畫筆離開,80代表懸停,81代表按下。 - 第 6~7 個 byte (🟥紅色) 爲壓感數值。
FF 1F(0x1FFF) 是 8191.
而換用 PW517 新筆之後,再次重複「離開 - 懸停 - 按壓 - 放鬆 - 懸停 - 離開」的過程時,數位板輸出的數據是這樣的:
觀察 Pressure 讀數可見,在筆尖進入懸停範圍的一瞬間,雖然還沒發生接觸,壓感就已經從 0 直接跳到了 8191。而當筆尖發生接觸,且下筆力度逐漸加大的時候,壓感反而從 8191 開始遞減,直到變爲 0。此時驅動將這種情況識別爲了懸停(圓圈消失了)。
根據以上觀察,俺發現了兩個事實:
- 在 OTD 中,按下與否的判定僅僅取決於壓感的數值,大於 0 則視爲按下,它不關心狀態 byte 爲何。
- 座標和傾斜角的識別是正常的。
至此,俺可以歸納出兩支畫筆的時間輸出曲線:
要讓移植的零件 PW517 正常工作,俺需要將它發送的數據轉換爲正確的語義。
OpenTabletDriver 插件系統
修改數位板數據的方法有二,第一種是直接給板子刷固件,可惜繪王啊繪王,妳事閉源 lib 大王;而第二種就是藉助 OpenTabletDriver 的開源 lib 來修改驅動程序了。在俺的印象中,OTD 最開始是一群 osu! 玩家爲了給 Linux 做數位板適配而發起的項目。之後項目逐漸壯大、功能逐漸豐富,支持的設備也越來越多。截至目前,OTD 支持了 Wacao 和繪王的所有板子。

值得稱讚的是,OTD 在最新版本中添加了對 Q620M 滾輪的支持,也支持了躺在我購物車中的 Q630M。雖然目前 Q630M 的滾輪還是 unsupported,俺相信要做適配也是易如反掌的事情。
不過俺要做的並不是從零開始適配一個全新的板子,俺只需要對上報的數據進行修正即可。幸運的是 OTD 內置了一個插件系統,架構如圖所示。插件會介入 OTD 的數據處理流水線,從而具備直接修改板子數據的能力。

利用這個系統,俺可以方便地 (?) 在不進行大改的前提下進行數據清洗和二次開發。OTD 還內置了一個插件商店,裏面的插件都是開源 lib,任我抄。

不廢話了,數據處理插件的第一版,直接上代碼:
using OpenTabletDriver.Plugin.Attributes;
using OpenTabletDriver.Plugin.Tablet;
using OpenTabletDriver.Plugin.Output;
using OpenTabletDriver;
namespace PW517Filter
{
[PluginName("PW517 For Legacy Q620M")]
public class PW517Filter : IPositionedPipelineElement<IDeviceReport>
{
public PipelinePosition Position => PipelinePosition.PreTransform;
public event Action<IDeviceReport>? Emit;
public void Consume(IDeviceReport report)
{
if (report is ITabletReport tabletReport)
{
byte status = tabletReport.Raw[1];
if (status == 0x80 || status == 0x81)
{
tabletReport.Pressure = 8191 - tabletReport.Pressure;
}
}
Emit?.Invoke(report);
}
}
}這裏的算法也是簡單粗暴,直接通過 8191 - pressure 來強行反轉壓感輸出曲線。俺相信這麼一個微小的補丁能一舉掃清俺的障礙。

俺美滋滋地 dotnet build,加載了這個插件,然後開始天真地以爲零件移植可以一發入魂……太天真了,俺當時還沒意識到繪王往他們的板子裏面加了多少狠活。
問題變得嚴肅……
在測試的時候俺發現,雖然這個插件成功反轉了壓感數值,在一定程度上還原了 PW517 的功能,但是在畫筆進入懸停範圍的一瞬間,還是出現了誤識別爲按下的情況。這種誤觸時間極短,只有十幾 ms。在使用 debugger 記錄了壓感的讀數曲線之後,俺觀察到畫筆進入懸停的一刻意外出現了一個小尖峰:
按照俺的理解,PW517 在進入懸停範圍的時候,會發生兩件事情:
- 狀態 byte 從
00(離開) 變成81(接觸) - 壓感讀數從 0 跳變成 8191
而俺的插件幹的事情正是在檢測到接觸狀態的時候,把壓感讀數從 8191 反轉到 0。那麼問題究竟出在哪裏呢?
多虧了多年的畫猜經驗,俺敏銳地意識到了一個細節:這個數位板自帶硬件防抖。
剛接觸電繪的新玩家都會遭遇到一個問題:畫線手抖。這個問題可以通過練習解決,不過對於新玩家來說最方便的方法還是打開繪圖軟件的畫筆穩定器。而值得注意的其實是絕大多數數位板都內置了硬件實現(或者固件實現)的畫筆穩定器。具體做法就是給畫筆座標、壓力和傾斜角等讀數套一個 👉🏻滑動平均濾波器。
受限於嵌入式設備的計算性能,其算法必定也只是粗茶淡飯,所以防抖效果……只能說聊勝於無吧。
但是這個硬件穩定器的存在會給俺的驅動帶來一個問題,畫筆在進入懸停範圍的時候,壓感讀數不再是從 0 直接跳變到 8191,而是會先拋出幾個中間值,比如說 2000, 4000, 6000,最後才達到 8191。這些中間插值的存在會導致在一個十幾 ms 的時間窗口之內,就算經過反轉處理,壓感讀數也不會爲 0,從而造成極其短暫的誤觸現象。
同理,在畫筆離開懸停區域的時候,大概也會有一個相同的跳變和誤觸現象 😇
屎山代碼的威力超乎我想象了。要讓零件動起來,俺必須要搭建一個能把這些垃圾數據洗得一乾二淨的算法。
流式數據處理的編程範式
至此,問題已經明確,讓我來整理一下眼前的挑戰:
- PW517 畫筆會將懸停時的壓感輸出爲 8191,用力按下時壓感卻會遞減到 0;
- 俺的主要目標是顛倒壓感數據曲線,即把 8191 轉換成 0,把 0 轉換成 8191;
- 在畫筆進入懸停區域時,壓感讀數輸出會有一個從 0 逐漸上升到 8191 的過程;
- 在畫筆離開懸停區域時,壓感讀數輸出會有一個從 8191 逐漸下降到 0 的過程;
針對這些挑戰,俺的算法必須要做到:
- 在畫筆進入懸停區域時,觀察壓感輸出,把這個逐漸上升的過程中的讀數強制輸出爲 0 來避免誤觸;
- 在壓感輸出穩定在 8191 之後,開始進行反轉數據的工作;
- 在畫筆離開懸停區域時,觀察壓感輸出,同樣把出現的尖峰強制輸出爲 0。
這三句話聽上去平平無奇,但是細思極恐。這裏的「上升過程」和「穩定以後」要該怎麼判定,「觀察壓感輸出」要該怎麼實現呢?而且,爲了不給數位板造成過大的延遲,算法必須實時輸出數據,不能使用憋一波大的再批量處理的手法。
能滿足這些需求的編程範式究竟是……
三步之內必有解藥——上核彈,ReactiveX!!

ReactiveX (簡稱 Rx) 是微軟在 2011 年推出的庫,主打功能是事件流處理。經過多年的發展,Rx 已經被移植給了幾乎所有的編程語言。因此俺們有 RxJS, RxJava, RxPY,以及本次的主角:Rx.NET。
要說 Rx 最特別的地方莫過於其編程範式。Rx 是面向時間編程,在哲學上和面向對象、面向過程已經分道揚鑣。Rx 在業界中最常見的應用場景就是處理 UI 上發生的各種事件,而這次俺涉足的是一個比較小衆的領域:實時處理傳感器數據流。
俺想請妳再次細品一下上面那張 Rx 主頁的背景圖,它完美暗示了 Rx 的看家本領——流式數據的實時處理,面向時間編程,成爲時間領主。不過俺這裏並不想死摳 Rx 的語法,這不符合本頻道的風格。比起羅列代碼,俺更喜歡講原理。
現在請妳忘記所有代碼,先跟我考慮以下場景:
俺在板子上揮了一筆,筆尖經歷了進入懸停,按下,用力,放鬆,懸停,離開的這一完整過程。
然後板子向電腦上報了一些數據,它們的先後順序如下。
其中,X 軸表示時間,Y 軸表示板子輸出的壓感強度。最開始讀數爲 0 的部分表示筆尖尚未進入探測範圍;讀數上升的紅色部分表示板子的硬件防抖功能正在自以爲是地對早已到達 8191 的壓感數據進行平滑處理,這會造成誤觸;跟隨其後的 V 字曲線是需要反轉的壓感讀數曲線;最後讀數爲 0 的部分表示筆尖已經離開板子的探測範圍。
而俺需要的正確壓感曲線,應該是長下面這樣的。用力的時候讀數上升才對嘛。
下面要將圖 A 中的數據轉換成圖 B 的樣子。上面已經說過,直接反轉並不能消除掉紅色的添油加醋數據,俺必須先想辦法幹掉這個自作主張的硬件防抖。
該怎麼實現呢?注意,這裏的曲線是在時間順序上發生的一系列事件,在接收到第一個讀數的時候,俺的程序並不知道接下來會發生甚麼,因此,使用普通的編程思維就只能得出「檢測到 8192 就趕緊穿越回去撤銷剛才的數據」這麼一個空想的實現。
在面向時間編程中,一切變量和狀態都是時間長河上的一個流。這裏俺使用一個 true/false 流來標記當前板子的狀態:
- 當畫筆進入懸停或者離開懸停的那一刻,輸出 true
- 當畫筆達到 8191 的那一刻,輸出 false
這個流的輸出應該長下面這樣:
之後一旦檢測到 true,俺就強制把壓感归零,壓制硬件防抖造成的誤觸。經過壓制後,PW517 的輸出上升沿將會從 0 直接跳變到 8191,如圖:
這下再對這個曲線執行反轉,輸出的數據就是正常工作的畫筆應有的樣子了。
思考到此爲止,讓我來把這些步驟翻譯成 Rx 代碼 😄
流式數據處理的代碼實現
新建一個 _input 流來接收畫筆的所有數據:
private readonly Subject<ITabletReport> _input = new();
// ...Class 聲明...
public void Consume(IDeviceReport report)
{
if (report is ITabletReport tabletReport)
{
// 一有數據進來,就塞到 _input 流裏面
_input.OnNext(tabletReport);
return;
}
Emit?.Invoke(report);
}方便起見,俺把 _input 的數據分成兩個流,分別是畫筆離開和畫筆接觸(和懸停)的事件流:
var _penLift = _input
.Where(report => GetPenStatus(report) == PenStatus.Lift);
var _penHover = _input
.Where(report => GetPenStatus(report) == PenStatus.Hover);
// 複製一下 Hover 流以便處理
var _sharedHover = _penHover.Publish().RefCount();再創建兩個新流來管理狀態:
- 當畫筆離開懸停的那一刻,輸出 true 的流,還有
- 當板子壓感輸出達到 8191 的時候,輸出 false 的流
// 畫筆一離開就輸出 true
var _startZero = _penLift.Select(_ => true);
var _stopZero = _sharedHover
.Where(h => h.Pressure >= 8192)
// Pressure 達到 8192 就輸出 false
.Select(_ => false);把這兩個流合併,用 true 和 false 來表示是否應該將當前壓感強制归零:
var _isZeroPhase = _startZero
.Merge(_stopZero)
.DistinctUntilChanged()
.StartWith(false); 當這個流輸出 true、並且壓感讀數在上升階段(硬件防抖介入)的時候,把壓感輸出归零,此爲分支 A:
var _zeroedHover = _sharedHover
.WithLatestFrom(_isZeroPhase, (data, shouldZero) => new { data, shouldZero })
.Where(x => x.shouldZero && x.data.Pressure < 8191)
.Select(x => SetParsedPressure(x.data, 0));當壓感壓感讀數穩定後(狀態流輸出 false)將曲線反轉,此爲分支 B:
var _reversedHover = _sharedHover
.WithLatestFrom(_isZeroPhase, (data, shouldZero) => new { data, shouldZero })
.Where(x => !x.shouldZero)
.Select(x => SetParsedPressure(x.data, 8191 - x.data.Pressure));合併分支 A、B 上的數據,並將這些數據交回到 OpenTabletDriver 的流水線:
var _fixedInput = _zeroedHover.Merge(_reversedHover);
_fixedInput.Subscribe(result => Emit?.Invoke(result));看到這邊妳可能會想罵了:這是在幹啥?Where, Select, Subscribe 都是些啥玩意??但是俺可以誠懇地告訴妳,這些運算符就是 Rx 思想最精妙的地方:俺全程都在描述每個事件流上應該進行的操作和它們之間的互動,卻不必關心複雜的數據保持、狀態管理和時序測算,而這些方面的實現正是最讓人頭痛的。
俺有時候會在想,要是世間降下詛咒,每個前端開發人員這輩子只能選兩個庫來做開發的話,俺會毫不猶豫地帶走 React 和 Rx,這倆都是俺窮盡一生都無法企及的高度。若是只能選一個——俺會在一番掙扎之後選擇 React,然後在某個晚上抱着 RxJS 的抱枕以淚洗面。React 是藝術的表達,Rx 是純粹的數學,相比數學,俺似乎更熱愛藝術。
如此,俺的零件配型算法就完成了。換上 PW517 畫筆之後,筆尖漂移的問題大有改善,畫一個晚上都不一定會漂移一次,就像在城樓底下長眠的那位突然重返了青春一樣。
後記:續上最後一秒……😆
零件強行配型成功,老板子算是續上了,首長健康工程的實踐又經受住了一次考驗。
但是,比起一秒秒地硬續,妳不覺得老東西應該直接滾蛋,換人上嗎?
繪王 Q630M,到貨!!😁


繪王 Q630M 增加了一個滾輪,還裝備了一塊非常漂亮的鋁合金背板,俺再也不用擔心拿它壓泡麪的時候被燙彎了。

自帶的畫筆是一支 PW517,於是現在俺就有 3 支畫筆了。

經測試,俺發現只有自帶的筆能用,剩下的兩支老筆直接沒有任何反應……等下,難道俺之前換的零件不是 PW517 嗎?繪王,妳到底有多少種型號的畫筆??果然科技的進步就在於換湯不換藥,然後強行淘汰老設備是吧。
手撕驅動強行續命,不如砸錢直接換新;
Wacao 十年潛心開發,不如繪王換換馬甲 😅
附錄:近期速寫(慢寫)成果
目前教學尚未結束,不過俺已經有一些速寫醬們可以給群友們分享:

下篇中再見!👋🏻

請停用 Dark Reader
評論區
妳的評論和建議是我前進的動力!
我很需要妳的評論!無論長短還是水,我都會非常高興 😘