use std::any::Any;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use crate::brush::eval::{BrushNodeEvaluator, EvalContext};
use crate::brush::gpu_context::{BrushGpuContext, MAX_DABS_PER_PHASE};
use crate::brush::node::BrushNodeRegistration;
use crate::brush::paint_target_ext::BrushPaintTargetExt;
use crate::brush::pipeline::{
BrushPipelineEntry, BrushPipelineRegistration, BuildContext, DynamicUniformRing,
};
use crate::brush::wgsl::{
pack_intrinsic_uniforms, pack_uniforms, CompileWgslCtx, CompiledBrush, DabField, NodeWgsl,
WgslType, INTRINSIC_UNIFORMS_SIZE,
};
use crate::brush::wire::{BrushWireType, ScalarValue};
use crate::nodegraph::{NodeRegistration, PortDef, UnitType};
const SIZE_REFERENCE_PX: f32 = crate::brush::DAB_REFERENCE_SIZE as f32;
const MAX_UNIFORM_BYTES: usize = 1024;
const STATIONARY_THRESHOLD_PX: f32 = 0.5;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct SmudgeDabMeta {
position: [f32; 2],
write_half: [f32; 2],
read_half: [f32; 2],
}
const SMUDGE_DAB_META_SIZE: usize = std::mem::size_of::<SmudgeDabMeta>();
struct PerBrushPipeline {
pipeline: wgpu::RenderPipeline,
uniform_ring: DynamicUniformRing,
uniform_bind_group: wgpu::BindGroup,
dabs_buffer: wgpu::Buffer,
dabs_bind_group: wgpu::BindGroup,
uniform_size: usize,
}
impl PerBrushPipeline {
fn build(ctx: &BuildContext, compiled: &CompiledBrush) -> Self {
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("smudge-brush"),
source: wgpu::ShaderSource::Wgsl(compiled.stroke_wgsl.clone().into()),
});
let dabs_bgl = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("smudge-dabs-bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("smudge-layout"),
bind_group_layouts: &[
Some(ctx.uniform_bgl),
Some(&dabs_bgl),
Some(ctx.selection_bgl),
Some(ctx.canvas_copy_bgl),
],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("smudge"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let uniform_size =
(INTRINSIC_UNIFORMS_SIZE + compiled.uniform_size).max(INTRINSIC_UNIFORMS_SIZE);
let uniform_ring = DynamicUniformRing::new(
ctx.device,
"smudge-uniforms",
uniform_size as u64,
ctx.min_uniform_align,
);
let uniform_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("smudge-uniform-bg"),
layout: ctx.uniform_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform_ring.buffer,
offset: 0,
size: Some(uniform_ring.binding_size()),
}),
}],
});
let dab_record_size = compiled.dab_record_size.max(16);
let dabs_buffer_size = (MAX_DABS_PER_PHASE as u64) * (dab_record_size as u64);
let dabs_buffer = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("smudge-dabs-buffer"),
size: dabs_buffer_size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let dabs_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("smudge-dabs-bg"),
layout: &dabs_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: dabs_buffer.as_entire_binding(),
}],
});
Self {
pipeline,
uniform_ring,
uniform_bind_group,
dabs_buffer,
dabs_bind_group,
uniform_size,
}
}
}
pub struct SmudgePipeline {
cache: RefCell<HashMap<u64, PerBrushPipeline>>,
}
impl SmudgePipeline {
fn build(_ctx: &BuildContext) -> Self {
Self {
cache: RefCell::new(HashMap::new()),
}
}
fn ensure_pipeline(&self, ctx: &BuildContext, compiled: &CompiledBrush) {
let mut cache = self.cache.borrow_mut();
cache
.entry(compiled.topology_hash)
.or_insert_with(|| PerBrushPipeline::build(ctx, compiled));
}
fn with_pipeline<R>(&self, hash: u64, f: impl FnOnce(&PerBrushPipeline) -> R) -> R {
let cache = self.cache.borrow();
let p = cache
.get(&hash)
.expect("ensure_pipeline must run before with_pipeline");
f(p)
}
}
impl BrushPipelineEntry for SmudgePipeline {
fn as_any(&self) -> &dyn Any {
self
}
fn ring(&self) -> Option<&DynamicUniformRing> {
None
}
fn rings(&self) -> Vec<&DynamicUniformRing> {
Vec::new()
}
}
fn smudge_pipeline_reg() -> BrushPipelineRegistration {
BrushPipelineRegistration {
id: "smudge",
build: |ctx| Box::new(SmudgePipeline::build(ctx)),
}
}
pub const TYPE_ID: &str = "smudge";
pub fn register() -> BrushNodeRegistration {
BrushNodeRegistration {
pipelines: vec![smudge_pipeline_reg()],
evaluator: || Box::new(SmudgeEvaluator),
lifecycle: crate::brush::node::Lifecycle::SeedScratchFromPreStroke,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
display_name: "Smudge",
description: "Output that drags existing canvas pixels along the stroke, like smearing wet paint with a finger.",
ports: vec![
PortDef::input("position", BrushWireType::Vec2)
.with_description("Canvas-pixel pen tip for this dab"),
PortDef::input("motion", BrushWireType::Vec2)
.with_description("Per-dab motion vector — the offset to sample from"),
PortDef::input("size_input", BrushWireType::Scalar)
.with_range(0.0, 1.0, 1.0)
.with_natural_range(0.0, 1.0)
.with_label("Size Input")
.with_unit(UnitType::Percent)
.with_description(
"Per-touch size multiplier (wire pressure here for pressure-sensitive size).",
),
PortDef::input("size", BrushWireType::Scalar)
.with_range(0.0, 4.0, 0.1)
.with_label("Size")
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:up-right-and-down-left-from-center")
.exposed()
.with_preview_value(0.1)
.with_description("Overall brush size"),
PortDef::input("rate", BrushWireType::Scalar)
.with_range(0.0, 1.0, 0.6)
.with_natural_range(0.0, 1.0)
.with_label("Smudge")
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:paint-roller")
.exposed()
.with_description(
"How strongly each touch drags the canvas along the stroke. \
Higher values produce a longer smear trail; lower values \
barely move pixels.",
),
PortDef::input("opacity", BrushWireType::Scalar)
.with_range(0.0, 1.0, 1.0)
.with_natural_range(0.0, 1.0)
.with_label("Opacity")
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:droplet")
.exposed()
.with_description(
"Overall stroke strength. Lower values reduce how much the smudge affects the canvas.",
),
PortDef::input("mask", BrushWireType::Scalar).with_description(
"Per-fragment shape mask (typically wired from shape.mask)",
),
PortDef::output("dab_size", BrushWireType::Vec2)
.with_description("Brush mark size in canvas pixels"),
],
params: &[],
is_gpu: true,
is_terminal: true,
supports_erase: false,
},
}
}
pub struct SmudgeEvaluator;
impl SmudgeEvaluator {
fn effective_radius(ctx: &EvalContext) -> f32 {
let size_input = ctx.input_f32("size_input").max(0.0);
let size = ctx.input_f32("size").max(0.0);
let effective_size = size_input * size;
(effective_size * SIZE_REFERENCE_PX * 0.5).max(0.5)
}
fn insert_copy_origin(gpu: &mut BrushGpuContext, node_id: u32, value: [f32; 2]) {
if let Some(outputs) = gpu.dab_batch.slot_outputs.as_mut() {
outputs.insert(
format!("n{}_copy_origin", node_id),
ScalarValue::Vec2(value),
);
}
}
}
impl BrushNodeEvaluator for SmudgeEvaluator {
fn evaluate_cpu(&self, _ctx: &EvalContext) -> Vec<(String, ScalarValue)> {
vec![]
}
fn evaluate_gpu(
&self,
ctx: &EvalContext,
gpu: &mut BrushGpuContext,
) -> Vec<(String, ScalarValue)> {
let Some(compiled) = gpu.dab_batch.compiled_brush.clone() else {
debug_assert!(false, "smudge requires compiled_brush on gpu_context");
return vec![];
};
let Some(stroke) = gpu.stroke.as_ref() else {
return vec![];
};
let paint_target = &stroke.paint_target;
let position = ctx.input("position").as_vec2();
let motion = ctx.input("motion").as_vec2();
let radius = Self::effective_radius(ctx);
let diameter = radius * 2.0;
if diameter <= 0.0 {
return vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))];
}
if motion[0].abs() < STATIONARY_THRESHOLD_PX && motion[1].abs() < STATIONARY_THRESHOLD_PX {
return vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))];
}
let bbox_radius = radius * compiled.brush_extent_factor + compiled.brush_extent_extra_px;
let canvas_ext = paint_target.canvas_extent();
let layer_x0 = canvas_ext.x0() as f32;
let layer_y0 = canvas_ext.y0() as f32;
let canvas_bbox = match canvas_ext.clamp_f32(
position[0] - bbox_radius,
position[1] - bbox_radius,
position[0] + bbox_radius,
position[1] + bbox_radius,
) {
Some(r) => r,
None => return vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))],
};
let local = paint_target
.canvas_frame()
.canvas_to_layer_rect(canvas_bbox)
.expect("canvas_bbox came from canvas_ext.clamp_f32, so it overlaps the extent");
gpu.dab_batch.push_write_bbox(canvas_bbox);
gpu.dab_batch.bbox = Some(match gpu.dab_batch.bbox {
Some([x0, y0, x1, y1]) => [
x0.min(local.x0()),
y0.min(local.y0()),
x1.max(local.x1()),
y1.max(local.y1()),
],
None => [local.x0(), local.y0(), local.x1(), local.y1()],
});
let write_half_w = bbox_radius;
let write_half_h = bbox_radius;
let read_half_w = write_half_w + motion[0].abs().ceil();
let read_half_h = write_half_h + motion[1].abs().ceil();
let read_x0 = (position[0] - read_half_w).max(layer_x0);
let read_y0 = (position[1] - read_half_h).max(layer_y0);
let copy_canvas_x = read_x0.floor();
let copy_canvas_y = read_y0.floor();
Self::insert_copy_origin(gpu, ctx.node_id.0 as u32, [copy_canvas_x, copy_canvas_y]);
gpu.dab_batch
.queue_dab(&compiled, position, bbox_radius, radius);
let meta = SmudgeDabMeta {
position,
write_half: [write_half_w, write_half_h],
read_half: [read_half_w, read_half_h],
};
gpu.dab_batch
.meta_bytes
.extend_from_slice(bytemuck::bytes_of(&meta));
vec![("dab_size".into(), ScalarValue::Vec2([diameter, diameter]))]
}
fn flush_dabs(&self, _ctx: &EvalContext, gpu: &mut BrushGpuContext) {
if gpu.dab_batch.count == 0 {
return;
}
let Some(compiled) = gpu.dab_batch.compiled_brush.clone() else {
debug_assert!(false, "smudge::flush_dabs requires compiled_brush");
return;
};
let bbox = gpu.dab_batch.bbox.unwrap_or([0, 0, 0, 0]);
let union_w = bbox[2].saturating_sub(bbox[0]);
let union_h = bbox[3].saturating_sub(bbox[1]);
let (dab_bytes, total_dabs) = gpu.dab_batch.take();
let meta_bytes = gpu.dab_batch.take_meta();
if total_dabs == 0 {
return;
}
debug_assert_eq!(
meta_bytes.len(),
(total_dabs as usize) * SMUDGE_DAB_META_SIZE,
"smudge meta queue out of sync with dab queue"
);
let metas: Vec<SmudgeDabMeta> = bytemuck::cast_slice(&meta_bytes).to_vec();
gpu.perf
.record_dab_flush_workload(total_dabs, union_w, union_h);
let pipeline_ref = gpu.pipelines.get::<SmudgePipeline>("smudge");
ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled);
let stroke = gpu
.stroke
.as_ref()
.expect("smudge::flush_dabs requires stroke resources");
let paint_target = &stroke.paint_target;
let canvas_ext = paint_target.canvas_extent();
let layer_offset = [canvas_ext.x0(), canvas_ext.y0()];
let layer_size = [canvas_ext.width, canvas_ext.height];
let mut uniform_bytes: Vec<u8> = Vec::with_capacity(MAX_UNIFORM_BYTES);
pack_intrinsic_uniforms(
&mut uniform_bytes,
gpu.intrinsic_header(layer_offset, layer_size),
);
let outputs = gpu
.dab_batch
.slot_outputs
.as_ref()
.expect("smudge::flush_dabs requires dab_batch.slot_outputs");
pack_uniforms(&compiled, outputs, &mut uniform_bytes);
pipeline_ref.with_pipeline(compiled.topology_hash, |per_brush| {
if uniform_bytes.len() < per_brush.uniform_size {
uniform_bytes.resize(per_brush.uniform_size, 0);
}
per_brush.uniform_ring.reset();
let uniform_offset = per_brush.uniform_ring.write(gpu.queue, &uniform_bytes);
gpu.queue
.write_buffer(&per_brush.dabs_buffer, 0, &dab_bytes);
for (i, meta) in metas.iter().enumerate() {
let _ = gpu.prepare_dab_canvas_copy(
meta.position,
meta.write_half[0],
meta.write_half[1],
meta.read_half[0],
meta.read_half[1],
);
let scratch_ref = &*gpu
.stroke
.as_ref()
.expect("smudge::flush_dabs requires stroke resources")
.scratch;
let read_bg = scratch_ref.read_mirror_bind_group();
let write_view = scratch_ref.write_view();
let mut pass = gpu.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("smudge-flush"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: write_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_viewport(
0.0,
0.0,
layer_size[0] as f32,
layer_size[1] as f32,
0.0,
1.0,
);
pass.set_pipeline(&per_brush.pipeline);
pass.set_bind_group(0, &per_brush.uniform_bind_group, &[uniform_offset]);
pass.set_bind_group(1, &per_brush.dabs_bind_group, &[]);
pass.set_bind_group(2, gpu.selection_bind_group, &[]);
pass.set_bind_group(3, read_bg, &[]);
let ii = i as u32;
pass.draw(0..6, ii..ii + 1);
}
});
gpu.perf.record_dab_flush(total_dabs);
}
fn commit(&self, _ctx: &EvalContext, gpu: &mut BrushGpuContext) {
let Some(stroke) = gpu.stroke.as_ref() else {
return;
};
stroke.paint_target.commit_scratch_blit(
gpu.device,
&mut gpu.encoder,
gpu.pipelines,
stroke.scratch.write_view(),
stroke.scratch.write_texture(),
);
}
fn render_cursor_preview(
&self,
ctx: &EvalContext,
gpu: &mut BrushGpuContext,
) -> Vec<(String, ScalarValue)> {
let radius = Self::effective_radius(ctx);
let _ = crate::brush::wgsl::render_compiled_cursor_preview(gpu, radius);
vec![]
}
fn compile_wgsl(&self, cctx: &CompileWgslCtx) -> Result<NodeWgsl, String> {
let mut wgsl = NodeWgsl::default();
let mask_expr = cctx.input("mask").as_f32();
let motion_expr = cctx.input("motion").as_vec2();
let rate_expr = cctx.input("rate").as_f32();
let opacity_expr = cctx.input("opacity").as_f32();
let copy_origin_field = cctx.dab_field_name("copy_origin");
let key = copy_origin_field.clone();
wgsl.dab_fields.push(DabField {
name: copy_origin_field.clone(),
ty: WgslType::Vec2,
pack: Arc::new(move |outputs, bytes| {
let v = outputs.get(&key).map(|s| s.as_vec2()).unwrap_or([0.0; 2]);
bytes.extend_from_slice(bytemuck::bytes_of(&v));
}),
});
wgsl.terminal_bindings = "@group(3) @binding(0) var scratch_mirror_tex: texture_2d<f32>;\n\
@group(3) @binding(1) var scratch_mirror_smp: sampler;\n"
.to_string();
wgsl.body = format!(
" let mask = clamp({mask_expr}, 0.0, 1.0);\n\
\x20 let motion_v = {motion_expr};\n\
\x20 let rate = clamp({rate_expr}, 0.0, 1.0);\n\
\x20 let stroke_opacity = clamp({opacity_expr}, 0.0, 1.0);\n\
\x20 let mirror_dims = vec2<f32>(textureDimensions(scratch_mirror_tex));\n\
\x20 let bg_uv = (target_pos - d.{copy_origin_field}) / mirror_dims;\n\
\x20 let src_uv = (target_pos - motion_v - d.{copy_origin_field}) / mirror_dims;\n\
\x20 let bg = textureSampleLevel(scratch_mirror_tex, scratch_mirror_smp, bg_uv, 0.0);\n\
\x20 let src = textureSampleLevel(scratch_mirror_tex, scratch_mirror_smp, src_uv, 0.0);\n\
\x20 let amount = clamp(rate * mask * sel * stroke_opacity, 0.0, 1.0);\n\
\x20 return mix(bg, src, amount);\n",
copy_origin_field = copy_origin_field,
);
Ok(wgsl)
}
fn compile_cursor_preview_body(&self, cctx: &CompileWgslCtx) -> Result<NodeWgsl, String> {
let mut wgsl = NodeWgsl::default();
let mask_expr = cctx.input("mask").as_f32();
wgsl.body = format!(
" let mask = clamp({mask_expr}, 0.0, 1.0);\n\
\x20 if (mask <= 0.0) {{ discard; }}\n\
\x20 let preview_color = vec3<f32>(0.6, 0.6, 0.6);\n\
\x20 return vec4<f32>(preview_color * mask, mask);\n"
);
Ok(wgsl)
}
}
fn ensure_per_brush_pipeline(
gpu: &BrushGpuContext,
pipe: &SmudgePipeline,
compiled: &CompiledBrush,
) {
if pipe.cache.borrow().contains_key(&compiled.topology_hash) {
return;
}
let ctx = BuildContext {
device: gpu.device,
queue: gpu.queue,
uniform_bgl: gpu.pipelines.uniform_bind_group_layout(),
selection_bgl: gpu.pipelines.selection_bind_group_layout(),
canvas_copy_bgl: gpu.pipelines.canvas_copy_bind_group_layout(),
canvas_copy_sampler: gpu.pipelines.canvas_copy_sampler(),
min_uniform_align: gpu.device.limits().min_uniform_buffer_offset_alignment,
texture_registry: gpu.pipelines.texture_registry(),
};
pipe.ensure_pipeline(&ctx, compiled);
}