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

6. Image Texture Mapping ​

Ray Tracing: The Next Week (v3.2.3): 6 Image Texture Mapping / 2.6 Image Texture Mapping

In addition to the procedural textures that determined colors from world coordinates in the preceding chapters, I now introduce ImageTexture for applying a two-dimensional image directly to a surface. I port the image_texture from the original to Rust and render a globe by mapping an equirectangular image of Earth, earthmap.jpg, onto a sphere.

The original C++ loads the image file at runtime with stb_image. Because these demos run in a browser as WASM, I instead bundle the image with the crate, decode it to RGB8 at build time, and embed the resulting byte sequence in the executable. The WASM runtime therefore does not need to include a JPEG decoder.

Designing ImageTexture ​

ImageTexture stores the pixel data and image dimensions and samples them through Texture::value(u, v, p). The (u,v) values come naturally from the spherical coordinates added to Sphere in Chapter 4.

JPEG and PNG decoding do not belong in common/, which should remain a small library that any renderer can depend on. Instead, the constructor from_rgb_bytes(data: Vec<u8>, width: u32, height: u32) accepts an already decoded RGB8 byte sequence. Each demo crate performs decoding in its own 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 is the number of bytes in one row, width * 3. Caching it avoids recalculating the linear offset from (j,i) during sampling.

Differences Between C++ and Rust ​

The original C++ stores unsigned char* data as a raw heap pointer and calls delete data in the destructor. Strictly speaking, this is a bug because memory returned by stbi_load should be released with stbi_image_free. Rust represents ownership with Vec<u8>, so the memory is released automatically and correctly when the value is dropped. Another difference is that the constructor only accepts a byte sequence instead of also loading the image like stbi_load. This delegates the choice of decoder, including whether to support only JPEG or PNG as well, to the demo crate and avoids adding dependencies to common/.

Implementing Sampling ​

The implementation of Texture::value follows the same steps as the original C++ code.

  1. If data is empty because loading failed, return cyan as a debugging fallback
  2. Clamp u and v to [0,1]. Image coordinates run from top to bottom while (u,v) runs from bottom to top, so invert the latter with v←1−v
  3. Calculate pixel coordinates (i,j)=(⌊u⋅W⌋,⌊v⋅H⌋) and clamp them at the boundaries
  4. Retrieve the RGB bytes at (j * bytes_per_scanline + i * 3) and multiply by 1/255 to convert them to 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)
    }
}

The _p: Point3 argument is unused because an image texture does not inspect three-dimensional world coordinates. Conversely, SolidColor and CheckerTexture do not inspect (u,v). Providing both through the Texture trait is a compromise that accommodates the different information required by each texture, following the design of the original.

Casting (u * width) to i32 truncates toward zero, but because u has been clamped to [0, 1] and is nonnegative, this is equivalent to floor. When u = 1.0, the result is i = width, so the subsequent i >= width check clamps it back to the final pixel. This also follows the original.

Decoding the Image at Build Time ​

Create the r206-earth crate and bundle assets/earthmap.jpg, reusing the image from the original repository. Its build.rs decodes the JPEG with the image crate, writes the raw RGB byte sequence to OUT_DIR/earthmap.rgb, and writes the dimensions to 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"] }

Because image is listed under build-dependencies, it runs only on the host during the build and is not included in the WASM binary. This is the principal advantage of avoiding image loading at runtime.

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");
}

Emitting cargo:rerun-if-changed causes the build script to run again only when the image or build.rs itself changes. OUT_DIR is a directory that Cargo provides to each crate for intermediate generated artifacts and is not added to the source repository.

Embedding and Loading the Image in the Crate ​

Embed the generated files from OUT_DIR at the beginning of src/lib.rs. include! imports the dimension constants, while include_bytes! imports the raw RGB byte sequence as &'static [u8]. This places 1024×512×3, or approximately 1.5 MB, directly in the WASM .rodata section. It becomes smaller after gzip compression.

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"));

Differences Between C++ and Rust ​

An equivalent C++ implementation would need to generate an array literal with a tool such as xxd -i and include it, or instruct the linker to embed the binary directly with --format=binary. Either approach makes the build configuration somewhat more involved. Rust's include_bytes! macro needs only a file path, making it useful when an asset should be bundled into an executable. Routing the generated files through OUT_DIR also avoids leaving raw RGB data in the source tree.

The Globe Scene ​

The scene is the same as the earth() function in the original, containing only one sphere of radius 2 at the origin. Passing an ImageTexture to Lambertian::with_texture directly reuses the textured Lambertian material introduced in Chapter 4.

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() copies the &'static [u8] into a Vec<u8> because ImageTexture is designed to take ownership. The texture is constructed only once and shared through Arc, so this one-time copy does not affect the rendering loop.

The camera retains the parameters used in the preceding chapters: lookfrom = (13, 2, 3), lookat = (0, 0, 0), vfov = 20°, and an aperture of 0. Because the sphere is centered at the world origin, the globe appears large and centered in the image.

As shown in the rendering at the top of the page, (u,v) on the sphere corresponds directly to the horizontal and vertical fractions of the image. The equator runs horizontally and the poles appear at the top and bottom, producing an equirectangular projection. This correspondence follows directly from sphere::get_sphere_uv, which returns

u=ϕ2π,v=θπ

The work done on Sphere in Chapter 4 makes this mapping straightforward.

Summary ​

  • Added ImageTexture to common/src/image_texture.rs. It stores an RGB8 byte sequence and image dimensions and samples pixels from UV coordinates through Texture::value. Keeping decoding out of this type and accepting only a byte sequence keeps common/ lightweight.
  • Added the r206-earth crate and bundled assets/earthmap.jpg. Its build.rs decodes the image with the host-side image crate, and only the raw RGB byte sequence is embedded in the WASM binary with include_bytes!.
  • Confirmed that the globe scene can be rendered using only the combination of a sphere's (u,v) coordinates and Lambertian::with_texture.

The tools for mapping a two-dimensional image onto a three-dimensional surface are now complete. The next chapter moves beyond textures to introduce self-emitting objects, including an emissive material and rectangular lights, in preparation for full scene lighting.