use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use uzor_urx_core::config::UrxConfig;
use crate::atlas::{AtlasStats, NativeGlyphAtlas};
use crate::encode::{self, BatchKind, EncodedFrame, FrameOp, MaskOp};
use crate::gradient_lut::GradientLutAtlas;
use crate::image_cache::NativeImageCache;
use crate::msaa::MsaaTarget;
use crate::native_error::NativeRenderError;
use crate::pipelines::blend_composite::BlendCompositePipeline;
use crate::pipelines::glyph::GlyphPipeline;
use crate::pipelines::gradient::GradientPipeline;
use crate::pipelines::image::ImagePipeline;
use crate::pipelines::line::LinePipeline;
use crate::pipelines::path::{GeometryCapacityError, PathPipeline};
use crate::pipelines::quad::QuadPipeline;
use crate::pipelines::stencil_mask::StencilMaskPipeline;
use crate::stencil::StencilTarget;
use crate::tessellate::{TessCache, TessCacheStats};
fn native_geometry_capacity_error(
frame_id: u64,
error: GeometryCapacityError,
) -> NativeRenderError {
crate::profile::stage(
"capacity_failure",
format_args!(
"failure_stage={} buffer={} requested_instances={} requested_bytes={} \
current_capacity_instances={} instance_size={} max_bytes={}",
error.stage,
error.buffer,
error.requested_instances,
error.requested_bytes,
error.current_capacity_instances,
error.instance_size,
error.max_bytes,
),
);
NativeRenderError::GeometryCapacity {
frame_id,
stage: error.stage,
buffer: error.buffer,
requested_instances: error.requested_instances,
requested_bytes: error.requested_bytes,
current_capacity_instances: error.current_capacity_instances,
instance_size: error.instance_size,
max_bytes: error.max_bytes,
}
}
fn profile_buffer_upload_begin<T>(buffer: &'static str, data: &[T]) {
crate::profile::stage(
"gpu_buffer_reserve_upload_begin",
format_args!(
"buffer={} instances={} upload_bytes={}",
buffer,
data.len(),
std::mem::size_of_val(data),
),
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Viewport {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PreparedFrameKey {
revision: u64,
viewport: Viewport,
}
struct PreparedFrame {
key: PreparedFrameKey,
frame: EncodedFrame,
}
impl PreparedFrame {
fn matches(&self, revision: u64, viewport: Viewport) -> bool {
self.key == PreparedFrameKey { revision, viewport }
}
}
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
struct Uniforms {
screen_size: [f32; 2],
_pad: [f32; 2],
}
struct BlendLayerTarget {
msaa_view: Option<wgpu::TextureView>,
resolve_view: wgpu::TextureView,
resolve_bind_group: wgpu::BindGroup,
stencil: StencilTarget,
width: u32,
height: u32,
}
impl BlendLayerTarget {
fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
sample_count: u32,
resolve_bgl: &wgpu::BindGroupLayout,
resolve_sampler: &wgpu::Sampler,
width: u32,
height: u32,
) -> Self {
let resolve_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("uzor_urx_wgpu.native_blend_layer_resolve"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let resolve_view = resolve_texture.create_view(&wgpu::TextureViewDescriptor::default());
let resolve_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("uzor_urx_wgpu.native_blend_layer_resolve_bg"),
layout: resolve_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&resolve_view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(resolve_sampler) },
],
});
let msaa_view = (sample_count > 1).then(|| {
let msaa_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("uzor_urx_wgpu.native_blend_layer_msaa"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
msaa_tex.create_view(&wgpu::TextureViewDescriptor::default())
});
Self { msaa_view, resolve_view, resolve_bind_group, stencil: StencilTarget::new(sample_count), width, height }
}
fn color_view(&self, sample_count: u32) -> &wgpu::TextureView {
if sample_count > 1 {
self.msaa_view.as_ref().expect("BlendLayerTarget built with sample_count > 1 must have an msaa_view")
} else {
&self.resolve_view
}
}
}
struct BlendLayerPool {
targets: Vec<Option<BlendLayerTarget>>,
}
impl BlendLayerPool {
fn new() -> Self {
Self { targets: Vec::new() }
}
fn ensure(
&mut self,
device: &wgpu::Device,
format: wgpu::TextureFormat,
sample_count: u32,
resolve_bgl: &wgpu::BindGroupLayout,
resolve_sampler: &wgpu::Sampler,
depth: u32,
width: u32,
height: u32,
armed: bool,
) {
let idx = (depth - 1) as usize;
if self.targets.len() <= idx {
self.targets.resize_with(idx + 1, || None);
}
let needs_new = match &self.targets[idx] {
Some(t) => t.width != width || t.height != height,
None => true,
};
if needs_new {
self.targets[idx] =
Some(BlendLayerTarget::new(device, format, sample_count, resolve_bgl, resolve_sampler, width, height));
}
if armed {
self.targets[idx]
.as_mut()
.expect("just ensured above")
.stencil
.ensure(device, width, height);
}
}
fn get(&self, depth: u32) -> &BlendLayerTarget {
self.targets[(depth - 1) as usize]
.as_ref()
.expect("BlendLayerPool::get called for a depth its matching PushLayer never ensured")
}
}
pub struct NativeUrxRenderer {
device: wgpu::Device,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
sample_count: u32,
quad: QuadPipeline,
line: LinePipeline,
path: PathPipeline,
glyph: GlyphPipeline,
stencil_mask: StencilMaskPipeline,
blend_composite: BlendCompositePipeline,
gradient: GradientPipeline,
image: ImagePipeline,
uniform_buffer: wgpu::Buffer,
uniform_bind_group: wgpu::BindGroup,
resolve_bgl: wgpu::BindGroupLayout,
resolve_sampler: wgpu::Sampler,
msaa: MsaaTarget,
stencil: StencilTarget,
layer_pool: BlendLayerPool,
tess_cache: TessCache,
glyph_atlas: NativeGlyphAtlas,
gradient_lut: GradientLutAtlas,
image_cache: NativeImageCache,
frame_scratch: encode::EncodedFrame,
prepared_frame: Option<PreparedFrame>,
blend_layer_max_depth: usize,
text_gamma_enabled: bool,
}
impl NativeUrxRenderer {
pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self {
Self::with_sample_count(device, queue, format, 4)
}
pub fn with_sample_count(
device: wgpu::Device,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
sample_count: u32,
) -> Self {
Self::with_config_and_cache_mode(
device,
queue,
format,
sample_count,
&UrxConfig::default(),
true,
)
}
pub fn with_config(
device: wgpu::Device,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
sample_count: u32,
cfg: &UrxConfig,
) -> Self {
Self::with_config_and_cache_mode(device, queue, format, sample_count, cfg, false)
}
fn with_config_and_cache_mode(
device: wgpu::Device,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
sample_count: u32,
cfg: &UrxConfig,
adaptive_tess_cache: bool,
) -> Self {
let sample_count = sample_count.max(1);
let uniform_data = Uniforms { screen_size: [1.0, 1.0], _pad: [0.0; 2] };
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("uzor_urx_wgpu.native_uniforms"),
contents: bytemuck::bytes_of(&uniform_data),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("uzor_urx_wgpu.native_uniform_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("uzor_urx_wgpu.native_uniform_bg"),
layout: &uniform_bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform_buffer.as_entire_binding() }],
});
let quad = QuadPipeline::new(&device, format, sample_count, &uniform_bgl);
let line = LinePipeline::new(&device, format, sample_count, &uniform_bgl);
let path = PathPipeline::new(&device, format, sample_count, &uniform_bgl);
let glyph_atlas = NativeGlyphAtlas::new(&device, &queue, cfg.wgpu_glyph_atlas_w, cfg.wgpu_glyph_atlas_h);
let glyph = GlyphPipeline::new(&device, format, sample_count, &uniform_bgl, glyph_atlas.bind_group_layout());
let stencil_mask = StencilMaskPipeline::new(&device, format, sample_count, &uniform_bgl);
let resolve_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("uzor_urx_wgpu.native_blend_layer_resolve_sampler"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let resolve_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("uzor_urx_wgpu.native_blend_layer_resolve_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let blend_composite = BlendCompositePipeline::new(&device, format, sample_count, &uniform_bgl, &resolve_bgl);
let gradient_lut = GradientLutAtlas::new(&device, cfg.wgpu_gradient_lut_rows);
let gradient = GradientPipeline::new(&device, format, sample_count, &uniform_bgl, gradient_lut.bind_group_layout());
let image_cache = NativeImageCache::new(&device, cfg.wgpu_image_cache_cap);
let image = ImagePipeline::new(&device, format, sample_count, &uniform_bgl, image_cache.bind_group_layout());
let msaa = MsaaTarget::new(sample_count, format);
let stencil = StencilTarget::new(sample_count);
let layer_pool = BlendLayerPool::new();
let tess_cache = if adaptive_tess_cache {
TessCache::with_adaptive_cap(cfg.path_tess_cache_cap)
} else {
TessCache::with_cap(cfg.path_tess_cache_cap)
};
Self {
device,
queue,
format,
sample_count,
quad,
line,
path,
glyph,
stencil_mask,
blend_composite,
gradient,
image,
uniform_buffer,
uniform_bind_group,
resolve_bgl,
resolve_sampler,
msaa,
stencil,
layer_pool,
tess_cache,
glyph_atlas,
gradient_lut,
image_cache,
frame_scratch: encode::EncodedFrame::default(),
prepared_frame: None,
blend_layer_max_depth: cfg.blend_layer_max_depth,
text_gamma_enabled: cfg.text_gamma_enabled,
}
}
pub fn format(&self) -> wgpu::TextureFormat {
self.format
}
pub fn sample_count(&self) -> u32 {
self.sample_count
}
pub fn resize(&mut self, width: u32, height: u32) {
self.msaa.ensure(&self.device, width, height);
}
pub fn tess_cache_stats(&self) -> TessCacheStats {
self.tess_cache.stats()
}
pub fn glyph_atlas_stats(&self) -> AtlasStats {
self.glyph_atlas.stats()
}
pub fn gradient_lut_stats(&self) -> crate::gradient_lut::GradientLutAtlasStats {
self.gradient_lut.stats()
}
pub fn image_cache_stats(&self) -> crate::image_cache::NativeImageCacheStats {
self.image_cache.stats()
}
pub fn render_into_encoder(
&mut self,
scene: &uzor_urx_core::scene::Scene,
encoder: &mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
viewport: Viewport,
) -> Result<(), NativeRenderError> {
self.invalidate_prepared_frame();
self.render_into_encoder_impl(scene, None, encoder, view, viewport)
}
pub fn render_retained_into_encoder(
&mut self,
scene: &uzor_urx_core::scene::Scene,
revision: u64,
encoder: &mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
viewport: Viewport,
) -> Result<(), NativeRenderError> {
self.render_into_encoder_impl(scene, Some(revision), encoder, view, viewport)
}
fn invalidate_prepared_frame(&mut self) {
if let Some(prepared) = self.prepared_frame.take() {
self.frame_scratch = prepared.frame;
}
}
fn render_into_encoder_impl(
&mut self,
scene: &uzor_urx_core::scene::Scene,
retained_revision: Option<u64>,
encoder: &mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
viewport: Viewport,
) -> Result<(), NativeRenderError> {
let frame_profile = crate::profile::FrameGuard::enter();
let frame_id = frame_profile.id();
let profile = frame_profile.enabled();
if viewport.width == 0 || viewport.height == 0 {
return Err(NativeRenderError::ZeroViewport { width: viewport.width, height: viewport.height });
}
if view.texture().format() != self.format {
return Err(NativeRenderError::FormatMismatch { expected: self.format });
}
self.resize(viewport.width, viewport.height);
self.glyph_atlas.begin_frame();
self.gradient_lut.begin_frame();
self.tess_cache.begin_frame();
self.image_cache.begin_frame();
let tess_before = profile.then(|| self.tess_cache.stats());
crate::profile::stage(
"encode_begin",
format_args!(
"commands={} viewport={}x{}",
scene.len(),
viewport.width,
viewport.height,
),
);
let prepared = self.prepared_frame.take();
let prepared_scene_cache_hit = retained_revision
.zip(prepared.as_ref())
.is_some_and(|(revision, prepared)| prepared.matches(revision, viewport));
crate::profile::stage(
"prepared_scene_cache",
format_args!(
"prepared_scene_cache_hit={} revision={}",
prepared_scene_cache_hit,
retained_revision.unwrap_or(0),
),
);
let (frame, encode_scene_us) = if prepared_scene_cache_hit {
(
prepared
.expect("prepared_scene_cache_hit requires a retained frame")
.frame,
profile.then_some(0),
)
} else {
let reusable = prepared
.map(|prepared| prepared.frame)
.unwrap_or_else(|| std::mem::take(&mut self.frame_scratch));
let profile_t0 = profile.then(std::time::Instant::now);
let frame = encode::encode_scene_reusing(
reusable,
scene,
viewport,
&mut self.tess_cache,
Some(&mut self.glyph_atlas),
Some(&mut self.gradient_lut),
self.blend_layer_max_depth,
self.text_gamma_enabled,
);
(frame, profile_t0.map(|t| t.elapsed().as_micros()))
};
let tess_after = profile.then(|| self.tess_cache.stats());
let tess_profile = self.tess_cache.frame_profile();
crate::profile::stage(
"encode_end",
format_args!(
"encode_us={} tessellate_us={} triangles={} geometry_used_bytes={} geometry_capacity_bytes={}",
encode_scene_us.unwrap_or(0),
tess_profile.tessellate_us,
frame.triangles.len(),
frame.geometry_used_bytes(),
frame.geometry_capacity_bytes(),
),
);
if let Some(error) = frame.geometry_capacity_error {
let error = native_geometry_capacity_error(frame_id, error);
self.frame_scratch = frame;
return Err(error);
}
let uniforms = Uniforms { screen_size: [viewport.width as f32, viewport.height as f32], _pad: [0.0; 2] };
let upload_uniform_t0 = profile.then(std::time::Instant::now);
self.queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
let upload_uniform_us = upload_uniform_t0.map(|t| t.elapsed().as_micros());
let upload_quad_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("quad", &frame.quads);
self.quad.upload(&self.device, &self.queue, &frame.quads);
let upload_quad_us = upload_quad_t0.map(|t| t.elapsed().as_micros());
let upload_line_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("line", &frame.lines);
self.line.upload(&self.device, &self.queue, &frame.lines);
let upload_line_us = upload_line_t0.map(|t| t.elapsed().as_micros());
let upload_path_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("path", &frame.triangles);
let path_upload = match self.path.upload(&self.device, &self.queue, &frame.triangles) {
Ok(profile) => profile,
Err(error) => {
let error = native_geometry_capacity_error(frame_id, error);
self.frame_scratch = frame;
return Err(error);
}
};
let upload_path_us = upload_path_t0.map(|t| t.elapsed().as_micros());
let upload_glyph_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("glyph", &frame.glyphs);
self.glyph.upload(&self.device, &self.queue, &frame.glyphs);
let upload_glyph_us = upload_glyph_t0.map(|t| t.elapsed().as_micros());
let upload_stencil_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("stencil_mask", &frame.stencil_masks);
let stencil_upload = match self.stencil_mask.upload(&self.device, &self.queue, &frame.stencil_masks) {
Ok(profile) => profile,
Err(error) => {
let error = native_geometry_capacity_error(frame_id, error);
self.frame_scratch = frame;
return Err(error);
}
};
let upload_stencil_us = upload_stencil_t0.map(|t| t.elapsed().as_micros());
let upload_composite_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("blend_composite", &frame.composites);
self.blend_composite.upload(&self.device, &self.queue, &frame.composites);
let upload_composite_us = upload_composite_t0.map(|t| t.elapsed().as_micros());
let upload_gradient_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("gradient", &frame.gradients);
self.gradient.upload(&self.device, &self.queue, &frame.gradients);
let upload_gradient_us = upload_gradient_t0.map(|t| t.elapsed().as_micros());
let upload_image_t0 = profile.then(std::time::Instant::now);
profile_buffer_upload_begin("image", &frame.images);
self.image.upload(&self.device, &self.queue, &frame.images);
let upload_image_us = upload_image_t0.map(|t| t.elapsed().as_micros());
let flush_glyph_t0 = profile.then(std::time::Instant::now);
self.glyph_atlas.flush_uploads(&self.queue);
let flush_glyph_us = flush_glyph_t0.map(|t| t.elapsed().as_micros());
let flush_gradient_t0 = profile.then(std::time::Instant::now);
self.gradient_lut.flush_uploads(&self.queue);
let flush_gradient_us = flush_gradient_t0.map(|t| t.elapsed().as_micros());
if frame.has_rounded_clip {
self.stencil.ensure(&self.device, viewport.width, viewport.height);
}
crate::profile::stage(
"replay_begin",
format_args!("ops={} rounded_clip={}", frame.ops.len(), frame.has_rounded_clip),
);
let replay_t0 = profile.then(std::time::Instant::now);
self.replay_ops(&frame, encoder, view, viewport);
let replay_us = replay_t0.map(|t| t.elapsed().as_micros());
crate::profile::stage(
"replay_end",
format_args!("replay_us={}", replay_us.unwrap_or(0)),
);
if profile {
crate::profile::stage(
"frame_summary",
format_args!(
"commands={} ops={} \
quads={} lines={} triangles={} glyphs={} stencil_masks={} composites={} gradients={} images={} \
tess_entries={} tess_hits={} tess_misses={} tessellate_calls={} tessellated_triangles={} \
tessellate_us={} tess_cache_growths={} tess_cache_reserved_entries={} tess_cache_reserve_us={} \
cpu_geometry_used_bytes={} cpu_geometry_capacity_bytes={} cpu_geometry_reserve_calls={} \
cpu_geometry_growths={} cpu_geometry_growth_bytes={} cpu_geometry_peak_requested_bytes={} \
cpu_geometry_reserve_us={} \
gpu_path_upload_bytes={} gpu_path_capacity_before_instances={} gpu_path_capacity_after_instances={} \
gpu_path_growth_bytes={} gpu_path_reserve_us={} gpu_path_upload_us={} \
gpu_stencil_upload_bytes={} gpu_stencil_capacity_before_instances={} \
gpu_stencil_capacity_after_instances={} gpu_stencil_growth_bytes={} \
gpu_stencil_reserve_us={} gpu_stencil_upload_us={} \
prepared_scene_cache_hit={} encode_scene_us={} upload_uniform_us={} upload_quad_us={} upload_line_us={} upload_path_us={} \
upload_glyph_us={} upload_stencil_us={} upload_composite_us={} upload_gradient_us={} upload_image_us={} \
flush_glyph_us={} flush_gradient_us={} replay_ops_us={}",
scene.len(),
frame.ops.len(),
frame.quads.len(),
frame.lines.len(),
frame.triangles.len(),
frame.glyphs.len(),
frame.stencil_masks.len(),
frame.composites.len(),
frame.gradients.len(),
frame.images.len(),
tess_after.map_or(0, |stats| stats.entries),
tess_after.zip(tess_before).map_or(0, |(after, before)| after.hits - before.hits),
tess_after.zip(tess_before).map_or(0, |(after, before)| after.misses - before.misses),
tess_profile.tessellate_calls,
tess_profile.tessellated_triangles,
tess_profile.tessellate_us,
tess_profile.cache_growths,
tess_profile.cache_reserved_entries,
tess_profile.cache_reserve_us,
frame.geometry_used_bytes(),
frame.geometry_capacity_bytes(),
frame.geometry_profile.reserve_calls,
frame.geometry_profile.growths,
frame.geometry_profile.growth_bytes,
frame.geometry_profile.peak_requested_bytes,
frame.geometry_profile.reserve_us,
path_upload.upload_bytes,
path_upload.capacity_before_instances,
path_upload.capacity_after_instances,
path_upload.growth_bytes,
path_upload.reserve_us,
path_upload.upload_us,
stencil_upload.upload_bytes,
stencil_upload.capacity_before_instances,
stencil_upload.capacity_after_instances,
stencil_upload.growth_bytes,
stencil_upload.reserve_us,
stencil_upload.upload_us,
prepared_scene_cache_hit,
encode_scene_us.unwrap_or(0),
upload_uniform_us.unwrap_or(0),
upload_quad_us.unwrap_or(0),
upload_line_us.unwrap_or(0),
upload_path_us.unwrap_or(0),
upload_glyph_us.unwrap_or(0),
upload_stencil_us.unwrap_or(0),
upload_composite_us.unwrap_or(0),
upload_gradient_us.unwrap_or(0),
upload_image_us.unwrap_or(0),
flush_glyph_us.unwrap_or(0),
flush_gradient_us.unwrap_or(0),
replay_us.unwrap_or(0),
),
);
}
if let Some(revision) = retained_revision {
self.prepared_frame = Some(PreparedFrame {
key: PreparedFrameKey { revision, viewport },
frame,
});
} else {
self.frame_scratch = frame;
}
Ok(())
}
fn replay_ops(
&mut self,
frame: &EncodedFrame,
encoder: &mut wgpu::CommandEncoder,
view: &wgpu::TextureView,
viewport: Viewport,
) {
let armed = frame.has_rounded_clip;
let sample_count = self.sample_count;
let ops = &frame.ops;
let effective_ref = |raw: Option<u32>| -> Option<u32> { if armed { Some(raw.unwrap_or(0)) } else { None } };
let mut idx = 0usize;
let mut current_depth = 0u32;
let mut open_kind = OpenKind::First;
let mut composite_idx = 0u32;
let mut pending_composite: Option<(u32, Option<u32>)> = None;
loop {
let close_kind = peek_close_kind(ops, idx);
if current_depth > 0 {
self.layer_pool.ensure(
&self.device,
self.format,
sample_count,
&self.resolve_bgl,
&self.resolve_sampler,
current_depth,
viewport.width,
viewport.height,
armed,
);
}
let depth_stencil_attachment = armed.then(|| {
stencil_attachment_for(current_depth, open_kind, close_kind, &self.stencil, &self.layer_pool)
});
let (color_view, resolve_target) =
color_attachment_for(current_depth, close_kind, sample_count, &self.msaa, &self.layer_pool, view);
let load_op = color_load_op(open_kind);
let store_op = color_store_op(close_kind, sample_count);
{
let mut pass =
open_pass(encoder, color_view, resolve_target, load_op, store_op, depth_stencil_attachment);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
if let Some((popped_depth, sr)) = pending_composite.take() {
let resolve_bg = &self.layer_pool.get(popped_depth).resolve_bind_group;
self.blend_composite.bind(&mut pass, sr, resolve_bg);
self.blend_composite.draw_one(&mut pass, composite_idx);
composite_idx += 1;
}
let mut current: Option<(BatchKind, Option<u32>)> = None;
while idx < ops.len() {
let FrameOp::Draw(batch) = &ops[idx] else { break };
idx += 1;
if batch.count == 0 {
continue;
}
match batch.kind {
BatchKind::Quad => {
let sr = effective_ref(batch.stencil_ref);
if current != Some((batch.kind, sr)) {
self.quad.bind(&mut pass, sr);
current = Some((batch.kind, sr));
}
self.quad.draw_range(&mut pass, batch.start, batch.count);
}
BatchKind::Line => {
let sr = effective_ref(batch.stencil_ref);
if current != Some((batch.kind, sr)) {
self.line.bind(&mut pass, sr);
current = Some((batch.kind, sr));
}
self.line.draw_range(&mut pass, batch.start, batch.count);
}
BatchKind::Triangle => {
let sr = effective_ref(batch.stencil_ref);
if current != Some((batch.kind, sr)) {
self.path.bind(&mut pass, sr);
current = Some((batch.kind, sr));
}
self.path.draw_range(&mut pass, batch.start, batch.count);
}
BatchKind::Glyph => {
let sr = effective_ref(batch.stencil_ref);
if current != Some((batch.kind, sr)) {
self.glyph.bind(&mut pass, sr, self.glyph_atlas.bind_group());
current = Some((batch.kind, sr));
}
self.glyph.draw_range(&mut pass, batch.start, batch.count);
}
BatchKind::StencilMask(op) => {
let gate = batch.stencil_ref.unwrap_or(0);
if current != Some((batch.kind, batch.stencil_ref)) {
match op {
MaskOp::Increment => self.stencil_mask.bind_increment(&mut pass, gate),
MaskOp::Decrement => self.stencil_mask.bind_decrement(&mut pass, gate),
}
current = Some((batch.kind, batch.stencil_ref));
}
self.stencil_mask.draw_range(&mut pass, batch.start, batch.count);
}
BatchKind::Gradient => {
let sr = effective_ref(batch.stencil_ref);
if current != Some((batch.kind, sr)) {
self.gradient.bind(&mut pass, sr, self.gradient_lut.bind_group());
current = Some((batch.kind, sr));
}
self.gradient.draw_range(&mut pass, batch.start, batch.count);
}
BatchKind::Image(id) => {
let sr = effective_ref(batch.stencil_ref);
let Some(slot) = self.image_cache.get_or_upload(&self.device, &self.queue, id) else {
metrics::counter!(
uzor_urx_core::metrics_keys::KEY_RENDER_PRIMITIVES,
"kind" => "native_image_cache_full_this_frame"
)
.increment(1);
current = None;
continue;
};
if current != Some((batch.kind, sr)) {
self.image.bind(&mut pass, sr, NativeImageCache::bind_group_of(slot));
current = Some((batch.kind, sr));
}
self.image.draw_range(&mut pass, batch.start, batch.count);
}
}
}
}
if idx >= ops.len() {
debug_assert_eq!(
current_depth, 0,
"the op stream must always end back at the root — encode.rs's end-of-scene force-close \
guarantees every PushLayer has a matching (real or synthetic) PopLayer"
);
break;
}
match &ops[idx] {
FrameOp::PushLayer { depth } => {
current_depth = *depth;
open_kind = OpenKind::First;
idx += 1;
}
FrameOp::PopLayer { depth, stencil_ref, .. } => {
let parent_depth = depth - 1;
pending_composite = Some((*depth, effective_ref(*stencil_ref)));
current_depth = parent_depth;
open_kind = OpenKind::Resume;
idx += 1;
}
FrameOp::Draw(_) => unreachable!(
"the draw-replay loop above only breaks on a non-Draw op or end-of-ops; a Draw here would mean \
it broke early without consuming it"
),
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OpenKind {
First,
Resume,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CloseKind {
Pause,
Final,
}
fn peek_close_kind(ops: &[FrameOp], mut i: usize) -> CloseKind {
while i < ops.len() {
match &ops[i] {
FrameOp::Draw(_) => i += 1,
FrameOp::PushLayer { .. } => return CloseKind::Pause,
FrameOp::PopLayer { .. } => return CloseKind::Final,
}
}
CloseKind::Final
}
fn color_load_op(open_kind: OpenKind) -> wgpu::LoadOp<wgpu::Color> {
match open_kind {
OpenKind::First => wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
OpenKind::Resume => wgpu::LoadOp::Load,
}
}
fn color_store_op(close_kind: CloseKind, sample_count: u32) -> wgpu::StoreOp {
match (close_kind, sample_count > 1) {
(CloseKind::Final, true) => wgpu::StoreOp::Discard,
(CloseKind::Final, false) => wgpu::StoreOp::Store,
(CloseKind::Pause, _) => wgpu::StoreOp::Store,
}
}
fn color_attachment_for<'a>(
depth: u32,
close_kind: CloseKind,
sample_count: u32,
msaa: &'a MsaaTarget,
layer_pool: &'a BlendLayerPool,
caller_view: &'a wgpu::TextureView,
) -> (&'a wgpu::TextureView, Option<&'a wgpu::TextureView>) {
if depth == 0 {
if sample_count > 1 {
let resolve = matches!(close_kind, CloseKind::Final).then_some(caller_view);
(msaa.color_view(), resolve)
} else {
(caller_view, None)
}
} else {
let target = layer_pool.get(depth);
let color_view = target.color_view(sample_count);
if sample_count > 1 {
let resolve = matches!(close_kind, CloseKind::Final).then_some(&target.resolve_view);
(color_view, resolve)
} else {
(color_view, None)
}
}
}
fn stencil_attachment_for<'a>(
depth: u32,
open_kind: OpenKind,
close_kind: CloseKind,
root_stencil: &'a StencilTarget,
layer_pool: &'a BlendLayerPool,
) -> wgpu::RenderPassDepthStencilAttachment<'a> {
let view = if depth == 0 { root_stencil.view() } else { layer_pool.get(depth).stencil.view() };
wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: None,
stencil_ops: Some(wgpu::Operations {
load: if open_kind == OpenKind::First { wgpu::LoadOp::Clear(0) } else { wgpu::LoadOp::Load },
store: if close_kind == CloseKind::Final { wgpu::StoreOp::Discard } else { wgpu::StoreOp::Store },
}),
}
}
fn open_pass<'enc>(
encoder: &'enc mut wgpu::CommandEncoder,
color_view: &wgpu::TextureView,
resolve_target: Option<&wgpu::TextureView>,
load_op: wgpu::LoadOp<wgpu::Color>,
store_op: wgpu::StoreOp,
depth_stencil_attachment: Option<wgpu::RenderPassDepthStencilAttachment<'_>>,
) -> wgpu::RenderPass<'enc> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("uzor_urx_wgpu.native_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color_view,
resolve_target,
depth_slice: None,
ops: wgpu::Operations { load: load_op, store: store_op },
})],
depth_stencil_attachment,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewport_is_plain_copy_data() {
let v = Viewport { width: 100, height: 200 };
let v2 = v;
assert_eq!(v, v2);
}
#[test]
fn prepared_frame_key_requires_exact_revision_and_viewport() {
let prepared = PreparedFrame {
key: PreparedFrameKey {
revision: 41,
viewport: Viewport { width: 1280, height: 720 },
},
frame: EncodedFrame::default(),
};
assert!(prepared.matches(41, Viewport { width: 1280, height: 720 }));
assert!(!prepared.matches(42, Viewport { width: 1280, height: 720 }));
assert!(!prepared.matches(41, Viewport { width: 1279, height: 720 }));
assert!(!prepared.matches(41, Viewport { width: 1280, height: 721 }));
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn gradient_lut_and_image_cache_stats_default_to_zero() {
let Some((device, queue)) = test_device() else { return };
let renderer = NativeUrxRenderer::new(device, queue, wgpu::TextureFormat::Rgba8Unorm);
let g = renderer.gradient_lut_stats();
assert_eq!(g.entries, 0);
assert_eq!(g.hits, 0);
assert_eq!(g.misses, 0);
assert_eq!(g.evictions, 0);
let i = renderer.image_cache_stats();
assert_eq!(i.entries, 0);
assert_eq!(i.hits, 0);
assert_eq!(i.misses, 0);
assert_eq!(i.evictions, 0);
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
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::LowPower,
force_fallback_adapter: false,
compatible_surface: None,
}))
.ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("uzor-urx-wgpu-renderer-test"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::default(),
trace: wgpu::Trace::Off,
experimental_features: wgpu::ExperimentalFeatures::default(),
}))
.ok()
}
fn readback_rgba(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture, width: u32, height: u32) -> Vec<u8> {
let aligned_stride = (width * 4 + 255) & !255;
let buf_size = (aligned_stride * height) as u64;
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("uzor-urx-wgpu-renderer-test-readback"),
size: buf_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo {
buffer: &staging,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(aligned_stride), rows_per_image: Some(height) },
},
wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
);
queue.submit(Some(enc.finish()));
let slice = staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
rx.recv()
.expect("map_async callback channel closed before firing")
.expect("staging buffer map failed");
let raw = slice.get_mapped_range();
let mut out = Vec::with_capacity((width * height * 4) as usize);
for row in 0..height as usize {
let row_start = row * aligned_stride as usize;
let row_end = row_start + (width * 4) as usize;
out.extend_from_slice(&raw[row_start..row_end]);
}
drop(raw);
staging.unmap();
out
}
fn make_target(device: &wgpu::Device, format: wgpu::TextureFormat, size: u32) -> (wgpu::Texture, wgpu::TextureView) {
let target = device.create_texture(&wgpu::TextureDescriptor {
label: Some("uzor-urx-wgpu-renderer-test-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,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
(target, view)
}
fn render_center_pixel(
renderer: &mut NativeUrxRenderer,
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
scene: &Scene,
revision: Option<u64>,
size: u32,
) -> [u8; 4] {
let (target, view) = make_target(device, format, size);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
let viewport = Viewport { width: size, height: size };
match revision {
Some(revision) => renderer
.render_retained_into_encoder(scene, revision, &mut encoder, &view, viewport)
.expect("retained scene render must succeed"),
None => renderer
.render_into_encoder(scene, &mut encoder, &view, viewport)
.expect("immediate scene render must succeed"),
}
queue.submit(Some(encoder.finish()));
let pixels = readback_rgba(device, queue, &target, size, size);
let i = (((size / 2) * size + size / 2) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn retained_frame_cache_hits_and_invalidates_on_revision_viewport_and_immediate_render() {
let Some((device, queue)) = test_device() else { return };
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
let mut renderer = NativeUrxRenderer::with_sample_count(device.clone(), queue.clone(), FORMAT, 1);
let scene = |rgba: [u8; 4], size: u32| {
let mut scene = Scene::new();
scene.push(solid_rect(0.0, 0.0, size as f64, size as f64, rgba));
scene
};
let red_64 = scene([220, 40, 40, 255], 64);
let blue_64 = scene([40, 40, 220, 255], 64);
assert_eq!(
render_center_pixel(&mut renderer, &device, &queue, FORMAT, &red_64, Some(7), 64),
[220, 40, 40, 255],
"first retained render must encode revision 7",
);
assert_eq!(
render_center_pixel(&mut renderer, &device, &queue, FORMAT, &blue_64, Some(7), 64),
[220, 40, 40, 255],
"same revision and viewport must replay the retained frame without re-encoding",
);
assert_eq!(
render_center_pixel(&mut renderer, &device, &queue, FORMAT, &blue_64, Some(8), 64),
[40, 40, 220, 255],
"revision change must miss and encode the new scene",
);
let green_32 = scene([40, 220, 40, 255], 32);
assert_eq!(
render_center_pixel(&mut renderer, &device, &queue, FORMAT, &green_32, Some(8), 32),
[40, 220, 40, 255],
"viewport change must miss even when the revision is unchanged",
);
let red_32 = scene([220, 40, 40, 255], 32);
assert_eq!(
render_center_pixel(&mut renderer, &device, &queue, FORMAT, &red_32, None, 32),
[220, 40, 40, 255],
"ordinary rendering must draw the immediate scene",
);
let blue_32 = scene([40, 40, 220, 255], 32);
assert_eq!(
render_center_pixel(&mut renderer, &device, &queue, FORMAT, &blue_32, Some(8), 32),
[40, 40, 220, 255],
"ordinary rendering must invalidate a matching retained revision and viewport",
);
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn rounded_clip_corner_is_background_and_center_is_fill() {
let Some((device, queue)) = test_device() else { return };
const NATIVE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const SIZE: u32 = 256;
let mut renderer = NativeUrxRenderer::new(device.clone(), queue.clone(), NATIVE_FORMAT);
use uzor_urx_core::math::{Affine, Brush, Color, Rect, RoundedRect};
use uzor_urx_core::scene::{DrawCommand, Scene};
let mut scene = Scene::new();
scene.push(DrawCommand::FillRect {
rect: Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64),
radii: None,
brush: Brush::Solid(Color::from_rgba8(32, 32, 32, 255)),
transform: Affine::IDENTITY,
});
scene.push(DrawCommand::PushClipRoundedRect {
rect: RoundedRect::new(50.5, 50.5, 200.5, 200.5, 40.0),
transform: Affine::IDENTITY,
});
scene.push(DrawCommand::FillRect {
rect: Rect::new(50.5, 50.5, 200.5, 200.5),
radii: None,
brush: Brush::Solid(Color::from_rgba8(220, 60, 60, 255)),
transform: Affine::IDENTITY,
});
scene.push(DrawCommand::PopClip);
let (target, view) = make_target(&device, NATIVE_FORMAT, SIZE);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
renderer
.render_into_encoder(&scene, &mut encoder, &view, Viewport { width: SIZE, height: SIZE })
.expect("a well-formed rounded-clip scene must not error");
queue.submit(Some(encoder.finish()));
let pixels = readback_rgba(&device, &queue, &target, SIZE, SIZE);
let px = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * SIZE + x) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
};
assert_eq!(
px(52, 52),
[32, 32, 32, 255],
"corner-cut pixel must be background — a bbox-approx clip would incorrectly show fill color here"
);
assert_eq!(px(125, 125), [220, 60, 60, 255], "center of the rounded clip must show the fill color");
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn rotated_uniform_radius_rect_corner_regions_render_correctly() {
let Some((device, queue)) = test_device() else { return };
const NATIVE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const SIZE: u32 = 256;
let mut renderer = NativeUrxRenderer::with_sample_count(device.clone(), queue.clone(), NATIVE_FORMAT, 1);
use uzor_urx_core::math::{Affine, Brush, Color, Rect};
use uzor_urx_core::scene::{DrawCommand, Scene};
let mut scene = Scene::new();
scene.push(DrawCommand::FillRect {
rect: Rect::new(0.0, 0.0, SIZE as f64, SIZE as f64),
radii: None,
brush: Brush::Solid(Color::from_rgba8(32, 32, 32, 255)),
transform: Affine::IDENTITY,
});
scene.push(DrawCommand::FillRect {
rect: Rect::new(88.0, 88.0, 168.0, 168.0),
radii: None,
brush: Brush::Solid(Color::from_rgba8(220, 60, 60, 255)),
transform: Affine::rotate_about(std::f64::consts::FRAC_PI_4, (128.0, 128.0)),
});
let (target, view) = make_target(&device, NATIVE_FORMAT, SIZE);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
renderer
.render_into_encoder(&scene, &mut encoder, &view, Viewport { width: SIZE, height: SIZE })
.expect("a well-formed rotated-rect scene must not error");
queue.submit(Some(encoder.finish()));
let pixels = readback_rgba(&device, &queue, &target, SIZE, SIZE);
let px = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * SIZE + x) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
};
assert_eq!(px(128, 128), [220, 60, 60, 255], "dead center must be fill regardless of rotation");
assert_eq!(
px(163, 93),
[32, 32, 32, 255],
"must be background: inside the UN-rotated bbox but outside the TRUE rotated shape"
);
assert_eq!(
px(178, 128),
[220, 60, 60, 255],
"must be fill: outside the UN-rotated bbox but inside the TRUE rotated shape"
);
}
use uzor_urx_core::math::{Affine, Brush, Color, Rect};
use uzor_urx_core::scene::{DrawCommand, Scene};
fn push_layer(alpha: f32) -> DrawCommand {
DrawCommand::PushBlendLayer { mode: uzor_urx_core::math::BlendMode::default(), alpha, transform: Affine::IDENTITY }
}
fn solid_rect(x: f64, y: f64, w: f64, h: f64, rgba: [u8; 4]) -> DrawCommand {
DrawCommand::FillRect {
rect: Rect::new(x, y, x + w, y + h),
radii: None,
brush: Brush::Solid(Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3])),
transform: Affine::IDENTITY,
}
}
fn blend_straight_over_opaque(src: [u8; 4], dst: [u8; 3], layer_alpha: f32) -> [u8; 3] {
let sa = (src[3] as f32 / 255.0) * layer_alpha;
let mut out = [0u8; 3];
for c in 0..3 {
let s = src[c] as f32 / 255.0;
let d = dst[c] as f32 / 255.0;
let v = s * sa + d * (1.0 - sa);
out[c] = (v * 255.0).round().clamp(0.0, 255.0) as u8;
}
out
}
fn assert_close_rgb(got: [u8; 3], expected: [u8; 3], tol: i32, msg: &str) {
for c in 0..3 {
let diff = (got[c] as i32 - expected[c] as i32).abs();
assert!(diff <= tol, "{msg} — channel {c}: got {} expected {} (diff {diff} > tol {tol})", got[c], expected[c]);
}
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn three_level_nested_blend_layers_do_not_panic_and_composite_correct_pixels() {
let Some((device, queue)) = test_device() else { return };
const NATIVE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const SIZE: u32 = 64;
let mut renderer = NativeUrxRenderer::with_sample_count(device.clone(), queue.clone(), NATIVE_FORMAT, 1);
let mut scene = Scene::new();
scene.push(solid_rect(0.0, 0.0, SIZE as f64, SIZE as f64, [0, 0, 0, 255]));
scene.push(push_layer(0.5));
scene.push(solid_rect(0.0, 0.0, SIZE as f64, SIZE as f64, [255, 0, 0, 255]));
scene.push(push_layer(0.5));
scene.push(solid_rect(0.0, 0.0, SIZE as f64, SIZE as f64, [0, 255, 0, 255]));
scene.push(DrawCommand::PopBlendLayer); scene.push(solid_rect(0.0, 0.0, 8.0, 8.0, [0, 0, 255, 255]));
scene.push(DrawCommand::PopBlendLayer); scene.push(push_layer(0.5));
scene.push(solid_rect(0.0, 0.0, SIZE as f64, SIZE as f64, [255, 255, 0, 255]));
scene.push(DrawCommand::PopBlendLayer);
let (target, view) = make_target(&device, NATIVE_FORMAT, SIZE);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
renderer
.render_into_encoder(&scene, &mut encoder, &view, Viewport { width: SIZE, height: SIZE })
.expect("a well-formed nested blend-layer scene must not panic or hit a wgpu validation error");
queue.submit(Some(encoder.finish()));
let pixels = readback_rgba(&device, &queue, &target, SIZE, SIZE);
let i = ((32 * SIZE + 32) * 4) as usize;
let got = [pixels[i], pixels[i + 1], pixels[i + 2]];
let bg = [0u8, 0, 0];
let after_layer2 = blend_straight_over_opaque([0, 255, 0, 255], [255, 0, 0], 0.5); let after_layer1 = blend_straight_over_opaque([after_layer2[0], after_layer2[1], after_layer2[2], 255], bg, 0.5);
let after_layer3 = blend_straight_over_opaque([255, 255, 0, 255], after_layer1, 0.5);
assert_close_rgb(
got,
after_layer3,
1,
"composited pixel must match the hand-computed 3-level nested blend result \
(bg <- layer1{red <- layer2{green}} <- layer3{yellow})",
);
let j = ((4 * SIZE + 4) * 4) as usize;
let got_corner = [pixels[j], pixels[j + 1], pixels[j + 2]];
let layer1_corner = blend_straight_over_opaque([0, 0, 255, 255], [255, 0, 0], 1.0); let expected_corner = blend_straight_over_opaque([layer1_corner[0], layer1_corner[1], layer1_corner[2], 255], bg, 0.5);
let expected_corner = blend_straight_over_opaque([255, 255, 0, 255], expected_corner, 0.5);
assert_close_rgb(
got_corner,
expected_corner,
1,
"the resumed layer-1 pass must retain its pre-pause red content under the blue corner rect",
);
}
#[test]
#[ignore = "needs a headless GPU adapter"]
fn blend_layer_group_differs_from_the_same_content_with_no_layer() {
let Some((device, queue)) = test_device() else { return };
const NATIVE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const SIZE: u32 = 64;
let render = |use_layer: bool| -> [u8; 3] {
let mut renderer = NativeUrxRenderer::with_sample_count(device.clone(), queue.clone(), NATIVE_FORMAT, 1);
let mut scene = Scene::new();
scene.push(solid_rect(0.0, 0.0, SIZE as f64, SIZE as f64, [10, 20, 30, 255]));
if use_layer {
scene.push(push_layer(0.5));
}
scene.push(solid_rect(0.0, 0.0, SIZE as f64, SIZE as f64, [200, 100, 50, 255]));
if use_layer {
scene.push(DrawCommand::PopBlendLayer);
}
let (target, view) = make_target(&device, NATIVE_FORMAT, SIZE);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
renderer
.render_into_encoder(&scene, &mut encoder, &view, Viewport { width: SIZE, height: SIZE })
.expect("well-formed scene must not error");
queue.submit(Some(encoder.finish()));
let pixels = readback_rgba(&device, &queue, &target, SIZE, SIZE);
let i = ((32 * SIZE + 32) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2]]
};
let with_layer = render(true);
let without_layer = render(false);
assert_ne!(
with_layer, without_layer,
"a layer composited at alpha 0.5 must produce visibly different pixels than drawing the same \
content directly with no layer at all — otherwise the offscreen isolation isn't actually happening"
);
assert_eq!(without_layer, [200, 100, 50]);
let expected = blend_straight_over_opaque([200, 100, 50, 255], [10, 20, 30], 0.5);
assert_eq!(with_layer, expected);
}
}