Simutrans の 画像変換システムは、PNG などの標準画像フォーマットと Simutrans 独自のバイナリスプライト形式の間で変換を行う仕組みです。makeobj ツールが PNG 画像を pak ファイルに変換する際、および Simutrans が pak ファイルからスプライトを読み込む際に使用されます。
このシステムは、Run-Length Encoding(RLE)圧縮、特殊色処理(プレイヤーカラー、ライト)、透過処理を含む高度な画像エンコーディングを実装しています。
画像変換システムは以下の目的で設計されています:
PNG 画像ファイル
↓
raw_image_t (生画像データ)
↓
image_writer_t (エンコーダ)
↓
image_t (RLE 圧縮スプライト)
↓
PAK ファイル
↓
image_reader_t (デコーダ)
↓
レンダリングシステム
関連ファイル:
src/simutrans/io/raw_image.{h,cc}, raw_image_png.cc, raw_image_bmp.cc, raw_image_ppm.ccsrc/simutrans/descriptor/writer/image_writer.{h,cc}src/simutrans/descriptor/reader/image_reader.{h,cc}src/simutrans/descriptor/image.{h,cc}生の画像データを保持するクラスです。
class raw_image_t {
public:
enum format_t {
FMT_INVALID = 0,
FMT_RGBA8888 = 1, // 32ビット RGBA
FMT_RGB888 = 2, // 24ビット RGB
FMT_GRAY8 = 3 // 8ビット グレースケール
};
private:
uint8 *data; // ピクセルデータ
uint32 width; // 画像幅
uint32 height; // 画像高さ
uint8 fmt; // フォーマット
uint8 bpp; // ビット/ピクセル
};
bool raw_image_t::read_from_file(const char *filename) {
file_info_t finfo;
const file_classify_status_t status = classify_image_file(filename, &finfo);
if (status != FILE_CLASSIFY_OK) {
return false;
}
switch (finfo.file_type) {
case file_info_t::TYPE_PNG: return read_png(filename);
case file_info_t::TYPE_BMP: return read_bmp(filename);
case file_info_t::TYPE_PPM: return read_ppm(filename);
default: return false;
}
}
bool raw_image_t::read_png_data(FILE *file) {
png_structp png_ptr = png_create_read_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
png_infop info_ptr = png_create_info_struct(png_ptr);
// エラーハンドリング
#ifdef PNG_SETJMP_SUPPORTED
if (setjmp(png_jmpbuf(png_ptr))) {
dbg->error("raw_image_t::read_png_data", "Fatal error in %s.", filename_.c_str());
png_destroy_read_struct(&png_ptr, &info_ptr, (png_info**)0);
return false;
}
#endif
// PNG ファイルの読み込み
png_init_io(png_ptr, file);
png_read_info(png_ptr, info_ptr);
// IHDR チャンクから情報取得
png_uint_32 new_width;
png_uint_32 new_height;
int bit_depth;
int color_type;
png_get_IHDR(png_ptr, info_ptr, &new_width, &new_height,
&bit_depth, &color_type, 0, 0, 0);
}
Simutrans は RGBA8888、RGB888、GRAY8 のみをサポートするため、PNG データを変換します:
/* 16 ビット/色を 8 ビットに削減 */
png_set_strip_16(png_ptr);
/* 1、2、4 ビットを 8 ビットに展開 */
png_set_packing(png_ptr);
/* パレットを RGB に展開 */
if (color_type == PNG_COLOR_TYPE_PALETTE) {
png_set_expand(png_ptr); // tRNS をアルファに変換
if (!png_get_valid(png_ptr, info_ptr, PNG_INFO_tRNS)) {
// アルファチャンネルがない場合は 0xFF(不透明)を追加
png_set_filler(png_ptr, 0xFF, PNG_FILLER_AFTER);
}
color_type = PNG_COLOR_TYPE_RGBA;
}
else if (color_type == PNG_COLOR_TYPE_RGB) {
// RGB に不透明アルファチャンネルを追加
png_set_filler(png_ptr, 0xFF, PNG_FILLER_AFTER);
color_type = PNG_COLOR_TYPE_RGBA;
}
else if (color_type == PNG_COLOR_TYPE_GA) {
// グレースケール+アルファはアルファを削除
png_set_strip_alpha(png_ptr);
color_type = PNG_COLOR_TYPE_GRAY;
}
// 行ポインタの配列を作成
png_bytep *row_pointers = MALLOCN(png_bytep, new_height);
row_pointers[0] = MALLOCN(png_byte, rowbytes * new_height);
for (uint32 row = 1; row < new_height; row++) {
row_pointers[row] = row_pointers[row - 1] + rowbytes;
}
/* 画像全体を一度に読み込み */
png_read_image(png_ptr, row_pointers);
// フォーマットを決定
format_t new_fmt = FMT_INVALID;
switch (color_type) {
case PNG_COLOR_TYPE_RGBA: new_fmt = FMT_RGBA8888; break;
case PNG_COLOR_TYPE_RGB: new_fmt = FMT_RGB888; break;
case PNG_COLOR_TYPE_GRAY: new_fmt = FMT_GRAY8; break;
}
// メモリに格納
uint8 *dst = data;
for (uint32 y = 0; y < height; y++) {
for (uint32 x = 0; x < width * (bpp/CHAR_BIT); x++) {
*dst++ = row_pointers[y][x];
}
}
free(row_pointers[0]);
free(row_pointers);
png_read_end(png_ptr, info_ptr);
png_destroy_read_struct(&png_ptr, &info_ptr, (png_infopp)NULL);
生画像からピクセルを読み取り、内部 RGB 形式に変換します:
uint32 image_writer_t::block_getpix(int x, int y) {
const uint8 *pixel_data = input_img.access_pixel(x, y);
switch (input_img.get_format()) {
case raw_image_t::FMT_GRAY8: {
const uint8 gray_level = pixel_data[0];
return
gray_level << 0 | // B
gray_level << 8 | // G
gray_level << 16; // R
}
case raw_image_t::FMT_RGBA8888: {
const uint32 pixel =
(pixel_data[2] << 0) + // B
(pixel_data[1] << 8) + // G
(pixel_data[0] << 16) + // R
(pixel_data[3] << 24); // A
// アルファチャンネルを反転(0 = 不透明)
return pixel ^ 0xFF000000;
}
case raw_image_t::FMT_RGB888: {
return
(pixel_data[2] << 0) | // B
(pixel_data[1] << 8) | // G
(pixel_data[0] << 16); // R
}
}
}
注意: Simutrans は内部的に アルファ値を反転します(0 = 不透明、255 = 透明)。
Simutrans には 31 種類の特殊色が定義されています:
const uint32 image_t::rgbtab[SPECIAL] = {
// プレイヤーカラー 1 (8 段階)
0x244B67, 0x395E7C, 0x4C7191, 0x6084A7,
0x7497BD, 0x88ABD3, 0x9CBEE9, 0xB0D2FF,
// プレイヤーカラー 2 (8 段階)
0x7B5803, 0x8E6F04, 0xA18605, 0xB49D07,
0xC6B408, 0xD9CB0A, 0xECE20B, 0xFFF90D,
// ライトと窓(15 種類)
0x57656F, // ダークウィンドウ(夜は黄色く光る)
0x7F9BF1, // ライトウィンドウ(夜は青く光る)
0xFFFF53, // 黄色ライト
0xFF211D, // 赤ライト
0x01DD01, // 緑ライト
0x6B6B6B, 0x9B9B9B, 0xB3B3B3, 0xC9C9C9, 0xDFDFDF, // グレー(メニュー用、暗くならない)
0xE3E3FF, // ほぼ白(夜は黄色)
0xC1B1D1, // ウィンドウ(黄色く光る)
0x4D4D4D, // ウィンドウ(黄色く光る)
0xFF017F, // 紫ライト
0x0101FF, // 青ライト
};
RGB 値を Simutrans の 16 ビット PIXVAL 形式に変換します:
static uint16 pixrgb_to_pixval(uint32 rgb) {
uint16 pix;
// アルファ値を確認(透明度の閾値)
#define ALPHA_THRESHOLD (0xF8000000u)
assert(rgb < ALPHA_THRESHOLD);
// アルファを 32 段階に変換(Simutrans は反転形式)
int alpha = 30 - (rgb >> 24) / 8; // 0 ... 30
// 半透明ピクセル(アルファ > 0)
if (rgb > 0x00FFFFFF) {
// 特殊色(プレイヤーカラー、ライト)かチェック
for (int i = 0; i < SPECIAL; i++) {
if (image_t::rgbtab[i] == (uint32)(rgb & 0x00FFFFFF)) {
// 特殊色 + 透明度
pix = 0x8020 + i * 31 + alpha;
return endian(pix);
}
}
// 一般的な色 + 透明度(RGB 334 フォーマット)
// R: 3 ビット、G: 4 ビット、B: 3 ビット
pix = ((rgb >> 14) & 0x0380) | // R
((rgb >> 9) & 0x0078) | // G
((rgb >> 5) & 0x0007); // B
pix = 0x8020 + 31 * 31 + pix * 31 + alpha;
return pix;
}
// 完全不透明ピクセル
// 特殊色かチェック
for (int i = 0; i < SPECIAL; i++) {
if (image_t::rgbtab[i] == (uint32)rgb) {
pix = 0x8000 + i;
return pix;
}
}
// 一般的な色(RGB 555 フォーマット)
const int r = (rgb >> 16);
const int g = (rgb >> 8) & 0xFF;
const int b = (rgb >> 0) & 0xFF;
// RGB 555: R5G5B5
pix = ((r & 0xF8) << 7) | ((g & 0xF8) << 2) | ((b & 0xF8) >> 3);
return pix;
}
16 ビット PIXVAL の構造:
不透明な一般色:
0b0RRRRRGGGGGBBBBB (RGB555)
不透明な特殊色:
0b1000000000000000 + 特殊色インデックス (0-30)
半透明な特殊色:
0b1000000000100000 + 特殊色インデックス * 31 + アルファ値 (0-30)
半透明な一般色:
0b1000000000100000 + 31 * 31 + RGB334 * 31 + アルファ値 (0-30)
メモリマップ:
0x0000 - 0x7FFF: RGB555 の不透明色(32768 色)0x8000 - 0x801E: 特殊色(31 色)0x801F: 未使用0x8020 - 0x83DD: 特殊色 + アルファ(31 色 × 31 段階)0x83DE - 0xFFFF: 一般色 + アルファ(RGB334 × 31 段階)Simutrans は行ごとの RLE 圧縮を使用してスプライトデータを圧縮します。
各行は以下の形式で保存されます:
[透明ピクセル数] [色付きピクセル数 | フラグ] [色データ...] [透明...] [色付き...] ... [0]
0x8000)が立っている場合、色データに半透明ピクセルが含まれる0 で終了uint16 *image_writer_t::encode_image(int x, int y, dimension* dim, int* len) {
int line;
uint16 *dest;
uint16 *dest_base = new uint16[img_size * img_size * 2];
uint16 *colored_run_counter;
dest = dest_base;
x += dim->xmin;
y += dim->ymin;
const int img_width = dim->xmax - dim->xmin + 1;
const int img_height = dim->ymax - dim->ymin + 1;
for (line = 0; line < img_height; line++) {
int row_px_count = 0;
uint16 clear_colored_run_pair_count = 0;
uint32 pix = block_getpix(x + row_px_count, y + line);
row_px_count++;
do { // 1 行を読み取る
uint16 count = 0;
// 透明ピクセルを読み取る
while (is_transparent(pix)) {
count++;
if (row_px_count >= img_width) {
break;
}
pix = block_getpix(x + row_px_count, y + line);
row_px_count++;
}
// 透明ピクセル数を書き込み
*dest++ = endian(count);
// 色付きピクセル数を書き込む位置を記憶
colored_run_counter = dest++;
count = 0;
PIXVAL has_transparent = 0;
while (!is_transparent(pix)) {
// 色付きピクセルを書き込み
PIXVAL pixval = pixrgb_to_pixval(pix);
// 半透明ピクセルの検出
if (pixval >= 0x8020 && !has_transparent) {
// 新しいランを開始(半透明フラグを設定)
if (count) {
*colored_run_counter = endian(uint16(count));
*dest++ = endian(uint16(0x8000)); // セパレータ
colored_run_counter = dest++;
count = 0;
}
has_transparent = 0x8000;
}
else if (pixval < 0x8020 && has_transparent) {
// 不透明ピクセルに戻る
if (count) {
*colored_run_counter = endian(uint16(count + has_transparent));
*dest++ = endian(uint16(0x8000)); // セパレータ
colored_run_counter = dest++;
count = 0;
}
has_transparent = 0;
}
*dest++ = endian(uint16(pixval));
count++;
if (row_px_count >= img_width) {
break;
}
pix = block_getpix(x + row_px_count, y + line);
row_px_count++;
}
// 色付きランが空の場合は削除
if (clear_colored_run_pair_count > 0 && count == 0) {
dest -= 2;
}
else {
*colored_run_counter = endian(uint16(count + has_transparent));
clear_colored_run_pair_count++;
}
} while (row_px_count < img_width);
*dest++ = 0; // 行の終端
}
*len = dest - dest_base;
return dest_base;
}
[透明][透明][赤][赤][赤][透明][青][透明][透明]
[2] // 透明ピクセル 2 個
[3] // 色付きピクセル 3 個
[赤PIXVAL] // 赤
[赤PIXVAL] // 赤
[赤PIXVAL] // 赤
[1] // 透明ピクセル 1 個
[1] // 色付きピクセル 1 個
[青PIXVAL] // 青
[2] // 透明ピクセル 2 個
[0] // 行の終端
[0] // 行の終端
inline bool is_transparent(const uint32 pix) {
return (pix & 0x00FFFFFF) == SPECIAL_TRANSPARENT // 特殊透明色
|| (pix >= ALPHA_THRESHOLD); // 高アルファ値
}
#define SPECIAL_TRANSPARENT (0x00E7FFFF)
#define ALPHA_THRESHOLD (0xF8000000u)
透明判定:
0x00E7FFFF (マゼンタ相当) → レガシー透明色画像の実際の描画領域(透明でない部分)を計算します:
static void init_dim(uint32 *image, dimension *dim, int img_size) {
int x, y;
bool found = false;
dim->ymin = dim->xmin = img_size;
dim->ymax = dim->xmax = 0;
for (y = 0; y < img_size; y++) {
for (x = 0; x < img_size; x++) {
if (!is_transparent(image[x + y * img_size])) {
if (x < dim->xmin) dim->xmin = x;
if (y < dim->ymin) dim->ymin = y;
if (x > dim->xmax) dim->xmax = x;
if (y > dim->ymax) dim->ymax = y;
found = true;
}
}
}
if (!found) {
// 完全に透明な画像
dim->xmin = 1;
dim->ymin = 1;
}
}
用途:
PAK ファイルから画像データを読み込みます。
obj_desc_t *image_reader_t::read_node(FILE *fp, obj_node_info_t &node) {
array_tpl<char> desc_buf(node.size);
if (fread(desc_buf.begin(), node.size, 1, fp) != 1) {
return NULL;
}
char *p = desc_buf.begin() + 6;
// バージョンを読み取る
uint8 version = decode_uint8(p);
p = desc_buf.begin();
image_t *desc = new image_t();
if (version == 0) {
// 旧フォーマット(8 ビットサイズ)
desc->x = decode_uint8(p);
desc->w = decode_uint8(p);
desc->y = decode_uint8(p);
desc->h = decode_uint8(p);
desc->alloc(decode_uint32(p)); // len
desc->imageid = IMG_EMPTY;
p += 2; // dummys
desc->zoomable = decode_uint8(p);
uint16* dest = desc->data;
p = desc_buf.begin() + 12;
if (desc->h > 0) {
for (uint i = 0; i < desc->len; i++) {
uint16 data = decode_uint16(p);
// 旧バージョンのプレイヤーカラーオフセットを修正
if (data >= 0x8000u && data <= 0x800Fu) {
data++;
}
*dest++ = data;
}
}
}
else if (version <= 2) {
// 中間フォーマット(16 ビットサイズ)
desc->x = decode_sint16(p);
desc->y = decode_sint16(p);
desc->w = decode_uint8(p);
desc->h = decode_uint8(p);
p++; // skip version information
desc->alloc(decode_uint16(p)); // len
desc->zoomable = decode_uint8(p);
desc->imageid = IMG_EMPTY;
uint16* dest = desc->data;
if (desc->h > 0) {
for (uint i = 0; i < desc->len; i++) {
*dest++ = decode_uint16(p);
}
}
}
else if (version == 3) {
// 現行フォーマット
desc->x = decode_sint16(p);
desc->y = decode_sint16(p);
desc->w = decode_sint16(p);
p++; // skip version information
desc->h = decode_sint16(p);
desc->alloc((node.size - 10) / 2); // len
desc->zoomable = decode_uint8(p);
desc->imageid = IMG_EMPTY;
uint16* dest = desc->data;
if (desc->h > 0) {
for (uint i = 0; i < desc->len; i++) {
*dest++ = decode_uint16(p);
}
}
}
return desc;
}
PAK ファイルは階層的なノード構造を持ちます:
obj_node_t {
uint32 type; // ノードタイプ(画像、車両、建物など)
uint32 size; // データサイズ
uint16 children; // 子ノード数
uint16 reserved;
uint8 data[...]; // ノードデータ
}
struct image_node_t {
sint16 x; // X オフセット
sint16 y; // Y オフセット
sint16 w; // 幅
uint8 version; // バージョン番号
sint16 h; // 高さ
uint8 zoomable; // ズーム可能フラグ
uint16 data[...]; // RLE 圧縮ピクセルデータ
};
合計サイズ: 10 バイト + データ長
#ifdef SIM_BIG_ENDIAN
#define endian(x) (uint16)((((x) & 0xFF) << 8) | (((x) >> 8) & 0xFF))
#else
#define endian(x) (x)
#endif
PAK ファイルはリトルエンディアン形式で保存されます。ビッグエンディアンシステム(PowerPC Mac など)では自動的に変換されます。
std::string image_writer_t::last_img_file;
raw_image_t image_writer_t::input_img;
bool image_writer_t::block_load(const char *fname) {
// 最後に読み込んだファイルをキャッシュ
if (last_img_file == fname) {
return true;
}
else if (load_image_from_file(fname)) {
// タイルサイズの確認
if ((input_img.get_width() % img_size != 0) ||
(input_img.get_height() % img_size != 0)) {
dbg->error("image_writer_t::block_load",
"Cannot load image file '%s': Size not divisible by %d.",
fname, img_size);
last_img_file = "";
return false;
}
last_img_file = fname;
return true;
}
last_img_file = "";
return false;
}
利点:
典型的な Simutrans スプライトの圧縮率:
例:
64×64 ピクセル = 4096 ピクセル
未圧縮: 4096 × 2 バイト = 8192 バイト
RLE 圧縮後: 1000-2000 バイト(平均)
圧縮率: 約 75-87%
// ギャップなしのメモリ配置
access_pixel(x, y+1) == access_pixel(x, y) + get_width() * (get_bpp()/CHAR_BIT)
利点:
makeobj DEBUG PAK output.pak input.dat
出力例:
Source: Obj=vehicle
Source: Name=bus_example
Image: buses.png
X: 0
Y: 0
Off X: -20
Off Y: -30
Width: 64
Height: 32
Zoom: 1
Encoding image...
Line 0: 5 clear, 15 colored, 10 clear, 20 colored
Line 1: 3 clear, 25 colored, 8 clear
...
Encoded length: 1234 bytes
症状: 画像が真っ黒になる
原因: アルファチャンネルが反転している
対処法:
症状: プレイヤーカラーが正しく機能しない
原因: RGB 値が特殊色テーブルと厳密に一致していない
対処法:
#B0D2FFエラー: Size not divisible by 64
原因: 画像サイズがタイルサイズの倍数でない
対処法:
# PAK64 の場合、画像サイズは 64 の倍数である必要がある
# 例: 64×64, 128×64, 64×128, 128×128
# PAK128 の場合
makeobj PAK128 output.pak input.dat
# 画像サイズは 128 の倍数
プレイヤーカラーは 8 段階の明るさを持ちます:
// プレイヤーカラー 1(青系)
0x244B67, // 最も暗い
0x395E7C,
0x4C7191,
0x6084A7,
0x7497BD,
0x88ABD3,
0x9CBEE9,
0xB0D2FF, // 最も明るい
使用方法:
0xFFFF53, // 黄色ライト → 夜間に明るく光る
0xFF211D, // 赤ライト → 信号機、テールライト
0x01DD01, // 緑ライト → 信号機
仕組み:
0x57656F, // ダークウィンドウ → 夜間に黄色く光る
0x7F9BF1, // ライトウィンドウ → 夜間に青く光る
使い方:
典型的なベンチマーク(現代的な CPU):
64×64 画像: 0.1-0.5 ms / 画像
128×128 画像: 0.5-2 ms / 画像
256×256 画像: 2-8 ms / 画像
Pakset 全体(10,000 画像): 5-30 秒
// 生画像データ(PNG 読み込み後)
64×64 RGBA: 64 × 64 × 4 = 16,384 バイト
// RLE 圧縮後(典型的な圧縮率 75%)
64×64 RLE: 約 1,000-2,000 バイト
// 圧縮率: 87-93%
Simutrans は PAK ファイルから PNG への変換もサポートしています(デバッグ用):
bool raw_image_t::write_png(const char *file_name) const {
png_structp png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
png_infop info_ptr = png_create_info_struct(png_ptr);
FILE *fp = dr_fopen(file_name, "wb");
if (!fp) {
return false;
}
png_init_io(png_ptr, fp);
#if PNG_LIBPNG_VER_MAJOR <= 1 && PNG_LIBPNG_VER_MINOR < 5
// 最高の圧縮レベルを設定
png_set_compression_level(png_ptr, Z_BEST_COMPRESSION);
#endif
// ヘッダーを出力
int color_type;
switch (fmt) {
case FMT_RGBA8888: color_type = PNG_COLOR_TYPE_RGBA; break;
case FMT_RGB888: color_type = PNG_COLOR_TYPE_RGB; break;
case FMT_GRAY8: color_type = PNG_COLOR_TYPE_GRAY; break;
default:
dbg->fatal("raw_image_t::write_png", "Unsupported source format");
}
png_set_IHDR(png_ptr, info_ptr, width, height, 8, color_type,
PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT,
PNG_FILTER_TYPE_DEFAULT);
png_write_info(png_ptr, info_ptr);
// ピクセルデータを書き込み
for (uint32 y = 0; y < height; ++y) {
const uint8 *row = access_pixel(0, y);
png_write_row(png_ptr, const_cast<png_bytep>(row));
}
png_write_end(png_ptr, info_ptr);
png_destroy_write_struct(&png_ptr, &info_ptr);
fclose(fp);
return true;
}
src/simutrans/io/raw_image_png.ccsrc/simutrans/io/raw_image_bmp.ccsrc/simutrans/io/raw_image_ppm.ccsrc/simutrans/io/raw_image.{h,cc}src/simutrans/descriptor/writer/image_writer.{h,cc}src/simutrans/descriptor/reader/image_reader.{h,cc}src/simutrans/descriptor/image.{h,cc}src/makeobj/makeobj.ccsrc/makeobj/CMakeLists.txt, src/makeobj/MakefileSimutrans の画像変換システムは、効率性と柔軟性を兼ね備えた高度な設計になっています:
主な特徴:
この変換システムにより、Simutrans は数万枚のスプライトを効率的に管理し、リアルタイムで滑らかなゲーム体験を提供しています。
Pakset 制作者は、このシステムを理解することで、より最適化された、視覚的に美しいコンテンツを作成できます。