mirage-engine 0.1.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
//! What a blocker casts over the ground a light reaches.

use super::*;

/// The position a blocker is drawn at for a sun and a cone, and its
/// material.
const STANDING: Vec3 = Vec3::new(-2.0, 2.0, 0.0);
const SOLID: Material = Material::lit(Color::WHITE);

/// The side of the target a cascade scene is read off.
const FIELD: u32 = 128;

/// The two views a standing sprite is seen from: straight down on it,
/// which leaves it in no upright plane at all, and down at it from
/// behind, which is the view a game stands a sprite in.
const OVERHEAD: Camera = Camera::new(
    View::look_at(Vec3::new(0.0, 10.0, 0.0), Vec3::ZERO).with_up(Vec3::NEG_Z),
    Projection::orthographic(ACROSS),
);
const OVER_THE_SHOULDER: Camera = Camera::new(
    View::look_at(Vec3::new(0.0, 4.0, 5.0), Vec3::new(0.0, 1.0, 0.0)),
    Projection::perspective(45.0),
);

/// Ground extent a cascade scene lays down, in meters.
const LONG: f32 = 100.0;

/// The two cameras a cascade scene is seen from, both looking at the
/// origin: one close over it, within the nearest cascade, and one far
/// back and above it, past where that cascade is split.
const CLOSE: Camera = Camera::new(
    View::look_at(Vec3::new(0.0, 1.2, 2.5), Vec3::ZERO),
    Projection::perspective(60.0),
);
const BACK: Camera = Camera::new(
    View::look_at(Vec3::new(0.0, 12.0, 30.0), Vec3::ZERO),
    Projection::perspective(60.0),
);

meshes! { enum RelievedSet { Relieved } }

/// A ground plane with a blocker drawn over it, turned to the camera
/// or not and faded or not, lit by one light.
struct Cast {
    light: Light,
    blocker: Option<(Vec3, Material)>,
    faced: bool,
    fade: f32,
}

/// Seen from straight above, so that the ground fills the target and the
/// blocker is well left of what is read back.
impl Game for Cast {
    type Meshes = GroundSet;
    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::Y * 10.0, Vec3::ZERO).with_up(Vec3::NEG_Z),
            Projection::orthographic(ACROSS),
        ));
        ctx.light(self.light);
        ctx.draw(
            Ground::Floor
                .at(Transform::from_scale(Vec3::splat(ACROSS)))
                .material(Material::lit(Color::WHITE)),
        );
        if let Some((standing, material)) = self.blocker {
            let blocker = Ground::Blocker
                .at(standing)
                .material(material)
                .faded(self.fade);
            ctx.draw(if self.faced {
                blocker.billboard()
            } else {
                blocker
            });
        }
    }
}

/// The colors one cast scene leaves at the origin, which every blocker
/// here is in front of, and three meters along `X`, which none is.
fn cast(light: Light, blocker: Option<(Vec3, Material)>) -> Option<([u8; 4], [u8; 4])> {
    shone(Cast {
        light,
        blocker,
        faced: false,
        fade: 1.0,
    })
}

/// The same, over a scene the test laid out itself.
fn shone(scene: Cast) -> Option<([u8; 4], [u8; 4])> {
    let config = raw("headless shadows").with_shadow_resolution(512);
    let pixels = sized(config, UVec2::splat(YARD), scene)?;
    let ground = |across: u32| {
        let at = ((YARD / 2 * YARD + across) * 4) as usize;
        [pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]]
    };

    Some((ground(YARD / 2), ground(YARD / 2 + 24)))
}

#[test]
fn a_translucent_blocker_takes_the_fraction_of_the_light_its_alpha_covers() {
    let sun = Light::directional(Vec3::new(1.0, -1.0, 0.0), Color::WHITE).shadow();
    let glass = |alpha| Material::lit(Color::rgba(1.0, 1.0, 1.0, alpha));

    let Some((behind_glass, _)) = cast(sun, Some((STANDING, glass(0.5)))) else {
        eprintln!("skipped: this machine has no usable graphics adapter");
        return;
    };
    let Some((behind_a_pane_of_air, _)) = cast(sun, Some((STANDING, glass(0.0)))) else {
        return;
    };
    let Some((clear, _)) = cast(sun, None) else {
        return;
    };

    assert!(
        behind_glass[0] > 0 && behind_glass[0] < clear[0],
        "half of the light passes: {behind_glass:?} against {clear:?}"
    );
    assert_eq!(
        behind_a_pane_of_air, clear,
        "and a draw covering nothing blocks nothing"
    );
}

#[test]
fn an_additive_blocker_casts_nothing() {
    let sun = Light::directional(Vec3::new(1.0, -1.0, 0.0), Color::WHITE).shadow();

    let Some((behind, _)) = cast(sun, Some((STANDING, SOLID.additive()))) else {
        eprintln!("skipped: this machine has no usable graphics adapter");
        return;
    };
    let Some((clear, _)) = cast(sun, None) else {
        return;
    };

    assert_eq!(behind, clear, "a draw that is light blocks none of it");
}

/// A ground plane with one faced sprite standing on it, lit by a sun
/// crossing the ground along `+Z`, seen from the camera the test names.
struct Standing {
    sprite: Relieved,
    seen: Camera,
    casts: bool,
}

impl Game for Standing {
    type Meshes = RelievedSet;
    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(self.seen);
        let sun = Light::directional(Vec3::new(0.0, -1.0, 1.0), Color::WHITE);
        ctx.light(if self.casts { sun.shadow() } else { sun });
        ctx.draw(
            Relieved::Ground
                .at(Transform::from_scale(Vec3::splat(ACROSS)))
                .material(Material::lit(Color::WHITE)),
        );
        ctx.draw(
            self.sprite
                .at(Transform::from_scale_rotation_translation(
                    Vec3::new(2.0, 2.0, 1.0),
                    Quat::IDENTITY,
                    Vec3::Y,
                ))
                .material(Material::lit(Color::WHITE).cutout())
                .billboard(),
        );
    }
}

/// The meshes a `Standing` scene draws: the ground, a flat sprite, and
/// two drawn in a relief a whole sprite width deep.
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
enum Relieved {
    Ground,
    Flat,
    Mapped,
    Domed,
}

impl Catalog for Relieved {
    fn catalog() -> Vec<Self> {
        vec![Self::Ground, Self::Flat, Self::Mapped, Self::Domed]
    }
}

impl Mesh for Relieved {
    fn build(&self, assets: &Assets) -> MeshData {
        let white = TextureData::rgba8(UVec2::ONE, vec![u8::MAX; 4]);
        match self {
            Self::Ground => Plane.build(assets),
            Self::Flat => Quad.build(assets).with_texture(white),
            // A normal straight out of the sprite, at its full depth.
            Self::Mapped => Quad
                .build(assets)
                .with_texture(white)
                .with_relief(ReliefData::rgba8(
                    UVec2::ONE,
                    vec![128, 128, u8::MAX, u8::MAX],
                )),
            // The same at its full depth, turned up the map.
            Self::Domed => Quad
                .build(assets)
                .with_texture(white)
                .with_relief(ReliefData::rgba8(UVec2::ONE, vec![128, 218, 218, u8::MAX])),
        }
    }
}

/// The color a `Standing` scene's own sprite draws at its middle, which
/// both cameras below look at.
fn stood(sprite: Relieved, seen: Camera, casts: bool) -> Option<[u8; 4]> {
    let config = raw("headless relief").with_shadow_resolution(512);
    let scene = Standing {
        sprite,
        seen,
        casts,
    };

    Some(middle(&sized(config, UVec2::splat(SIDE), scene)?))
}

#[test]
fn a_faced_draw_never_stands_in_its_own_shadow() {
    for seen in [OVERHEAD, OVER_THE_SHOULDER] {
        let sprite = |sprite, casts| stood(sprite, seen, casts);

        let Some(domed) = sprite(Relieved::Domed, true) else {
            eprintln!("skipped: this machine has no usable graphics adapter");
            return;
        };
        let (Some(domed_clear), Some(mapped), Some(mapped_clear), Some(flat), Some(flat_clear)) = (
            sprite(Relieved::Domed, false),
            sprite(Relieved::Mapped, true),
            sprite(Relieved::Mapped, false),
            sprite(Relieved::Flat, true),
            sprite(Relieved::Flat, false),
        ) else {
            return;
        };

        assert_eq!(
            domed, domed_clear,
            "a relief turned off the sprite clears its own record"
        );
        assert_eq!(
            mapped, mapped_clear,
            "one straight out of it clears its own"
        );
        assert_eq!(flat, flat_clear, "and a flat sprite clears its own");
        assert_ne!(mapped, flat, "where the three are lit by different normals");
        assert_ne!(domed, mapped, "the turned one taking its own share");
    }
}

/// A ground plane with one blocker over it, lit by a sun that drops the
/// blocker's shadow over the origin.
struct Cascades {
    camera: Camera,
    light: Light,
    blocker: Option<Transform>,
}

impl Game for Cascades {
    type Meshes = GroundSet;
    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(self.camera);
        ctx.light(self.light);
        ctx.draw(
            Ground::Floor
                .at(Transform::from_scale(Vec3::splat(LONG)))
                .material(SOLID),
        );
        if let Some(standing) = self.blocker {
            ctx.draw(Ground::Blocker.at(standing).material(SOLID));
        }
    }
}

/// A blocker `side` meters across, placed where the sun of a cascade
/// scene drops its shadow over the origin.
fn standing(side: f32) -> Transform {
    let lift = side * 0.75;

    Transform::from_scale_rotation_translation(
        Vec3::splat(side),
        Quat::IDENTITY,
        Vec3::new(0.0, lift, -lift),
    )
}

/// The color one cascade scene leaves at the origin, which is the
/// middle of either camera's view.
fn over(camera: Camera, light: Light, blocker: Option<f32>) -> Option<[u8; 4]> {
    let config = raw("headless cascades").with_shadow_resolution(512);
    let scene = Cascades {
        camera,
        light,
        blocker: blocker.map(standing),
    };
    let pixels = sized(config, UVec2::splat(FIELD), scene)?;
    let at = ((FIELD / 2 * FIELD + FIELD / 2) * 4) as usize;

    Some([pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]])
}

#[test]
fn a_suns_cascades_darken_the_ground_near_the_camera_and_far_from_it() {
    let sun = Light::directional(Vec3::new(0.0, -1.0, 1.0), Color::WHITE).shadow();
    let Some([near, ..]) = over(CLOSE, sun, Some(1.0)) else {
        eprintln!("skipped: this machine has no usable graphics adapter");
        return;
    };
    let (Some([near_clear, ..]), Some([far, ..]), Some([far_clear, ..])) = (
        over(CLOSE, sun, None),
        over(BACK, sun, Some(3.0)),
        over(BACK, sun, None),
    ) else {
        return;
    };

    assert!(
        near < near_clear,
        "the nearest cascade darkens the ground behind a blocker, \
         which reads {near} against {near_clear}"
    );
    assert!(
        far < far_clear,
        "and the widest carries what is past its split, \
         which reads {far} against {far_clear}"
    );
}