【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その4 ドットエディタの基礎とアンドゥ設計
2026.09.28

どもです。
最近は、キャッチアイ画像もChat-GPTに生成してもらっているのですが、すごく楽でよいですね。また、ファミコンがファミコンぽくなくてAI味をすごく出しているのがまたいいなとそのまま利用しております。
さて、前回はバンクビューで CHR 全体を表示して、タイルをクリックで選べるところまで来ました。今回はその選んだタイルを実際に編集するドットエディタの話となります。
エディタである以上、「塗る」だけでなく「戻せる」ことが重要でして。今回は描画ツールの中でも一番基本のペンツールを題材に、UI とデータ更新の分離、座標変換、そしてアンドゥの設計をまとめた形になります。
連載の目次
- 概要とアーキテクチャ
- NES の CHR フォーマット(2BPP)と iNES ヘッダ、パレット
- egui で組む 3 パネル UI とバンクビュー
- ドットエディタの基礎とアンドゥ設計(この記事)
- 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)
- PNG インポートと 3 つのマッピング戦略
- macOS ネイティブメニュー・多言語対応・GitHub Actions でのリリース
ツール一覧
まずは全体像から。R-CHR には 10 種類の描画ツールがあり、ツールバーのアイコンで切り替えます。

| # | ツール | 動作 |
|---|---|---|
| 0 | ペン | クリック / ドラッグで 1 ドットずつ描く |
| 1 | ペン(パターン) | 市松模様で描く |
| 2 | 直線 | ドラッグで直線(Bresenham) |
| 3 | 矩形(枠線) | ドラッグで矩形の枠だけ |
| 4 | 矩形(塗り) | ドラッグで矩形を塗りつぶし |
| 5 | 矩形(パターン) | ドラッグで矩形を市松模様で塗る |
| 6 | 楕円(枠線) | ドラッグで楕円の輪郭 |
| 7 | 楕円(塗り) | ドラッグで楕円を塗りつぶし |
| 8 | 塗りつぶし | クリックした場所と同じ色の領域を塗る(BFS) |
| 9 | スタンプ | 範囲を選んでコピーし、何度でも貼れる |
共通操作として、右クリックはスポイトで、クリックしたドットの色が描画色になります。
今回はこのうち 0 と 1、それとアンドゥとコピー & ペースト。2〜9 は次回です。
UI とデータ更新を分離する:EditorAction
egui の update() はフレームごとに 1 回、UI 全体を描き直します。その最中に「クリックされたので CHR データを書き換えて…」とやり始めると、self の借用が UI クロージャと衝突してあっという間に借用チェッカーに怒られます。
なので R-CHR では、ドットエディタの描画関数は「何をすべきか」を表す値を返すだけにして、実際のデータ更新は UI 描画が終わってから別の関数で行う、という形に分けています。
show_dot_editor() → Option
↓
apply_action(action: EditorAction) ← CHR 書き込み・undo 処理
EditorAction は enum です。
/// ドットエディタが発行するアクション(UI 描画とデータ変更を分離するため)
pub(super) enum EditorAction {
/// ドットを塗る。push_undo=true のときは変更前のタイルを undo スタックへ
PaintDot { tile_offset: usize, px: usize, py: usize, color: u8, push_undo: bool },
/// スポイト:ドットの色を描画色として取得
Eyedrop { color_idx: u8 },
/// 描画色の選択
SelectDrawingColor { color_idx: u8 },
/// 線ツール:確定したドット列 (tile_offset, px_in_tile, py_in_tile) を一括適用
ApplyLine { pixels: Vec<(usize, usize, usize)> },
/// スタンプ:(tile_offset, px_in_tile, py_in_tile, color_idx) を一括適用
ApplyStamp { pixels: Vec<(usize, usize, usize, u8)> },
}
app.rs 側の呼び出しはこうです。
let mut editor_action: Option = None;
egui::SidePanel::right("dot_editor_panel")
// ...
.show(ctx, |ui| {
editor_action = self.show_dot_editor(ui);
});
// ── バンクビュー(CentralPanel)...
// ── アクション適用(UI 描画後)
if let Some(action) = editor_action {
self.apply_action(action);
}
こうしておくと、1 フレームにつき最大 1 つのアクションが、UI 描画がすべて終わったあとに確実に 1 回だけ適用されます。借用の問題が消えるだけでなく、「データが変わるタイミング」が 1 箇所に絞られるので、is_modified や texture_dirty のフラグ管理も apply_action() の中だけで済みます。
キャンバスの描画
ドットエディタは、バンクビューで選択したタイルを左上として、フォーカスサイズぶんの N×N タイル(block_px = n * 8 ドット四方)を拡大表示します。
まず前々回の decode_block() でブロック全体を色インデックスの 2 次元配列にデコードします。これは毎フレームやっていますが、最大でも 128×128 = 16,384 要素なので気にするほどではありません。
let n = self.focus_size.tile_count(); // ブロック 1 辺のタイル数
let block_px = n * 8; // ブロック 1 辺のドット数
// フォーカスブロック全体をデコード(rom の借用をここで解放)
let (block, chr_len) = {
let Some(rom) = self.rom.as_ref() else { return None };
(decode_block(rom.chr_data(), top_left_tile, 16, n), rom.chr_data().len())
};
ブロックスコープで rom の借用を閉じているのは、この後 self の他のフィールドを触るためです。Rust あるあるですね。
1 ドットの表示サイズは、パネルの利用可能領域から「正方形に収まる最大の整数」で決めます。
let available = ui.available_size() - egui::vec2(pad, 0.0); let dot_size = (available.x.min(available.y) / block_px as f32).floor().max(1.0); let canvas = dot_size * block_px as f32; let alloc = ui.allocate_exact_size(egui::vec2(canvas, canvas), egui::Sense::click_and_drag());
Sense::click_and_drag() にしているので、返ってくる response でクリックもドラッグも判定できます。
あとは block[px] の色インデックスをパレットで RGB にして、painter.rect_filled() で四角を並べるだけです。
for py in 0..block_px {
for px in 0..block_px {
let fill = self.dat_palette.color32(
self.selected_palette_set,
block[py][px] as usize,
&self.master_palette,
);
let dot_rect = egui::Rect::from_min_size(
egui::pos2(rect.left() + px as f32 * dot_size, rect.top() + py as f32 * dot_size),
egui::vec2(dot_size, dot_size),
);
painter.rect_filled(dot_rect, 0.0, fill);
// ドットグリッド線(dot_size が十分大きいときのみ描画)
if dot_size >= 4.0 {
painter.rect_stroke(
dot_rect, 0.0,
egui::Stroke::new(0.5, egui::Color32::from_gray(60)),
egui::StrokeKind::Inside,
);
}
}
}
バンクビューはテクスチャでしたが、こちらは矩形の直描きです。ドット数が高々 16,384 個なので、これで十分な速度が出ます。ドットが小さくなりすぎたらグリッド線は省略、フォーカスが 2 タイル以上ならタイル境界線を薄く引く、といった調整も入れています。
ツールバーの SVG アイコン
ツールバーのアイコンは SVG を include_bytes! で埋め込んで、egui::Image::from_bytes() で読んでいます。egui_extras の svg feature と、main.rs で呼んだ install_image_loaders() のおかげです。
let img = egui::Image::from_bytes(
format!("bytes://icon_{name}.svg"),
bytes.to_vec(),
)
.fit_to_exact_size(egui::vec2(16.0, 16.0))
.tint(tint);
bytes:// スキームの URI をキーにしてキャッシュされるので、毎フレーム from_bytes() を呼んでも再デコードはされません。選択中のツールは tint で色を反転させて、背景も明るくしています。
座標変換:ポインタ位置 → CHR のバイト位置
クリックやドラッグの位置から「CHR のどのバイトのどのビットか」を求める部分です。ここが全ツール共通の土台になります。
let Some(pos) = response.interact_pointer_pos() else { return None };
let rel_x = pos.x - rect.left();
let rel_y = pos.y - rect.top();
if rel_x < 0.0 || rel_y < 0.0 { return None }
let px = (rel_x / dot_size) as usize; // ブロック内ドット座標
let py = (rel_y / dot_size) as usize;
if px >= block_px || py >= block_px { return None }
// クリック / ドラッグしたドットが属するタイルのオフセットを計算
let block_col = px / 8;
let block_row = py / 8;
let top_col = top_left_tile % 16;
let top_row = top_left_tile / 16;
let tile_global = (top_row + block_row) * 16 + (top_col + block_col);
let tile_offset = tile_global * 16;
let dot_px = px % 8; // タイル内ドット座標
let dot_py = py % 8;
if tile_offset + 16 > chr_len { return None; }
段階を追うとこうです。
- ポインタ位置からキャンバス左上を引いて、
dot_sizeで割る → ブロック内のドット座標(px, py) / 8でブロック内のタイル位置、% 8でタイル内のドット位置に分解- 左上タイルの行・列にブロック内タイル位置を足して、グローバルタイル番号 →
× 16でバイトオフセット
最終的に (tile_offset, dot_px, dot_py) の 3 つ組が出てきて、これをそのまま前々回の encode_dot() に渡せます。
右クリック:スポイト
右クリックはスポイトです。デコード済みの block から色インデックスを拾って返すだけ。
if response.secondary_clicked() {
if self.drawing_tool == 9 && (self.stamp_buffer.is_some() || self.stamp_sel_start.is_some()) {
// スタンプ中は右クリックでキャンセル(次回)
// ...
return None;
}
return Some(EditorAction::Eyedrop { color_idx: block[px] });
}
apply_action() 側では self.drawing_color_idx = color_idx するだけです。
ペンツール
ペンは、押した瞬間・ドラッグ中・単クリックのいずれかなら PaintDot を返します。
// 左クリック / 左ドラッグ → 描画(ペン系ツール)
let drag_started = response.drag_started_by(egui::PointerButton::Primary);
let dragging = response.dragged_by(egui::PointerButton::Primary);
let clicked = response.clicked_by(egui::PointerButton::Primary);
if drag_started || dragging || clicked {
let push_undo = drag_started || clicked;
return Some(EditorAction::PaintDot {
tile_offset, px: dot_px, py: dot_py,
color: self.drawing_color_idx,
push_undo,
});
}
ポイントは push_undo です。ドラッグ開始または単クリックのときだけ true で、ドラッグ中は false。これがアンドゥの単位を「1 ストローク」にするための仕掛けになります。
アンドゥの設計
さて本題です。
アンドゥの単位をどうするか、というのはエディタ設計の肝でして。「1 ドットずつ戻る」だと、ドラッグで 50 ドット描いたあとに 50 回 Cmd+Z を押す羽目になります。そうなるとそれはつらい。
R-CHR では 1 ストローク(マウスを押してから離すまで)= 1 アンドゥにしています。
スナップショット方式
アンドゥの実装方式は大きく分けて「操作を記録して逆操作する」か「変更前の状態を保存しておいて戻す」かの 2 通りありますが、R-CHR は後者のスナップショット方式です。ただし CHR 全体を保存するのではなく、変更したタイルの 16 バイトだけを保存します。
/// アンドゥスタック: 1操作 = Vec<(バイトオフセット, 変更前16バイト)> pub(super) undo_stack: Vec<vec<(usize, [u8;="" 16])="">>, /// 現在のドラッグ操作で既にアンドゥ保存済みのタイルオフセット集合 pub(super) drag_undo_tiles: std::collections::HashSet,</vec<(usize,>
スタックの 1 要素が「1 操作」で、その中身は「(タイルオフセット, 変更前の 16 バイト)」のリスト。1 ストロークで複数タイルをまたいだ場合は、そのリストに触ったタイルぶんのエントリが入ります。
1 タイル 16 バイトなので、100 タイルまたぐ豪快なストロークをしても 1.6KB。軽いものです。
push_undo_batch と上限
スタックへの積み込みは 1 箇所に集約しています。
pub(super) fn push_undo_batch(&mut self, batch: Vec<(usize, [u8; 16])>) {
if batch.is_empty() { return; }
const UNDO_LIMIT: usize = 100;
if self.undo_stack.len() >= UNDO_LIMIT {
self.undo_stack.remove(0);
}
self.undo_stack.push(batch);
}
上限 100 ステップで、超えたら古いものから捨てます。Vec::remove(0) は先頭削除なので O(n) ですが、n ≤ 100 なので気にしない方向で。
ペンのストロークをどう 1 バッチにまとめるか
apply_action() の PaintDot 処理がこちら。
EditorAction::PaintDot { tile_offset, px, py, color, push_undo } => {
// ...
if push_undo {
// ドラッグ開始 or クリック: 起点パリティを記録して新規バッチ開始
self.drag_pattern_parity = parity;
self.drag_undo_tiles.clear();
let saved: [u8; 16] = self.rom.as_ref().unwrap().chr_data()
[tile_offset..tile_offset + 16].try_into().unwrap();
self.push_undo_batch(vec![(tile_offset, saved)]);
self.drag_undo_tiles.insert(tile_offset);
} else {
// ペン(パターン): 起点と異なるパリティのドットはスキップ
if self.drawing_tool == 1 && parity != self.drag_pattern_parity {
return;
}
// ドラッグ中に初めて触れたタイル: 現在バッチに追記
if !self.drag_undo_tiles.contains(&tile_offset) {
let saved: [u8; 16] = self.rom.as_ref().unwrap().chr_data()
[tile_offset..tile_offset + 16].try_into().unwrap();
if let Some(batch) = self.undo_stack.last_mut() {
batch.push((tile_offset, saved));
}
self.drag_undo_tiles.insert(tile_offset);
}
}
if let Some(rom) = &mut self.rom {
encode_dot(&mut rom.chr_data_mut()[tile_offset..tile_offset + 16], px, py, color);
}
self.is_modified = true;
self.texture_dirty = true;
}
流れはこうです。
- ドラッグ開始(
push_undo = true):drag_undo_tilesを空にして、触ったタイルのスナップショットで新しいバッチを積む - ドラッグ中(
push_undo = false):触ったタイルがまだdrag_undo_tilesに無ければ、スナップショットを取ってスタックの末尾のバッチに追記する。既にあれば何もしない
drag_undo_tiles という HashSet で「このストロークで既に保存したタイル」を追跡しているのがミソで、これがあるおかげで、フォーカス 128px で 16×16 タイルをまたぐような大きなストロークでも、各タイルの「ストローク開始時点の状態」がちょうど 1 回だけ保存されます。
スナップショットを取るのはそのタイルに最初に触れたときでなければいけません。2 回目以降に取ると、既にこのストロークで塗ったあとの状態を保存してしまい、戻しきれなくなります。
Undo の実行
戻すのは簡単で、スタックから 1 バッチ pop して、各タイルに 16 バイトを書き戻すだけです。
pub(super) fn do_undo(&mut self) {
let Some(batch) = self.undo_stack.pop() else { return };
let Some(rom) = &mut self.rom else { return };
for (offset, saved) in batch {
if offset + 16 <= rom.chr_data().len() {
rom.chr_data_mut()[offset..offset + 16].copy_from_slice(&saved);
}
}
self.texture_dirty = true;
}
次回の図形ツールも、PNG インポートも、コピー & ペーストも、全部この push_undo_batch() に「影響タイルのスナップショット」を渡すだけでアンドゥ対応になります。ツールごとにアンドゥの実装を持たなくて良い、というのがこの設計の一番のうまみですね。
ペン(パターン):市松模様で描く
ペンのバリエーションとして、市松模様(チェッカーボード)で描くパターンペンがあります。ファミコンのドット絵で中間色っぽく見せるときの定番テクニックですね。
実装は上のコードにすでに出てきていますが、ドラッグ開始点のパリティ (x + y) % 2 を覚えておいて、同じパリティのドットしか塗らないというだけです。
// ペン(パターン)ツール: グローバルピクセル座標でチェッカーボードパリティを計算 let tile_global = tile_offset / 16; let global_x = (tile_global % 16) * 8 + px; let global_y = (tile_global / 16) * 8 + py; let parity = ((global_x + global_y) % 2) as u8;
ここで global_x / global_y を CHR 全体でのドット座標にしているのが小さなこだわりです。タイルの 1 辺は 8 ドットで偶数なので、実はタイル内座標で計算しても結果は同じになるのですが、「バンクビュー全体で一貫した市松模様」という意図をコードに残しておきたかったので、グローバル座標で計算しています。
コピー & ペースト
タイル単位のコピー & ペーストも、この回で触れておきます。実装は clipboard.rs で、フォーカスサイズぶんの N×N タイルを CHR バイト列のままバッファに入れるだけです。
/// タイルコピーバッファ: (n辺タイル数, n×n タイルの CHR バイト列) pub(super) tile_clipboard: Option<(usize, Vec)>,
コピーは選択ブロックの各タイル 16 バイトを順に extend_from_slice。ペーストはその逆で、貼り付け先の各タイルをスナップショットしてから上書きします。
pub(super) fn paste_tiles(&mut self) {
let Some(tile_idx) = self.selected_tile else { return };
let Some((n, src)) = self.tile_clipboard.clone() else { return };
let Some(rom) = &mut self.rom else { return };
let chr_len = rom.chr_data().len();
let total_tiles = chr_len / 16;
let mut batch: Vec<(usize, [u8; 16])> = Vec::new();
for dy in 0..n {
for dx in 0..n {
let dst_tile = tile_idx + dy * 16 + dx;
if dst_tile >= total_tiles { continue; }
let dst_offset = dst_tile * 16;
let src_offset = (dy * n + dx) * 16;
let saved: [u8; 16] = rom.chr_data()[dst_offset..dst_offset + 16]
.try_into().unwrap();
batch.push((dst_offset, saved));
rom.chr_data_mut()[dst_offset..dst_offset + 16]
.copy_from_slice(&src[src_offset..src_offset + 16]);
}
}
self.push_undo_batch(batch);
self.is_modified = true;
self.texture_dirty = true;
self.status_msg = Some(format!("ペースト: {}×{} タイル", n, n));
}
見ての通り push_undo_batch() を呼ぶだけでアンドゥ対応完了です。色インデックスではなく CHR のバイト列をそのまま持っているので、パレットセットを切り替えてもコピー内容には影響しません。
まとめ
- ドットエディタは
show_dot_editor()がOptionを返し、UI 描画後にapply_action()で適用する。借用問題の回避と、データ変更点の一元化 - ポインタ位置 → ブロック内ドット座標 → タイル番号 + タイル内座標 → バイトオフセット、と段階的に変換
- アンドゥは「変更タイルの 16 バイトのスナップショット」を 1 操作 = 1 バッチで積む。上限 100
drag_undo_tilesでストローク中に初めて触れたタイルだけ保存し、複数タイルをまたぐドラッグも 1 回で戻せる- パターンペンはグローバル座標のパリティで判定
- コピー & ペーストも
push_undo_batch()に乗せるだけ
「アンドゥは全ツールが push_undo_batch() にスナップショットを渡すだけ」という形が固まったところで、次回はいよいよ図形ツールです。Bresenham の直線、楕円、BFS の塗りつぶし、2 フェーズのスタンプと、アルゴリズム多めの回になります。
ではではぁ。
またまたぁ。

















