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 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.
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
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.
- If
datais empty because loading failed, return cyan as a debugging fallback - Clamp
and to . Image coordinates run from top to bottom while runs from bottom to top, so invert the latter with - Calculate pixel coordinates
and clamp them at the boundaries - Retrieve the RGB bytes at
(j * bytes_per_scanline + i * 3)and multiply byto convert them to Color
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 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.
[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.
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 .rodata section. It becomes smaller after gzip compression.
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.
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, sphere::get_sphere_uv, which returns
The work done on Sphere in Chapter 4 makes this mapping straightforward.
Summary
- Added
ImageTexturetocommon/src/image_texture.rs. It stores an RGB8 byte sequence and image dimensions and samples pixels from UV coordinates throughTexture::value. Keeping decoding out of this type and accepting only a byte sequence keepscommon/lightweight. - Added the
r206-earthcrate and bundledassets/earthmap.jpg. Itsbuild.rsdecodes the image with the host-sideimagecrate, and only the raw RGB byte sequence is embedded in the WASM binary withinclude_bytes!. - Confirmed that the globe scene can be rendered using only the combination of a sphere's
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.