#![cfg(not(target_arch = "wasm32"))]
use std::sync::{Mutex, MutexGuard, PoisonError};
use frust_engine::gpu::present::{UNPREMULTIPLY, UNPREMULTIPLY_NAME, UnpremultiplyPass};
use frust_engine::{EngineRenderer, EngineTarget, OutputAlpha};
use frust_gpu::{HeadlessTarget, TierCaps};
use frust_scene::{Scene, SceneBuilder};
use kurbo::{Affine, Rect};
use peniko::color::palette::css::WHITE;
use peniko::{Brush, Color};
use wgpu::naga;
const SIZE: u32 = 64;
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const INTERMEDIATE: wgpu::TextureFormat = frust_engine::gpu::pipelines::INTERMEDIATE_FORMAT;
const FILL: Rect = Rect::new(16.0, 16.0, 48.0, 48.0);
const INTERIOR: &[(u32, u32)] = &[(20, 20), (32, 32), (44, 44)];
const OUTSIDE: &[(u32, u32)] = &[(4, 4), (60, 4), (4, 60), (60, 60)];
const PREMULTIPLIED: [u8; 4] = [128, 128, 128, 128];
const STRAIGHT: [u8; 4] = [255, 255, 255, 128];
const TOLERANCE: u8 = 2;
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 present adapter: {:?}", adapter.get_info());
let caps = TierCaps::probe(&adapter);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("frust-engine present 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 intermediate(device: &wgpu::Device) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("frust-engine present test intermediate"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
texture.create_view(&wgpu::TextureViewDescriptor::default())
}
fn half_alpha_white() -> Scene {
let mut scene = Scene::new();
let mut builder = SceneBuilder::new(&mut scene);
builder.fill_rect(FILL, Brush::Solid(WHITE.with_alpha(0.5)));
scene
}
fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] {
let index = ((y * SIZE + x) * 4) as usize;
pixels[index..index + 4]
.try_into()
.expect("a readback row holds four bytes per pixel")
}
fn assert_pixels(pixels: &[u8], at: &[(u32, u32)], expected: [u8; 4], what: &str) {
for &(x, y) in at {
let actual = pixel(pixels, x, y);
let off = (0..4).any(|c| actual[c].abs_diff(expected[c]) > TOLERANCE);
assert!(
!off,
"{what}: pixel ({x}, {y}) is {actual:?}, expected {expected:?} +/- {TOLERANCE}"
);
}
}
fn through_the_present_pass() -> Vec<u8> {
let (device, queue, caps) = gpu();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let target = HeadlessTarget::new(&device, SIZE, SIZE, FORMAT);
let source = intermediate(&device);
let mut renderer = EngineRenderer::new(&device, &caps, INTERMEDIATE, None)
.expect("the engine builds on this device");
let present = UnpremultiplyPass::new(&device, FORMAT, None);
assert_eq!(present.format(), FORMAT);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-engine present frame"),
});
renderer
.encode(
&device,
&queue,
&mut encoder,
&half_alpha_white(),
EngineTarget {
view: &source,
format: INTERMEDIATE,
width: SIZE,
height: SIZE,
depth: None,
output: OutputAlpha::Premultiplied,
},
Color::TRANSPARENT,
Affine::IDENTITY,
)
.expect("a well-formed frame encodes");
present.record(&device, &mut encoder, &source, target.view(), None);
queue.submit([encoder.finish()]);
renderer.end_frame(&queue);
let error = drain_error_scope(&device, scope);
assert!(error.is_none(), "the frame raised {error:?}");
target.read_back(&device, &queue)
}
fn without_the_present_pass() -> Vec<u8> {
let (device, queue, caps) = gpu();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let target = HeadlessTarget::new(&device, SIZE, SIZE, FORMAT);
let mut renderer = EngineRenderer::new(&device, &caps, FORMAT, None)
.expect("the engine builds on this device");
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("frust-engine premultiplied frame"),
});
renderer
.encode(
&device,
&queue,
&mut encoder,
&half_alpha_white(),
EngineTarget {
view: target.view(),
format: FORMAT,
width: SIZE,
height: SIZE,
depth: None,
output: OutputAlpha::Premultiplied,
},
Color::TRANSPARENT,
Affine::IDENTITY,
)
.expect("a well-formed frame encodes");
queue.submit([encoder.finish()]);
renderer.end_frame(&queue);
let error = drain_error_scope(&device, scope);
assert!(error.is_none(), "the frame raised {error:?}");
target.read_back(&device, &queue)
}
fn validate(name: &str, src: &str) {
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|err| panic!("{name} failed to parse: {err:?}"));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap_or_else(|err| panic!("{name} failed validation: {err:?}"));
}
#[test]
fn the_present_program_parses_and_validates() {
validate(UNPREMULTIPLY_NAME, UNPREMULTIPLY);
assert!(
UNPREMULTIPLY
.lines()
.any(|line| line.trim_start().starts_with("const ALPHA_FLOOR")),
"the validated program must declare the divisor guard"
);
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test present -- --ignored`"]
fn a_half_alpha_fill_reaches_a_straight_alpha_target_unpremultiplied() {
let _serialized = render_lock();
let pixels = through_the_present_pass();
assert_pixels(
&pixels,
INTERIOR,
STRAIGHT,
"a 50%-alpha white fill must reach a straight-alpha swapchain with its colour channels \
restored",
);
let inside = pixel(&pixels, INTERIOR[0].0, INTERIOR[0].1);
assert_ne!(
inside, PREMULTIPLIED,
"the swapchain received the engine's premultiplied bytes unconverted"
);
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test present -- --ignored`"]
fn zero_alpha_pixels_stay_finite_and_transparent_black() {
let _serialized = render_lock();
let pixels = through_the_present_pass();
assert_pixels(
&pixels,
OUTSIDE,
[0, 0, 0, 0],
"an alpha-zero pixel must stay transparent black through the divisor guard",
);
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine --test present -- --ignored`"]
fn the_premultiplied_arm_is_unchanged_by_the_pass_existing() {
let _serialized = render_lock();
let pixels = without_the_present_pass();
assert_pixels(
&pixels,
INTERIOR,
PREMULTIPLIED,
"a target the engine writes directly must still receive premultiplied bytes",
);
assert_pixels(
&pixels,
OUTSIDE,
[0, 0, 0, 0],
"the transparent base colour is unchanged on the direct arm",
);
}