Skip to content
Copied!
published on 2026-06-08

6. 画像のテクスチャマッピング

前章までの「色を世界座標から手続き的に決める」テクスチャに加えて,本章では 2 次元の画像をそのまま貼り付ける ImageTexture を導入します。原典で導入する image_texture を Rust に移し,地球の経緯度マップ画像(earthmap.jpg)を球に貼って地球儀を描画します。

C++ 原典は実行時に stb_image で画像ファイルを読み込みますが,本書のデモは WASM としてブラウザ上で動かす都合上,画像をクレートに同梱し,ビルド時にデコードして RGB8 のバイト列として実行ファイルに埋め込みます。これにより WASM ランタイムには JPEG デコーダを含めずに済みます。

ImageTexture の設計

ImageTexture は画像のピクセルデータと寸法を保持し,Texture::value(u, v, p) でサンプリングします。(u,v) は 2.4 で Sphere に追加した球面座標から自然に得られます。

JPEG/PNG のデコード処理は common/ には入れません。common/ をどのレンダラからも依存できる小さなライブラリに保つためです。代わりに from_rgb_bytes(data: Vec<u8>, width: u32, height: u32) というコンストラクタで,既にデコード済みの RGB8 バイト列を受け取る形にします。デコードは各デモクレートの build.rs に委ねます。

common/src/image_texture.rs
rust
use crate::texture::Texture;
use crate::vec3::{Color, Point3};

pub const BYTES_PER_PIXEL: usize = 3;

pub struct ImageTexture {
    data: Vec<u8>,
    width: u32,
    height: u32,
    bytes_per_scanline: usize,
}

impl ImageTexture {
    pub fn from_rgb_bytes(data: Vec<u8>, width: u32, height: u32) -> Self {
        let expected = (width as usize) * (height as usize) * BYTES_PER_PIXEL;
        if width == 0 || height == 0 || data.len() != expected {
            // Empty texture: sampling returns solid cyan as a debugging aid.
            return ImageTexture {
                data: Vec::new(),
                width: 0,
                height: 0,
                bytes_per_scanline: 0,
            };
        }
        let bytes_per_scanline = BYTES_PER_PIXEL * (width as usize);
        ImageTexture {
            data,
            width,
            height,
            bytes_per_scanline,
        }
    }
}

bytes_per_scanline は 1 行あたりのバイト数(width * 3)で,後で (j, i) から線形オフセットを計算する際に再計算を省くためのキャッシュです。

C++ と Rust の違い

C++ 原典は unsigned char* data を生のヒープポインタで保持し,デストラクタで delete data します(実は stbi_load が返した領域は stbi_image_free で解放すべきで,原典は厳密にはバグです)。Rust では Vec<u8> で所有権を表現するので,ドロップ時のメモリ解放は自動で正しく行われます。stbi_load のように画像読み込みも兼ねたコンストラクタは作らず,バイト列を受け取るだけのコンストラクタに分離した点も違いです。これによりデコーダの選択(JPEG だけサポートするか PNG も含めるか,など)をデモクレート側に押し付けられ,common/ の依存を増やさずに済みます。

サンプリングの実装

Texture::value の実装は,原典の C++ コードと同じ手順を踏みます。

  1. data が空(読み込み失敗)なら シアンを返す(デバッグ用フォールバック)
  2. u,v[0,1] にクランプ。v は画像座標が上→下である一方 (u,v) は下→上なので,v1v で反転
  3. ピクセル座標 (i,j)=(uW,vH) を計算し,端をクランプ
  4. (j * bytes_per_scanline + i * 3) で RGB バイトを取り出し,1/255 をかけて Color に変換
common/src/image_texture.rs
rust
impl Texture for ImageTexture {
    fn value(&self, u: f64, v: f64, _p: Point3) -> Color {
        if self.data.is_empty() {
            return Color::new(0.0, 1.0, 1.0);
        }

        let u = u.clamp(0.0, 1.0);
        let v = 1.0 - v.clamp(0.0, 1.0);

        let mut i = (u * self.width as f64) as i32;
        let mut j = (v * self.height as f64) as i32;
        if i >= self.width as i32 {
            i = self.width as i32 - 1;
        }
        if j >= self.height as i32 {
            j = self.height as i32 - 1;
        }
        if i < 0 {
            i = 0;
        }
        if j < 0 {
            j = 0;
        }

        let color_scale = 1.0 / 255.0;
        let offset = (j as usize) * self.bytes_per_scanline + (i as usize) * BYTES_PER_PIXEL;
        let r = self.data[offset] as f64;
        let g = self.data[offset + 1] as f64;
        let b = self.data[offset + 2] as f64;
        Color::new(color_scale * r, color_scale * g, color_scale * b)
    }
}

ここで _p: Point3 が未使用なのは,画像テクスチャは 3 次元世界座標を見ないからです(SolidColorCheckerTexture は逆に (u,v) を見ません)。Texture トレイトの値関数が両方を受け取る設計は,テクスチャ毎に必要な情報が異なるための妥協で,原典と同じ流儀です。

(u * width)i32 にキャストすると負方向に丸め(Rust の as i32 は 0 方向に切り捨て)になりますが,u[0, 1] にクランプしているので入力は非負,意味的には floor と同じです。u = 1.0 のときは i = width になってしまうので,後段の i >= width クランプで回収しています。この処理も原典と同じです。

ビルド時に画像をデコードする

r206-earth クレートを新設し,assets/earthmap.jpg を同梱します(原典のリポジトリの画像をそのまま流用)。build.rsimage クレートで JPEG をデコードし,生 RGB バイト列を OUT_DIR/earthmap.rgb に,寸法を OUT_DIR/earthmap_dims.rs に書き出します。

r206-earth/Cargo.toml
toml
[package]
name = "r206-earth"
version = "0.1.0"
edition = "2024"

[dependencies]
common = { workspace = true }

[build-dependencies]
image = { version = "0.25", default-features = false, features = ["jpeg"] }

build-dependencies に置いているので,image クレートは ホストのビルド側でしか走らず,WASM バイナリには一切入りません。これが「実行時に画像を読み込まない」設計の利点です。

r206-earth/build.rs
rust
use std::env;
use std::fs;
use std::path::PathBuf;

fn main() {
    let asset = "assets/earthmap.jpg";
    println!("cargo:rerun-if-changed={}", asset);
    println!("cargo:rerun-if-changed=build.rs");

    let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set"));

    let img = image::open(asset)
        .unwrap_or_else(|e| panic!("failed to open {}: {}", asset, e))
        .to_rgb8();
    let (width, height) = img.dimensions();
    let raw = img.into_raw();

    fs::write(out_dir.join("earthmap.rgb"), &raw).expect("failed to write earthmap.rgb");
    fs::write(
        out_dir.join("earthmap_dims.rs"),
        format!(
            "pub const EARTHMAP_WIDTH: u32 = {};\npub const EARTHMAP_HEIGHT: u32 = {};\n",
            width, height
        ),
    )
    .expect("failed to write earthmap_dims.rs");
}

cargo:rerun-if-changed を出力しておくと,画像か build.rs 自身が変わったときだけ再ビルドが走ります。OUT_DIR は cargo がクレートごとに用意してくれる中間生成物用ディレクトリで,本番のリポジトリには入りません。

クレート側で埋め込み・ロードする

src/lib.rs の冒頭で OUT_DIR の生成物を埋め込みます。include! で寸法定数を取り込み,include_bytes! で生 RGB バイト列を &'static [u8] として取り込みます。これにより 1024 × 512 × 3 = 約 1.5 MB のデータが WASM の .rodata に直接焼き込まれます(gzip 圧縮後はもっと小さくなります)。

r206-earth/src/lib.rs
rust
include!(concat!(env!("OUT_DIR"), "/earthmap_dims.rs"));

const EARTHMAP_RGB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/earthmap.rgb"));

C++ と Rust の違い

C++ で同等のことを行うには xxd -i などで配列リテラルを生成して #include するか,リンカに --format=binary でバイナリを直接埋め込ませる必要があり,ビルド設定がやや込み入ります。Rust の include_bytes! マクロはこの種の埋め込みを ファイルパスを書くだけで済ませるため,アセットを実行ファイルに同梱したいケースで重宝します。OUT_DIR 経由にしてあるので,ソースツリーには生 RGB を残さずに済みます。

地球儀のシーン

シーンは原典の earth() 関数そのままで,半径 2 の球を原点に 1 つ置くだけです。ImageTextureLambertian::with_texture に渡せば,2.4 で導入した「テクスチャ化された Lambertian」がそのまま使えます。

r206-earth/src/lib.rs
rust
fn earth() -> HittableList {
    let mut objects = HittableList::new();

    let earth_texture: Arc<dyn Texture> = Arc::new(ImageTexture::from_rgb_bytes(
        EARTHMAP_RGB.to_vec(),
        EARTHMAP_WIDTH,
        EARTHMAP_HEIGHT,
    ));
    let earth_surface = Arc::new(Lambertian::with_texture(earth_texture));
    objects.add(Box::new(Sphere::with_material(
        Point3::new(0.0, 0.0, 0.0),
        2.0,
        earth_surface,
    )));

    objects
}

EARTHMAP_RGB.to_vec()&'static [u8]Vec<u8> にコピーしているのは,ImageTexture が所有権を取りたい設計だからです。テクスチャを 1 回だけ作って Arc で共有するこのシーンでは,コピーは初回 1 回限りで,描画ループのコストには影響しません。

カメラは前章までと同じ lookfrom = (13, 2, 3)lookat = (0, 0, 0)vfov = 20°,絞り 0 で,球が世界の中心に置かれているので地球が画面中央に大きく見えます。

ページ冒頭のレンダリング結果のように,球面上の (u,v) がそのまま画像の縦横分数に対応するので,赤道が水平に走り,極が球の上下になる正距円筒図法(equirectangular projection)の貼り方になります。これは sphere::get_sphere_uv

u=ϕ2π,v=θπ

で UV を返していたからこその素直な対応で,Sphere 側を 2.4 で整備しておいた効果がここで効いてきます。

まとめ

  • common/src/image_texture.rsImageTexture を新設した。RGB8 バイト列と寸法を持ち,Texture::value で UV からピクセルをサンプリングする。デコード機能は持たず,バイト列を受け取るだけのコンストラクタにすることで common/ を軽量に保った。
  • r206-earth クレートを追加し,assets/earthmap.jpg を同梱。build.rs でホスト側に image クレートを使ってデコードし,WASM には生 RGB バイト列だけを include_bytes! で埋め込む構成にした。
  • 地球儀のシーンが Sphere(u, v) 座標と Lambertian::with_texture の組み合わせだけで描画できることを確認した。

これで 2D 画像を 3D サーフェスに貼り付ける道具が揃いました。次章では,テクスチャから一歩進んで,自ら光を発する物体(発光マテリアルと矩形ライト)を導入し,シーンに本格的な照明を入れる準備をします。