mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
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:
@@ -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:?}"),
|
||||
// =======================
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
8
node-graph/nodes/brush/src/basic_brush/consts.rs
Normal file
8
node-graph/nodes/brush/src/basic_brush/consts.rs
Normal 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;
|
||||
80
node-graph/nodes/brush/src/basic_brush/convert.rs
Normal file
80
node-graph/nodes/brush/src/basic_brush/convert.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
node-graph/nodes/brush/src/basic_brush/convert.wgsl
Normal file
37
node-graph/nodes/brush/src/basic_brush/convert.wgsl
Normal 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);
|
||||
}
|
||||
163
node-graph/nodes/brush/src/basic_brush/kernel.rs
Normal file
163
node-graph/nodes/brush/src/basic_brush/kernel.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
75
node-graph/nodes/brush/src/basic_brush/mod.rs
Normal file
75
node-graph/nodes/brush/src/basic_brush/mod.rs
Normal 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())
|
||||
}
|
||||
543
node-graph/nodes/brush/src/basic_brush/pipeline.rs
Normal file
543
node-graph/nodes/brush/src/basic_brush/pipeline.rs
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
67
node-graph/nodes/brush/src/basic_brush/region.rs
Normal file
67
node-graph/nodes/brush/src/basic_brush/region.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
450
node-graph/nodes/brush/src/basic_brush/render.rs
Normal file
450
node-graph/nodes/brush/src/basic_brush/render.rs
Normal 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?, ®ion)?;
|
||||
|
||||
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(®ion),
|
||||
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(®ion));
|
||||
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, ®ion);
|
||||
|
||||
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: ®ion,
|
||||
crop: &crop,
|
||||
scratch: &scratch,
|
||||
};
|
||||
for (index, stroke) in missing.iter().enumerate() {
|
||||
let scissor = crop.scissor(®ion, 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(®ion, active_bounds);
|
||||
let pending = StrokeRenderer {
|
||||
recorder: &mut recorder,
|
||||
executor,
|
||||
region: ®ion,
|
||||
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(®ion),
|
||||
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()));
|
||||
}
|
||||
}
|
||||
43
node-graph/nodes/brush/src/basic_brush/resolve.wgsl
Normal file
43
node-graph/nodes/brush/src/basic_brush/resolve.wgsl
Normal 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);
|
||||
}
|
||||
117
node-graph/nodes/brush/src/basic_brush/scatter.wgsl
Normal file
117
node-graph/nodes/brush/src/basic_brush/scatter.wgsl
Normal 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);
|
||||
}
|
||||
222
node-graph/nodes/brush/src/basic_brush/stroke.rs
Normal file
222
node-graph/nodes/brush/src/basic_brush/stroke.rs
Normal 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, ®ion);
|
||||
let second = chunked.update(&complete, ®ion);
|
||||
let mut committed = first.committed;
|
||||
committed.extend(second.committed);
|
||||
|
||||
let whole = Walk::default().update(&complete, ®ion);
|
||||
assert_eq!(committed, whole.committed);
|
||||
assert_eq!(second.tail, whole.tail);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user