Add dodge/burn tool

This commit is contained in:
Skyler Lehmkuhl 2026-03-06 09:05:14 -05:00
parent 7d55443b2a
commit 6590b249d1
7 changed files with 111 additions and 2 deletions

View File

@ -305,6 +305,7 @@ impl BrushEngine {
RasterBlendMode::CloneStamp => 3u32, RasterBlendMode::CloneStamp => 3u32,
RasterBlendMode::Healing => 4u32, RasterBlendMode::Healing => 4u32,
RasterBlendMode::PatternStamp => 5u32, RasterBlendMode::PatternStamp => 5u32,
RasterBlendMode::DodgeBurn => 6u32,
}; };
let push_dab = |dabs: &mut Vec<GpuDab>, let push_dab = |dabs: &mut Vec<GpuDab>,
@ -327,7 +328,7 @@ impl BrushEngine {
color_b: cb, color_b: cb,
// Clone stamp: color_r/color_g hold source canvas X/Y, so color_a = 1.0 // Clone stamp: color_r/color_g hold source canvas X/Y, so color_a = 1.0
// (blend strength is opa_weight × opacity × 1.0 in the shader). // (blend strength is opa_weight × opacity × 1.0 in the shader).
color_a: if blend_mode_u == 3 || blend_mode_u == 4 { 1.0 } else { stroke.color[3] }, color_a: if blend_mode_u == 3 || blend_mode_u == 4 || blend_mode_u == 6 { 1.0 } else { stroke.color[3] },
ndx, ndy, smudge_dist, ndx, ndy, smudge_dist,
blend_mode: blend_mode_u, blend_mode: blend_mode_u,
elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0), elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0),
@ -368,6 +369,9 @@ impl BrushEngine {
} else if matches!(base_blend, RasterBlendMode::PatternStamp) { } else if matches!(base_blend, RasterBlendMode::PatternStamp) {
// ndx = pattern_type, ndy = pattern_scale // ndx = pattern_type, ndy = pattern_scale
(cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale) (cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale)
} else if matches!(base_blend, RasterBlendMode::DodgeBurn) {
// color_r = mode (0=dodge, 1=burn); strength comes from opacity
(stroke.dodge_burn_mode as f32, 0.0, 0.0, 0.0, 0.0)
} else { } else {
(cr, cg, cb, 0.0, 0.0) (cr, cg, cb, 0.0, 0.0)
}; };
@ -495,6 +499,12 @@ impl BrushEngine {
push_dab(&mut dabs, &mut bbox, push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2, cr, cg, cb, ex, ey, radius2, opacity2, cr, cg, cb,
stroke.pattern_type as f32, stroke.pattern_scale, 0.0); stroke.pattern_type as f32, stroke.pattern_scale, 0.0);
} else if matches!(base_blend, RasterBlendMode::DodgeBurn) {
// color_r = mode (0=dodge, 1=burn)
push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2,
stroke.dodge_burn_mode as f32, 0.0, 0.0,
0.0, 0.0, 0.0);
} else { } else {
push_dab(&mut dabs, &mut bbox, push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2, cr, cg, cb, ex, ey, radius2, opacity2, cr, cg, cb,
@ -534,6 +544,9 @@ impl BrushEngine {
} else if matches!(base_blend, RasterBlendMode::PatternStamp) { } else if matches!(base_blend, RasterBlendMode::PatternStamp) {
// ndx = pattern_type, ndy = pattern_scale // ndx = pattern_type, ndy = pattern_scale
(cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale) (cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale)
} else if matches!(base_blend, RasterBlendMode::DodgeBurn) {
// color_r = mode (0=dodge, 1=burn)
(stroke.dodge_burn_mode as f32, 0.0, 0.0, 0.0, 0.0)
} else { } else {
(cr, cg, cb, 0.0, 0.0) (cr, cg, cb, 0.0, 0.0)
}; };

View File

@ -24,6 +24,8 @@ pub enum RasterBlendMode {
Healing, Healing,
/// Pattern stamp: paint with a repeating procedural tile pattern /// Pattern stamp: paint with a repeating procedural tile pattern
PatternStamp, PatternStamp,
/// Dodge / Burn: lighten (dodge) or darken (burn) existing pixels
DodgeBurn,
} }
impl Default for RasterBlendMode { impl Default for RasterBlendMode {
@ -65,6 +67,9 @@ pub struct StrokeRecord {
/// Pattern stamp: tile size in pixels /// Pattern stamp: tile size in pixels
#[serde(default = "default_pattern_scale")] #[serde(default = "default_pattern_scale")]
pub pattern_scale: f32, pub pattern_scale: f32,
/// Dodge/Burn mode: 0 = dodge (lighten), 1 = burn (darken)
#[serde(default)]
pub dodge_burn_mode: u32,
pub points: Vec<StrokePoint>, pub points: Vec<StrokePoint>,
} }

View File

@ -806,6 +806,12 @@ struct EditorApp {
/// Pattern stamp settings /// Pattern stamp settings
pattern_type: u32, pattern_type: u32,
pattern_scale: f32, pattern_scale: f32,
/// Dodge/Burn tool settings
dodge_burn_radius: f32,
dodge_burn_hardness: f32,
dodge_burn_spacing: f32,
dodge_burn_exposure: f32,
dodge_burn_mode: u32,
/// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare(). /// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare().
brush_preview_pixels: std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>, brush_preview_pixels: std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
// Audio engine integration // Audio engine integration
@ -1107,6 +1113,11 @@ impl EditorApp {
smudge_strength: 1.0, smudge_strength: 1.0,
pattern_type: 0, pattern_type: 0,
pattern_scale: 32.0, pattern_scale: 32.0,
dodge_burn_radius: 30.0,
dodge_burn_hardness: 0.5,
dodge_burn_spacing: 3.0,
dodge_burn_exposure: 0.5,
dodge_burn_mode: 0,
brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
audio_stream, audio_stream,
audio_controller, audio_controller,
@ -5577,6 +5588,11 @@ impl eframe::App for EditorApp {
smudge_strength: &mut self.smudge_strength, smudge_strength: &mut self.smudge_strength,
pattern_type: &mut self.pattern_type, pattern_type: &mut self.pattern_type,
pattern_scale: &mut self.pattern_scale, pattern_scale: &mut self.pattern_scale,
dodge_burn_radius: &mut self.dodge_burn_radius,
dodge_burn_hardness: &mut self.dodge_burn_hardness,
dodge_burn_spacing: &mut self.dodge_burn_spacing,
dodge_burn_exposure: &mut self.dodge_burn_exposure,
dodge_burn_mode: &mut self.dodge_burn_mode,
audio_controller: self.audio_controller.as_ref(), audio_controller: self.audio_controller.as_ref(),
video_manager: &self.video_manager, video_manager: &self.video_manager,
playback_time: &mut self.playback_time, playback_time: &mut self.playback_time,

View File

@ -173,6 +173,7 @@ impl InfopanelPane {
tool, tool,
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush
| Tool::Erase | Tool::Smudge | Tool::CloneStamp | Tool::HealingBrush | Tool::PatternStamp | Tool::Erase | Tool::Smudge | Tool::CloneStamp | Tool::HealingBrush | Tool::PatternStamp
| Tool::DodgeBurn
); );
// Only show tool options for tools that have options // Only show tool options for tools that have options
@ -197,6 +198,7 @@ impl InfopanelPane {
Tool::CloneStamp => "Clone Stamp", Tool::CloneStamp => "Clone Stamp",
Tool::HealingBrush => "Healing Brush", Tool::HealingBrush => "Healing Brush",
Tool::PatternStamp => "Pattern Stamp", Tool::PatternStamp => "Pattern Stamp",
Tool::DodgeBurn => "Dodge / Burn",
_ => "Brush", _ => "Brush",
} }
} else { } else {
@ -358,6 +360,37 @@ impl InfopanelPane {
self.render_raster_tool_options(ui, shared, false); self.render_raster_tool_options(ui, shared, false);
} }
Tool::DodgeBurn if is_raster_paint_tool => {
ui.horizontal(|ui| {
if ui.selectable_label(*shared.dodge_burn_mode == 0, "Dodge").clicked() {
*shared.dodge_burn_mode = 0;
}
if ui.selectable_label(*shared.dodge_burn_mode == 1, "Burn").clicked() {
*shared.dodge_burn_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(shared.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Exposure:");
ui.add(egui::Slider::new(shared.dodge_burn_exposure, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(shared.dodge_burn_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(shared.dodge_burn_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
Tool::Smudge if is_raster_paint_tool => { Tool::Smudge if is_raster_paint_tool => {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Size:"); ui.label("Size:");

View File

@ -212,6 +212,13 @@ pub struct SharedPaneState<'a> {
pub pattern_type: &'a mut u32, pub pattern_type: &'a mut u32,
/// Pattern stamp: tile size in pixels /// Pattern stamp: tile size in pixels
pub pattern_scale: &'a mut f32, pub pattern_scale: &'a mut f32,
/// Dodge/Burn tool settings
pub dodge_burn_radius: &'a mut f32,
pub dodge_burn_hardness: &'a mut f32,
pub dodge_burn_spacing: &'a mut f32,
pub dodge_burn_exposure: &'a mut f32,
/// 0 = dodge (lighten), 1 = burn (darken)
pub dodge_burn_mode: &'a mut u32,
/// Audio engine controller for playback control (wrapped in Arc<Mutex<>> for thread safety) /// Audio engine controller for playback control (wrapped in Arc<Mutex<>> for thread safety)
pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>, pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
/// Video manager for video decoding and frame caching /// Video manager for video decoding and frame caching

View File

@ -240,6 +240,26 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
alpha * corrected.b + ba * current.b, alpha * corrected.b + ba * current.b,
alpha * corrected.a + ba * current.a, alpha * corrected.a + ba * current.a,
); );
} else if dab.blend_mode == 6u {
// Dodge / Burn: power-curve exposure adjustment.
// color_r: 0.0 = dodge, 1.0 = burn
// Uses pow(channel, gamma) which is asymmetric across channels:
// burn (gamma > 1): low channels compressed toward 0 faster than high ones saturation increases
// dodge (gamma < 1): low channels lifted faster than high ones saturation decreases
// This matches the behaviour of GIMP / Photoshop dodge-burn tools.
let s = opa_weight * dab.opacity;
if s <= 0.0 { return current; }
let rgb = max(current.rgb, vec3<f32>(0.0));
var adjusted: vec3<f32>;
if dab.color_r < 0.5 {
// Dodge: gamma < 1 brightens
adjusted = pow(rgb, vec3<f32>(max(1.0 - s, 0.001)));
} else {
// Burn: gamma > 1 darkens and increases saturation
adjusted = pow(rgb, vec3<f32>(1.0 + s));
}
return vec4<f32>(clamp(adjusted, vec3<f32>(0.0), vec3<f32>(1.0)), current.a);
} else { } else {
return current; return current;
} }

View File

@ -617,6 +617,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
clone_src_offset: None, clone_src_offset: None,
pattern_type: 0, pattern_type: 0,
pattern_scale: 32.0, pattern_scale: 32.0,
dodge_burn_mode: 0,
points: vec![ points: vec![
StrokePoint { x: x0, y: y_lo, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, StrokePoint { x: x0, y: y_lo, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 },
StrokePoint { x: mid_x, y: mid_y, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, StrokePoint { x: mid_x, y: mid_y, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 },
@ -4885,6 +4886,13 @@ impl StagePane {
*shared.smudge_hardness, *shared.smudge_hardness,
*shared.smudge_spacing, *shared.smudge_spacing,
), ),
RasterBlendMode::DodgeBurn => (
lightningbeam_core::brush_settings::BrushSettings::default(),
*shared.dodge_burn_radius,
*shared.dodge_burn_exposure,
*shared.dodge_burn_hardness,
*shared.dodge_burn_spacing,
),
_ => ( _ => (
shared.active_brush_settings.clone(), shared.active_brush_settings.clone(),
*shared.brush_radius, *shared.brush_radius,
@ -4978,6 +4986,7 @@ impl StagePane {
clone_src_offset: self.clone_stroke_offset, clone_src_offset: self.clone_stroke_offset,
pattern_type: *shared.pattern_type, pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale, pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
points: vec![first_pt.clone()], points: vec![first_pt.clone()],
}; };
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
@ -5069,6 +5078,7 @@ impl StagePane {
clone_src_offset: self.clone_stroke_offset, clone_src_offset: self.clone_stroke_offset,
pattern_type: *shared.pattern_type, pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale, pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
points: vec![first_pt.clone()], points: vec![first_pt.clone()],
}; };
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
@ -5156,6 +5166,7 @@ impl StagePane {
clone_src_offset, clone_src_offset,
pattern_type: *shared.pattern_type, pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale, pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
points: vec![prev_pt, curr_local], points: vec![prev_pt, curr_local],
}; };
let current_time = ui.input(|i| i.time); let current_time = ui.input(|i| i.time);
@ -5220,6 +5231,7 @@ impl StagePane {
clone_src_offset: self.clone_stroke_offset, clone_src_offset: self.clone_stroke_offset,
pattern_type: *shared.pattern_type, pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale, pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
points: vec![pt], points: vec![pt],
}; };
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt); let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt);
@ -7586,6 +7598,9 @@ impl StagePane {
Tool::PatternStamp => { Tool::PatternStamp => {
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::PatternStamp, shared); self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::PatternStamp, shared);
} }
Tool::DodgeBurn => {
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::DodgeBurn, shared);
}
Tool::SelectLasso => { Tool::SelectLasso => {
self.handle_raster_lasso_tool(ui, &response, world_pos, shared); self.handle_raster_lasso_tool(ui, &response, world_pos, shared);
} }
@ -8066,8 +8081,8 @@ impl StagePane {
Tool::Erase => (*shared.eraser_radius, *shared.eraser_radius, 0.0_f32), Tool::Erase => (*shared.eraser_radius, *shared.eraser_radius, 0.0_f32),
Tool::Smudge Tool::Smudge
| Tool::BlurSharpen | Tool::BlurSharpen
| Tool::DodgeBurn
| Tool::Sponge => (*shared.smudge_radius, *shared.smudge_radius, 0.0_f32), | Tool::Sponge => (*shared.smudge_radius, *shared.smudge_radius, 0.0_f32),
Tool::DodgeBurn => (*shared.dodge_burn_radius, *shared.dodge_burn_radius, 0.0_f32),
_ => { _ => {
let bs = &shared.active_brush_settings; let bs = &shared.active_brush_settings;
let r = *shared.brush_radius; let r = *shared.brush_radius;