#![cfg(feature = "gpu_tests")]
use engawa_wgpu::catalog::{
aurora::{self, AuroraQuality},
colorblind, CatalogEffect, CATALOG_SAMPLER, OUT, SCENE,
};
use engawa_wgpu::{
BoundResource, BoundResources, FrameUniforms, TextureKey, TexturePool, WgpuDispatcher,
};
use garasu::headless::HeadlessTarget;
use garasu::GpuContext;
const W: u32 = 64;
const H: u32 = 64;
fn run_catalog_effect<P: bytemuck::Pod>(
effect: CatalogEffect,
scene_clear: wgpu::Color,
params: &P,
) -> Vec<u8> {
assert!(
effect.aux_resources().is_empty(),
"pixel harness covers single-node effects only; {} declares aux resources",
effect.name()
);
assert_eq!(
size_of::<P>(),
effect.params_size(),
"params type must match {}'s declared params size",
effect.name()
);
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let gpu = pollster::block_on(GpuContext::new()).expect("gpu");
let target = HeadlessTarget::new(&gpu, W, H, format);
let mut pool = TexturePool::new();
let scene_lease = pool.lease(&gpu.device, TextureKey::offscreen(W, H, format));
let mut encoder = gpu
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("catalog-gpu scene clear"),
});
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene-clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: scene_lease.view(),
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(scene_clear),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
gpu.queue.submit(std::iter::once(encoder.finish()));
let params_buf = gpu.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(effect.params_resource()),
size: effect.params_size() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("catalog sampler"),
..wgpu::SamplerDescriptor::default()
});
let graph = effect.graph().compile().expect("catalog graph compiles");
let bindings = engawa::ResourceBindings::new()
.with(SCENE, engawa::ResourceHandle::Texture(SCENE.into()))
.with(OUT, engawa::ResourceHandle::Texture(OUT.into()));
let bound = BoundResources::new()
.with(SCENE, scene_lease.bound_resource())
.with(OUT, BoundResource::Texture { view: target.view().clone(), format })
.with(CATALOG_SAMPLER, BoundResource::Sampler(sampler))
.with(effect.params_resource(), BoundResource::Uniform(params_buf));
let frame = FrameUniforms::new().with(effect.params_resource(), params);
let mut dispatcher = WgpuDispatcher::new(&gpu.device, &gpu.queue, format);
let cmd = dispatcher
.dispatch_with(&graph, &bindings, bound, &frame)
.expect("dispatch");
gpu.queue.submit(std::iter::once(cmd));
let _ = gpu.device.poll(wgpu::PollType::Wait);
pool.release(scene_lease);
assert_eq!(pool.free_count(), 1, "released lease must land in the free list");
target.read_pixels_rgba8(&gpu)
}
fn run_colorblind(params: colorblind::ColorblindParams) -> Vec<u8> {
run_catalog_effect(
CatalogEffect::Colorblind,
wgpu::Color { r: 0.0, g: 1.0, b: 0.0, a: 1.0 },
¶ms,
)
}
fn center_pixel(pixels: &[u8]) -> [u8; 4] {
let i = ((H / 2 * W + W / 2) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn srgb_encode_u8(linear: f32) -> u8 {
let c = linear.clamp(0.0, 1.0);
let s = if c <= 0.003_130_8 {
12.92 * c
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
};
(s * 255.0).round() as u8
}
fn assert_pixel_close(got: [u8; 4], expected_rgb: [u8; 3], label: &str) {
let mut failures: Vec<(&str, u8, u8)> = Vec::new();
for (channel, (g, e)) in ["R", "G", "B"].iter().zip(got.iter().zip(expected_rgb.iter())) {
if (i32::from(*g) - i32::from(*e)).abs() > 2 {
failures.push((channel, *g, *e));
}
}
assert!(
failures.is_empty(),
"{label}: channels off by more than ±2 (channel, got, expected): {failures:?}"
);
}
#[test]
fn colorblind_mode_none_passes_green_through() {
let pixels = run_colorblind(colorblind::ColorblindParams::new(
colorblind::ColorblindMode::None,
));
assert_pixel_close(center_pixel(&pixels), [0, 255, 0], "mode none");
}
#[test]
fn colorblind_protanopia_transforms_pure_green_per_machado() {
let pixels = run_colorblind(colorblind::ColorblindParams::new(
colorblind::ColorblindMode::Protanopia,
));
let expected = [
srgb_encode_u8(colorblind::MACHADO_PROTANOPIA[0][1]),
srgb_encode_u8(colorblind::MACHADO_PROTANOPIA[1][1]),
srgb_encode_u8(colorblind::MACHADO_PROTANOPIA[2][1]),
];
assert_pixel_close(center_pixel(&pixels), expected, "protanopia");
}
#[test]
fn out_of_contract_mode_word_degrades_to_pass_through() {
let params: colorblind::ColorblindParams = bytemuck::cast([7_u32, 0, 0, 0]);
let pixels = run_colorblind(params);
assert_pixel_close(center_pixel(&pixels), [0, 255, 0], "mode 7 (out of contract)");
}
const AURORA_SCENE: wgpu::Color = wgpu::Color { r: 0.02, g: 0.03, b: 0.08, a: 1.0 };
fn aurora_test_params() -> aurora::AuroraParams {
#[allow(clippy::cast_precision_loss)]
aurora::AuroraParams::default().with_resolution([W as f32, H as f32])
}
fn run_aurora(params: aurora::AuroraParams) -> Vec<u8> {
run_catalog_effect(CatalogEffect::Aurora, AURORA_SCENE, ¶ms)
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn first_below_horizon_row(horizon: f32) -> u32 {
(horizon * H as f32 - 0.5).ceil() as u32
}
#[allow(clippy::cast_possible_truncation)]
#[test]
fn aurora_off_tier_renders_the_scene() {
let pixels = run_aurora(aurora_test_params().with_quality(AuroraQuality::Off));
let expected = [
srgb_encode_u8(AURORA_SCENE.r as f32),
srgb_encode_u8(AURORA_SCENE.g as f32),
srgb_encode_u8(AURORA_SCENE.b as f32),
];
let mut off_pixels = 0usize;
let mut first: Option<(u32, u32, [u8; 4])> = None;
for y in 0..H {
for x in 0..W {
let i = ((y * W + x) * 4) as usize;
let got = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]];
let close = got[..3]
.iter()
.zip(expected.iter())
.all(|(g, e)| (i32::from(*g) - i32::from(*e)).abs() <= 2)
&& got[3] == 255;
if !close {
off_pixels += 1;
first.get_or_insert((x, y, got));
}
}
}
assert_eq!(
off_pixels, 0,
"Off tier must render the scene everywhere: {off_pixels} pixels off \
(first at {first:?}, expected ~{expected:?})"
);
}
#[test]
fn aurora_out_of_contract_tier_word_matches_off_byte_exact() {
let off = run_aurora(aurora_test_params().with_quality(AuroraQuality::Off));
let rogue = aurora::AuroraParams { tier: [7, 0, 0, 0], ..aurora_test_params() };
let pixels = run_aurora(rogue);
let diff_bytes = pixels.iter().zip(off.iter()).filter(|(a, b)| a != b).count();
assert_eq!(
diff_bytes, 0,
"tier word 7 must be byte-identical to Off pass-through; {diff_bytes} bytes differ"
);
}
#[allow(clippy::cast_possible_truncation)]
#[test]
fn aurora_medium_draws_above_the_horizon_and_never_below() {
let params = aurora_test_params();
assert_eq!(params.quality(), Some(AuroraQuality::Medium), "shipped default tier");
let off = run_aurora(aurora_test_params().with_quality(AuroraQuality::Off));
let med = run_aurora(params);
let below_start = (first_below_horizon_row(params.geometry[0]) * W * 4) as usize;
let below_diff = med[below_start..]
.iter()
.zip(off[below_start..].iter())
.filter(|(a, b)| a != b)
.count();
assert_eq!(
below_diff, 0,
"below-horizon pixels must be scene byte-exact; {below_diff} bytes differ"
);
let mut diff_pixels = 0usize;
let mut max_delta = 0i32;
for (m, o) in med[..below_start].chunks_exact(4).zip(off[..below_start].chunks_exact(4)) {
let delta = m
.iter()
.zip(o.iter())
.map(|(a, b)| (i32::from(*a) - i32::from(*b)).abs())
.max()
.unwrap_or(0);
if delta > 0 {
diff_pixels += 1;
}
max_delta = max_delta.max(delta);
}
assert!(
diff_pixels > 0,
"Medium must change at least one above-horizon pixel — the curtain never drew"
);
assert!(
max_delta >= 16,
"curtain contribution too weak to be the curtain (max channel delta {max_delta}, \
{diff_pixels} pixels differ) — alpha is collapsing toward zero"
);
eprintln!(
"aurora medium pixel proof: {diff_pixels} above-horizon pixels differ, \
max channel delta {max_delta}"
);
}
#[test]
fn every_catalog_effect_dispatches_on_a_real_adapter() {
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let gpu = pollster::block_on(GpuContext::new()).expect("gpu");
let target = HeadlessTarget::new(&gpu, W, H, format);
let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("catalog sampler"),
..wgpu::SamplerDescriptor::default()
});
let mut pool = TexturePool::new();
let scene_lease = pool.lease(&gpu.device, TextureKey::offscreen(W, H, format));
let mut dispatcher = WgpuDispatcher::new(&gpu.device, &gpu.queue, format);
let mut failures: Vec<(&'static str, String)> = Vec::new();
for e in CatalogEffect::ALL {
let graph = match e.graph().compile() {
Ok(g) => g,
Err(err) => {
failures.push((e.name(), err.to_string()));
continue;
}
};
let params_buf = gpu.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(e.params_resource()),
size: e.params_size() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
gpu.queue.write_buffer(¶ms_buf, 0, &e.default_params_bytes());
let mut bindings = engawa::ResourceBindings::new()
.with(SCENE, engawa::ResourceHandle::Texture(SCENE.into()))
.with(OUT, engawa::ResourceHandle::Texture(OUT.into()));
let mut bound = BoundResources::new()
.with(SCENE, scene_lease.bound_resource())
.with(OUT, BoundResource::Texture { view: target.view().clone(), format })
.with(CATALOG_SAMPLER, BoundResource::Sampler(sampler.clone()))
.with(e.params_resource(), BoundResource::Uniform(params_buf));
let mut aux_leases = Vec::new();
for (id, _kind) in e.aux_resources() {
bindings.insert(id, engawa::ResourceHandle::Texture(id.into()));
let lease = pool.lease(&gpu.device, TextureKey::offscreen(W, H, format));
bound.insert(id, lease.bound_resource());
aux_leases.push(lease);
}
match dispatcher.dispatch_with(&graph, &bindings, bound, &FrameUniforms::new()) {
Ok(cmd) => {
gpu.queue.submit(std::iter::once(cmd));
let _ = gpu.device.poll(wgpu::PollType::Wait);
}
Err(err) => failures.push((e.name(), err.to_string())),
}
for lease in aux_leases {
pool.release(lease);
}
}
assert!(
failures.is_empty(),
"{} catalog effects failed to dispatch:\n{:#?}",
failures.len(),
failures
);
assert_eq!(dispatcher.cached_pipeline_count(), 11);
}
#[test]
fn texture_pool_reuses_released_textures_by_key() {
let gpu = pollster::block_on(GpuContext::new()).expect("gpu");
let key = TextureKey::offscreen(32, 32, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut pool = TexturePool::new();
let a = pool.lease(&gpu.device, key);
assert_eq!(pool.free_count(), 0);
pool.release(a);
assert_eq!(pool.free_count(), 1);
let b = pool.lease(&gpu.device, key);
assert_eq!(pool.free_count(), 0, "matching lease must reuse, not allocate");
pool.release(b);
let other = pool.lease(
&gpu.device,
TextureKey::offscreen(64, 32, wgpu::TextureFormat::Rgba8UnormSrgb),
);
assert_eq!(pool.free_count(), 1, "mismatched key must not consume the free list");
pool.release(other);
assert_eq!(pool.free_count(), 2);
}
#[test]
fn retain_evicts_stale_size_buckets() {
let gpu = pollster::block_on(GpuContext::new()).expect("gpu");
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let mut pool = TexturePool::new();
let old = pool.lease(&gpu.device, TextureKey::offscreen(32, 32, format));
pool.release(old);
let new = pool.lease(&gpu.device, TextureKey::offscreen(64, 64, format));
pool.release(new);
assert_eq!(pool.free_count(), 2);
pool.retain(|k| k.width == 64 && k.height == 64);
assert_eq!(pool.free_count(), 1, "stale 32x32 bucket must be evicted");
let reused = pool.lease(&gpu.device, TextureKey::offscreen(64, 64, format));
assert_eq!(pool.free_count(), 0, "retained texture must be reused, not reallocated");
pool.release(reused);
}
#[allow(clippy::cast_precision_loss)]
#[test]
fn aurora_tier_cost_is_monotone_off_low_med_high() {
use std::time::Instant;
const PW: u32 = 1024;
const PH: u32 = 1024;
const WARMUP: usize = 8;
const FRAMES: usize = 64;
const SLACK: f64 = 1.25;
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let gpu = pollster::block_on(GpuContext::new()).expect("gpu");
let target = HeadlessTarget::new(&gpu, PW, PH, format);
let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("catalog sampler"),
..wgpu::SamplerDescriptor::default()
});
let mut pool = TexturePool::new();
let scene_lease = pool.lease(&gpu.device, TextureKey::offscreen(PW, PH, format));
let mut dispatcher = WgpuDispatcher::new(&gpu.device, &gpu.queue, format);
let graph = CatalogEffect::Aurora
.graph()
.compile()
.expect("aurora graph compiles");
let params_buf = gpu.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(aurora::PARAMS_RESOURCE),
size: CatalogEffect::Aurora.params_size() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut time_tier = |q: AuroraQuality| -> f64 {
let mut params = aurora::AuroraParams::default()
.with_resolution([PW as f32, PH as f32])
.with_quality(q)
.with_intensity(0.5);
let mut start = Instant::now();
for i in 0..(WARMUP + FRAMES) {
if i == WARMUP {
start = Instant::now();
}
params.set_time(i as f32 / 60.0);
let bindings = engawa::ResourceBindings::new()
.with(SCENE, engawa::ResourceHandle::Texture(SCENE.into()))
.with(OUT, engawa::ResourceHandle::Texture(OUT.into()));
let bound = BoundResources::new()
.with(SCENE, scene_lease.bound_resource())
.with(OUT, BoundResource::Texture { view: target.view().clone(), format })
.with(CATALOG_SAMPLER, BoundResource::Sampler(sampler.clone()))
.with(aurora::PARAMS_RESOURCE, BoundResource::Uniform(params_buf.clone()));
let frame = FrameUniforms::new().with(aurora::PARAMS_RESOURCE, ¶ms);
let cmd = dispatcher
.dispatch_with(&graph, &bindings, bound, &frame)
.expect("aurora dispatch");
gpu.queue.submit(std::iter::once(cmd));
let _ = gpu.device.poll(wgpu::PollType::Wait);
}
start.elapsed().as_secs_f64() * 1000.0
};
let off_ms = time_tier(AuroraQuality::Off);
let low_ms = time_tier(AuroraQuality::Low);
let med_ms = time_tier(AuroraQuality::Medium);
let high_ms = time_tier(AuroraQuality::High);
eprintln!(
"aurora perf smoke ({FRAMES} frames @ {PW}x{PH}): \
off={off_ms:.2}ms ({:.3}ms/frame) \
low={low_ms:.2}ms ({:.3}ms/frame) \
med={med_ms:.2}ms ({:.3}ms/frame) \
high={high_ms:.2}ms ({:.3}ms/frame)",
off_ms / FRAMES as f64,
low_ms / FRAMES as f64,
med_ms / FRAMES as f64,
high_ms / FRAMES as f64,
);
assert!(
off_ms <= low_ms * SLACK,
"tier cost inversion: Off ({off_ms:.2}ms) costs more than Low ({low_ms:.2}ms) × slack"
);
assert!(
low_ms <= med_ms * SLACK,
"tier cost inversion: Low ({low_ms:.2}ms) costs more than Medium ({med_ms:.2}ms) × slack"
);
assert!(
med_ms <= high_ms * SLACK,
"tier cost inversion: Medium ({med_ms:.2}ms) costs more than High ({high_ms:.2}ms) × slack"
);
}