neiam
/neiam-devices
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
moire: optional bokeh drifting behind the pattern
ca16122 · Gabriel Morell · 2026-09-29 20:55
Files changed
modified
README.md
+4
−0
@@ -65,6 +65,10 @@ latitude and longitude. Between phases it blends by the sun's
| 65 | 65 | elevation, turning the hue so the transitions pass through the purples |
| 66 | 66 | of a real twilight rather than grey. |
| 67 | 67 | |
| 68 | +Bokeh (off by default) adds soft out-of-focus discs in the accent and | |
| 69 | +primary colours, drifting upward behind the pattern and fading in and | |
| 70 | +out as they rise. | |
| 71 | + | |
| 68 | 72 | A checkbox writes "NEIAM" in the bottom right corner, in the theme's |
| 69 | 73 | accent colour, in B612 Mono Bold at 12% of the screen height; the package pulls |
| 70 | 74 | in the font (`fonts-b612` / `firstyear-b612-fonts`). |
modified
moire/shaders/field.frag
+51
−2
@@ -1,5 +1,6 @@
| 1 | −// The moire wallpaper's whole picture: ground, halftone dots and moire | |
| 2 | −// rings, each fading out into the ground away from a drifting hub. | |
| 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. | |
| 3 | 4 | // |
| 4 | 5 | // Everything that moves is computed in Field.qml, in double precision, |
| 5 | 6 | // and arrives here as positions and phases; the shader holds no clock, |
@@ -28,6 +29,8 @@ layout(std140, binding = 0) uniform buf {
| 28 | 29 | float cell; // halftone grid spacing |
| 29 | 30 | float angle; // halftone grid rotation, radians |
| 30 | 31 | float wave; // halftone wave phase, radians, kept in [0, 2pi) |
| 32 | + float bokeh; // 0-1, how much of the bokeh to show | |
| 33 | + float drift; // bokeh phase, 0-1 over its whole cycle | |
| 31 | 34 | |
| 32 | 35 | vec4 base; // the ground |
| 33 | 36 | vec4 deep; // the ground at the edges |
@@ -49,6 +52,45 @@ float rings(vec2 p, vec2 centre, float px)
| 49 | 52 | return clamp(1.0 - (d - halfWidth) / px, 0.0, 1.0); |
| 50 | 53 | } |
| 51 | 54 | |
| 55 | +// A repeatable pseudo-random number in [0, 1) per disc and purpose. | |
| 56 | +float hash(float n) | |
| 57 | +{ | |
| 58 | + return fract(sin(n * 127.1 + 311.7) * 43758.5453); | |
| 59 | +} | |
| 60 | + | |
| 61 | +// Out-of-focus lights drifting upward behind the pattern, as if the | |
| 62 | +// moire were in focus in front of them. Each disc moves at a whole | |
| 63 | +// multiple of drift, so when drift wraps from 1 back to 0 every disc | |
| 64 | +// is exactly where it started; each fades in rising from the bottom | |
| 65 | +// and out before the top, so none ever appears or vanishes abruptly. | |
| 66 | +const int DISCS = 14; | |
| 67 | + | |
| 68 | +float bokehLight(vec2 p, float aspect, float px, out float tint) | |
| 69 | +{ | |
| 70 | + float total = 0.0; | |
| 71 | + tint = 0.0; | |
| 72 | + for (int i = 0; i < DISCS; i++) { | |
| 73 | + float n = float(i); | |
| 74 | + float climb = 1.0 + floor(hash(n + 1.0) * 3.0); // 1-3 laps a cycle | |
| 75 | + float sway = 1.0 + floor(hash(n + 2.0) * 2.0); | |
| 76 | + float life = fract(hash(n + 3.0) + climb * drift); // 0 at bottom, 1 at top | |
| 77 | + vec2 c = vec2(hash(n + 4.0) * aspect | |
| 78 | + + 0.04 * sin(6.2831853 * (sway * drift + hash(n + 5.0))), | |
| 79 | + 1.15 - 1.3 * life); | |
| 80 | + float radius = 0.035 + 0.075 * hash(n + 6.0); | |
| 81 | + float d = length(p - c); | |
| 82 | + float disc = clamp((radius - d) / (2.5 * px) + 0.5, 0.0, 1.0); | |
| 83 | + // A lens renders a defocused point with a slightly brighter rim. | |
| 84 | + float rim = smoothstep(radius * 0.7, radius, d) * disc; | |
| 85 | + float shape = disc * 0.65 + rim * 0.35; | |
| 86 | + float strength = shape * sin(3.1415927 * life) * (0.45 + 0.55 * hash(n + 7.0)); | |
| 87 | + total += strength; | |
| 88 | + tint += strength * step(0.5, hash(n + 8.0)); | |
| 89 | + } | |
| 90 | + tint = total > 0.0 ? tint / total : 0.0; | |
| 91 | + return min(total, 1.0); | |
| 92 | +} | |
| 93 | + | |
| 52 | 94 | void main() |
| 53 | 95 | { |
| 54 | 96 | float px = 1.0 / size.y; |
@@ -59,6 +101,13 @@ void main()
| 59 | 101 | float v = smoothstep(0.35, 1.25, length(p - centre)); |
| 60 | 102 | vec3 colour = mix(base.rgb, deep.rgb, v); |
| 61 | 103 | |
| 104 | + // Bokeh sits behind everything else: it is the out-of-focus scene. | |
| 105 | + if (bokeh > 0.0) { | |
| 106 | + float tint; | |
| 107 | + float light = bokehLight(p, size.x * px, px, tint); | |
| 108 | + colour = mix(colour, mix(ink.rgb, tone.rgb, tint), light * 0.14 * bokeh); | |
| 109 | + } | |
| 110 | + | |
| 62 | 111 | // Halftone: dots on a rotated grid, each sized by the fade and a |
| 63 | 112 | // travelling wave sampled at its cell's centre, so a dot is round |
| 64 | 113 | // and uniform rather than cut by the field. Area, not radius, is |
modified
moire/theme/contents/config/main.xml
+4
−0
@@ -18,6 +18,10 @@
| 18 | 18 | <label>Frames per second while animating</label> |
| 19 | 19 | <default>30</default> |
| 20 | 20 | </entry> |
| 21 | + <entry name="Bokeh" type="Bool"> | |
| 22 | + <label>Soft out-of-focus lights drifting up behind the pattern</label> | |
| 23 | + <default>false</default> | |
| 24 | + </entry> | |
| 21 | 25 | <entry name="Wordmark" type="Bool"> |
| 22 | 26 | <label>Write NEIAM in the bottom right corner</label> |
| 23 | 27 | <default>false</default> |
modified
moire/theme/contents/ui/Field.qml
+8
−0
@@ -18,6 +18,8 @@ Item {
| 18 | 18 | property int frameRate: 30 |
| 19 | 19 | // "NEIAM" in the bottom right, in the ink colour. |
| 20 | 20 | property bool wordmark: false |
| 21 | + // Soft out-of-focus discs drifting up behind the pattern. | |
| 22 | + property bool bokeh: false | |
| 21 | 23 | |
| 22 | 24 | property color base: "#661e1e" |
| 23 | 25 | property color deep: "#470005" |
@@ -64,6 +66,12 @@ Item {
| 64 | 66 | property real angle: 0.26 + 0.03 * field.sweep(211, 0.2) |
| 65 | 67 | property real wave: (2 * Math.PI * field.clock / 16) % (2 * Math.PI) |
| 66 | 68 | |
| 69 | + // One bokeh cycle is ten minutes; the fastest disc climbs the | |
| 70 | + // screen three times in it. | |
| 71 | + property real drift: (field.clock / 600) % 1 | |
| 72 | + property real bokeh: field.bokeh ? 1 : 0 | |
| 73 | + Behavior on bokeh { NumberAnimation { duration: 1500 } } | |
| 74 | + | |
| 67 | 75 | // Amounts ease so a pattern change fades rather than cuts. |
| 68 | 76 | readonly property real alternate: 0.5 + 0.5 * field.sweep(360, 0) |
| 69 | 77 | property real moire: field.pattern === "halftone" ? 0 |
modified
moire/theme/contents/ui/config.qml
+8
−0
@@ -15,6 +15,7 @@ Kirigami.FormLayout {
| 15 | 15 | property double cfg_Speed |
| 16 | 16 | property int cfg_FrameRate |
| 17 | 17 | property bool cfg_Wordmark |
| 18 | + property bool cfg_Bokeh | |
| 18 | 19 | property string cfg_ThemeMode |
| 19 | 20 | property string cfg_Theme |
| 20 | 21 | property string cfg_NightTheme |
@@ -85,6 +86,13 @@ Kirigami.FormLayout {
| 85 | 86 | enabled: root.cfg_Speed > 0 |
| 86 | 87 | } |
| 87 | 88 | |
| 89 | + QQC2.CheckBox { | |
| 90 | + Kirigami.FormData.label: "Bokeh:" | |
| 91 | + text: "Drifting out-of-focus lights" | |
| 92 | + checked: root.cfg_Bokeh | |
| 93 | + onToggled: root.cfg_Bokeh = checked | |
| 94 | + } | |
| 95 | + | |
| 88 | 96 | QQC2.CheckBox { |
| 89 | 97 | Kirigami.FormData.label: "Wordmark:" |
| 90 | 98 | text: "Write NEIAM in the corner" |
modified
?
+0
−0
Binary file — not rendered.
modified
moire/theme/contents/ui/main.qml
+1
−0
@@ -32,6 +32,7 @@ WallpaperItem {
| 32 | 32 | speed: root.cfg.Speed |
| 33 | 33 | frameRate: root.cfg.FrameRate |
| 34 | 34 | wordmark: root.cfg.Wordmark |
| 35 | + bokeh: root.cfg.Bokeh | |
| 35 | 36 | base: root.scheme.base |
| 36 | 37 | deep: root.scheme.deep |
| 37 | 38 | ink: root.scheme.ink |
Parents: eb8ebcc