<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Andras Schmelczer</title><description>A decade of building the whole thing: 8-bit firmware, browser ray tracers, Rust libraries on three registries, and web products that stay up for years.</description><link>https://schmelczer.dev/</link><language>en-gb</language><lastBuildDate>Tue, 11 Aug 2026 06:24:07 GMT</lastBuildDate><atom:link href="https://schmelczer.dev/rss.xml" rel="self" type="application/rss+xml"/><image><url>https://schmelczer.dev/_astro/og-default.-s_RGf-d_Z1BMaIt.jpg</url><title>Andras Schmelczer</title><link>https://schmelczer.dev</link></image><item><title>Turning Linear Search into the Most Performant Data Processing Engine</title><link>https://schmelczer.dev/articles/perfect-postcode/</link><guid isPermaLink="true">https://schmelczer.dev/articles/perfect-postcode/</guid><description>Apply 87 filters to 25 million records under 100 ms to help inform your biggest decision.</description><pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-pitch&quot;&gt;The pitch&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to The pitch&quot; href=&quot;#the-pitch&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;There’s an immense amount of publicly available, highly granular data on the UK, especially England and Wales: the &lt;a href=&quot;https://www.gov.uk/guidance/about-the-price-paid-data&quot;&gt;price of every property transaction&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;, &lt;a href=&quot;https://epc.opendatacommunities.org/&quot;&gt;floor areas and construction years&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;, all with full addresses; &lt;a href=&quot;https://data.police.uk/&quot;&gt;street-level crime&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot; aria-label=&quot;Footnote 3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;; &lt;a href=&quot;https://environment.data.gov.uk/dataset/562c9d56-7c2d-4d42-83bb-578d6e97a517&quot;&gt;noise levels at a 10m by 10m granularity&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot; aria-label=&quot;Footnote 4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;; the &lt;a href=&quot;https://www.bus-data.dft.gov.uk/&quot;&gt;full schedule of every public transport option&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot; aria-label=&quot;Footnote 5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;; the &lt;a href=&quot;https://www.forestresearch.gov.uk/tools-and-resources/national-forest-inventory/&quot;&gt;location of all trees and woodlands&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-6&quot; id=&quot;fnref-6&quot; aria-label=&quot;Footnote 6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt;; conservation areas, council houses, &lt;a href=&quot;https://opendata-historicengland.hub.arcgis.com/&quot;&gt;listed buildings&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-7&quot; id=&quot;fnref-7&quot; aria-label=&quot;Footnote 7&quot;&gt;7&lt;/a&gt;&lt;/sup&gt;, &lt;a href=&quot;https://get-information-schools.service.gov.uk/&quot;&gt;schools&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-8&quot; id=&quot;fnref-8&quot; aria-label=&quot;Footnote 8&quot;&gt;8&lt;/a&gt;&lt;/sup&gt;; and &lt;a href=&quot;https://www.ons.gov.uk/census&quot;&gt;detailed demographics&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-9&quot; id=&quot;fnref-9&quot; aria-label=&quot;Footnote 9&quot;&gt;9&lt;/a&gt;&lt;/sup&gt; on education, renting, and so on. When I was looking to buy property, these came in really handy for deciding where to move and knowing what to expect.&lt;/p&gt;
&lt;p&gt;There are numerous websites, like &lt;a href=&quot;https://www.streetcheck.co.uk&quot;&gt;StreetCheck&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-10&quot; id=&quot;fnref-10&quot; aria-label=&quot;Footnote 10&quot;&gt;10&lt;/a&gt;&lt;/sup&gt; or &lt;a href=&quot;https://crystalroof.co.uk&quot;&gt;CrystalRoof&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-11&quot; id=&quot;fnref-11&quot; aria-label=&quot;Footnote 11&quot;&gt;11&lt;/a&gt;&lt;/sup&gt;, which give you some of this data for a given postcode. My problem was that I only moved to London four years ago, and London has way too many areas for me to have the full picture of all its neighbourhoods. So I wanted to flip the lookup: instead of going from a postcode to its attributes, I wanted to go from the description of my ideal area to the postcode. That’s why I created &lt;a href=&quot;https://perfect-postcode.co.uk&quot;&gt;perfect-postcode.co.uk&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-12&quot; id=&quot;fnref-12&quot; aria-label=&quot;Footnote 12&quot;&gt;12&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;Perfect Postcode has found me multiple hidden-gem areas that I’d never even heard of, then fell in love with when visiting in person. It became an easy way to leave the comfort zone of well-known places and get a holistic picture of London instead. On top of this, the site gave me a reality check on what my expectations would cost, and how the equation changes with a compromise or two.&lt;/p&gt;
&lt;p&gt;In short, users can filter properties by setting a minimum and a maximum for each attribute. For instance, I can say: only show me properties at most a 10-minute walk from a station, with a noise level of less than 56 dB, and 85 more filters like these. The result is a heatmap of which areas have the most matching properties (based on past sales), suggesting where the criteria are most likely to be satisfied. From there, the next step is to narrow the search to those areas, contact estate agents, get access to off-market properties, and keep an eye on Rightmove and Zoopla.&lt;/p&gt;
&lt;h2 id=&quot;rough-architecture&quot;&gt;Rough architecture&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Rough architecture&quot; href=&quot;#rough-architecture&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The filtering happens on a per-property level: all the public data is projected onto the 25M or so properties, and the results are shown as a heatmap of H3 hexagons of varying granularity, then as postcode boundaries at the highest zoom.&lt;/p&gt;
&lt;p&gt;Given the spec, it’s easy to come up with a simple architecture: download all the data, normalise, join, add a filtering UI, and write a backend that applies the filters and returns the matching areas. But then we have to consider one big non-functional requirement: the filtering has to be snappy, so that it’s easy to get an intuitive sense of the cause and effect of changing filters.&lt;/p&gt;
&lt;p&gt;The backend has to filter the 25M-row dataset for every query. Given that this is actually a fairly small number, and it’s not expected to grow exponentially over time, I figured we could just keep the full dataframe in memory and see how far brute-force linear search takes us. Effective brute-forcing requires some preparation. So most attributes are quantised to 16 bits, as things like the number of crimes committed or the number of rooms don’t justify anything larger. There’s only a single index: a spatial grid for pruning all but the addresses roughly overlapping the user’s viewport. The data is stored in a row-major format, as queries often filter on a few dozen attributes at once. And the rows are sorted by their spatial position, so the linear search touches contiguous chunks. This makes the scanning incredibly cache-friendly, especially with each chunk iterated over in parallel.&lt;/p&gt;
&lt;p&gt;The actual evaluation logic is as simple as:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;rs&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;let&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; base &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; row &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; num_features;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;filters&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;iter&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;all&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    let&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; raw &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; feature_data[base &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; f&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;feat_idx];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    raw &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; NAN_U16&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; raw &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; f&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;min_&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;u16&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; raw &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; f&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;max_&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;u16&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;})&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the end, it takes around 12 GB of memory to run the server, which is very much justified by the P99 latency it enables: less than 100 ms.&lt;/p&gt;
&lt;p&gt;Still, 100 ms plus network latency is too long for proper real-time feedback on the UI. Fortunately, the filters always apply in an &lt;code&gt;AND&lt;/code&gt; fashion, and users can only adjust one at a time by changing an attribute’s min/max bounds. This leaves room for a simple trick to make the map more responsive: when a user starts adjusting a filter, the frontend requests that attribute’s min/max values for every visible hexagon or postcode. From then on, the colour coding and filtering can happen on the frontend, without a round trip. You can see both the backend and frontend optimisations in action here:&lt;/p&gt;
&lt;video autoplay loop muted playsinline controls width=&quot;1792&quot; height=&quot;922&quot; aria-label=&quot;Filtering London by maximum public transport travel time from Bank station: the map of matching areas redraws as the slider moves, then a travel time heatmap and one area&amp;#x27;s journey times, street view, and price history&quot;&gt;
  &lt;source src=&quot;https://schmelczer.dev/media/video/travel-times.webm&quot; type=&quot;video/webm&quot;&gt;
  &lt;source src=&quot;https://schmelczer.dev/media/video/travel-times.mp4&quot; type=&quot;video/mp4&quot;&gt;
&lt;/video&gt;
&lt;p&gt;Of course, there’s a bit more to it, especially around the coarser H3 hexagons: they’re the most expensive to filter on, so they get cached. A lot more data is kept outside the hot path too, such as the history of individual properties, or the travel times: those are pre-rendered for every combination of postcode and destination under different constraints, complete with the full itinerary. But the short version really is that brute force can sometimes be the best solution when applied in a smart way.&lt;/p&gt;
&lt;h2 id=&quot;derived-data&quot;&gt;Derived data&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Derived data&quot; href=&quot;#derived-data&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Besides the open data, the data pipelines powering the app create interesting derived values, like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tree canopy density percentile: based on the dataset of all trees and woodlands within the UK&lt;/li&gt;
&lt;li&gt;Price growth percentile: to help understand the expanding and shrinking parts of the market&lt;/li&gt;
&lt;li&gt;Public transport travel time: calculated for every single station and neighbourhood using &lt;a href=&quot;https://conveyal.com&quot;&gt;R5&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-13&quot; id=&quot;fnref-13&quot; aria-label=&quot;Footnote 13&quot;&gt;13&lt;/a&gt;&lt;/sup&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next?&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to What’s next?&quot; href=&quot;#whats-next&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Going to viewings and scouting areas in person for six months gave me the perfect feedback loop: whenever the numbers and my impression of an area differed, a feature was missing. Now that the product has been perfected for me, it’s time to iterate on user feedback.&lt;/p&gt;
&lt;p&gt;Originally, the app’s main target audience was my partner and me. However, as we added more features, it became apparent that there’s no similar offering on the market, and others could get value out of it too. That’s why I’ve started focusing on Perfect Postcode’s user experience: to appeal to users, it needs convenient features, like saving and sharing filters, explanations, and an actually usable mobile experience, plus a payment flow and some marketing.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;price of every property transaction: &lt;a href=&quot;https://www.gov.uk/guidance/about-the-price-paid-data&quot;&gt;https://www.gov.uk/guidance/about-the-price-paid-data&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;floor areas and construction years: &lt;a href=&quot;https://epc.opendatacommunities.org/&quot;&gt;https://epc.opendatacommunities.org/&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;street-level crime: &lt;a href=&quot;https://data.police.uk/&quot;&gt;https://data.police.uk/&lt;/a&gt;&lt;a href=&quot;#fnref-3&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 3 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;noise levels at a 10m by 10m granularity: &lt;a href=&quot;https://environment.data.gov.uk/dataset/562c9d56-7c2d-4d42-83bb-578d6e97a517&quot;&gt;https://environment.data.gov.uk/dataset/562c9d56-7c2d-4d42-83bb-578d6e97a517&lt;/a&gt;&lt;a href=&quot;#fnref-4&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 4 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;full schedule of every public transport option: &lt;a href=&quot;https://www.bus-data.dft.gov.uk/&quot;&gt;https://www.bus-data.dft.gov.uk/&lt;/a&gt;&lt;a href=&quot;#fnref-5&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 5 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-6&quot;&gt;location of all trees and woodlands: &lt;a href=&quot;https://www.forestresearch.gov.uk/tools-and-resources/national-forest-inventory/&quot;&gt;https://www.forestresearch.gov.uk/tools-and-resources/national-forest-inventory/&lt;/a&gt;&lt;a href=&quot;#fnref-6&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 6 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-7&quot;&gt;listed buildings: &lt;a href=&quot;https://opendata-historicengland.hub.arcgis.com/&quot;&gt;https://opendata-historicengland.hub.arcgis.com/&lt;/a&gt;&lt;a href=&quot;#fnref-7&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 7 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-8&quot;&gt;schools: &lt;a href=&quot;https://get-information-schools.service.gov.uk/&quot;&gt;https://get-information-schools.service.gov.uk/&lt;/a&gt;&lt;a href=&quot;#fnref-8&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 8 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-9&quot;&gt;detailed demographics: &lt;a href=&quot;https://www.ons.gov.uk/census&quot;&gt;https://www.ons.gov.uk/census&lt;/a&gt;&lt;a href=&quot;#fnref-9&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 9 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-10&quot;&gt;StreetCheck: &lt;a href=&quot;https://www.streetcheck.co.uk&quot;&gt;https://www.streetcheck.co.uk/&lt;/a&gt;&lt;a href=&quot;#fnref-10&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 10 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-11&quot;&gt;CrystalRoof: &lt;a href=&quot;https://crystalroof.co.uk&quot;&gt;https://crystalroof.co.uk/&lt;/a&gt;&lt;a href=&quot;#fnref-11&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 11 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-12&quot;&gt;&lt;a href=&quot;https://perfect-postcode.co.uk&quot;&gt;https://perfect-postcode.co.uk/&lt;/a&gt;&lt;a href=&quot;#fnref-12&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 12 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-13&quot;&gt;R5: &lt;a href=&quot;https://conveyal.com&quot;&gt;https://conveyal.com/&lt;/a&gt;&lt;a href=&quot;#fnref-13&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 13 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>systems</category><category>web</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>Compounding Self-Hosting</title><link>https://schmelczer.dev/articles/frame-eink-photo-display/</link><guid isPermaLink="true">https://schmelczer.dev/articles/frame-eink-photo-display/</guid><description>Combining Immich, Home Assistant, an e-ink screen, and an overpowered Raspberry Pi for truly personal tech.</description><pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A small e-ink photo frame for our home. It pulls from a self-hosted &lt;a href=&quot;https://immich.app/&quot;&gt;Immich&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; library, checks a self-hosted &lt;a href=&quot;https://www.home-assistant.io/&quot;&gt;Home Assistant&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; to see if anyone is home, and puts a photo on the &lt;a href=&quot;https://www.waveshare.com/wiki/PhotoPainter&quot;&gt;PhotoPainter&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot; aria-label=&quot;Footnote 3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; for everyone to enjoy.&lt;/p&gt;
&lt;h2 id=&quot;why&quot;&gt;Why&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Why&quot; href=&quot;#why&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Most digital frames offer a choice between two bad options: preprocess photos onto an SD card yourself, or hand your library to a cloud service like Google Photos. Realistically, the SD card gets updated once a year. And I’d rather not give a cloud provider access to my most cherished memories, or let it hold them hostage.&lt;/p&gt;
&lt;p&gt;Yet, coming home to a photo from earlier that day, or one from five years ago, hits differently when it’s inside a real picture frame instead of buried in your phone. So I decided to spend a weekend building with Claude Code. After some experimenting with dithering and post-processing, and some fine-tuning of the photo-picking algorithm, I ended up with this project.&lt;/p&gt;
&lt;p&gt;A weekend was enough only because of how much came for free: Immich had already run face detection on every photo, already reverse-geocoded the place names shown on the overlay, and already knew our favourites; Home Assistant already knew who was home. That’s the compounding return of self-hosting: every service exposes an easy-to-integrate API, and each new project gets to stand on all of them. I’m now looking for more ways to leverage this.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to How it works&quot; href=&quot;#how-it-works&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The frame is driven by a Raspberry Pi Zero 2W, which is fast enough that I could write plain Python and mostly ignore performance, a luxury after &lt;a href=&quot;https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/&quot;&gt;counting individual bytes on an ATtiny85&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot; aria-label=&quot;Footnote 4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;. Cron triggers a script every 15 minutes. Each run:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Quits if it’s between midnight and 7 am. The e-ink display holds its image at zero power, so the last photo of the evening simply stays up overnight.&lt;/li&gt;
&lt;li&gt;Asks Home Assistant whether anyone in &lt;code&gt;HA_PRESENCE&lt;/code&gt; is home. If not, it quits; no point wasting power and straining the display.&lt;/li&gt;
&lt;li&gt;Picks a random photo from Immich, drawn from four overlapping pools: “on this day” memories, favourites, the last 30 days, and the whole library. The weights work out to roughly a 25% chance for a memory (10% if only the ±3-day fallback has matches), 15% for favourites, and 30% for each of the other two. A 7-day rolling history prevents repeats, photos matching the frame’s orientation get 4× the weight, and any candidate whose crop would clip a head is rejected (more on that below).&lt;/li&gt;
&lt;li&gt;Crops around any detected faces, boosts contrast and saturation (e-ink is short on both), dithers down to the six-colour palette, and pushes the result to the panel. The photo’s age and EXIF location are painted into the bottom corners as white text with a black stroke, so the dither can’t smear the edges.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;image-pipeline&quot;&gt;Image pipeline&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Image pipeline&quot; href=&quot;#image-pipeline&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Two choices matter most for how the result looks: the face-aware crop and dithering.&lt;/p&gt;
&lt;h3 id=&quot;cropping&quot;&gt;Cropping&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Cropping&quot; href=&quot;#cropping&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The frame stands in one orientation, but I didn’t want to limit it to photos that happen to match. So a face-aware algorithm resize-crops the image to fill the panel, biasing the crop window towards the faces Immich has already detected. A landscape shot with some room around the subject usually crops cleanly to portrait this way. If the crop would cut into any heads, the photo is instead rejected and the picker moves on to the next candidate.&lt;/p&gt;
&lt;p&gt;The examples below show how the head bounding boxes steer the final crop, and which candidates get rejected.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Crop comparison showing original photos with face boxes, naive centre crops, and face-aware crops for a portrait frame target, with one candidate rejected for cutting into a head&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;1965&quot; height=&quot;1701&quot; src=&quot;https://schmelczer.dev/_astro/crop_compare_portrait.CDc1jU9J_aR7Sq.webp&quot; &gt;&lt;/p&gt;
&lt;h3 id=&quot;dithering&quot;&gt;Dithering&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Dithering&quot; href=&quot;#dithering&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The panel shows exactly six colours: black, white, red, yellow, blue, and green. There’s no intensity control like on an LCD, so every pixel is one of the six. Anything photo-like has to be dithered, and the candidate algorithms differ wildly in both output quality and running time. Here’s how a few of them compare:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Palette-preserving dither comparison showing several 6-colour algorithms applied to a hiker in the mountains&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;1920&quot; height=&quot;1046&quot; src=&quot;https://schmelczer.dev/_astro/dither_compare_hiker_in_mountains.B2VygJ-n_17LIBs.png&quot; &gt;&lt;/p&gt;
&lt;p&gt;I settled on &lt;a href=&quot;https://en.wikipedia.org/wiki/Atkinson_dithering&quot;&gt;Atkinson dithering&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot; aria-label=&quot;Footnote 5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;, which keeps the most contrast without too many visible artefacts. Unlike its alternatives, it only diffuses 6/8 of each pixel’s quantisation error, so highlights stay bright instead of getting dragged grey by their neighbours. Dithering was also the first place pure Python became unusably slow on the Pi Zero. So the inner loop, including the perceptually weighted nearest-colour matching, runs through numba which makes it roughly two orders of magnitude faster once the JIT cache is warm.&lt;/p&gt;
&lt;h2 id=&quot;learnings&quot;&gt;Learnings&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Learnings&quot; href=&quot;#learnings&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The Pi Zero 2W is overkill. It drains the battery way too quickly if you try to run it cordless, and it spends most of its life idle, waiting for the next cron tick. An RTC wired to an interrupt pin plus deep sleep would have fixed that, but the Waveshare board doesn’t come with one soldered on and I couldn’t be bothered to hack it in. For a battery-powered build I’d reach for an ESP32 with deep sleep instead; with 15 minutes between refreshes, even a slow chip has all the time it needs for the image processing and dithering.&lt;/p&gt;
&lt;p&gt;A few reliability quirks worth knowing: the Zero 2W’s Wi-Fi drops when power-save kicks in, so a separate cron job runs &lt;code&gt;wifi-check.sh&lt;/code&gt; every 5 minutes to reconnect. I also ensured that swap is masked and journald is set to volatile, because SD-card wear is the only thing likely to slowly kill this build.&lt;/p&gt;
&lt;p&gt;I’d also like to try an &lt;a href=&quot;https://shop.pimoroni.com/products/inky-impression?variant=55186435244411&quot;&gt;Inky Impression&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-6&quot; id=&quot;fnref-6&quot; aria-label=&quot;Footnote 6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt; in a custom-made frame: a larger display, and perhaps integrated lights, because the e-ink looks a bit muddled in evening light. A dedicated light source would be the greatest improvement by far.&lt;/p&gt;
&lt;p&gt;But all in all, the frame already does its job. It stands in the living room as a pleasant speck of curiosity, subtle enough that most visitors never notice it (until I inevitably point it out). But once you know it’s there, catching the panel mid-refresh comes with a small rush of excitement: you’re about to be pulled into a memory, distant or very recent.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;Immich: &lt;a href=&quot;https://immich.app/&quot;&gt;https://immich.app/&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Home Assistant: &lt;a href=&quot;https://www.home-assistant.io/&quot;&gt;https://www.home-assistant.io/&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;PhotoPainter: &lt;a href=&quot;https://www.waveshare.com/wiki/PhotoPainter&quot;&gt;https://www.waveshare.com/wiki/PhotoPainter&lt;/a&gt;&lt;a href=&quot;#fnref-3&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 3 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;counting individual bytes on an ATtiny85: &lt;a href=&quot;https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/&quot;&gt;https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/&lt;/a&gt;&lt;a href=&quot;#fnref-4&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 4 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;Atkinson dithering: &lt;a href=&quot;https://en.wikipedia.org/wiki/Atkinson_dithering&quot;&gt;https://en.wikipedia.org/wiki/Atkinson_dithering&lt;/a&gt;&lt;a href=&quot;#fnref-5&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 5 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-6&quot;&gt;Inky Impression: &lt;a href=&quot;https://shop.pimoroni.com/products/inky-impression?variant=55186435244411&quot;&gt;https://shop.pimoroni.com/products/inky-impression?variant=55186435244411&lt;/a&gt;&lt;a href=&quot;#fnref-6&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 6 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>embedded</category><category>graphics</category><category>systems</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>A 3-Way Text Merger That Never Shows Conflict Markers</title><link>https://schmelczer.dev/articles/reconcile-text-3-way-merge/</link><guid isPermaLink="true">https://schmelczer.dev/articles/reconcile-text-3-way-merge/</guid><description>Conflict-free 3-way text merging for Rust, JavaScript, and Python. Both sides&apos; edits survive, cursors move with them, and no conflict markers appear.</description><pubDate>Wed, 01 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;reconcile-text&lt;/code&gt; merges two conflicting edits of the same text into one, without asking a human to sort it out. Where a traditional 3-way merge stops and writes &lt;code&gt;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&lt;/code&gt; markers, it applies both sets of changes using an algorithm inspired by Operational Transformation, and repositions any cursors and selections along the way. It’s a Rust library with WebAssembly bindings for JavaScript and native bindings for Python; the &lt;a href=&quot;https://schmelczer.dev/reconcile/&quot;&gt;interactive demo&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; shows it merging as you type.&lt;/p&gt;
&lt;p&gt;The whole API is one call. Give it the common ancestor and the two versions that drifted from it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; { reconcile } &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;reconcile-text&apos;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6A737D;--shiki-dark:#6A737D&quot;&gt;// the version both sides start from&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; parent&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;Hello world&apos;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6A737D;--shiki-dark:#6A737D&quot;&gt;// one user added &quot;beautiful&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; left&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;Hello beautiful world&apos;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6A737D;--shiki-dark:#6A737D&quot;&gt;// the other changed the greeting&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; right&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;Hi world&apos;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6A737D;--shiki-dark:#6A737D&quot;&gt;// &quot;Hi beautiful world&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;reconcile&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(parent, left, right).text;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All three packages expose the same function with the same semantics: &lt;code&gt;cargo add reconcile-text&lt;/code&gt;, &lt;code&gt;npm install reconcile-text&lt;/code&gt;, or &lt;code&gt;pip install reconcile-text&lt;/code&gt;, depending on where you need it.&lt;/p&gt;
&lt;h2 id=&quot;why&quot;&gt;Why&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Why&quot; href=&quot;#why&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Merging concurrent edits is a solved problem, provided you control the whole editing stack. CRDTs and Operational Transformation both work by capturing every individual operation as it happens, which is fine when you own the editor, the transport layer, and the storage format. Many workflows aren’t like that: an Obsidian vault gets edited by everything from Vim to VS Code, and all a sync engine ever sees is the final state of each file. That’s a Differential Synchronisation scenario: the last synced parent plus two divergent children, with no record of the keystrokes that produced them.&lt;/p&gt;
&lt;p&gt;It’s the same problem Git addresses, except Git hands the hard cases back to you as conflict markers. That’s the right call for source code, where an incorrect merge is a bug and a human has to verify the result. &lt;code&gt;reconcile-text&lt;/code&gt; bets that human text is more forgiving: a slightly imperfect sentence is usually better than conflict markers interrupting the flow of a document. (Not every kind of text qualifies: in a legal contract, two edits that combine into a double negation quietly change the meaning. And for code the problem runs the other way: a merge can be semantically wrong even with no syntactic conflict.)&lt;/p&gt;
&lt;p&gt;So the library does exactly one thing: three strings in, one string out, every time. It’s the merge primitive underneath VaultLink, my Obsidian sync engine; differential sync only feels right if the merge step never needs a human.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to How it works&quot; href=&quot;#how-it-works&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;It starts off like &lt;code&gt;diff3&lt;/code&gt;, then adds the conflict-resolution step &lt;code&gt;diff3&lt;/code&gt; refuses to take. Given the parent and the two modified versions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Tokenisation.&lt;/strong&gt; The texts are split into the units the merge will operate on: words by default, or characters, lines, or a custom tokeniser. This is a bigger lever than it looks: at word granularity, most prose “conflicts” dissolve into adjacent edits that can both survive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diff computation.&lt;/strong&gt; Myers’ algorithm produces two edit scripts, parent → left and parent → right.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diff optimisation.&lt;/strong&gt; The operations are reordered and consolidated so related changes chain together.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The weave.&lt;/strong&gt; The two scripts are combined using OT principles: each side’s edits are transformed over the other’s, so every modification lands, and cursor positions are carried through each transformation.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I never set out to implement OT for its own sake; transforming batched diffs just happens to be an elegant way to merge two Myers outputs. The same could be achieved with a CRDT. But when all you can observe is end states, merge quality is capped by the quality of the underlying 2-way diffs, whichever machinery does the merging. A moved paragraph, for instance, reaches the merger as an unrelated delete and insert, because that’s all Myers’ algorithm can say about it.&lt;/p&gt;
&lt;p&gt;The whole pipeline handles Unicode properly: full UTF-8, with grapheme clusters kept intact so complex scripts never get split mid-character.&lt;/p&gt;
&lt;h2 id=&quot;where-it-sits-among-the-alternatives&quot;&gt;Where it sits among the alternatives&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Where it sits among the alternatives&quot; href=&quot;#where-it-sits-among-the-alternatives&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Every nearby tool stops short of this job in a different place.&lt;/p&gt;
&lt;h3 id=&quot;diff3-and-git&quot;&gt;diff3 and Git&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to diff3 and Git&quot; href=&quot;#diff3-and-git&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;diff3&lt;/code&gt; and &lt;code&gt;git merge-file&lt;/code&gt; do the structural work, then write &lt;code&gt;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&amp;#x3C;&lt;/code&gt; markers whenever both sides touch the same region; so do the libraries that reimplement them: diffy and merge3 in Rust, node-diff3 in JavaScript. &lt;code&gt;reconcile-text&lt;/code&gt; shares their diff3-like foundation; the difference is the resolution step that eliminates markers entirely.&lt;/p&gt;
&lt;h3 id=&quot;diff-match-patch&quot;&gt;diff-match-patch&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to diff-match-patch&quot; href=&quot;#diff-match-patch&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;diff-match-patch&lt;/code&gt; is Neil Fraser’s widely used library from his time at Google: character-level Myers diffing, fuzzy matching, and patch application, powering his Differential Synchronisation protocol. It’s the closest tool in spirit, and it differs in four ways that matter here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;2-way, not 3-way.&lt;/strong&gt; It diffs two texts and applies the result as a patch to a third. There’s no concept of a common ancestor, so it can’t reason about what the left and right sides each intended.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Character-level only.&lt;/strong&gt; Word- or line-level diffing requires encoding tokens as single Unicode characters first; &lt;code&gt;reconcile-text&lt;/code&gt; tokenises natively.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Patches can fail.&lt;/strong&gt; &lt;code&gt;patch_apply&lt;/code&gt; reports per-patch success, and failed patches are dropped. Inside a sync loop, that failure self-corrects on the next cycle, but in a one-shot merge, the edit is simply lost. &lt;code&gt;reconcile-text&lt;/code&gt; always produces a complete merged result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No cursor tracking or provenance.&lt;/strong&gt; It won’t reposition cursors or tell you which side made which edit; &lt;code&gt;reconcile-text&lt;/code&gt; does both.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The repo carries a &lt;a href=&quot;https://git.schmelczer.dev/andras/reconcile&quot;&gt;runnable comparison&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; with concrete inputs where diff-match-patch garbles adjacent edits and silently drops an entire sentence; &lt;code&gt;reconcile-text&lt;/code&gt; merges both correctly. When you genuinely have no common ancestor (two texts that diverged through an unknown sequence of edits), diff-match-patch is the right tool; with an ancestor, the 3-way merge wins.&lt;/p&gt;
&lt;h3 id=&quot;crdts&quot;&gt;CRDTs&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to CRDTs&quot; href=&quot;#crdts&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Yjs, Automerge, Loro, cola, and diamond-types guarantee convergence by construction: every operation commutes, so application order stops mattering. They capture each operation with a unique identity, work peer-to-peer, scale past two concurrent editors, and never lose an edit. The trade-off is state: an operation log or internal structure that grows with the document’s history. You can’t hand a CRDT library three plain strings and ask for a merge; that’s exactly the gap &lt;code&gt;reconcile-text&lt;/code&gt; fills. The advice cuts both ways: if you do control the whole editing stack, a CRDT gives you stronger guarantees, and it handles N editors natively where &lt;code&gt;reconcile-text&lt;/code&gt; merges exactly two forks at a time (though merges can be chained).&lt;/p&gt;
&lt;h3 id=&quot;operational-transformation&quot;&gt;Operational Transformation&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Operational Transformation&quot; href=&quot;#operational-transformation&quot;&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;OT libraries like ot.js and ShareJS transform live operations against each other, typically with a central server deciding the canonical order. &lt;code&gt;reconcile-text&lt;/code&gt; borrows the transformation concept but aims it at a different problem: instead of individual keystrokes in real time, it transforms the consolidated diffs of two complete edits. No server, no operation capture, fully offline. If you need sub-second real-time collaboration and can run a coordination server, use the real thing; this library is for merge points, not keystroke-by-keystroke sync.&lt;/p&gt;
&lt;h2 id=&quot;one-core-three-registries&quot;&gt;One core, three registries&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to One core, three registries&quot; href=&quot;#one-core-three-registries&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The Rust core compiles to WebAssembly through wasm-bindgen for the npm package and binds natively to Python through pyo3, so all three languages run the same merge logic. The strangest target is React Native: Hermes, its default engine, exposes no &lt;code&gt;WebAssembly&lt;/code&gt; global at runtime, so the package’s &lt;code&gt;react-native&lt;/code&gt; entry point ships a pure-JavaScript build of the same core, transpiled from the WASM by Binaryen’s wasm2js. Slower, but it behaves the same anywhere JavaScript runs.&lt;/p&gt;
&lt;p&gt;If you’d like to poke at the merge behaviour without installing anything, the &lt;a href=&quot;https://schmelczer.dev/reconcile/&quot;&gt;demo&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; runs that same WASM build directly in your browser.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;interactive demo: &lt;a href=&quot;https://schmelczer.dev/reconcile/&quot;&gt;https://schmelczer.dev/reconcile/&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;runnable comparison: &lt;a href=&quot;https://git.schmelczer.dev/andras/reconcile&quot;&gt;https://git.schmelczer.dev/andras/reconcile&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>tools</category><category>web</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>A Fleeting Garden on Your GPU</title><link>https://schmelczer.dev/articles/fleeting-garden-webgpu-drawing/</link><guid isPermaLink="true">https://schmelczer.dev/articles/fleeting-garden-webgpu-drawing/</guid><description>A WebGPU slime mould simulation. Control millions of agents with your paintbrush and watch them bring your drawing to life.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Watching millions of independent agents converge into art is fascinating for a while, but really not that long. My favourite art pieces make the viewer part of the work, so this project had to be interactive to live up to my expectations. That was the hard part. The rest was a slime mould simulation inspired by &lt;a href=&quot;https://www.youtube.com/watch?v=X-iSQQgOd1A&quot;&gt;Sebastian Lague’s video&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;, of the kind &lt;a href=&quot;https://cargocollective.com/sagejenson/physarum&quot;&gt;Sage Jenson has taken furthest&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;. I built it while the &lt;a href=&quot;https://www.w3.org/TR/webgpu/&quot;&gt;WebGPU spec&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot; aria-label=&quot;Footnote 3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; was still a moving target, because I’d felt WebGL’s limits while &lt;a href=&quot;https://schmelczer.dev/articles/sdf-2d-ray-tracing/&quot;&gt;ray-tracing 2D scenes&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot; aria-label=&quot;Footnote 4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; and wanted compute shaders as a first-class concept.&lt;/p&gt;
&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Architecture&quot; href=&quot;#architecture&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The agents’ lifecycle is managed by a compute shader updating an agent array in place. Each individual follows a simple decision-making loop: on every iteration it takes three samples, one ahead of its position and one to each side, then chooses between going straight, turning left, or turning right. Each theme of the app carries a matrix specifying how the agent types relate to one another, either attracting or avoiding each other, or staying indifferent:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;3 by 3 matrix showing the interactions between 3 trail types&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;1050&quot; height=&quot;420&quot; src=&quot;https://schmelczer.dev/_astro/fleeting-garden-attraction.-9SQCQj2_2sNjzA.webp&quot; &gt;&lt;/p&gt;
&lt;p&gt;The samples come from a map that keeps the history of the agents’ paths and slowly diffuses over time. It’s a texture where the R, G, and B channels each correspond to one agent type’s deposits. I considered using the fourth channel too, but three colours already end up busy enough, and a fourth would’ve made colour theory a fair bit harder for my colour-blind brain.&lt;/p&gt;
&lt;h2 id=&quot;make-it-good&quot;&gt;Make it good&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Make it good&quot; href=&quot;#make-it-good&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My first experiment let the user draw over the diffusion texture, swaying the mould into changing direction. It turned out to be one layer too detached: it barely changed the behaviour unless the weight was so high that the strokes looked thick. In the end, drawing only spawns new agents on the pen’s path. When there are too many agents on the canvas, old ones get removed as new ones arrive.&lt;/p&gt;
&lt;p&gt;Drawing was really fun, but I can’t draw well, and there isn’t enough line smoothing in the world to change that (though adding some did improve my work a bit). So I also added mirroring, a fairly popular tool in drawing programs for turning scribbles into artsy kaleidoscopic symmetries.&lt;/p&gt;
&lt;p&gt;Then, to deepen the immersion and give drawing more weight, I added procedurally generated piano sounds that reward more drawing with more complex note progressions.&lt;/p&gt;
&lt;p&gt;A lot of minor touches give the app its polish. Theme changes go through the history stack, so the back button works as expected. The toolbar reads the pixels behind it, a 13×7 grid of them, and adjusts its own opacity so it stays legible against any background. And of course, the first thing a visitor sees is 180,000 agents drawing the word &lt;em&gt;Fleeting&lt;/em&gt; out of their own trails, then wandering off as the simulation takes over and the letters come apart.&lt;/p&gt;
&lt;h2 id=&quot;then-make-it-fast&quot;&gt;Then make it fast&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Then make it fast&quot; href=&quot;#then-make-it-fast&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The simulation is split across six compute-and-render stages, spread over ten WGSL files: agent step, diffusion and decay, brush, eraser (one variant clears the trail, the other kills agents), agent generation with resize and compaction, and the final palette render. A calm frame only touches three of them (agent step, diffusion, and render), and the whole frame goes into a single command buffer with one submit.&lt;/p&gt;
&lt;p&gt;The code is kept simple and mostly branch-free; three larger decisions do the rest. Agents write their deposits without blending, because concurrent compute writes to a storage texture don’t blend anyway: when two agents land on the same pixel, one write simply wins. The next diffusion pass smears the survivor across its neighbours, so the lost one is impossible to spot. Keeping the agents contiguous took more thought: each workgroup prefix-sums its own survivors, so compaction costs one atomic per workgroup rather than one per agent. And the agent count follows the frame rate. It starts at a million, sheds 200,000 a second whenever the smoothed rate drops below 90% of 60 FPS, and never falls below 50,000, so a weaker device gets a thinner garden instead of a slideshow.&lt;/p&gt;
&lt;h2 id=&quot;give-it-a-try&quot;&gt;Give it a try&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Give it a try&quot; href=&quot;#give-it-a-try&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;There’s only so much a blog post can do to describe an immersive art experience. Ultimately, the best way to learn more about it is to give it a try, recommended with the sound on: &lt;a href=&quot;https://fleeting.schmelczer.dev&quot;&gt;fleeting.schmelczer.dev&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot; aria-label=&quot;Footnote 5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;Sebastian Lague’s video: &lt;a href=&quot;https://www.youtube.com/watch?v=X-iSQQgOd1A&quot;&gt;https://www.youtube.com/watch?v=X-iSQQgOd1A&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Sage Jenson has taken furthest: &lt;a href=&quot;https://cargocollective.com/sagejenson/physarum&quot;&gt;https://cargocollective.com/sagejenson/physarum&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;WebGPU spec: &lt;a href=&quot;https://www.w3.org/TR/webgpu/&quot;&gt;https://www.w3.org/TR/webgpu/&lt;/a&gt;&lt;a href=&quot;#fnref-3&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 3 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;ray-tracing 2D scenes: &lt;a href=&quot;https://schmelczer.dev/articles/sdf-2d-ray-tracing/&quot;&gt;https://schmelczer.dev/articles/sdf-2d-ray-tracing/&lt;/a&gt;&lt;a href=&quot;#fnref-4&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 4 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;&lt;a href=&quot;https://fleeting.schmelczer.dev&quot;&gt;https://fleeting.schmelczer.dev/&lt;/a&gt;&lt;a href=&quot;#fnref-5&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 5 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>graphics</category><category>web</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>Simple, Sophisticated Backup Strategy</title><link>https://schmelczer.dev/articles/backup-container-btrfs-borg/</link><guid isPermaLink="true">https://schmelczer.dev/articles/backup-container-btrfs-borg/</guid><description>One short shell script backs up my whole home server live: betting on crash recovery instead of per-database tooling.</description><pubDate>Sun, 01 Jan 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The more critical a system is, the simpler it should be. In practice, that means building on battle-tested foundations, and adding only the thin layer of logic that you absolutely must have.&lt;/p&gt;
&lt;p&gt;My home server’s backup is a good example. I aim for SQLite wherever I can, but reality has added multiple Postgres instances, Redis, message queues, and so on to my self-hosted stack. I still wanted time-machine-style backups with granular file history, which means backing up often (hourly, in my case). But shutting down the stack every hour would’ve greatly inconvenienced my only user (myself), so the only solution was to back up everything live.&lt;/p&gt;
&lt;p&gt;I could have gone down the rabbit hole of coordinating each database’s dedicated backup tooling: one bespoke method per service, each with its own failure modes. That would have been anything but simple. Instead I rely on serious databases having a battle-tested crash recovery path.&lt;/p&gt;
&lt;p&gt;With this assumption, the backup container turns out to be just a short shell script. Every container volume lives on a &lt;a href=&quot;https://btrfs.readthedocs.io/en/latest/&quot;&gt;btrfs&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; subvolume, so the script can take an atomic snapshot of each. The snapshots are then fed into &lt;a href=&quot;https://www.borgbackup.org/&quot;&gt;borg&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;, which handles incrementality and deduplication across local and SSH backup targets. To a database restored from this backup, the state is indistinguishable from a power cut. My backup script’s contribution is just the plumbing: configuring btrfs and borg, handling failures gracefully, and maintaining a healthcheck status for when human intervention is needed.&lt;/p&gt;
&lt;p&gt;I’ve been using this since 2023; I’ve restored various files and folders and done two full disaster recoveries. Nothing builds confidence like a restore that works: borg, btrfs, and this little script have earned their keep, and I don’t expect to migrate away any time soon.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;btrfs: &lt;a href=&quot;https://btrfs.readthedocs.io/en/latest/&quot;&gt;https://btrfs.readthedocs.io/en/latest/&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;borg: &lt;a href=&quot;https://www.borgbackup.org/&quot;&gt;https://www.borgbackup.org/&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>systems</category><category>tools</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>A Real-Time 2D Ray Tracer That Runs on Phones</title><link>https://schmelczer.dev/articles/sdf-2d-ray-tracing/</link><guid isPermaLink="true">https://schmelczer.dev/articles/sdf-2d-ray-tracing/</guid><description>A TypeScript library that ray-traces 2D scenes built from signed distance fields. Tile-based rendering and generated shaders keep it real-time on phones.</description><pubDate>Tue, 01 Dec 2020 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My BSc thesis needed two things: a renderer, and something to render. The renderer became SDF-2D, a 2D ray tracer for the browser built on signed distance fields; the something became decla.red, a multiplayer space shooter that proved the renderer could survive a real game loop. This article is about the renderer.&lt;/p&gt;
&lt;p&gt;The motivation was mild dissatisfaction: modern 2D games mostly ship flat sprites and no lighting while sitting on ever-faster GPUs. Ray tracing is how you get lighting worth staring at, and in 2D it’s almost cheap enough to run on a phone. Most of my thesis was about the “almost”.&lt;/p&gt;
&lt;h2 id=&quot;circle-tracing&quot;&gt;Circle tracing&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Circle tracing&quot; href=&quot;#circle-tracing&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Sphere tracing renders shapes defined by a signed distance field: it marches along each ray in steps equal to the field’s value at the ray’s current end. Circle tracing is its 2D simplification. The pleasant surprise of 2D is that the geometry needs no rays at all: evaluate the field at every pixel, and a negative value means the pixel is inside an object (values near zero hand you antialiasing for free). Rays are only needed for lighting, so performance is all about how often the field gets evaluated.&lt;/p&gt;
&lt;p&gt;Scenes are described in plain TypeScript; a minimal animation of a circle orbiting a light looks like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;ts&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; { CircleFactory, CircleLight, hsl, runAnimation } &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;sdf-2d&apos;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; canvas&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; document.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;querySelector&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;canvas&apos;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; Circle&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; CircleFactory&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;hsl&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;180&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;100&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;40&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; draw&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#E36209;--shiki-dark:#FFAB70&quot;&gt;renderer&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#E36209;--shiki-dark:#FFAB70&quot;&gt;time&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&gt;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  renderer.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;addDrawable&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;    new&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Circle&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;([&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;150&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 50&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Math.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;cos&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(time &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1000&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;75&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; +&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 50&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Math.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sin&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(time &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1000&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)], &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;25&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  );&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  renderer.&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;addDrawable&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;new&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; CircleLight&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;([&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;150&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;75&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;], &lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;hsl&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;270&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;100&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;40&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0.1&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;--shiki-light:#6A737D;--shiki-dark:#6A737D&quot;&gt;// flag that more frames are coming&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;};&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;runAnimation&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(canvas, [Circle.descriptor, CircleLight.descriptor], draw);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The descriptor list at the end looks redundant (surely the library can see what I’m drawing?), but it’s load-bearing; I’ll come back to why.&lt;/p&gt;
&lt;h2 id=&quot;two-passes-and-a-grid&quot;&gt;Two passes and a grid&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Two passes and a grid&quot; href=&quot;#two-passes-and-a-grid&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The naive renderer evaluates the field pixels × lights × steps times per frame. Deferred shading fixes most of that: a first pass renders the distance field itself into a texture, the lighting pass reads the memoised values back, and the field gets evaluated once per pixel. The distance pass can even render at half resolution with barely any visible difference.&lt;/p&gt;
&lt;p&gt;The second fix borrows from tiled renderers: the screen is split into an 8 by 8 grid, and each tile receives the short list of objects near it before the fragment shader runs, so its pixels never consider the whole scene. In my test scene of 200 objects, the average tile needed to know about 23 of them.&lt;/p&gt;
&lt;p&gt;Measured on that scene (200 objects, two lights, 2560 × 1080, a desktop RX 590), with identical lighting code throughout:&lt;/p&gt;
&lt;div tabindex=&quot;0&quot; class=&quot;table-scroll&quot;&gt;
























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Optimisations enabled&lt;/th&gt;&lt;th&gt;FPS&lt;/th&gt;&lt;th&gt;GPU draw time&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;None&lt;/td&gt;&lt;td&gt;7.5&lt;/td&gt;&lt;td&gt;130 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Memoised distance field&lt;/td&gt;&lt;td&gt;60 (capped)&lt;/td&gt;&lt;td&gt;5 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Memoised field and tiles&lt;/td&gt;&lt;td&gt;60 (capped)&lt;/td&gt;&lt;td&gt;1.85 ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;That’s a 70-fold speed-up, and the difference between a tech demo and something a mid-range phone can run.&lt;/p&gt;
&lt;h2 id=&quot;shadows-in-16-steps&quot;&gt;Shadows in 16 steps&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Shadows in 16 steps&quot; href=&quot;#shadows-in-16-steps&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The textbook route to soft SDF shadows is &lt;a href=&quot;https://iquilezles.org/articles/rmshadows/&quot;&gt;Quilez’s ray-marched penumbra technique&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;: lovely, but it wants 64 to 128 steps per light and an exact field rather than a lower bound. I ended up with a blunter instrument: march towards the light for 16 steps, divide how far the ray got by how far the light is, and raise the ratio to an arbitrary power to smooth it.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;glsl&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;float&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; shadowTransparency&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;float&lt;/span&gt;&lt;span style=&quot;--shiki-light:#E36209;--shiki-dark:#FFAB70&quot;&gt; lightDistance&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, vec2 &lt;/span&gt;&lt;span style=&quot;--shiki-light:#E36209;--shiki-dark:#FFAB70&quot;&gt;lightDirection&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;  float&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; rayLength &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0.0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;int&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; j &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; j &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 16&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; j&lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    rayLength &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;+=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; max&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0.0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getDistance&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(uvCoordinates &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; lightDirection &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; rayLength));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt; min&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1.0&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#6F42C1;--shiki-dark:#B392F0&quot;&gt;pow&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(rayLength &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; lightDistance, &lt;/span&gt;&lt;span style=&quot;--shiki-light:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0.3&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The closer the ray gets to the light, the less shadow lands on its starting point; that’s the whole theory. There is no physical justification for it. It just runs several times faster than the principled version, tolerates lower-bound fields, and looks right. It also has one known bug: an object standing in another object’s shadow casts a second shadow from the same light. I know it’s there; nobody ever noticed it during testing.&lt;/p&gt;
&lt;h2 id=&quot;shaders-written-by-a-program&quot;&gt;Shaders written by a program&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Shaders written by a program&quot; href=&quot;#shaders-written-by-a-program&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;WebGL1 requires loop bounds to be known at shader compile time, but a library can’t know ahead of time how many circles you’ll draw. So SDF-2D writes its own shaders at runtime: it generates a program from GLSL templates for every combination of the object counts each drawable type declares. Each frame then runs the smallest program that fits the scene. Keeping the combinations to a couple hundred programs wasn’t hard. Compiling them all before the first frame is why the library leans on parallel shader compilation (JavaScript is single-threaded; the driver doing the compiling is not). It’s also why &lt;code&gt;runAnimation&lt;/code&gt; demands those descriptors up front: the generator has to know every type you’ll ever draw before it can write the shaders.&lt;/p&gt;
&lt;h2 id=&quot;surviving-the-browser&quot;&gt;Surviving the browser&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Surviving the browser&quot; href=&quot;#surviving-the-browser&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The library also supports WebGL1 alongside WebGL2 (in 2020, iPhones left no choice), degrades gracefully when extensions are missing, and even survives the browser yanking the GPU away mid-frame. I debugged that last failure mode with a simulator that killed and restored the rendering context at random intervals until nothing broke any more.&lt;/p&gt;
&lt;h2 id=&quot;field-testing-in-an-electronics-store&quot;&gt;Field testing in an electronics store&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Field testing in an electronics store&quot; href=&quot;#field-testing-in-an-electronics-store&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;“Runs on phones” is an empirical claim, so the &lt;a href=&quot;https://sdf2d.schmelczer.dev&quot;&gt;demo page&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; anonymously logs frame rates. Gathering the data was the fun part: beyond organic visitors and pestered acquaintances, I walked through an electronics store running the demo on every device on display, because the companies that rent out real hardware for testing charge more than a student can afford. Across 37 unique devices, 33 held 30 FPS or better, most sat pinned at the browser’s 60 FPS cap, and the strangest row in the data was a 4K TV running a four-year-old Chrome. The numbers fed straight back into the autoscaler that trades render resolution for frame rate: I lowered its target to 30 FPS and added motion blur so weaker devices still feel smooth. &lt;a href=&quot;https://schmelczer.dev/articles/fleeting-garden-webgpu-drawing/&quot;&gt;Fleeting Garden&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot; aria-label=&quot;Footnote 3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; got the same treatment years later, shedding agents instead of pixels.&lt;/p&gt;
&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Limitations&quot; href=&quot;#limitations&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Objects reach the GPU as uniforms, and uniform counts are capped by the GPU, the driver, and the browser, so a sufficiently complex scene hits a wall. The planned fix was a non-uniform tile grid, finer where the scene is busy.&lt;/li&gt;
&lt;li&gt;There are exactly two light types and no way to plug in a custom lighting model.&lt;/li&gt;
&lt;li&gt;The banding around lights bothers me to this day. Dithering would fix it; it looked too expensive at the time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The library is still on npm and gets a hundred-odd downloads a week, which may not be much, but I like to imagine that somebody, somewhere, is actually using it. The demo scenes are still up too. The game half of the thesis deserves its own article, and it will get one.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;Quilez’s ray-marched penumbra technique: &lt;a href=&quot;https://iquilezles.org/articles/rmshadows/&quot;&gt;https://iquilezles.org/articles/rmshadows/&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;demo page: &lt;a href=&quot;https://sdf2d.schmelczer.dev&quot;&gt;https://sdf2d.schmelczer.dev/&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;Fleeting Garden: &lt;a href=&quot;https://schmelczer.dev/articles/fleeting-garden-webgpu-drawing/&quot;&gt;https://schmelczer.dev/articles/fleeting-garden-webgpu-drawing/&lt;/a&gt;&lt;a href=&quot;#fnref-3&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 3 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>graphics</category><category>web</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>A 50 FPS Game Engine on an 8-Bit Microcontroller</title><link>https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/</link><guid isPermaLink="true">https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/</guid><description>A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver. 8 MHz, 512 bytes of RAM, and a charming atmosphere.</description><pubDate>Sun, 01 Mar 2020 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I grew up playing with little widgets: fake Tamagotchis, Game Boy imitations with 100+ built-in games, and the like. It was only natural that I wanted to make my own.&lt;/p&gt;
&lt;h2 id=&quot;the-game&quot;&gt;The game&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to The game&quot; href=&quot;#the-game&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;You keep a broken spaceship alive. Asteroids drift past, you mine them and spend the points on upgrades, each of which unlocks a new station to tend or a new crewmate who does the tending for you.&lt;/p&gt;
&lt;h2 id=&quot;the-hardware&quot;&gt;The hardware&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to The hardware&quot; href=&quot;#the-hardware&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I settled on the &lt;a href=&quot;https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-2586-AVR-8-bit-Microcontroller-ATtiny25-ATtiny45-ATtiny85_Datasheet.pdf&quot;&gt;ATtiny85&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;, driving a common &lt;a href=&quot;https://cdn-shop.adafruit.com/datasheets/SSD1306.pdf&quot;&gt;128×64 1-bit OLED&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;. To keep the footprint small I skipped a built-in controller and used an IR receiver instead, which works with almost any remote; mine was a generic hi-fi remote I got on AliExpress for pennies. Connecting the components was quite simple, even if my breadboard might’ve led you to believe otherwise:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;A breadboard threaded with jumper wires, carrying the ATtiny85, an IR receiver, a power module, and the OLED mid-game, with a small remote beside it&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;1600&quot; height=&quot;1066&quot; src=&quot;https://schmelczer.dev/_astro/ad-astra-breadboard.B8PEeAfT_1G2Mkl.webp&quot; &gt;&lt;/p&gt;
&lt;p&gt;Routing the PCB from this design was also straightforward; the real hard part was the firmware.&lt;/p&gt;
&lt;h2 id=&quot;reality-check&quot;&gt;Reality check&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Reality check&quot; href=&quot;#reality-check&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I had aspired to build a game with an open world, “advanced” AI, and so on, but I quickly got a reality check: even after bitpacking, the framebuffer alone would take up twice the microcontroller’s 512 bytes of RAM. The 8 KB of program memory wasn’t generous enough for grandiose ambitions either. I still made it work though.&lt;/p&gt;
&lt;h2 id=&quot;rendering-engine&quot;&gt;Rendering engine&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Rendering engine&quot; href=&quot;#rendering-engine&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To achieve 50 FPS, rendering happens at quarter resolution (64 by 32 pixels). The OLED’s zoom mode doubles the image vertically on its own, and horizontally each byte is simply sent twice, so the engine doesn’t have to store extra pixels for the upscaling.&lt;/p&gt;
&lt;p&gt;The screen’s pixels are either on or off, so 1 bit per pixel should be enough. But compositing is a basic expectation of any rendering engine, and it costs each pixel a second bit: transparency. That’s what lets the black pixels inside the spaceship’s sprite hide the stars behind it, while the black pixels outside it stay transparent and let the stars show through.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We should realise that 2 bits per pixel is wasteful, since the valid states are only black, white, and transparent black: there’s no difference between transparent and opaque white, as both end up as a white pixel regardless of what’s behind.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Keeping the entire framebuffer in memory would be impossible: 64 × 32 × 2 bits is exactly the amount of RAM the microcontroller has, leaving zero bytes for everything else. The pragmatic choice at this point would’ve been to just get a bigger microcontroller. But hobby projects are allowed to be non-pragmatic at times. So I carried on and optimised the renderer to work in a streaming manner, 8 bits at a time, processing 8 pixels simultaneously in a SIMD fashion.&lt;/p&gt;
&lt;p&gt;Sprites are stored as 16-bit columns (matching the word-size of the EEPROM): the high byte is an inverted mask, the low byte the fill bits. Compositing eight vertical pixels of a sprite onto the framebuffer is a single expression:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;c&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt;newColumn &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; oldColumn &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; transparencyMask &lt;/span&gt;&lt;span style=&quot;--shiki-light:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;--shiki-light:#24292E;--shiki-dark:#E1E4E8&quot;&gt; fill;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sprites are C arrays injected into the source code by a Python script, so the somewhat strange layout doesn’t leak into the art workflow. Lastly, the ATtiny bit-bangs each byte out to the display, as it has no dedicated SPI peripheral.&lt;/p&gt;
&lt;h2 id=&quot;game-logic&quot;&gt;Game logic&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Game logic&quot; href=&quot;#game-logic&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;For scripting the game engine, I took inspiration from JavaScript’s prototype system and Unity’s object model. Of course, the size constraints only allowed for a lite version. The final design is a single array of game objects, where each object holds a union of the possible object states and a pointer to its prototype: a special struct, essentially a virtual function table, pointing to the object’s &lt;code&gt;tick&lt;/code&gt; and &lt;code&gt;draw&lt;/code&gt; functions. Even though simulation and rendering follow the same 50 Hz tick, the two functions are separate because &lt;code&gt;draw&lt;/code&gt; has to support rendering an 8-pixel stripe of the object, and thus may get called multiple times per frame. This quasi-vtable approach doesn’t become a performance bottleneck either; the beauty of the ATtiny is that it only deals with SRAM, so there’s no complex caching and there are no line faults so jumping around in memory doesn’t bring a penalty.&lt;/p&gt;
&lt;p&gt;In the end, the game does have “AI” too, in the form of a finite state machine (FSM): your astronaut crewmates have a priority list of activities to attempt, based on the player’s current activity.&lt;/p&gt;
&lt;h2 id=&quot;saves&quot;&gt;Saves&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Saves&quot; href=&quot;#saves&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The program memory is only 8 KB, so the sprites have to live in the EEPROM. However, there’s still enough space in there to cram in a few dozen bytes for a save file too, which is just the serialised state of the game-object list. To avoid corrupting the saved state on an unexpected shutdown, the EEPROM holds two save buffers, along with a flag telling which one was completed last. The flag only flips once the data has been fully written into the older buffer, which makes saves atomic. It all happens in the background through interrupts, to avoid jitters in the rendering loop.&lt;/p&gt;
&lt;h2 id=&quot;was-it-worth-it&quot;&gt;Was it worth it?&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Was it worth it?&quot; href=&quot;#was-it-worth-it&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Absolutely. Don’t get me wrong, the gameplay gets old after 15 minutes: you spend the first 5 completing the game, and the rest just watching your crewmates operate the spaceship autonomously as their FSM logic converges.&lt;/p&gt;
&lt;p&gt;It was incredibly fun to read the datasheets cover to cover and actually understand the limits, and how to push them. On the one hand, this is as full-stack as it gets, and it allowed me to think about performance on a very different scale than usual. On the other hand, by the end of the project the game and the UART logging code no longer fit into program memory together, so I had to pick which parts of the game to disable.&lt;/p&gt;
&lt;p&gt;If I were to redo it, I strongly believe writing a simulator for the console would be the right call: it would allow faster iteration by skipping the flashing step, and it would make it easier to debug the “there’s no output, what now?” type of issue. A simulator would also mean E2E testing in CI. Fortunately, in this case, there’s only so much complexity an 8 KB program can hold, but anything bigger would’ve been prohibitively expensive to develop without a better testing setup.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;ATtiny85: &lt;a href=&quot;https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-2586-AVR-8-bit-Microcontroller-ATtiny25-ATtiny45-ATtiny85_Datasheet.pdf&quot;&gt;https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-2586-AVR-8-bit-Microcontroller-ATtiny25-ATtiny45-ATtiny85_Datasheet.pdf&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;128×64 1-bit OLED: &lt;a href=&quot;https://cdn-shop.adafruit.com/datasheets/SSD1306.pdf&quot;&gt;https://cdn-shop.adafruit.com/datasheets/SSD1306.pdf&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>embedded</category><category>games</category><category>graphics</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>A City Where Bad PLC Code Made Cars Crash</title><link>https://schmelczer.dev/articles/city-simulation-unity-traffic/</link><guid isPermaLink="true">https://schmelczer.dev/articles/city-simulation-unity-traffic/</guid><description>A low-poly Unity city built for a CrySyS PLC-hacking challenge, with explosive results.</description><pubDate>Sun, 01 Jul 2018 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;While I was working at CrySyS, my first university’s cybersecurity research group, I got a fun opportunity: simulating a small city. The lab was preparing a hacking challenge whose targets were PLCs operating traffic lights, so the result of a successful attack was mayhem on the roads.&lt;/p&gt;
&lt;p&gt;I quickly got building in &lt;a href=&quot;https://unity.com/&quot;&gt;Unity&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;: C# scripts gave the drivers their AI, the driving model was tuned to look realistic while staying precisely controllable, and a REST API exposed the traffic lights for remote control. The drivers obeyed the lights unconditionally, tunnel vision and all, which is the whole point: wrong control logic ends in disaster on screen.&lt;/p&gt;
&lt;p&gt;The art was on me too, so I ended up watching a bunch of &lt;a href=&quot;https://www.youtube.com/@blenderguru&quot;&gt;Blender Guru&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; tutorials and modelling everything in a low-poly style. Far too much of that time went into trying to get the animations right; they didn’t turn out the best, but zoomed out they were passable.&lt;/p&gt;
&lt;p&gt;The venue was quite large, with screens dotted around it, which added one final complexity: netcode. The whole simulation runs server-side, and each screen is a thin client that merely extrapolates from the latest state it received.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;Unity: &lt;a href=&quot;https://unity.com/&quot;&gt;https://unity.com/&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Blender Guru: &lt;a href=&quot;https://www.youtube.com/@blenderguru&quot;&gt;https://www.youtube.com/@blenderguru&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>games</category><category>graphics</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>A 3D Voxel Game in C</title><link>https://schmelczer.dev/articles/platform-game-c-sdl/</link><guid isPermaLink="true">https://schmelczer.dev/articles/platform-game-c-sdl/</guid><description>My Basics of Programming project: a 3D platformer in pure C with SDL 1.2. Destructible voxel terrain, flying enemies, and a renderer derived on post-it notes.</description><pubDate>Fri, 01 Dec 2017 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Update: I haven’t used Windows for a long time now, and I’ve been wanting to revive this game, but couldn’t be bothered to migrate it to SDL2. Now, in 2026, I could finally just one-shot this migration with an AI agent. So my game from 9 years ago is playable again at &lt;a href=&quot;https://schmelczer.dev/platform-game&quot;&gt;schmelczer.dev/platform-game&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I titled my first proper university project &lt;em&gt;Platform game&lt;/em&gt;. I’ve clearly come a long way since then, yet one thing hasn’t changed: I’m still bad at naming things. Back in 2017, I created an actually fun 3D game written in pure C with the help of &lt;a href=&quot;https://www.libsdl.org/&quot;&gt;SDL&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; 1.2.&lt;/p&gt;
&lt;p&gt;I’d been wanting to build it ever since I first downloaded &lt;a href=&quot;https://gamemaker.io&quot;&gt;GameMaker&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot; aria-label=&quot;Footnote 3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; and realised that &lt;strong&gt;I&lt;/strong&gt; could build a video game. I’d tried already with &lt;a href=&quot;https://schmelczer.dev/articles/avoid-early-web-game/&quot;&gt;Avoid&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot; aria-label=&quot;Footnote 4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;, and &lt;em&gt;Platform game&lt;/em&gt; is essentially its 3D rewrite.&lt;/p&gt;
&lt;p&gt;The maps are randomly generated, but always so that the jumps between platforms stay possible given the user-adjustable gravity and jump-strength settings. And the map is fully destructible, voxel by voxel! This lets you build structures to hide from the flying enemies chasing you, although once the enemies have merged and grown larger, they can destroy the terrain too. After collecting enough power-ups, you can shoot back, and even slow down time in exchange for losing some points: a strategic trade-off.&lt;/p&gt;
&lt;p&gt;One feature I distinctly recall is the save file format. Initially, it dumped the entire procedurally generated map into a file, so a single save could reach hundreds of megabytes. I ended up discovering run-length encoding; as I later learnt, I wasn’t the first person to come up with it. It exploits the spatial similarity of the map, mainly air on top and ground at the bottom, and it massively reduced the file sizes. One thing I didn’t end up discovering during this project is how to do computer graphics properly: I settled on a software renderer that’s only capable of drawing cubes, and derived its 3D-to-2D projection using trigonometry and a lot of post-it notes. Doing it properly had to wait a few more years, for &lt;a href=&quot;https://schmelczer.dev/articles/sdf-2d-ray-tracing/&quot;&gt;a 2D ray tracer&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot; aria-label=&quot;Footnote 5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;I’ve made the source code publicly available, though more as a reminder for myself than as something for others to learn from. Even if this project is far from elegant or efficient, it resulted in a genuinely fun game, and it made me fully realise my passion for programming.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;&lt;a href=&quot;https://schmelczer.dev/platform-game&quot;&gt;https://schmelczer.dev/platform-game&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;SDL: &lt;a href=&quot;https://www.libsdl.org/&quot;&gt;https://www.libsdl.org/&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;GameMaker: &lt;a href=&quot;https://gamemaker.io&quot;&gt;https://gamemaker.io/&lt;/a&gt;&lt;a href=&quot;#fnref-3&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 3 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-4&quot;&gt;Avoid: &lt;a href=&quot;https://schmelczer.dev/articles/avoid-early-web-game/&quot;&gt;https://schmelczer.dev/articles/avoid-early-web-game/&lt;/a&gt;&lt;a href=&quot;#fnref-4&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 4 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-5&quot;&gt;a 2D ray tracer: &lt;a href=&quot;https://schmelczer.dev/articles/sdf-2d-ray-tracing/&quot;&gt;https://schmelczer.dev/articles/sdf-2d-ray-tracing/&lt;/a&gt;&lt;a href=&quot;#fnref-5&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 5 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>games</category><category>graphics</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>Avoid</title><link>https://schmelczer.dev/articles/avoid-early-web-game/</link><guid isPermaLink="true">https://schmelczer.dev/articles/avoid-early-web-game/</guid><description>My first browser game, and later the handout for SchDesign&apos;s JS/Canvas workshop at BME. It&apos;s trivial, yet I still enjoy playing it, so it made the cut.</description><pubDate>Sun, 01 Jan 2017 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My first browser game. You’re a dot; other dots pour in from the edges and chase you. Your only defence is to out-manoeuvre your enemies until they crash into each other. That’s the entire game, and it’s genuinely fun for the first few minutes: a decent return on so little code.&lt;/p&gt;
&lt;p&gt;It was also my first time touching the &lt;code&gt;&amp;#x3C;canvas&gt;&lt;/code&gt; element. I didn’t know it then, but that one API started an obsession with graphics programming that hasn’t let go since.&lt;/p&gt;
&lt;p&gt;A few months later the game got a second life as the handout for our design society’s web workshop, where we rebuilt it step by step with people who’d never drawn a pixel from code before. It turned out to be exactly the right size for that: small enough to finish in a few hours, alive enough to feel worth finishing. The handout’s one piece of process advice (get it working first, make it pretty after) is still roughly the only process I follow.&lt;/p&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>games</category><category>graphics</category><category>web</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>Photo Site Generator</title><link>https://schmelczer.dev/articles/photo-site-generator/</link><guid isPermaLink="true">https://schmelczer.dev/articles/photo-site-generator/</guid><description>My photo portfolio site: once PHP on a Raspberry Pi, now a Vite script turning a folder of JPEGs into static HTML. Publishing a photo costs one git push.</description><pubDate>Thu, 01 Dec 2016 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I bought my first proper camera, a &lt;a href=&quot;https://en.wikipedia.org/wiki/Nikon_D3200&quot;&gt;Nikon D3200&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;, at fifteen with my first summer job’s salary, and I’ve been photographing whatever captures my imagination or sparks aesthetic joy ever since. I wanted a personal way to share the results without going through social media, which was all the excuse I needed to build a photo portfolio site.&lt;/p&gt;
&lt;p&gt;It started off with PHP running on a Raspberry Pi in my room. To keep maintenance simple, it has since evolved into a small Vite script generating static HTML and variously sized photos from a folder.&lt;/p&gt;
&lt;p&gt;The only rule for good tech is that it has to give me more value than the effort it takes to use it. The part of photography I enjoy is taking photos, so the website itself is just a nice-to-have: publishing has to cost me almost no time. Committing a JPEG into a Git repo and pushing it is the right amount of effort to expect in this case. It’s still one more step than the PHP version asked for, but I have taste in tech too, not just photography.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;Nikon D3200: &lt;a href=&quot;https://en.wikipedia.org/wiki/Nikon_D3200&quot;&gt;https://en.wikipedia.org/wiki/Nikon_D3200&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>tools</category><category>web</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>A Physics Practice App for the Hungarian Érettségi</title><link>https://schmelczer.dev/articles/fizika-erettsegi-practice-app/</link><guid isPermaLink="true">https://schmelczer.dev/articles/fizika-erettsegi-practice-app/</guid><description>How I learnt what good software actually means.</description><pubDate>Thu, 01 Sep 2016 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Producing value is the ultimate test of software. Users don’t care about the cool tech or the clever architecture; most often, they don’t even care about half the features. Users have a problem they wish your software could solve for them.&lt;/p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to The problem&quot; href=&quot;#the-problem&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My problem, and my classmates’, was the Hungarian physics érettségi, a university entrance exam. We were advised to start preparing two years ahead by solving questions from &lt;a href=&quot;https://www.oktatas.hu/kozneveles/erettsegi/feladatsorok&quot;&gt;past exams&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;. But the questions are randomly sampled from the whole curriculum. That makes it tedious to practise a single topic, and hard to tell whether a question you can’t answer is simply material you haven’t learnt yet.&lt;/p&gt;
&lt;h2 id=&quot;the-evolution-of-the-solution&quot;&gt;The evolution of the solution&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to The evolution of the solution&quot; href=&quot;#the-evolution-of-the-solution&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The solution was to manually extract the questions (and their diagrams) from twenty years of exam PDFs, a perfect way to procrastinate on studying. Then, together with the project’s main stakeholder (my physics teacher), I went through every question and categorised it by topic. Finally, I built a small interface over this database, and the site still runs at &lt;a href=&quot;https://fizika.schmelczer.dev&quot;&gt;fizika.schmelczer.dev&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;: it makes the questions searchable and generates mock exams drawn only from the topics a student should already have mastered.&lt;/p&gt;
&lt;p&gt;It’s one of my earliest projects, built with a rudimentary understanding of full-stack application development, yet for years it was my most popular app by both active users and time spent per user. It has stood the test of time, too: more than nine years on, it’s still moderately popular, and generations of students at my old school (PTE Babits Mihály Gyakorló Gimnázium) have used it to prepare for the same exam.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Plausible analytics dashboard showing 273 unique visitors in a 60-day window&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;3346&quot; height=&quot;1702&quot; src=&quot;https://schmelczer.dev/_astro/fizika-analytics.BMGTdws1_O8Njv.webp&quot; &gt;&lt;/p&gt;
&lt;p&gt;The app has gone through a few iterations. It started as the most spaghetti PHP one could imagine, coupled with a real (and absolutely unnecessary) MySQL database. But it worked. I got a really good score on the exam and left the site running for future students.&lt;/p&gt;
&lt;p&gt;Eventually, hosting a full-blown database became too expensive to justify, so I migrated everything to a static site served through Firebase. Then, year after year, students would write in asking how to add the latest questions, and walking each of them through the GitHub workflow got tiresome. So this year, I vibecoded a small CRUD admin interface that edits a JSON file on disk. The file is under version control, so nothing is lost should an edit go wrong. We’re back to running a backend server, but &lt;a href=&quot;https://schmelczer.dev/articles/frame-eink-photo-display/&quot;&gt;self-hosting compounds&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot; aria-label=&quot;Footnote 3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;: I already had an established self-hosting setup, so the marginal cost was negligible. Happy users, happy maintainer.&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;a class=&quot;heading-anchor&quot; aria-label=&quot;Permalink to Conclusion&quot; href=&quot;#conclusion&quot;&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;There are two takeaways here. First, data can be vastly more important than the app wrapping it: Fizika’s wrapper has been replaced twice without changing the core value it provides. Second, there’s building software for the thrill of the engineering challenge, and there’s building it to create value at little cost. This project taught me both.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;past exams: &lt;a href=&quot;https://www.oktatas.hu/kozneveles/erettsegi/feladatsorok&quot;&gt;https://www.oktatas.hu/kozneveles/erettsegi/feladatsorok&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;&lt;a href=&quot;https://fizika.schmelczer.dev&quot;&gt;https://fizika.schmelczer.dev/&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;self-hosting compounds: &lt;a href=&quot;https://schmelczer.dev/articles/frame-eink-photo-display/&quot;&gt;https://schmelczer.dev/articles/frame-eink-photo-display/&lt;/a&gt;&lt;a href=&quot;#fnref-3&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 3 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>tools</category><category>web</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item><item><title>My First Real Project: LEDs Driven by an FFT</title><link>https://schmelczer.dev/articles/lights-synchronised-to-music/</link><guid isPermaLink="true">https://schmelczer.dev/articles/lights-synchronised-to-music/</guid><description>A Raspberry Pi music player that drove RGB LED strips through MOSFETs.</description><pubDate>Tue, 01 Mar 2016 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My first ever project, and the one that paved the way to the &lt;a href=&quot;https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/&quot;&gt;ATtiny85 handheld&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot; aria-label=&quot;Footnote 1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; four years later.&lt;/p&gt;
&lt;p&gt;Back when I bought my first Raspberry Pi (a &lt;a href=&quot;https://en.wikipedia.org/wiki/Raspberry_Pi&quot;&gt;Raspberry Pi 1 Model A&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot; aria-label=&quot;Footnote 2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;, to be exact), I had no idea what I was doing. After burning myself both figuratively and literally trying to drive a 12 V RGB LED strip, I understood how to wire up MOSFETs, and then how to control them with PWM. The obvious next step was to make the LED strip do something. I’d seen LEDs react to music before. Then I stumbled upon &lt;a href=&quot;https://betterexplained.com/articles/an-interactive-guide-to-the-fourier-transform/&quot;&gt;an article on the FFT&lt;/a&gt;&lt;sup class=&quot;footnote-ref&quot;&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot; aria-label=&quot;Footnote 3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;, thoroughly arcane to me at the time, and with that, I had all the building blocks to do it myself.&lt;/p&gt;
&lt;p&gt;Without quite meaning to, I’d started with a real full-stack application: I learnt about Ajax and XMLHttpRequest, got fascinated by CSS, and saw that programming is like magic in real life. I wish more kids got a similar first impression of programming, one that sparks their curiosity for building.&lt;/p&gt;&lt;section class=&quot;footnotes&quot; aria-label=&quot;Footnotes&quot;&gt;&lt;ol&gt;&lt;li id=&quot;fn-1&quot;&gt;ATtiny85 handheld: &lt;a href=&quot;https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/&quot;&gt;https://schmelczer.dev/articles/ad-astra-attiny85-game-engine/&lt;/a&gt;&lt;a href=&quot;#fnref-1&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 1 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-2&quot;&gt;Raspberry Pi 1 Model A: &lt;a href=&quot;https://en.wikipedia.org/wiki/Raspberry_Pi&quot;&gt;https://en.wikipedia.org/wiki/Raspberry_Pi&lt;/a&gt;&lt;a href=&quot;#fnref-2&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 2 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;li id=&quot;fn-3&quot;&gt;an article on the FFT: &lt;a href=&quot;https://betterexplained.com/articles/an-interactive-guide-to-the-fourier-transform/&quot;&gt;https://betterexplained.com/articles/an-interactive-guide-to-the-fourier-transform/&lt;/a&gt;&lt;a href=&quot;#fnref-3&quot; class=&quot;footnote-back&quot; aria-label=&quot;Back to footnote 3 in the text&quot;&gt;↩&lt;/a&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;</content:encoded><dc:creator>Andras Schmelczer</dc:creator><category>embedded</category><category>tools</category><author>andras@schmelczer.dev (Andras Schmelczer)</author></item></channel></rss>