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

* Draw raster images with pad extension instead of repeat

* Add the GPU basic brush renderer

* Rework the brush tool around the GPU basic brush

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

5
Cargo.lock generated
View File

@@ -371,16 +371,19 @@ name = "brush-nodes"
version = "0.1.0"
dependencies = [
"brush-types",
"bytemuck",
"core-types",
"dyn-any",
"glam",
"graphene-hash",
"graphic-types",
"half",
"node-macro",
"raster-nodes",
"raster-types",
"serde",
"tokio",
"wgpu",
"wgpu-executor",
]
[[package]]

View File

@@ -135,7 +135,9 @@ pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.;
// BRUSH TOOL
pub const BRUSH_SIZE_CHANGE_KEYBOARD: f64 = 5.;
pub const DEFAULT_BRUSH_SIZE: f64 = 20.;
pub const BRUSH_SIZE_DEFAULT: f64 = 40.;
pub const BRUSH_HARDNESS_DEFAULT: f64 = 0.;
pub const BRUSH_FLOW_DEFAULT: f64 = 100.;
// EYEDROPPER TOOL
pub const EYEDROPPER_PREVIEW_AREA_RESOLUTION: u32 = 11;

View File

@@ -275,11 +275,7 @@ impl PreferencesDialogMessageHandler {
let checkbox_id = CheckboxId::new();
let brush_tool_description = "
Enable the Brush tool to support basic raster-based layer painting.\n\
\n\
This legacy experimental tool has performance and quality limitations and is slated for replacement in future versions of Graphite that will have a renewed focus on raster graphics editing.\n\
\n\
Content created with the Brush tool may not be compatible with future versions of Graphite.\n\
Enable the Brush tool to support basic layer painting.\n\
\n\
*Default: Off.*
"

View File

@@ -1066,7 +1066,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
(true, Some(storage)) => storage
.export_to_bytes(
document_format::ExportFormat::Xz,
document_format::ExportOptions::default(),
document_format::ExportOptions {
include_history: false,
..Default::default()
},
export_load_handle.as_ref(),
Some(&legacy_document),
)

View File

@@ -5,7 +5,6 @@ use crate::messages::prelude::*;
use glam::{DAffine2, DVec2};
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::brush::brush_stroke::BrushStroke;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TypesettingConfig};
@@ -112,9 +111,23 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier,
modification_type: VectorModificationType,
},
Brush {
NewBrushGroupLayer {
id: NodeId,
strokes_node_id: NodeId,
parent: LayerNodeIdentifier,
insert_index: usize,
color: Color,
diameter: f64,
hardness: f64,
flow: f64,
},
NewBrushStrokesNode {
layer: LayerNodeIdentifier,
strokes: Vec<BrushStroke>,
strokes_node_id: NodeId,
color: Color,
diameter: f64,
hardness: f64,
flow: f64,
},
SetUpstreamToChain {
layer: LayerNodeIdentifier,

View File

@@ -2,13 +2,14 @@ use super::transform_utils;
use super::utility_types::{ModifyInputsContext, set_stroke_paint_order};
use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::{BLEND_PATH_INPUT_INDEX, DefinitionIdentifier};
use crate::messages::portfolio::document::node_graph::document_node_definitions::{BLEND_PATH_INPUT_INDEX, DefinitionIdentifier, resolve_proto_node_type};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector};
use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::get_clip_mode;
use glam::{DAffine2, DVec2, IVec2};
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graph_craft::list;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
@@ -172,10 +173,35 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
modify_inputs.vector_modify(modification_type);
}
}
GraphOperationMessage::Brush { layer, strokes } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.brush_modify(strokes);
}
GraphOperationMessage::NewBrushGroupLayer {
id,
strokes_node_id,
parent,
insert_index,
color,
diameter,
hardness,
flow,
} => {
let layer = ModifyInputsContext::new(network_interface, responses).create_layer(id);
insert_brush_strokes_chain(network_interface, layer, strokes_node_id, color, diameter, hardness, flow);
responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index });
responses.add(GraphOperationMessage::SetUpstreamToChain { layer });
responses.add(NodeGraphMessage::RunDocumentGraph);
}
GraphOperationMessage::NewBrushStrokesNode {
layer,
strokes_node_id,
color,
diameter,
hardness,
flow,
} => {
insert_brush_strokes_chain(network_interface, layer, strokes_node_id, color, diameter, hardness, flow);
responses.add(GraphOperationMessage::SetUpstreamToChain { layer });
responses.add(NodeGraphMessage::RunDocumentGraph);
}
GraphOperationMessage::SetUpstreamToChain { layer } => {
let Some(OutputConnector::Node { node_id: first_chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::layer_secondary_input(layer.to_node()), &[]) else {
@@ -847,6 +873,22 @@ fn import_usvg_node_inner(
}
}
fn insert_brush_strokes_chain(network_interface: &mut NodeNetworkInterface, layer: LayerNodeIdentifier, strokes_node_id: NodeId, color: Color, diameter: f64, hardness: f64, flow: f64) {
let Some(strokes_node_type) = resolve_proto_node_type(graphene_std::brush::brush_strokes::IDENTIFIER) else {
log::error!("Brush strokes node does not exist");
return;
};
let strokes_node = strokes_node_type.node_template_input_override([
Some(NodeInput::value(TaggedValue::Strokes(Vec::new()), false)),
Some(NodeInput::value(TaggedValue::Color(color), false)),
Some(NodeInput::value(TaggedValue::F64(diameter), false)),
Some(NodeInput::value(TaggedValue::F64(hardness), false)),
Some(NodeInput::value(TaggedValue::F64(flow), false)),
]);
network_interface.insert_node(strokes_node_id, strokes_node, &[]);
network_interface.set_input(&InputConnector::node_at_index(layer.to_node(), 1), NodeInput::node(strokes_node_id, 0), &[]);
}
/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, gradient_info: &SvgGradientInfo) {
let bezpath = convert_usvg_path(path);

View File

@@ -13,7 +13,6 @@ use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graph_craft::{ProtoNodeIdentifier, list};
use graphene_std::brush::brush_stroke::BrushStroke;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TypesettingConfig};
@@ -977,17 +976,6 @@ impl<'a> ModifyInputsContext<'a> {
self.responses.add(NodeGraphMessage::RunDocumentGraph);
}
pub fn brush_modify(&mut self, strokes: Vec<BrushStroke>) {
let Some(brush_node_id) = self.existing_proto_node_id(graphene_std::brush::brush::brush::IDENTIFIER, true) else {
return;
};
self.set_input_with_refresh(
InputConnector::node(brush_node_id, graphene_std::brush::brush::brush::TraceInput),
NodeInput::value(TaggedValue::BrushStrokes(strokes), false),
false,
);
}
pub fn resize_artboard(&mut self, location: DVec2, dimensions: DVec2) {
let Some(artboard_node_id) = self.existing_network_node_id("Artboard", true) else {
return;

View File

@@ -17,7 +17,6 @@ use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
use graph_craft::{Type, concrete};
use graphene_std::animation::RealTimeMode;
use graphene_std::brush::brush_stroke::BrushTrace;
use graphene_std::color::SRGBA8;
use graphene_std::extract_xy::XY;
use graphene_std::raster::{
@@ -286,7 +285,6 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<DAffine2>(x) => transform_widget(default_info, &mut extra_widgets),
Some(x) if id_is::<Color>(x) => color_widget(default_info, ColorInput::default().allow_none(false)),
Some(x) if id_is::<Gradient>(x) => color_widget(default_info, ColorInput::default().allow_none(false)),
Some(x) if id_is::<BrushTrace>(x) => brush_strokes_widget(default_info).into(),
// ============
// STRUCT TYPES
// ============
@@ -460,33 +458,6 @@ pub fn vector_modification_widget(parameter_widgets_info: ParameterWidgetsInfo)
widgets
}
pub fn brush_strokes_widget(parameter_widgets_info: ParameterWidgetsInfo) -> Vec<WidgetInstance> {
let ParameterWidgetsInfo { document_node, node_id: _, index, .. } = parameter_widgets_info;
let mut widgets = start_widgets(&parameter_widgets_info);
let Some(document_node) = document_node else { return widgets };
let Some(input) = document_node.inputs.get(index) else { return widgets };
if let Some(TaggedValue::BrushStrokes(strokes)) = input.as_non_exposed_value() {
let stroke_count = strokes.len();
let sample_count: usize = strokes.iter().map(|s| s.trace.len()).sum();
let label = if stroke_count == 0 {
"Empty".to_string()
} else {
format!(
"{stroke_count} {} / {sample_count} {}",
if stroke_count == 1 { "Stroke" } else { "Strokes" },
if sample_count == 1 { "Sample" } else { "Samples" }
)
};
widgets.extend_from_slice(&[Separator::new(SeparatorStyle::Unrelated).widget_instance(), TextLabel::new(label).widget_instance()]);
}
widgets
}
pub fn image_data_widget(parameter_widgets_info: ParameterWidgetsInfo) -> Vec<WidgetInstance> {
let ParameterWidgetsInfo { document_node, node_id: _, index, .. } = parameter_widgets_info;

View File

@@ -76,25 +76,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
aliases: &["graphene_core::raster::OpacityNode", "graphene_core::blending_nodes::OpacityNode"],
},
// ================================
// brush
// ================================
NodeReplacement {
node: graphene_std::brush::brush::blit::IDENTIFIER,
aliases: &["graphene_brush::BlitNode", "graphene_std::brush::BlitNode", "graphene_brush::brush::BlitNode"],
},
NodeReplacement {
node: graphene_std::brush::brush::brush::IDENTIFIER,
aliases: &["graphene_brush::BrushNode", "graphene_std::brush::BrushNode", "graphene_brush::brush::BrushNode"],
},
NodeReplacement {
node: graphene_std::brush::brush::brush_stamp_generator::IDENTIFIER,
aliases: &[
"graphene_brush::BrushStampGeneratorNode",
"graphene_std::brush::BrushStampGeneratorNode",
"graphene_brush::brush::BrushStampGeneratorNode",
],
},
// ================================
// gcore
// ================================
NodeReplacement {
@@ -1189,30 +1170,6 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
}
}
// The "Brush" wrapper network was replaced with the `brush` proto node directly. Convert old `Network("Brush")` instances to the proto node, forwarding all 3 inputs (Background, Trace, Cache) one-to-one.
// This must run as a pre-pass before the recursive iteration below: replacing the outer Brush's network impl orphans its child paths, and the recursive iteration would log errors for those stale paths.
let brush_layers: Vec<(NodeId, Vec<NodeId>)> = document
.network_interface
.document_network()
.recursive_nodes()
.filter_map(|(node_id, _, path)| (document.network_interface.reference(node_id, &path) == Some(DefinitionIdentifier::Network("Brush".into()))).then_some((*node_id, path)))
.collect();
for (node_id, network_path) in &brush_layers {
// Pre-load `outward_wires` so the chain-break check inside `set_input` resolves the original upstream→node wire from cache
// rather than triggering a fresh rebuild from the (already-mutated) post-`replace_inputs` state, which would orphan wires.
let _ = document.network_interface.outward_wires(network_path);
let new_reference = DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER);
let Some(definition) = resolve_document_node_type(&new_reference) else { continue };
let mut node_template = definition.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut node_template) else {
continue;
};
for (index, input) in old_inputs.iter().take(3).enumerate() {
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path);
}
}
// The "Transform" wrapper network was replaced with the `transform` proto node directly. Convert old `Network("Transform")` instances to the proto node, forwarding the 5 user-facing inputs (Value, Translation, Rotation, Scale, Skew) and dropping the legacy migration sentinels (Origin Offset, Scale Appearance) at indices 5 and 6 if present.
// Pre-pass for the same reason as the Brush migration above: replacing the outer Transform's network impl orphans its child paths.
let transform_layers: Vec<(NodeId, Vec<NodeId>, usize)> = document
@@ -2229,36 +2186,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path);
}
// Old shape: [background, bounds, trace, cache]. Both "bounds" (input 1) and "cache" (input 3) are dropped, and "cache" is now stored as
// internal node state via `#[data]` on the brush node, so it is not a node input at all in the new shape.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && inputs_count == 4 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[2].clone(), network_path);
}
// Old shape: [background, trace, cache]. The "cache" input is dropped because the brush node now stores its cache as internal `#[data]` state.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && inputs_count == 3 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path);
}
// A brush node saved before `Item<Raster<CPU>>` had a default stored its unconnected background as the invalid `()`,
// which fails type resolution against the raster primary; adopt the definition's empty-raster default instead.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && matches!(node.inputs.first().and_then(|input| input.as_value()), Some(TaggedValue::None)) {
let default_background = resolve_document_node_type(&reference)?.node_template.inputs.first()?.clone();
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), default_background, network_path);
}
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_core::vector::RemoveHandlesNode")) {
let mut node_template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::auto_tangents::IDENTIFIER))?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);

View File

@@ -93,6 +93,7 @@ impl MessageHandler<PreferencesMessage, PreferencesMessageContext<'_>> for Prefe
zoom_with_scroll: self.zoom_with_scroll,
});
responses.add(FrontendMessage::UpdateUIScale { scale: self.ui_scale });
responses.add(ToolMessage::RefreshToolShelf);
}
PreferencesMessage::ResetToDefaults => {
responses.add(PreferencesMessage::Load { preferences: Self::default() });

View File

@@ -1,27 +1,21 @@
use super::tool_prelude::*;
use crate::consts::DEFAULT_BRUSH_SIZE;
use crate::application::generate_uuid;
use crate::consts::{BRUSH_FLOW_DEFAULT, BRUSH_HARDNESS_DEFAULT, BRUSH_SIZE_DEFAULT};
use crate::messages::portfolio::document::graph_operation::transform_utils::get_current_transform;
use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_proto_node_type};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::FlowType;
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid};
use graph_craft::document::NodeId;
use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, OutputConnector};
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, selection_changed_since_last_sync, solid};
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graphene_std::Color;
use graphene_std::brush::brush_stroke::{BrushInputSample, BrushStroke, BrushStyle};
use graphene_std::brush::basic_brush::basic_brush as active_brush;
use graphene_std::brush::{Channel, Stroke};
use graphene_std::color::SRGBA8;
use graphene_std::raster::BlendMode;
use graphene_std::vector::style::FillChoice;
const BRUSH_MAX_SIZE: f64 = 5000.;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DrawMode {
Draw = 0,
Erase,
Restore,
}
const SAMPLE_MERGE_DISTANCE: f64 = 1.;
const SAMPLE_MERGE_PRESSURE: f64 = 0.01;
#[derive(Default, ExtractField)]
pub struct BrushTool {
@@ -34,32 +28,35 @@ pub struct BrushOptions {
diameter: f64,
hardness: f64,
flow: f64,
spacing: f64,
color: ToolColorOptions,
blend_mode: BlendMode,
draw_mode: DrawMode,
last_synced_selection: Vec<LayerNodeIdentifier>,
}
impl Default for BrushOptions {
fn default() -> Self {
Self {
diameter: DEFAULT_BRUSH_SIZE,
hardness: 0.,
flow: 100.,
spacing: 20.,
diameter: BRUSH_SIZE_DEFAULT,
hardness: BRUSH_HARDNESS_DEFAULT,
flow: BRUSH_FLOW_DEFAULT,
color: ToolColorOptions::default(),
blend_mode: BlendMode::Normal,
draw_mode: DrawMode::Draw,
last_synced_selection: Vec::new(),
}
}
}
impl BrushOptions {
fn active_color(&self) -> Color {
self.color.active_color().unwrap_or_default()
}
}
#[impl_message(Message, ToolMessage, Brush)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum BrushToolMessage {
// Standard messages
Abort,
SelectionChanged,
WorkingColorChanged,
// Tool-specific messages
@@ -72,14 +69,11 @@ pub enum BrushToolMessage {
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum BrushToolMessageOptionsUpdate {
BlendMode(BlendMode),
ChangeDiameter(f64),
Color(Option<Color>),
Diameter(f64),
DrawMode(DrawMode),
Flow(f64),
Hardness(f64),
Spacing(f64),
Flow(f64),
WorkingColorsChanged,
}
@@ -104,7 +98,7 @@ impl ToolMetadata for BrushTool {
impl LayoutHolder for BrushTool {
fn layout(&self) -> Layout {
let mut widgets = vec![
let widgets = vec![
ColorInput::new(FillChoice::<SRGBA8>::from(self.options.color.fill_choice.as_ref().unwrap_or(&FillChoice::None)))
.mixed(self.options.color.fill_choice.is_none())
.narrow(true)
@@ -119,9 +113,13 @@ impl LayoutHolder for BrushTool {
NumberInput::new(Some(self.options.diameter))
.label("Diameter")
.min(1.)
.max(BRUSH_MAX_SIZE) /* Anything bigger would cause the application to be unresponsive and eventually die */
.unit(" px")
.on_update(|number_input: &NumberInput| BrushToolMessage::UpdateOptions { options: BrushToolMessageOptionsUpdate::Diameter(number_input.value.unwrap()) }.into())
.on_update(|number_input: &NumberInput| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::Diameter(number_input.value.unwrap()),
}
.into()
})
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
NumberInput::new(Some(self.options.hardness))
@@ -151,63 +149,8 @@ impl LayoutHolder for BrushTool {
.into()
})
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
NumberInput::new(Some(self.options.spacing))
.label("Spacing")
.min(1.)
.max(100.)
.mode_range()
.unit("%")
.on_update(|number_input: &NumberInput| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::Spacing(number_input.value.unwrap()),
}
.into()
})
.widget_instance(),
];
widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
let draw_mode_entries: Vec<_> = [DrawMode::Draw, DrawMode::Erase, DrawMode::Restore]
.into_iter()
.map(|draw_mode| {
RadioEntryData::new(format!("{draw_mode:?}")).label(format!("{draw_mode:?}")).on_update(move |_| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::DrawMode(draw_mode),
}
.into()
})
})
.collect();
widgets.push(RadioInput::new(draw_mode_entries).selected_index(Some(self.options.draw_mode as u32)).widget_instance());
widgets.push(Separator::new(SeparatorStyle::Related).widget_instance());
let blend_mode_entries: Vec<Vec<_>> = BlendMode::list()
.iter()
.map(|section| {
section
.iter()
.map(|blend_mode| {
MenuListEntry::new(format!("{blend_mode:?}")).label(blend_mode.to_string()).on_commit(|_| {
BrushToolMessage::UpdateOptions {
options: BrushToolMessageOptionsUpdate::BlendMode(*blend_mode),
}
.into()
})
})
.collect()
})
.collect();
widgets.push(
DropdownInput::new(blend_mode_entries)
.selected_index(self.options.blend_mode.index_in_list().map(|index| index as u32))
.tooltip_description("The blend mode used with the background when performing a brush stroke. Only used in draw mode.")
.disabled(self.options.draw_mode != DrawMode::Draw)
.widget_instance(),
);
Layout(vec![LayoutGroup::row(widgets)])
}
}
@@ -215,12 +158,18 @@ impl LayoutHolder for BrushTool {
#[message_handler_data]
impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for BrushTool {
fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque<Message>, context: &mut ToolActionMessageContext<'a>) {
if matches!(&message, ToolMessage::Brush(BrushToolMessage::SelectionChanged)) {
if self.fsm_state == BrushToolFsmState::Ready && selection_changed_since_last_sync(&mut self.options.last_synced_selection, context.document) {
self.sync_options_from_selection(context.document, responses);
}
return;
}
let ToolMessage::Brush(BrushToolMessage::UpdateOptions { options }) = message else {
self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, true);
return;
};
match options {
BrushToolMessageOptionsUpdate::BlendMode(blend_mode) => self.options.blend_mode = blend_mode,
BrushToolMessageOptionsUpdate::ChangeDiameter(change) => {
let needs_rounding = ((self.options.diameter + change.abs() / 2.) % change.abs() - change.abs() / 2.).abs() > 0.5;
if needs_rounding && change > 0. {
@@ -234,12 +183,9 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Brus
self.send_layout(responses, LayoutTarget::ToolOptions);
}
BrushToolMessageOptionsUpdate::Diameter(diameter) => self.options.diameter = diameter,
BrushToolMessageOptionsUpdate::DrawMode(draw_mode) => self.options.draw_mode = draw_mode,
BrushToolMessageOptionsUpdate::Hardness(hardness) => self.options.hardness = hardness,
BrushToolMessageOptionsUpdate::Flow(flow) => self.options.flow = flow,
BrushToolMessageOptionsUpdate::Spacing(spacing) => self.options.spacing = spacing,
BrushToolMessageOptionsUpdate::Color(color) => {
// User picked a color: push to the global primary working color (no tool-local customization).
if let Some(color) = color {
responses.add(ToolMessage::SelectWorkingColor { color, primary: true });
}
@@ -273,30 +219,115 @@ impl ToolTransition for BrushTool {
fn event_to_message_map(&self) -> EventToMessageMap {
EventToMessageMap {
tool_abort: Some(BrushToolMessage::Abort.into()),
selection_changed: Some(BrushToolMessage::SelectionChanged.into()),
working_color_changed: Some(BrushToolMessage::WorkingColorChanged.into()),
..Default::default()
}
}
}
impl BrushTool {
fn sync_options_from_selection(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
let Some(strokes_node) = selected_strokes_node(document) else { return };
let Some(node) = document.network_interface.document_network().nodes.get(&strokes_node) else {
return;
};
let value = |index: usize| node.inputs.get(index).and_then(|input| input.as_value());
if let Some(TaggedValue::F64(diameter)) = value(STROKES_DIAMETER_INPUT) {
self.options.diameter = *diameter;
}
if let Some(TaggedValue::F64(hardness)) = value(STROKES_HARDNESS_INPUT) {
self.options.hardness = *hardness;
}
if let Some(TaggedValue::F64(flow)) = value(STROKES_FLOW_INPUT) {
self.options.flow = *flow;
}
if let Some(TaggedValue::Color(color)) = value(STROKES_COLOR_INPUT)
&& *color != self.options.active_color()
{
responses.add(ToolMessage::SelectWorkingColor { color: *color, primary: true });
} else {
self.send_layout(responses, LayoutTarget::ToolOptions);
}
}
}
const STROKES_COLOR_INPUT: usize = 1;
const STROKES_DIAMETER_INPUT: usize = 2;
const STROKES_HARDNESS_INPUT: usize = 3;
const STROKES_FLOW_INPUT: usize = 4;
#[derive(Clone, Debug, Default)]
struct BrushToolData {
strokes: Vec<BrushStroke>,
stroke: Stroke,
stroke_node_id: Option<NodeId>,
strokes_before: Vec<Stroke>,
layer: Option<LayerNodeIdentifier>,
transform: DAffine2,
last_sample: (DVec2, Option<f64>),
}
enum BrushTarget {
Existing { strokes_node_id: NodeId, strokes: Vec<Stroke> },
NewGroup { parent: LayerNodeIdentifier, insert_index: usize },
FillEmpty { layer: LayerNodeIdentifier },
}
impl BrushToolData {
fn load_existing_strokes(&mut self, document: &DocumentMessageHandler) -> Option<LayerNodeIdentifier> {
self.transform = DAffine2::IDENTITY;
fn resolve_target(&mut self, document: &DocumentMessageHandler, options: &BrushOptions) -> Option<(LayerNodeIdentifier, BrushTarget)> {
self.layer = None;
if document.network_interface.selected_nodes().selected_layers(document.metadata()).count() != 1 {
let selected_nodes = document.network_interface.selected_nodes();
let mut selected_layers = selected_nodes.selected_layers(document.metadata());
let selected_layer = selected_layers.next().filter(|_| selected_layers.next().is_none())?;
if self.load_brush_layer(document, selected_layer) {
return Some((
selected_layer,
BrushTarget::NewGroup {
parent: selected_layer,
insert_index: 0,
},
));
}
let parent = selected_layer.parent(document.metadata()).filter(|&parent| parent != LayerNodeIdentifier::ROOT_PARENT)?;
if !self.load_brush_layer(document, parent) {
return None;
}
let layer = document.network_interface.selected_nodes().selected_layers(document.metadata()).next()?;
self.layer = Some(layer);
for node_id in document.network_interface.upstream_flow_back_from_nodes(vec![layer.to_node()], &[], FlowType::HorizontalFlow) {
let Some(output) = document.network_interface.upstream_output_connector(&InputConnector::node_at_index(selected_layer.to_node(), 1), &[]) else {
return Some((parent, BrushTarget::FillEmpty { layer: selected_layer }));
};
let new_group = || {
let insert_index = parent.children(document.metadata()).position(|child| child == selected_layer).unwrap_or_default();
BrushTarget::NewGroup { parent, insert_index }
};
let OutputConnector::Node { node_id: strokes_node_id, .. } = output else {
return Some((parent, new_group()));
};
if document.network_interface.reference(&strokes_node_id, &[]) != Some(DefinitionIdentifier::ProtoNode(graphene_std::brush::brush_strokes::IDENTIFIER)) {
return Some((parent, new_group()));
}
let strokes = document
.network_interface
.document_network()
.nodes
.get(&strokes_node_id)
.and_then(|node| node.inputs.first())
.and_then(|input| input.as_value())
.and_then(|value| if let TaggedValue::Strokes(strokes) = value { Some(strokes.clone()) } else { None });
match strokes {
Some(strokes) if Self::style_matches(document, strokes_node_id, options) => Some((parent, BrushTarget::Existing { strokes_node_id, strokes })),
_ => Some((parent, new_group())),
}
}
fn load_brush_layer(&mut self, document: &DocumentMessageHandler, candidate: LayerNodeIdentifier) -> bool {
self.transform = DAffine2::IDENTITY;
for node_id in document.network_interface.upstream_flow_back_from_nodes(vec![candidate.to_node()], &[], FlowType::HorizontalFlow) {
let Some(node) = document.network_interface.document_network().nodes.get(&node_id) else {
continue;
};
@@ -304,12 +335,9 @@ impl BrushToolData {
continue;
};
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && node_id != layer.to_node() {
let points_input = node.inputs.get(1)?;
let Some(TaggedValue::BrushStrokes(strokes)) = points_input.as_value() else { continue };
self.strokes = strokes.clone();
return Some(layer);
if reference == DefinitionIdentifier::ProtoNode(active_brush::IDENTIFIER) && node_id != candidate.to_node() {
self.layer = Some(candidate);
return true;
}
if reference == DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER) {
@@ -318,13 +346,39 @@ impl BrushToolData {
}
self.transform = DAffine2::IDENTITY;
None
false
}
fn update_strokes(&self, responses: &mut VecDeque<Message>) {
let Some(layer) = self.layer else { return };
let strokes = self.strokes.clone();
responses.add(GraphOperationMessage::Brush { layer, strokes });
fn style_matches(document: &DocumentMessageHandler, strokes_node: NodeId, options: &BrushOptions) -> bool {
let Some(node) = document.network_interface.document_network().nodes.get(&strokes_node) else {
return false;
};
let value = |index: usize| node.inputs.get(index).and_then(|input| input.as_value());
matches!(value(STROKES_COLOR_INPUT), Some(TaggedValue::Color(color)) if *color == options.active_color())
&& matches!(value(STROKES_DIAMETER_INPUT), Some(TaggedValue::F64(diameter)) if *diameter == options.diameter)
&& matches!(value(STROKES_HARDNESS_INPUT), Some(TaggedValue::F64(hardness)) if *hardness == options.hardness)
&& matches!(value(STROKES_FLOW_INPUT), Some(TaggedValue::F64(flow)) if *flow == options.flow)
}
fn push_sample(&mut self, position: DVec2, pressure: Option<f64>, elapsed_milliseconds: f64) {
self.stroke.position.push(position);
if let Channel::Samples(times) = &mut self.stroke.time {
times.push(elapsed_milliseconds / 1000.);
}
if let Channel::Samples(pressures) = &mut self.stroke.pressure {
pressures.push(pressure.unwrap_or(1.) as f32);
}
}
fn update_stroke(&self, responses: &mut VecDeque<Message>) {
let Some(stroke_node_id) = self.stroke_node_id else { return };
let mut strokes = self.strokes_before.clone();
strokes.push(self.stroke.clone());
responses.add(NodeGraphMessage::SetInputValue {
node_id: stroke_node_id,
input_index: 0,
value: TaggedValue::Strokes(strokes).into(),
});
}
}
@@ -346,60 +400,96 @@ impl Fsm for BrushToolFsmState {
match (self, event) {
(BrushToolFsmState::Ready, BrushToolMessage::DragStart) => {
responses.add(DocumentMessage::StartTransaction);
let loaded_layer = tool_data.load_existing_strokes(document);
if let Some(layer) = loaded_layer {
let pos = document
.network_interface
.document_metadata()
.downstream_transform_to_viewport(layer)
.inverse()
.transform_point2(input.mouse.position);
let layer_position = tool_data.transform.inverse().transform_point2(pos);
let layer_document_scale = document.metadata().downstream_transform_to_viewport(layer) * tool_data.transform;
// TODO: Also scale it based on the input image ('Background' input).
// TODO: Resizing the input image results in a different brush size from the chosen diameter.
let layer_scale = 0.0001_f64 // Safety against division by zero
.max((layer_document_scale.matrix2 * glam::DVec2::X).length())
.max((layer_document_scale.matrix2 * glam::DVec2::Y).length());
// Start a new stroke with a single sample
let blend_mode = match tool_options.draw_mode {
DrawMode::Draw => tool_options.blend_mode,
DrawMode::Erase => BlendMode::Erase,
DrawMode::Restore => BlendMode::Restore,
};
tool_data.strokes.push(BrushStroke {
trace: vec![BrushInputSample { position: layer_position }],
style: BrushStyle {
color: tool_options.color.active_color().unwrap_or_default(),
diameter: tool_options.diameter / layer_scale,
hardness: tool_options.hardness,
flow: tool_options.flow,
spacing: tool_options.spacing,
blend_mode,
},
});
tool_data.update_strokes(responses);
BrushToolFsmState::Drawing
}
// Create the new layer, wait for the render output to return its transform, and then create the rest of the layer
else {
// A new brush layer needs a graph run before the stroke can start.
let Some((brush_layer, target)) = tool_data.resolve_target(document, tool_options) else {
new_brush_layer(document, responses);
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(DeferMessage::AfterGraphRun {
messages: vec![BrushToolMessage::DragStart.into()],
});
BrushToolFsmState::Ready
return BrushToolFsmState::Ready;
};
let pos = document
.network_interface
.document_metadata()
.downstream_transform_to_viewport(brush_layer)
.inverse()
.transform_point2(input.mouse.position);
let layer_position = tool_data.transform.inverse().transform_point2(pos);
let pressure = input.mouse.pressure;
tool_data.stroke = Stroke {
time: Channel::Samples(Vec::new()),
seed: generate_uuid(),
..Default::default()
};
if pressure.is_some() {
tool_data.stroke.pressure = Channel::Samples(Vec::new());
}
tool_data.push_sample(layer_position, pressure, input.mouse.time.unwrap_or(0.));
tool_data.last_sample = (input.mouse.position, pressure);
match target {
BrushTarget::Existing { strokes_node_id, strokes } => {
tool_data.stroke_node_id = Some(strokes_node_id);
tool_data.strokes_before = strokes;
}
BrushTarget::NewGroup { parent, insert_index } => {
let group_id = NodeId::new();
let strokes_node_id = NodeId::new();
tool_data.stroke_node_id = Some(strokes_node_id);
tool_data.strokes_before = Vec::new();
responses.add(GraphOperationMessage::NewBrushGroupLayer {
id: group_id,
strokes_node_id,
parent,
insert_index,
color: tool_options.active_color(),
diameter: tool_options.diameter,
hardness: tool_options.hardness,
flow: tool_options.flow,
});
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![group_id] });
}
BrushTarget::FillEmpty { layer } => {
let strokes_node_id = NodeId::new();
tool_data.stroke_node_id = Some(strokes_node_id);
tool_data.strokes_before = Vec::new();
responses.add(GraphOperationMessage::NewBrushStrokesNode {
layer,
strokes_node_id,
color: tool_options.active_color(),
diameter: tool_options.diameter,
hardness: tool_options.hardness,
flow: tool_options.flow,
});
}
}
tool_data.update_stroke(responses);
BrushToolFsmState::Drawing
}
(BrushToolFsmState::Drawing, BrushToolMessage::PointerMove) => {
if let Some(layer) = tool_data.layer
&& let Some(stroke) = tool_data.strokes.last_mut()
{
let pressure = input.mouse.pressure;
if pressure == Some(0.) {
return BrushToolFsmState::Drawing;
}
let (last_position, last_pressure) = tool_data.last_sample;
let moved = input.mouse.position.distance(last_position) >= SAMPLE_MERGE_DISTANCE;
let pressure_changed = match (pressure, last_pressure) {
(Some(pressure), Some(last_pressure)) => (pressure - last_pressure).abs() >= SAMPLE_MERGE_PRESSURE,
(pressure, last_pressure) => pressure.is_some() != last_pressure.is_some(),
};
if !moved && !pressure_changed {
return BrushToolFsmState::Drawing;
}
if let Some(layer) = tool_data.layer {
let layer_position = document
.network_interface
.document_metadata()
@@ -408,26 +498,31 @@ impl Fsm for BrushToolFsmState {
.transform_point2(input.mouse.position);
let layer_position = tool_data.transform.inverse().transform_point2(layer_position);
stroke.trace.push(BrushInputSample { position: layer_position })
tool_data.push_sample(layer_position, pressure, input.mouse.time.unwrap_or(0.));
tool_data.last_sample = (input.mouse.position, pressure);
}
tool_data.update_strokes(responses);
tool_data.update_stroke(responses);
BrushToolFsmState::Drawing
}
(BrushToolFsmState::Drawing, BrushToolMessage::DragStop) => {
if !tool_data.strokes.is_empty() {
if tool_data.stroke_node_id.is_some() {
responses.add(DocumentMessage::EndTransaction);
} else {
responses.add(DocumentMessage::AbortTransaction);
}
tool_data.strokes.clear();
tool_data.stroke_node_id = None;
tool_data.stroke = Stroke::default();
tool_data.strokes_before = Vec::new();
BrushToolFsmState::Ready
}
(BrushToolFsmState::Drawing, BrushToolMessage::Abort) => {
responses.add(DocumentMessage::AbortTransaction);
tool_data.strokes.clear();
tool_data.stroke_node_id = None;
tool_data.stroke = Stroke::default();
tool_data.strokes_before = Vec::new();
BrushToolFsmState::Ready
}
@@ -458,12 +553,40 @@ impl Fsm for BrushToolFsmState {
}
}
fn selected_strokes_node(document: &DocumentMessageHandler) -> Option<NodeId> {
let selected_nodes = document.network_interface.selected_nodes();
let mut selected_layers = selected_nodes.selected_layers(document.metadata());
let selected_layer = selected_layers.next().filter(|_| selected_layers.next().is_none())?;
let group = if is_brush_layer(document, selected_layer) {
selected_layer.children(document.metadata()).next()?
} else {
let parent = selected_layer.parent(document.metadata()).filter(|&parent| parent != LayerNodeIdentifier::ROOT_PARENT)?;
if !is_brush_layer(document, parent) {
return None;
}
selected_layer
};
let OutputConnector::Node { node_id, .. } = document.network_interface.upstream_output_connector(&InputConnector::node_at_index(group.to_node(), 1), &[])? else {
return None;
};
(document.network_interface.reference(&node_id, &[]) == Some(DefinitionIdentifier::ProtoNode(graphene_std::brush::brush_strokes::IDENTIFIER))).then_some(node_id)
}
fn is_brush_layer(document: &DocumentMessageHandler, candidate: LayerNodeIdentifier) -> bool {
document
.network_interface
.upstream_flow_back_from_nodes(vec![candidate.to_node()], &[], FlowType::HorizontalFlow)
.any(|node_id| node_id != candidate.to_node() && document.network_interface.reference(&node_id, &[]) == Some(DefinitionIdentifier::ProtoNode(active_brush::IDENTIFIER)))
}
fn new_brush_layer(document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) -> LayerNodeIdentifier {
responses.add(DocumentMessage::DeselectAllLayers);
let brush_node = resolve_proto_node_type(graphene_std::brush::brush::brush::IDENTIFIER)
let brush_node = resolve_proto_node_type(active_brush::IDENTIFIER)
.expect("Brush node does not exist")
.default_node_template();
.node_template_input_override([None, Some(NodeInput::value(TaggedValue::BrushCache(Default::default()), false))]);
let id = NodeId::new();
responses.add(GraphOperationMessage::NewCustomLayer {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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