moire: bokeh count and per-disc colour; fade between any two patterns

bd286fb · Gabriel Morell · 2026-09-29 21:11

13 files +125 -33
Message
{commit_body(@commit)}

Files changed

modified README.md
+5 −3
@@ -53,9 +53,11 @@ adds to its size.
53 53
54 54 `plasma-wallpaper-neiam-moire` is a Plasma wallpaper plugin ("neiam
55 55 moire" under *Wallpaper type*, for the desktop or the lock screen)
56 −that draws the moire live, with a halftone field, both, or fading
57 −between the two, or bokeh: soft out-of-focus discs in the accent and
58 −primary colours, drifting upward and fading in and out as they rise.
56 +that draws its patterns live: the moire, a halftone field, both, or
57 +bokeh (soft out-of-focus discs drifting upward and fading in and out
58 +as they rise, 4 to 64 of them, each its own shade from the primary
59 +towards the accent and from muted to vivid). It can also fade back and
60 +forth between any two of those every few minutes.
59 61 Everything drifts slowly and never visibly repeats.
60 62
61 63 Its colours come from the waxx themes (dark, light, her, afterdark,
modified moire/shaders/field.frag
+25 −11
@@ -30,6 +30,7 @@ layout(std140, binding = 0) uniform buf {
30 30 float angle; // halftone grid rotation, radians
31 31 float wave; // halftone wave phase, radians, kept in [0, 2pi)
32 32 float bokeh; // 0-1, how much of the bokeh to show
33 + float lights; // how many bokeh discs, up to MAX_DISCS
33 34 float drift; // bokeh phase, 0-1 over its whole cycle
34 35
35 36 vec4 base; // the ground
@@ -62,13 +63,23 @@ float hash(float n)
62 63 // multiple of drift, so when drift wraps from 1 back to 0 every disc
63 64 // is exactly where it started; each fades in rising from the bottom
64 65 // and out before the top, so none ever appears or vanishes abruptly.
65 −const int DISCS = 14;
66 +//
67 +// Every disc has its own colour: somewhere from the primary to the
68 +// accent, weighted towards the accent, and somewhere from muted to
69 +// vivid. Returns the discs' blended colour and their combined coverage.
70 +const int MAX_DISCS = 64;
66 71
67 −float bokehLight(vec2 p, float aspect, float px, out float tint)
72 +vec4 bokehLight(vec2 p, float aspect, float px)
68 73 {
74 + // More discs means smaller ones, so a crowd stays a scatter of
75 + // lights rather than a haze.
76 + float scale = clamp(pow(14.0 / max(lights, 1.0), 0.3), 0.6, 1.2);
77 +
78 + vec3 sum = vec3(0.0);
69 79 float total = 0.0;
70 − tint = 0.0;
71 − for (int i = 0; i < DISCS; i++) {
80 + for (int i = 0; i < MAX_DISCS; i++) {
81 + if (float(i) >= lights)
82 + break;
72 83 float n = float(i);
73 84 float climb = 1.0 + floor(hash(n + 1.0) * 3.0); // 1-3 laps a cycle
74 85 float sway = 1.0 + floor(hash(n + 2.0) * 2.0);
@@ -76,18 +87,22 @@ float bokehLight(vec2 p, float aspect, float px, out float tint)
76 87 vec2 c = vec2(hash(n + 4.0) * aspect
77 88 + 0.04 * sin(6.2831853 * (sway * drift + hash(n + 5.0))),
78 89 1.15 - 1.3 * life);
79 − float radius = 0.035 + 0.075 * hash(n + 6.0);
90 + float radius = (0.035 + 0.075 * hash(n + 6.0)) * scale;
80 91 float d = length(p - c);
81 92 float disc = clamp((radius - d) / (2.5 * px) + 0.5, 0.0, 1.0);
82 93 // A lens renders a defocused point with a slightly brighter rim.
83 94 float rim = smoothstep(radius * 0.7, radius, d) * disc;
84 95 float shape = disc * 0.65 + rim * 0.35;
85 96 float strength = shape * sin(3.1415927 * life) * (0.45 + 0.55 * hash(n + 7.0));
97 +
98 + vec3 colour = mix(tone.rgb, ink.rgb, sqrt(hash(n + 8.0)));
99 + float grey = dot(colour, vec3(0.2126, 0.7152, 0.0722));
100 + colour = clamp(mix(vec3(grey), colour, 0.3 + 0.9 * hash(n + 9.0)), 0.0, 1.0);
101 +
102 + sum += colour * strength;
86 103 total += strength;
87 − tint += strength * step(0.5, hash(n + 8.0));
88 104 }
89 − tint = total > 0.0 ? tint / total : 0.0;
90 − return min(total, 1.0);
105 + return vec4(total > 0.0 ? sum / total : vec3(0.0), min(total, 1.0));
91 106 }
92 107
93 108 void main()
@@ -101,9 +116,8 @@ void main()
101 116 vec3 colour = mix(base.rgb, deep.rgb, v);
102 117
103 118 if (bokeh > 0.0) {
104 − float tint;
105 − float light = bokehLight(p, size.x * px, px, tint);
106 − colour = mix(colour, mix(ink.rgb, tone.rgb, tint), light * 0.3 * bokeh);
119 + vec4 light = bokehLight(p, size.x * px, px);
120 + colour = mix(colour, light.rgb, light.a * 0.3 * bokeh);
107 121 }
108 122
109 123 // Halftone: dots on a rotated grid, each sized by the fade and a
modified moire/theme/contents/config/main.xml
+14 −1
@@ -7,9 +7,22 @@
7 7
8 8 <group name="General">
9 9 <entry name="Pattern" type="String">
10 − <label>moire, halftone, both, alternate, or bokeh</label>
10 + <label>moire, halftone, both, bokeh, or alternate</label>
11 11 <default>both</default>
12 12 </entry>
13 + <entry name="FadeFrom" type="String">
14 + <label>With Pattern alternate, the pattern faded from: moire, halftone, both, or bokeh</label>
15 + <default>moire</default>
16 + </entry>
17 + <entry name="FadeTo" type="String">
18 + <default>halftone</default>
19 + </entry>
20 + <entry name="BokehLights" type="Int">
21 + <label>How many bokeh discs</label>
22 + <default>14</default>
23 + <min>4</min>
24 + <max>64</max>
25 + </entry>
13 26 <entry name="Speed" type="Double">
14 27 <label>How fast the pattern drifts; 0 holds it still</label>
15 28 <default>1</default>
modified moire/theme/contents/ui/Field.qml
+29 −10
@@ -10,9 +10,13 @@ import QtQuick 2.15
10 10 Item {
11 11 id: field
12 12
13 − // "moire", "halftone", "both" (moire over halftone), "alternate"
14 − // (fading between those two every few minutes), or "bokeh".
13 + // "moire", "halftone", "both" (moire over halftone), "bokeh", or
14 + // "alternate": fading between fadeFrom and fadeTo, which are any of
15 + // the others, every few minutes.
15 16 property string pattern: "both"
17 + property string fadeFrom: "moire"
18 + property string fadeTo: "halftone"
19 + property int bokehLights: 14
16 20 // Multiplies how fast everything drifts; 0 holds the picture still.
17 21 property real speed: 1
18 22 property int frameRate: 30
@@ -36,6 +40,22 @@ Item {
36 40
37 41 readonly property real aspect: height > 0 ? width / height : 16 / 9
38 42
43 + // How much of each layer a pattern shows.
44 + function layers(name) {
45 + return {
46 + moire: name === "moire" || name === "both" ? 1 : 0,
47 + halftone: name === "halftone" || name === "both" ? 1 : 0,
48 + bokeh: name === "bokeh" ? 1 : 0,
49 + }
50 + }
51 +
52 + function amount(layer) {
53 + if (pattern !== "alternate")
54 + return layers(pattern)[layer]
55 + var t = 0.5 + 0.5 * sweep(360, 0)
56 + return layers(fadeFrom)[layer] * (1 - t) + layers(fadeTo)[layer] * t
57 + }
58 +
39 59 function sweep(period, phase) {
40 60 return Math.sin(2 * Math.PI * (clock / period + phase))
41 61 }
@@ -68,16 +88,15 @@ Item {
68 88 // screen three times in it.
69 89 property real drift: (field.clock / 600) % 1
70 90
71 − // Amounts ease so a pattern change fades rather than cuts.
72 − readonly property real alternate: 0.5 + 0.5 * field.sweep(360, 0)
73 − property real moire: field.pattern === "alternate" ? alternate
74 − : field.pattern === "moire" || field.pattern === "both" ? 1 : 0
75 − property real halftone: field.pattern === "alternate" ? 1 - alternate
76 − : field.pattern === "halftone" || field.pattern === "both" ? 1 : 0
77 − property real bokeh: field.pattern === "bokeh" ? 1 : 0
91 + // Amounts ease so a pattern change fades rather than cuts; while
92 + // alternating they are already continuous, so easing would lag.
93 + property real moire: field.amount("moire")
94 + property real halftone: field.amount("halftone")
95 + property real bokeh: field.amount("bokeh")
96 + property real lights: field.bokehLights
78 97 Behavior on moire { enabled: field.pattern !== "alternate"; NumberAnimation { duration: 1500 } }
79 98 Behavior on halftone { enabled: field.pattern !== "alternate"; NumberAnimation { duration: 1500 } }
80 − Behavior on bokeh { NumberAnimation { duration: 1500 } }
99 + Behavior on bokeh { enabled: field.pattern !== "alternate"; NumberAnimation { duration: 1500 } }
81 100
82 101 property color base: field.base
83 102 property color deep: field.deep
modified moire/theme/contents/ui/config.qml
+49 −8
@@ -12,6 +12,9 @@ Kirigami.FormLayout {
12 12 property alias formLayout: root
13 13
14 14 property string cfg_Pattern
15 + property string cfg_FadeFrom
16 + property string cfg_FadeTo
17 + property int cfg_BokehLights
15 18 property double cfg_Speed
16 19 property int cfg_FrameRate
17 20 property bool cfg_Wordmark
@@ -27,13 +30,29 @@ Kirigami.FormLayout {
27 30 property string cfg_SunriseTime
28 31 property string cfg_SunsetTime
29 32
30 − readonly property var patterns: [
33 + // The patterns a fade can go between; Pattern adds the fade itself.
34 + readonly property var layers: [
31 35 { value: "both", text: "Moire and halftone" },
32 36 { value: "moire", text: "Moire" },
33 37 { value: "halftone", text: "Halftone" },
34 − { value: "alternate", text: "Fade between moire and halftone" },
35 38 { value: "bokeh", text: "Bokeh" },
36 39 ]
40 + readonly property var patterns: layers.concat([{ value: "alternate", text: "Fade between two…" }])
41 +
42 + readonly property bool fading: cfg_Pattern === "alternate"
43 + readonly property bool showsBokeh: cfg_Pattern === "bokeh"
44 + || (fading && (cfg_FadeFrom === "bokeh" || cfg_FadeTo === "bokeh"))
45 +
46 + component PatternBox: QQC2.ComboBox {
47 + property var choices
48 + property string value
49 + signal picked(string value)
50 + implicitContentWidthPolicy: QQC2.ComboBox.WidestText
51 + model: choices
52 + textRole: "text"
53 + currentIndex: Math.max(0, choices.findIndex(p => p.value === value))
54 + onActivated: index => picked(choices[index].value)
55 + }
37 56
38 57 component ThemeBox: QQC2.ComboBox {
39 58 property string value
@@ -58,13 +77,35 @@ Kirigami.FormLayout {
58 77
59 78 // ------------------------------------------------------------ pattern
60 79
61 − QQC2.ComboBox {
80 + PatternBox {
62 81 Kirigami.FormData.label: "Pattern:"
63 − implicitContentWidthPolicy: QQC2.ComboBox.WidestText
64 − model: root.patterns
65 − textRole: "text"
66 − currentIndex: Math.max(0, root.patterns.findIndex(p => p.value === root.cfg_Pattern))
67 − onActivated: index => root.cfg_Pattern = root.patterns[index].value
82 + choices: root.patterns
83 + value: root.cfg_Pattern
84 + onPicked: v => root.cfg_Pattern = v
85 + }
86 +
87 + PatternBox {
88 + Kirigami.FormData.label: "From:"
89 + visible: root.fading
90 + choices: root.layers
91 + value: root.cfg_FadeFrom
92 + onPicked: v => root.cfg_FadeFrom = v
93 + }
94 +
95 + PatternBox {
96 + Kirigami.FormData.label: "To:"
97 + visible: root.fading
98 + choices: root.layers
99 + value: root.cfg_FadeTo
100 + onPicked: v => root.cfg_FadeTo = v
101 + }
102 +
103 + QQC2.SpinBox {
104 + Kirigami.FormData.label: "Bokeh lights:"
105 + visible: root.showsBokeh
106 + from: 4; to: 64
107 + value: root.cfg_BokehLights
108 + onValueModified: root.cfg_BokehLights = value
68 109 }
69 110
70 111 RowLayout {
modified ?
+0 −0

Binary file — not rendered.

modified moire/theme/contents/ui/main.qml
+3 −0
@@ -29,6 +29,9 @@ WallpaperItem {
29 29 Field {
30 30 anchors.fill: parent
31 31 pattern: root.cfg.Pattern
32 + fadeFrom: root.cfg.FadeFrom
33 + fadeTo: root.cfg.FadeTo
34 + bokehLights: root.cfg.BokehLights
32 35 speed: root.cfg.Speed
33 36 frameRate: root.cfg.FrameRate
34 37 wordmark: root.cfg.Wordmark

Parents: c3df760