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, 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 ATLAS_WIDTH: u32 = 128;
const ATLAS_HEIGHT: u32 = 128;
const MAX_UNIFORM_BYTES: usize = 1024;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct PickupUniforms {
pre_stroke_origin: [i32; 2],
pre_stroke_size: [u32; 2],
atlas_width: u32,
atlas_height: u32,
pickup_size: f32,
_pad: f32,
}
struct PerBrushPipeline {
pickup_pipeline: wgpu::RenderPipeline,
composite_pipeline: wgpu::RenderPipeline,
pickup_uniform_ring: DynamicUniformRing,
pickup_uniform_bind_group: wgpu::BindGroup,
composite_uniform_ring: DynamicUniformRing,
composite_uniform_bind_group: wgpu::BindGroup,
composite_uniform_size: usize,
dabs_buffer: wgpu::Buffer,
dabs_bind_group_pickup: wgpu::BindGroup,
dabs_bind_group_composite: wgpu::BindGroup,
_atlas_texture: wgpu::Texture,
atlas_attachment_view: wgpu::TextureView,
atlas_bind_group: wgpu::BindGroup,
}
impl PerBrushPipeline {
fn build(ctx: &BuildContext, compiled: &CompiledBrush) -> Self {
let composite_shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("watercolor-composite"),
source: wgpu::ShaderSource::Wgsl(compiled.stroke_wgsl.clone().into()),
});
let pickup_wgsl = build_pickup_shader(compiled);
let pickup_shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("watercolor-pickup"),
source: wgpu::ShaderSource::Wgsl(pickup_wgsl.into()),
});
let dabs_bgl = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("watercolor-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 composite_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("watercolor-composite-layout"),
bind_group_layouts: &[
Some(ctx.uniform_bgl),
Some(&dabs_bgl),
Some(ctx.selection_bgl),
Some(ctx.canvas_copy_bgl), ],
immediate_size: 0,
});
let pickup_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("watercolor-pickup-layout"),
bind_group_layouts: &[
Some(ctx.uniform_bgl),
Some(&dabs_bgl),
Some(ctx.canvas_copy_bgl), ],
immediate_size: 0,
});
let composite_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 composite_pipeline =
ctx.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("watercolor-composite"),
layout: Some(&composite_layout),
vertex: wgpu::VertexState {
module: &composite_shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &composite_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: Some(composite_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 pickup_pipeline = ctx
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("watercolor-pickup"),
layout: Some(&pickup_layout),
vertex: wgpu::VertexState {
module: &pickup_shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &pickup_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
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 composite_uniform_size =
(INTRINSIC_UNIFORMS_SIZE + compiled.uniform_size).max(INTRINSIC_UNIFORMS_SIZE);
let composite_uniform_ring = DynamicUniformRing::new(
ctx.device,
"watercolor-composite-uniforms",
composite_uniform_size as u64,
ctx.min_uniform_align,
);
let composite_uniform_bind_group =
ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watercolor-composite-uniform-bg"),
layout: ctx.uniform_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &composite_uniform_ring.buffer,
offset: 0,
size: Some(composite_uniform_ring.binding_size()),
}),
}],
});
let pickup_uniform_ring = DynamicUniformRing::new(
ctx.device,
"watercolor-pickup-uniforms",
std::mem::size_of::<PickupUniforms>() as u64,
ctx.min_uniform_align,
);
let pickup_uniform_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watercolor-pickup-uniform-bg"),
layout: ctx.uniform_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &pickup_uniform_ring.buffer,
offset: 0,
size: Some(pickup_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("watercolor-dabs-buffer"),
size: dabs_buffer_size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let dabs_bind_group_pickup = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watercolor-dabs-bg-pickup"),
layout: &dabs_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: dabs_buffer.as_entire_binding(),
}],
});
let dabs_bind_group_composite = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watercolor-dabs-bg-composite"),
layout: &dabs_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: dabs_buffer.as_entire_binding(),
}],
});
let atlas_texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("watercolor-atlas"),
size: wgpu::Extent3d {
width: ATLAS_WIDTH,
height: ATLAS_HEIGHT,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let atlas_attachment_view =
atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
let atlas_sample_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
let atlas_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("watercolor-atlas-bg"),
layout: ctx.canvas_copy_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&atlas_sample_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(ctx.canvas_copy_sampler),
},
],
});
let _ = dab_record_size;
Self {
pickup_pipeline,
composite_pipeline,
pickup_uniform_ring,
pickup_uniform_bind_group,
composite_uniform_ring,
composite_uniform_bind_group,
composite_uniform_size,
dabs_buffer,
dabs_bind_group_pickup,
dabs_bind_group_composite,
_atlas_texture: atlas_texture,
atlas_attachment_view,
atlas_bind_group,
}
}
}
pub struct WatercolorPipeline {
cache: RefCell<HashMap<u64, PerBrushPipeline>>,
}
impl WatercolorPipeline {
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 WatercolorPipeline {
fn as_any(&self) -> &dyn Any {
self
}
fn ring(&self) -> Option<&DynamicUniformRing> {
None
}
fn rings(&self) -> Vec<&DynamicUniformRing> {
Vec::new()
}
}
fn watercolor_pipeline_reg() -> BrushPipelineRegistration {
BrushPipelineRegistration {
id: "watercolor",
build: |ctx| Box::new(WatercolorPipeline::build(ctx)),
}
}
const PICKUP_SHADER_TAIL: &str = r#"
struct PickupUniforms {
pre_stroke_origin: vec2<i32>,
pre_stroke_size: vec2<u32>,
atlas_width: u32,
atlas_height: u32,
pickup_size: f32,
_pad: f32,
}
@group(0) @binding(0) var<uniform> u: PickupUniforms;
@group(1) @binding(0) var<storage, read> dabs: array<DabRecord>;
@group(2) @binding(0) var t_pre_stroke: texture_2d<f32>;
@group(2) @binding(1) var s_pre_stroke: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) @interpolate(flat) instance_idx: u32,
}
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
) -> VertexOutput {
let corners = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
);
let corner = corners[vi];
let atlas_x = f32(ii % u.atlas_width);
let atlas_y = f32(ii / u.atlas_width);
let pixel = vec2<f32>(atlas_x, atlas_y) + corner;
let aw = f32(u.atlas_width);
let ah = f32(u.atlas_height);
let ndc = vec2<f32>(
pixel.x / aw * 2.0 - 1.0,
1.0 - pixel.y / ah * 2.0,
);
var out: VertexOutput;
out.position = vec4<f32>(ndc, 0.0, 1.0);
out.instance_idx = ii;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let dab = dabs[in.instance_idx];
// Pickup samples within a fraction of the dab's *nominal* radius
// (not the bbox-inflated extent). The visible "smudge influence"
// should track where the brush is actually marking, not the
// worst-case shape-bbox footprint. `pickup_size` is the brush
// property scrub — default ≈ 0.33, exposed on the terminal.
//
// STROKE-ONLY: this shader is dispatched only from the stroke
// pipeline (it samples `t_pre_stroke`, which is unbound at preview
// time). Under the stroke convention the dab record's
// `inv_radius_target_px` is `1/radius_canvas_px` (target ≡ canvas),
// so `1 / dab.inv_radius_target_px` recovers canvas-px radius and
// `pickup_half` ends up in canvas px as the rest of the shader
// expects. Do not dispatch this from a preview path — the
// conversion is invalid when target ≢ canvas.
let pickup_half = max(u.pickup_size / dab.inv_radius_target_px, 0.5);
let half_extent = vec2<f32>(pickup_half);
var sum_rgb = vec3<f32>(0.0);
var sum_a = 0.0;
let n: u32 = 8u;
let inv_n = 1.0 / f32(n);
let count = f32(n * n);
let origin_f = vec2<f32>(f32(u.pre_stroke_origin.x), f32(u.pre_stroke_origin.y));
let size_f = vec2<f32>(f32(u.pre_stroke_size.x), f32(u.pre_stroke_size.y));
for (var j: u32 = 0u; j < n; j = j + 1u) {
for (var i: u32 = 0u; i < n; i = i + 1u) {
let cell = (vec2<f32>(f32(i), f32(j)) + 0.5) * inv_n;
let canvas_pos = dab.pos + (cell - 0.5) * 2.0 * half_extent;
let uv = (canvas_pos - origin_f) / size_f;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
continue;
}
let s = textureSampleLevel(t_pre_stroke, s_pre_stroke, uv, 0.0);
sum_rgb = sum_rgb + s.rgb * s.a;
sum_a = sum_a + s.a;
}
}
let avg_rgb = select(vec3<f32>(0.0), sum_rgb / sum_a, sum_a > 0.0001);
let avg_a = sum_a / count;
return vec4<f32>(avg_rgb, avg_a);
}
"#;
fn build_pickup_shader(compiled: &CompiledBrush) -> String {
let mut out = String::with_capacity(PICKUP_SHADER_TAIL.len() + 256);
out.push_str("struct DabRecord {\n");
for f in &compiled.dab_layout {
out.push_str(&format!(" {}: {},\n", f.name, f.ty.wgsl_name()));
}
out.push_str("};\n");
out.push_str(PICKUP_SHADER_TAIL);
out
}
pub const TYPE_ID: &str = "watercolor";
pub fn register() -> BrushNodeRegistration {
BrushNodeRegistration {
pipelines: vec![watercolor_pipeline_reg()],
evaluator: || Box::new(WatercolorEvaluator),
lifecycle: crate::brush::node::Lifecycle::ClearScratchToTransparent,
node: NodeRegistration {
type_id: TYPE_ID,
category: "output",
display_name: "Watercolor",
description: "Output for wet-on-wet watercolor: pigment bleeds, pools, and darkens at the edges.",
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("Per-dab flow (folded into color alpha → max-deposit ceiling)"),
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("deposit", BrushWireType::Scalar)
.with_range(0.0, 1.0, 0.5)
.with_natural_range(0.0, 1.0)
.with_label("Deposit")
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:circle")
.exposed()
.with_description(
"How strongly the brush color replaces the pickup canvas color",
),
PortDef::input("wetness", BrushWireType::Scalar)
.with_range(0.0, 1.0, 0.7)
.with_natural_range(0.0, 1.0)
.with_label("Wetness")
.with_unit(UnitType::Percent)
.exposed()
.with_description("How much pickup color tints the load"),
PortDef::input("pickup_size", BrushWireType::Scalar)
.with_range(0.0, 2.0, 1.0)
.with_natural_range(0.0, 2.0)
.with_label("Pickup Size")
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:eye-dropper")
.exposed()
.with_description(
"Radius of the canvas-sampling neighborhood as a fraction of the dab radius. \
Smaller values keep the smudge influence local to the brush tip; larger \
values pull color from a wider area.",
),
PortDef::input("color", BrushWireType::Vec4)
.with_description("Brush color (typically wired from paint_color)"),
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 WatercolorEvaluator;
impl WatercolorEvaluator {
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 WatercolorEvaluator {
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, "watercolor 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, "watercolor::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 Some(stroke) = gpu.stroke.as_ref() else {
return;
};
let pre_stroke_bg = stroke.pre_stroke_bind_group;
let pre_stroke_size = [
stroke.pre_stroke_texture.width(),
stroke.pre_stroke_texture.height(),
];
let pipeline_ref = gpu.pipelines.get::<WatercolorPipeline>("watercolor");
ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled);
let stroke = gpu
.stroke
.as_ref()
.expect("watercolor::flush_dabs requires stroke resources");
let scratch = &*stroke.scratch;
let paint_target = &stroke.paint_target;
let canvas_ext = paint_target.canvas_extent();
let pre_stroke_origin = [canvas_ext.x0(), canvas_ext.y0()];
let layer_offset = [canvas_ext.x0(), canvas_ext.y0()];
let layer_size = [canvas_ext.width, canvas_ext.height];
let mut composite_uniform_bytes: Vec<u8> = Vec::with_capacity(MAX_UNIFORM_BYTES);
pack_intrinsic_uniforms(
&mut composite_uniform_bytes,
gpu.intrinsic_header(layer_offset, layer_size),
);
let outputs = gpu
.dab_batch
.slot_outputs
.as_ref()
.expect("watercolor::flush_dabs requires dab_batch.slot_outputs");
pack_uniforms(&compiled, outputs, &mut composite_uniform_bytes);
let pickup_size = ctx.input_f32("pickup_size").clamp(0.0, 2.0);
let pickup_uniforms = PickupUniforms {
pre_stroke_origin,
pre_stroke_size,
atlas_width: ATLAS_WIDTH,
atlas_height: ATLAS_HEIGHT,
pickup_size,
_pad: 0.0,
};
pipeline_ref.with_pipeline(compiled.topology_hash, |per_brush| {
if composite_uniform_bytes.len() < per_brush.composite_uniform_size {
composite_uniform_bytes.resize(per_brush.composite_uniform_size, 0);
}
per_brush.composite_uniform_ring.reset();
per_brush.pickup_uniform_ring.reset();
let composite_offset = per_brush
.composite_uniform_ring
.write(gpu.queue, &composite_uniform_bytes);
let pickup_offset = per_brush
.pickup_uniform_ring
.write(gpu.queue, bytemuck::bytes_of(&pickup_uniforms));
gpu.queue
.write_buffer(&per_brush.dabs_buffer, 0, &dab_bytes);
{
let mut pass = gpu.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("watercolor-pickup"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &per_brush.atlas_attachment_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_viewport(0.0, 0.0, ATLAS_WIDTH as f32, ATLAS_HEIGHT as f32, 0.0, 1.0);
pass.set_pipeline(&per_brush.pickup_pipeline);
pass.set_bind_group(0, &per_brush.pickup_uniform_bind_group, &[pickup_offset]);
pass.set_bind_group(1, &per_brush.dabs_bind_group_pickup, &[]);
pass.set_bind_group(2, pre_stroke_bg, &[]);
pass.draw(0..6, 0..total_dabs);
}
{
let mut pass = gpu.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("watercolor-composite"),
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(&per_brush.composite_pipeline);
pass.set_bind_group(
0,
&per_brush.composite_uniform_bind_group,
&[composite_offset],
);
pass.set_bind_group(1, &per_brush.dabs_bind_group_composite, &[]);
pass.set_bind_group(2, gpu.selection_bind_group, &[]);
pass.set_bind_group(3, &per_brush.atlas_bind_group, &[]);
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 mask_expr = cctx.input("mask").as_f32();
let color_expr = cctx.input("color").as_vec4();
let flow_expr = cctx.input("flow").as_f32();
let deposit_expr = cctx.input("deposit").as_f32();
let wetness_expr = cctx.input("wetness").as_f32();
wgsl.terminal_bindings = "@group(3) @binding(0) var atlas_tex: texture_2d<f32>;\n\
@group(3) @binding(1) var atlas_smp: sampler;\n"
.to_string();
wgsl.body = format!(
" let mask = clamp({mask_expr}, 0.0, 1.0);\n\
\x20 if (mask <= 0.0) {{ discard; }}\n\
\x20 if (sel <= 0.0) {{ discard; }}\n\
\x20 var fg_color: vec4<f32> = {color_expr};\n\
\x20 let flow = clamp({flow_expr}, 0.0, 1.0);\n\
\x20 fg_color.a = fg_color.a * flow;\n\
\x20 let deposit = clamp({deposit_expr}, 0.0, 1.0);\n\
\x20 let wetness = clamp({wetness_expr}, 0.0, 1.0);\n\
\x20 let atlas_w: u32 = {atlas_w}u;\n\
\x20 let atlas_h: u32 = {atlas_h}u;\n\
\x20 let atlas_x = i32(in.dab_idx % atlas_w);\n\
\x20 let atlas_y = i32(in.dab_idx / atlas_w);\n\
\x20 let atlas_uv = (vec2<f32>(f32(atlas_x), f32(atlas_y)) + vec2<f32>(0.5)) /\n\
\x20 vec2<f32>(f32(atlas_w), f32(atlas_h));\n\
\x20 let pickup = textureSampleLevel(atlas_tex, atlas_smp, atlas_uv, 0.0);\n\
\x20 let has_canvas = pickup.a > 0.05;\n\
\x20 let canvas_rgb = select(fg_color.rgb, pickup.rgb, has_canvas);\n\
\x20 let load_rgb = mix(canvas_rgb, fg_color.rgb, deposit);\n\
\x20 let load_alpha = mix(pickup.a, fg_color.a, deposit);\n\
\x20 let fg_a = mask * sel * wetness * load_alpha;\n\
\x20 return vec4<f32>(load_rgb * fg_a, fg_a);\n",
atlas_w = ATLAS_WIDTH,
atlas_h = ATLAS_HEIGHT,
);
let _ = std::marker::PhantomData::<WgslType>;
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();
let color_expr = cctx.input("color").as_vec4();
let flow_expr = cctx.input("flow").as_f32();
wgsl.body = format!(
" let mask = clamp({mask_expr}, 0.0, 1.0);\n\
\x20 if (mask <= 0.0) {{ discard; }}\n\
\x20 var fg_color: vec4<f32> = {color_expr};\n\
\x20 let flow = clamp({flow_expr}, 0.0, 1.0);\n\
\x20 let a = mask * flow * fg_color.a;\n\
\x20 return vec4<f32>(fg_color.rgb * a, a);\n"
);
Ok(wgsl)
}
}
fn ensure_per_brush_pipeline(
gpu: &BrushGpuContext,
pipe: &WatercolorPipeline,
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);
}