use std::sync::{Arc, OnceLock};
use darkly::brush::compile_graph;
use darkly::brush::eval::BrushGraphRunner;
use darkly::brush::gpu_context::{BrushGpuContext, BrushPerfCounters, DabBatch, StrokeResources};
use darkly::brush::nodes::liquify::LIQUIFY_SPACING_PX;
use darkly::brush::paint_info::PaintInformation;
use darkly::brush::pipeline::BrushPipelines;
use darkly::brush::stroke_buffer::StrokeBuffer;
use darkly::gpu::test_utils::{create_test_texture, readback_texture, test_device};
const CANVAS: u32 = 128;
fn shared_device() -> (Arc<wgpu::Device>, Arc<wgpu::Queue>) {
static HANDLES: OnceLock<(Arc<wgpu::Device>, Arc<wgpu::Queue>)> = OnceLock::new();
HANDLES
.get_or_init(|| {
let (d, q) = test_device();
(Arc::new(d), Arc::new(q))
})
.clone()
}
fn two_tone_canvas(red_x_threshold: u32) -> Vec<u8> {
let mut out = vec![0u8; (CANVAS * CANVAS * 4) as usize];
for y in 0..CANVAS {
for x in 0..CANVAS {
let idx = ((y * CANVAS + x) * 4) as usize;
if x < red_x_threshold {
out[idx] = 220;
out[idx + 1] = 20;
out[idx + 2] = 20;
}
out[idx + 3] = 255;
}
}
out
}
fn pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] {
let idx = ((y * CANVAS + x) * 4) as usize;
[rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]]
}
fn render_liquify_dabs(size_override: f32, dabs: &[([f32; 2], f32, f32)]) -> Vec<u8> {
let brush = darkly::brush::builtin_brushes::all()
.into_iter()
.find(|b| b.metadata.name == "Liquify")
.unwrap();
let mut graph = brush.metadata.graph.clone();
let term_id = darkly::brush::find_terminal(&graph).expect("Liquify brush has a terminal");
graph
.set_port_default(term_id, "size", size_override)
.unwrap();
graph.set_port_default(term_id, "strength", 1.0).unwrap();
let (device, queue) = shared_device();
let (layer_texture, layer_view) =
create_test_texture(&device, &queue, CANVAS, CANVAS, &two_tone_canvas(36));
let pipelines = BrushPipelines::new(
&device,
&queue,
&darkly::gpu::selection::selection_mask_bgl(&device),
);
let mut stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines);
let pre_stroke = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture(
&layer_texture,
&layer_view,
wgpu::TextureFormat::Rgba8Unorm,
darkly::coord::CanvasRect::from_xywh(0, 0, CANVAS, CANVAS),
);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("liquify-test-pre-stroke"),
});
stroke_buffer.save_pre_stroke(&device, &mut enc, &pipelines, &pre_stroke);
queue.submit([enc.finish()]);
let mut runner: BrushGraphRunner = compile_graph(&graph).expect("brush compiles");
macro_rules! make_ctx {
($label:expr) => {{
let (scratch, pre_stroke_tex, pre_stroke_bg) = stroke_buffer.parts_for_brush_ctx();
BrushGpuContext {
encoder: device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some($label),
}),
device: &device,
queue: &queue,
pipelines: &pipelines,
selection_bind_group: pipelines.default_selection_bind_group(),
canvas_width: CANVAS,
canvas_height: CANVAS,
canvas_origin: [0, 0],
blend_mode: 0,
view_rotation: 0.0,
perf: BrushPerfCounters::default(),
stroke: Some(StrokeResources {
scratch,
paint_target: darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture(
&layer_texture,
&layer_view,
wgpu::TextureFormat::Rgba8Unorm,
darkly::coord::CanvasRect::from_xywh(0, 0, CANVAS, CANVAS),
),
pre_stroke_texture: pre_stroke_tex,
pre_stroke_bind_group: pre_stroke_bg,
}),
preview: None,
dab_batch: DabBatch::default(),
}
}};
}
{
let mut ctx = make_ctx!("liquify-test-begin");
runner.begin_stroke(&mut ctx);
queue.submit([ctx.encoder.finish()]);
}
{
let mut ctx = make_ctx!("liquify-test-flush");
for (i, (pos, dir, dist)) in dabs.iter().enumerate() {
let motion = [
LIQUIFY_SPACING_PX * dir.cos(),
LIQUIFY_SPACING_PX * dir.sin(),
];
let info = PaintInformation {
pos: *pos,
drawing_angle: *dir,
distance: *dist,
motion,
pressure: 1.0,
..Default::default()
};
runner.seed_sensors(&info, [1.0, 1.0, 1.0, 1.0], 0xC0FFEE, i as u32);
runner.execute_cpu();
runner.execute_gpu(&mut ctx);
}
runner.flush_dabs(&mut ctx);
runner.commit(&mut ctx);
queue.submit([ctx.encoder.finish()]);
}
readback_texture(
&device,
&queue,
&layer_texture,
wgpu::TextureFormat::Rgba8Unorm,
CANVAS,
CANVAS,
)
}
#[test]
fn single_liquify_dab_warps_red_into_center() {
let rgba = render_liquify_dabs(0.3, &[([38.0, 64.0], 0.0, 10.0)]);
let centre = pixel(&rgba, 38, 64);
assert!(
centre[0] > 150,
"single liquify dab warping from the red bar should deposit \
red at the centre; got {centre:?}"
);
assert!(
centre[0] > centre[1] + 50,
"warped pixel should be clearly red, not noise; got {centre:?}"
);
}
#[test]
fn liquify_dab2_reads_dab1_deposit_not_pre_stroke() {
let rgba = render_liquify_dabs(
0.3,
&[
([38.0, 64.0], 0.0, 10.0),
([42.0, 64.0], 0.0, 20.0),
],
);
let centre_2 = pixel(&rgba, 42, 64);
assert!(
centre_2[0] > 120,
"dab 2's warp source must read dab 1's red deposit through \
the per-dab barrier — got centre {centre_2:?}. Pre-stroke at \
(38, 64) was BLACK; if dab 2 sees this value it means the \
inter-dab `copy_texture_to_texture` (and thus the per-dab \
serialization) is broken."
);
assert!(
centre_2[0] > centre_2[1] + 50,
"dab 2 reading dab 1's red deposit should leave red dominant; \
got {centre_2:?}"
);
}
#[test]
fn warp_magnitude_is_size_invariant() {
let small = render_liquify_dabs(0.3, &[([38.0, 64.0], 0.0, 10.0)]);
let small_at_centre = pixel(&small, 38, 64);
assert!(
small_at_centre[0] > 150,
"small brush: centre should be red (warp ran), got \
{small_at_centre:?}"
);
let small_at_42 = pixel(&small, 42, 64);
assert!(
small_at_42[0] < 60,
"small brush: (42, 64) should sample from background, got \
{small_at_42:?}"
);
let large = render_liquify_dabs(1.0, &[([38.0, 64.0], 0.0, 10.0)]);
let large_at_centre = pixel(&large, 38, 64);
assert!(
large_at_centre[0] > 150,
"large brush: centre should also be red (size doesn't change \
displacement), got {large_at_centre:?}"
);
let large_at_42 = pixel(&large, 42, 64);
assert!(
large_at_42[0] < 60,
"large brush: (42, 64) must still sample from background — \
if this is red the radius-coupled formula has come back and \
the strength slider once again grows with brush size. Got \
{large_at_42:?}"
);
}