scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
#[allow(dead_code)]
mod support;

use std::fs::{self, File};
use std::io::BufWriter;
use std::path::{Path, PathBuf};

use scena::{
    AnimationChannel, AnimationClip, AnimationInterpolation, AnimationOutput, AnimationTarget,
    AntiAliasing, AssetPath, Assets, Color, GeometryDesc, MaterialDesc, Scene, Transform, Vec3,
};
use support::parity::{
    ParitySweep, PixelRegion, RenderBackend, RgbaFrame, renderer_for_backend,
    require_cpu_gpu_parity_adapter_or_skip,
};

const WIDTH: u32 = 96;
const HEIGHT: u32 = 72;
const SCHEMA: &str = "scena.dynamic_transform_parity_sweep.v1";

#[derive(Debug)]
struct MotionCapture {
    before: Vec<u8>,
    after: Vec<u8>,
    before_centroid: (f32, f32),
    after_centroid: (f32, f32),
}

#[derive(Debug)]
struct MovementRecord {
    case_name: &'static str,
    cpu_delta_x: f32,
    gpu_delta_x: f32,
    cpu_delta_y: f32,
    gpu_delta_y: f32,
}

#[test]
fn dynamic_transform_motion_matches_cpu_and_gpu_for_authored_animation_and_imports() {
    if !require_cpu_gpu_parity_adapter_or_skip(
        "dynamic_transform_motion_matches_cpu_and_gpu_for_authored_animation_and_imports",
    ) {
        return;
    }

    let artifacts = artifact_dir();
    let region = PixelRegion {
        x: 0,
        y: 0,
        width: WIDTH,
        height: HEIGHT,
    };
    let mut sweep = ParitySweep::new(SCHEMA);
    let mut movements = Vec::new();

    compare_case(
        "authored-set-transform",
        render_authored_set_transform,
        &artifacts,
        region,
        &mut sweep,
        &mut movements,
    );
    compare_case(
        "authored-animation-seek",
        render_authored_animation_seek,
        &artifacts,
        region,
        &mut sweep,
        &mut movements,
    );
    compare_case(
        "imported-gltf-set-transform",
        render_imported_set_transform,
        &artifacts,
        region,
        &mut sweep,
        &mut movements,
    );

    let movement_json = movement_records_json(&movements);
    sweep.write_json(
        &artifacts.join("dynamic-transform-parity.json"),
        &[("movement_records", movement_json)],
    );
}

fn compare_case(
    case_name: &'static str,
    render: fn(RenderBackend, &Path, &'static str) -> MotionCapture,
    artifacts: &Path,
    region: PixelRegion,
    sweep: &mut ParitySweep,
    movements: &mut Vec<MovementRecord>,
) {
    let cpu = render(RenderBackend::Cpu, artifacts, case_name);
    let gpu = render(RenderBackend::Gpu, artifacts, case_name);

    let cpu_delta_x = cpu.after_centroid.0 - cpu.before_centroid.0;
    let gpu_delta_x = gpu.after_centroid.0 - gpu.before_centroid.0;
    let cpu_delta_y = cpu.after_centroid.1 - cpu.before_centroid.1;
    let gpu_delta_y = gpu.after_centroid.1 - gpu.before_centroid.1;
    assert!(
        cpu_delta_x > 10.0,
        "{case_name} CPU path must move rendered object pixels: before={:?}, after={:?}",
        cpu.before_centroid,
        cpu.after_centroid
    );
    assert!(
        gpu_delta_x > 10.0,
        "{case_name} GPU path must move rendered object pixels, not only update scene state: before={:?}, after={:?}",
        gpu.before_centroid,
        gpu.after_centroid
    );
    assert!(
        (cpu_delta_x - gpu_delta_x).abs() <= 8.0,
        "{case_name} CPU/GPU movement delta must agree: cpu_delta_x={cpu_delta_x:.2}, gpu_delta_x={gpu_delta_x:.2}"
    );
    assert!(
        (cpu_delta_y - gpu_delta_y).abs() <= 4.0,
        "{case_name} CPU/GPU vertical movement must remain stable: cpu_delta_y={cpu_delta_y:.2}, gpu_delta_y={gpu_delta_y:.2}"
    );

    let before = sweep.compare_region(
        format!("{case_name}-before-cpu-vs-gpu"),
        RgbaFrame::new("cpu-before", &cpu.before, WIDTH, HEIGHT),
        RgbaFrame::new("gpu-before", &gpu.before, WIDTH, HEIGHT),
        region,
    );
    assert!(
        before.rmse <= 0.14,
        "{case_name} CPU/GPU initial-frame RMSE too high: {:.5}",
        before.rmse
    );
    assert!(
        before.channel_delta.mean_channel_delta <= 22.0,
        "{case_name} CPU/GPU initial-frame mean channel delta too high: {:.5}",
        before.channel_delta.mean_channel_delta
    );

    let after = sweep.compare_region(
        format!("{case_name}-after-cpu-vs-gpu"),
        RgbaFrame::new("cpu-after", &cpu.after, WIDTH, HEIGHT),
        RgbaFrame::new("gpu-after", &gpu.after, WIDTH, HEIGHT),
        region,
    );
    assert!(
        after.rmse <= 0.14,
        "{case_name} CPU/GPU moved-frame RMSE too high: {:.5}",
        after.rmse
    );
    assert!(
        after.channel_delta.mean_channel_delta <= 22.0,
        "{case_name} CPU/GPU moved-frame mean channel delta too high: {:.5}",
        after.channel_delta.mean_channel_delta
    );

    movements.push(MovementRecord {
        case_name,
        cpu_delta_x,
        gpu_delta_x,
        cpu_delta_y,
        gpu_delta_y,
    });
}

fn render_authored_set_transform(
    backend: RenderBackend,
    artifacts: &Path,
    case_name: &'static str,
) -> MotionCapture {
    render_motion(backend, artifacts, case_name, |scene, assets| {
        let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.34, 0.34, 0.34));
        let material =
            assets.create_material(MaterialDesc::unlit(Color::from_srgb_u8(80, 190, 240)));
        let moving = scene
            .mesh(geometry, material)
            .transform(Transform::at(Vec3::new(-0.45, 0.0, 0.0)))
            .add()
            .expect("authored moving mesh inserts");
        Box::new(move |scene| {
            scene
                .set_transform(moving, Transform::at(Vec3::new(0.45, 0.0, 0.0)))
                .expect("authored moving mesh transform updates");
        })
    })
}

fn render_authored_animation_seek(
    backend: RenderBackend,
    artifacts: &Path,
    case_name: &'static str,
) -> MotionCapture {
    render_motion(backend, artifacts, case_name, |scene, assets| {
        let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.34, 0.34, 0.34));
        let material =
            assets.create_material(MaterialDesc::unlit(Color::from_srgb_u8(240, 170, 70)));
        let moving = scene
            .mesh(geometry, material)
            .add()
            .expect("authored animated mesh inserts");
        let clip = AnimationClip::authored(
            Some("MoveX".to_owned()),
            vec![AnimationChannel::new(
                moving,
                AnimationTarget::Translation,
                vec![0.0, 1.0],
                AnimationOutput::Vec3(vec![Vec3::new(-0.45, 0.0, 0.0), Vec3::new(0.45, 0.0, 0.0)]),
                AnimationInterpolation::Linear,
            )],
            1.0,
        )
        .expect("authored translation clip is valid");
        let mixer = scene
            .create_authored_animation_mixer(clip)
            .expect("authored mixer creates");
        scene
            .seek_animation(mixer, 0.0)
            .expect("initial animation pose applies");
        Box::new(move |scene| {
            scene
                .seek_animation(mixer, 1.0)
                .expect("moved animation pose applies");
        })
    })
}

fn render_imported_set_transform(
    backend: RenderBackend,
    artifacts: &Path,
    case_name: &'static str,
) -> MotionCapture {
    render_motion(backend, artifacts, case_name, |scene, assets| {
        let scene_asset = pollster::block_on(assets.load_scene(AssetPath::from(
            "tests/assets/gltf/mesh_material_vertex_color_scene.gltf",
        )))
        .expect("movement glTF fixture loads");
        let import = scene
            .instantiate(&scene_asset)
            .expect("movement glTF fixture instantiates");
        let moving = import.roots()[0];
        scene
            .set_transform(moving, Transform::at(Vec3::new(-0.45, 0.0, 0.0)))
            .expect("import root starts left");
        Box::new(move |scene| {
            scene
                .set_transform(moving, Transform::at(Vec3::new(0.45, 0.0, 0.0)))
                .expect("import root moves right");
        })
    })
}

fn render_motion(
    backend: RenderBackend,
    artifacts: &Path,
    case_name: &'static str,
    build: impl FnOnce(&mut Scene, &Assets) -> Box<dyn FnOnce(&mut Scene)>,
) -> MotionCapture {
    let assets = Assets::new();
    let mut scene = Scene::new();
    let camera = scene.add_default_camera().expect("camera inserts");
    let mutate = build(&mut scene, &assets);
    let mut renderer = renderer_for_backend(backend, WIDTH, HEIGHT, AntiAliasing::None);

    renderer
        .prepare_with_assets(&mut scene, &assets)
        .expect("initial prepare succeeds");
    renderer
        .render(&scene, camera)
        .expect("initial render succeeds");
    let before = renderer.frame_rgba8().to_vec();
    write_png(
        &artifacts.join(format!("{case_name}-{}-before.png", backend.name())),
        &before,
    );
    let before_centroid =
        visible_centroid(&before).expect("initial render must contain visible object pixels");

    mutate(&mut scene);
    renderer
        .prepare_with_assets(&mut scene, &assets)
        .expect("moved prepare succeeds");
    renderer
        .render(&scene, camera)
        .expect("moved render succeeds");
    let after = renderer.frame_rgba8().to_vec();
    write_png(
        &artifacts.join(format!("{case_name}-{}-after.png", backend.name())),
        &after,
    );
    let after_centroid =
        visible_centroid(&after).expect("moved render must contain visible object pixels");

    MotionCapture {
        before,
        after,
        before_centroid,
        after_centroid,
    }
}

fn visible_centroid(frame: &[u8]) -> Option<(f32, f32)> {
    let mut weighted_x = 0.0_f32;
    let mut weighted_y = 0.0_f32;
    let mut count = 0_u32;
    for (index, pixel) in frame.chunks_exact(4).enumerate() {
        if pixel[3] > 0 && pixel[..3].iter().any(|channel| *channel > 24) {
            weighted_x += (index as u32 % WIDTH) as f32;
            weighted_y += (index as u32 / WIDTH) as f32;
            count = count.saturating_add(1);
        }
    }
    (count > 0).then_some((weighted_x / count as f32, weighted_y / count as f32))
}

fn artifact_dir() -> PathBuf {
    let dir = PathBuf::from("target/gate-artifacts/dynamic-transform-parity");
    fs::create_dir_all(&dir).expect("artifact dir creates");
    dir
}

fn write_png(path: &Path, rgba8: &[u8]) {
    let file = File::create(path).expect("create artifact PNG");
    let writer = BufWriter::new(file);
    let mut encoder = png::Encoder::new(writer, WIDTH, HEIGHT);
    encoder.set_color(png::ColorType::Rgba);
    encoder.set_depth(png::BitDepth::Eight);
    let mut writer = encoder.write_header().expect("PNG header writes");
    writer.write_image_data(rgba8).expect("PNG payload writes");
}

fn movement_records_json(records: &[MovementRecord]) -> String {
    let mut json = String::from("[");
    for (index, record) in records.iter().enumerate() {
        if index > 0 {
            json.push_str(", ");
        }
        json.push_str(&format!(
            "{{ \"case\": \"{}\", \"cpu_delta_x\": {:.3}, \"gpu_delta_x\": {:.3}, \"cpu_delta_y\": {:.3}, \"gpu_delta_y\": {:.3} }}",
            record.case_name,
            record.cpu_delta_x,
            record.gpu_delta_x,
            record.cpu_delta_y,
            record.gpu_delta_y
        ));
    }
    json.push(']');
    json
}