小緑人とは
手っ取り早くソースコードが見たい、という人はここをクリック
台湾が好きで時々遊びに行きます。食べ物がおいしく、観光地もあります。台北を含む各地に電気街もあります。
台北の電気街は光華商場というビルを中心とした一角です。光華商場には1Fに名だたるPCメーカーが軒を連ね、2Fは大手ショップやマザーボードメーカと、上に行くにしたがって、怪しいお店が増えていき、5Fくらいからワケがワカラナイ、80年代の秋葉原のようになってきます。
お隣の光華國際電子廣場には、B1、B2に電子工作好きの人向けのショップが集まっています。大手のお店は、秋月電子とマルツ(日本橋なら共立?)を合わせたようなお店や、アイテンドーの本店のようなお店もたくさんありますし、小さいお店は国際ラジオか日米か、という感じです。

さて、本題です。台湾に行った人は誰でも一度は必ず、歩行者用信号のアニメーションを目にします。
前々から、これがなかなか可愛らしいと思っていたのですが、私だけではなかったようで「小緑人(シャオリューレン)」と呼ばれて台湾の名物になっています。現地には記念碑もありますし、キーホルダーやブローチ、マグネットなどにもなっています。
やはり、これは一家に一台、絶対に必要なものだと言えるでしょう。
しかし、悲しいかな売っているものでもなく、また、さすがに信号機を手元に置くわけにもいきません。となると、自分好みの小緑人を作るしかありません。
実際の小緑人
現地で観察したところ、よく見かける小緑人には16x16マトリックスLEDのもの、独立したLEDを必要な場所に配置してあるものがあります。(それ以外には、バイク用のもの、円形のもの、より高い解像度のものもあります)
独立したLEDのものは、LEDの総数が4~50個ですがLEDの配置を自分で考える必要があること、結線を自分で考える必要があるのでかなり難しそうです。パターンの修正はハードウェア変更が必要になります。
こう考えると、マトリックスLEDで実現するのが現実的でしょう。
走れ!小緑人
これが作成された小緑人です。
起動時は待機状態(節電状態)で始まります。STARTボタンを押すと停止状態(歩行者信号の「止まれ」)になります。再度STARTボタンを押すと、小緑人が歩き出します。50秒間は歩き、残り10秒間は走ります。
停止状態が30秒続くと、待機状態に戻ります。
せっかくのカラーLEDなので、ほかのキャラクターも歩かせるようにしてみました。
待機状態(歩行者信号の「とまれ」)で、スイッチを押すと表示されるキャラクターが切り替わります。
ビデオ映像にすると、全体的に白っぽくなってしまいなんだかわかりにくなってしまいますが、有名なヒゲのおじさん、緑の服のエルフ、プププランドの若者、伝説の勇者です。
WS2812は、LED自体が大きく、ドットが密に配置されていません。そのため視認性はイマイチです。
光拡散シートなどを使って、LEDの隙間が見えにくくする必要がありそうです。ただ、そうすると小緑人「ぽさ」が失われてしまうのが悩みどころです。
工夫した点
小緑人は7パターンの繰り返しで動作しますが、これをそのまま表示すると絵がパカパカと変わってしまい、今一つな印象でした。そこで、暗くした前のパターンと今回のパターンを重ねたようなフレームを挿入し、滑らかに動くように見せています。
実際の小緑人をビデオに撮ったものでは、LEDの消え方は緩やかになっています。これが、単に目やビデオの錯覚なのかはわかりませんが、これを実装した形になっています。
制作のメモ
マトリックスLEDの検討
まず、マトリックスLEDの部品を選定します。マトリックスLEDには、制御方法にいくつか種類があるようです。
- 直接制御
- HUB75
- WS2812
直接制御
格子状に結線されたLEDのマトリックスを、独立して制御します。

そのままだと、16x16のLEDの場合GPIOが32本必要です。Raspberry pi PICOには30本のGPIOがありますが、数としては不足します。そのため、デコーダーなどを使う必要があるでしょう。
例えば、アノード側(COL側)は74xx4514、カソード側(ROW側)に74xx154を使うなどが使用できるかもしれません。74xx154/4514は、4ビット(0x0~0xF)を受け取り、16ビット側にそのビットを出力します。たとえば、74xx154では、0b0011 (0x3) を入力すると、出力側は A0から1101111111111111など、3ビット目だけがオフになります。(Active Low) 74xx4514はその逆で、出力側の3ビット目だけがオンになります。
マトリックスLEDでは、例えば列側に 0b0100、行側に0b1001 を出力すると、(4,9)の場所のLEDが点灯します。
わかりやすい方法ですが、TTLの入手方法に難ありです。手はんだが容易なDIP版はもう製造中止でDigiKey、Mouserどちらにも在庫がありません。
(なお、台湾の光華商場のビルの隣、光華國際電子廣場ではまだ普通に売っていました。買ってこなかったのは失敗です)
HUB75
街角で見かける、大型のLEDディスプレイなどで使われているプロトコルです。情報も多く、使うポートは16本です。
ピンの機能 | 数 | ピン名 |
---|---|---|
Addr | 4 | A1~A4 |
LED | 6 | R1,G1,B1,R2,G2,B2 |
Utils | 3 | CLK,LAT,OE |
その他 | 3 | GND |
点灯方式はダイナミック点灯で、4本のアドレスピンに0~15を出力し、R1G1B1とR2G2B2で2行分出力するという方法です。
情報は多いのですが、実際に動かすのには大変そうです。
HUB75では、輝度指定がありません。調べると、輝度の指定は画面の点灯時間で制御する必要がある(いわゆるPWM)ようです。
つまり、HUB75ではLEDはダイナミック点灯で同時に点灯するのはROW一つだけ、さらに中間色を表現するにはPWMがひつっ用ということになります。そのため、中間色を出すには、画面のリフレッシュを高速で行い、点灯時間を制御する必要があります。
パネル単位、行単位で同じ輝度を使うにはさほど難しくはなさそうですが、ドット単位で輝度を変えるとなるとかなり難しい気がします。
もう少し深堀りしないときちんとしたものにはならなそうです。
HUB75では、LEDは普通の三色LEDを使います。そのため、パネル自体が安価になりそうです。ただ、調べた範囲では、個人が使う1枚2枚の数量では後述のWS2812に比べて特段安いということはありませんでした。
WS2812
テープLEDなどで使われているもので、信号線は1本です。電源、GNDと合わせても3線でコントロールされます。
LED自体がインテリジェントなLEDで、データを受け取るとリセットされるまでその色で光り続けます。光り続けている間に、次の色指定が来るとそのまま次のLEDにそれを伝えます。
回覧板のようにデータが次のLEDに渡されることで、マトリックスLEDが表示されます。信号は、24ビットカラーで、輝度も変更できます。
信号は1本で24bitなので、16x16なら256個分、6Kbitを送信する必要があります。そのため信号は非常にシビアになっているようです。
たとえば、1ビットの送信では1.25μs、許容される誤差は±0.15usです。0と1は、オン⇒オフの切り替えで行いますが、0.2μsの違いで1と0が切り替わることになります。delay_msなどで適当に待つようなコードでは無理そうです。
WS2812
結局、WS2812を使用することにしました。WSS2812のマトリクスLEDは、入手性が良く、価格も1枚2枚というレベルであればHUB75と変わりません。
秋月電子 WS2812B搭載フルカラーLEDディスプレイ 16×16 … ¥3,800
ピカリ館 … ¥9,800
Aliexpress … ¥2,000 ~ ¥4,000
購入時は、16x16のものが必要です。
配線図
結線図としては次のようになります。回路図を書くほどではありませんので回路図は省略です。

WS2812LEDには、3種類の線が出ています。
名称 | 色 | 用途 |
---|---|---|
DIN | 白緑赤 | 信号の入力 最初のLEDマトリックスパネルでは、ここにマイコンなどのコントローラーを接続する |
5V | 赤黒 | 電源 |
DOUT | 白緑赤 | 信号の出力 複数のLEDマトリックスを使用する場合、上流のDOUTに、下流のDINを接続する |
WS2812は、カスケード接続することができます。DINには、上流のパネルのDOUTを接続します。
WS2812はそれぞれのLEDが、CPUを持っています。データを受け取ると、そのデータに基づいて光りますが、もしすでに光っている場合、データをそのまま隣のLEDに渡します。パネルの末尾に来てもこの動作は変わらず、DOUTにそのまま次のデータを引き渡します。このデータを2枚目以降のパネルが受け取り、発光を続けます。
バケツリレーのように、データが次のLEDに引き渡されることになります。
制御プロトコル
WS2812は電源(+5VとGND)を除くと1ピンで制御します。
この1ピンに流す信号は非常にシンプルですが、タイミングがシビアです。基本的に1ビットを、1.25μs±600nsで送る必要があります。
許される誤差は、信号全体で±600ns、LOW/HIGHそれぞれの部分で±150nsです。
送信データ | HIGHの時間 | LOWの時間 | |
---|---|---|---|
RESET | なし | 50μS以上 | |
0 | 0.4μS | 0.85μS | 0.25~0.55μS |
1 | 0.8 | 0.45μS |
許される誤差は、信号全体で±600ns、LOW/HIGHそれぞれの部分で±150nsです。
表示するフレームレート
まず、きちんとアニメーションできるスピードになるかを調べます。
LEDは24ビットフルカラーで点灯させるため、LEDを一つ制御するために、データを24回送信する必要があります。
すると、1つのLEDを任意の色で点灯させるには(0.4μS + 0.85μS) x 24 = 30μS、16x16個のマトリックスLEDの場合、30μS x 256 = 7680μS また、最初にリセットを送るため、50μ秒が必要です。 7680 μS+50μS = 7730μ秒となります。これは7.73msです。
1画面の更新に7.73msかかるので、秒間の更新数は129フレーム/秒になります。
この速度はアニメーションとして十分な速度です。
Raspberry PI PICO側の構造
制御プロトコルの送信方法
WS2812の信号がタイミングとしてシビアなので、これを守ることができるかを調べます。
120MHzで動作しているのであれば、1クロックは 1/120,000,000 秒なので、8.33nS となります。
一つの信号が1.25μ秒なので約150クロック。RP2040は簡単な命令だと1クロック1命令なので、可能は可能ですが、各ビットの送信の間で行われる処理(次のビットを準備する)などを、かなりシビアなタイミングで実行する必要がありそうで、これにはかなり神経質なプログラム作成が必要になりそうです。(命令数を数えて、NOPなどで調整する)
これはかなり避けたい事態ですが、Raspberry pi PICOには、PIOと呼ばれる便利機能があります。PIOは、本体のCPUとは別の、独立したステートマシンで動作します。これを使えば、メインプログラム側のタイミングに依存せず、細かい制御が実行できそうです。今回は、このPIO機能を使ってみることにしました。
FIFOで受け取ったデータを、逐次LEDに送出するというPIOプログラムを作成し、登録しておけばメインプログラム側は、FIFOへの書き込みだけでタイミングを気にする必要がなくなります。
表示方法
表示するためのデータは、よくある方法としてVRAMのように、オフスクリーンでメモリを用意し、そのメモリに値を書き込む、一定周期(または必要に応じて)そのVRAMのデータをWS2812に転送する方法としました。
メモリは、各ドットごとに32ビットのワードマップとし、24ビットで色を表現します。最上位の残り8ビットは使用しません。
前述のように、1画面すべてを書き換えても7.73msなので、この辺は特に気にする必要はなさそうです。Raspberry PI は、マルチCPUなので、画面更新を別CPUで非同期に回すという手もあるでしょう。
これをクラスとしてまとめておけば、汎用的に使用できるかもしれません。
ソースコード
ソースコードはGitHubにて公開しています。
WS2812用のライブラリ
本プログラムで使用されている、WS2812に任意の画像を表示するためのクラス。
複数のパネル用のコードも作ってありますが、パネルを一枚しか買っていないので試していない。
ファイル名 | 説明 |
---|---|
WS2812.cpp/WS2812.h | WS2812クラスのクラス定義と本体 |
WS2812.pio | PIOファイル |
※ CMakefiles.txtの、target_link_librariesに、hardware_pio の定義が必要です。
コード上の注意点
LEDマトリックスの配列と開始点
LED は、千鳥配列で配線されている場合がある。WS2812の特性上、各LEDは一本の「LEDテープ」のように扱われる。
こうした特性をそのままマトリックスLEDにしているので、LEDの襦袢が右から左、上から下に一列に並ぶのではなく、右から左に行くと、折り返して左から右に変化していくタイプのLEDマトリックスが多い。
また開始点がパネルの左上ではなく、右上から左に向かっていることもある。
このプログラムでは、VRAMの並びは常に、左上から始まり、右方向にX座標、下方向にY座標となっている。実際のパネルの動作とのすり合わせは、ScanBuffer、ScanPanelなどで、千鳥配列かどうかのフラグと、開始店の位置を指定して、VRAMをメモリに反映させるときに行われる。
リファレンス
コンストラクタ
WS2812(uint8_t pin, uint8_t xSize, uint8_t ySize, uint8_t xPanelCount, uint8_t yPanelCount)
- pin: 出力GPIO
- xSize/ySize: 1枚のパネルの幅/高さ(ピクセル)
- xPanelCount/yPanelCount: パネルの配置数(横/縦)
- 800kHzでPIO/SMを初期化し、VRAMを0で確保
主要メソッド
void Reset()
次フレーム送出前のラッチ確保(>50µs Low)。安全余裕で sleep_us を含む。
void Keep()
アイドル用ラベルへジャンプし、ラインをHighで維持(SM有効時)。
void setColorDirect(uint8_t r, uint8_t g, uint8_t b)
1ピクセル分の24bitを即時送信(ブロッキング)。VRAMは使わない。
void setColorDirect(uint32_t grb)
1ピクセル分の24bitを即時送信(ブロッキング)。VRAMは使わない。
void fillRandomColorDirect()
デモ用のランダム色を即時送信。
void Clear(uint32_t grb = 0)
VRAM全体を指定色で塗る。
void SetPixel(uint16_t x, uint16_t y, uint32_t grb)
VRAMの1ピクセルを書き換え(範囲外は無視)。
void ScanPanel(uint8_t posX, uint8_t posY, bool serpentine = false, bool leftToRight = true)
- posX/posYはVRAM座標(左上)
- serpentine: 千鳥配線対応。true なら奇数行で左右反転
- leftToRight: 基準の走査方向(行の偶奇でserpentineが反転を加える)
1パネル(幅xSize×高さySize)分のデータをVRAMからLEDマトリックスに送出。
void ScanBuffer(bool serpentine = false, bool leftToRight = true)
- serpentine: 千鳥配線対応。true なら奇数行で左右反転
- leftToRight: 基準の走査方向(行の偶奇でserpentineが反転を加える)
全パネルを左上→右下の順に走査して送出。
void DrawPanelBorder(uint8_t panelX, uint8_t panelY, uint32_t grb)
指定パネルの外枠をVRAMへ描画。
void DrawRandomBorders()
全パネルの外枠にランダム色を描画(簡易デモ)。
void DrawBuffer(const uint32_t pattern[], uint8_t width, uint8_t height, uint8_t X, uint8_t Y, uint32_t colorReplace, bool isOverlay)
- pattern 描画元のピクセル配列(フラット)。色は 0x00GGRRBB(GRB順、上位8bit未使用)インデックスは row-major: pattern[py*width + px]
- width / height pattern の実寸(描画範囲)。幅×高さ 要素を参照
- X / Y VRAMへの貼り付け先の左上座標。VRAM外に出た画素は SetPixel の範囲チェックで無視される
colorReplace
置換色モードの指定。0以外なら「patternの非0ピクセル」をすべてこの色に置き換えて描画。0なら置換なしで pattern の色そのままを使用 - isOverlay true: 黒(0x000000)を「透明」として扱い、該当ピクセルはVRAMを変更しない。false: 黒も「不透明」。patternが黒の画素はVRAMを0で上書きする。
任意のパターン配列(フラット)をVRAMへ描画。colorReplace≠0で非0ピクセルを色置換。isOverlay=trueで0ピクセルを透明として重ねる。
使用例:
WS2812 led_matrix(PIN_WS2812_1, 16, 16);
//LEDのリセット
led_matrix.Reset();
//VRAMのクリア
led_matrix.Clear(0);
// クリアしたVRAMをLEDに転送
led_matrix.ScanBuffer();
// bufに指定された領域を、VRAMに転送する
led_matrix.DrawBuffer(buf, 16, 16, 0, 0, 0, false); // パターンを描画
// 左上に緑の点を打つ
led_matrix.setPixel(0,0,0xFF0000);
led_matrix.ScanBuffer();