scena 1.0.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use serde_json::json;
use wasm_bindgen::prelude::JsValue;
use web_sys::HtmlCanvasElement;

use crate::{
    Assets, Backend, Color, GeometryDesc, HitTarget, MaterialDesc, NotPreparedReason, RenderError,
    RenderMode, Renderer, RendererOptions, Scene, SurfaceEvent, Transform, Vec3,
};

use super::super::renderer_readback_json;
use super::super::report::{capabilities_json, diagnostics_json, stats_json};

pub(in crate::browser_probe) async fn render_state_lifecycle_probe(
    canvas: HtmlCanvasElement,
    backend: Backend,
) -> Result<String, JsValue> {
    let mut renderer = Renderer::from_surface_async_with_options(
        super::browser_surface(&canvas, backend, "browser state lifecycle probe")?,
        RendererOptions::default().with_render_mode(RenderMode::OnChange),
    )
    .await
    .map_err(|error| JsValue::from_str(&format!("state lifecycle build failed: {error:?}")))?;

    let mut events = Vec::new();
    let lifetime = verify_resource_lifetime(&mut renderer, &mut events)?;
    let assets = Assets::new();
    let base_geometry = assets.create_geometry(GeometryDesc::box_xyz(0.35, 0.35, 0.35));
    let base_material =
        assets.create_material(MaterialDesc::unlit(Color::from_srgb_u8(70, 200, 180)));
    let accent_material =
        assets.create_material(MaterialDesc::unlit(Color::from_srgb_u8(240, 110, 90)));
    let mut scene = Scene::new();
    let node = scene
        .mesh(base_geometry, base_material)
        .transform(Transform::at(Vec3::new(-0.25, 0.0, 0.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("state mesh insert failed: {error:?}")))?;
    let camera = scene
        .add_default_camera()
        .map_err(|error| JsValue::from_str(&format!("state camera insert failed: {error:?}")))?;

    renderer
        .prepare_with_assets(&mut scene, &assets)
        .map_err(|error| JsValue::from_str(&format!("state prepare failed: {error:?}")))?;
    let first_render = renderer
        .render(&scene, camera)
        .map_err(|error| JsValue::from_str(&format!("state render failed: {error:?}")))?;
    let idle_render = renderer
        .render(&scene, camera)
        .map_err(|error| JsValue::from_str(&format!("state idle render failed: {error:?}")))?;
    if !idle_render.skipped {
        return Err(JsValue::from_str(
            "idle-render-skipped: OnChange render should skip unchanged browser frame",
        ));
    }
    events.push("idle-render-skipped");

    scene
        .set_transform(node, Transform::at(Vec3::new(0.1, 0.05, 0.0)))
        .map_err(|error| JsValue::from_str(&format!("dirty transform failed: {error:?}")))?;
    expect_scene_changed(renderer.render(&scene, camera), "dirty-transform")?;
    reprepare_and_render(
        &mut renderer,
        &assets,
        &mut scene,
        camera,
        "dirty-transform",
    )?;
    events.push("dirty-transform");

    scene
        .mesh(base_geometry, accent_material)
        .transform(Transform::at(Vec3::new(0.35, 0.0, 0.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("dirty material insert failed: {error:?}")))?;
    expect_scene_changed(renderer.render(&scene, camera), "dirty-material")?;
    reprepare_and_render(&mut renderer, &assets, &mut scene, camera, "dirty-material")?;
    events.push("dirty-material");

    let instance_set = scene
        .add_instance_set(
            scene.root(),
            base_geometry,
            base_material,
            Transform::default(),
        )
        .map_err(|error| JsValue::from_str(&format!("dirty instance set failed: {error:?}")))?;
    scene
        .push_instance(instance_set, Transform::at(Vec3::new(-0.6, -0.1, 0.0)))
        .map_err(|error| JsValue::from_str(&format!("dirty instance failed: {error:?}")))?;
    expect_scene_changed(renderer.render(&scene, camera), "dirty-instance")?;
    reprepare_and_render(&mut renderer, &assets, &mut scene, camera, "dirty-instance")?;
    events.push("dirty-instance");

    let camera_node = scene
        .camera_node(camera)
        .ok_or_else(|| JsValue::from_str("dirty-camera: camera node not found"))?;
    scene
        .set_transform(camera_node, Transform::at(Vec3::new(0.0, 0.0, 2.4)))
        .map_err(|error| JsValue::from_str(&format!("dirty camera failed: {error:?}")))?;
    expect_scene_changed(renderer.render(&scene, camera), "dirty-camera")?;
    reprepare_and_render(&mut renderer, &assets, &mut scene, camera, "dirty-camera")?;
    events.push("dirty-camera");

    canvas.set_width(96);
    canvas.set_height(64);
    renderer
        .handle_surface_event(SurfaceEvent::Resize {
            width: 96,
            height: 64,
        })
        .map_err(|error| JsValue::from_str(&format!("dirty resize failed: {error:?}")))?;
    renderer
        .handle_surface_event(SurfaceEvent::ScaleFactorChanged { scale_factor: 2.0 })
        .map_err(|error| JsValue::from_str(&format!("dirty DPR failed: {error:?}")))?;
    expect_target_changed(renderer.render(&scene, camera), "dirty-resize-dpr")?;
    let resized_render = reprepare_and_render(
        &mut renderer,
        &assets,
        &mut scene,
        camera,
        "dirty-resize-dpr",
    )?;
    events.push("dirty-resize-dpr");

    scene
        .interaction_mut()
        .set_hover(Some(HitTarget::Node(node)));
    scene
        .interaction_mut()
        .set_primary_selection(Some(HitTarget::Node(node)));
    expect_scene_changed(renderer.render(&scene, camera), "dirty-hover-selection")?;
    let selection_render = reprepare_and_render(
        &mut renderer,
        &assets,
        &mut scene,
        camera,
        "dirty-hover-selection",
    )?;
    events.push("dirty-hover-selection");

    let animation = verify_animation_dirty(&mut renderer, &mut events).await?;
    super::verify_context_recovery(&mut renderer, &assets, &mut scene, camera, &mut events)?;
    events.push("context-recovery");
    let renderer_readback = renderer
        .browser_probe_readback_rgba8()
        .await?
        .map(|readback| renderer_readback_json(&readback));

    let stats = renderer.stats();
    let capabilities = renderer.capabilities();
    Ok(json!({
        "schema": "scena.m6.browser_state_lifecycle_probe.v1",
        "status": "rendered",
        "workflow": "state-lifetime-idle",
        "scene_api": "Scene",
        "assets_api": "Assets",
        "prepare_api": "Renderer::prepare_with_assets",
        "render_api": "Renderer::render",
        "event_sequence": events,
        "dirty_state": {
            "transform": "requires explicit prepare",
            "material": "requires explicit prepare",
            "instance": "requires explicit prepare",
            "camera": "requires explicit prepare",
            "resize_dpr": "requires explicit prepare",
            "hover_selection": "requires explicit prepare",
            "animation_mixer": "requires explicit prepare",
            "context_recovery": "requires explicit prepare",
        },
        "resource_lifetime": lifetime,
        "allocation_steady_state": {
            "render_mode": "OnChange",
            "idle_render_skipped": idle_render.skipped,
            "idle_draw_calls": idle_render.draw_calls,
            "skipped_frames": stats.skipped_frames,
        },
        "animation": animation,
        "initial": {
            "draw_calls": first_render.draw_calls,
            "primitives": first_render.primitives,
        },
        "resized": {
            "width": resized_render.width,
            "height": resized_render.height,
            "draw_calls": resized_render.draw_calls,
        },
        "selection": {
            "draw_calls": selection_render.draw_calls,
        },
        "capabilities": capabilities_json(*capabilities),
        "diagnostics": diagnostics_json(renderer.diagnostics()),
        "backend": format!("{:?}", capabilities.backend),
        "gpu_device": capabilities.gpu_device,
        "surface_attached": capabilities.surface_attached,
        "width": selection_render.width,
        "height": selection_render.height,
        "draw_calls": selection_render.draw_calls,
        "primitives": selection_render.primitives,
        "stats": stats_json(stats),
        "gpu_submissions": stats.gpu_submissions,
        "prepared_buffers": stats.buffers,
        "prepared_pipelines": stats.pipelines,
        "prepared_bind_groups": stats.bind_groups,
        "renderer_readback": renderer_readback,
    })
    .to_string())
}

fn verify_resource_lifetime(
    renderer: &mut Renderer,
    events: &mut Vec<&'static str>,
) -> Result<serde_json::Value, JsValue> {
    let empty_assets = Assets::new();
    let mut empty_scene = Scene::new();
    empty_scene
        .add_default_camera()
        .map_err(|error| JsValue::from_str(&format!("lifetime camera insert failed: {error:?}")))?;
    renderer
        .prepare_with_assets(&mut empty_scene, &empty_assets)
        .map_err(|error| {
            JsValue::from_str(&format!("lifetime baseline prepare failed: {error:?}"))
        })?;
    renderer.poll_device();
    let baseline = renderer.stats();

    let heavy_assets = Assets::new();
    let geometry = heavy_assets.create_geometry(GeometryDesc::box_xyz(0.4, 0.4, 0.4));
    let material =
        heavy_assets.create_material(MaterialDesc::unlit(Color::from_srgb_u8(120, 180, 255)));
    let mut heavy_scene = Scene::new();
    heavy_scene
        .mesh(geometry, material)
        .add()
        .map_err(|error| JsValue::from_str(&format!("lifetime mesh insert failed: {error:?}")))?;
    let heavy_camera = heavy_scene
        .add_default_camera()
        .map_err(|error| JsValue::from_str(&format!("lifetime heavy camera failed: {error:?}")))?;
    renderer
        .prepare_with_assets(&mut heavy_scene, &heavy_assets)
        .map_err(|error| JsValue::from_str(&format!("lifetime heavy prepare failed: {error:?}")))?;
    renderer
        .render(&heavy_scene, heavy_camera)
        .map_err(|error| JsValue::from_str(&format!("lifetime heavy render failed: {error:?}")))?;
    let heavy = renderer.stats();
    if heavy.live_logical_handles <= baseline.live_logical_handles {
        return Err(JsValue::from_str(
            "resource-lifetime: prepared scene should increase live logical handles",
        ));
    }

    renderer
        .prepare_with_assets(&mut empty_scene, &empty_assets)
        .map_err(|error| {
            JsValue::from_str(&format!("lifetime release prepare failed: {error:?}"))
        })?;
    renderer.poll_device();
    let released = renderer.stats();
    if released.live_logical_handles != baseline.live_logical_handles {
        return Err(JsValue::from_str(
            "resource-lifetime: live logical handles should return to baseline",
        ));
    }
    events.push("resource-lifetime");

    Ok(json!({
        "baseline_live_logical_handles": baseline.live_logical_handles,
        "heavy_live_logical_handles": heavy.live_logical_handles,
        "released_live_logical_handles": released.live_logical_handles,
        "baseline_pending_destructions": baseline.pending_destructions,
        "released_pending_destructions": released.pending_destructions,
        "pending_returned_to_baseline": released.pending_destructions == baseline.pending_destructions,
    }))
}

async fn verify_animation_dirty(
    renderer: &mut Renderer,
    events: &mut Vec<&'static str>,
) -> Result<serde_json::Value, JsValue> {
    let assets = Assets::new();
    let scene_asset = assets
        .load_scene("/fixtures/gltf/khronos/MorphCube/AnimatedMorphCube.gltf")
        .await
        .map_err(|error| JsValue::from_str(&format!("dirty animation load failed: {error:?}")))?;
    let mut scene = Scene::new();
    let import = scene.instantiate(&scene_asset).map_err(|error| {
        JsValue::from_str(&format!("dirty animation instantiate failed: {error:?}"))
    })?;
    let mixer = scene
        .create_animation_mixer(&import, "Square")
        .map_err(|error| JsValue::from_str(&format!("dirty animation mixer failed: {error:?}")))?;
    scene
        .play_animation(mixer)
        .map_err(|error| JsValue::from_str(&format!("dirty animation play failed: {error:?}")))?;
    let camera = scene
        .add_default_camera()
        .map_err(|error| JsValue::from_str(&format!("dirty animation camera failed: {error:?}")))?;
    if let Some(bounds) = import.bounds_world(&scene) {
        scene.frame(camera, bounds).map_err(|error| {
            JsValue::from_str(&format!("dirty animation frame failed: {error:?}"))
        })?;
    }
    renderer
        .prepare_with_assets(&mut scene, &assets)
        .map_err(|error| {
            JsValue::from_str(&format!("dirty animation prepare failed: {error:?}"))
        })?;
    renderer
        .render(&scene, camera)
        .map_err(|error| JsValue::from_str(&format!("dirty animation render failed: {error:?}")))?;
    scene
        .update_animation(mixer, 1.0 / 30.0)
        .map_err(|error| JsValue::from_str(&format!("dirty animation update failed: {error:?}")))?;
    expect_scene_changed(renderer.render(&scene, camera), "dirty-animation-mixer")?;
    let render = reprepare_and_render(
        renderer,
        &assets,
        &mut scene,
        camera,
        "dirty-animation-mixer",
    )?;
    events.push("dirty-animation-mixer");

    Ok(json!({
        "clip": "Square",
        "draw_calls": render.draw_calls,
        "primitives": render.primitives,
    }))
}

fn expect_scene_changed(
    result: Result<crate::RenderOutcome, RenderError>,
    label: &str,
) -> Result<(), JsValue> {
    if matches!(
        result,
        Err(RenderError::NotPrepared {
            reason: NotPreparedReason::SceneChanged { .. }
        })
    ) {
        return Ok(());
    }
    Err(JsValue::from_str(&format!(
        "{label}: render should require explicit prepare after scene change"
    )))
}

fn expect_target_changed(
    result: Result<crate::RenderOutcome, RenderError>,
    label: &str,
) -> Result<(), JsValue> {
    if matches!(
        result,
        Err(RenderError::NotPrepared {
            reason: NotPreparedReason::TargetChanged { .. }
        })
    ) {
        return Ok(());
    }
    Err(JsValue::from_str(&format!(
        "{label}: render should require explicit prepare after target change"
    )))
}

fn reprepare_and_render<F>(
    renderer: &mut Renderer,
    assets: &Assets<F>,
    scene: &mut Scene,
    camera: crate::CameraKey,
    label: &str,
) -> Result<crate::RenderOutcome, JsValue> {
    renderer
        .prepare_with_assets(scene, assets)
        .map_err(|error| JsValue::from_str(&format!("{label} prepare failed: {error:?}")))?;
    renderer
        .render(scene, camera)
        .map_err(|error| JsValue::from_str(&format!("{label} render failed: {error:?}")))
}