lumen-engine-gpu 0.2.0

GPU rendering infrastructure for the Lumen engine.
Documentation
use lumen_engine_gpu::*;

const SHADER: &str = r#"
@vertex
fn vs_main(@builtin(vertex_index) vertex: u32) -> @builtin(position) vec4<f32> {
    let positions = array<vec2<f32>, 3>(
        vec2<f32>(-1.0, -1.0),
        vec2<f32>(3.0, -1.0),
        vec2<f32>(-1.0, 3.0)
    );
    return vec4<f32>(positions[vertex], 0.0, 1.0);
}

@fragment
fn fs_main() -> @location(0) vec4<f32> {
    return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}
"#;

#[test]
fn texture_domains_preserve_storage_display_and_data_bounds() {
    let size = Size::new(1920, 1080);
    let desc = TextureDesc {
        domain: TextureDomain {
            storage_size: size,
            display_rect: RectI::new(-10, 20, 640, 360),
            data_rect: RectI::new(0, 0, 1280, 720),
        },
        format: wgpu::TextureFormat::Rgba8Unorm,
        usage: wgpu::TextureUsages::TEXTURE_BINDING,
    };

    assert_eq!(desc.domain.storage_size, size);
    assert_eq!(desc.domain.display_rect, RectI::new(-10, 20, 640, 360));
    assert_eq!(desc.domain.data_rect, RectI::new(0, 0, 1280, 720));
}

#[test]
fn resource_descriptors_include_expected_gpu_usages() {
    let size = Size::new(16, 8);
    let target = TextureDesc::render_target(size, wgpu::TextureFormat::Rgba8Unorm);
    let sampled = TextureDesc::sampled(size, wgpu::TextureFormat::Rgba8Unorm);
    let storage = TextureDesc::storage(size, wgpu::TextureFormat::Rgba8Unorm);

    assert!(
        target
            .usage
            .contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
    );
    assert!(target.usage.contains(wgpu::TextureUsages::TEXTURE_BINDING));
    assert!(target.usage.contains(wgpu::TextureUsages::COPY_SRC));
    assert!(sampled.usage.contains(wgpu::TextureUsages::COPY_DST));
    assert!(sampled.usage.contains(wgpu::TextureUsages::COPY_SRC));
    assert!(storage.usage.contains(wgpu::TextureUsages::STORAGE_BINDING));
    assert!(storage.usage.contains(wgpu::TextureUsages::TEXTURE_BINDING));

    assert!(
        BufferDesc::uniform(64)
            .usage
            .contains(wgpu::BufferUsages::UNIFORM)
    );
    assert!(
        BufferDesc::storage(64)
            .usage
            .contains(wgpu::BufferUsages::STORAGE)
    );
    assert!(
        BufferDesc::vertex(64)
            .usage
            .contains(wgpu::BufferUsages::VERTEX)
    );
    assert!(
        BufferDesc::index(64)
            .usage
            .contains(wgpu::BufferUsages::INDEX)
    );
}

#[test]
fn builder_assigns_stable_ids_and_node_ownership() {
    let node = NodeKey(9001);
    let mut builder = RenderPlan::builder();
    let tex_a = builder.texture(
        Some("input".to_string()),
        TextureDesc::sampled(Size::new(4, 4), wgpu::TextureFormat::Rgba8Unorm),
    );
    let tex_b = builder.texture_for(
        node,
        Some("node-output".to_string()),
        TextureDesc::render_target(Size::new(4, 4), wgpu::TextureFormat::Rgba8Unorm),
    );
    let buf_a = builder.buffer(None, BufferDesc::uniform(16));
    let buf_b = builder.buffer_for(node, Some("params".to_string()), BufferDesc::storage(32));
    let program = builder.program_for(node, render_program_desc());
    let pass = builder.render_pass(RenderPassDesc {
        label: Some("paint".to_string()),
        owner: Some(node),
        program,
        targets: vec![RenderTargetRef {
            texture: tex_b,
            load: LoadOp::Clear(wgpu::Color::BLACK),
            store: wgpu::StoreOp::Store,
        }],
        bindings: Vec::new(),
        vertex_buffers: Vec::new(),
        index_buffer: None,
        draw: DrawCommand::Draw(Draw {
            vertices: 0..3,
            instances: 0..1,
        }),
        scissor: Some(ScissorRect {
            x: 1,
            y: 2,
            width: 3,
            height: 4,
        }),
    });
    let plan = builder.build();

    assert_eq!(tex_a, TextureId(0));
    assert_eq!(tex_b, TextureId(1));
    assert_eq!(buf_a, BufferId(0));
    assert_eq!(buf_b, BufferId(1));
    assert_eq!(program, ProgramId(0));
    assert_eq!(pass, PassId(0));
    assert_eq!(plan.textures()[0].owner, None);
    assert_eq!(plan.textures()[1].owner, Some(node));
    assert_eq!(plan.buffers()[1].owner, Some(node));
    assert_eq!(plan.programs()[0].owner, Some(node));
    assert_eq!(plan.passes()[0].id, pass);
}

#[test]
fn plan_records_pass_order_across_render_compute_and_copy() {
    let mut builder = RenderPlan::builder();
    let size = Size::new(8, 8);
    let a = builder.texture(
        None,
        TextureDesc::storage(size, wgpu::TextureFormat::Rgba8Unorm),
    );
    let b = builder.texture(
        None,
        TextureDesc::render_target(size, wgpu::TextureFormat::Rgba8Unorm),
    );
    let render = builder.program(render_program_desc());
    let compute = builder.program(ProgramDesc::Compute(ComputeProgramDesc {
        label: Some("compute".to_string()),
        shader: "@compute @workgroup_size(1) fn cs_main() {}".to_string(),
        entry: "cs_main".to_string(),
        bind_groups: Vec::new(),
    }));

    builder.compute_pass(ComputePassDesc {
        label: Some("first".to_string()),
        owner: None,
        program: compute,
        bindings: Vec::new(),
        dispatch: Dispatch { x: 1, y: 1, z: 1 }.into(),
    });
    builder.render_pass(RenderPassDesc {
        label: Some("second".to_string()),
        owner: None,
        program: render,
        targets: vec![RenderTargetRef {
            texture: b,
            load: LoadOp::Load,
            store: wgpu::StoreOp::Store,
        }],
        bindings: Vec::new(),
        vertex_buffers: Vec::new(),
        index_buffer: None,
        draw: DrawCommand::Draw(Draw {
            vertices: 0..3,
            instances: 0..1,
        }),
        scissor: None,
    });
    builder.copy_texture(CopyTextureDesc {
        label: Some("third".to_string()),
        source: b,
        destination: a,
        origin: wgpu::Origin3d::ZERO,
        size,
    });
    let plan = builder.build();

    assert!(matches!(plan.passes()[0].desc, PassDesc::Compute(_)));
    assert!(matches!(plan.passes()[1].desc, PassDesc::Render(_)));
    assert!(matches!(plan.passes()[2].desc, PassDesc::CopyTexture(_)));
}

#[test]
fn params_link_expression_slots_to_dynamic_resources() {
    let node = NodeKey(7);
    let mut builder = RenderPlan::builder();
    let uniforms = builder.buffer_for(node, Some("uniforms".to_string()), BufferDesc::uniform(64));
    let image = builder.texture_for(
        node,
        Some("animated-image".to_string()),
        TextureDesc::sampled(Size::new(2, 2), wgpu::TextureFormat::Rgba8Unorm),
    );
    builder
        .param(
            ParamKey {
                owner: node,
                slot: 0,
            },
            ParamTarget::Buffer(uniforms),
        )
        .param(
            ParamKey {
                owner: node,
                slot: 1,
            },
            ParamTarget::Texture(image),
        );
    let plan = builder.build();

    assert_eq!(plan.params().len(), 2);
    assert_eq!(plan.params()[0].key.owner, node);
    assert_eq!(plan.params()[0].target, ParamTarget::Buffer(uniforms));
    assert_eq!(plan.params()[1].target, ParamTarget::Texture(image));
}

#[test]
fn frame_update_records_buffer_and_texture_uploads_in_order() {
    let buffer_bytes = [1, 2, 3, 4];
    let texture_bytes = [255; 16];
    let mut update = FrameUpdate::new();
    update
        .write_buffer(BufferId(3), 8, &buffer_bytes)
        .write_texture_rgba8(TextureId(2), &texture_bytes, 8, 2);

    assert_eq!(update.uploads().len(), 2);
    match &update.uploads()[0] {
        Upload::Buffer { id, offset, data } => {
            assert_eq!(*id, BufferId(3));
            assert_eq!(*offset, 8);
            assert_eq!(*data, buffer_bytes);
        }
        Upload::TextureRgba8 { .. }
        | Upload::TextureRgba8Region { .. }
        | Upload::TextureRgba16Float { .. } => {
            panic!("expected buffer upload first")
        }
    }
    match &update.uploads()[1] {
        Upload::TextureRgba8 {
            id,
            data,
            bytes_per_row,
            rows_per_image,
        } => {
            assert_eq!(*id, TextureId(2));
            assert_eq!(*data, texture_bytes);
            assert_eq!(*bytes_per_row, 8);
            assert_eq!(*rows_per_image, 2);
        }
        Upload::Buffer { .. }
        | Upload::TextureRgba8Region { .. }
        | Upload::TextureRgba16Float { .. } => {
            panic!("expected texture upload second")
        }
    }
}

#[test]
fn binding_helpers_record_group_binding_and_access_kind() {
    assert_eq!(
        Binding::sampled_texture(1, 2, TextureId(3)).resource,
        BindingResource::Texture {
            id: TextureId(3),
            access: TextureAccess::Sampled,
        }
    );
    assert_eq!(
        Binding::storage_texture(1, 2, TextureId(3)).resource,
        BindingResource::Texture {
            id: TextureId(3),
            access: TextureAccess::Storage,
        }
    );
    assert_eq!(
        Binding::uniform(0, 4, BufferId(5)).resource,
        BindingResource::Buffer {
            id: BufferId(5),
            access: BufferAccess::Uniform,
        }
    );
    assert_eq!(
        Binding::storage_buffer(0, 4, BufferId(5)).resource,
        BindingResource::Buffer {
            id: BufferId(5),
            access: BufferAccess::Storage,
        }
    );
    assert_eq!(
        Binding::sampler(2, 9, SamplerId(11)).resource,
        BindingResource::Sampler(SamplerId(11))
    );
}

fn render_program_desc() -> ProgramDesc {
    ProgramDesc::Render(RenderProgramDesc {
        label: Some("render".to_string()),
        shader: SHADER.to_string(),
        vertex_entry: "vs_main".to_string(),
        fragment_entry: "fs_main".to_string(),
        bind_groups: Vec::new(),
        targets: vec![Some(wgpu::ColorTargetState {
            format: wgpu::TextureFormat::Rgba8Unorm,
            blend: Some(wgpu::BlendState::REPLACE),
            write_mask: wgpu::ColorWrites::ALL,
        })],
        vertex_buffers: Vec::new(),
        primitive: wgpu::PrimitiveState::default(),
    })
}