moire: stars, glints, breathing, film grain and dither effects

cced89e · Gabriel Morell · 2026-09-29 21:28

13 files +172 -12
Message
{commit_body(@commit)}

Files changed

modified README.md
+7 −3
@@ -57,9 +57,13 @@ that draws its patterns live: the moire, a halftone field, both, or
57 57 bokeh (soft out-of-focus discs drifting upward and fading in and out
58 58 as they rise, 4 to 64 of them, each its own shade from the primary
59 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. Optionally, with any
61 −pattern, a few large defocused discs drift past in front now and then,
62 −like a light passing close to the lens.
60 +forth between any two of those every few minutes. Effects, each a checkbox and usable with any pattern: twinkling stars
61 +behind it; a glint, a band of light sweeping across the rings and dots
62 +every minute and a half; breathing, the pattern slowly swelling and
63 +easing; a few large defocused discs drifting past in front now and
64 +then, like a light close to the lens; film grain; and dither (on by
65 +default), which keeps the slow dark gradients from banding on 8-bit
66 +displays.
63 67 Everything drifts slowly and never visibly repeats.
64 68
65 69 Its colours come from the waxx themes (dark, light, her, afterdark,
modified moire/shaders/field.frag
+79 −5
@@ -1,6 +1,7 @@
1 −// The moire wallpaper's whole picture: ground, bokeh, halftone dots and
2 −// moire rings, the patterns fading out into the ground away from a
3 −// drifting hub.
1 +// The moire wallpaper's whole picture, back to front: ground, stars,
2 +// bokeh, halftone dots, moire rings (the patterns fading out into the
3 +// ground away from a drifting hub), foreground discs, then grain and
4 +// dither over the finished pixel.
4 5 //
5 6 // Everything that moves is computed in Field.qml, in double precision,
6 7 // and arrives here as positions and phases; the shader holds no clock,
@@ -33,6 +34,14 @@ layout(std140, binding = 0) uniform buf {
33 34 float lights; // how many bokeh discs, up to MAX_DISCS
34 35 float flare; // 0-1, how much of the large foreground discs to show
35 36 float drift; // bokeh phase, 0-1 over its whole cycle
37 + float stars; // 0-1, how much of the starfield to show
38 + float twinkle; // star phase, 0-1 over its whole cycle
39 + float glints; // 0-1, how much light the glint catches
40 + float glintPhase; // 0-1 over one glint cycle; the sweep is its first 30%
41 + float breath; // multiplies the patterns' strength, near 1
42 + float grain; // 0-1, film grain
43 + float dither; // 0-1, dither
44 + float seed; // 0-1, changes each frame to move the grain
36 45
37 46 vec4 base; // the ground
38 47 vec4 deep; // the ground at the edges
@@ -60,6 +69,53 @@ float hash(float n)
60 69 return fract(sin(n * 127.1 + 311.7) * 43758.5453);
61 70 }
62 71
72 +// Per-pixel noise without sin() or integers: GLSL ES 100, one of the
73 +// targets, has no unsigned integers, and sin() of a large pixel
74 +// coordinate loses precision on some GPUs. (Dave Hoskins' hash12.)
75 +float hash12(vec2 p)
76 +{
77 + vec3 p3 = fract(vec3(p.xyx) * 0.1031);
78 + p3 += dot(p3, p3.yzx + 33.33);
79 + return fract((p3.x + p3.y) * p3.z);
80 +}
81 +
82 +// Stars on a jittered grid, most faint and a few bright, each
83 +// twinkling at a whole multiple of the twinkle phase so the wrap is
84 +// seamless. A star sits well inside its cell, so only one cell needs
85 +// checking per pixel.
86 +float starLight(vec2 p, float px)
87 +{
88 + const float spacing = 0.035;
89 + vec2 id = floor(p / spacing);
90 + if (hash12(id) > 0.4)
91 + return 0.0; // 40% of cells hold a star
92 + vec2 at = (id + 0.15 + 0.7 * vec2(hash12(id + 17.0), hash12(id + 31.0))) * spacing;
93 + float bright = hash12(id + 47.0);
94 + bright *= bright;
95 + float rate = 1.0 + floor(hash12(id + 59.0) * 4.0);
96 + float shimmer = 0.55 + 0.45 * sin(6.2831853 * (rate * twinkle + hash12(id + 71.0)));
97 + float r = px * (0.8 + 1.4 * bright);
98 + float d = length(p - at);
99 + return exp(-d * d / (r * r)) * (0.25 + 0.75 * bright) * shimmer;
100 +}
101 +
102 +// A band of light sweeping diagonally across the screen during the
103 +// first 30% of each glint cycle. It lights only the rings and dots, as
104 +// light catches fine grooves, and is zero between sweeps.
105 +float glintBand(vec2 p, float aspect)
106 +{
107 + if (glintPhase >= 0.3)
108 + return 0.0;
109 + float s = glintPhase / 0.3;
110 + vec2 dir = vec2(0.8, -0.6);
111 + float c0 = 0.0, c1 = aspect * dir.x, c2 = dir.y, c3 = aspect * dir.x + dir.y;
112 + float lo = min(min(c0, c1), min(c2, c3));
113 + float hi = max(max(c0, c1), max(c2, c3));
114 + const float width = 0.08;
115 + float x = (dot(p, dir) - mix(lo - 3.0 * width, hi + 3.0 * width, s)) / width;
116 + return exp(-x * x) * sin(3.1415927 * s);
117 +}
118 +
63 119 // Out-of-focus lights drifting upward. Each disc moves at a whole
64 120 // multiple of drift, so when drift wraps from 1 back to 0 every disc
65 121 // is exactly where it started; each fades in rising from the bottom
@@ -153,6 +209,11 @@ void main()
153 209 float v = smoothstep(0.35, 1.25, length(p - centre));
154 210 vec3 colour = mix(base.rgb, deep.rgb, v);
155 211
212 + if (stars > 0.0)
213 + colour = mix(colour, ink.rgb, starLight(p, px) * 0.85 * stars);
214 +
215 + float glint = glints > 0.0 ? glintBand(p, size.x * px) * glints : 0.0;
216 +
156 217 if (bokeh > 0.0) {
157 218 vec4 light = bokehLight(p, size.x * px, px);
158 219 colour = mix(colour, light.rgb, light.a * 0.3 * bokeh);
@@ -172,7 +233,8 @@ void main()
172 233 * (0.55 + 0.45 * sin(dot(cellCentre, vec2(0.8, 0.6)) * 9.0 - wave));
173 234 float radius = 0.5 * cell * sqrt(field);
174 235 float dotCover = clamp((radius - length(local)) / px + 0.5, 0.0, 1.0);
175 − colour = mix(colour, tone.rgb, dotCover * halftone * 0.55);
236 + colour = mix(colour, tone.rgb, dotCover * halftone * breath * 0.55);
237 + colour = mix(colour, ink.rgb, dotCover * halftone * glint * 0.5);
176 238 }
177 239
178 240 // Moire: two ring families slightly apart. Where their rings
@@ -182,7 +244,11 @@ void main()
182 244 float a = rings(p, ringA, px);
183 245 float b = rings(p, ringB, px);
184 246 float light = 1.0 - (1.0 - 0.85 * a) * (1.0 - 0.85 * b);
185 − colour = mix(colour, ink.rgb, light * 0.6 * fade(p) * moire);
247 + float f = fade(p);
248 + colour = mix(colour, ink.rgb, light * 0.6 * f * moire * breath);
249 + // The glint reaches a little past the fade, catching rings the
250 + // eye had lost.
251 + colour = mix(colour, mix(ink.rgb, vec3(1.0), 0.3), light * glint * moire * (0.4 + 0.6 * f) * 0.55);
186 252 }
187 253
188 254 // In front of everything, so drawn last.
@@ -191,5 +257,13 @@ void main()
191 257 colour = mix(colour, light.rgb, light.a * 0.11 * flare);
192 258 }
193 259
260 + vec2 pixel = qt_TexCoord0 * size;
261 + if (grain > 0.0)
262 + colour += (hash12(pixel + seed * 1000.0) - 0.5) * 0.06 * grain;
263 + // Triangular noise of about one 8-bit step: invisible as noise, but
264 + // it breaks the slow gradients' banding into something smooth.
265 + if (dither > 0.0)
266 + colour += (hash12(pixel + 0.5) + hash12(pixel + 91.3) - 1.0) / 255.0 * dither;
267 +
194 268 fragColor = vec4(colour, 1.0) * qt_Opacity;
195 269 }
modified moire/theme/contents/config/main.xml
+20 −0
@@ -35,6 +35,26 @@
35 35 <label>Now and then, large defocused lights drifting past in front</label>
36 36 <default>false</default>
37 37 </entry>
38 + <entry name="Stars" type="Bool">
39 + <label>Twinkling stars behind the pattern</label>
40 + <default>false</default>
41 + </entry>
42 + <entry name="Glints" type="Bool">
43 + <label>A band of light sweeping across the rings and dots now and then</label>
44 + <default>false</default>
45 + </entry>
46 + <entry name="Breathing" type="Bool">
47 + <label>The pattern slowly swelling and easing</label>
48 + <default>false</default>
49 + </entry>
50 + <entry name="Grain" type="Bool">
51 + <label>Film grain</label>
52 + <default>false</default>
53 + </entry>
54 + <entry name="Dither" type="Bool">
55 + <label>Dither, to keep slow gradients from banding</label>
56 + <default>true</default>
57 + </entry>
38 58 <entry name="Wordmark" type="Bool">
39 59 <label>Write NEIAM in the bottom right corner</label>
40 60 <default>false</default>
modified moire/theme/contents/ui/Field.qml
+29 −2
@@ -19,6 +19,14 @@ Item {
19 19 property int bokehLights: 14
20 20 // Now and then, a few large defocused discs drifting past in front.
21 21 property bool foreground: false
22 + property bool stars: false
23 + property bool glints: false
24 + property bool breathing: false
25 + property bool grain: false
26 + property bool dither: true
27 +
28 + // Counts frames, to move the grain.
29 + property int frame: 0
22 30 // Multiplies how fast everything drifts; 0 holds the picture still.
23 31 property real speed: 1
24 32 property int frameRate: 30
@@ -79,7 +87,12 @@ Item {
79 87 hub.y - spread * Math.sin(turn) / 2)
80 88 property point ringB: Qt.point(hub.x + spread * Math.cos(turn) / 2,
81 89 hub.y + spread * Math.sin(turn) / 2)
82 − property real reach: 0.95 + 0.06 * field.sweep(43, 0.7)
90 + // Breathing swells the pattern's reach and strength together over
91 + // sixteen seconds, eased in and out by breathe.
92 + property real breathe: field.breathing ? 1 : 0
93 + Behavior on breathe { NumberAnimation { duration: 1500 } }
94 + property real breath: 1 - 0.2 * breathe * (0.5 - 0.5 * field.sweep(16, 0))
95 + property real reach: 0.95 + 0.06 * field.sweep(43, 0.7) + 0.08 * breathe * field.sweep(16, 0)
83 96 property real period: 1 / 110
84 97
85 98 property real cell: 1 / 70
@@ -98,6 +111,17 @@ Item {
98 111 property real lights: field.bokehLights
99 112 property real flare: field.foreground ? 1 : 0
100 113 Behavior on flare { NumberAnimation { duration: 1500 } }
114 +
115 + property real stars: field.stars ? 1 : 0
116 + property real twinkle: (field.clock / 12) % 1
117 + property real glints: field.glints ? 1 : 0
118 + property real glintPhase: (field.clock / 90) % 1
119 + property real grain: field.grain ? 1 : 0
120 + property real dither: field.dither ? 1 : 0
121 + property real seed: (field.frame * 0.618034) % 1
122 + Behavior on stars { NumberAnimation { duration: 1500 } }
123 + Behavior on glints { NumberAnimation { duration: 1500 } }
124 + Behavior on grain { NumberAnimation { duration: 1500 } }
101 125 Behavior on moire { enabled: field.pattern !== "alternate"; NumberAnimation { duration: 1500 } }
102 126 Behavior on halftone { enabled: field.pattern !== "alternate"; NumberAnimation { duration: 1500 } }
103 127 Behavior on bokeh { enabled: field.pattern !== "alternate"; NumberAnimation { duration: 1500 } }
@@ -125,6 +149,9 @@ Item {
125 149 running: field.speed > 0 && field.visible
126 150 repeat: true
127 151 interval: Math.round(1000 / Math.max(1, field.frameRate))
128 − onTriggered: field.clock += field.speed * interval / 1000
152 + onTriggered: {
153 + field.clock += field.speed * interval / 1000
154 + field.frame = (field.frame + 1) % 100000
155 + }
129 156 }
130 157 }
modified moire/theme/contents/ui/config.qml
+32 −2
@@ -19,6 +19,11 @@ Kirigami.FormLayout {
19 19 property int cfg_FrameRate
20 20 property bool cfg_Wordmark
21 21 property bool cfg_ForegroundBokeh
22 + property bool cfg_Stars
23 + property bool cfg_Glints
24 + property bool cfg_Breathing
25 + property bool cfg_Grain
26 + property bool cfg_Dither
22 27 property string cfg_ThemeMode
23 28 property string cfg_Theme
24 29 property string cfg_NightTheme
@@ -129,11 +134,36 @@ Kirigami.FormLayout {
129 134 }
130 135
131 136 QQC2.CheckBox {
132 − Kirigami.FormData.label: "Foreground:"
133 − text: "Large lights drifting past now and then"
137 + Kirigami.FormData.label: "Effects:"
138 + text: "Twinkling stars"
139 + checked: root.cfg_Stars
140 + onToggled: root.cfg_Stars = checked
141 + }
142 + QQC2.CheckBox {
143 + text: "Light glinting across the pattern"
144 + checked: root.cfg_Glints
145 + onToggled: root.cfg_Glints = checked
146 + }
147 + QQC2.CheckBox {
148 + text: "Breathing"
149 + checked: root.cfg_Breathing
150 + onToggled: root.cfg_Breathing = checked
151 + }
152 + QQC2.CheckBox {
153 + text: "Large lights drifting past in front"
134 154 checked: root.cfg_ForegroundBokeh
135 155 onToggled: root.cfg_ForegroundBokeh = checked
136 156 }
157 + QQC2.CheckBox {
158 + text: "Film grain"
159 + checked: root.cfg_Grain
160 + onToggled: root.cfg_Grain = checked
161 + }
162 + QQC2.CheckBox {
163 + text: "Dither (smooths banding in gradients)"
164 + checked: root.cfg_Dither
165 + onToggled: root.cfg_Dither = checked
166 + }
137 167
138 168 QQC2.CheckBox {
139 169 Kirigami.FormData.label: "Wordmark:"
modified ?
+0 −0

Binary file — not rendered.

modified moire/theme/contents/ui/main.qml
+5 −0
@@ -33,6 +33,11 @@ WallpaperItem {
33 33 fadeTo: root.cfg.FadeTo
34 34 bokehLights: root.cfg.BokehLights
35 35 foreground: root.cfg.ForegroundBokeh
36 + stars: root.cfg.Stars
37 + glints: root.cfg.Glints
38 + breathing: root.cfg.Breathing
39 + grain: root.cfg.Grain
40 + dither: root.cfg.Dither
36 41 speed: root.cfg.Speed
37 42 frameRate: root.cfg.FrameRate
38 43 wordmark: root.cfg.Wordmark

Parents: 8f734ab