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

* Draw raster images with pad extension instead of repeat

* Add the GPU basic brush renderer

* Rework the brush tool around the GPU basic brush

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

View File

@@ -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);
}
}