Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ node_modules/
target/
.DS_Store

# secrets — PIXELLAB_API_KEY for demos/*/gen-assets.ts lives here
.env

# pass-1 transform cache (content-hash keyed; safe to delete any time)
.cache/
# per-app build output of scripts/build.ts (dist/ is ignored repo-wide too)
Expand Down
Binary file added assets/screenshots/nightbloom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 62 additions & 1 deletion core/src/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,13 +519,14 @@ struct Walker<'a> {
inspect_slot: u32,
/// World AABB of `inspect_slot`, set when the walk reaches it.
inspect_hit: Option<Clip>,
particle_layers: &'a [crate::ParticleLayer],
}

/// Build the full DrawList for the current (laid-out) tree. `frame` is the
/// core's vblank counter (Ui.frame); animated sprites pick their cell from it.
/// `screen` is the viewport every coordinate is clipped to.
#[allow(clippy::too_many_arguments)]
pub fn build(
pub(crate) fn build(
tree: &Tree,
styles: &StyleTable,
fonts: &Fonts,
Expand All @@ -534,6 +535,7 @@ pub fn build(
textures: &mut Vec<crate::TexSlot>,
tex_free: &mut Vec<u32>,
discs: &mut DiscCache,
particle_layers: &[crate::ParticleLayer],
dl: &mut DrawList,
inspect_id: i32,
inspect_prev: Option<(f32, f32, f32, f32)>,
Expand All @@ -559,6 +561,7 @@ pub fn build(
discs,
inspect_slot,
inspect_hit: None,
particle_layers,
};
let root_slot = crate::tree::split_id(spec::ROOT_ID).1;
w.paint(root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), dl);
Expand Down Expand Up @@ -734,6 +737,64 @@ impl<'a> Walker<'a> {
self.emit_tex_quad(dl, &world, l.w, l.h, node.tex as u32, op, &clip, fu0, fv0, fu1, fv1);
}

// -- paint-only particle layer ---------------------------------------
if let Some(layer) = self.particle_layers.iter().find(|layer| layer.node == node.id(slot)) {
for particle in &layer.particles {
if !particle.x.is_finite()
|| !particle.y.is_finite()
|| !particle.size.is_finite()
|| particle.size <= 0.0
|| particle.size > 64.0
|| alpha(particle.color) == 0
{
continue;
}
let (x, y) = world.apply(particle.x, particle.y);
if node.tex >= 0 {
self.emit_corner_quad(
dl,
node.tex as u32,
x,
y,
particle.size,
0.0,
0.0,
1.0,
scale_alpha(particle.color, op),
&clip,
);
continue;
}
let r = roundf(particle.size * 0.5).max(1.0) as u32;
if let Some((tex, dim)) = disc_texture(self.discs, self.textures, self.tex_free, r) {
let size = (r * 2) as f32;
self.emit_corner_quad(
dl,
tex,
x,
y,
size,
0.0,
0.0,
size / dim as f32,
scale_alpha(particle.color, op),
&clip,
);
} else {
self.emit_box(
dl,
&world,
particle.x,
particle.y,
particle.x + particle.size,
particle.y + particle.size,
Fill::Flat(scale_alpha(particle.color, op)),
&clip,
);
}
}
}

// -- children (overflow-hidden scissor around them; z-index stable
// sort within siblings) ---------------------------------------------
let mut child_clip = clip;
Expand Down
111 changes: 111 additions & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,38 @@ struct TimelineInst {
loop_frames: u16,
}

/// One paint-only particle owned by a particle-layer node. Coordinates are
/// local to that node; the normal tree transform and overflow clip apply.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Particle {
pub x: f32,
pub y: f32,
pub size: f32,
pub color: u32,
}

pub(crate) struct ParticleLayer {
pub node: i32,
pub particles: Vec<Particle>,
}

#[inline]
fn valid_particle(particle: &Particle) -> bool {
particle.x.is_finite()
&& particle.y.is_finite()
&& particle.size.is_finite()
&& particle.size > 0.0
&& particle.size <= 64.0
}

/// The retained UI core. One per host/screen.
pub struct Ui {
tree: tree::Tree,
styles: style::StyleTable,
fonts: text::Fonts,
anims: anim::Anims,
timelines: Vec<TimelineInst>,
particle_layers: Vec<ParticleLayer>,
layout: layout::LayoutEngine,
/// Generation-tagged texture slots (handles per spec.ts TEX_SLOT_BITS).
textures: Vec<TexSlot>,
Expand Down Expand Up @@ -249,6 +274,7 @@ impl Ui {
fonts: text::Fonts::new(),
anims: anim::Anims::new(),
timelines: Vec::new(),
particle_layers: Vec::new(),
layout: layout::LayoutEngine::new(),
textures: Vec::new(),
tex_free: Vec::new(),
Expand Down Expand Up @@ -290,6 +316,7 @@ impl Ui {
for slot in slots {
let nid = self.tree.slots[slot as usize].id(slot);
self.anims.kill_node(nid);
self.particle_layers.retain(|layer| layer.node != nid);
self.tree.free_slot(slot);
}
self.layout.dirty = true;
Expand Down Expand Up @@ -355,6 +382,89 @@ impl Ui {
}
}

/// Replace the paint-only particle batch attached to `id`. Capacity is
/// retained across frames, so steady-state updates allocate nothing.
pub fn set_particles(&mut self, id: i32, particles: &[Particle]) {
if self.tree.resolve(id).is_none() {
return;
}
if let Some(layer) = self.particle_layers.iter_mut().find(|layer| layer.node == id) {
layer.particles.clear();
layer.particles.extend(particles.iter().copied().filter(valid_particle));
return;
}
self.particle_layers.push(ParticleLayer {
node: id,
particles: particles.iter().copied().filter(valid_particle).collect(),
});
}

/// Packed host form: repeated [x:f32 bits, y:f32 bits, size:f32 bits,
/// color:ABGR] words. Only `count` complete particles are consumed.
pub fn set_particle_words(&mut self, id: i32, words: &[u32], count: usize) {
if self.tree.resolve(id).is_none() {
return;
}
let index = match self.particle_layers.iter().position(|layer| layer.node == id) {
Some(index) => index,
None => {
self.particle_layers.push(ParticleLayer { node: id, particles: Vec::new() });
self.particle_layers.len() - 1
}
};
let particles = &mut self.particle_layers[index].particles;
particles.clear();
let count = count.min(words.len() / 4);
if particles.capacity() < count {
particles.reserve(count - particles.capacity());
}
for chunk in words[..count * 4].chunks_exact(4) {
let particle = Particle {
x: f32::from_bits(chunk[0]),
y: f32::from_bits(chunk[1]),
size: f32::from_bits(chunk[2]),
color: chunk[3],
};
if valid_particle(&particle) {
particles.push(particle);
}
}
}

/// Byte-oriented host form. Unlike casting an ArrayBuffer pointer to
/// `u32`, this remains defined even when a JS engine returns an unaligned
/// typed-array backing store.
pub fn set_particle_bytes(&mut self, id: i32, bytes: &[u8], count: usize) {
if self.tree.resolve(id).is_none() {
return;
}
let index = match self.particle_layers.iter().position(|layer| layer.node == id) {
Some(index) => index,
None => {
self.particle_layers.push(ParticleLayer { node: id, particles: Vec::new() });
self.particle_layers.len() - 1
}
};
let particles = &mut self.particle_layers[index].particles;
particles.clear();
let count = count.min(bytes.len() / 16);
if particles.capacity() < count {
particles.reserve(count - particles.capacity());
}
for chunk in bytes[..count * 16].chunks_exact(16) {
let word = |at: usize| u32::from_le_bytes([chunk[at], chunk[at + 1], chunk[at + 2], chunk[at + 3]]);
let particle = Particle {
x: f32::from_bits(word(0)),
y: f32::from_bits(word(4)),
size: f32::from_bits(word(8)),
color: word(12),
};
if valid_particle(&particle) {
particles.push(particle);
}
}
}

// ---- text ------------------------------------------------------------

/// Set the UTF-8 content of a text node. Empty text nodes are excluded
Expand Down Expand Up @@ -907,6 +1017,7 @@ impl Ui {
&mut self.textures,
&mut self.tex_free,
&mut self.discs,
&self.particle_layers,
&mut self.draw_list,
self.inspect_id,
self.inspect_drawn,
Expand Down
62 changes: 61 additions & 1 deletion core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use alloc::vec::Vec;

use crate::{spec, style, Ui};
use crate::{spec, style, Particle, Ui};

// ---- binary blob builders (bytes hand-assembled per spec.ts formats) --------

Expand Down Expand Up @@ -243,6 +243,66 @@ fn validate_drawlist(words: &[u32]) -> [u32; 8] {

// ---- tests ---------------------------------------------------------------------

#[test]
fn particle_layer_reuses_one_tree_node_and_cleans_up_with_it() {
let mut ui = Ui::new();
let layer = ui.create_node(spec::NodeType::View as u8);
ui.set_prop(layer, spec::prop::WIDTH, 100.0);
ui.set_prop(layer, spec::prop::HEIGHT, 100.0);
ui.insert_before(spec::ROOT_ID, layer, 0);
ui.set_particles(
layer,
&[
Particle { x: 3.0, y: 5.0, size: 8.0, color: abgr(255, 0, 0, 255) },
Particle { x: 20.0, y: 30.0, size: 4.0, color: abgr(0, 255, 0, 255) },
],
);
ui.tick();
assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 2);

ui.set_particles(layer, &[Particle { x: 1.0, y: 2.0, size: 3.0, color: abgr(0, 0, 255, 255) }]);
assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 1);

// Host bytes may begin at an unaligned address. Decode bytewise, then
// reject a corrupt giant size instead of covering the clipped field.
let mut packed = alloc::vec![0xaa];
for word in [4.0f32.to_bits(), 6.0f32.to_bits(), 8.0f32.to_bits(), abgr(0, 255, 255, 255)] {
packed.extend_from_slice(&word.to_le_bytes());
}
ui.set_particle_bytes(layer, &packed[1..], 1);
assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 1);
packed[9..13].copy_from_slice(&200.0f32.to_bits().to_le_bytes());
ui.set_particle_bytes(layer, &packed[1..], 1);
assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 0);

ui.destroy_node(layer);
ui.tick();
assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::RECT as usize], 0);
}

#[test]
fn image_particle_layer_shares_its_bound_texture() {
let mut ui = Ui::new();
let pixels = alloc::vec![0xffu8; 2 * 2 * 4];
let tex = ui.upload_texture(&pixels, 2, 2, spec::psm::PSM_8888);
let layer = ui.create_node(spec::NodeType::Image as u8);
ui.set_prop(layer, spec::prop::WIDTH, 0.0);
ui.set_prop(layer, spec::prop::HEIGHT, 0.0);
ui.set_image(layer, tex);
ui.insert_before(spec::ROOT_ID, layer, 0);
ui.set_particles(layer, &[Particle { x: 4.0, y: 6.0, size: 10.0, color: 0xffffffff }]);
ui.tick();

let words = ui.draw().words.clone();
assert_eq!(validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], 1);
let i = words.iter().position(|&word| word == spec::draw_op::TEX_QUAD).unwrap();
assert_eq!(words[i + 1], tex as u32);
assert_eq!(decode_xy(words[i + 2]), (4, 6));
assert_eq!(decode_wh(words[i + 3]), (10, 10));
assert_eq!(f32::from_bits(words[i + 4]), 0.0);
assert_eq!(f32::from_bits(words[i + 6]), 1.0);
}

#[test]
fn arena_id_reuse_and_stale_id_noop() {
let mut ui = Ui::new();
Expand Down
Loading