use std::any::Any;
use std::cell::RefCell;
use std::collections::HashMap;
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, InputBinding, NodeWgsl,
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;
struct PerBrushPipeline {
paint_pipeline: wgpu::RenderPipeline,
uniform_ring: DynamicUniformRing,
uniform_bind_group: wgpu::BindGroup,
dabs_buffer: wgpu::Buffer,
dabs_bind_group: wgpu::BindGroup,
uniform_size: usize,
graph_textures_bind_group: Option<wgpu::BindGroup>,
}
impl PerBrushPipeline {
fn build(ctx: &BuildContext, compiled: &CompiledBrush) -> Self {
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("paint-brush"),
source: wgpu::ShaderSource::Wgsl(compiled.stroke_wgsl.clone().into()),
});
let dabs_bgl = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("paint-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 graph_layout = if compiled.graph_texture_names.is_empty() {
None
} else {
Some(
ctx.texture_registry
.layout_for_count(ctx.device, compiled.graph_texture_names.len()),
)
};
let layout = match &graph_layout {
None => ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("paint-layout"),
bind_group_layouts: &[
Some(ctx.uniform_bgl),
Some(&dabs_bgl),
Some(ctx.selection_bgl),
],
immediate_size: 0,
}),
Some(gl) => ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("paint-layout-with-graph-textures"),
bind_group_layouts: &[
Some(ctx.uniform_bgl),
Some(&dabs_bgl),
Some(ctx.selection_bgl),
Some(gl.as_ref()),
],
immediate_size: 0,
}),
};
let paint_blend = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
};
let paint_pipeline = ctx
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("paint"),
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(paint_blend),
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,
"paint-uniforms",
uniform_size as u64,
ctx.min_uniform_align,
);
let uniform_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("paint-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("paint-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("paint-dabs-bg"),
layout: &dabs_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: dabs_buffer.as_entire_binding(),
}],
});
let _ = dab_record_size;
let graph_textures_bind_group = if compiled.graph_texture_names.is_empty() {
None
} else {
let (_layout, bg) = ctx
.texture_registry
.make_bind_group(ctx.device, &compiled.graph_texture_names);
Some(bg)
};
Self {
paint_pipeline,
uniform_ring,
uniform_bind_group,
dabs_buffer,
dabs_bind_group,
uniform_size,
graph_textures_bind_group,
}
}
}
pub struct PaintPipeline {
cache: RefCell<HashMap<u64, PerBrushPipeline>>,
}
impl PaintPipeline {
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 PaintPipeline {
fn as_any(&self) -> &dyn Any {
self
}
fn ring(&self) -> Option<&DynamicUniformRing> {
None
}
fn rings(&self) -> Vec<&DynamicUniformRing> {
Vec::new()
}
}
fn paint_pipeline_reg() -> BrushPipelineRegistration {
BrushPipelineRegistration {
id: "paint",
build: |ctx| Box::new(PaintPipeline::build(ctx)),
}
}
pub const TYPE_ID: &str = "paint";
pub fn register() -> BrushNodeRegistration {
BrushNodeRegistration {
pipelines: vec![paint_pipeline_reg()],
evaluator: || Box::new(PaintEvaluator),
lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
display_name: "Paint",
description: "Output that deposits a brush mark onto the canvas. Plug a Stamp Tip (or any colored mark) into the dab input — this is where paint actually lands.",
ports: vec![
PortDef::input("position", BrushWireType::Vec2)
.with_description("Canvas-pixel pen tip for this dab"),
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("flow", BrushWireType::Scalar)
.with_range(0.0, 1.0, 1.0)
.with_natural_range(0.0, 1.0)
.with_label("Flow")
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:droplet")
.exposed()
.with_description("Stroke-level flow cap (folded into rgba alpha)"),
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:fill-drip")
.exposed()
.with_description("Stroke-level opacity cap (applied at commit)"),
PortDef::input("rgba", BrushWireType::Vec4).with_description(
"Premultiplied RGBA from the upstream compiled graph (typically `stamp.dab`)",
),
PortDef::output("dab_size", BrushWireType::Vec2)
.with_description("Brush mark size in canvas pixels"),
],
params: &[],
is_gpu: true,
is_terminal: true,
supports_erase: true,
},
}
}
pub struct PaintEvaluator;
impl PaintEvaluator {
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)
}
}
impl BrushNodeEvaluator for PaintEvaluator {
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, "paint 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 radius = Self::effective_radius(ctx);
let diameter = radius * 2.0;
if diameter <= 0.0 {
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 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()],
});
gpu.dab_batch
.queue_dab(&compiled, position, bbox_radius, radius);
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, "paint::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();
if total_dabs == 0 {
return;
}
gpu.perf
.record_dab_flush_workload(total_dabs, union_w, union_h);
let pipeline_ref = gpu.pipelines.get::<PaintPipeline>("paint");
ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled);
let stroke = gpu
.stroke
.as_ref()
.expect("paint::flush_dabs requires stroke resources");
let scratch = &*stroke.scratch;
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("paint::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);
let pipeline = &per_brush.paint_pipeline;
let mut pass = gpu.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("paint-flush"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: scratch.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(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, &[]);
if let Some(graph_bg) = per_brush.graph_textures_bind_group.as_ref() {
pass.set_bind_group(3, graph_bg, &[]);
}
pass.draw(0..6, 0..total_dabs);
});
gpu.perf.record_dab_flush(total_dabs);
}
fn commit(&self, ctx: &EvalContext, gpu: &mut BrushGpuContext) {
let Some(stroke) = gpu.stroke.as_ref() else {
return;
};
let opacity = ctx.input_f32("opacity").clamp(0.0, 1.0);
stroke.paint_target.commit_brush_dab(
&mut gpu.encoder,
gpu.pipelines,
gpu.queue,
stroke.scratch.write_bind_group(),
gpu.selection_bind_group,
stroke.pre_stroke_bind_group,
opacity,
gpu.blend_mode,
true,
);
}
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 rgba_expr = match cctx.inputs.get("rgba") {
Some(InputBinding::Wired(expr)) => expr.clone(),
_ => {
"vec4<f32>(1.0, 1.0, 1.0, 1.0) * max(1.0 - local_dist, 0.0)".into()
}
};
let flow_expr = cctx.input("flow").as_f32();
wgsl.body = format!(
" let rgba = {rgba_expr};\n\
\x20 let flow = clamp({flow_expr}, 0.0, 1.0);\n\
\x20 return rgba * flow * sel;\n"
);
Ok(wgsl)
}
}
fn ensure_per_brush_pipeline(
gpu: &BrushGpuContext,
pipe: &PaintPipeline,
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);
}