use damascene_core::color::ColorSpace;
use damascene_core::prelude::*;
use damascene_wgpu::Runner;
const SIZE: u32 = 160;
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
const RAMP_W: u32 = 256;
const RAMP_PEAK: f32 = 4.0;
fn headless_device() -> Option<(wgpu::Device, wgpu::Queue, String)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: None,
force_fallback_adapter: false,
apply_limit_buckets: false,
}))
.ok()?;
let backend = format!("{:?}", adapter.get_info().backend);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("image_remaster_test"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
experimental_features: wgpu::ExperimentalFeatures::default(),
memory_hints: wgpu::MemoryHints::Performance,
trace: wgpu::Trace::Off,
}))
.ok()?;
Some((device, queue, backend))
}
fn ramp() -> Image {
let mut px = Vec::with_capacity((RAMP_W * 4) as usize);
for x in 0..RAMP_W {
let v = x as f32 / (RAMP_W - 1) as f32 * RAMP_PEAK;
px.extend([v, v, v, 1.0]);
}
Image::from_rgba_f32_in(ColorSpace::SCRGB_LINEAR, RAMP_W, 1, px)
}
fn ramp_tree(limit: DynamicRangeLimit) -> El {
image(ramp())
.dynamic_range_limit(limit)
.image_fit(ImageFit::Fill)
.width(Size::Fixed(SIZE as f32))
.height(Size::Fixed(SIZE as f32))
}
fn render_row(
device: &wgpu::Device,
queue: &wgpu::Queue,
runner: &mut Runner,
tree: El,
) -> Vec<f32> {
runner.prepare(
device,
queue,
tree,
Rect::new(0.0, 0.0, SIZE as f32, SIZE as f32),
1.0,
);
let target = device.create_texture(&wgpu::TextureDescriptor {
label: Some("image_remaster_target"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let target_view = target.create_view(&wgpu::TextureViewDescriptor::default());
let unpadded = SIZE * 8;
let bytes_per_row =
unpadded.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("image_remaster_readback"),
size: (bytes_per_row * SIZE) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("image_remaster"),
});
runner.render(
device,
&mut encoder,
&target,
&target_view,
None,
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(SIZE),
},
},
wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
let slice = readback.slice(..);
slice.map_async(wgpu::MapMode::Read, |r| r.expect("map readback"));
device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
let data = slice.get_mapped_range().unwrap();
let row = SIZE as usize / 2;
let start = row * bytes_per_row as usize;
let out = data[start..start + unpadded as usize]
.chunks_exact(8)
.map(|px| half::f16::from_ne_bytes([px[0], px[1]]).to_f32())
.collect();
drop(data);
readback.unmap();
out
}
fn max_of(row: &[f32]) -> f32 {
row.iter().copied().fold(0.0, f32::max)
}
#[test]
fn remaster_follows_dynamic_range_limit() {
let Some((device, queue, backend)) = headless_device() else {
eprintln!("image_remaster: no GPU adapter, skipping");
return;
};
eprintln!("image_remaster: using {backend} adapter");
let mut runner = Runner::new(&device, &queue, FORMAT);
runner.set_surface_size(SIZE, SIZE);
runner.set_output_luminance(2.0, 203.0);
let row = render_row(
&device,
&queue,
&mut runner,
ramp_tree(DynamicRangeLimit::NoLimit),
);
let peak = max_of(&row);
assert!(
peak <= 2.02,
"no-limit on a 2× panel must stay within headroom, peaked at {peak}"
);
assert!(
peak > 1.5,
"no-limit on a 2× panel should still use the headroom, peaked at {peak}"
);
let (aa, bb) = (row[SIZE as usize * 3 / 4], row[SIZE as usize - 2]);
assert!(
bb > aa + 0.01,
"roll-off must keep highlight gradation (got {aa} → {bb})"
);
let row = render_row(
&device,
&queue,
&mut runner,
ramp_tree(DynamicRangeLimit::Standard),
);
let peak = max_of(&row);
assert!(
peak <= 1.02,
"standard must tonemap to SDR, peaked at {peak}"
);
let (aa, bb) = (row[SIZE as usize / 2], row[SIZE as usize - 2]);
assert!(
bb > aa + 0.005,
"standard roll-off must keep highlight gradation (got {aa} → {bb})"
);
runner.set_output_luminance(f32::INFINITY, 203.0);
let row = render_row(
&device,
&queue,
&mut runner,
ramp_tree(DynamicRangeLimit::NoLimit),
);
let peak = max_of(&row);
assert!(
peak > 3.5,
"unbounded headroom must pass HDR through, peaked at {peak}"
);
let row = render_row(
&device,
&queue,
&mut runner,
ramp_tree(DynamicRangeLimit::ConstrainedHigh),
);
let peak = max_of(&row);
assert!(
peak <= 2.02,
"constrained-high must cap at 2x, peaked at {peak}"
);
}