#![cfg(not(target_arch = "wasm32"))]
use std::sync::{Mutex, MutexGuard, PoisonError};
use frust_engine::{EngineRenderer, EngineTarget, OutputAlpha};
use frust_gpu::{HeadlessTarget, SceneTextureId, Texture, TextureDesc, TierCaps};
use frust_scene::{Scene, SceneBuilder};
use kurbo::{Affine, Rect};
use peniko::Color;
use peniko::color::palette::css::{BLACK, BLUE, GREEN, RED};
const SIZE: u32 = 64;
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const TEXTURE: u32 = 32;
const BLOCK: u32 = 8;
const DEST: Rect = Rect::new(8.0, 8.0, 40.0, 40.0);
static RENDER_LOCK: Mutex<()> = Mutex::new(());
fn render_lock() -> MutexGuard<'static, ()> {
RENDER_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => std::thread::yield_now(),
}
}
}
fn drain_error_scope(device: &wgpu::Device, scope: wgpu::ErrorScopeGuard) -> Option<wgpu::Error> {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(scope.pop());
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(error) => return error,
Poll::Pending => {
let _ = device.poll(wgpu::PollType::wait_indefinitely());
}
}
}
}
fn gpu() -> (wgpu::Device, wgpu::Queue, TierCaps) {
block_on(async {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
.await
.expect("no compatible GPU adapter");
println!(
"frust-engine scene texture adapter: {:?}",
adapter.get_info()
);
let caps = TierCaps::probe(&adapter);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("frust-engine scene texture device"),
required_features: wgpu::Features::empty(),
required_limits: frust_gpu::test_device_limits(&adapter, &caps),
..Default::default()
})
.await
.expect("failed to create the device");
(device, queue, caps)
})
}
fn checker_at(x: u32, y: u32, even: Color, odd: Color) -> Color {
if (x / BLOCK + y / BLOCK).is_multiple_of(2) {
even
} else {
odd
}
}
fn checkerboard(even: Color, odd: Color) -> Vec<u8> {
let mut pixels = Vec::with_capacity((TEXTURE * TEXTURE * 4) as usize);
for y in 0..TEXTURE {
for x in 0..TEXTURE {
pixels.extend_from_slice(&checker_at(x, y, even, odd).to_rgba8().to_u8_array());
}
}
pixels
}
fn upload(device: &wgpu::Device, queue: &wgpu::Queue, pixels: &[u8]) -> Texture {
let desc = TextureDesc {
width: TEXTURE,
height: TEXTURE,
format: FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
label: Some("scene texture source".to_string()),
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: desc.label.as_deref(),
size: wgpu::Extent3d {
width: desc.width,
height: desc.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: desc.format,
usage: desc.usage,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(TEXTURE * 4),
rows_per_image: Some(TEXTURE),
},
wgpu::Extent3d {
width: TEXTURE,
height: TEXTURE,
depth_or_array_layers: 1,
},
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
Texture::new(texture, view, desc)
}
struct Harness {
device: wgpu::Device,
queue: wgpu::Queue,
renderer: EngineRenderer,
target: HeadlessTarget,
}
impl Harness {
fn new() -> Self {
let (device, queue, caps) = gpu();
let target = HeadlessTarget::new(&device, SIZE, SIZE, FORMAT);
let renderer = EngineRenderer::new(&device, &caps, FORMAT, None)
.expect("the engine builds on this device");
Self {
device,
queue,
renderer,
target,
}
}
fn bind(&mut self, texture: &Texture) -> SceneTextureId {
let id = texture.as_scene_texture();
let (width, height) = texture.size();
self.renderer
.bind_texture(id, (width, height), texture.view().clone());
id
}
fn render(&mut self, scene: &Scene) -> Vec<u8> {
let scope = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-engine scene texture frame"),
});
self.renderer
.encode(
&self.device,
&self.queue,
&mut encoder,
scene,
EngineTarget {
view: self.target.view(),
format: FORMAT,
width: SIZE,
height: SIZE,
depth: None,
output: OutputAlpha::Premultiplied,
},
BLACK,
Affine::IDENTITY,
)
.expect("a well-formed frame encodes");
self.queue.submit([encoder.finish()]);
self.renderer.end_frame(&self.queue);
let error = drain_error_scope(&self.device, scope);
assert!(error.is_none(), "the frame raised {error:?}");
self.target.read_back(&self.device, &self.queue)
}
}
fn scene_of(record: impl FnOnce(&mut SceneBuilder<'_>)) -> Scene {
let mut scene = Scene::new();
let mut builder = SceneBuilder::new(&mut scene);
record(&mut builder);
scene
}
fn scene_drawing(id: SceneTextureId, dest: Rect) -> Scene {
scene_of(|builder| builder.scene_texture(id.get(), dest))
}
fn pixel(frame: &[u8], x: u32, y: u32) -> [u8; 4] {
let start = ((y * SIZE + x) * 4) as usize;
frame[start..start + 4]
.try_into()
.expect("a read-back pixel is four bytes")
}
fn rgba(color: Color) -> [u8; 4] {
color.to_rgba8().to_u8_array()
}
fn assert_checkerboard(frame: &[u8], even: Color, odd: Color) {
let origin = (DEST.x0 as u32, DEST.y0 as u32);
let squares = TEXTURE / BLOCK;
for by in 0..squares {
for bx in 0..squares {
let texel = (bx * BLOCK + BLOCK / 2, by * BLOCK + BLOCK / 2);
let device = (origin.0 + texel.0, origin.1 + texel.1);
assert_eq!(
pixel(frame, device.0, device.1),
rgba(checker_at(texel.0, texel.1, even, odd)),
"square ({bx}, {by}) at device pixel {device:?}"
);
}
}
}
fn assert_only_dest_painted(frame: &[u8]) {
for probe in [(1, 1), (SIZE - 2, 1), (1, SIZE - 2), (SIZE - 2, SIZE - 2)] {
assert_eq!(
pixel(frame, probe.0, probe.1),
rgba(BLACK),
"the frame outside the destination rectangle is the base colour at {probe:?}"
);
}
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test scene_texture -- --ignored`"]
fn a_registered_texture_draws_its_own_texels_over_the_destination() {
let _guard = render_lock();
let mut harness = Harness::new();
let source = upload(&harness.device, &harness.queue, &checkerboard(RED, BLUE));
let id = harness.bind(&source);
assert_eq!(harness.renderer.bound_texture_count(), 1);
let frame = harness.render(&scene_drawing(id, DEST));
assert_checkerboard(&frame, RED, BLUE);
assert_only_dest_painted(&frame);
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test scene_texture -- --ignored`"]
fn an_unregistered_id_draws_only_the_base_colour() {
let _guard = render_lock();
let mut harness = Harness::new();
let source = upload(&harness.device, &harness.queue, &checkerboard(RED, BLUE));
assert_eq!(harness.renderer.bound_texture_count(), 0);
let frame = harness.render(&scene_drawing(source.as_scene_texture(), DEST));
assert!(
frame
.as_chunks::<4>()
.0
.iter()
.all(|texel| *texel == rgba(BLACK)),
"an unregistered id leaves the frame as its base colour"
);
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test scene_texture -- --ignored`"]
fn unbinding_a_texture_stops_the_very_next_frame_drawing_it() {
let _guard = render_lock();
let mut harness = Harness::new();
let source = upload(&harness.device, &harness.queue, &checkerboard(RED, BLUE));
let id = harness.bind(&source);
let scene = scene_drawing(id, DEST);
let drawn = harness.render(&scene);
assert_checkerboard(&drawn, RED, BLUE);
assert!(harness.renderer.unbind_texture(id).is_some());
assert_eq!(harness.renderer.bound_texture_count(), 0);
let after = harness.render(&scene);
assert!(
after
.as_chunks::<4>()
.0
.iter()
.all(|texel| *texel == rgba(BLACK)),
"an unbound texture is not drawn from a retained bind group"
);
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test scene_texture -- --ignored`"]
fn two_textures_in_one_frame_each_draw_their_own_pixels() {
let _guard = render_lock();
let mut harness = Harness::new();
let first = upload(&harness.device, &harness.queue, &checkerboard(RED, BLUE));
let second = upload(&harness.device, &harness.queue, &checkerboard(GREEN, BLUE));
let first_id = harness.bind(&first);
let second_id = harness.bind(&second);
assert_eq!(harness.renderer.bound_texture_count(), 2);
let left = Rect::new(0.0, 16.0, 32.0, 48.0);
let right = Rect::new(32.0, 16.0, 64.0, 48.0);
let scene = scene_of(|builder| {
builder.scene_texture(first_id.get(), left);
builder.scene_texture(second_id.get(), right);
});
let frame = harness.render(&scene);
assert_eq!(
pixel(&frame, 4, 20),
rgba(RED),
"the left rectangle draws the first texture"
);
assert_eq!(
pixel(&frame, 36, 20),
rgba(GREEN),
"the right rectangle draws the second texture"
);
}