scena 1.0.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use std::io::Cursor;

use base64::Engine;
use serde_json::json;
use wasm_bindgen::prelude::JsValue;

use super::{WorkflowScene, add_default_camera};
use crate::{
    Angle, Assets, Color, DirectionalLight, GeometryDesc, GeometryTopology, GeometryVertex,
    MaterialDesc, PointLight, Scene, SpotLight, Transform, Vec3,
};

pub(super) fn point_light_scene() -> Result<WorkflowScene, JsValue> {
    let assets = Assets::new();
    let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.65, 0.65, 0.05));
    let material = assets.create_material(
        MaterialDesc::pbr_metallic_roughness(Color::from_linear_rgb(0.25, 0.25, 0.25), 0.0, 0.8)
            .with_double_sided(true),
    );
    let mut scene = Scene::new();
    scene
        .mesh(geometry, material)
        .add()
        .map_err(|error| JsValue::from_str(&format!("PBR point-light mesh failed: {error:?}")))?;
    scene
        .point_light(
            PointLight::default()
                .with_color(Color::from_linear_rgb(0.0, 1.0, 0.0))
                .with_intensity_candela(900.0)
                .with_range(5.0),
        )
        .transform(Transform::at(Vec3::new(0.0, 0.0, 1.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("PBR point light insert failed: {error:?}")))?;
    let camera = add_default_camera(&mut scene)?;
    Ok(WorkflowScene {
        assets,
        scene,
        camera,
        metadata: json!({
            "proof_class": "browser-pbr-punctual-light",
            "light_kind": "green",
            "light_type": "point",
            "material_kind": "pbr-metallic-roughness",
        }),
    })
}

pub(super) fn spot_light_scene() -> Result<WorkflowScene, JsValue> {
    let assets = Assets::new();
    let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.65, 0.65, 0.05));
    let material = assets.create_material(
        MaterialDesc::pbr_metallic_roughness(Color::from_linear_rgb(0.25, 0.25, 0.25), 0.0, 0.8)
            .with_double_sided(true),
    );
    let mut scene = Scene::new();
    scene
        .mesh(geometry, material)
        .add()
        .map_err(|error| JsValue::from_str(&format!("PBR spot-light mesh failed: {error:?}")))?;
    scene
        .spot_light(
            SpotLight::default()
                .with_color(Color::from_linear_rgb(0.0, 0.0, 1.0))
                .with_intensity_candela(1_000.0)
                .with_range(5.0)
                .with_inner_cone_angle(Angle::from_degrees(20.0))
                .with_outer_cone_angle(Angle::from_degrees(35.0)),
        )
        .transform(Transform::at(Vec3::new(0.0, 0.0, 1.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("PBR spot light insert failed: {error:?}")))?;
    let camera = add_default_camera(&mut scene)?;
    Ok(WorkflowScene {
        assets,
        scene,
        camera,
        metadata: json!({
            "proof_class": "browser-pbr-punctual-light",
            "light_kind": "blue",
            "light_type": "spot",
            "material_kind": "pbr-metallic-roughness",
        }),
    })
}

pub(super) async fn normal_map_scene() -> Result<WorkflowScene, JsValue> {
    let assets = Assets::new();
    let flat = assets
        .load_texture(
            rgba_png_data_uri([128, 128, 255, 255])?,
            crate::TextureColorSpace::Linear,
        )
        .await
        .map_err(|error| JsValue::from_str(&format!("flat normal texture failed: {error:?}")))?;
    let inverted = assets
        .load_texture(
            rgba_png_data_uri([128, 128, 0, 255])?,
            crate::TextureColorSpace::Linear,
        )
        .await
        .map_err(|error| {
            JsValue::from_str(&format!("inverted normal texture failed: {error:?}"))
        })?;
    let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.55, 0.55, 0.05));
    let flat_material = assets.create_material(
        MaterialDesc::pbr_metallic_roughness(Color::WHITE, 0.0, 0.8)
            .with_normal_texture(flat)
            .with_double_sided(true),
    );
    let inverted_material = assets.create_material(
        MaterialDesc::pbr_metallic_roughness(Color::WHITE, 0.0, 0.8)
            .with_normal_texture(inverted)
            .with_double_sided(true),
    );
    let mut scene = Scene::new();
    scene
        .mesh(geometry, flat_material)
        .transform(Transform::at(Vec3::new(-0.4, 0.0, 0.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("flat normal mesh failed: {error:?}")))?;
    scene
        .mesh(geometry, inverted_material)
        .transform(Transform::at(Vec3::new(0.4, 0.0, 0.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("inverted normal mesh failed: {error:?}")))?;
    scene
        .directional_light(crate::DirectionalLight::default().with_illuminance_lux(20_000.0))
        .add()
        .map_err(|error| JsValue::from_str(&format!("normal-map light failed: {error:?}")))?;
    let camera = add_default_camera(&mut scene)?;
    Ok(WorkflowScene {
        assets,
        scene,
        camera,
        metadata: json!({
            "proof_class": "browser-pbr-normal-map",
            "material_kind": "pbr-metallic-roughness",
            "normal_map_pixels": {
                "flat_normal": true,
                "inverted_normal": true,
            },
        }),
    })
}

pub(super) fn environment_scene() -> Result<WorkflowScene, JsValue> {
    let assets = Assets::new();
    let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.65, 0.65, 0.05));
    let material = assets.create_material(
        MaterialDesc::pbr_metallic_roughness(Color::from_linear_rgb(0.04, 0.04, 0.04), 0.0, 0.7)
            .with_double_sided(true),
    );
    let mut scene = Scene::new();
    scene
        .mesh(geometry, material)
        .add()
        .map_err(|error| JsValue::from_str(&format!("PBR environment mesh failed: {error:?}")))?;
    let camera = add_default_camera(&mut scene)?;
    Ok(WorkflowScene {
        assets,
        scene,
        camera,
        metadata: json!({
            "proof_class": "browser-pbr-environment-light",
            "environment_kind": "inline-radiance-hdr",
            "material_kind": "pbr-metallic-roughness",
            "environment_path": radiance_hdr_data_uri(
                2,
                1,
                &[[16, 32, 255, 132], [16, 32, 255, 132]],
                "studio-blue_2x1.hdr",
            ),
        }),
    })
}

pub(super) fn shadow_visibility_scene() -> Result<WorkflowScene, JsValue> {
    let assets = Assets::new();
    let receiver = assets.create_geometry(shadow_receiver_geometry());
    let caster = assets.create_geometry(shadow_caster_geometry());
    let receiver_material =
        assets.create_material(MaterialDesc::pbr_metallic_roughness(Color::WHITE, 0.0, 1.0));
    let caster_material = assets.create_material(MaterialDesc::unlit(Color::BLACK));
    let mut scene = Scene::new();
    scene
        .mesh(receiver, receiver_material)
        .transform(Transform::at(Vec3::new(-0.4, 0.0, 0.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("lit receiver insert failed: {error:?}")))?;
    scene
        .mesh(receiver, receiver_material)
        .transform(Transform::at(Vec3::new(0.4, 0.0, 0.0)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("shadow receiver insert failed: {error:?}")))?;
    scene
        .mesh(caster, caster_material)
        .transform(Transform::at(Vec3::new(0.69, 0.0, 0.50)))
        .add()
        .map_err(|error| JsValue::from_str(&format!("shadow caster insert failed: {error:?}")))?;
    scene
        .directional_light(
            DirectionalLight::default()
                .with_illuminance_lux(10_000.0)
                .with_shadows(true),
        )
        .transform(Transform::IDENTITY.rotate_y_deg(30.0))
        .add()
        .map_err(|error| JsValue::from_str(&format!("shadow light insert failed: {error:?}")))?;
    let camera = add_default_camera(&mut scene)?;
    Ok(WorkflowScene {
        assets,
        scene,
        camera,
        metadata: json!({
            "proof_class": "browser-pbr-directional-shadow-visibility",
            "material_kind": "pbr-metallic-roughness",
            "shadow_source": "prepared-visibility",
            "point_spot_shadows": "v1.x-deferred",
        }),
    })
}

fn rgba_png_data_uri(pixel: [u8; 4]) -> Result<String, JsValue> {
    let mut bytes = Vec::new();
    {
        let mut encoder = png::Encoder::new(Cursor::new(&mut bytes), 1, 1);
        encoder.set_color(png::ColorType::Rgba);
        encoder.set_depth(png::BitDepth::Eight);
        let mut writer = encoder
            .write_header()
            .map_err(|error| JsValue::from_str(&format!("normal PNG header failed: {error}")))?;
        writer
            .write_image_data(&pixel)
            .map_err(|error| JsValue::from_str(&format!("normal PNG payload failed: {error}")))?;
    }
    Ok(format!(
        "data:image/png;base64,{}",
        base64::engine::general_purpose::STANDARD.encode(bytes)
    ))
}

fn shadow_receiver_geometry() -> GeometryDesc {
    GeometryDesc::try_new(
        GeometryTopology::Triangles,
        vec![
            GeometryVertex {
                position: Vec3::new(-0.15, -0.18, 0.0),
                normal: Vec3::new(0.0, 0.0, 1.0),
            },
            GeometryVertex {
                position: Vec3::new(0.15, -0.18, 0.0),
                normal: Vec3::new(0.0, 0.0, 1.0),
            },
            GeometryVertex {
                position: Vec3::new(0.15, 0.18, 0.0),
                normal: Vec3::new(0.0, 0.0, 1.0),
            },
            GeometryVertex {
                position: Vec3::new(-0.15, 0.18, 0.0),
                normal: Vec3::new(0.0, 0.0, 1.0),
            },
        ],
        vec![0, 1, 2, 0, 2, 3],
    )
    .expect("browser shadow receiver geometry is valid")
}

fn shadow_caster_geometry() -> GeometryDesc {
    GeometryDesc::try_new(
        GeometryTopology::Triangles,
        vec![
            GeometryVertex {
                position: Vec3::new(-0.23, -0.24, 0.0),
                normal: Vec3::new(0.0, 0.0, -1.0),
            },
            GeometryVertex {
                position: Vec3::new(0.23, -0.24, 0.0),
                normal: Vec3::new(0.0, 0.0, -1.0),
            },
            GeometryVertex {
                position: Vec3::new(0.23, 0.24, 0.0),
                normal: Vec3::new(0.0, 0.0, -1.0),
            },
            GeometryVertex {
                position: Vec3::new(-0.23, 0.24, 0.0),
                normal: Vec3::new(0.0, 0.0, -1.0),
            },
        ],
        vec![0, 1, 2, 0, 2, 3],
    )
    .expect("browser shadow caster geometry is valid")
}

fn radiance_hdr_data_uri(width: u32, height: u32, pixels: &[[u8; 4]], name: &str) -> String {
    let mut bytes =
        format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y {height} +X {width}\n").into_bytes();
    for pixel in pixels {
        bytes.extend_from_slice(pixel);
    }
    format!(
        "data:application/radiance-hdr;base64,{}#{name}",
        base64::engine::general_purpose::STANDARD.encode(bytes)
    )
}