neiam
/neiam-devices
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
moire: stars, glints, breathing, film grain and dither effects
cced89e · Gabriel Morell · 2026-09-29 21:28
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