【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その6 PNG インポートと 3 つのマッピング戦略
2026.10.02

どもです。
前回までで、エディタの中でドットを描く機能は一通り揃いました。ただ、正直なところ、本格的にドット絵を描くなら Aseprite のような専用ツールのほうが圧倒的に描きやすいわけでして。
だったら「外で描いた PNG をそのまま CHR に放り込める」ようにすれば良いのでは、と。YY-CHR にも「Paste Image」はあるのですが、対応しているのが 8bpp インデックスカラーの BMP / PNG だけで、ここでいつも一手間かかっていたんですよね。
ということで、今回は R-CHR の PNG インポートの実装、特に「PNG の色をどうやって 0〜3 の色インデックスに落とすか」という 3 つのマッピング戦略と透過処理をまとめた形になります。
連載の目次
- 概要とアーキテクチャ
- NES の CHR フォーマット(2BPP)と iNES ヘッダ、パレット
- egui で組む 3 パネル UI とバンクビュー
- ドットエディタの基礎とアンドゥ設計
- 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)
- PNG インポートと 3 つのマッピング戦略(この記事)
- macOS ネイティブメニュー・多言語対応・GitHub Actions でのリリース
対応フォーマット
R-CHR が受け付ける PNG はこれだけあります。
- インデックスカラー PNG(1-bit / 2-bit / 4-bit / 8-bit)
- フルカラー PNG(RGB / RGBA)
- 透過付き PNG(tRNS チャンク / アルファチャンネル)
加えて BMP も読めます(こちらは RGB 近似のみ)。
「なんでも放り込める」のが目標だったので、フルカラーの PNG やスクリーンショットでも、近似にはなりますがとりあえず CHR になります。
全体の流れ
File → Import PNG… / ドラッグ & ドロップ ↓ ファイルを読み込み → import_png(bytes, DAT, セット, マスター, None) … 戦略は自動選択 ↓ PngImportDialog を開く(プレビューテクスチャ生成) ↓ ラジオボタンで戦略を変更 → import_png(..., Some(strategy)) で再変換 → プレビュー更新 ↓ 「貼り付け」→ apply_png_import() ↓ 影響タイル全部をスナップショット → push_undo_batch() ↓ write_to_chr() で encode_dot() を 1 ドットずつ
変換ロジックは io/png.rs、ダイアログ UI は editor/png_import.rs に分かれています。io/png.rs は egui に依存していないので、単体でテストしたり CLI ツールに流用したりもできる形です。
変換結果はこの構造体に入ります。
pub struct PngImportResult {
/// 画像幅(ピクセル)
pub width: usize,
/// 画像高さ(ピクセル)
pub height: usize,
/// CHR カラーインデックス [y][x] = 0〜3
pub pixels: Vec<vec>,
/// 実際に使用したマッピング戦略
pub strategy: MappingStrategy,
/// 警告リスト(言語非依存)
pub warnings: Vec,
}</vec
要は「画像と同じサイズの 0〜3 の 2 次元配列」です。ここまで落とせれば、あとは前回までと同じ encode_dot() で書くだけ。
PNG のメタ情報を読む:png クレート
まず PNG から必要な情報を取り出します。ここでは image クレートではなく、より低レベルな png クレートを使っています。理由は、インデックスカラー PNG の PLTE(パレット)チャンクと tRNS(透過)チャンクの中身を直接見たいからです。image クレートは便利なのですが、読み込んだ時点で RGBA に展開されてしまって、元のパレットインデックスが分からなくなります。
fn read_png_meta(data: &[u8]) -> Result<pngmeta, string=""> {
use png::{BitDepth, ColorType};
let decoder = png::Decoder::new(std::io::Cursor::new(data));
let mut reader = decoder.read_info().map_err(|e| format!("PNG 解析失敗: {e}"))?;
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).map_err(|e| format!("PNG フレーム読み込み失敗: {e}"))?;
let is_indexed = matches!(info.color_type, ColorType::Indexed);
let palette = reader
.info()
.palette
.as_deref()
.unwrap_or(&[])
.chunks_exact(3)
.map(|c| , c[1], c[2]])
.collect();
// tRNS チャンク: インデックス PNG では各パレットエントリのアルファ値
let transparency = reader
.info()
.trns
.as_deref()
.unwrap_or(&[])
.to_vec();
// ...
}</pngmeta,>
ビット深度の展開
インデックスカラー PNG は、ビット深度が 8 未満だと 1 バイトに複数ピクセルが詰まっています。4-bit なら 1 バイトに 2 ピクセル、2-bit なら 4 ピクセル、1-bit なら 8 ピクセル。
後段のコードで [y * w + x] と素直にアクセスできるように、ここで 1 バイト = 1 ピクセルに展開しておきます。2-bit の場合はこんな感じです。
BitDepth::Two => {
let mut pixels = Vec::with_capacity(w * h);
for y in 0..h {
let row = &raw[y * info.line_size..];
for x in 0..w {
let byte = row[x / 4];
let shift = (3 - (x % 4)) * 2;
pixels.push((byte >> shift) & 0x03);
}
}
pixels
}
「1 バイトに 4 ピクセル、左のピクセルが上位ビット」という PNG の仕様に従って、x / 4 でバイト、(3 - x % 4) * 2 でシフト量を求めてマスクしています。行の先頭は info.line_size 単位で揃っているので、行ごとに row を切り直しているのも地味に大事なところです(行末のパディングを踏まないように)。
ちなみに 2-bit のインデックスカラー PNG は、ファミコンの 4 色とぴったり対応するので、Aseprite からの書き出しでは割とよく出てくる形式です。
3 つのマッピング戦略
さて本題。PNG のピクセルを 0〜3 のインデックスにする方法として、3 つの戦略を用意しました。
/// ピクセルを CHR カラーインデックス(0〜3)へ変換する戦略
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MappingStrategy {
/// インデックス PNG のピクセル値を mod 4 する(パレット先頭 4 色 = CHR 0〜3)
IndexDirect,
/// インデックス PNG の PLTE を NES マスターパレットに照合し DAT セットの 0〜3 に変換
PaletteMatch,
/// フルカラー PNG の各ピクセルを DAT パレットセット 4 色に RGB 近似マッピング
RgbApprox,
}
どれを使うかは、ファイルの種類から自動で選ばれます。ダイアログのラジオボタンで手動で変えることもできます。
// 戦略を自動選択(ヒントがあればそれを使う)
let strategy = match strategy_hint {
Some(s) => s,
None => {
if meta.is_indexed && !meta.palette.is_empty() {
MappingStrategy::PaletteMatch
} else if meta.is_indexed {
MappingStrategy::IndexDirect
} else {
MappingStrategy::RgbApprox
}
}
};
| 条件 | 自動選択される戦略 |
|---|---|
| インデックスカラー + PLTE あり | パレット照合(Palette Match) |
| インデックスカラー + PLTE なし | インデックス直接(Index Direct) |
| フルカラー(RGB / RGBA) | RGB 近似(RGB Approx) |
色の距離
3 つとも「近い色を探す」処理が出てくるので、色の距離関数を共通で持っています。RGB 空間のユークリッド距離の 2 乗です。
fn color_distance(a: [u8; 3], b: [u8; 3]) -> u32 {
let dr = a[0] as i32 - b[0] as i32;
let dg = a[1] as i32 - b[1] as i32;
let db = a[2] as i32 - b[2] as i32;
(dr * dr + dg * dg + db * db) as u32
}
/// RGB を DAT パレットセット 4 色の中で最近傍の色インデックス(0〜3)に変換
fn nearest_dat_index(rgb: [u8; 3], dat: &DatPalette, set: usize, master: &MasterPalette) -> u8 {
(0u8..4)
.min_by_key(|&i| color_distance(rgb, dat.color_rgb(set as usize, i as usize, master)))
.unwrap_or(0)
}
人間の知覚に合わせた色差(CIE の ΔE とか)を使う手もありますが、比較対象が高々 4 色や 64 色なので、単純な RGB 距離で十分実用になっています。平方根を取らないのは比較にしか使わないからですね。
戦略 1:パレット照合(Palette Match)
Aseprite との連携を想定した戦略となります。
PLTE[i] の RGB → NES マスターパレット 64 色の中で最近傍を探す → その色に一番近い、現在の DAT パレットセット 4 色の中のインデックスを選ぶ → CHR インデックス 0〜3
ポイントは、変換をピクセルごとではなく PLTE のエントリごとにやっていることです。PLTE が 16 色なら 16 回だけ最近傍探索をして「PLTE インデックス → CHR インデックス」の対応表を作り、あとはピクセル値でその表を引くだけ。
MappingStrategy::PaletteMatch => {
// ...
let plte_len = meta.palette.len();
let mut plte_to_chr = vec![0u8; plte_len];
let mut unmatched_count = 0usize;
let mut transparent_entries = 0usize;
for (i, &rgb) in meta.palette.iter().enumerate() {
if is_transparent_entry(i) {
plte_to_chr[i] = 0; // 透明 → CHR 0
transparent_entries += 1;
continue;
}
// NES マスターパレット(64 色)に最近傍マッチ
let nes_idx = (0usize..64)
.min_by_key(|&j| color_distance(rgb, master.colors[j]))
.unwrap_or(0);
let nes_rgb = master.colors[nes_idx];
// DAT パレットセット内で最近傍の色インデックスを探す
let mut best_chr = 0u8;
let mut best_dist = u32::MAX;
for c in 0u8..4 {
let dat_rgb = dat.color_rgb(palette_set, c as usize, master);
let d = color_distance(nes_rgb, dat_rgb);
if d < best_dist {
best_dist = d;
best_chr = c;
}
}
plte_to_chr[i] = best_chr;
if best_dist > 0 {
unmatched_count += 1;
}
}
// ...
for y in 0..h {
for x in 0..w {
let idx = meta.raw_pixels[y * w + x] as usize;
pixels[y][x] = if idx < plte_len { plte_to_chr[idx] } else { 0 };
}
}
}
「一度 NES の 64 色に丸めてから DAT セットと比較する」という 2 段階にしているのは、Aseprite 側で rchr.pal の 64 色をパレットとして使っている想定だからです。この場合 PLTE の色は NES の 64 色のどれかに完全一致するので、1 段目で正確な NES インデックスが取れ、2 段目で「今のパレットセットにその色があるか」を見る、という流れになります。
best_dist > 0 のとき、つまり完全一致しなかったエントリは数えておいて、ダイアログで「3 色が近似されました」のような警告を出します。
戦略 2:インデックス直接(Index Direct)
一番単純で、ピクセルのインデックス値を 4 で割った余りをそのまま CHR インデックスにします。
pixels[y][x] = (idx % 4) as u8;
「パレットの 0〜3 番を意図的に CHR の 0〜3 に対応させて描いている」場合向けです。PLTE が無いインデックス PNG で自動選択されますが、PLTE があっても手動で選べます。透明エントリを除いた最大インデックスが 4 以上だった場合は「4 以上のインデックスがあります」と警告を出します。
戦略 3:RGB 近似(RGB Approx)
フルカラー PNG やスクリーンショット用です。各ピクセルの RGB を、現在の DAT パレットセット 4 色の中で一番近い色にマッピングします。
MappingStrategy::RgbApprox => {
// image クレートで RGBA に展開(アルファチャンネルを保持するため)
let img = image::load_from_memory(png_data)
.map_err(|e| format!("Image decode failed: {e}"))?
.into_rgba8();
// ...
for y in 0..h {
for x in 0..w {
let px = img.get_pixel(x as u32, y as u32);
// アルファ < 128 は透明扱い → CHR インデックス 0
if px[3] < 128 {
pixels[y][x] = 0;
transparent_count += 1;
continue;
}
let rgb = [px[0], px[1], px[2]];
let chr_idx = nearest_dat_index(rgb, dat, palette_set, master);
let expected_rgb = dat.color_rgb(palette_set, chr_idx as usize, master);
if expected_rgb != rgb { approx_count += 1; }
pixels[y][x] = chr_idx;
}
}
}
こちらは image クレートで RGBA に展開しています。アルファチャンネルをそのまま扱いたいので、こっちのほうが楽なんですよね。同じ PNG を png クレートと image クレートの両方で読むことになりますが、サイズの整合性だけチェックして良しとしています。
当然ながら再現度は「近似」でして、DAT パレットが元絵の色に近いほど良い結果になります。逆に言うと、パレットセットを先に元絵に合わせておけば、フルカラー PNG からでもかなり綺麗に取り込めます。
透過ピクセルはインデックス 0 に
透過の扱いは 3 戦略とも共通で、透明なピクセルは CHR インデックス 0 にマッピングします。
| 形式 | 透明の判定 |
|---|---|
| インデックス PNG(tRNS あり) | tRNS でアルファ = 0 のパレットエントリ |
| RGBA PNG | アルファ値 < 128 のピクセル |
NES ではインデックス 0 が背景色で、スプライトの場合は「透明色」として扱われます。なので、絵を描くときに透明にした部分がそのままスプライトの抜きになる、というのが自然な挙動かと思います。
tRNS の判定はクロージャで小さくまとめています。
// tRNS: パレットエントリのアルファ値(未記載エントリは 255 = 不透明)
let transparency = &meta.transparency;
let is_transparent_entry = |idx: usize| -> bool {
transparency.get(idx).copied().unwrap_or(255) == 0
};
tRNS チャンクは「PLTE の先頭から順にアルファ値を並べたもの」で、途中で打ち切って良い仕様なので、記載が無いエントリは不透明(255)として扱います。
警告は言語非依存の enum で持つ
インポート時の警告(近似された色数、透明ピクセル数など)は、io/png.rs の中で文字列にせず、enum のまま返しています。
/// インポート時に発生した警告(言語非依存の構造体)
#[derive(Debug, Clone)]
pub enum PngWarning {
/// 透明ピクセルがインデックス 0 にマッピングされた(px 数)
TransparentPixels(usize),
/// 透明パレットエントリがインデックス 0 にマッピングされた(エントリ数)
TransparentPaletteEntries(usize),
/// パレット照合で近似された色数
ApproxColors(usize),
/// RGB 近似されたピクセル数
ApproxPixels(usize),
/// IndexDirect で最大インデックスが 3 を超えた
IndexMaxExceeded(u8),
}
文字列化はダイアログ側で、現在の表示言語に合わせて行います。
for w in &dialog.result.warnings {
let msg = match w {
PngWarning::TransparentPixels(n) => lang.fmt_transparent_px(*n),
PngWarning::TransparentPaletteEntries(n) => lang.fmt_transparent_pal(*n),
PngWarning::ApproxColors(n) => lang.fmt_approx_colors(*n),
PngWarning::ApproxPixels(n) => lang.fmt_approx_pixels(*n),
PngWarning::IndexMaxExceeded(n) => lang.fmt_idx_warn(*n),
};
ui.colored_label(egui::Color32::YELLOW, format!("⚠ {msg}"));
}
最初は Vec で日本語の文言を直接入れていたのですが、後から英語 UI を足したときに「io 層が UI の言語を知っている」のはおかしいよなぁ、となって enum に切り替えた経緯があります。データ処理層は事実だけを返して、表現は UI 層で、というやつですね。
インポートダイアログ
ダイアログは egui::Window で出しています。
┌────────────────────────────────────────────────────────┐ │ 画像インポート │ │ │ │ ファイル: sprite.png (32×32 px = 4×4 タイル) │ │ │ │ マッピング戦略: │ │ ● パレット照合 (推奨) ○ インデックス直接 ○ RGB 近似 │ │ │ │ ⚠ 3 色がパレットに完全一致せず近似されました │ │ ⚠ 透明パレットエントリ 1 色 → インデックス 0 に変換 │ │ │ │ プレビュー (変換後): │ │ [変換後の CHR 色でレンダリングしたプレビュー] │ │ │ │ 貼り付け先: タイル 0 (0x000000) から │ │ │ │ [キャンセル] [貼り付け] │ └────────────────────────────────────────────────────────┘
プレビューは、変換結果の 0〜3 を現在のパレットで RGB にして、バンクビューと同じように ctx.load_texture() でテクスチャにしています。ラジオボタンで戦略を切り替えると preview_dirty が立って再生成されるので、変換結果をその場で見比べながら選べます。
ダイアログの状態は Option で持っていて、Some の間だけ描画、閉じたら None。即時モードだとモーダルダイアログもこの程度で済みます。
pub(super) struct PngImportDialog {
/// 読み込んだ画像の生バイト(再マッピング用)
png_bytes: Vec,
/// ファイル名(表示用)
file_name: String,
/// PNG なら true、BMP なら false(false の場合は RgbApprox のみ使用可)
is_png: bool,
/// 現在のマッピング戦略
strategy: MappingStrategy,
/// 現在の変換結果
result: PngImportResult,
/// プレビューテクスチャ(変換後 CHR 色でレンダリング)
preview_texture: Option,
/// プレビューテクスチャが古くなっているか
preview_dirty: bool,
}
元の PNG バイト列を持っているのは、戦略を変えたときに再変換するためです。
貼り付け:アンドゥ対応で CHR に書く
「貼り付け」を押すと、バンクビューで選択中のタイルを左上として書き込みます。
pub(super) fn apply_png_import(&mut self) {
if self.png_import_dialog.is_none() || self.rom.is_none() { return }
let top_left_tile = self.selected_tile.unwrap_or(0);
let chr_len = self.rom.as_ref().unwrap().chr_data().len();
// Undo 用: 影響範囲の全タイルを保存(rom の借用を先に解放)
let (tw, th, top_row, top_col) = {
let d = self.png_import_dialog.as_ref().unwrap();
(d.result.tile_width(), d.result.tile_height(),
top_left_tile / 16, top_left_tile % 16)
};
let mut batch: Vec<(usize, [u8; 16])> = Vec::new();
for by in 0..th {
for bx in 0..tw {
let offset = ((top_row + by) * 16 + (top_col + bx)) * 16;
if offset + 16 <= chr_len {
let saved: [u8; 16] = self.rom.as_ref().unwrap().chr_data()
[offset..offset + 16].try_into().unwrap();
batch.push((offset, saved));
}
}
}
self.push_undo_batch(batch);
// CHR へ書き込み
{
let dialog = self.png_import_dialog.as_ref().unwrap();
crate::io::png::write_to_chr(
self.rom.as_mut().unwrap().chr_data_mut(),
&dialog.result,
top_left_tile,
16,
);
}
self.is_modified = true;
self.texture_dirty = true;
// ...
}
画像が占めるタイル数は (width + 7) / 8 で切り上げ。その範囲のタイルを全部スナップショットしてから書くので、32×32 の画像を貼っても Cmd+Z 一発で戻せます。
write_to_chr() は、画像の各ピクセルをタイルオフセットとタイル内座標に分解して encode_dot() を呼ぶだけです。CHR の末尾を超える部分はスキップします。
pub fn write_to_chr(
chr_data: &mut [u8],
result: &PngImportResult,
top_left_tile: usize,
tiles_per_row: usize,
) {
use crate::io::chr::encode_dot;
let top_row = top_left_tile / tiles_per_row;
let top_col = top_left_tile % tiles_per_row;
for py in 0..result.height {
for px in 0..result.width {
let tile_col = top_col + px / 8;
let tile_row = top_row + py / 8;
let tile_global = tile_row * tiles_per_row + tile_col;
let tile_offset = tile_global * 16;
if tile_offset + 16 > chr_data.len() {
continue; // CHR 末尾を超えたらスキップ
}
encode_dot(
&mut chr_data[tile_offset..tile_offset + 16],
px % 8,
py % 8,
result.pixels[px],
);
}
}
}
ここでも結局 encode_dot() に辿り着くわけで、第 2 回で「全ての書き込みの最終出口」と言った意味が分かっていただけるかと。
Aseprite からの実際の流れ
最後に、自分が普段やっているワークフローを。
- Aseprite で インデックスカラーモードのスプライトを作る
- パレットを
rchr.pal(または対象ゲームの実際の NES パレット)から読み込む - File → Export で PNG 書き出し(インデックスカラーのまま)
- R-CHR で対象の
.nesを開き、貼り付け先のタイルを選択 - PNG をウィンドウにドラッグ & ドロップ → 「パレット照合」が自動選択される
- プレビューを確認して「貼り付け」
Aseprite 側で透明にした部分は tRNS に乗ってくるので、そのまま CHR のインデックス 0 になります。この流れが整ってからは、R-CHR 側で直接描くのは細かい修正だけになりました。
まとめ
- PNG インポートは「画像 → 0〜3 の 2 次元配列」に落とす変換層(
io/png.rs)と、ダイアログ UI(editor/png_import.rs)に分離 - PLTE / tRNS を直接見たいので
pngクレートで低レベルに読み、ビット深度は 1 バイト / ピクセルに展開 - 戦略は 3 つ。パレット照合(PLTE → NES 64 色 → DAT セット)、インデックス直接(mod 4)、RGB 近似(各ピクセル → DAT セット 4 色)
- ファイル種別で自動選択、ダイアログで手動切り替え + プレビュー
- 透明ピクセルはインデックス 0(スプライトの透明色)に
- 警告は言語非依存の enum で返し、UI 層で文字列化
- 貼り付けは影響タイル全部をスナップショットしてから
encode_dot()で書くので、アンドゥ可能
これで R-CHR の機能面の解説はほぼ全部です。次回は最終回、macOS のネイティブメニュー、日本語 / 英語の切り替え、そして GitHub Actions で 3 OS 向けにビルドしてリリースする仕組みについてまとめます。egui だけでは足りなかった部分をどう埋めたか、という話ですね。
ではではぁ。
またまたぁ。


















