Add the 'Basic Brush' node and replace the legacy Brush tool implementation with it (#4469)

* Draw raster images with pad extension instead of repeat

* Add the GPU basic brush renderer

* Rework the brush tool around the GPU basic brush

* Remove the CPU brush implementation
This commit is contained in:
Timon
2026-08-27 15:19:02 +00:00
committed by GitHub
parent fd31f2d89b
commit c7a64fff0c
31 changed files with 2181 additions and 1016 deletions

View File

@@ -2,7 +2,6 @@ use super::DocumentNode;
use crate::application_io::PlatformEditorApi;
use crate::application_io::resource::Resource;
use crate::proto::{Any as DAny, FutureAny};
use brush_nodes::brush_stroke::{BrushStroke, BrushTrace};
use brush_nodes::{BrushCache, Stroke};
use core_types::color::SRGBA8;
use core_types::list::{Item, List, NodeIdPath};
@@ -94,10 +93,6 @@ macro_rules! tagged_value {
/// (Old documents stored flat stops, a tuple list, or the ancient full `Gradient` struct under the legacy `"Gradient"` tag, all routed by `deserialize_tagged_value_with_legacy_migration`.)
#[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")]
GradientRamp(GradientRamp),
/// Stored compactly as a `Vec<BrushStroke>`, materializes as the single-value `Item<BrushTrace>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
#[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code
#[serde(alias = "BrushStrokeTable")]
BrushStrokes(Vec<BrushStroke>),
Strokes(Vec<Stroke>),
BrushCache(BrushCache),
// =======================
@@ -142,7 +137,6 @@ macro_rules! tagged_value {
Self::DashPattern(lengths) => lengths.cache_hash(state),
Self::BoxCorners(values) => values.cache_hash(state),
Self::GradientRamp(ramp) => ramp.cache_hash(state),
Self::BrushStrokes(strokes) => strokes.cache_hash(state),
Self::Strokes(strokes) => strokes.cache_hash(state),
Self::BrushCache(cache) => cache.cache_hash(state),
// =======================
@@ -207,7 +201,6 @@ macro_rules! tagged_value {
Self::DashPattern(lengths) => Box::new(Item::new_from_element(DashPattern::from(lengths))),
Self::BoxCorners(values) => Box::new(Item::new_from_element(BoxCorners::from(values))),
Self::GradientRamp(ramp) => Box::new(Item::<Gradient>::from(ramp)),
Self::BrushStrokes(strokes) => Box::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))),
Self::Strokes(strokes) => {
let list: List<Stroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
Box::new(list)
@@ -275,7 +268,6 @@ macro_rules! tagged_value {
Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))),
Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))),
Self::GradientRamp(ramp) => Arc::new(Item::<Gradient>::from(ramp)),
Self::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))),
Self::Strokes(strokes) => {
let list: List<Stroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
Arc::new(list)
@@ -309,7 +301,6 @@ macro_rules! tagged_value {
Self::DashPattern(_) => item!(DashPattern),
Self::BoxCorners(_) => item!(BoxCorners),
Self::GradientRamp(_) => item!(Gradient),
Self::BrushStrokes(_) => item!(BrushTrace),
Self::Strokes(_) => list!(Stroke),
Self::BrushCache(_) => item!(BrushCache),
// =======================
@@ -350,8 +341,6 @@ macro_rules! tagged_value {
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(downcast::<Item<BoxCorners>>(input).unwrap().into_element().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::<Gradient>(input).unwrap()))),
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::<Item<Gradient>>(input).unwrap()))),
x if x == TypeId::of::<Vec<BrushStroke>>() => Ok(TaggedValue::BrushStrokes(*downcast(input).unwrap())),
x if x == TypeId::of::<Item<BrushTrace>>() => Ok(TaggedValue::BrushStrokes(downcast::<Item<BrushTrace>>(input).unwrap().into_element().0.iter_element_values().cloned().collect())),
x if x == TypeId::of::<List<Stroke>>() => Ok(TaggedValue::Strokes(downcast::<List<Stroke>>(input).unwrap().into_iter().map(Item::into_element).collect())),
x if x == TypeId::of::<Item<BrushCache>>() => Ok(TaggedValue::BrushCache(downcast::<Item<BrushCache>>(input).unwrap().into_element())),
// =======================
@@ -386,8 +375,6 @@ macro_rules! tagged_value {
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::<Item<BoxCorners>>().unwrap().element().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::<Gradient>().unwrap()))),
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::<Item<Gradient>>().unwrap()))),
x if x == TypeId::of::<Vec<BrushStroke>>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::<Vec<BrushStroke>>().unwrap().clone())),
x if x == TypeId::of::<Item<BrushTrace>>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::<Item<BrushTrace>>().unwrap().element().0.iter_element_values().cloned().collect())),
x if x == TypeId::of::<List<Stroke>>() => Ok(TaggedValue::Strokes(input.downcast_ref::<List<Stroke>>().unwrap().iter_element_values().cloned().collect())),
x if x == TypeId::of::<Item<BrushCache>>() => Ok(TaggedValue::BrushCache(input.downcast_ref::<Item<BrushCache>>().unwrap().element().clone())),
// =======================
@@ -417,7 +404,6 @@ macro_rules! tagged_value {
if name == std::any::type_name::<DashPattern>() { return Some(TaggedValue::DashPattern(Vec::new())) }
if name == std::any::type_name::<BoxCorners>() { return Some(TaggedValue::BoxCorners(Vec::new())) }
$( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )*
if name == std::any::type_name::<BrushTrace>() { return Some(TaggedValue::BrushStrokes(Vec::new())) }
if name == std::any::type_name::<List<Stroke>>() { return Some(TaggedValue::Strokes(Vec::new())) }
if name == std::any::type_name::<BrushCache>() { return Some(TaggedValue::BrushCache(Default::default())) }
// Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time
@@ -475,7 +461,6 @@ macro_rules! tagged_value {
Self::DashPattern(lengths) => format!("DashPattern({lengths:?})"),
Self::BoxCorners(values) => format!("BoxCorners({values:?})"),
Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"),
Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"),
Self::Strokes(strokes) => format!("Strokes({strokes:?})"),
Self::BrushCache(cache) => format!("{cache:?}"),
// =======================

View File

@@ -1059,7 +1059,7 @@ mod test {
// If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them.
assert_eq!(
ids,
vec![NodeId(9617677014563055585), NodeId(3306304180790283913), NodeId(4482673701109291121), NodeId(1535890178157254933)]
vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)]
);
}

View File

@@ -7,7 +7,6 @@ use graph_craft::proto::{NodeConstructor, TypeErasedBox};
use graphene_std::animation::RealTimeMode;
use graphene_std::any::DynAnyNode;
use graphene_std::brush::Stroke;
use graphene_std::brush::brush_stroke::BrushTrace;
use graphene_std::extract_xy::XY;
use graphene_std::gradient::Gradient;
use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath};
@@ -82,7 +81,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List<GradientInterpolation>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<AttributeValueDyn>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => ListDyn]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<BrushTrace>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List<graphene_std::brush::Stroke>]),
// Context nullification
#[cfg(feature = "gpu")]
@@ -146,7 +144,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<&PlatformEditorApi>]),
#[cfg(feature = "gpu")]
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<Raster<GPU>>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<BrushTrace>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<graphene_std::brush::Stroke>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<RenderIntermediate>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<&wgpu_executor::WgpuExecutor>]),
@@ -355,7 +352,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
RedGreenBlueAlpha,
RelativeAbsolute,
SelectiveColorChoice,
BrushTrace,
Stroke,
XY,
ScaleType,

View File

@@ -2414,7 +2414,7 @@ fn render_raster_cpu_item_to_vello(item: ItemRef<'_, Raster<CPU>>, scene: &mut S
height: image.height,
alpha_type: peniko::ImageAlphaType::Alpha,
})
.with_extend(peniko::Extend::Repeat);
.with_extend(peniko::Extend::Pad);
scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array()));
@@ -2562,7 +2562,7 @@ fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster<GPU>>, scene: &mut S
height,
alpha_type: peniko::ImageAlphaType::Alpha,
})
.with_extend(peniko::Extend::Repeat);
.with_extend(peniko::Extend::Pad);
let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64));
scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array()));
context.resource_overrides.push((image, raster.texture.clone()));

View File

@@ -8,7 +8,7 @@ license = "MIT OR Apache-2.0"
[features]
default = ["serde"]
serde = ["dep:serde", "core-types/serde", "raster-types/serde", "raster-nodes/serde"]
serde = ["dep:serde", "core-types/serde", "raster-types/serde"]
[dependencies]
# Local dependencies
@@ -17,12 +17,15 @@ brush-types = { workspace = true }
core-types = { workspace = true }
graphene-hash = { workspace = true }
graphic-types = { workspace = true }
raster-types = { workspace = true }
raster-nodes = { workspace = true }
raster-types = { workspace = true, features = ["wgpu"] }
wgpu-executor = { workspace = true }
node-macro = { workspace = true }
# Workspace dependencies
glam = { workspace = true }
half = { workspace = true }
bytemuck = { workspace = true }
wgpu = { workspace = true }
# Optional workspace dependencies
serde = { workspace = true, optional = true, features = ["derive"] }

View File

@@ -0,0 +1,8 @@
pub(super) const SIGMA_CUTOFF: f32 = 3.;
pub(super) const SIGMA_PER_DIAMETER: f64 = 1. / 4.;
pub(super) const RIDGE_GAIN: f32 = 5.075688;
pub(super) const LUT_SIZE: u32 = 256;
pub(super) const LUT_V_MAX: f64 = 7.5;
pub(super) const LUT_T_MAX: f64 = 7.5;
pub(super) const LUT_CACHE_SIZE: usize = 64;

View File

@@ -0,0 +1,80 @@
pub(super) struct Convert {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
}
impl Convert {
pub(super) fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::include_wgsl!("convert.wgsl"));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("basic_brush_convert_bind_group_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("basic_brush_convert_pipeline_layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("basic_brush_convert_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
Self { pipeline, layout }
}
pub(super) fn encode(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, target: &wgpu::TextureView) {
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("basic_brush_convert_bind_group"),
layout: &self.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
}],
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("basic_brush_convert_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.draw(0..3, 0..1);
}
}

View File

@@ -0,0 +1,37 @@
// =============
// VERTEX SHADER
// =============
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> {
let pos = array(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
return vec4f(pos[vertex_index], 0.0, 1.0);
}
// ===============
// FRAGMENT SHADER
// ===============
@group(0) @binding(0)
var t_composite: texture_2d<f32>;
fn linear_to_srgb(channel: f32) -> f32 {
if (channel <= 0.0031308) {
return channel * 12.92;
}
return 1.055 * pow(channel, 1.0 / 2.4) - 0.055;
}
@fragment
fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let premultiplied = textureLoad(t_composite, vec2<i32>(frag.xy), 0);
var straight = vec3<f32>(0.0);
if (premultiplied.a > 0.0) {
straight = premultiplied.rgb / premultiplied.a;
}
return vec4<f32>(linear_to_srgb(straight.r), linear_to_srgb(straight.g), linear_to_srgb(straight.b), premultiplied.a);
}

View File

@@ -0,0 +1,163 @@
//! Brush kernel baking + caching.
//!
//! Kernel is a super-Gaussian `exp(-((v^2 + s^2) / 2)^p)`: p = 1 plain Gaussian, higher p
//! flattens center + steepens edge. Hardness controls p. Sweep along a segment has no
//! closed form, so baked numerically into a texture: row per perpendicular distance,
//! columns accumulate the along-axis integral. Segment = two LUT samples,
//! `F(v, t) - F(v, t - len)`. Normalized so a long stroke's interior settles at 1.
//!
//! Calibration defines diameter: find where resolved alpha crosses EDGE_ALPHA, scale so
//! that contour lands on `diameter / 2`. Painted width matches the setting, hard or soft.
//! p clamped so the edge stays >= MIN_EDGE_TEXELS on screen.
//!
//! Baked kernels: small LRU keyed by quantized p. Textures from the global pool, held
//! weakly; evicted under pressure -> bake again.
use super::consts::{LUT_CACHE_SIZE, LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_PER_DIAMETER};
use super::stroke::StyledStroke;
use glam::UVec2;
use raster_types::{Texture, TextureWeakRef};
use std::sync::Mutex;
use wgpu_executor::WgpuExecutor;
const INTEGRATE_END: f64 = 12.;
const FINE_STEPS: usize = 4096;
const MIN_EDGE_TEXELS: f64 = 1.5;
const EDGE_WIDTH_FACTOR: f64 = 3.09;
const KEY_STEPS_PER_LN: f64 = 24.;
const SOFTEST: f64 = 0.7;
const HARDEST: f64 = 48.;
const EDGE_ALPHA: f64 = 0.05;
pub(super) struct Kernel {
pub(super) texture: Texture,
pub(super) scale: f32,
pub(super) exponent: f32,
pub(super) section_scale: f32,
}
struct Baked {
scale: f32,
exponent: f32,
section_scale: f32,
texture: TextureWeakRef,
}
#[derive(Default)]
pub(super) struct KernelCache {
entries: Mutex<Vec<(i32, Baked)>>,
}
impl KernelCache {
pub(super) fn get(&self, executor: &WgpuExecutor, stroke: &StyledStroke, scale: f64) -> Kernel {
let sigma_texels = stroke.diameter.max(0.) * SIGMA_PER_DIAMETER * scale;
let sharpest = (EDGE_WIDTH_FACTOR * sigma_texels / (2. * MIN_EDGE_TEXELS)).max(1.);
let exponent = (SOFTEST * (HARDEST / SOFTEST).powf(stroke.hardness.clamp(0., 1.))).min(sharpest);
let key = (exponent.ln() * KEY_STEPS_PER_LN).round() as i32;
let mut entries = self.entries.lock().unwrap();
if let Some(index) = entries.iter().position(|(cached, _)| *cached == key) {
if let Some(texture) = entries[index].1.texture.upgrade() {
let entry = entries.remove(index);
let kernel = Kernel {
texture,
scale: entry.1.scale,
exponent: entry.1.exponent,
section_scale: entry.1.section_scale,
};
entries.insert(0, entry);
return kernel;
}
entries.remove(index);
}
let kernel = bake(executor, (key as f64 / KEY_STEPS_PER_LN).exp());
let baked = Baked {
scale: kernel.scale,
exponent: kernel.exponent,
section_scale: kernel.section_scale,
texture: kernel.texture.downgrade(),
};
entries.insert(0, (key, baked));
entries.truncate(LUT_CACHE_SIZE);
kernel
}
}
fn kernel(v: f64, s: f64, exponent: f64) -> f64 {
(-((v * v + s * s) / 2.).powf(exponent)).exp()
}
fn sweep_row(v: f64, exponent: f64) -> (Vec<f64>, f64) {
let ds = 2. * INTEGRATE_END / FINE_STEPS as f64;
let mut cumulative = Vec::with_capacity(FINE_STEPS + 1);
let mut total = 0.;
let mut previous = kernel(v, -INTEGRATE_END, exponent);
cumulative.push(0.);
for i in 1..=FINE_STEPS {
let value = kernel(v, -INTEGRATE_END + i as f64 * ds, exponent);
total += (previous + value) / 2. * ds;
previous = value;
cumulative.push(total);
}
let samples = (0..LUT_SIZE)
.map(|j| {
let t = -LUT_T_MAX + j as f64 * 2. * LUT_T_MAX / (LUT_SIZE - 1) as f64;
let x = (t + INTEGRATE_END) / ds;
let i = (x.floor() as usize).min(FINE_STEPS - 1);
cumulative[i] + (cumulative[i + 1] - cumulative[i]) * (x - i as f64)
})
.collect();
(samples, total)
}
fn calibrate(ridge: &[f64], target: f64) -> f64 {
let step = LUT_V_MAX / (LUT_SIZE - 1) as f64;
let Some(i) = ridge.iter().position(|&r| r < target).filter(|&i| i > 0) else {
return LUT_V_MAX;
};
let (above, below) = (ridge[i - 1], ridge[i]);
step * ((i - 1) as f64 + (above - target) / (above - below))
}
fn bake(executor: &WgpuExecutor, exponent: f64) -> Kernel {
let mut rows = Vec::with_capacity((LUT_SIZE * LUT_SIZE) as usize);
let mut ridge = Vec::with_capacity(LUT_SIZE as usize);
let mut norm = 1.;
for row in 0..LUT_SIZE {
let v = row as f64 * LUT_V_MAX / (LUT_SIZE - 1) as f64;
if kernel(v, 0., exponent) < 1e-9 {
rows.resize(rows.len() + LUT_SIZE as usize, half::f16::ZERO);
ridge.push(0.);
continue;
}
let (samples, total) = sweep_row(v, exponent);
if row == 0 {
norm = 1. / total;
}
rows.extend(samples.into_iter().map(|value| half::f16::from_f64(value * norm)));
ridge.push(total * norm);
}
let texture = executor.request_texture_with_format(UVec2::splat(LUT_SIZE), wgpu::TextureFormat::R16Float);
executor.context().queue.write_texture(
texture.as_image_copy(),
bytemuck::cast_slice(&rows),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(LUT_SIZE * 2),
rows_per_image: Some(LUT_SIZE),
},
texture.size(),
);
let gain = RIDGE_GAIN as f64;
let target = -(1. - EDGE_ALPHA * (1. - (-gain).exp())).ln() / gain;
let a = calibrate(&ridge, target);
Kernel {
texture,
scale: (a / 2.) as f32,
exponent: exponent as f32,
section_scale: ((2. * (1. / target).ln().powf(1. / exponent)).sqrt() / 2.) as f32,
}
}

View File

@@ -0,0 +1,75 @@
mod consts;
mod convert;
mod kernel;
mod pipeline;
mod region;
mod render;
mod stroke;
use brush_types::BrushCache;
use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List};
use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint};
use graphic_types::Graphic;
use pipeline::{BasicBrushPipeline, BasicBrushPipelineArgs};
use raster_types::{GPU, Raster};
use wgpu_executor::{WgpuExecutor, WgpuPipelineCache};
#[node_macro::node(category("Raster: Brush"))]
pub async fn basic_brush<'a: 'n>(
ctx: impl Ctx + ExtractFootprint,
strokes: List<Graphic>,
#[widget(ParsedWidgetOverride::Hidden)] cache: Item<BrushCache>,
#[scope(basic_brush_pipeline::IDENTIFIER)] pipeline: Item<WgpuPipelineCache>,
) -> List<Raster<GPU>> {
let (cache, pipeline) = (cache.into_element(), pipeline.into_element());
let mut stack = vec![strokes.into_iter()];
let mut strokes = Vec::new();
while let Some(top) = stack.last_mut() {
let Some(item) = top.next() else {
stack.pop();
continue;
};
let color = item.attribute_cloned_or(ATTR_COLOR, crate::DEFAULT_COLOR);
let diameter = item.attribute_cloned_or(ATTR_DIAMETER, crate::DEFAULT_DIAMETER);
let hardness = item.attribute_cloned_or(ATTR_HARDNESS, crate::DEFAULT_HARDNESS / 100.);
let flow = item.attribute_cloned_or(ATTR_FLOW, crate::DEFAULT_FLOW / 100.);
match item.into_element() {
Graphic::StrokeList(list) => strokes.extend(
list.into_iter()
.map(Item::into_element)
.filter(|stroke| !stroke.is_empty() && stroke.is_valid())
.map(|stroke| stroke::StyledStroke {
color,
diameter,
hardness,
flow,
stroke,
}),
),
Graphic::Graphic(item) => stack.push(List::new_from_item(*item).into_iter()),
Graphic::GraphicList(nested) => stack.push(nested.into_iter()),
_ => {}
}
}
let args = BasicBrushPipelineArgs {
footprint: *ctx.footprint(),
strokes: &strokes,
cache: &cache,
};
let Some((texture, transform)) = pipeline.run::<BasicBrushPipeline>(&args).await else {
return List::new();
};
let raster = Raster::<GPU>::new_gpu(texture);
List::new_from_item(Item::new_from_element(raster).with_attribute(ATTR_TRANSFORM, transform))
}
#[node_macro::node(category(""), inject_scope)]
async fn basic_brush_pipeline<'a: 'n>(
_ctx: impl Ctx,
#[scope(ProtoNodeIdentifier::new("graphene_std::platform_application_io::WgpuExecutorNode"))] executor: Item<&'a WgpuExecutor>,
#[data] pipeline: WgpuPipelineCache,
) -> Item<WgpuPipelineCache> {
executor.into_element().pipeline_init::<BasicBrushPipeline>(pipeline);
Item::new_from_element(pipeline.clone())
}

View File

@@ -0,0 +1,543 @@
use super::consts::{LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_CUTOFF};
use super::convert::Convert;
use super::kernel::{Kernel, KernelCache};
use super::region::{Crop, Region};
use super::stroke::{Edge, StyledStroke};
use brush_types::BrushCache;
use bytemuck::{Pod, Zeroable};
use core_types::Color;
use core_types::transform::Footprint;
use glam::{DAffine2, UVec2};
use raster_types::Texture;
use wgpu_executor::{AsyncWgpuPipeline, Buffer, WgpuExecutor};
pub(super) const DENSITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float;
pub(super) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct ScatterUniforms {
frame_size: [f32; 2],
kernel_scale: f32,
kernel_exponent: f32,
kernel_section_scale: f32,
_pad: f32,
}
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct ResolveUniforms {
color: [f32; 4],
density_offset: [f32; 2],
_pad: [f32; 2],
}
pub struct BasicBrushPipeline {
scatter: Scatter,
resolve: Resolve,
convert: Convert,
kernels: KernelCache,
}
pub(super) struct Field {
pub(super) density: Texture,
pub(super) stamp: Texture,
}
impl Field {
pub(super) fn request(executor: &WgpuExecutor, size: UVec2) -> Self {
Self {
density: executor.request_texture_with_format(size, DENSITY_FORMAT),
stamp: executor.request_texture_with_format(size, DENSITY_FORMAT),
}
}
pub(super) fn views(&self) -> FieldViews {
FieldViews {
density: self.density.create_view(&wgpu::TextureViewDescriptor::default()),
stamp: self.stamp.create_view(&wgpu::TextureViewDescriptor::default()),
}
}
}
pub(super) struct FieldViews {
pub(super) density: wgpu::TextureView,
pub(super) stamp: wgpu::TextureView,
}
pub struct BasicBrushPipelineArgs<'a> {
pub(super) footprint: Footprint,
pub(super) strokes: &'a [StyledStroke],
pub(super) cache: &'a BrushCache,
}
impl AsyncWgpuPipeline for BasicBrushPipeline {
type Args<'a> = BasicBrushPipelineArgs<'a>;
type Out = Option<(Texture, DAffine2)>;
fn create(executor: &WgpuExecutor) -> Self {
let device = &executor.context().device;
Self {
scatter: Scatter::new(device),
resolve: Resolve::new(device),
convert: Convert::new(device),
kernels: KernelCache::default(),
}
}
async fn run<'a>(&'a self, executor: &'a WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out {
let frame = super::render::Frame::new(args.strokes)?;
let region = Region::new(&args.footprint)?;
let state = args.cache.take(&args.footprint).unwrap_or_default();
let rendered = super::render::render(self, executor, frame, region, state)?;
args.cache.store(&args.footprint, rendered.state);
Some((rendered.texture, rendered.transform))
}
}
struct Scatter {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
}
impl Scatter {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::include_wgsl!("scatter.wgsl"));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("basic_brush_density_bind_group_layout"),
entries: &[
uniform_entry(0, wgpu::ShaderStages::VERTEX_FRAGMENT),
wgpu::BindGroupLayoutEntry {
binding: 1,
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: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("basic_brush_kernel_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("basic_brush_density_pipeline_layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let instance_layout = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Edge>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
},
wgpu::VertexAttribute {
offset: 8,
shader_location: 1,
format: wgpu::VertexFormat::Float32x2,
},
wgpu::VertexAttribute {
offset: 16,
shader_location: 2,
format: wgpu::VertexFormat::Float32,
},
wgpu::VertexAttribute {
offset: 20,
shader_location: 3,
format: wgpu::VertexFormat::Float32,
},
],
};
let additive = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
};
let union = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Max,
};
let options = wgpu::PipelineCompilationOptions {
constants: &[("CUTOFF_SIGMA", SIGMA_CUTOFF as f64), ("LUT_SIZE", LUT_SIZE as f64), ("LUT_V_MAX", LUT_V_MAX), ("LUT_T_MAX", LUT_T_MAX)],
..Default::default()
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("basic_brush_density_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: options.clone(),
buffers: &[instance_layout],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: options,
targets: &[
Some(wgpu::ColorTargetState {
format: DENSITY_FORMAT,
blend: Some(wgpu::BlendState { color: additive, alpha: additive }),
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: DENSITY_FORMAT,
blend: Some(wgpu::BlendState { color: union, alpha: union }),
write_mask: wgpu::ColorWrites::ALL,
}),
],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
Self { pipeline, layout, sampler }
}
fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, kernel: &wgpu::TextureView) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("basic_brush_density_bind_group"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(kernel),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &FieldViews, bind: &wgpu::BindGroup, buffer: &wgpu::Buffer, instances: u32) {
let attachment = |view| {
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("basic_brush_density_pass"),
color_attachments: &[attachment(&target.density), attachment(&target.stamp)],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, bind, &[]);
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw(0..4, 0..instances);
}
}
struct Resolve {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
}
impl Resolve {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::include_wgsl!("resolve.wgsl"));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("basic_brush_resolve_bind_group_layout"),
entries: &[uniform_entry(0, wgpu::ShaderStages::FRAGMENT), texture_entry(1), texture_entry(2)],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("basic_brush_resolve_pipeline_layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let options = wgpu::PipelineCompilationOptions {
constants: &[("RIDGE_GAIN", RIDGE_GAIN as f64), ("RIDGE_NORM", 1. / (1. - (-RIDGE_GAIN as f64).exp()))],
..Default::default()
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("basic_brush_resolve_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: options.clone(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: COMPOSITE_FORMAT,
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
Self { pipeline, layout }
}
fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, source: &FieldViews) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("basic_brush_resolve_bind_group"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&source.density),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&source.stamp),
},
],
})
}
fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, bind: &wgpu::BindGroup, scissor: (UVec2, UVec2)) {
let (origin, size) = scissor;
if !size.cmpgt(UVec2::ZERO).all() {
return;
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("basic_brush_resolve_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, bind, &[]);
pass.set_scissor_rect(origin.x, origin.y, size.x, size.y);
pass.draw(0..3, 0..1);
}
}
pub(super) struct Recorder<'a> {
pipeline: &'a BasicBrushPipeline,
executor: &'a WgpuExecutor,
encoder: wgpu::CommandEncoder,
region: Region,
buffers: Vec<Buffer>,
textures: Vec<Texture>,
}
impl<'a> Recorder<'a> {
pub(super) fn new(pipeline: &'a BasicBrushPipeline, executor: &'a WgpuExecutor, region: &Region) -> Self {
let device = &executor.context().device;
let encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("basic_brush_encoder") });
Self {
pipeline,
executor,
encoder,
region: *region,
buffers: Vec::new(),
textures: Vec::new(),
}
}
pub(super) fn kernel(&self, stroke: &StyledStroke) -> Kernel {
self.pipeline.kernels.get(self.executor, stroke, self.region.scale)
}
pub(super) fn clear(&mut self, target: &wgpu::TextureView) {
self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("basic_brush_clear_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
..Default::default()
});
}
pub(super) fn clear_field(&mut self, target: &FieldViews) {
let attachment = |view| {
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})
};
self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("basic_brush_clear_pass"),
color_attachments: &[attachment(&target.density), attachment(&target.stamp)],
..Default::default()
});
}
pub(super) fn scatter(&mut self, target: &FieldViews, edges: &[Edge], kernel: &Kernel) {
if edges.is_empty() {
return;
}
let globals = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("basic_brush_scatter_uniform"),
contents: bytemuck::bytes_of(&ScatterUniforms {
frame_size: [self.region.size.x as f32, self.region.size.y as f32],
kernel_scale: kernel.scale,
kernel_exponent: kernel.exponent,
kernel_section_scale: kernel.section_scale,
_pad: 0.,
}),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = kernel.texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind = self.pipeline.scatter.bind(&self.executor.context().device, &globals, &view);
let buffer = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("basic_brush_segment_buffer"),
contents: bytemuck::cast_slice(edges),
usage: wgpu::BufferUsages::VERTEX,
});
self.pipeline.scatter.encode(&mut self.encoder, target, &bind, &buffer, edges.len() as u32);
self.buffers.push(globals);
self.buffers.push(buffer);
self.textures.push(kernel.texture.clone());
}
pub(super) fn resolve(&mut self, color: Color, crop: &Crop, source: &FieldViews, target: &wgpu::TextureView, scissor: (UVec2, UVec2)) {
let globals = resolve_uniform(self.executor, color, crop);
let bind = self.pipeline.resolve.bind(&self.executor.context().device, &globals, source);
self.pipeline.resolve.encode(&mut self.encoder, target, &bind, scissor);
self.buffers.push(globals);
}
pub(super) fn copy(&mut self, from: &Texture, from_origin: UVec2, to: &Texture, to_origin: UVec2) {
copy_placed(&mut self.encoder, from, from_origin, to, to_origin);
}
pub(super) fn copy_texture(&mut self, from: &Texture, to: &Texture) {
self.encoder.copy_texture_to_texture(from.as_image_copy(), to.as_image_copy(), from.size());
}
pub(super) fn convert(&mut self, source: &wgpu::TextureView, target: &wgpu::TextureView) {
self.pipeline.convert.encode(&self.executor.context().device, &mut self.encoder, source, target);
}
pub(super) fn keep(&mut self, texture: Texture) {
self.textures.push(texture);
}
pub(super) fn submit(self) {
let command = self.encoder.finish();
self.executor.context().queue.submit([command]);
}
}
fn uniform_entry(binding: u32, visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
fn resolve_uniform(executor: &WgpuExecutor, color: Color, crop: &Crop) -> Buffer {
let uniforms = ResolveUniforms {
color: [color.r(), color.g(), color.b(), color.a()],
density_offset: [crop.origin.x as f32, crop.origin.y as f32],
_pad: [0.; 2],
};
executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("basic_brush_resolve_uniform"),
contents: bytemuck::bytes_of(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
})
}
fn copy_placed(encoder: &mut wgpu::CommandEncoder, from: &wgpu::Texture, from_origin: UVec2, to: &wgpu::Texture, to_origin: UVec2) {
let start = from_origin.max(to_origin);
let end = (from_origin + UVec2::new(from.width(), from.height())).min(to_origin + UVec2::new(to.width(), to.height()));
if !end.cmpgt(start).all() {
return;
}
let info = |texture, origin: UVec2| wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d { x: origin.x, y: origin.y, z: 0 },
aspect: wgpu::TextureAspect::All,
};
let extent = end - start;
encoder.copy_texture_to_texture(
info(from, start - from_origin),
info(to, start - to_origin),
wgpu::Extent3d {
width: extent.x,
height: extent.y,
depth_or_array_layers: 1,
},
);
}

View File

@@ -0,0 +1,67 @@
use core_types::math::bbox::AxisAlignedBbox;
use core_types::transform::Footprint;
use glam::{DAffine2, DVec2, UVec2};
const MAX_RESOLUTION: u32 = 8192;
const CROP_STEP: u32 = 256;
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct Region {
pub(crate) min: DVec2,
pub(crate) scale: f64,
pub(crate) size: UVec2,
}
impl Region {
pub(crate) fn new(footprint: &Footprint) -> Option<Self> {
let margin = DVec2::splat(2. / footprint.scale().max_element());
let viewport = footprint.viewport_bounds_in_local_space();
let bounds = AxisAlignedBbox {
start: viewport.start - margin,
end: viewport.end + margin,
};
if !bounds.size().cmpgt(DVec2::ZERO).all() {
return None;
}
// -2 leaves room for the floor/ceil below to add a texel per side at the cap.
let scale = footprint.scale().max_element().min((MAX_RESOLUTION as f64 - 2.) / bounds.size().max_element());
if !scale.is_finite() || scale <= 0. {
return None;
}
let start = (bounds.start * scale).floor();
let end = (bounds.end * scale).ceil();
let size = (end - start).as_uvec2().max(UVec2::ONE);
Some(Self { min: start / scale, scale, size })
}
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) struct Crop {
pub(crate) origin: UVec2,
pub(crate) size: UVec2,
}
impl Crop {
pub(crate) fn new(content: AxisAlignedBbox, region: &Region) -> Option<Self> {
let start = ((content.start - region.min) * region.scale).floor().max(DVec2::ZERO);
let end = ((content.end - region.min) * region.scale).ceil().min(region.size.as_dvec2());
if !(end - start).cmpgt(DVec2::ZERO).all() {
return None;
}
let origin = start.as_uvec2() / CROP_STEP * CROP_STEP;
let end = ((end.as_uvec2() + UVec2::splat(CROP_STEP - 1)) / CROP_STEP * CROP_STEP).min(region.size);
Some(Self { origin, size: end - origin })
}
pub(crate) fn transform(&self, region: &Region) -> DAffine2 {
DAffine2::from_translation(region.min + self.origin.as_dvec2() / region.scale) * DAffine2::from_scale(self.size.as_dvec2() / region.scale)
}
pub(crate) fn scissor(&self, region: &Region, bounds: AxisAlignedBbox) -> (UVec2, UVec2) {
let clamp = |texels: UVec2| texels.max(self.origin).min(self.origin + self.size) - self.origin;
let min = ((bounds.start - region.min) * region.scale).floor().max(DVec2::ZERO).as_uvec2().min(region.size);
let max = ((bounds.end - region.min) * region.scale).ceil().max(DVec2::ZERO).as_uvec2().min(region.size);
(clamp(min), clamp(max) - clamp(min))
}
}

View File

@@ -0,0 +1,450 @@
use super::pipeline::{BasicBrushPipeline, COMPOSITE_FORMAT, Field, Recorder};
use super::region::{Crop, Region};
use super::stroke::{self, StyledStroke, Walk};
use core_types::CacheHash;
use core_types::math::bbox::AxisAlignedBbox;
use glam::{DAffine2, UVec2};
use raster_types::{Texture, TextureWeakRef};
use std::hash::{Hash, Hasher};
use wgpu_executor::WgpuExecutor;
#[derive(Clone, Copy, PartialEq, Eq)]
struct StrokeKey(u64);
#[derive(Clone, Copy, PartialEq, Eq)]
struct DensityKey(u64);
#[derive(Clone, Copy, PartialEq, Eq)]
struct PrefixKey(u64);
#[derive(PartialEq, Eq)]
struct FrameKey {
finished: Vec<StrokeKey>,
active: StrokeKey,
}
pub(super) struct Frame<'a> {
finished: &'a [StyledStroke],
active: &'a StyledStroke,
}
impl<'a> Frame<'a> {
pub(super) fn new(strokes: &'a [StyledStroke]) -> Option<Self> {
let (active, finished) = strokes.split_last()?;
Some(Self { finished, active })
}
}
#[derive(Default)]
pub(super) struct State {
finished: Finished,
pending: Option<Pending>,
output: Option<CachedOutput>,
}
#[derive(Default)]
struct Finished {
strokes: Vec<Record>,
image: Option<Placed<TextureWeakRef>>,
}
struct Record {
key: StrokeKey,
bounds: AxisAlignedBbox,
}
struct Placed<T> {
texture: T,
origin: UVec2,
}
struct CachedOutput {
key: FrameKey,
texture: TextureWeakRef,
}
struct Pending {
key: PendingKey,
walk: Walk,
density: TextureWeakRef,
stamp: TextureWeakRef,
}
struct LivePending {
key: PendingKey,
walk: Walk,
field: Field,
}
#[derive(Clone, Copy)]
struct PendingKey {
seed: u64,
density: DensityKey,
prefix: PrefixKey,
}
impl PendingKey {
fn new(stroke: &StyledStroke, consumed: usize) -> Self {
Self {
seed: stroke.stroke.seed,
density: density_key(stroke),
prefix: prefix_key(stroke, consumed),
}
}
fn matches(&self, stroke: &StyledStroke, consumed: usize) -> bool {
self.seed == stroke.stroke.seed && self.density == density_key(stroke) && self.prefix == prefix_key(stroke, consumed)
}
}
impl Pending {
fn upgrade(self, region: &Region) -> Option<LivePending> {
let density = self.density.upgrade()?;
let stamp = self.stamp.upgrade()?;
if density.width() != region.size.x || density.height() != region.size.y {
return None;
}
Some(LivePending {
key: self.key,
walk: self.walk,
field: Field { density, stamp },
})
}
}
impl LivePending {
fn matches(&self, stroke: &StyledStroke) -> bool {
self.walk.consumed > 0 && self.walk.consumed <= stroke.stroke.len() && self.key.matches(stroke, self.walk.consumed)
}
fn park(self) -> Pending {
Pending {
key: self.key,
walk: self.walk,
density: self.field.density.downgrade(),
stamp: self.field.stamp.downgrade(),
}
}
}
pub(super) struct Rendered {
pub(super) texture: Texture,
pub(super) transform: DAffine2,
pub(super) state: State,
}
pub(super) fn render(pipeline: &BasicBrushPipeline, executor: &WgpuExecutor, frame: Frame<'_>, region: Region, mut state: State) -> Option<Rendered> {
let keys: Vec<_> = frame.finished.iter().map(stroke_key).collect();
let active_key = stroke_key(frame.active);
let frame_key = frame_key(&keys, active_key);
let prefix = state.finished.strokes.len() <= keys.len() && state.finished.strokes.iter().zip(&keys).all(|(cached, current)| cached.key == *current);
let known = if prefix { state.finished.strokes.len() } else { 0 };
let mut bounds: Vec<_> = if prefix {
state.finished.strokes.iter().map(|record| record.bounds.clone()).collect()
} else {
Vec::new()
};
bounds.extend(frame.finished[known..].iter().map(|stroke| stroke::bounds(stroke, region.scale)));
let active_bounds = stroke::bounds(frame.active, region.scale);
let mut content = None;
for bounds in &bounds {
stroke::union(&mut content, bounds.clone());
}
stroke::union(&mut content, active_bounds.clone());
let crop = Crop::new(content?, &region)?;
if let Some(texture) = state.output.as_ref().filter(|output| output.key == frame_key).and_then(|output| output.texture.upgrade()) {
return Some(Rendered {
texture,
transform: crop.transform(&region),
state,
});
}
let base = state
.finished
.image
.take()
.and_then(|placed| {
Some(Placed {
texture: placed.texture.upgrade()?,
origin: placed.origin,
})
})
.filter(|placed| prefix && (placed.origin + UVec2::new(placed.texture.width(), placed.texture.height())).cmple(region.size).all());
let covered = if base.is_some() { state.finished.strokes.len() } else { 0 };
let missing = &frame.finished[covered..];
let pending = state.pending.take().and_then(|pending| pending.upgrade(&region));
let (active_pending, mut finished_pending) = match pending {
Some(pending) if pending.matches(frame.active) => (Some(pending), None),
pending => (None, pending),
};
let updated = (!missing.is_empty()).then(|| executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT));
let composite = executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT);
let scratch = Field::request(executor, region.size);
let output = executor.request_texture(crop.size);
let mut recorder = Recorder::new(pipeline, executor, &region);
if let Some(updated) = &updated {
let target = updated.create_view(&wgpu::TextureViewDescriptor::default());
recorder.clear(&target);
if let Some(base) = &base {
recorder.copy(&base.texture, base.origin, updated, crop.origin);
}
let mut strokes = StrokeRenderer {
recorder: &mut recorder,
executor,
region: &region,
crop: &crop,
scratch: &scratch,
};
for (index, stroke) in missing.iter().enumerate() {
let scissor = crop.scissor(&region, bounds[covered + index].clone());
if !scissor.1.cmpgt(UVec2::ZERO).all() {
continue;
}
let previous = if finished_pending.as_ref().is_some_and(|pending| pending.matches(stroke)) {
finished_pending.take()
} else {
None
};
strokes.render(stroke, previous, Tail::Commit, Target { view: &target, scissor });
}
}
let composite_view = composite.create_view(&wgpu::TextureViewDescriptor::default());
match (&updated, &base) {
(Some(updated), _) => recorder.copy_texture(updated, &composite),
(None, Some(base)) => {
recorder.clear(&composite_view);
recorder.copy(&base.texture, base.origin, &composite, crop.origin);
}
(None, None) => recorder.clear(&composite_view),
}
let active_scissor = crop.scissor(&region, active_bounds);
let pending = StrokeRenderer {
recorder: &mut recorder,
executor,
region: &region,
crop: &crop,
scratch: &scratch,
}
.render(
frame.active,
active_pending,
Tail::Preview,
Target {
view: &composite_view,
scissor: active_scissor,
},
)?;
let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
recorder.convert(&composite_view, &output_view);
recorder.submit();
let image = updated
.map(|texture| Placed {
texture: texture.downgrade(),
origin: crop.origin,
})
.or_else(|| {
base.map(|placed| Placed {
texture: placed.texture.downgrade(),
origin: placed.origin,
})
});
let state = State {
finished: Finished {
strokes: keys.into_iter().zip(bounds).map(|(key, bounds)| Record { key, bounds }).collect(),
image,
},
pending: Some(pending.park()),
output: Some(CachedOutput {
key: frame_key,
texture: output.downgrade(),
}),
};
Some(Rendered {
texture: output,
transform: crop.transform(&region),
state,
})
}
#[derive(Clone, Copy)]
enum Tail {
Commit,
Preview,
}
enum Density<'a> {
Temporary(&'a Field),
Owned(Field),
}
impl Density<'_> {
fn field(&self) -> &Field {
match self {
Self::Temporary(field) => field,
Self::Owned(field) => field,
}
}
}
struct Target<'a> {
view: &'a wgpu::TextureView,
scissor: (UVec2, UVec2),
}
struct StrokeRenderer<'a, 'gpu> {
recorder: &'a mut Recorder<'gpu>,
executor: &'gpu WgpuExecutor,
region: &'a Region,
crop: &'a Crop,
scratch: &'a Field,
}
impl StrokeRenderer<'_, '_> {
fn render(&mut self, stroke: &StyledStroke, previous: Option<LivePending>, tail: Tail, target: Target<'_>) -> Option<LivePending> {
let (mut walk, density) = match previous {
Some(pending) => (pending.walk, Density::Owned(pending.field)),
None => match tail {
Tail::Commit => (Walk::default(), Density::Temporary(self.scratch)),
Tail::Preview => (Walk::default(), Density::Owned(Field::request(self.executor, self.region.size))),
},
};
let views = density.field().views();
if walk.consumed == 0 {
self.recorder.clear_field(&views);
}
let kernel = self.recorder.kernel(stroke);
let mut update = walk.update(stroke, self.region);
match tail {
Tail::Commit => {
update.committed.append(&mut update.tail);
self.recorder.scatter(&views, &update.committed, &kernel);
self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor);
if let Density::Owned(field) = density {
self.recorder.keep(field.density);
self.recorder.keep(field.stamp);
}
None
}
Tail::Preview => {
self.recorder.scatter(&views, &update.committed, &kernel);
if update.tail.is_empty() {
self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor);
} else {
let field = density.field();
self.recorder.copy_texture(&field.density, &self.scratch.density);
self.recorder.copy_texture(&field.stamp, &self.scratch.stamp);
let scratch_views = self.scratch.views();
self.recorder.scatter(&scratch_views, &update.tail, &kernel);
self.recorder.resolve(stroke.color, self.crop, &scratch_views, target.view, target.scissor);
}
let Density::Owned(field) = density else { unreachable!() };
Some(LivePending {
key: PendingKey::new(stroke, walk.consumed),
walk,
field,
})
}
}
}
}
fn stroke_key(stroke: &StyledStroke) -> StrokeKey {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
stroke.stroke.cache_hash(&mut hasher);
stroke.color.cache_hash(&mut hasher);
stroke.diameter.cache_hash(&mut hasher);
stroke.hardness.cache_hash(&mut hasher);
stroke.flow.cache_hash(&mut hasher);
StrokeKey(hasher.finish())
}
fn density_key(stroke: &StyledStroke) -> DensityKey {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
(stroke.diameter.max(0.) as f32).to_bits().hash(&mut hasher);
(stroke.hardness.clamp(0., 1.) as f32).to_bits().hash(&mut hasher);
(stroke.flow.clamp(0., 1.) as f32).to_bits().hash(&mut hasher);
DensityKey(hasher.finish())
}
fn prefix_key(stroke: &StyledStroke, consumed: usize) -> PrefixKey {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for sample in stroke.stroke.samples().take(consumed) {
sample.position.x.to_bits().hash(&mut hasher);
sample.position.y.to_bits().hash(&mut hasher);
sample.pressure.clamp(0., 1.).to_bits().hash(&mut hasher);
}
PrefixKey(hasher.finish())
}
fn frame_key(finished: &[StrokeKey], active: StrokeKey) -> FrameKey {
FrameKey { finished: finished.to_vec(), active }
}
#[cfg(test)]
mod tests {
use super::*;
use brush_types::{Channel, Stroke};
use core_types::Color;
use glam::DVec2;
fn stroke() -> StyledStroke {
StyledStroke {
color: Color::BLACK,
diameter: 20.,
hardness: 0.8,
flow: 1.,
stroke: Stroke {
position: vec![DVec2::new(1., 2.), DVec2::new(3., 4.), DVec2::new(5., 6.)],
pressure: Channel::Samples(vec![0.2, 0.4, 0.6]),
seed: 42,
..Default::default()
},
}
}
#[test]
fn pending_key_accepts_an_appended_stroke() {
let original = stroke();
let key = PendingKey::new(&original, original.stroke.len());
let mut appended = stroke();
appended.stroke.position.push(DVec2::new(7., 8.));
let Channel::Samples(pressure) = &mut appended.stroke.pressure else { unreachable!() };
pressure.push(0.8);
assert!(key.matches(&appended, original.stroke.len()));
}
#[test]
fn pending_key_rejects_changed_render_data() {
let original = stroke();
let key = PendingKey::new(&original, original.stroke.len());
let mut position = stroke();
position.stroke.position[0].x += 1.;
assert!(!key.matches(&position, original.stroke.len()));
let mut pressure = stroke();
let Channel::Samples(samples) = &mut pressure.stroke.pressure else { unreachable!() };
samples[1] += 0.1;
assert!(!key.matches(&pressure, original.stroke.len()));
let mut flow = stroke();
flow.flow *= 0.5;
assert!(!key.matches(&flow, original.stroke.len()));
}
#[test]
fn pending_key_ignores_color() {
let original = stroke();
let key = PendingKey::new(&original, original.stroke.len());
let mut recolored = stroke();
recolored.color = Color::WHITE;
assert!(key.matches(&recolored, original.stroke.len()));
}
}

View File

@@ -0,0 +1,43 @@
// =============
// VERTEX SHADER
// =============
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> {
let pos = array(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
return vec4f(pos[vertex_index], 0.0, 1.0);
}
// ===============
// FRAGMENT SHADER
// ===============
struct Uniforms {
color: vec4<f32>,
density_offset: vec2<f32>,
_pad: vec2<f32>,
};
override RIDGE_GAIN: f32;
override RIDGE_NORM: f32;
@group(0) @binding(0)
var<uniform> uniforms: Uniforms;
@group(0) @binding(1)
var t_density: texture_2d<f32>;
@group(0) @binding(2)
var t_stamp: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let texel = vec2<i32>(frag.xy + uniforms.density_offset);
let field = max(textureLoad(t_density, texel, 0).r, textureLoad(t_stamp, texel, 0).r);
let alpha = clamp((1.0 - exp(-field * RIDGE_GAIN)) * RIDGE_NORM, 0.0, 1.0) * uniforms.color.a;
return vec4<f32>(uniforms.color.rgb * alpha, alpha);
}

View File

@@ -0,0 +1,117 @@
override CUTOFF_SIGMA: f32;
override LUT_SIZE: f32;
override LUT_V_MAX: f32;
override LUT_T_MAX: f32;
// =============
// VERTEX SHADER
// =============
struct Uniforms {
frame_size: vec2<f32>,
kernel_scale: f32,
kernel_exponent: f32,
kernel_section_scale: f32,
};
@group(0) @binding(0)
var<uniform> uniforms: Uniforms;
@group(0) @binding(1)
var t_kernel: texture_2d<f32>;
@group(0) @binding(2)
var s_kernel: sampler;
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) @interpolate(flat) a: vec2<f32>,
@location(1) @interpolate(flat) b: vec2<f32>,
@location(2) @interpolate(flat) sigma: f32,
@location(3) @interpolate(flat) weight: f32,
};
@vertex
fn vs_main(
@builtin(vertex_index) vertex_index: u32,
@location(0) a: vec2<f32>,
@location(1) b: vec2<f32>,
@location(2) sigma: f32,
@location(3) weight: f32,
) -> VertexOutput {
let cutoff = CUTOFF_SIGMA * sigma;
let d = b - a;
let len = length(d);
var e = vec2f(1.0, 0.0);
if (len > 1e-6) {
e = d / len;
}
let n = vec2f(-e.y, e.x);
let base = select(a - e * cutoff, b + e * cutoff, (vertex_index & 1u) == 1u);
let normal_sign = select(-1.0, 1.0, vertex_index >= 2u);
let corner = base + n * (cutoff * normal_sign);
let ndc = vec2f(corner.x / uniforms.frame_size.x * 2.0 - 1.0, 1.0 - corner.y / uniforms.frame_size.y * 2.0);
var out: VertexOutput;
out.clip_position = vec4f(ndc, 0.0, 1.0);
out.a = a;
out.b = b;
out.sigma = sigma;
out.weight = weight;
return out;
}
// ===============
// FRAGMENT SHADER
// ===============
fn sweep(v: f32, t: f32) -> f32 {
let texel = (LUT_SIZE - 1.0) / LUT_SIZE;
let uv = vec2f(
((t + LUT_T_MAX) / (2.0 * LUT_T_MAX)) * texel + 0.5 / LUT_SIZE,
(v / LUT_V_MAX) * texel + 0.5 / LUT_SIZE,
);
return textureSampleLevel(t_kernel, s_kernel, uv, 0.0).r;
}
fn section(r2: f32) -> f32 {
// Max avoids pow undefined log at zero.
return exp(-pow(max(r2 * 0.5, 1e-20), uniforms.kernel_exponent));
}
struct FragmentOutput {
@location(0) density: f32,
@location(1) stamp: f32,
};
@fragment
fn fs_main(in: VertexOutput) -> FragmentOutput {
let p = in.clip_position.xy;
let inv_section = uniforms.kernel_section_scale / in.sigma;
let d = in.b - in.a;
let len = length(d);
if (len < 1e-6) {
let dab = in.weight * section(dot(p - in.a, p - in.a) * inv_section * inv_section);
return FragmentOutput(dab, dab);
}
let e = d / len;
let rel = p - in.a;
let along = dot(rel, e);
let perp2 = max(dot(rel, rel) - along * along, 0.0);
let cutoff = CUTOFF_SIGMA * in.sigma;
if (perp2 > cutoff * cutoff || along < -cutoff || along > len + cutoff) {
return FragmentOutput(0.0, 0.0);
}
let inv_sp = uniforms.kernel_scale / in.sigma;
let v = sqrt(perp2) * inv_sp;
let ridge = sweep(v, along * inv_sp) - sweep(v, (along - len) * inv_sp);
let overhang = max(max(-along, along - len), 0.0);
let stamp = in.weight * section((perp2 + overhang * overhang) * inv_section * inv_section);
return FragmentOutput(in.weight * max(ridge, 0.0), stamp);
}

View File

@@ -0,0 +1,222 @@
use super::consts::{RIDGE_GAIN, SIGMA_CUTOFF, SIGMA_PER_DIAMETER};
use brush_types::{Sample, Stroke};
use bytemuck::{Pod, Zeroable};
use core_types::Color;
use core_types::math::bbox::AxisAlignedBbox;
use glam::DVec2;
const MIN_SIGMA: f32 = f32::EPSILON;
const MAX_EDGE_SHIFT: f32 = 0.25;
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
pub(super) struct Edge {
a: [f32; 2],
b: [f32; 2],
sigma: f32,
weight: f32,
}
pub(super) struct StyledStroke {
pub(super) color: Color,
pub(super) diameter: f64,
pub(super) hardness: f64,
pub(super) flow: f64,
pub(super) stroke: Stroke,
}
#[derive(Clone, Copy)]
struct Dab {
position: DVec2,
sigma: f32,
weight: f32,
}
fn dab(sample: &Sample, stroke: &StyledStroke) -> Dab {
let pressure = sample.pressure.clamp(0., 1.);
let flow = stroke.flow.clamp(0., 1.) as f32;
Dab {
position: sample.position,
sigma: (stroke.diameter.max(0.) * SIGMA_PER_DIAMETER) as f32 * pressure,
weight: -(1. - flow * (1. - (-RIDGE_GAIN).exp())).ln() / RIDGE_GAIN,
}
}
fn dab_pad(dab: Dab, scale: f64) -> AxisAlignedBbox {
let sigma = (dab.sigma as f64).max(MIN_SIGMA as f64 / scale);
let pad = DVec2::splat(SIGMA_CUTOFF as f64 * sigma + 1f64.max(1. / scale));
AxisAlignedBbox {
start: dab.position - pad,
end: dab.position + pad,
}
}
pub(super) fn union(bounds: &mut Option<AxisAlignedBbox>, other: AxisAlignedBbox) {
*bounds = Some(match bounds.take() {
Some(existing) => existing.union(&other),
None => other,
});
}
pub(super) fn bounds(stroke: &StyledStroke, scale: f64) -> AxisAlignedBbox {
let mut bounds = None;
for sample in stroke.stroke.samples() {
union(&mut bounds, dab_pad(dab(&sample, stroke), scale));
}
bounds.unwrap_or(AxisAlignedBbox::ZERO)
}
pub(super) struct Update {
pub(super) committed: Vec<Edge>,
pub(super) tail: Vec<Edge>,
}
#[derive(Clone)]
pub(super) struct Walk {
sigma_min: f32,
kept_last: Option<Dab>,
kept: usize,
pub(super) consumed: usize,
}
impl Default for Walk {
fn default() -> Self {
Self {
sigma_min: f32::MAX,
kept_last: None,
kept: 0,
consumed: 0,
}
}
}
impl Walk {
fn advance(&mut self, stroke: &StyledStroke, scale: f64) -> Vec<Dab> {
let mut kept = Vec::new();
for index in self.consumed..stroke.stroke.len() {
let sample = stroke.stroke.sample(index);
let dab = dab(&sample, stroke);
self.sigma_min = self.sigma_min.min(dab.sigma);
let min_step = (self.sigma_min as f64 * 0.5).max(0.5 / scale);
if self.kept_last.is_none_or(|last| last.position.distance(dab.position) >= min_step) {
kept.push(dab);
self.kept_last = Some(dab);
self.kept += 1;
}
}
self.consumed = stroke.stroke.len();
kept
}
fn tail(&self, stroke: &StyledStroke) -> Option<(Dab, Dab)> {
let kept_last = self.kept_last?;
let dab = dab(&stroke.stroke.sample(stroke.stroke.len() - 1), stroke);
if dab.position == kept_last.position {
return (self.kept == 1).then_some((kept_last, kept_last));
}
Some((kept_last, dab))
}
pub(super) fn update(&mut self, stroke: &StyledStroke, region: &super::region::Region) -> Update {
let previous = self.kept_last;
let kept = self.advance(stroke, region.scale);
let tail = self.tail(stroke);
Update {
committed: edges(region, previous, &kept, None),
tail: edges(region, None, &[], tail),
}
}
}
fn texel(region: &super::region::Region, p: DVec2) -> [f32; 2] {
[((p.x - region.min.x) * region.scale) as f32, ((p.y - region.min.y) * region.scale) as f32]
}
fn edge(region: &super::region::Region, a: Dab, b: Dab) -> Edge {
Edge {
a: texel(region, a.position),
b: texel(region, b.position),
sigma: ((a.sigma + b.sigma) / 2. * region.scale as f32).max(MIN_SIGMA),
weight: (a.weight + b.weight) / 2.,
}
}
fn mix(a: Dab, b: Dab, t: f32) -> Dab {
Dab {
position: a.position.lerp(b.position, t as f64),
sigma: a.sigma + (b.sigma - a.sigma) * t,
weight: a.weight + (b.weight - a.weight) * t,
}
}
fn segment_edges(edges: &mut Vec<Edge>, region: &super::region::Region, a: Dab, b: Dab) {
let scale = region.scale as f32;
let gradient = (a.sigma.min(b.sigma) * scale).max(1.);
let shift = (b.sigma - a.sigma).abs() * scale * SIGMA_CUTOFF;
let pieces = (shift / (MAX_EDGE_SHIFT * gradient)).ceil().clamp(1., 64.) as usize;
let mut previous = a;
for piece in 1..=pieces {
let next = if piece == pieces { b } else { mix(a, b, piece as f32 / pieces as f32) };
edges.push(edge(region, previous, next));
previous = next;
}
}
fn edges(region: &super::region::Region, prev: Option<Dab>, kept: &[Dab], tail: Option<(Dab, Dab)>) -> Vec<Edge> {
let mut edges = Vec::with_capacity(kept.len() + 1);
let mut last = prev;
for &dab in kept {
if let Some(previous) = last {
segment_edges(&mut edges, region, previous, dab);
}
last = Some(dab);
}
if let Some((a, b)) = tail {
segment_edges(&mut edges, region, a, b);
}
edges
}
#[cfg(test)]
mod tests {
use super::*;
use glam::{DVec2, UVec2};
fn stroke(points: &[[f64; 2]]) -> StyledStroke {
StyledStroke {
color: Color::BLACK,
diameter: 20.,
hardness: 0.8,
flow: 1.,
stroke: Stroke {
position: points.iter().copied().map(DVec2::from).collect(),
..Default::default()
},
}
}
fn region() -> super::super::region::Region {
super::super::region::Region {
min: DVec2::ZERO,
scale: 2.,
size: UVec2::splat(512),
}
}
#[test]
fn chunked_walk_matches_whole_stroke() {
let partial = stroke(&[[10., 10.], [15., 12.], [20., 15.]]);
let complete = stroke(&[[10., 10.], [15., 12.], [20., 15.], [31., 19.], [45., 24.]]);
let region = region();
let mut chunked = Walk::default();
let first = chunked.update(&partial, &region);
let second = chunked.update(&complete, &region);
let mut committed = first.committed;
committed.extend(second.committed);
let whole = Walk::default().update(&complete, &region);
assert_eq!(committed, whole.committed);
assert_eq!(second.tail, whole.tail);
}
}

View File

@@ -1,428 +0,0 @@
use crate::brush_cache::BrushCache;
use crate::brush_stroke::{BrushStyle, BrushTrace};
use core_types::ATTR_TRANSFORM;
use core_types::blending::BlendMode;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::color::{Alpha, Color, Pixel, Sample};
use core_types::generic::FnNode;
use core_types::list::{Item, List};
use core_types::math::bbox::{AxisAlignedBbox, Bbox};
use core_types::registry::FutureWrapperNode;
use core_types::transform::Transform;
use core_types::value::ClonedNode;
use core_types::{Ctx, Node};
use glam::{DAffine2, DVec2};
use raster_nodes::blending_nodes::blend_colors;
use raster_nodes::std_nodes::{empty_image, extend_image_to_bounds};
use raster_types::BitmapMut;
use raster_types::Image;
use raster_types::{CPU, Raster};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BrushStampGenerator<P: Pixel + Alpha> {
color: P,
feather_exponent: f32,
transform: DAffine2,
}
impl<P: Pixel + Alpha> Transform for BrushStampGenerator<P> {
fn transform(&self) -> DAffine2 {
self.transform
}
}
impl<P: Pixel + Alpha> Sample for BrushStampGenerator<P> {
type Pixel = P;
#[inline]
fn sample(&self, position: DVec2, area: DVec2) -> Option<P> {
let position = self.transform.inverse().transform_point2(position);
let area = self.transform.inverse().transform_vector2(area);
let aa_blur_radius = area.length() as f32 * 2.;
let center = DVec2::splat(0.5);
let distance = (position + area / 2. - center).length() as f32 * 2.;
let edge_opacity = 1. - (1. - aa_blur_radius).powf(self.feather_exponent);
let result = if distance < 1. - aa_blur_radius {
1. - distance.powf(self.feather_exponent)
} else if distance < 1. {
// TODO: Replace this with a proper analytical AA implementation
edge_opacity * ((1. - distance) / aa_blur_radius)
} else {
return None;
};
use core_types::color::Channel;
Some(self.color.multiplied_alpha(P::AlphaChannel::from_linear(result)))
}
}
/// Controls the brush shape with diameter and hardness, plus color and opacity (via flow).
/// The feather exponent is calculated from hardness to determine edge softness.
/// Used internally to create the brush texture before stamping it repeatedly along a stroke path.
#[node_macro::node(category(""), skip_impl)]
fn brush_stamp_generator(#[unit(" px")] diameter: f64, color: Color, hardness: f64, flow: f64) -> BrushStampGenerator<Color> {
// Diameter
let radius = diameter / 2.;
// Hardness
let hardness = hardness / 100.;
let feather_exponent = 1. / (1. - hardness) as f32;
// Flow
let flow = flow / 100.;
// Color
let color = color.apply_opacity(flow as f32);
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(diameter), 0., -DVec2::splat(radius));
BrushStampGenerator { color, feather_exponent, transform }
}
/// Used to efficiently paint brush strokes. Applies the same texture repeatedly at different positions with proper blending and boundary handling.
#[node_macro::node(category(""), skip_impl)]
fn blit<BlendFn>(target: List<Raster<CPU>>, texture: Raster<CPU>, positions: Vec<DVec2>, blend_mode: BlendFn) -> List<Raster<CPU>>
where
BlendFn: for<'any_input> Node<'any_input, (Color, Color), Output = Color>,
{
let mut target = target;
if positions.is_empty() {
return target;
}
let (elements, transforms) = target.element_and_attribute_slices_mut::<DAffine2>(ATTR_TRANSFORM);
for (element, transform_attribute) in elements.iter_mut().zip(transforms.iter()) {
let target_width = element.width;
let target_height = element.height;
let target_size = DVec2::new(target_width as f64, target_height as f64);
let texture_size = DVec2::new(texture.width as f64, texture.height as f64);
let document_to_target = DAffine2::from_translation(-texture_size / 2.) * DAffine2::from_scale(target_size) * transform_attribute.inverse();
for position in &positions {
let start = document_to_target.transform_point2(*position).round();
let stop = start + texture_size;
// Half-open integer ranges [start, stop).
let clamp_start = start.clamp(DVec2::ZERO, target_size).as_uvec2();
let clamp_stop = stop.clamp(DVec2::ZERO, target_size).as_uvec2();
let blit_area_offset = (clamp_start.as_dvec2() - start).as_uvec2().min(texture_size.as_uvec2());
let blit_area_dimensions = (clamp_stop - clamp_start).min(texture_size.as_uvec2() - blit_area_offset);
// Tight blitting loop. Eagerly assert bounds to hopefully eliminate bounds check inside loop.
let texture_index = |x: u32, y: u32| -> usize { (y as usize * texture.width as usize) + (x as usize) };
let target_index = |x: u32, y: u32| -> usize { (y as usize * target_width as usize) + (x as usize) };
let max_y = (blit_area_offset.y + blit_area_dimensions.y).saturating_sub(1);
let max_x = (blit_area_offset.x + blit_area_dimensions.x).saturating_sub(1);
assert!(texture_index(max_x, max_y) < texture.data.len());
assert!(target_index(max_x, max_y) < element.data.len());
for y in blit_area_offset.y..blit_area_offset.y + blit_area_dimensions.y {
for x in blit_area_offset.x..blit_area_offset.x + blit_area_dimensions.x {
let src_pixel = texture.data[texture_index(x, y)];
let dst_pixel = &mut element.data_mut().data[target_index(x + clamp_start.x, y + clamp_start.y)];
*dst_pixel = blend_mode.eval((src_pixel, *dst_pixel));
}
}
}
}
target
}
pub async fn create_brush_texture(brush_style: &BrushStyle) -> Raster<CPU> {
let stamp = brush_stamp_generator(brush_style.diameter, brush_style.color, brush_style.hardness, brush_style.flow);
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(brush_style.diameter), 0., -DVec2::splat(brush_style.diameter / 2.));
let blank_texture = empty_image((), Item::new_from_element(transform), Item::new_from_element(Color::TRANSPARENT));
let image = blend_stamp_closure(stamp, blank_texture, |a, b| blend_colors(a, b, BlendMode::Normal, 1.));
image.into_element()
}
pub fn blend_with_mode(background: Item<Raster<CPU>>, foreground: Item<Raster<CPU>>, blend_mode: BlendMode, opacity: f64) -> Item<Raster<CPU>> {
let opacity = opacity as f32 / 100.;
match std::hint::black_box(blend_mode) {
// Normal group
BlendMode::Normal => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Normal, opacity)),
// Darken group
BlendMode::Darken => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Darken, opacity)),
BlendMode::Multiply => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Multiply, opacity)),
BlendMode::ColorBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorBurn, opacity)),
BlendMode::LinearBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearBurn, opacity)),
BlendMode::DarkerColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::DarkerColor, opacity)),
// Lighten group
BlendMode::Lighten => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Lighten, opacity)),
BlendMode::Screen => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Screen, opacity)),
BlendMode::ColorDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorDodge, opacity)),
BlendMode::LinearDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearDodge, opacity)),
BlendMode::LighterColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LighterColor, opacity)),
// Contrast group
BlendMode::Overlay => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Overlay, opacity)),
BlendMode::SoftLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::SoftLight, opacity)),
BlendMode::HardLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardLight, opacity)),
BlendMode::VividLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::VividLight, opacity)),
BlendMode::LinearLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearLight, opacity)),
BlendMode::PinLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::PinLight, opacity)),
BlendMode::HardMix => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardMix, opacity)),
// Inversion group
BlendMode::Difference => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Difference, opacity)),
BlendMode::Exclusion => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Exclusion, opacity)),
BlendMode::Subtract => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Subtract, opacity)),
BlendMode::Divide => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Divide, opacity)),
// Component group
BlendMode::Hue => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Hue, opacity)),
BlendMode::Saturation => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Saturation, opacity)),
BlendMode::Color => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Color, opacity)),
BlendMode::Luminosity => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Luminosity, opacity)),
// Other utility blend modes (hidden from the normal list)
BlendMode::Erase => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Erase, opacity)),
BlendMode::Restore => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Restore, opacity)),
BlendMode::MultiplyAlpha => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::MultiplyAlpha, opacity)),
}
}
/// Generates the brush strokes painted with the Brush tool as a raster image.
/// If an input image is supplied, strokes are drawn on top of it, expanding bounds as needed.
#[node_macro::node(category("Raster"))]
async fn brush(
_: impl Ctx,
/// Optional raster content that may be drawn onto.
background: Item<Raster<CPU>>,
/// The list of brush stroke paths drawn by the Brush tool, with each including both its coordinates and styles.
trace: Item<BrushTrace>,
/// Internal cache data used to accelerate rendering of the brush content.
#[data]
cache: BrushCache,
) -> Item<Raster<CPU>> {
let trace = trace.into_element().0;
let list_item = background;
let mut result_item = list_item.clone();
let bounds = List::new_from_item(list_item.clone()).bounding_box(DAffine2::IDENTITY, false);
let [start, end] = if let RenderBoundingBox::Rectangle(rect) = bounds { rect } else { [DVec2::ZERO, DVec2::ZERO] };
let background_bbox = AxisAlignedBbox { start, end };
let stroke_bbox = trace.iter_element_values().map(|s| s.bounding_box()).reduce(|a, b| a.union(&b)).unwrap_or(AxisAlignedBbox::ZERO);
let bbox = if background_bbox.size().length() < 0.1 {
stroke_bbox
} else {
stroke_bbox.union(&background_bbox)
};
let background_bounds = bbox.to_transform();
let mut draw_strokes: Vec<_> = trace
.iter_element_values()
.filter(|&s| !matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore))
.cloned()
.collect();
let mut brush_plan = cache.compute_brush_plan(list_item, &draw_strokes);
let mut actual_image = extend_image_to_bounds((), brush_plan.background, Item::new_from_element(background_bounds));
let final_stroke_idx = brush_plan.strokes.len().saturating_sub(1);
for (idx, stroke) in brush_plan.strokes.into_iter().enumerate() {
// Create brush texture.
// TODO: apply rotation from layer to stamp for non-rotationally-symmetric brushes.
let mut brush_texture = cache.get_cached_brush(&stroke.style);
if brush_texture.is_none() {
let tex = create_brush_texture(&stroke.style).await;
cache.store_brush(stroke.style.clone(), tex.clone());
brush_texture = Some(tex);
}
let brush_texture = brush_texture.unwrap();
// Compute transformation from stroke texture space into layer space, and create the stroke texture.
let skip = if idx == 0 { brush_plan.first_stroke_point_skip } else { 0 };
let positions: Vec<_> = stroke.compute_blit_points().into_iter().skip(skip).collect();
let stroke_texture = if idx == 0 && positions.is_empty() {
core::mem::take(&mut brush_plan.first_stroke_texture)
} else {
let mut bbox = stroke.bounding_box();
bbox.start = bbox.start.floor();
bbox.end = bbox.end.floor();
let stroke_size = bbox.size() + DVec2::splat(stroke.style.diameter);
// For numerical stability we want to place the first blit point at a stable, integer offset in layer space.
let snap_offset = positions[0].floor() - positions[0];
let stroke_origin_in_layer = bbox.start - snap_offset - DVec2::splat(stroke.style.diameter / 2.);
let stroke_to_layer = DAffine2::from_translation(stroke_origin_in_layer) * DAffine2::from_scale(stroke_size);
let normal_blend = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::Normal, 1.));
let blit_node = BlitNode::new(
FutureWrapperNode::new(ClonedNode::new(brush_texture)),
FutureWrapperNode::new(ClonedNode::new(positions)),
FutureWrapperNode::new(ClonedNode::new(normal_blend)),
);
let blit_target = if idx == 0 {
let target = core::mem::take(&mut brush_plan.first_stroke_texture);
List::new_from_item(extend_image_to_bounds((), target, Item::new_from_element(stroke_to_layer)))
} else {
List::new_from_item(empty_image((), Item::new_from_element(stroke_to_layer), Item::new_from_element(Color::TRANSPARENT)))
};
let list = blit_node.eval(blit_target).await;
assert_eq!(list.len(), 1);
list.into_iter().next().unwrap_or_default()
};
// Cache image before doing final blend, and store final stroke texture.
if idx == final_stroke_idx {
cache.cache_results(core::mem::take(&mut draw_strokes), actual_image.clone(), stroke_texture.clone());
}
// TODO: Is this the correct way to do opacity in blending?
actual_image = blend_with_mode(actual_image, stroke_texture, stroke.style.blend_mode, (stroke.style.color.a() * 100.) as f64);
}
let has_erase_or_restore_strokes = trace.iter_element_values().any(|s| matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore));
if has_erase_or_restore_strokes {
let opaque_image = Image::new(bbox.size().x as u32, bbox.size().y as u32, Color::WHITE);
let mut erase_restore_mask = Item::new_from_element(Raster::new_cpu(opaque_image)).with_attribute(ATTR_TRANSFORM, background_bounds);
for stroke in trace.into_iter().map(|row| row.into_element()) {
let mut brush_texture = cache.get_cached_brush(&stroke.style);
if brush_texture.is_none() {
let tex = create_brush_texture(&stroke.style).await;
cache.store_brush(stroke.style.clone(), tex.clone());
brush_texture = Some(tex);
}
let brush_texture = brush_texture.unwrap();
let positions: Vec<_> = stroke.compute_blit_points().into_iter().collect();
// For mask composition: Erase subtracts alpha, Restore adds alpha, and Draw acts like Restore to allow repainting erased areas.
let mask_blend_mode = match stroke.style.blend_mode {
BlendMode::Erase => BlendMode::Erase,
BlendMode::Restore => BlendMode::Restore,
_ => BlendMode::Restore,
};
let blend_params = FnNode::new(move |(a, b)| blend_colors(a, b, mask_blend_mode, 1.));
let blit_node = BlitNode::new(
FutureWrapperNode::new(ClonedNode::new(brush_texture)),
FutureWrapperNode::new(ClonedNode::new(positions)),
FutureWrapperNode::new(ClonedNode::new(blend_params)),
);
erase_restore_mask = blit_node.eval(List::new_from_item(erase_restore_mask)).await.into_iter().next().unwrap_or_default();
}
let blend_params = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::MultiplyAlpha, 1.));
actual_image = blend_image_closure(erase_restore_mask, actual_image, |a, b| blend_params.eval((a, b)));
}
// The paint operation changes only the raster and its bounds, so set just the resulting transform; blending, opacity,
// clipping, and layer-path attributes carry through from the input `background` rather than being invented here.
let transform: DAffine2 = actual_image.attribute_cloned_or_default(ATTR_TRANSFORM);
*result_item.element_mut() = actual_image.into_element();
result_item.set_attribute(ATTR_TRANSFORM, transform);
result_item
}
pub fn blend_image_closure(foreground: Item<Raster<CPU>>, mut background: Item<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> Item<Raster<CPU>> {
let foreground_size = DVec2::new(foreground.element().width as f64, foreground.element().height as f64);
let background_size = DVec2::new(background.element().width as f64, background.element().height as f64);
// Transforms a point from the background image to the foreground image
let foreground_transform: DAffine2 = foreground.attribute_cloned_or_default(ATTR_TRANSFORM);
let background_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
let background_to_foreground = DAffine2::from_scale(foreground_size) * foreground_transform.inverse() * background_transform * DAffine2::from_scale(1. / background_size);
// Footprint of the foreground image (0, 0)..(1, 1) in the background image space
let background_aabb = Bbox::unit().affine_transform(background_transform.inverse() * foreground_transform).to_axis_aligned_bbox();
// Clamp the foreground image to the background image
let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2();
let end = (background_aabb.end * background_size).min(background_size).as_uvec2();
for y in start.y..end.y {
for x in start.x..end.x {
let background_point = DVec2::new(x as f64, y as f64);
let foreground_point = background_to_foreground.transform_point2(background_point);
let source_pixel = foreground.element().sample(foreground_point);
let Some(destination_pixel) = background.element_mut().data_mut().get_pixel_mut(x, y) else {
continue;
};
*destination_pixel = map_fn(source_pixel, *destination_pixel);
}
}
background
}
pub fn blend_stamp_closure(foreground: BrushStampGenerator<Color>, mut background: Item<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> Item<Raster<CPU>> {
let background_size = DVec2::new(background.element().width as f64, background.element().height as f64);
// Transforms a point from the background image to the foreground image
let background_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
let background_to_foreground = background_transform * DAffine2::from_scale(1. / background_size);
// Footprint of the foreground image (0, 0)..(1, 1) in the background image space
let background_aabb = Bbox::unit().affine_transform(background_transform.inverse() * foreground.transform()).to_axis_aligned_bbox();
// Clamp the foreground image to the background image
let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2();
let end = (background_aabb.end * background_size).min(background_size).as_uvec2();
let area = background_to_foreground.transform_point2(DVec2::new(1., 1.)) - background_to_foreground.transform_point2(DVec2::ZERO);
for y in start.y..end.y {
for x in start.x..end.x {
let background_point = DVec2::new(x as f64, y as f64);
let foreground_point = background_to_foreground.transform_point2(background_point);
let Some(source_pixel) = foreground.sample(foreground_point, area) else { continue };
let Some(destination_pixel) = background.element_mut().data_mut().get_pixel_mut(x, y) else {
continue;
};
*destination_pixel = map_fn(source_pixel, *destination_pixel);
}
}
background
}
#[cfg(test)]
mod test {
use super::*;
use crate::brush_stroke::BrushStroke;
use core_types::transform::Transform;
use glam::DAffine2;
#[test]
fn test_brush_texture() {
let size = 20.;
let image = brush_stamp_generator(size, Color::BLACK, 100., 100.);
assert_eq!(image.transform(), DAffine2::from_scale_angle_translation(DVec2::splat(size.ceil()), 0., -DVec2::splat(size / 2.)));
// center pixel should be BLACK
assert_eq!(image.sample(DVec2::splat(0.), DVec2::ONE), Some(Color::BLACK));
}
#[tokio::test]
async fn test_brush_output_size() {
let image = brush(
(),
&BrushCache::default(),
Item::new_from_element(Raster::new_cpu(Image::<Color>::default())),
Item::new_from_element(BrushTrace::from(vec![BrushStroke {
trace: vec![crate::brush_stroke::BrushInputSample { position: DVec2::ZERO }],
style: BrushStyle {
color: Color::BLACK,
diameter: 20.,
hardness: 20.,
flow: 20.,
spacing: 20.,
blend_mode: BlendMode::Normal,
},
}])),
)
.await;
assert_eq!(image.element().width, 20);
}
}

View File

@@ -1,116 +0,0 @@
use crate::brush_stroke::BrushStroke;
use crate::brush_stroke::BrushStyle;
use core_types::ATTR_TRANSFORM;
use core_types::graphene_hash::CacheHashWrapper;
use core_types::list::Item;
use raster_types::CPU;
use raster_types::Raster;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug, Default)]
struct BrushCacheImpl {
// The full previous input that was cached.
prev_input: Vec<BrushStroke>,
// The strokes that have been fully processed and blended into the background.
background: Item<Raster<CPU>>,
blended_image: Item<Raster<CPU>>,
last_stroke_texture: Item<Raster<CPU>>,
// A cache for brush textures.
brush_texture_cache: HashMap<CacheHashWrapper<BrushStyle>, Raster<CPU>>,
}
impl BrushCacheImpl {
fn compute_brush_plan(&mut self, mut background: Item<Raster<CPU>>, input: &[BrushStroke]) -> BrushPlan {
// Do background invalidation.
if background != self.background {
self.background = background.clone();
return BrushPlan {
strokes: input.to_vec(),
background,
..Default::default()
};
}
// Do blended_image invalidation.
let blended_strokes = &self.prev_input[..self.prev_input.len().saturating_sub(1)];
let num_blended_strokes = blended_strokes.len();
if input.get(..num_blended_strokes) != Some(blended_strokes) {
return BrushPlan {
strokes: input.to_vec(),
background,
..Default::default()
};
}
// Take our previous blended image (and invalidate the cache).
// Since we're about to replace our cache anyway, this saves a clone.
background = std::mem::take(&mut self.blended_image);
// Check if the first non-blended stroke is an extension of the last one.
// Transform is set to ZERO (not the default IDENTITY) as a sentinel to mark this item as uninitialized.
let mut first_stroke_texture = Item::new_from_element(Raster::<CPU>::default()).with_attribute(ATTR_TRANSFORM, glam::DAffine2::ZERO);
let mut first_stroke_point_skip = 0;
let strokes = input[num_blended_strokes..].to_vec();
if !strokes.is_empty() && self.prev_input.len() > num_blended_strokes {
let last_stroke = &self.prev_input[num_blended_strokes];
let same_style = strokes[0].style == last_stroke.style;
let prev_points = last_stroke.compute_blit_points();
let new_points = strokes[0].compute_blit_points();
let is_point_prefix = new_points.get(..prev_points.len()) == Some(&prev_points);
if same_style && is_point_prefix {
first_stroke_texture = std::mem::take(&mut self.last_stroke_texture);
first_stroke_point_skip = prev_points.len();
}
}
self.prev_input = Vec::new();
BrushPlan {
strokes,
background,
first_stroke_texture,
first_stroke_point_skip,
}
}
pub fn cache_results(&mut self, input: Vec<BrushStroke>, blended_image: Item<Raster<CPU>>, last_stroke_texture: Item<Raster<CPU>>) {
self.prev_input = input;
self.blended_image = blended_image;
self.last_stroke_texture = last_stroke_texture;
}
}
#[derive(Clone, Debug, Default)]
pub struct BrushPlan {
pub strokes: Vec<BrushStroke>,
pub background: Item<Raster<CPU>>,
pub first_stroke_texture: Item<Raster<CPU>>,
pub first_stroke_point_skip: usize,
}
#[derive(Debug, Default, Clone)]
pub struct BrushCache(Arc<Mutex<BrushCacheImpl>>);
impl BrushCache {
pub fn compute_brush_plan(&self, background: Item<Raster<CPU>>, input: &[BrushStroke]) -> BrushPlan {
let mut inner = self.0.lock().unwrap();
inner.compute_brush_plan(background, input)
}
pub fn cache_results(&self, input: Vec<BrushStroke>, blended_image: Item<Raster<CPU>>, last_stroke_texture: Item<Raster<CPU>>) {
let mut inner = self.0.lock().unwrap();
inner.cache_results(input, blended_image, last_stroke_texture)
}
pub fn get_cached_brush(&self, style: &BrushStyle) -> Option<Raster<CPU>> {
let inner = self.0.lock().unwrap();
inner.brush_texture_cache.get(&CacheHashWrapper(style.clone())).cloned()
}
pub fn store_brush(&self, style: BrushStyle, brush: Raster<CPU>) {
let mut inner = self.0.lock().unwrap();
inner.brush_texture_cache.insert(CacheHashWrapper(style), brush);
}
}

View File

@@ -1,125 +0,0 @@
use core_types::CacheHash;
use core_types::blending::BlendMode;
use core_types::color::Color;
use core_types::list::{Item, List};
use core_types::math::bbox::AxisAlignedBbox;
use dyn_any::DynAny;
use glam::DVec2;
/// The style of a brush.
#[derive(Clone, Debug, CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BrushStyle {
pub color: Color,
pub diameter: f64,
pub hardness: f64,
pub flow: f64,
pub spacing: f64, // Spacing as a fraction of the diameter.
pub blend_mode: BlendMode,
}
impl Default for BrushStyle {
fn default() -> Self {
Self {
color: Color::BLACK,
diameter: 40.,
hardness: 50.,
flow: 100.,
spacing: 50., // Percentage of diameter.
blend_mode: BlendMode::Normal,
}
}
}
impl Eq for BrushStyle {}
impl PartialEq for BrushStyle {
fn eq(&self, other: &Self) -> bool {
self.color == other.color
&& self.diameter.to_bits() == other.diameter.to_bits()
&& self.hardness.to_bits() == other.hardness.to_bits()
&& self.flow.to_bits() == other.flow.to_bits()
&& self.spacing.to_bits() == other.spacing.to_bits()
&& self.blend_mode == other.blend_mode
}
}
/// A single sample of brush parameters across the brush stroke.
#[derive(Clone, Debug, PartialEq, core_types::CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BrushInputSample {
pub position: DVec2,
}
/// The parameters for a single stroke brush.
#[derive(Clone, Debug, PartialEq, core_types::CacheHash, Default, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BrushStroke {
pub style: BrushStyle,
pub trace: Vec<BrushInputSample>,
}
/// One Brush layer's full sequence of strokes, treated as a single rank-0 value rather than a frame of independent strokes.
#[derive(Default, Debug, Clone, PartialEq, CacheHash, DynAny)]
pub struct BrushTrace(pub List<BrushStroke>);
impl From<List<BrushStroke>> for BrushTrace {
fn from(strokes: List<BrushStroke>) -> Self {
Self(strokes)
}
}
impl From<Vec<BrushStroke>> for BrushTrace {
fn from(strokes: Vec<BrushStroke>) -> Self {
Self(strokes.into_iter().map(Item::new_from_element).collect())
}
}
impl BrushStroke {
pub fn bounding_box(&self) -> AxisAlignedBbox {
let radius = self.style.diameter / 2.;
self.compute_blit_points()
.iter()
.map(|pos| AxisAlignedBbox {
start: *pos + DVec2::new(-radius, -radius),
end: *pos + DVec2::new(radius, radius),
})
.reduce(|a, b| a.union(&b))
.unwrap_or(AxisAlignedBbox::ZERO)
}
pub fn compute_blit_points(&self) -> Vec<DVec2> {
// We always travel in a straight line towards the next user input,
// placing a blit point every time we travelled our spacing distance.
let spacing_dist = self.style.spacing / 100. * self.style.diameter;
let Some(first_sample) = self.trace.first() else {
return Vec::new();
};
let mut cur_pos = first_sample.position;
let mut result = vec![cur_pos];
let mut dist_until_next_blit = spacing_dist;
for sample in &self.trace[1..] {
// Travel to the next sample.
let delta = sample.position - cur_pos;
let mut dist_left = delta.length();
let unit_step = delta / dist_left;
while dist_left >= dist_until_next_blit {
// Take a step to the next blit point.
cur_pos += dist_until_next_blit * unit_step;
dist_left -= dist_until_next_blit;
// Blit.
result.push(cur_pos);
dist_until_next_blit = spacing_dist;
}
// Take the partial step to land at the sample.
dist_until_next_blit -= dist_left;
cur_pos = sample.position;
}
result
}
}

View File

@@ -3,20 +3,23 @@ use core_types::registry::types::Percentage;
use core_types::{Color, Ctx};
use graphic_types::Graphic;
pub mod brush;
mod brush_cache;
pub mod brush_stroke;
pub mod basic_brush;
pub use brush_types::*;
pub(crate) const DEFAULT_DIAMETER: f64 = 40.;
pub(crate) const DEFAULT_HARDNESS: f64 = 0.;
pub(crate) const DEFAULT_FLOW: f64 = 100.;
pub(crate) const DEFAULT_COLOR: Color = Color::BLACK;
#[node_macro::node(category("Raster: Brush"))]
fn brush_strokes(
_: impl Ctx,
strokes: List<Stroke>,
color: List<Color>,
#[default(40.)] diameter: Item<f64>,
#[default(0.)] hardness: Item<Percentage>,
#[default(100.)] flow: Item<Percentage>,
#[default(DEFAULT_DIAMETER)] diameter: Item<f64>,
#[default(DEFAULT_HARDNESS)] hardness: Item<Percentage>,
#[default(DEFAULT_FLOW)] flow: Item<Percentage>,
) -> List<Graphic> {
let (diameter, hardness, flow) = (diameter.into_element(), hardness.into_element(), flow.into_element());
List::new_from_item(
@@ -27,30 +30,3 @@ fn brush_strokes(
.with_attribute(ATTR_FLOW, (flow / 100.).clamp(0., 1.)),
)
}
pub mod migrations {
use crate::brush_stroke::BrushStroke;
// TODO: Eventually remove this document upgrade code
pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<BrushStroke>, D::Error> {
use serde::Deserialize;
#[derive(serde::Deserialize)]
struct LegacyTable {
#[serde(alias = "instances", alias = "instance")]
element: Vec<BrushStroke>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum BrushStrokesFormat {
Strokes(Vec<BrushStroke>),
List(LegacyTable),
}
Ok(match BrushStrokesFormat::deserialize(deserializer)? {
BrushStrokesFormat::Strokes(strokes) => strokes,
BrushStrokesFormat::List(list) => list.element,
})
}
}