Merge origin/master into the async record refactor

Scaffolding merge for the reconcile; the final series to master is
authored fresh. Rank plumbing resolves to our axis-IR model, the node
macro and the LaneSource render walk stay ours, master's vector
restructure and gradient vocabulary are adopted, and the paint and
appearance adoption is deliberately deferred behind our fill and stroke
markers.
This commit is contained in:
Dennis Kobert
2026-09-08 15:03:57 +00:00
385 changed files with 34669 additions and 20078 deletions

View File

@@ -8,19 +8,24 @@ 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
dyn-any = { workspace = true }
brush-types = { workspace = true }
core-types = { workspace = true }
graphene-hash = { workspace = true }
raster-types = { workspace = true }
raster-nodes = { workspace = true }
graphic-types = { 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,74 @@
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::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,492 +0,0 @@
use crate::brush_cache::BrushCache;
use crate::brush_stroke::{BrushStroke, BrushStyle};
use core_types::attribute::{Attr, BlendMode as BlendModeAttr, ClippingMask, EditorLayerPath, Opacity, OpacityFill, Transform as TransformAttr};
use core_types::blending::BlendMode;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::color::{Alpha, Color, Pixel, Sample};
use core_types::extent::{LevelIn, ListIn};
use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt};
use core_types::list::{Item, List};
use core_types::math::bbox::{AxisAlignedBbox, Bbox};
use core_types::transform::Transform;
use core_types::uuid::NodeId;
use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM};
use core_types::{Ctx, ExtractIndex, InjectIndex};
use glam::{DAffine2, DVec2};
use raster_nodes::blending_nodes::blend_colors;
use raster_nodes::std_nodes::{empty_image_core, extend_image_to_bounds_core};
use raster_types::BitmapMut;
use raster_types::Image;
use raster_types::{CPU, Raster};
#[derive(Clone, Copy, Debug, PartialEq, dyn_any::DynAny)]
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(_: impl Ctx, #[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: Fn(Color, Color) -> Color>(_: impl Ctx, mut target: List<Raster<CPU>>, texture: Raster<CPU>, positions: Vec<DVec2>, blend_mode: BlendFn) -> List<Raster<CPU>> {
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(src_pixel, *dst_pixel);
}
}
}
}
target
}
pub 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 = {
let mut item = Item::new_from_element(empty_image_core(transform, Color::TRANSPARENT));
item.set_attribute(ATTR_TRANSFORM, transform);
item
};
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)),
}
}
/// Lane 0 of the materialized background as the legacy item the brush core works
/// on; an empty level starts from a blank item, as the pre-flip node did.
fn legacy_background(background: core_types::node::List<'_, Raster<CPU>>) -> Item<Raster<CPU>> {
if background.is_empty() {
return Item::default();
}
let lane = background.lane(0);
let mut item = Item::new_from_element(background.element_ref(0).clone());
item.set_attribute(ATTR_TRANSFORM, lane.attr::<TransformAttr>());
item.set_attribute(ATTR_BLEND_MODE, lane.attr::<BlendModeAttr>());
item.set_attribute(ATTR_OPACITY, lane.attr::<Opacity>());
item.set_attribute(ATTR_OPACITY_FILL, lane.attr::<OpacityFill>());
item.set_attribute(ATTR_CLIPPING_MASK, lane.attr::<ClippingMask>());
item
}
/// The brushed image replaces the whole background level with one lane.
fn brush_extent(_background: ListIn<'_, Raster<CPU>>, _trace: ListIn<'_, BrushStroke>, _level: LevelIn) -> GPoll<Extent> {
GPoll::Final(Extent::Exactly(1))
}
/// 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"), extent(brush_extent))]
fn brush<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
/// Optional raster content that may be drawn onto.
background: IList<Raster<CPU>>,
/// The list of brush stroke paths drawn by the Brush tool, with each including both its coordinates and styles.
trace: IList<BrushStroke>,
/// Internal cache data used to accelerate rendering of the brush content.
#[data]
cache: BrushCache,
) -> Result<
IList<(
Raster<CPU>,
Attr<'e, TransformAttr>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
Attr<'e, ClippingMask>,
Attr<'e, EditorLayerPath>,
)>,
Interrupt,
> {
if ctx.innermost_index() > 0 {
return Err(GraphError::past_end().into());
}
// The layer path only rides through, so it is read off the source lane
// rather than round-tripped as a legacy list attribute.
let layer_path: Vec<NodeId> = match background.is_empty() {
true => Vec::new(),
false => background.lane(0).attr::<EditorLayerPath>().to_vec(),
};
let strokes: Vec<BrushStroke> = (0..trace.len()).map(|row| trace.element_ref(row).clone()).collect();
let actual_image = brush_core(legacy_background(background), strokes, cache);
let transform: DAffine2 = actual_image.attribute_cloned_or_default(ATTR_TRANSFORM);
let blend_mode: BlendMode = actual_image.attribute_cloned_or_default(ATTR_BLEND_MODE);
let opacity: f64 = actual_image.attribute_cloned_or(ATTR_OPACITY, 1.);
let fill: f64 = actual_image.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
let clip: bool = actual_image.attribute_cloned_or_default(ATTR_CLIPPING_MASK);
let layer_path = ctx
.arena()
.alloc(layer_path)
.ok_or_else(|| {
Interrupt::from(GraphError {
kind: core_types::gpoll::ErrorKind::ArenaExhausted,
trace: Vec::new(),
})
})?
.0;
Ok((
actual_image.into_element(),
Attr(transform),
Attr(blend_mode),
Attr(opacity),
Attr(fill),
Attr(clip),
Attr(layer_path.as_slice()),
))
}
/// The pre-flip brush body, on legacy items: one background item plus every
/// stroke in order, returning the painted image.
fn brush_core(list_item: Item<Raster<CPU>>, strokes: Vec<BrushStroke>, cache: &BrushCache) -> Item<Raster<CPU>> {
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 = strokes.iter().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<_> = strokes.iter().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);
// TODO: Find a way to handle more than one item
let mut actual_image = {
let background = brush_plan.background;
let transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
let (element, attributes) = background.into_parts();
let (element, transform) = extend_image_to_bounds_core(element, transform, background_bounds);
let mut item = Item::from_parts(element, attributes);
item.set_attribute(ATTR_TRANSFORM, transform);
item
};
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);
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 blit_target = if idx == 0 {
let target = core::mem::take(&mut brush_plan.first_stroke_texture);
let transform: DAffine2 = target.attribute_cloned_or_default(ATTR_TRANSFORM);
let (element, attributes) = target.into_parts();
let (element, transform) = extend_image_to_bounds_core(element, transform, stroke_to_layer);
let mut item = Item::from_parts(element, attributes);
item.set_attribute(ATTR_TRANSFORM, transform);
List::new_from_item(item)
} else {
let mut item = Item::new_from_element(empty_image_core(stroke_to_layer, Color::TRANSPARENT));
item.set_attribute(ATTR_TRANSFORM, stroke_to_layer);
List::new_from_item(item)
};
let list = blit(&(), blit_target, brush_texture, positions, |a, b| blend_colors(a, b, BlendMode::Normal, 1.));
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 = strokes.iter().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 strokes {
let mut brush_texture = cache.get_cached_brush(&stroke.style);
if brush_texture.is_none() {
let tex = create_brush_texture(&stroke.style);
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,
};
erase_restore_mask = blit(&(), List::new_from_item(erase_restore_mask), brush_texture, positions, move |a, b| {
blend_colors(a, b, mask_blend_mode, 1.)
})
.into_iter()
.next()
.unwrap_or_default();
}
actual_image = blend_image_closure(erase_restore_mask, actual_image, |a, b| blend_colors(a, b, BlendMode::MultiplyAlpha, 1.));
}
actual_image
}
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 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));
}
#[test]
fn test_brush_output_size() {
let image = brush_core(
Item::new_from_element(Raster::new_cpu(Image::<Color>::default())),
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,
},
}],
&BrushCache::default(),
);
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,108 +0,0 @@
use core_types::CacheHash;
use core_types::blending::BlendMode;
use core_types::color::Color;
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>,
}
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

@@ -1,30 +1,32 @@
pub mod brush;
mod brush_cache;
pub mod brush_stroke;
use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List};
use core_types::registry::types::Percentage;
use core_types::{Color, Ctx};
use graphic_types::Graphic;
pub mod migrations {
use crate::brush_stroke::BrushStroke;
pub mod basic_brush;
// TODO: Eventually remove this migration document upgrade code
pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<BrushStroke>, D::Error> {
use serde::Deserialize;
pub use brush_types::*;
#[derive(serde::Deserialize)]
struct LegacyTable {
#[serde(alias = "instances", alias = "instance")]
element: Vec<BrushStroke>,
}
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;
#[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,
})
}
#[node_macro::node(category("Raster: Brush"))]
fn brush_strokes(
_: impl Ctx,
strokes: List<Stroke>,
color: List<Color>,
#[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(
Item::new_from_element(Graphic::from(strokes))
.with_attribute(ATTR_COLOR, color.element(0).copied().unwrap_or_default())
.with_attribute(ATTR_DIAMETER, diameter.max(0.))
.with_attribute(ATTR_HARDNESS, (hardness / 100.).clamp(0., 1.))
.with_attribute(ATTR_FLOW, (flow / 100.).clamp(0., 1.)),
)
}