mirage-engine 0.1.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
//! What the tone map, the exposure, the bloom chain and the samples per pixel leave of a frame.

use super::*;

/// The light the curves are read at, from black to well past the screen's
/// range.
const RAMP: [f32; 6] = [0.0, 0.25, 0.5, 1.0, 4.0, 64.0];

/// A square whose only light is its own, drawn at whatever exposure and
/// bloom the test set.
struct Glow {
    light: Color,
    scale: f32,
    exposure: f32,
    bloom: f32,
}

impl Glow {
    /// A square filling the view, drawn as the chain's defaults would.
    fn lit(light: Color) -> Self {
        Self {
            light,
            scale: 1.0,
            exposure: 1.0,
            bloom: 0.0,
        }
    }
}

impl Game for Glow {
    type Meshes = QuadSet;
    type Sounds = NoSounds;
    type InputActions = NoInputActions;
    type Skyboxes = NoSkyboxes;
    type SurfaceStyles = ();
    type PostEffects = ();

    fn tick(&mut self, _ctx: &mut TickContext<'_, Self>) {}

    fn frame(&mut self, ctx: &mut FrameContext<'_, Self>) {
        ctx.set_camera(Camera::new(
            View::look_at(Vec3::Z, Vec3::ZERO),
            Projection::orthographic(1.0),
        ));
        ctx.set_exposure(self.exposure);
        ctx.set_bloom(self.bloom);
        ctx.draw(
            Quad.at(Transform::from_scale(Vec3::splat(self.scale)))
                .material(Material::color(Color::BLACK).emissive(self.light)),
        );
    }
}

/// The value `curve` leaves of each step of [`RAMP`], read off the red
/// channel.
fn ramp(curve: Tonemap) -> Option<Vec<u8>> {
    RAMP.iter()
        .map(|&light| {
            let config = Config::new("headless tone map").with_tonemap(curve);
            let pixels = rendered(config, Glow::lit(Color::rgb(light, light, light)))?;
            Some(middle(&pixels)[0])
        })
        .collect()
}

#[test]
fn every_curve_rises_with_the_light_it_maps_and_holds_at_white() {
    let Some(off) = ramp(Tonemap::Off) else {
        eprintln!("skipped: this machine has no usable graphics adapter");
        return;
    };
    let Some(neutral) = ramp(Tonemap::Neutral) else {
        return;
    };
    let Some(aces) = ramp(Tonemap::Aces) else {
        return;
    };

    for mapped in [&off, &neutral, &aces] {
        assert_eq!(mapped[0], 0, "no light leaves no light, got {mapped:?}");
        assert!(
            mapped.windows(2).all(|pair| pair[0] <= pair[1]),
            "{mapped:?} never falls as its light rises"
        );
        assert_eq!(
            mapped.last(),
            Some(&u8::MAX),
            "{mapped:?} reaches white and stays a number"
        );
    }

    assert_eq!(off[3], u8::MAX, "off clamps at the screen's range");
    assert!(
        neutral[3] < u8::MAX && aces[3] < u8::MAX,
        "a curve keeps room past what the screen shows, got {neutral:?} {aces:?}"
    );
}

#[test]
fn exposure_scales_the_frame_before_the_curve() {
    let quarter = Color::rgb(0.25, 0.25, 0.25);
    let at = |exposure| {
        let square = Glow {
            exposure,
            ..Glow::lit(quarter)
        };
        Some(middle(&rendered(raw("headless exposure"), square)?)[0])
    };

    let Some(plain) = at(1.0) else {
        eprintln!("skipped: this machine has no usable graphics adapter");
        return;
    };
    let Some(doubled) = at(2.0) else {
        return;
    };
    let Some(none) = at(0.0) else {
        return;
    };

    assert!(doubled > plain, "{doubled} is more light than {plain}");
    assert_eq!(none, 0, "no exposure leaves no light");
}

#[test]
fn bloom_spreads_light_past_what_drew_it_and_none_skips_the_chain() {
    let read = |bloom| {
        let square = Glow {
            scale: 0.25,
            bloom,
            ..Glow::lit(Color::WHITE)
        };
        let pixels = sized(raw("headless bloom"), UVec2::splat(WIDE), square)?;
        // A quarter of the way in: outside the square itself (which
        // reaches only one eighth of of the way from the center), close
        // enough for the chain's spread to read above the background.
        let beside = ((WIDE / 4 * WIDE + WIDE / 4) * 4) as usize;
        let center = ((WIDE / 2 * WIDE + WIDE / 2) * 4) as usize;
        // The target's own corner, which the small square never reaches.
        Some((pixels[beside], pixels[center], pixels[0]))
    };

    let Some((dark, full, background)) = read(0.0) else {
        eprintln!("skipped: this machine has no usable graphics adapter");
        return;
    };
    let Some((scattered, spent, _)) = read(0.5) else {
        return;
    };

    assert_eq!(
        dark, background,
        "nothing reaches beside the square with the chain skipped"
    );
    assert_eq!(full, u8::MAX, "the square itself fills the screen's range");
    assert!(
        scattered > background,
        "the chain spreads light in beside it, got {scattered} against {background}"
    );
    assert!(
        spent < full,
        "and takes it from the square, which keeps the frame's light whole"
    );
}

/// A square turned away from the pixel rows, so its edges run through
/// pixels rather than between them.
struct Tilted;

impl Game for Tilted {
    type Meshes = QuadSet;
    type Sounds = NoSounds;
    type InputActions = NoInputActions;
    type Skyboxes = NoSkyboxes;
    type SurfaceStyles = ();
    type PostEffects = ();

    fn tick(&mut self, _ctx: &mut TickContext<'_, Self>) {}

    fn frame(&mut self, ctx: &mut FrameContext<'_, Self>) {
        ctx.set_camera(Camera::new(
            View::look_at(Vec3::Z, Vec3::ZERO),
            Projection::orthographic(2.0),
        ));
        ctx.draw(
            Quad.at(Transform::from_rotation(Quat::from_rotation_z(0.4)))
                .material(Material::color(Color::WHITE)),
        );
    }
}

#[test]
fn antialiasing_leaves_edge_pixels_between_the_two_sides_of_an_edge() {
    let between = |antialiasing| {
        let config = raw("headless edges").with_antialiasing(antialiasing);
        let pixels = sized(config, UVec2::splat(WIDE), Tilted)?;
        // The corner, which the turned square never reaches.
        let background = i32::from(wide_pixel(&pixels, 0, 0)[0]);
        Some(
            pixels
                .chunks(4)
                .filter(|pixel| {
                    let red = i32::from(pixel[0]);
                    red > background + 16 && red < 240
                })
                .count(),
        )
    };

    let Some(smooth) = between(true) else {
        eprintln!("skipped: this machine has no usable graphics adapter");
        return;
    };
    let Some(stepped) = between(false) else {
        return;
    };

    assert_eq!(stepped, 0, "one sample per pixel takes one side or other");
    assert!(smooth > 0, "four of them take both where an edge crosses");
}