use bevy::camera::visibility::RenderLayers;
use bevy::camera::{ClearColorConfig, RenderTarget};
use bevy::image::{TextureAtlas, TextureAtlasLayout};
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
use bevy::ui::ComputedNode;
use bevy::ui::widget::{ImageNode, NodeImageMode};
use bevy_pf_vector::{
Brush, DashPattern, FillRule as VFillRule, GradientStop, HudTransform, LineCap, LineJoin,
PathCommand, PathStyle, StrokeStyle, VectorPrimitive, VectorShape,
};
use bevy_pf_xaml::geometry::{FillRule, PathData, PathSegment};
use bevy_pf_xaml::value as v;
use crate::shapes::{PfShape, PfShapeClaim, PfShapeRendered, ShapeGeometry, arc_to_cubics};
const SHAPE_LAYER: usize = 24;
const ATLAS_SIZE: u32 = 2048;
const SLOT_PADDING: u32 = 4;
#[derive(Resource)]
pub struct PfShapeAtlas {
pub image: Handle<Image>,
pub layout: Handle<TextureAtlasLayout>,
packer: guillotiere::AtlasAllocator,
extra: Vec<AtlasPage>,
generation: u32,
}
pub struct AtlasPage {
pub image: Handle<Image>,
pub layout: Handle<TextureAtlasLayout>,
packer: guillotiere::AtlasAllocator,
}
const MAX_PAGES: usize = 4;
impl PfShapeAtlas {
fn packers(&mut self) -> impl Iterator<Item = &mut guillotiere::AtlasAllocator> {
std::iter::once(&mut self.packer).chain(self.extra.iter_mut().map(|p| &mut p.packer))
}
pub fn page(&self, index: usize) -> (&Handle<Image>, &Handle<TextureAtlasLayout>) {
match index.checked_sub(1) {
None => (&self.image, &self.layout),
Some(extra) => {
let page = &self.extra[extra];
(&page.image, &page.layout)
}
}
}
fn page_count(&self) -> usize {
1 + self.extra.len()
}
fn allocate(&mut self, size: UVec2) -> Option<(usize, UVec2, UVec2, guillotiere::AllocId)> {
let padded = size + UVec2::splat(SLOT_PADDING);
if padded.x > ATLAS_SIZE || padded.y > ATLAS_SIZE {
return None;
}
let wanted = guillotiere::size2(padded.x as i32, padded.y as i32);
let mut alloc = None;
let mut page = 0;
for (index, packer) in self.packers().enumerate() {
if let Some(a) = packer.allocate(wanted) {
alloc = Some(a);
page = index;
break;
}
}
let alloc = alloc?;
let min = alloc.rectangle.min;
let granted = alloc.rectangle.size();
let capacity = UVec2::new(
(granted.width as u32).saturating_sub(SLOT_PADDING),
(granted.height as u32).saturating_sub(SLOT_PADDING),
);
Some((
page,
UVec2::new(min.x as u32, min.y as u32),
capacity,
alloc.id,
))
}
fn release(&mut self, page: usize, id: guillotiere::AllocId, generation: u32) {
if generation != self.generation {
return;
}
match page.checked_sub(1) {
None => self.packer.deallocate(id),
Some(extra) => {
if let Some(page) = self.extra.get_mut(extra) {
page.packer.deallocate(id);
}
}
}
}
fn reset(&mut self) {
self.packer.clear();
self.generation = self.generation.wrapping_add(1);
}
}
#[derive(Component, Debug, Clone)]
#[component(on_remove = release_slot)]
pub struct PfShapeGpu {
index: usize,
origin: UVec2,
capacity: UVec2,
alloc: guillotiere::AllocId,
page: usize,
size: UVec2,
draw: Entity,
draw_stroke: Option<Entity>,
generation: u32,
}
fn release_slot(
mut world: bevy::ecs::world::DeferredWorld,
ctx: bevy::ecs::lifecycle::HookContext,
) {
let Some(gpu) = world.get::<PfShapeGpu>(ctx.entity).cloned() else {
return;
};
if let Some(mut atlas) = world.get_resource_mut::<PfShapeAtlas>() {
atlas.release(gpu.page, gpu.alloc, gpu.generation);
}
let mut commands = world.commands();
commands.entity(gpu.draw).try_despawn();
if let Some(stroke) = gpu.draw_stroke {
commands.entity(stroke).try_despawn();
}
}
fn slot_capacity(px: UVec2) -> UVec2 {
const GRAIN: u32 = 16;
UVec2::new(px.x.div_ceil(GRAIN) * GRAIN, px.y.div_ceil(GRAIN) * GRAIN)
}
#[derive(Resource, Default)]
struct PfAtlasFull(bool);
#[derive(Resource, Default)]
struct PfAtlasRebuildCooldown(u32);
const REBUILD_COOLDOWN: u32 = 300;
#[derive(Resource, Default)]
pub struct PfAtlasRebuilds(pub u32);
#[derive(Component)]
struct PfShapeAtlasCamera(usize);
#[derive(Resource, Default)]
struct PfAtlasDirty(u8);
pub struct PfShapeGpuPlugin;
impl Plugin for PfShapeGpuPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<bevy_pf_vector::PfVectorPlugin>() {
app.add_plugins(bevy_pf_vector::PfVectorPlugin);
}
let claim = (sync_gpu_shapes, rebuild_atlas_if_full, gate_atlas_camera)
.chain()
.in_set(crate::shapes::PfShapeSystems::Claim);
#[cfg(feature = "native_shapes")]
let claim = claim.after(crate::shapes::style_native_shapes);
app.init_resource::<PfAtlasFull>()
.init_resource::<PfAtlasRebuildCooldown>()
.init_resource::<PfAtlasRebuilds>()
.init_resource::<PfAtlasDirty>()
.add_systems(Startup, setup_atlas)
.add_systems(PostUpdate, claim);
}
}
fn open_page(
index: usize,
commands: &mut Commands,
images: &mut Assets<Image>,
layouts: &mut Assets<TextureAtlasLayout>,
) -> AtlasPage {
let mut image = Image::new_fill(
Extent3d {
width: ATLAS_SIZE,
height: ATLAS_SIZE,
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[0, 0, 0, 0],
TextureFormat::Rgba8UnormSrgb,
bevy::asset::RenderAssetUsages::RENDER_WORLD,
);
image.texture_descriptor.usage =
TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
image.sampler = bevy::image::ImageSampler::nearest();
let image = images.add(image);
let layout = layouts.add(TextureAtlasLayout::new_empty(UVec2::splat(ATLAS_SIZE)));
let mut projection = OrthographicProjection::default_2d();
projection.scaling_mode = bevy::camera::ScalingMode::Fixed {
width: ATLAS_SIZE as f32,
height: ATLAS_SIZE as f32,
};
commands.spawn((
Camera2d,
Camera {
clear_color: ClearColorConfig::Custom(Color::NONE),
order: -100,
..default()
},
RenderTarget::Image(image.clone().into()),
Projection::Orthographic(projection),
bevy::render::view::Msaa::Off,
RenderLayers::layer(SHAPE_LAYER + index),
PfShapeAtlasCamera(index),
Name::new(format!("PfShapeAtlasCamera{index}")),
));
AtlasPage {
image,
layout,
packer: guillotiere::AtlasAllocator::new(guillotiere::size2(
ATLAS_SIZE as i32,
ATLAS_SIZE as i32,
)),
}
}
fn setup_atlas(
mut commands: Commands,
images: Option<ResMut<Assets<Image>>>,
layouts: Option<ResMut<Assets<TextureAtlasLayout>>>,
) {
let (Some(mut images), Some(mut layouts)) = (images, layouts) else {
return;
};
let mut image = Image::new_fill(
Extent3d {
width: ATLAS_SIZE,
height: ATLAS_SIZE,
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[0, 0, 0, 0],
TextureFormat::Rgba8UnormSrgb,
bevy::asset::RenderAssetUsages::RENDER_WORLD,
);
image.texture_descriptor.usage =
TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
image.sampler = bevy::image::ImageSampler::nearest();
let image = images.add(image);
let layout = layouts.add(TextureAtlasLayout::new_empty(UVec2::splat(ATLAS_SIZE)));
let mut projection = OrthographicProjection::default_2d();
projection.scaling_mode = bevy::camera::ScalingMode::Fixed {
width: ATLAS_SIZE as f32,
height: ATLAS_SIZE as f32,
};
commands.spawn((
Camera2d,
Camera {
clear_color: ClearColorConfig::Custom(Color::NONE),
order: -100,
..default()
},
RenderTarget::Image(image.clone().into()),
Projection::Orthographic(projection),
bevy::render::view::Msaa::Off,
RenderLayers::layer(SHAPE_LAYER),
PfShapeAtlasCamera(0),
Name::new("PfShapeAtlasCamera0"),
));
commands.insert_resource(PfShapeAtlas {
image,
layout,
packer: guillotiere::AtlasAllocator::new(guillotiere::size2(
ATLAS_SIZE as i32,
ATLAS_SIZE as i32,
)),
generation: 0,
extra: Vec::new(),
});
}
fn slot_center_world(origin: UVec2, size: UVec2) -> Vec2 {
let half = ATLAS_SIZE as f32 * 0.5;
Vec2::new(
origin.x as f32 + size.x as f32 * 0.5 - half,
half - (origin.y as f32 + size.y as f32 * 0.5),
)
}
fn to_local(x: f32, y: f32, size: Vec2) -> Vec2 {
Vec2::new(x - size.x * 0.5, size.y * 0.5 - y)
}
fn to_color(c: v::PfColor) -> LinearRgba {
Color::srgba_u8(c.r, c.g, c.b, c.a).to_linear()
}
fn to_brush(brush: &v::PfBrush, size: Vec2) -> Brush {
let stops = |stops: &Vec<v::GradientStop>| -> Vec<GradientStop> {
stops
.iter()
.map(|s| GradientStop {
offset: s.offset.clamp(0.0, 1.0),
color: to_color(s.color),
})
.collect()
};
match brush {
v::PfBrush::Solid(c) => Brush::Solid(to_color(*c)),
v::PfBrush::LinearGradient {
start,
end,
stops: s,
} => Brush::Linear {
start: to_local(start.x * size.x, start.y * size.y, size),
end: to_local(end.x * size.x, end.y * size.y, size),
stops: stops(s),
},
v::PfBrush::RadialGradient {
center,
radius_x,
radius_y,
stops: s,
} => Brush::Radial {
center: to_local(center.x * size.x, center.y * size.y, size),
radius: (radius_x * size.x).max(radius_y * size.y).max(1.0),
stops: stops(s),
},
}
}
fn rounded_rect(l: f32, t: f32, r: f32, b: f32, rx: f32, ry: f32, size: Vec2) -> Vec<PathCommand> {
let k = 0.5522848;
let p = |x: f32, y: f32| to_local(x, y, size);
vec![
PathCommand::MoveTo(p(l + rx, t)),
PathCommand::LineTo(p(r - rx, t)),
PathCommand::CubicTo {
ctrl1: p(r - rx + k * rx, t),
ctrl2: p(r, t + ry - k * ry),
to: p(r, t + ry),
},
PathCommand::LineTo(p(r, b - ry)),
PathCommand::CubicTo {
ctrl1: p(r, b - ry + k * ry),
ctrl2: p(r - rx + k * rx, b),
to: p(r - rx, b),
},
PathCommand::LineTo(p(l + rx, b)),
PathCommand::CubicTo {
ctrl1: p(l + rx - k * rx, b),
ctrl2: p(l, b - ry + k * ry),
to: p(l, b - ry),
},
PathCommand::LineTo(p(l, t + ry)),
PathCommand::CubicTo {
ctrl1: p(l, t + ry - k * ry),
ctrl2: p(l + rx - k * rx, t),
to: p(l + rx, t),
},
PathCommand::Close,
]
}
fn path_data_commands(data: &PathData, size: Vec2) -> Vec<PathCommand> {
let p = |pt: v::Point| to_local(pt.x, pt.y, size);
let mut out = Vec::new();
for figure in &data.figures {
out.push(PathCommand::MoveTo(p(figure.start)));
let mut cursor = figure.start;
for segment in &figure.segments {
match segment {
PathSegment::Line(to) => {
out.push(PathCommand::LineTo(p(*to)));
cursor = *to;
}
PathSegment::Cubic(c1, c2, to) => {
out.push(PathCommand::CubicTo {
ctrl1: p(*c1),
ctrl2: p(*c2),
to: p(*to),
});
cursor = *to;
}
PathSegment::Quadratic(c, to) => {
out.push(PathCommand::QuadTo {
ctrl: p(*c),
to: p(*to),
});
cursor = *to;
}
PathSegment::Arc {
radii,
rotation,
large_arc,
sweep,
to,
} => {
for (c1, c2, end) in
arc_to_cubics(cursor, *radii, *rotation, *large_arc, *sweep, *to)
{
out.push(PathCommand::CubicTo {
ctrl1: p(c1),
ctrl2: p(c2),
to: p(end),
});
}
cursor = *to;
}
}
}
if figure.closed {
out.push(PathCommand::Close);
}
}
out
}
fn commands_bounds(commands: &[PathCommand]) -> Option<(Vec2, Vec2)> {
let mut min = Vec2::splat(f32::INFINITY);
let mut max = Vec2::splat(f32::NEG_INFINITY);
let mut any = false;
let mut visit = |p: Vec2| {
min = min.min(p);
max = max.max(p);
any = true;
};
for command in commands {
match command {
PathCommand::MoveTo(p) | PathCommand::LineTo(p) => visit(*p),
PathCommand::QuadTo { ctrl, to } => {
visit(*ctrl);
visit(*to);
}
PathCommand::CubicTo { ctrl1, ctrl2, to } => {
visit(*ctrl1);
visit(*ctrl2);
visit(*to);
}
PathCommand::Close => {}
}
}
any.then_some((min, max))
}
fn map_commands(commands: &mut [PathCommand], f: impl Fn(Vec2) -> Vec2) {
for command in commands {
match command {
PathCommand::MoveTo(p) | PathCommand::LineTo(p) => *p = f(*p),
PathCommand::QuadTo { ctrl, to } => {
*ctrl = f(*ctrl);
*to = f(*to);
}
PathCommand::CubicTo { ctrl1, ctrl2, to } => {
*ctrl1 = f(*ctrl1);
*ctrl2 = f(*ctrl2);
*to = f(*to);
}
PathCommand::Close => {}
}
}
}
pub fn shape_to_vector(shape: &PfShape, px: UVec2) -> Option<(Vec<PathCommand>, PathStyle)> {
let size = Vec2::new(px.x as f32, px.y as f32);
let (w, h) = (size.x, size.y);
let st = shape.stroke_thickness;
let inset = if shape.stroke.is_some() {
st * 0.5
} else {
0.0
};
let (mut commands, rule) = match &shape.geometry {
ShapeGeometry::Rectangle { radius_x, radius_y } => {
let (l, t) = (inset, inset);
let (r, b) = ((w - inset).max(inset + 0.1), (h - inset).max(inset + 0.1));
let commands = if *radius_x > 0.0 || *radius_y > 0.0 {
let rx = radius_x.min((r - l) / 2.0);
let ry = radius_y.max(0.0).min((b - t) / 2.0);
rounded_rect(l, t, r, b, rx, ry, size)
} else {
let p = |x: f32, y: f32| to_local(x, y, size);
vec![
PathCommand::MoveTo(p(l, t)),
PathCommand::LineTo(p(r, t)),
PathCommand::LineTo(p(r, b)),
PathCommand::LineTo(p(l, b)),
PathCommand::Close,
]
};
(commands, VFillRule::NonZero)
}
ShapeGeometry::Ellipse => {
let (l, t) = (inset, inset);
let (r, b) = ((w - inset).max(inset + 0.1), (h - inset).max(inset + 0.1));
let (rx, ry) = ((r - l) * 0.5, (b - t) * 0.5);
(rounded_rect(l, t, r, b, rx, ry, size), VFillRule::NonZero)
}
ShapeGeometry::Line { x1, y1, x2, y2 } => (
vec![
PathCommand::MoveTo(to_local(*x1, *y1, size)),
PathCommand::LineTo(to_local(*x2, *y2, size)),
],
VFillRule::NonZero,
),
ShapeGeometry::Polyline { points, closed } => {
let mut iter = points.iter();
let first = iter.next()?;
let mut commands = vec![PathCommand::MoveTo(to_local(first.x, first.y, size))];
for p in iter {
commands.push(PathCommand::LineTo(to_local(p.x, p.y, size)));
}
if *closed {
commands.push(PathCommand::Close);
}
let rule = match shape.fill_rule {
Some(FillRule::NonZero) => VFillRule::NonZero,
_ => VFillRule::EvenOdd, };
(commands, rule)
}
ShapeGeometry::Path(data) => {
let rule = match data.fill_rule {
FillRule::EvenOdd => VFillRule::EvenOdd,
FillRule::NonZero => VFillRule::NonZero,
};
(path_data_commands(data, size), rule)
}
};
let stretchable = !matches!(
shape.geometry,
ShapeGeometry::Rectangle { .. } | ShapeGeometry::Ellipse
);
if stretchable
&& shape.stretch != v::Stretch::None
&& let Some((min, max)) = commands_bounds(&commands)
{
let extent = (max - min).max(Vec2::splat(1e-3));
let avail = Vec2::new((w - st).max(1.0), (h - st).max(1.0));
let mut scale = avail / extent;
match shape.stretch {
v::Stretch::Uniform => scale = Vec2::splat(scale.x.min(scale.y)),
v::Stretch::UniformToFill => scale = Vec2::splat(scale.x.max(scale.y)),
_ => {}
}
let centre = (min + max) * 0.5;
let target = Vec2::new(0.0, 0.0);
map_commands(&mut commands, |p| (p - centre) * scale + target);
}
let fill = shape.fill.as_ref().map(|b| to_brush(b, size));
let stroke = shape.stroke.as_ref().map(|b| {
let width = st.max(0.01);
StrokeStyle {
brush: to_brush(b, size),
width,
join: match shape.stroke_join {
v::PenLineJoin::Miter => LineJoin::Miter,
v::PenLineJoin::Bevel => LineJoin::Bevel,
v::PenLineJoin::Round => LineJoin::Round,
},
cap: match shape.stroke_cap {
v::PenLineCap::Flat => LineCap::Butt,
v::PenLineCap::Square | v::PenLineCap::Triangle => LineCap::Square,
v::PenLineCap::Round => LineCap::Round,
},
miter_limit: shape.stroke_miter_limit.max(1.0),
dash: (!shape.stroke_dash_array.is_empty()).then(|| {
let mut pattern: Vec<f32> = shape
.stroke_dash_array
.iter()
.map(|d| (d * width).max(0.01))
.collect();
if pattern.len() % 2 != 0 {
let copy = pattern.clone();
pattern.extend(copy); }
DashPattern {
pattern,
offset: shape.stroke_dash_offset * width,
}
}),
}
});
if fill.is_none() && stroke.is_none() {
return None;
}
Some((
commands,
PathStyle {
fill,
stroke,
fill_rule: rule,
},
))
}
fn as_rounded_box(shape: &PfShape, px: UVec2) -> Option<(Vec2, f32)> {
let size = Vec2::new(px.x as f32, px.y as f32);
match &shape.geometry {
ShapeGeometry::Rectangle { radius_x, radius_y } => {
if (radius_x - radius_y).abs() > 0.01 {
return None;
}
Some((size, *radius_x))
}
ShapeGeometry::Ellipse => {
if (size.x - size.y).abs() > 0.01 {
return None;
}
Some((size, size.x * 0.5))
}
_ => None,
}
}
fn solid(brush: &v::PfBrush) -> Option<Color> {
match brush {
v::PfBrush::Solid(c) => Some(Color::srgba_u8(c.r, c.g, c.b, c.a)),
_ => None,
}
}
fn sdf_instances(shape: &PfShape, px: UVec2) -> Option<(VectorPrimitive, Option<VectorPrimitive>)> {
let (size, radius) = as_rounded_box(shape, px)?;
let fill = shape.fill.as_ref().and_then(solid);
let stroke = shape.stroke.as_ref().and_then(solid);
if fill.is_none() && stroke.is_none() {
return None;
}
let st = shape.stroke_thickness;
let stroke_prim = |color: Color| VectorPrimitive::Rect {
size,
radius,
thickness: st.max(0.01),
color: color.to_linear(),
};
let Some(fill) = fill else {
return Some((stroke_prim(stroke.expect("fill or stroke")), None));
};
let inset = if stroke.is_some() { st * 0.5 } else { 0.0 };
let fill_prim = VectorPrimitive::Rect {
size: (size - Vec2::splat(inset * 2.0)).max(Vec2::splat(0.1)),
radius: (radius - inset).max(0.0),
thickness: 0.0,
color: fill.to_linear(),
};
Some((fill_prim, stroke.map(stroke_prim)))
}
fn spawn_draws(
commands: &mut Commands,
shape: &PfShape,
px: UVec2,
origin: UVec2,
page: usize,
) -> (Entity, Option<Entity>) {
let centre = slot_center_world(origin, px).extend(0.0);
let transform = || HudTransform {
translation: centre,
..default()
};
if let Some((first_prim, second_prim)) = sdf_instances(shape, px) {
let first = commands
.spawn((
first_prim,
transform(),
RenderLayers::layer(SHAPE_LAYER + page),
Name::new("PfShapeSdf"),
))
.id();
let second = second_prim.map(|prim| {
commands
.spawn((
prim,
HudTransform {
translation: centre + Vec3::new(0.0, 0.0, 1.0e-4),
..default()
},
RenderLayers::layer(SHAPE_LAYER + page),
Name::new("PfShapeSdfStroke"),
))
.id()
});
return (first, second);
}
let (path, style) = shape_to_vector(shape, px)
.unwrap_or_else(|| (Vec::new(), PathStyle::fill(LinearRgba::NONE)));
let draw = commands
.spawn((
VectorShape {
commands: path,
style,
},
transform(),
RenderLayers::layer(SHAPE_LAYER + page),
Name::new("PfShapeDraw"),
))
.id();
(draw, None)
}
#[allow(clippy::type_complexity)]
fn sync_gpu_shapes(
mut shapes: Query<(
Entity,
Ref<PfShape>,
&ComputedNode,
Option<&mut PfShapeGpu>,
Option<&PfShapeRendered>,
Option<&PfShapeClaim>,
)>,
mut draws: Query<(&mut VectorShape, &mut HudTransform), Without<VectorPrimitive>>,
mut prims: Query<(&mut VectorPrimitive, &mut HudTransform), Without<VectorShape>>,
atlas: Option<ResMut<PfShapeAtlas>>,
layouts: Option<ResMut<Assets<TextureAtlasLayout>>>,
images: Option<ResMut<Assets<Image>>>,
mut full: ResMut<PfAtlasFull>,
mut dirty: ResMut<PfAtlasDirty>,
mut commands: Commands,
) {
let Some(mut atlas) = atlas else { return };
let Some(mut layouts) = layouts else { return };
let Some(mut images) = images else { return };
for (entity, shape, computed, mut gpu, cpu_rendered, claimed) in &mut shapes {
let size = computed.size();
let px = UVec2::new(size.x.round() as u32, size.y.round() as u32);
if px.x == 0 || px.y == 0 {
continue;
}
let resized = gpu.as_ref().is_none_or(|g| g.size != px);
if !resized && !shape.is_changed() {
continue;
}
let Some((_path, _style)) = shape_to_vector(&shape, px) else {
continue;
};
let previous_draw: Vec<Entity> = gpu
.as_ref()
.map(|g| {
[Some(g.draw), g.draw_stroke]
.into_iter()
.flatten()
.collect()
})
.unwrap_or_default();
let old_slot = gpu.as_ref().map(|g| (g.page, g.alloc, g.generation));
if claimed.is_some_and(|c| c.backend != "vector_gpu") {
continue;
}
let held = gpu.is_some() || claimed.is_some();
let old_index = gpu.as_ref().map(|g| (g.page, g.index));
if let Some(gpu) = gpu.as_mut()
&& px.cmple(gpu.capacity).all()
&& let Some((first, second)) = sdf_instances(&shape, px)
&& second.is_some() == gpu.draw_stroke.is_some()
&& prims.contains(gpu.draw)
&& gpu.draw_stroke.is_none_or(|e| prims.contains(e))
{
let centre = slot_center_world(gpu.origin, px).extend(0.0);
if let Ok((mut prim, mut transform)) = prims.get_mut(gpu.draw) {
*prim = first;
transform.translation = centre;
}
if let Some(stroke_entity) = gpu.draw_stroke
&& let Some(second) = second
&& let Ok((mut prim, mut transform)) = prims.get_mut(stroke_entity)
{
*prim = second;
transform.translation = centre + Vec3::new(0.0, 0.0, 1.0e-4);
}
dirty.0 = 2;
if gpu.size != px {
gpu.size = px;
if let Some(mut layout) = layouts.get_mut(atlas.page(gpu.page).1.clone().id())
&& let Some(rect) = layout.textures.get_mut(gpu.index)
{
*rect = URect::from_corners(gpu.origin, gpu.origin + px);
}
}
continue;
}
if let Some(mut gpu) = gpu
&& px.cmple(gpu.capacity).all()
&& gpu.draw_stroke.is_none()
&& let Ok((mut vector, mut transform)) = draws.get_mut(gpu.draw)
{
let Some((path, style)) = shape_to_vector(&shape, px) else {
continue;
};
vector.commands = path;
vector.style = style;
dirty.0 = 2;
if gpu.size != px {
gpu.size = px;
transform.translation = slot_center_world(gpu.origin, px).extend(0.0);
if let Some(mut layout) = layouts.get_mut(atlas.page(gpu.page).1.clone().id())
&& let Some(rect) = layout.textures.get_mut(gpu.index)
{
*rect = URect::from_corners(gpu.origin, gpu.origin + px);
}
}
continue;
}
let wanted = slot_capacity(px);
let mut slot = atlas.allocate(wanted);
if slot.is_none() && atlas.page_count() < MAX_PAGES {
let index = atlas.page_count();
let page = open_page(index, &mut commands, &mut images, &mut layouts);
atlas.extra.push(page);
slot = atlas.allocate(wanted);
}
let Some((page, origin, capacity, alloc)) = slot else {
if wanted.x + SLOT_PADDING <= ATLAS_SIZE && wanted.y + SLOT_PADDING <= ATLAS_SIZE {
full.0 = true;
}
if held {
commands
.entity(entity)
.remove::<(PfShapeGpu, PfShapeClaim, ImageNode, PfShapeRendered)>();
}
continue;
};
if let Some((page, alloc, generation)) = old_slot {
atlas.release(page, alloc, generation);
}
let Some(mut layout) = layouts.get_mut(atlas.page(page).1.clone().id()) else {
continue;
};
let rect = URect::from_corners(origin, origin + px);
let index = match old_index {
Some((old_page, index)) if old_page == page && index < layout.textures.len() => {
layout.textures[index] = rect;
index
}
_ => layout.add_texture(rect),
};
for previous in previous_draw {
commands.entity(previous).despawn();
}
let (draw, draw_stroke) = spawn_draws(&mut commands, &shape, px, origin, page);
dirty.0 = 2;
commands.entity(entity).insert((
ImageNode::from_atlas_image(
atlas.page(page).0.clone(),
TextureAtlas {
layout: atlas.page(page).1.clone(),
index,
},
)
.with_mode(NodeImageMode::Stretch),
PfShapeGpu {
index,
origin,
capacity,
alloc,
page,
size: px,
draw,
draw_stroke,
generation: atlas.generation,
},
PfShapeClaim {
backend: "vector_gpu",
},
));
if cpu_rendered.is_some() {
commands.entity(entity).remove::<PfShapeRendered>();
}
}
}
fn rebuild_atlas_if_full(
mut full: ResMut<PfAtlasFull>,
atlas: Option<ResMut<PfShapeAtlas>>,
layouts: Option<ResMut<Assets<TextureAtlasLayout>>>,
slots: Query<(Entity, &PfShapeGpu)>,
mut rebuilds: ResMut<PfAtlasRebuilds>,
mut cooldown: ResMut<PfAtlasRebuildCooldown>,
mut commands: Commands,
) {
if cooldown.0 > 0 {
cooldown.0 -= 1;
full.0 = false;
return;
}
if !full.0 {
return;
}
full.0 = false;
cooldown.0 = REBUILD_COOLDOWN;
let losing = slots.iter().count();
warn!("pf: shape atlas rebuild -- {losing} shapes lose their slot for one frame");
rebuilds.0 = rebuilds.0.saturating_add(1);
let Some(mut atlas) = atlas else { return };
let Some(mut layouts) = layouts else { return };
atlas.reset();
if let Some(mut layout) = layouts.get_mut(&atlas.layout) {
layout.textures.clear();
}
for (entity, gpu) in &slots {
commands.entity(gpu.draw).despawn();
if let Some(stroke) = gpu.draw_stroke {
commands.entity(stroke).despawn();
}
commands
.entity(entity)
.remove::<(PfShapeGpu, PfShapeClaim, ImageNode)>();
}
}
fn gate_atlas_camera(
mut dirty: ResMut<PfAtlasDirty>,
slots: Query<&PfShapeGpu>,
mut cameras: Query<(&mut Camera, &PfShapeAtlasCamera)>,
) {
dirty.0 = 0;
let mut occupied = [false; MAX_PAGES];
for gpu in &slots {
if let Some(flag) = occupied.get_mut(gpu.page) {
*flag = true;
}
}
for (mut camera, page) in &mut cameras {
let active = occupied.get(page.0).copied().unwrap_or(false);
if camera.is_active != active {
camera.is_active = active;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> PfShapeAtlas {
PfShapeAtlas {
image: Handle::default(),
layout: Handle::default(),
packer: guillotiere::AtlasAllocator::new(guillotiere::size2(
ATLAS_SIZE as i32,
ATLAS_SIZE as i32,
)),
extra: Vec::new(),
generation: 0,
}
}
#[test]
fn atlas_camera_runs_only_while_a_slot_is_sampled() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<PfAtlasDirty>()
.add_systems(Update, gate_atlas_camera);
let camera = app
.world_mut()
.spawn((Camera::default(), PfShapeAtlasCamera(0)))
.id();
app.update();
assert!(
!app.world().get::<Camera>(camera).unwrap().is_active,
"an empty atlas has no consumers and should not clear or render"
);
let mut atlas = atlas();
let (page, origin, capacity, alloc) = atlas.allocate(UVec2::splat(16)).unwrap();
let draw = app.world_mut().spawn_empty().id();
let slot = app
.world_mut()
.spawn(PfShapeGpu {
index: 0,
origin,
capacity,
alloc,
page,
size: UVec2::splat(16),
draw,
draw_stroke: None,
generation: 0,
})
.id();
app.update();
assert!(
app.world().get::<Camera>(camera).unwrap().is_active,
"a live slot samples retained atlas pixels, so the camera must stay active"
);
app.world_mut().despawn(slot);
app.update();
assert!(
!app.world().get::<Camera>(camera).unwrap().is_active,
"once the last slot is gone, target persistence no longer matters"
);
}
#[test]
fn a_release_from_before_a_reset_cannot_free_a_live_allocation() {
let mut atlas = atlas();
let stale: Vec<guillotiere::AllocId> = (0..8)
.map(|_| atlas.allocate(UVec2::splat(16)).expect("fresh atlas").3)
.collect();
atlas.reset();
let live: Vec<(UVec2, guillotiere::AllocId)> = (0..8)
.map(|_| {
let (_, o, _, id) = atlas.allocate(UVec2::splat(16)).expect("rebuilt atlas");
(o, id)
})
.collect();
for id in &stale {
atlas.release(0, *id, 0);
}
let live_origins: std::collections::HashSet<(u32, u32)> =
live.iter().map(|(o, _)| (o.x, o.y)).collect();
for _ in 0..8 {
let (_, origin, _, _) = atlas.allocate(UVec2::splat(16)).expect("space remains");
assert!(
!live_origins.contains(&(origin.x, origin.y)),
"origin {origin:?} was handed out while a live shape still holds it -- \
a stale release freed an allocation belonging to somebody else"
);
}
}
#[test]
fn a_release_within_the_same_generation_returns_usable_space() {
let mut atlas = atlas();
let mut ids = Vec::new();
while let Some((_, _, _, id)) = atlas.allocate(UVec2::splat(256)) {
ids.push(id);
if ids.len() > 128 {
break;
}
}
assert!(ids.len() > 8, "expected many 256px slots in a 2048px atlas");
let generation = atlas.generation;
for id in ids {
atlas.release(0, id, generation);
}
assert!(
atlas.allocate(UVec2::splat(1024)).is_some(),
"after freeing every small slot, a large one must fit -- freed \
regions have to merge, not sit in per-size pools"
);
}
#[test]
fn the_sdf_stroke_covers_the_node_edge_not_a_half_stroke_inside_it() {
let mut shape = PfShape::new(crate::shapes::ShapeGeometry::Rectangle {
radius_x: 0.0,
radius_y: 0.0,
});
shape.fill = Some(v::PfBrush::Solid(v::PfColor {
r: 1,
g: 2,
b: 3,
a: 255,
}));
shape.stroke = Some(v::PfBrush::Solid(v::PfColor {
r: 4,
g: 5,
b: 6,
a: 255,
}));
shape.stroke_thickness = 4.0;
let (fill, stroke) = sdf_instances(&shape, UVec2::new(100, 60)).expect("SDF eligible");
let VectorPrimitive::Rect {
size: stroke_size,
thickness,
..
} = stroke.expect("fill + stroke means two instances")
else {
panic!("stroke instance should be a Rect")
};
assert_eq!(
stroke_size,
Vec2::new(100.0, 60.0),
"the stroke instance must take the FULL size; handing it the inset \
size stroked inward from an already-inset edge"
);
assert_eq!(thickness, 4.0);
let VectorPrimitive::Rect {
size: fill_size,
thickness: fill_thickness,
..
} = fill
else {
panic!("fill instance should be a Rect")
};
assert_eq!(fill_size, Vec2::new(96.0, 56.0));
assert_eq!(fill_thickness, 0.0, "a fill is not a stroke");
}
#[test]
fn a_stroke_only_shape_is_a_single_full_size_instance() {
let mut shape = PfShape::new(crate::shapes::ShapeGeometry::Rectangle {
radius_x: 0.0,
radius_y: 0.0,
});
shape.stroke = Some(v::PfBrush::Solid(v::PfColor {
r: 4,
g: 5,
b: 6,
a: 255,
}));
shape.stroke_thickness = 2.0;
let (first, second) = sdf_instances(&shape, UVec2::new(40, 40)).expect("SDF eligible");
assert!(second.is_none(), "no fill means no second instance");
let VectorPrimitive::Rect {
size, thickness, ..
} = first
else {
panic!("expected a Rect")
};
assert_eq!(size, Vec2::new(40.0, 40.0));
assert_eq!(thickness, 2.0);
}
}