Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
> [!IMPORTANT]
> **Darkly is in beta**! Features are being [added daily](#feature-roadmap). Please [report bugs](https://github.com/darkly-art/darkly/issues/new) so we can squash them.

Do you suffer from the _oppressive sanity_ of rulers, guides, and nondestructive workflows? Break free with [Darkly](https://darkly.art), home of happy accidents and beautiful catastrophies. Harness the **[dark arts](#dark-arts)**, and craft a chaotic masterpiece that will make your graphic design professor roll over in his grave. Madness isn't a bug, it's a feature.
Do you suffer from the _oppressive sanity_ of rulers, guides, and nondestructive workflows? Break free with [Darkly](https://darkly.art), the home of happy accidents and beautiful catastrophies. Madness isn't a bug, it's a feature.

Darkly is an open source Photoshop alternative written in Rust. Digital painters are first-class citizens.
Darkly is a Photoshop alternative where painters are first-class citizens. It has a powerful brush engine, and **[dark arts](#dark-arts)** to help you commune with your imagination.

**Try the demo [here](https://demo.darkly.art).**

Expand Down
43 changes: 36 additions & 7 deletions crates/darkly/brushes/hair.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,42 @@ nodes:
brush_settings:
type: brush_settings
inputs:
size: 0.5685366988182068
spacing: 0.009999999776482582
size: 0.2
spacing: 0.01
stabilize: 1.0
circle:
type: circle
curve:
type: curve
comment: pen pressure affects the threshold of hair volume
inputs:
curve:
- - 0.0
- 1.0
- - 0.6688293
- 0.49044272
- - 0.682973
- 0.5305826
- - 1.0
- 0.0
divide:
type: divide
levels:
type: levels
multiply:
type: multiply
inputs:
b: 2.56
multiply_2:
type: multiply
inputs:
a: 0.5
multiply_3:
type: multiply
inputs:
b: 0.085
noise:
type: noise
inputs:
scale: 12.040367126464844
scale_with_brush: false
scale: 12.0
space: 1
paint:
type: paint
Expand All @@ -40,20 +54,35 @@ nodes:
subtract:
type: subtract
inputs:
a: 0.11924592405557632
a: 0.12
connections:
- add.result -> levels.input
- brush_settings.size -> divide.b
- brush_settings.size -> multiply_3.a
- circle.mask -> add.b
- curve.output -> levels.in_low
- divide.result -> multiply.a
- levels.output -> stamp.tip
- multiply.result -> multiply_2.b
- multiply_2.result -> noise.rotation
- multiply_3.result -> noise.scale
- noise.value -> subtract.b
- paint_color.color -> stamp.color
- pen_input.distance -> divide.a
- pen_input.position -> paint.position
- pen_input.pressure -> curve.input
- stamp.dab -> paint.rgba
- subtract.result -> add.a
exposed_ports:
brush_settings.stabilize: {}
brush_settings.size: {}
multiply_2.a:
label: Twirl
description: Twirliness factor
icon: tabler:galaxy
paint.flow: {}
paint.opacity: {}
multiply_3.b:
label: Hair Thickness
description: Controls how many and how thick are the individual strands
icon: fa6-solid:arrows-left-right
Original file line number Diff line number Diff line change
@@ -1,52 +1,59 @@
name: Twirly Hair
name: Sponge
category: Dry Media
nodes:
add:
type: add
brush_settings:
type: brush_settings
inputs:
size: 0.4
spacing: 0.01
size: 0.2
spacing: 0.02
stabilize: 1.0
circle:
type: circle
curve:
type: curve
comment: pen pressure affects the threshold of hair volume
inputs:
curve:
- - 0.0
- 1.0
- - 0.682973
- 0.5305826
- 0.70523924
- - 0.59307975
- 0.36470732
- - 1.0
- 0.0
divide:
type: divide
comment: we have to divide the distance factor by the brush size, to keep the 'twirl' setting consistent
curve_2:
type: curve
inputs:
curve:
- - 0.0
- 0.0
- - 0.6392242
- 0.36304563
- - 1.0
- 1.0
levels:
type: levels
multiply:
type: multiply
inputs:
b: 0.005
multiply_2:
type: multiply
inputs:
a: 0.5
noise:
type: noise
inputs:
scale: 12
scale_with_brush: false
scale: 6.0
space: 1
paint:
type: paint
paint_color:
type: paint_color
pen_input:
type: pen_input
polygon:
type: polygon
inputs:
points: 4
rounding: 0.30
softness: 0.30
squeeze: 0.35
squeeze_angle: -0.7853981852531433
random:
type: random
random_2:
type: random
stamp:
type: stamp
subtract:
Expand All @@ -55,26 +62,24 @@ nodes:
a: 0.11924592405557632
connections:
- add.result -> levels.input
- brush_settings.size -> divide.b
- circle.mask -> add.b
- curve.output -> levels.in_low
- divide.result -> multiply.a
- curve_2.output -> paint.flow
- levels.output -> stamp.tip
- multiply.result -> multiply_2.b
- multiply_2.result -> noise.rotation
- noise.value -> subtract.b
- paint_color.color -> stamp.color
- pen_input.distance -> divide.a
- pen_input.drawing_angle -> polygon.rotation_input
- pen_input.position -> paint.position
- pen_input.pressure -> curve.input
- pen_input.pressure -> curve_2.input
- polygon.mask -> add.b
- random.value -> noise.rotation
- random_2.value -> noise.variation
- stamp.dab -> paint.rgba
- subtract.result -> add.a
exposed_ports:
brush_settings.stabilize: {}
brush_settings.size: {}
multiply_2.a:
label: Twirl
description: Twirliness factor
icon: tabler:galaxy
paint.flow: {}
paint.opacity: {}
polygon.softness: {}
polygon.rounding: {}
7 changes: 3 additions & 4 deletions crates/darkly/presets/defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,9 @@ hotkeys:
# Photoshop binds Edit > Preferences to $mod+K; Krita/GIMP have no
# default. Darkly picks the macOS preferences convention.
openSettings: $mod+Comma
# Photoshop & GIMP both have export (Photoshop: $mod+Alt+W for "Export
# As"; GIMP: $mod+Shift+E for "Export As…"). Krita's `file_export_file`
# is explicitly `none`. Darkly picks GIMP's binding.
exportImage: $mod+Shift+KeyE
# Image export is folded into Save As (the save picker's type dropdown
# offers PNG/JPEG/WebP alongside `.darkly`), so there's no separate
# export-image hotkey — Save As ($mod+Shift+KeyS) covers it.
# Photoshop binds Lasso to L and Polygonal Lasso to Shift+L; Krita's
# selection-tool .action files have empty <shortcut>; GIMP's analog is
# "Free Select" (KeyF) with no polygonal counterpart. Darkly inherits
Expand Down
3 changes: 0 additions & 3 deletions crates/darkly/presets/gimp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ hotkeys:
pasteInPlace: $mod+Alt+KeyV
# GIMP has no native Preferences hotkey. Fall through to Darkly's pick.
openSettings: $mod+Comma
# GIMP binds File > Export As… to <primary><shift>E (see
# gimp/app/actions/file-actions.c — `file-export-as`).
exportImage: $mod+Shift+KeyE
# mirrorViewH: intentionally unbound. GIMP has no native mirror-view
# hotkey, and Krita's M would set no established GIMP-user expectation
# — leave it for explicit user binding rather than invent a default.
Expand Down
5 changes: 0 additions & 5 deletions crates/darkly/presets/krita.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@ hotkeys:
# Krita has no native Preferences hotkey. Fall through to Darkly's
# macOS-convention pick; users can rebind via the settings UI.
openSettings: $mod+Comma
# Krita's `file_export_file` is explicitly `none` (see
# kritamenu.action). GIMP binds Export As to <primary><shift>E
# (gimp/app/actions/file-actions.c — `file-export-as`); Darkly inherits
# that key here.
exportImage: $mod+Shift+KeyE
# View > Mirror View. Krita binds M (kritamenu.action `mirror_canvas`,
# <shortcut>M</shortcut>). Photoshop and GIMP have no opinion; their
# overlays leave mirrorViewH unbound rather than reuse M (Photoshop's
Expand Down
3 changes: 0 additions & 3 deletions crates/darkly/presets/photoshop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ hotkeys:
pasteInPlace: $mod+Shift+KeyV
# Photoshop binds Edit > Preferences to Ctrl+K (Cmd+K on macOS).
openSettings: $mod+KeyK
# Photoshop's File > Export > Export As… is Alt+Shift+Ctrl+W; Darkly
# uses the closer Alt+Ctrl+W variant.
exportImage: $mod+Alt+KeyW
# mirrorViewH: intentionally unbound. Photoshop has no native mirror-
# view action, and M is already taken by rectSelectTool here. Users
# who want a mirror-view hotkey under the PS preset can set one via
Expand Down
10 changes: 5 additions & 5 deletions crates/darkly/shaders/brush/bake_source.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ struct BakeParams {
gain: f32,
warp: f32,
// Field units the tile spans across [0,1) uv — matches the node's
// sample-time divide by TILE_SPAN so baked feature size equals the live
// sample-time divide by FIELD_SPAN so baked feature size equals the live
// path and the field repeats once per this many field units.
tile_span: f32,
field_span: f32,
// 0 = grayscale value (single fBm at the base seed, written to R),
// 1 = chromatic rgba (three fBm at seed+{0,1,2}).
channels: u32,
Expand Down Expand Up @@ -51,11 +51,11 @@ fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
// The tile spans `tile_span` field units across [0,1) uv; `fbm_tile` wraps
// The tile spans `field_span` field units across [0,1) uv; `fbm_tile` wraps
// its lattice at that same period, so the baked tile is seamless under the
// repeat-wrapped sampler.
let coord = in.uv * params.tile_span;
let period = i32(params.tile_span);
let coord = in.uv * params.field_span;
let period = i32(params.field_span);
if (params.channels == 0u) {
let v = fbm_tile(coord, params.seed, params.octaves, params.gain, params.warp, period);
return vec4<f32>(v, v, v, 1.0);
Expand Down
11 changes: 11 additions & 0 deletions crates/darkly/shaders/lib/fbm2d.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ fn fbm_seed_xform(seed: u32) -> vec3f {
return vec3f(a, ox, oy);
}

/// Seed → a 2D point scattered uniformly over `[0, period)²`. Two independent
/// `fbm_pcg` draws (the golden-ratio salt makes the y draw independent of the
/// x draw), so the point covers the plane rather than collapsing onto the
/// `x == y` diagonal a single scaled scalar would trace. Used to decorrelate
/// per-dab sampling of a period-`period` field.
fn fbm_offset2(seed: u32, period: f32) -> vec2f {
let ox = f32(fbm_pcg(seed)) / 4294967295.0;
let oy = f32(fbm_pcg(seed ^ 0x9e3779b9u)) / 4294967295.0;
return vec2f(ox, oy) * period;
}

/// Domain-warped, **tileable** fBm of value noise. The lattice wraps at
/// `base_period` cells (and `base_period * 2^i` per octave, so every octave is
/// co-periodic), giving a field that is exactly seamless with period
Expand Down
37 changes: 37 additions & 0 deletions crates/darkly/src/brush/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::nodegraph::{

use super::curve_math::CurveLut;
use super::nodes::{brush_settings, clone_source, paint_color, pen_input};
use super::DAB_REFERENCE_SIZE;

use super::gpu_context::BrushGpuContext;
use super::wgsl::CompiledBrush;
Expand Down Expand Up @@ -470,6 +471,16 @@ pub struct BrushGraphRunner {
/// Pre-resolved slot index for paint_color's output. Same rationale
/// as `pen_input_slots` — avoid plan traversal on the hot path.
paint_color_slot: Option<usize>,
/// Pre-resolved slot index of `brush_settings.size`'s source output.
/// Seeded per dab in `seed_sensors` with the base size projected to
/// canvas pixels (`base_size * DAB_REFERENCE_SIZE` — the brush
/// *diameter*), so the graph signal is a pixel-domain quantity like
/// `pen_input.position`. This is why `brush_settings` is skipped in
/// `execute_cpu`'s generic settable-source republish (which would
/// otherwise overwrite it with the raw normalized knob value): the
/// projection lives here, at the one seeding point, keeping the CPU
/// slot and the GPU-packed dab field numerically identical.
brush_settings_size_slot: Option<usize>,
/// Node id of the (first) `clone_source` node, resolved at build.
/// When set alongside [`Self::clone_state`], `build_slot_outputs`
/// injects the stroke's anchor uniforms under this node's keys so the
Expand Down Expand Up @@ -606,6 +617,16 @@ impl BrushGraphRunner {
.and_then(|s| s.output_slots.iter().find(|(name, _)| name == "color"))
.map(|(_, slot)| *slot);

// Find brush_settings' `size` source output slot — seeded per dab in
// pixels (see the field doc). The `size` port is a settable-source, so
// the compiler assigns it an output slot even though it's an input.
let brush_settings_size_slot = plan
.steps
.iter()
.find(|s| s.type_id == brush_settings::TYPE_ID)
.and_then(|s| s.output_slots.iter().find(|(name, _)| name == "size"))
.map(|(_, slot)| *slot);

// Find the (first) clone_source node for anchor-uniform seeding.
let clone_source_node = plan
.steps
Expand Down Expand Up @@ -642,6 +663,7 @@ impl BrushGraphRunner {
node_data,
pen_input_slots,
paint_color_slot,
brush_settings_size_slot,
clone_source_node,
clone_state: None,
dab_size_slot,
Expand Down Expand Up @@ -786,6 +808,16 @@ impl BrushGraphRunner {
if let Some(slot) = self.paint_color_slot {
self.slots[slot] = Some(ScalarValue::Vec4(color));
}

// Seed the `brush_settings.size` graph signal in canvas pixels: the
// stroke's base size projected to the brush diameter. Downstream nodes
// (noise.scale, math) then operate in the same pixel domain as their
// sinks, so a `× 1` gain is a true no-op and feature size tracks the
// brush directly.
if let Some(slot) = self.brush_settings_size_slot {
let size_px = self.base_size * DAB_REFERENCE_SIZE as f32;
self.slots[slot] = Some(ScalarValue::Scalar(size_px));
}
}

/// Execute all CPU nodes in topological order.
Expand All @@ -802,8 +834,13 @@ impl BrushGraphRunner {
let step = &self.plan.steps[idx];
if step.type_id == pen_input::TYPE_ID
|| step.type_id == paint_color::TYPE_ID
|| step.type_id == brush_settings::TYPE_ID
|| step.is_gpu
{
// `brush_settings` is seeded bespoke in `seed_sensors` (its
// `size` source in pixels); its only other job is publishing
// that signal, so the generic republish below must not run and
// overwrite it with the raw normalized knob value.
continue;
}

Expand Down
Loading
Loading