<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://thomasburgess.dev">
  <title>Thomas Burgess</title>
  <link href="https://thomasburgess.dev/feed.xml" rel="self"/>
  <link href="https://thomasburgess.dev"/>
  <updated>2026-06-08T18:52:57Z</updated>
  <id>https://thomasburgess.dev</id><entry>
    <title>Resume</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/work/resume/</id>
    <content type="html">&lt;h1 id=&quot;thomas-burgess&quot; tabindex=&quot;-1&quot;&gt;Thomas Burgess&lt;/h1&gt;
&lt;p&gt;Email: me@thomasburgess.dev&lt;br /&gt;
&lt;a href=&quot;https://github.com/ThomasBurgess2000&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;https://github.com/ThomasBurgess2000&lt;/a&gt;&lt;br /&gt;
&lt;a href=&quot;https://www.linkedin.com/in/jthomasburgess&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;https://www.linkedin.com/in/jthomasburgess&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id=&quot;lead-software-engineer&quot; tabindex=&quot;-1&quot;&gt;Lead Software Engineer&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;MeetKai, Jan 2025 - Present&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Single-handedly made the iOS and Android companion apps, the backend voice services, the &lt;a href=&quot;https://wearables-website.vercel.app/&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;marketing website&lt;/a&gt;, and customized the RTOS firmware for an upcoming AR glasses platform.&lt;/li&gt;
&lt;li&gt;Architecting a scalable API for our AI platform, with a focus on sandbox infrastructure for agents.&lt;/li&gt;
&lt;li&gt;Creating web and mobile apps that build on our AI platform&#39;s SDK.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;technical-lead&quot; tabindex=&quot;-1&quot;&gt;Technical Lead&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;MeetKai, Dec 2020 - Jan 2025&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lead development of &lt;a href=&quot;https://bitnile.com/&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;bitnile.com&lt;/a&gt;, architecting full-stack solutions using Next.js and Babylon.js.&lt;/li&gt;
&lt;li&gt;Manage a team of 8 engineers, driving technical decisions and mentoring junior developers.&lt;/li&gt;
&lt;li&gt;Architected and implemented browser-based metaverse platform, for companies such as &lt;a href=&quot;https://byd-metaverse.mkms.io/&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;BYD&lt;/a&gt;, &lt;a href=&quot;https://www.youtube.com/watch?v=V8zJ_4dDaD8&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;Sony&lt;/a&gt;, &lt;a href=&quot;https://www.youtube.com/watch?v=ZKZfZXieDXw&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;DRL&lt;/a&gt; and the &lt;a href=&quot;https://hornets.mkms.io/?map_id=1676933990&amp;amp;shard_id=76&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;Charlotte Hornets&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;data-team-lead&quot; tabindex=&quot;-1&quot;&gt;Data Team Lead&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;MeetKai, Jul 2021 - Mar 2022&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Led team of 3 engineers in building data acquisition pipelines.&lt;/li&gt;
&lt;li&gt;Wrote systems in Go and TypeScript to scrape, parse, and export data from various sites at scale.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;software-engineer&quot; tabindex=&quot;-1&quot;&gt;Software Engineer&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;MeetKai, Dec 2020 - Jun 2021&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Worked on the backend team.&lt;/li&gt;
&lt;li&gt;Wrote code in Go and Python for NLP, data aggregation, and automation features.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;software-engineer-intern&quot; tabindex=&quot;-1&quot;&gt;Software Engineer Intern&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Lockheed Martin Aeronautics, May 2020 - Dec 2020&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Worked on the F-35 weapons team, completing coding, testing, and debugging tasks in C++.&lt;/li&gt;
&lt;li&gt;Led effort to automate the build and test processes using Jenkins and Python.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3 id=&quot;bachelor-of-computer-science&quot; tabindex=&quot;-1&quot;&gt;Bachelor of Computer Science&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Biola University&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3.9 GPA, Stewart Science Honors Program, Torrey Honors College&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;Experienced with &lt;strong&gt;TypeScript&lt;/strong&gt;, &lt;strong&gt;Next.js&lt;/strong&gt;, &lt;strong&gt;Go&lt;/strong&gt;, &lt;strong&gt;Temporal&lt;/strong&gt;, &lt;strong&gt;React&lt;/strong&gt;, &lt;strong&gt;Python&lt;/strong&gt;, &lt;strong&gt;Babylon.js&lt;/strong&gt;, &lt;strong&gt;Prisma&lt;/strong&gt;, &lt;strong&gt;C++&lt;/strong&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/work/resume/"/>
  </entry><entry>
    <title>How to Use an Ultrasonic Sensor with RPi and C++</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/raspberry-pi/how-to-use-an-ultrasonic-sensor-with-r-pi-and-c/</id>
    <content type="html">&lt;p&gt;Follow this wiring diagram for the HC-SR04 ultrasonic sensor: &lt;img src=&quot;https://thomasburgess.dev/img/user/Dev%20Log/attachments/Pasted%20image%2020241203012203.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I used 1KΩ and 2KΩ resistors instead of 330Ω and 470Ω (because it&#39;s what I had on hand). Here&#39;s what that looked like:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://thomasburgess.dev/img/user/Dev%20Log/attachments/Pasted%20image%2020241203014349.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Test the wiring with this simple Python script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from gpiozero import DistanceSensor
from time import sleep

ultrasonic = DistanceSensor(echo=17, trigger=4, max_distance=4)

while True:
    print(f&amp;quot;{ultrasonic.distance * 100:.1f} cm&amp;quot;)
    sleep(1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then install &lt;a href=&quot;https://github.com/WiringPi/WiringPi&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;WiringPi&lt;/a&gt; , the fastest RPi GPIO access library:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/WiringPi/WiringPi.git
cd WiringPi
./build debian
mv debian-template/wiringpi_3.10_arm64.deb .
sudo apt install ./wiringpi_3.10_arm64.deb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a file with this program:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt; &amp;lt;wiringPi.h&amp;gt;
&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt; &amp;lt;iostream&amp;gt;
&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt; &amp;lt;iomanip&amp;gt;
&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt; &amp;lt;unistd.h&amp;gt;
  
&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#define&quot;&gt;#define&lt;/a&gt; TRIGGER_PIN 4    // GPIO 4
&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#define&quot;&gt;#define&lt;/a&gt; ECHO_PIN    17   // GPIO 17
&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#define&quot;&gt;#define&lt;/a&gt; TIMEOUT     25000 // Maximum time to wait for echo (in microseconds)
  
double measureDistance() {
    // Send trigger pulse
    digitalWrite(TRIGGER_PIN, LOW);
    delayMicroseconds(2);
    digitalWrite(TRIGGER_PIN, HIGH);
    delayMicroseconds(10);
    digitalWrite(TRIGGER_PIN, LOW);
    
    // Wait for echo start
    while (digitalRead(ECHO_PIN) == LOW);
    long startTime = micros();
    
    // Wait for echo end
    while (digitalRead(ECHO_PIN) == HIGH &amp;amp;&amp;amp; micros() - startTime &amp;lt; TIMEOUT);
    long endTime = micros();

    // Calculate distance
    double duration = (endTime - startTime);
    double distance = (duration * 0.0343) / 2; // Speed of sound = 343 m/s
    return distance;
}

  

int main() {
    // Initialize WiringPi
    if (wiringPiSetupGpio() == -1) {
        std::cerr &amp;lt;&amp;lt; &amp;quot;Failed to initialize WiringPi&amp;quot; &amp;lt;&amp;lt; std::endl;
        return 1;
    }

    // Setup pins
    pinMode(TRIGGER_PIN, OUTPUT);
    pinMode(ECHO_PIN, INPUT);

    while (true) {
        double distance = measureDistance();
        std::cout &amp;lt;&amp;lt; std::fixed &amp;lt;&amp;lt; std::setprecision(1)
                  &amp;lt;&amp;lt; distance &amp;lt;&amp;lt; &amp;quot; cm&amp;quot; &amp;lt;&amp;lt; std::endl;
        sleep(1);
    }

    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compile it (linking wiringPi):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;g++ -o ultrasonic main.cpp -l wiringPi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./ultrasonic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If everything worked correctly, you should see something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;66.7 cm
66.2 cm
65.8 cm
4.8 cm
24.4 cm
23.6 cm
4.9 cm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All code available on &lt;a href=&quot;https://github.com/ThomasBurgess2000/ultrasonic-sensor-rpi&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;12.3.2024&lt;/em&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/raspberry-pi/how-to-use-an-ultrasonic-sensor-with-r-pi-and-c/"/>
  </entry><entry>
    <title>Tracking Camera with Head Animation</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/meet-kai/tracking-camera-with-head-animation/</id>
    <content type="html">&lt;p&gt;I wanted to make the player&#39;s head track with the camera. So first, I had to disconnect the animations from controlling the neck and overriding whatever rotations I was setting. Then I had to make the neck&#39;s rotation correspond to the rotation of the camera.&lt;/p&gt;
&lt;p&gt;Defining this behavior turned out to be quite difficult. Firstly, I could not merely match the rotation of the camera to the neck because they used different systems to determine rotation. The &lt;code&gt;ArcRotateCamera&lt;/code&gt; we are using has a target (the player) and 3 values to determine its position: alpha, beta, and radius. The neck on the other hand just uses a quaternion.&lt;/p&gt;
&lt;p&gt;I also wanted different behavior based on whether the camera was behind the player or in front of the player. If the camera was behind the player, I wanted the head to point to where the camera was looking, if the camera was in front of the player I wanted the head to look at the camera.&lt;/p&gt;
&lt;p&gt;So first I needed a way to determine whether the camera was in front of the player or behind the player. After trial and error, I determined that this was essentially converting the rotation measurements of the camera and the player to something I could directly compare.&lt;/p&gt;
&lt;p&gt;The way I described this was: imagine 2 CD&#39;s on a CD tower rotating independently of each other (the flattening here was important). Now draw a dot on the edge of the top CD and color in half of the bottom CD. Essentially what I was trying to do was determine when the dot on the top CD was over the colored-in portion of the bottom CD.&lt;/p&gt;
&lt;p&gt;This is how I got the direction that the player was facing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;let playerDirection = new Vector3(0, 0, -1);

playerDirection = playerDirection.rotateByQuaternionAroundPointToRef(playerRotation, new Vector3(0, 0, 0), playerDirection);

const playerDirectionInRadians = Math.atan2(playerDirection.x, playerDirection.z);

let normalizedPlayerDirection = playerDirectionInRadians % (2 * Math.PI);

if (normalizedPlayerDirection &amp;lt; 0) {
	normalizedPlayerDirection += 2 * Math.PI;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The alpha value for the camera was basically where the camera was in the horizontal orbit around the player. Normalizing this value to make sure the angle was between 0 and 2*Pi, correcting negative values, and then inverting it to reflect it over the axis made it match the player direction&#39;s format.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const alpha = camera.alpha;

let normalizedAlpha = (alpha + Math.PI / 2) % (2 * Math.PI);

if (normalizedAlpha &amp;lt; 0) {
	normalizedAlpha += 2 * Math.PI;
}

normalizedAlpha = 2 * Math.PI - normalizedAlpha;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I just had to get the absolute value of the difference between &lt;code&gt;normalizedAlpha&lt;/code&gt; and &lt;code&gt;normalizedPlayerDirection&lt;/code&gt; (and if the value was larger than Pi, subtract from 2*Pi to get the complementary angle, ensuring it represented the smallest angle between the two) and see if the value was greater or less than Pi/2. This would determine whether the camera was in front of or behind the player.&lt;/p&gt;
&lt;p&gt;Then based on this information, I would set the neck&#39;s X rotation value to the beta or -beta value of the camera (along with an offset and clamping the values so the player didn&#39;t look up or down too much).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;let correction = -1.1;

if (!isInFrontOfPlayer) {
	beta = -beta;
	correction = 1.9;
}

if (beta &amp;lt; 0.7 &amp;amp;&amp;amp; isInFrontOfPlayer) {
	beta = 0.7;
}

if (beta &amp;gt; -0.8 &amp;amp;&amp;amp; !isInFrontOfPlayer) {
	beta = -0.8;
}

const finalRotation = new Vector3(beta + correction, 0, 0);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While I was originally setting the neck rotation directly as described above, people complained that the neck&#39;s movement was too fast when the camera moved from the front of the player to the back (or vice versa) since it would involve a big rotation change. So I ended up sacrificing some responsiveness of the head movement for smoothness, by using a 1s animation with a cubic easing function to move the neck whenever the camera moved.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const finalQuaternion = Quaternion.FromEulerVector(finalRotation);

const easingFunction = new CubicEase();
easingFunction.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT);

Animation.CreateAndStartAnimation(
	&#39;neckRotationAnimation&#39;,
	window.localPlayer.neck,
	&#39;rotationQuaternion&#39;,
	60,
	60,
	window.localPlayer.neck.rotationQuaternion,
	finalQuaternion,
	Animation.ANIMATIONLOOPMODE_CONSTANT,
	easingFunction,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;7.27.23&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/meet-kai/tracking-camera-with-head-animation/"/>
  </entry><entry>
    <title>LLMs are 800x Cheaper for Translation than DeepL</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/meet-kai/ll-ms-are-800x-cheaper-for-translation-than-deep-l/</id>
    <content type="html">&lt;p&gt;When looking at the cost of translation APIs, I was floored by the prices. Azure is $10 per million characters, Google is $20, and DeepL is $25.&lt;/p&gt;
&lt;p&gt;To come up with a rough estimate for a real-time translation use case, I assumed 150 WPM speaking speed, with each word being translated 3 times (since the text gets retranslated multiple times as the context lengthens). This resulted in the following costs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Azure: $1.62/hr&lt;/li&gt;
&lt;li&gt;Google: $3.24/hr&lt;/li&gt;
&lt;li&gt;DeepL: $4.05/hr&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Assuming the same numbers, &lt;code&gt;gemini-2.0-flash-lite&lt;/code&gt; would cost &lt;strong&gt;less than $0.01/hr&lt;/strong&gt;. Cost varies based on prompt length, but I&#39;m actually getting just under $0.005/hr.&lt;/p&gt;
&lt;p&gt;That&#39;s over 800x cheaper than DeepL, or 0.1% of the cost.&lt;/p&gt;
&lt;p&gt;Presumably the quality of the translations would be somewhat worse, but how much worse? And how long will that disadvantage last? I can stomach a certain amount of worse for 99% cheaper, and it seems easy to foresee that LLMs will surpass the quality of the legacy translation models in the near future.&lt;/p&gt;
&lt;p&gt;Right now the accuracy depends a lot on the prompting. I need to run a lot more evals, but so far in my tests I&#39;m seeing that the translations I&#39;m getting are as good (most of the time identical) or &lt;em&gt;better&lt;/em&gt; than Google&#39;s the vast majority of the time. I&#39;m confident I can get to 90% of Google&#39;s accuracy with better prompting.&lt;/p&gt;
&lt;p&gt;I can live with 90% accuracy with a 99.9% cost reduction.&lt;/p&gt;
&lt;p&gt;For many, 90% doesn&#39;t cut it for their translation needs and they are willing to pay a premium for the best. But the high costs of legacy translation APIs will become increasingly indefensible as LLM-based solutions improve, and we&#39;ll see translation incorporated in ways that were previously cost-prohibitive.&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/meet-kai/ll-ms-are-800x-cheaper-for-translation-than-deep-l/"/>
  </entry><entry>
    <title>Sending Images to Smart Glasses via Bluetooth</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/meet-kai/ai-lens/sending-images-to-smart-glasses-via-bluetooth/</id>
    <content type="html">&lt;p&gt;I recently tackled the challenge of figuring out how to send images to a pair of smart glasses we&#39;re developing. The significant hurdle was the lack of direct access to the firmware and extremely limited documentation on available commands or their formats. All I initially had was an outdated APK that no longer worked and some partial source code that only supported basic functionality.&lt;/p&gt;
&lt;p&gt;This project was entirely new territory for me—I&#39;d never worked with Bluetooth protocols, real-time operating systems (RTOS), or even Android development extensively. Fortunately, the availability of powerful language models (LLMs) made the exploration feasible.&lt;/p&gt;
&lt;h3 id=&quot;initial-attempts&quot; tabindex=&quot;-1&quot;&gt;Initial Attempts&lt;/h3&gt;
&lt;p&gt;I began by sending commands based on the limited documentation, hoping to trigger an image display. These attempts failed completely, giving no response or indication from the glasses that anything had been received—akin to shouting into a void.&lt;/p&gt;
&lt;h3 id=&quot;reverse-engineering&quot; tabindex=&quot;-1&quot;&gt;Reverse Engineering&lt;/h3&gt;
&lt;p&gt;Realizing I needed deeper insight into what code could possibly be running on the glasses, I decompiled an old Android APK to investigate the original implementation for sending commands. Surprisingly, I discovered the APK didn&#39;t contain a working example of sending images. Instead, I found unused code snippets that suggested the capability existed at one point, but it had not been completed in this version of the app.&lt;/p&gt;
&lt;p&gt;I noticed there &lt;em&gt;were&lt;/em&gt; commands for drawing primitives (lines, rectangles, text) with clear examples, yet nothing similar for images. I reconstructed packet headers by referencing working commands and attempted to modify them to accommodate image data, but initially, this yielded no results.&lt;/p&gt;
&lt;h3 id=&quot;leveraging-uui-ds-and-subscriptions&quot; tabindex=&quot;-1&quot;&gt;Leveraging UUIDs and Subscriptions&lt;/h3&gt;
&lt;p&gt;Next, I shifted my approach toward trying to get any sort of feedback from the glasses. I learned that when connecting via Bluetooth Low Energy (BLE), devices respond with a list of subscription UUIDs. Unfortunately, these UUIDs lacked descriptive documentation.&lt;/p&gt;
&lt;p&gt;By systematically logging every UUID interaction, I gradually identified patterns correlating specific UUIDs to particular functionalities. Even though the returned data was raw bytes and challenging to interpret directly, logging helped me detect responses indicating malformed requests. This marked significant progress—I finally had confirmation that the glasses were at least receiving and processing my messages.&lt;/p&gt;
&lt;h3 id=&quot;image-format-and-compression-issues&quot; tabindex=&quot;-1&quot;&gt;Image Format and Compression Issues&lt;/h3&gt;
&lt;p&gt;Initially, I assumed the issue was image formatting. Since the APK command for image drawing was unused, I had no guidance on the expected image format. Additionally, the image underwent compression via a native binary library written in C—completely opaque to my investigation efforts.&lt;/p&gt;
&lt;p&gt;Unable to decipher the compression algorithm through online searches, I extracted and incorporated the native binary directly into my workflow, hoping to correctly mimic the required compression step. Despite my efforts, repeated &amp;quot;malformed request&amp;quot; errors continued.&lt;/p&gt;
&lt;h3 id=&quot;breakthrough-with-header-adjustment&quot; tabindex=&quot;-1&quot;&gt;Breakthrough with Header Adjustment&lt;/h3&gt;
&lt;p&gt;I revisited the header configurations and realized I had mistakenly used a &amp;quot;draw canvas&amp;quot; command instead of the correct &amp;quot;raw image&amp;quot; command. Correcting this mistake resulted in the glasses returning a success message—my first tangible indication that the protocol was partially correct.&lt;/p&gt;
&lt;p&gt;However, despite receiving success confirmations, no image appeared on the glasses. Suspecting color issues, I experimented by switching the bitmap color from black to white. Immediately, a visible line appeared on the glasses&#39; screen.&lt;/p&gt;
&lt;h3 id=&quot;line-to-box&quot; tabindex=&quot;-1&quot;&gt;Line to Box&lt;/h3&gt;
&lt;p&gt;However, I expected a 16x16 box and not a line. I discovered that for RAW_IMAGE messages the glasses expect every pixel to be defined. So when it received the 256 bytes, it started placing them on the first row of pixels. So if I wanted just a box in the middle of the screen, I needed to pad the box with black pixels.&lt;/p&gt;
&lt;p&gt;This means sending a &lt;em&gt;lot&lt;/em&gt; more pixels, which meant I immediately required sending multiple packets to display a single image on the screen. This broke the CRC calculation, because I was only calculating the CRC once for the whole image data, and apparently this firmware requires the CRC to be calculated for each packet. But after that, it worked.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;7.2.25&lt;/em&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/meet-kai/ai-lens/sending-images-to-smart-glasses-via-bluetooth/"/>
  </entry><entry>
    <title>Building Smart Glasses as a Solo Engineer</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/meet-kai/ai-lens/building-smart-glasses-as-a-solo-engineer/</id>
    <content type="html">&lt;p&gt;For the better part of the last year, I have been the principal engineer (and for most of the project, the &lt;em&gt;only&lt;/em&gt; engineer) for an upcoming smart glasses product. I&#39;ve had to customize the firmware, build the app for communicating with the glasses, and build the backend services. This is despite having no previous work experience in hardware, app development, or three of the six programming languages I would end up using (C++, Go, Python, Kotlin, TypeScript, Swift).&lt;/p&gt;
&lt;p&gt;I want to give credit where credit is due right at the outset: I could not have done this without the &lt;em&gt;massive&lt;/em&gt; assistance of LLMs. The impact a single moderately skilled engineer can make in areas totally outside their realm of expertise is beyond what I thought possible.&lt;/p&gt;
&lt;p&gt;I was so surprised at the scope of what I have built so far that I decided to document it at a high level, primarily for my own later recollection:&lt;/p&gt;
&lt;h2 id=&quot;dev-log&quot; tabindex=&quot;-1&quot;&gt;Dev Log&lt;/h2&gt;
&lt;p&gt;We are sourcing glasses from China that run a simple RTOS (real-time operating system) and have dual waveguide displays, speakers, and microphones. The firmware is basic, primarily consisting of Bluetooth connection code, a GUI for navigating an app menu, and BLE commands to send content to the glasses. The compiled binary is about ~3mb. The apps that were in the firmware were basically templates. For example, there were pre-made UI components for chat bubbles, but anything that would appear in those chat bubbles had to be sent from the phone. The glasses themselves have no internet access.&lt;/p&gt;
&lt;p&gt;Thankfully the firmware edits were relatively minor, given the firmware is extremely painful to build and update OTA whenever I make a change. It&#39;s written in C++ and requires proprietary build software ($200 a month) to compile. I fixed some bugs (such as the pairing screen not being dismissible after the connection dropped temporarily), updated a couple UI components (through the extremely unintuitive &amp;quot;GUIX Studio&amp;quot;), and Americanized some aspects (display 12hr time formatting, temperature in Fahrenheit, etc.). The ability to dump the entire firmware into Cursor/Codex/Gemini CLI (whatever happens to be leading at the time) is priceless. It&#39;s a massive codebase whose structure I do not know, but I am able to ask things like &amp;quot;where are all the places that deal with navigation&amp;quot; and dig in from there.&lt;/p&gt;
&lt;p&gt;The app has been the real challenge. We decided to build it as an Expo app to be cross-platform across iOS and Android, but given the nature of the app, I have to dive into native code for each platform quite often. At the time of writing actually, I&#39;ve really only done the Android side of things, with some cursory research on how I might approach it for iOS.&lt;/p&gt;
&lt;p&gt;If you&#39;re unfamiliar with how Bluetooth works, there&#39;s not just one protocol called &amp;quot;Bluetooth.&amp;quot; There are a &lt;em&gt;ton&lt;/em&gt; of protocols that a device could possibly use to communicate over Bluetooth. Discovering the intricacies of how to achieve consistent pairing, connecting, and communicating with the glasses was...a process. I had no documentation on the firmware besides an incomplete list of BLE (Bluetooth Low Energy) commands the glasses supported, and the rest I had to figure out by reading the firmware itself. And of course, this all had to be done through native code and is completely different on iOS and Android.&lt;/p&gt;
&lt;p&gt;For most of what I did, I was only concerned with BLE. This covered things like sending the text to display on the glasses or setting the brightness level.&lt;/p&gt;
&lt;p&gt;But I also had to make the glasses work as regular Bluetooth headphones. It took me a while to figure this out, but I eventually discovered the firmware blocked the ability to pair the glasses as headphones through a phone&#39;s native Bluetooth settings until the user had paired the glasses through the vendor&#39;s app, an intentional decision on the manufacturer&#39;s part.&lt;/p&gt;
&lt;p&gt;I knew that in order to work as headphones, I had to connect two other Bluetooth profiles: A2DP and HFP. However, they had made it so these profiles were not available until you had connected &lt;em&gt;another&lt;/em&gt; profile called SPP through the app.&lt;/p&gt;
&lt;p&gt;This is different behavior than the Meta Ray-Bans for example, which you can pair as a regular Bluetooth headset without having to first pair the glasses through the Meta AI app. I think the theory was that customers would be confused if they paired the glasses through their phone&#39;s Bluetooth settings and audio worked, but then the glasses still complained about not being paired (since they still lacked the BLE connection).&lt;/p&gt;
&lt;p&gt;Anyway, even once I figured that out, managing pairing state was tricky because of what iOS and Android let you see about the status of Bluetooth devices connected to the user&#39;s phone. For example, Android lets you see which devices are &amp;quot;bound&amp;quot; to the device (have ever been paired) but doesn&#39;t let you see which are currently connected. iOS doesn&#39;t let you see anything.&lt;/p&gt;
&lt;p&gt;I had to create a flow in the app that would let the user establish BLE, SPP, A2DP and HFP connections to the glasses as simply as possible. On Android, this looked like sending a pairing request to the glasses, long-press confirming on the glasses (this completed BLE pairing), then adding the SPP connection, and then prompting the user to accept a pairing notification from the phone&#39;s native Bluetooth settings (for A2DP and HFP). As far as I can tell, this is as simple as I can make it, mirroring the behavior of the Meta Ray-Bans closely.&lt;/p&gt;
&lt;p&gt;And that was just the initial connection. Knowing the state of the glasses at all times (are they connected over BLE but not A2DP/HFP? are they disconnected from SPP? did they disconnect because the user unpaired them in their phone&#39;s Bluetooth settings, because they got too far from the phone, because they turned off Bluetooth, or...?) involved quite the state machine.&lt;/p&gt;
&lt;p&gt;But once I gave the phone the ability to talk to the glasses, I had to figure out what to say and how to say it. I had to figure out how to format the packets, how to sign them with CRC, how to split large payloads across chunks, what custom form of run-length encoding they were using for images, etc. etc. This was probably what I actually spent the most time on, because I actually worked on prototyping the app for a long time before I had access to the firmware itself. See &lt;a href=&quot;https://thomasburgess.dev/Sending%20Images%20to%20Smart%20Glasses%20via%20Bluetooth.md&quot; class=&quot;internal-link&quot;&gt;Sending Images to Smart Glasses via Bluetooth&lt;/a&gt; for a full description of how I tried reverse-engineering one of the commands.&lt;/p&gt;
&lt;p&gt;Once I knew how to communicate with the glasses, I needed to actually make them useful. We decided on an initial list of features we thought were achievable in a couple months:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Translation&lt;/li&gt;
&lt;li&gt;Chat&lt;/li&gt;
&lt;li&gt;Listener&lt;/li&gt;
&lt;li&gt;Live Captions&lt;/li&gt;
&lt;li&gt;Notifications&lt;/li&gt;
&lt;li&gt;Calls&lt;/li&gt;
&lt;li&gt;Weather&lt;/li&gt;
&lt;li&gt;Music&lt;/li&gt;
&lt;li&gt;Navigation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I will probably just link to a separate post about how I built the backend services for Translation, Chat, Listener, and Live Captions. If there&#39;s not a link here yet, it&#39;s because I haven&#39;t written that post. The gist is it was originally written in Python, then rewritten in Go, and had some neat techniques to achieve translation across multiple languages simultaneously in real-time.&lt;/p&gt;
&lt;p&gt;The more standard smartwatch-like features that could live offline were more straightforward. Calling and Music were actually implemented entirely within the firmware already. But there were still a couple interesting elements:&lt;/p&gt;
&lt;p&gt;For example, on Android you can get permission to read the user&#39;s notifications from other apps, allowing you to forward to the glasses for display. So all you have to do on the app side of things is let the user select which notifications they want to see, and forward those. On iOS however, Apple does not allow you to read the notifications from other apps. They have their own internal API they use for this sort of functionality with the Apple Watch, but they do not allow third-party developers access to it. Instead, they have their own old, poorly-documented method of broadcasting these notifications over Bluetooth directly to the paired devices that require them, bypassing the app. This meant it was harder to give the user control over the types of notifications they wanted to receive on the glasses, because instead of just being a menu in the app, it had to be done via firmware on the glasses.&lt;/p&gt;
&lt;p&gt;Another interesting feature was navigation. The obvious way to approach this would be to use a navigation API like Google Maps and have some sort of navigation integration within the app. However, Google Maps is not free and I figured users would prefer to not have to switch how they navigated simply to get it to display in the glasses. And I did not want to have to implement a whole nav app if if I didn&#39;t have to.&lt;/p&gt;
&lt;p&gt;So on Android, I leveraged the ability to read notifications again, and simply extracted the directions data from Google Maps notifications and forwarded them to the glasses, where they would be parsed and displayed. On Apple, this was not possible as already stated, so I&#39;m going to have to make an Apple Maps integration (thankfully the Apple MapsKit is free for use in iOS apps).&lt;/p&gt;
&lt;p&gt;Thus far I have MVPs of Translation, Chat, Listener, Notifications, and Navigation in the Android app, with Calling and Music being handled by the firmware.&lt;/p&gt;
&lt;p&gt;I have a teammate who might take implementing the iOS side of things, but they&#39;re busy on another project, so it&#39;s possible I&#39;ll be doing that as well.&lt;/p&gt;
&lt;p&gt;I&#39;ve really enjoyed getting to stretch my abilities and will be thrilled if the glasses actually end up in the hands of consumers by the end of the year.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;9.2.25&lt;/em&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/meet-kai/ai-lens/building-smart-glasses-as-a-solo-engineer/"/>
  </entry><entry>
    <title>(Multiplayer) Provable Fairness Explainer</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/meet-kai/multiplayer-provable-fairness-explainer/</id>
    <content type="html">&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Create a long list of random numbers that we can prove:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;We have not tampered with since it was created.&lt;/li&gt;
&lt;li&gt;We did not generate a lot of random number lists and then cherry pick the list that was most skewed in our favor.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Methodology:&lt;/strong&gt; We generate a long chain of hashes from an initial seed that we keep private.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function generateHash(input: string): string {
  return createHash(&amp;quot;sha256&amp;quot;).update(input).digest(&amp;quot;hex&amp;quot;);
}

function generateHashChain(serverSeed: string, length: number): string[] {
  let currentHash = serverSeed;
  const chain: string[] = [];

  for (let i = 0; i &amp;lt; length; i++) {
    currentHash = generateHash(currentHash);
    chain.push(currentHash);
  }
  return chain;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We publish the last hash of that chain. We then reverse that chain, and each game we shift the chain, take that hash, and salt it with a publicly disclosed &amp;quot;client&amp;quot; seed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function reverseHashChain(chain: string[]): string[] {
  return chain.reverse();
}

function saltHash(hash: string, clientSeed: string) {
  return createHmac(&amp;quot;sha256&amp;quot;, hash).update(clientSeed).digest(&amp;quot;hex&amp;quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We then use that salted hash to generate the random crash point for that game.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function generateCrashPoint(serverSeed: string, clientSeed: string): number {
  const hash = saltHash(serverSeed, clientSeed);

  if (shouldInstaCrash(hash, houseEdge)) {
    return 1;
  }

  const h = parseInt(hash.substring(0, 52 / 4), 16);
  const e = Math.pow(2, 52);

  return Math.floor((100 * e - h) / (e - h)) / 100;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each game we tell the user a hash of what the salted hash used to generate the next value will be. After the game, we reveal the actual hash used.Users can then verify that the hash chain has not been tampered with by first verifying that the secondary hash they were told before the game actually corresponds to the hash that was revealed after the game. Then they can put the revealed hash in a validation function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function validateFairness(hash: string, clientSeed: string, numGames: number = 10): void {
  const chain = generateHashChain(hash, numGames);
  const reversedChain = reverseHashChain(chain);

  console.log(`Validating fairness for the last ${numGames} games:`);

  reversedChain.forEach((gameHash, index) =&amp;gt; {
    const crashPoint = generateCrashPoint(gameHash, clientSeed);
    console.log(`Game ${index + 1}: Hash = ${gameHash}, Crash Point = ${crashPoint.toFixed(2)}`);
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This function generates the results of the preceding games leading up to the current one, and the user can verify they indeed match the history. If they generate all the preceding games, the hash of the very first one should be the hash that we publicly disclosed at launch (the last hash of the reversed chain).&lt;/p&gt;
&lt;p&gt;So all of the above just proves that we didn&#39;t tamper with the list once it was created. But to prove that we didn&#39;t cherry pick the list, the publicly disclosed client seed that we use to salt the chain with has to be something that we couldn&#39;t have picked.&lt;/p&gt;
&lt;p&gt;Typically companies have picked this client seed by publicly committing (normally on Twitter) to use the hash of a future Bitcoin block as the client seed. So this way they can commit to a number that they can&#39;t have known in advance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Most critical security vulnerability:&lt;/strong&gt;&lt;br /&gt;
The creation and storage of the pre-salted hash chain. The hash chain that we generate with our initial private seed has to be kept entirely secret, because whoever has access to it can predict the outcome of future games. I&#39;m not sure what the best way to handle this is. We need some secure way of retrieving the next element in the chain without being able to output the entire chain, even internally.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;4.19.2024&lt;/em&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/meet-kai/multiplayer-provable-fairness-explainer/"/>
  </entry><entry>
    <title>Handy Speech-to-Text to Tmux</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/handy-speech-to-text-to-tmux/</id>
    <content type="html">&lt;p&gt;I installed &lt;a href=&quot;https://github.com/cjpais/Handy&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;Handy&lt;/a&gt;, a local speech-to-text app, on Kubuntu. It uses ONNX models (parakeet-tdt) to transcribe speech locally. Getting it to work required fixing a couple of issues, and then I built a small watcher script to pipe transcriptions into a tmux session.&lt;/p&gt;
&lt;h2 id=&quot;fixing-the-crash&quot; tabindex=&quot;-1&quot;&gt;Fixing the Crash&lt;/h2&gt;
&lt;p&gt;Handy was crashing silently every time I pressed the shortcut to start recording. The log at &lt;code&gt;~/.local/share/com.pais.handy/logs/handy.log&lt;/code&gt; would just stop dead mid-recording with no error. No segfault in dmesg, no coredump, nothing.&lt;/p&gt;
&lt;p&gt;The root cause was that &lt;code&gt;pipewire-alsa&lt;/code&gt; wasn&#39;t installed. Handy uses CPAL (a Rust audio library) which goes through ALSA, but without the PipeWire-ALSA bridge, it couldn&#39;t route audio and would silently die. Running Handy from a terminal showed the errors:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ALSA lib pcm.c:2722:(snd_pcm_open_noupdate) Unknown PCM pipewire
ALSA lib pcm_dmix.c:1000:(snd_pcm_dmix_open) unable to open slave
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fix:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install pipewire-alsa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that, the ALSA errors went away and recording worked.&lt;/p&gt;
&lt;h2 id=&quot;push-to-talk-doesn-t-work-on-kde-wayland&quot; tabindex=&quot;-1&quot;&gt;Push-to-Talk Doesn&#39;t Work on KDE Wayland&lt;/h2&gt;
&lt;p&gt;Push-to-talk mode (hold key to record, release to stop) doesn&#39;t work properly on KDE Wayland. The key release event fires immediately, so the recording stops before any speech is captured. The log shows &lt;code&gt;TranscribeAction::start&lt;/code&gt; and &lt;code&gt;TranscribeAction::stop&lt;/code&gt; in the same second.&lt;/p&gt;
&lt;p&gt;This is because Handy falls back to a regular window on KDE Wayland (&lt;code&gt;Skipping GTK layer shell init on KDE Wayland&lt;/code&gt;) and global shortcut key-release detection is unreliable under Wayland&#39;s security model.&lt;/p&gt;
&lt;p&gt;Workaround: use toggle mode instead (&lt;code&gt;push_to_talk: false&lt;/code&gt;). Press once to start recording, press again to stop.&lt;/p&gt;
&lt;h2 id=&quot;handy-settings&quot; tabindex=&quot;-1&quot;&gt;Handy Settings&lt;/h2&gt;
&lt;p&gt;In Handy&#39;s settings I changed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Push to Talk&lt;/strong&gt;: disabled (toggle mode, because of the KDE Wayland issue)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Paste output&lt;/strong&gt;: disabled (so it only saves to history, doesn&#39;t paste into the focused window)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Debug mode&lt;/strong&gt;: enabled (for the extra logging while troubleshooting)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The settings file lives at &lt;code&gt;~/.local/share/com.pais.handy/settings_store.json&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;sending-transcriptions-to-a-tmux-session&quot; tabindex=&quot;-1&quot;&gt;Sending Transcriptions to a Tmux Session&lt;/h2&gt;
&lt;p&gt;I wanted to be able to dictate into a specific terminal without switching focus to it. The idea: keep working in whatever window I&#39;m in, press Ctrl+Space, speak, and have the text appear in a tmux session called &amp;quot;handy&amp;quot;.&lt;/p&gt;
&lt;p&gt;Handy saves every transcription to a SQLite database at &lt;code&gt;~/.local/share/com.pais.handy/history.db&lt;/code&gt;. The schema is simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE transcription_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    file_name TEXT NOT NULL,
    timestamp INTEGER NOT NULL,
    saved BOOLEAN NOT NULL DEFAULT 0,
    title TEXT NOT NULL,
    transcription_text TEXT NOT NULL,
    post_processed_text TEXT,
    post_process_prompt TEXT
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I wrote a watcher script at &lt;code&gt;~/.local/bin/handy-to-tmux&lt;/code&gt; that polls this database for new entries and sends them to the tmux session via &lt;code&gt;tmux send-keys&lt;/code&gt;. It debounces rapid writes (Handy sometimes writes partial results) so only the final transcription gets sent.&lt;/p&gt;
&lt;p&gt;Usage:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;handy-to-tmux start    # start watcher in background
handy-to-tmux stop     # stop it
handy-to-tmux status   # check if running
handy-to-tmux run      # run in foreground for debugging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It uses a pidfile at &lt;code&gt;~/.local/state/handy-to-tmux.pid&lt;/code&gt; to ensure only one instance runs at a time. Logs go to &lt;code&gt;~/.local/state/handy-to-tmux.log&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The tmux session just needs to exist first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tmux new-session -d -s handy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then whatever shell is running in that session receives the transcribed text as if it were typed in.&lt;/p&gt;
&lt;h2 id=&quot;targeting-different-tmux-sessions&quot; tabindex=&quot;-1&quot;&gt;Targeting Different Tmux Sessions&lt;/h2&gt;
&lt;p&gt;I added the ability to route transcriptions to different tmux sessions using separate keyboard shortcuts. The &lt;code&gt;target&lt;/code&gt; subcommand sets which session to send to and triggers Handy&#39;s recording in one step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;handy-to-tmux target code    # sets target to &amp;quot;code&amp;quot; and starts recording
handy-to-tmux target chat    # sets target to &amp;quot;chat&amp;quot; and starts recording
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The target persists in &lt;code&gt;~/.local/state/handy-to-tmux.target&lt;/code&gt; until changed, so subsequent Ctrl+Space presses (without setting a new target) continue going to the same session. Defaults to &amp;quot;handy&amp;quot; if no target is set.&lt;/p&gt;
&lt;p&gt;To set up per-session shortcuts, bind KDE global shortcuts to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Ctrl+Alt+1&lt;/code&gt; → &lt;code&gt;handy-to-tmux target handy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Ctrl+Alt+2&lt;/code&gt; → &lt;code&gt;handy-to-tmux target code&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;etc.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each shortcut starts recording and routes the result to its session. Press Ctrl+Space to stop recording.&lt;/p&gt;
&lt;h2 id=&quot;repo&quot; tabindex=&quot;-1&quot;&gt;Repo&lt;/h2&gt;
&lt;p&gt;Source is at &lt;a href=&quot;https://github.com/ThomasBurgess2000/handy-to-tmux&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;https://github.com/ThomasBurgess2000/handy-to-tmux&lt;/a&gt;. Install with &lt;code&gt;./install.sh&lt;/code&gt; which copies the script to &lt;code&gt;~/.local/bin/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;2.16.2026&lt;/em&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/handy-speech-to-text-to-tmux/"/>
  </entry><entry>
    <title>Deploying Next.js Apps to Local Debian Server</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/deploying-next-js-apps-to-local-debian-server/</id>
    <content type="html">&lt;p&gt;To deploy my Next.js app to my local Debian server while still having my domain point to it, I had to first enable port forwarding on my router. I forwarded 80 to 80 and 443 to 443. Don&#39;t do what some guides say and do 80 to 3000, I can&#39;t remember why, but I think it had something to do with nginx.&lt;/p&gt;
&lt;p&gt;I had to enable some ports through my &lt;code&gt;ufw&lt;/code&gt; firewall, I don&#39;t really remember which. I think one was &lt;code&gt;sudo ufw allow &#39;Nginx Full&#39;&lt;/code&gt;. Then &lt;code&gt;sudo ufw reload&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Speaking of, BEFORE STARTING THE NEXTJS app, go ahead and get nginx setup.&lt;/p&gt;
&lt;p&gt;This is what my &lt;code&gt;/etc/nginx/sites-available/nextjs-app&lt;/code&gt; looked like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {

    server_name mydomain.com www.mydomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then link it with &lt;code&gt;sudo ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/&lt;/code&gt;. Test with &lt;code&gt;sudo nginx -t&lt;/code&gt;. Restart with &lt;code&gt;sudo systemctl restart nginx&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I made sure my DNS records were set up to point to my IP:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Host: @, Type: A, Data: MY_IP_ADDRESS&lt;/li&gt;
&lt;li&gt;Host: www, Type: www, Data: MY_IP_ADDRESS&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then I ran &lt;code&gt;sudo certbot --nginx&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This would automatically update my nginx file to accept SSL, but after that I changed it to work with Next.js. This is what it looked like after certbot and the NextJS addition:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {

    server_name mydomain.com www.mydomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &#39;upgrade&#39;;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    listen [::]:443 ssl ipv6only=on; # managed by Certbot
    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/mydomain.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot


}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then when I had a built version of my app on the server, I ran &lt;code&gt;pm2 start yarn --name &amp;quot;app_name&amp;quot; -- start&lt;/code&gt; from within the app directory. You can set it to always start on server boot with &lt;code&gt;pm2 startup&lt;/code&gt;. Then save with &lt;code&gt;pm2 save&lt;/code&gt;. Now I can do like &lt;code&gt;pm2 start app_name&lt;/code&gt;, &lt;code&gt;pm2 stop app_name&lt;/code&gt;, &lt;code&gt;pm2 restart app_name&lt;/code&gt;, and &lt;code&gt;pm2 reload app_name&lt;/code&gt; (last one is supposedly zero downtime).&lt;/p&gt;
&lt;p&gt;After this, I was able to access my app at &lt;a href=&quot;https://mydomain.com/&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;https://mydomain.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;9.2.2024&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I then wanted to deploy a second app to my server, using the subdomain &lt;a href=&quot;http://example.anotherdomain.com/&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;example.anotherdomain.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In order to do this, I added a new A record in Cloudflare:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host: example
Type: A
Data: MY_IP_ADDRESS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I cloned the repo I wanted to run to my server, and started it with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pm2 start yarn --name &amp;quot;second_example_app&amp;quot; -- start
pm2 startup
pm2 save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I had to create a new Nginx server block:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nano /etc/nginx/sites-available/example.anotherdomain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where I put:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {

    server_name example.anotherdomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &#39;upgrade&#39;;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    listen [::]:443 ssl; # managed by Certbot
    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/example.anotherdomain.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/example.anotherdomain.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}
server {
    if ($host = example.anotherdomain.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    listen 80;
    listen [::]:80;

    server_name example.anotherdomain.com;
    return 404; # managed by Certbot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I enabled the server block with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ln -s /etc/nginx/sites-available/example.anotherdomain.com /etc/nginx/sites-enabled
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tested with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nginx -t
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reloaded nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Got the SSL certificate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo certbot --nginx -d example.anotherdomain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restarted the app for good measure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pm2 restart second_example_app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;12.1.2024&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I make no promises as to the security of either of these methods. If there are glaring vulnerabilities or easy ways to harden these, please shoot me an email at &lt;a href=&quot;mailto:me@thomasburgess.dev&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;me@thomasburgess.dev&lt;/a&gt;.&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/deploying-next-js-apps-to-local-debian-server/"/>
  </entry><entry>
    <title>Converting a Shader from Three.js to Babylon.js</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/dev-log/converting-a-shader-from-three-js-to-babylon-js/</id>
    <content type="html">&lt;h2 id=&quot;in-search-of-the-perfect-tree&quot; tabindex=&quot;-1&quot;&gt;In Search of the Perfect Tree&lt;/h2&gt;
&lt;p&gt;There have been many, many attempts at creating a forest of performant trees in Babylon.js, to the point that there&#39;s a dedicated &lt;a href=&quot;https://doc.babylonjs.com/communityExtensions/treeGenerators&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;&amp;quot;Tree Generators&amp;quot;&lt;/a&gt; page in the official documentation.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://thomasburgess.dev/img/user/Dev%20Log/attachments/Pasted%20image%2020231219033129.png&quot; alt=&quot;Tree generators in Babylon documentation&quot; /&gt;&lt;/p&gt;
&lt;p&gt;However, I was not impressed by the looks of the existing solutions, and went in search of a higher quality tree.&lt;/p&gt;
&lt;p&gt;I came across this blog post titled &lt;a href=&quot;https://douges.dev/blog/threejs-trees-1&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;&amp;quot;Creating fluffy trees with Three.js&amp;quot;&lt;/a&gt; and it instantly stood out visually. It used a custom GLSL shader applied to an extremely basic low-poly model of a tree, and the result was a beautiful, stylized, and performant tree.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://thomasburgess.dev/img/user/Dev%20Log/attachments/Untitled.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;At this point I knew &lt;em&gt;nothing&lt;/em&gt; about shaders, and assumed I could just copy over the shader code into BJS and it would work. This turned out to not quite be the case, so I&#39;m going to walk through the steps I had to take to make the shader Babylon-compatible.&lt;/p&gt;
&lt;h2 id=&quot;using-glsl-in-babylon-js&quot; tabindex=&quot;-1&quot;&gt;Using GLSL in Babylon.js&lt;/h2&gt;
&lt;p&gt;First I had to figure out how to use GLSL shaders in Babylon. The &lt;a href=&quot;https://doc.babylonjs.com/features/featuresDeepDive/materials/shaders/shaderCodeInBjs&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;docs provide four ways&lt;/a&gt; of importing GLSL code into your project, and I found the easiest way to be to simply copy the code into a string and assign it to the &lt;code&gt;BABYLON.Effect.ShadersStore&lt;/code&gt;, like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ShaderStore.ShadersStore[&#39;customVertexShader&#39;] = `
    attribute vec3 position;
    attribute vec2 uv;
    attribute vec3 normal;

    &lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt;&amp;lt;instancesDeclaration&amp;gt;
    uniform mat4 view;
    ...
    `;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then to use that shader, you create a new &lt;code&gt;ShaderMaterial&lt;/code&gt;, like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const shaderMaterial = new ShaderMaterial(
      &#39;shader&#39;,
      scene,
      { vertex: &#39;custom&#39;, fragment: &#39;custom&#39; },
      {
        attributes: [&#39;position&#39;, &#39;uv&#39;, &#39;normal&#39;],
        uniforms: [&#39;view&#39;, &#39;projection&#39;, &#39;vLightPosition&#39;, &#39;u_color&#39;],
        uniformBuffers: undefined,
        shaderLanguage: ShaderLanguage.GLSL,
      },
    );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I quickly realized I could not simply paste in the &lt;code&gt;vertex.glsl&lt;/code&gt; file from the 3JS blog post though.&lt;/p&gt;
&lt;h2 id=&quot;converting-the-vertex-shader-for-babylon-use&quot; tabindex=&quot;-1&quot;&gt;Converting the Vertex Shader for Babylon Use&lt;/h2&gt;
&lt;p&gt;Babylon has a &lt;a href=&quot;https://cyos.babylonjs.com/&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;Create Your Own Shader tool&lt;/a&gt; that helps identifying errors in your shader code, and I discovered that 3JS has different &amp;quot;uniforms&amp;quot; (variables passed from the application to the shader code) than those used by Babylon. Some of these are explicitly defined, others are built in.&lt;/p&gt;
&lt;p&gt;Here&#39;s the entirety of the vertex shader I was trying to convert:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;uniform float u_effectBlend;
uniform float u_remap;
uniform float u_normalize;

varying vec2 v_uvs;

float inverseLerp(float v, float minValue, float maxValue) {
  return (v - minValue) / (maxValue - minValue);
}

float remap(float v, float prevMin, float prevMax, float newMin, float newMax) {
  float t = inverseLerp(v, prevMin, prevMax);
  return mix(newMin, newMax, t);
}

void main() {
  v_uvs = uv;

  vec2 vertexOffset = vec2(
    remap(uv.x, 0.0, 1.0, -u_remap, 1.0),
    remap(uv.y, 0.0, 1.0, -u_remap, 1.0)
  );

  vertexOffset *= vec2(-1.0, 1.0);

  if (u_remap == 1.0) {
    vertexOffset = mix(vertexOffset, normalize(vertexOffset), u_normalize);
  }

  vec4 worldViewPosition = modelViewMatrix * vec4(position, 1.0);

  worldViewPosition += vec4(mix(vec3(0.0), vec3(vertexOffset, 1.0), u_effectBlend), 0.0);

  gl_Position = projectionMatrix * worldViewPosition;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All this shader does is translate the vertices relative to the camera so that the quads on the mesh each have a pseudo-billboard effect, giving the tree its fluffy look.&lt;/p&gt;
&lt;p&gt;There are &lt;a href=&quot;https://threejs.org/docs/#api/en/renderers/webgl/WebGLProgram&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;built-in uniforms that 3JS uses&lt;/a&gt; that &lt;a href=&quot;https://doc.babylonjs.com/features/featuresDeepDive/materials/shaders/introToShaders#built-in-variables&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;Babylon doesn&#39;t have&lt;/a&gt;. Here the 3JS-specific ones were &lt;code&gt;modelViewMatrix&lt;/code&gt; and &lt;code&gt;projectionMatrix&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;projectionMatrix&lt;/code&gt; can just be replaced by &lt;code&gt;projection&lt;/code&gt;. In 3JS, &lt;code&gt;modelViewMatrix&lt;/code&gt; is &lt;code&gt;viewMatrix * modelMatrix&lt;/code&gt;, so in Babylon we would replace &lt;code&gt;modelViewMatrix&lt;/code&gt; with &lt;code&gt;view * world&lt;/code&gt; (reminder that matrix multiplication is not commutative).&lt;/p&gt;
&lt;p&gt;I then declared &lt;code&gt;view&lt;/code&gt;, &lt;code&gt;world&lt;/code&gt;, and &lt;code&gt;projection&lt;/code&gt; at the top with the other uniforms, and when I created the &lt;code&gt;ShaderMaterial&lt;/code&gt; in Babylon, I passed those uniforms in. I also had to explicitly add &lt;code&gt;position&lt;/code&gt; and &lt;code&gt;uv&lt;/code&gt; as attributes (per-vertex data).&lt;/p&gt;
&lt;p&gt;Once I replaced those uniforms, the next issue I ran into was &lt;code&gt;varying vec2 v_uvs&lt;/code&gt;.  &lt;code&gt;varying&lt;/code&gt; variables are used to pass data from vertex shaders to fragment shaders, but the blog post I was following didn&#39;t actually have a fragment shader explicitly defined. It appears they were using a package that provided it, so we&#39;ll come back to that variable later.&lt;/p&gt;
&lt;p&gt;But at the end of the conversion, the vertex shader looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ShaderStore.ShadersStore[&#39;customVertexShader&#39;] = `
attribute vec3 position;
attribute vec2 uv;

uniform mat4 world;
uniform mat4 view;
uniform float u_effectBlend;
uniform float u_remap;
uniform float u_normalize;
uniform mat4 projection;

varying vec2 v_uvs;

float inverseLerp(float v, float minValue, float maxValue) {
  return (v - minValue) / (maxValue - minValue);
}

float remap(float v, float inMin, float inMax, float outMin, float outMax) {
  float t = inverseLerp(v, inMin, inMax);
  return mix(outMin, outMax, t);
}

void main() {
  v_uvs = uv;
  
  vec2 vertexOffset = vec2(
    remap(uv.x, 0.0, 1.0, -u_remap, 1.0),
    remap(uv.y, 0.0, 1.0, -u_remap, 1.0)
  );

  vertexOffset *= vec2(-1.0, 1.0);

  if (u_remap == 1.0) {
    vertexOffset = mix(vertexOffset, normalize(vertexOffset), u_normalize);
  }

  vec4 worldViewPosition = view * world * vec4(position, 1.0);

  worldViewPosition += vec4(mix(vec3(0.0), vec3(vertexOffset, 1.0), u_effectBlend), 0.0);

  gl_Position = projection * worldViewPosition;
}
`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then after creating the &lt;code&gt;ShaderMaterial&lt;/code&gt;, I passed in the uniform starting values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;shaderMaterial.setFloat(&#39;u_remap&#39;, 1.0);
shaderMaterial.setFloat(&#39;u_normalize&#39;, 1.0);
shaderMaterial.setFloat(&#39;u_effectBlend&#39;, 1.0);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This resulted in a shader that made the quads of the mesh face the camera, like this in the 3JS demo (just without the colors):&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://thomasburgess.dev/img/user/Dev%20Log/attachments/Pasted%20image%2020231219042943.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h2 id=&quot;creating-the-fragment-shader&quot; tabindex=&quot;-1&quot;&gt;Creating the Fragment Shader&lt;/h2&gt;
&lt;p&gt;Next I needed to make it actually look like foliage, and for this, I had to make the fragment shader that was omitted from the 3JS demo. This is what I came up with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;ShaderStore.ShadersStore[&#39;customFragmentShader&#39;] = `
	precision highp float;
	varying vec2 v_uvs;
	uniform sampler2D textureSampler;
	uniform vec3 u_color;
	
	void main(void) {
	    vec4 texColor = texture2D(textureSampler, vUV);

	    float luminance = dot(texColor.rgb, vec3(0.299, 0.587, 0.114));
	
	    gl_FragColor = vec4(u_color * texColor.rgb, luminance);
	
	    if (luminance &amp;lt; 0.75) {
	      discard;
	    }
	}`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was the texture I had from the demo:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://thomasburgess.dev/img/user/Dev%20Log/attachments/foliage_alpha3.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The fragment shader takes that texture, determines the &amp;quot;luminance&amp;quot; at coordinates &lt;code&gt;v_uvs&lt;/code&gt; (from the vertex shader) and if it is sufficiently dark (black basically) it does not draw that fragment. This is essentially using a cookie cutter on the quads we saw earlier to turn them into leaf shapes.&lt;/p&gt;
&lt;p&gt;It then multiplies it by the color you pass in to make the fragment that color.&lt;/p&gt;
&lt;p&gt;The texture and color were passed into the shader from Babylon like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const alphaMap = new Texture(&#39;https://douges.dev/static/foliage_alpha3.png&#39;, scene, false, false, Texture.NEAREST_NEAREST);
alphaMap.hasAlpha = true;
shaderMaterial.alphaMode = Material.MATERIAL_ALPHABLEND;
shaderMaterial.setTexture(&#39;textureSampler&#39;, alphaMap);
const foliageColor = new Color3(63 / 255, 109 / 255, 33 / 255);
shaderMaterial.setColor3(&#39;u_color&#39;, foliageColor);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;enabling-the-shader-for-use-with-instances&quot; tabindex=&quot;-1&quot;&gt;Enabling the Shader for Use with Instances&lt;/h2&gt;
&lt;p&gt;At this point I tried to create an instance of my tree mesh and discovered that my shaders as written didn&#39;t work. The foliage mesh was invisible.&lt;/p&gt;
&lt;p&gt;It turns out there are some adjustments you have to make to your vertex shader in order for it to work with instances in Babylon. First, at the top of the GLSL code, you need to add &lt;code&gt;&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt;&amp;lt;instancesDeclaration&amp;gt;&lt;/code&gt; and at the top of the &lt;code&gt;main()&lt;/code&gt; function you need to add &lt;code&gt;&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt;&amp;lt;instancesVertex&amp;gt;&lt;/code&gt;. You no longer have to pass in &lt;code&gt;world&lt;/code&gt; (so you can delete it from the uniforms), and instead you will use &lt;code&gt;finalWorld&lt;/code&gt; (provided by the &lt;code&gt;include&lt;/code&gt; I believe).&lt;/p&gt;
&lt;p&gt;Once that&#39;s done, you should be able to create an instance of the mesh that already has the &lt;code&gt;ShaderMaterial&lt;/code&gt; applied and it should just work.&lt;/p&gt;
&lt;h2 id=&quot;lighting-effects&quot; tabindex=&quot;-1&quot;&gt;Lighting Effects&lt;/h2&gt;
&lt;p&gt;At this point the foliage was all one shade of green, and didn&#39;t have the nice shadows that were in the 3JS demo. I found an example of a toon shader in the &lt;a href=&quot;https://cyos.babylonjs.com/&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;CYOS&lt;/a&gt; tool, and incorporated that into my fragment shader, resulting in its final form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;precision highp float;

// Varying variables for lighting calculations
varying vec2 vUV;
varying vec3 vPositionW;
varying vec3 vNormalW;

// Uniforms
uniform sampler2D textureSampler;
uniform vec3 u_color;
uniform vec3 vLightPosition; // Add a light position uniform

void main(void) {

  // Toon shader thresholds and brightness levels
  float ToonThresholds[4];
  ToonThresholds[0] = 0.95;
  ToonThresholds[1] = 0.5;
  ToonThresholds[2] = 0.2;
  ToonThresholds[3] = 0.03;

  float ToonBrightnessLevels[5];
  ToonBrightnessLevels[0] = 1.0;
  ToonBrightnessLevels[1] = 0.8;
  ToonBrightnessLevels[2] = 0.6;
  ToonBrightnessLevels[3] = 0.35;
  ToonBrightnessLevels[4] = 0.2;

  // Light calculation
  vec3 lightVectorW = normalize(vPositionW - vLightPosition);
  float ndl = max(0., dot(vNormalW, lightVectorW));

  // Apply toon shading
  vec3 color = texture2D(textureSampler, vUV).rgb;
  for (int i = 0; i &amp;lt; 4; i++) {
	  if (ndl &amp;gt; ToonThresholds[i]) {
		  color *= ToonBrightnessLevels[i];
		  break;
	  }
  }
  
  // Original luminance and transparency logic
  float luminance = dot(color, vec3(0.299, 0.587, 0.114));
  if (luminance &amp;lt; 0.75) {
	  discard;
  }

  gl_FragColor = vec4(u_color * color, luminance);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The addition of new &lt;code&gt;varying&lt;/code&gt; variables also required me to update my vertex shader by adding calculations for those variables. Here is the final version of the vertex shader:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;attribute vec3 position;
attribute vec2 uv;
attribute vec3 normal;

&lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt;&amp;lt;instancesDeclaration&amp;gt;
uniform mat4 view;
uniform float u_effectBlend;
uniform float u_remap;
uniform float u_normalize;
uniform mat4 projection;

varying vec2 vUV;
varying vec3 vPositionW;
varying vec3 vNormalW;

float inverseLerp(float v, float minValue, float maxValue) {
  return (v - minValue) / (maxValue - minValue);
}

float remap(float v, float inMin, float inMax, float outMin, float outMax) {
  float t = inverseLerp(v, inMin, inMax);
  return mix(outMin, outMax, t);
}

void main() {
  &lt;a class=&quot;tag&quot; onclick=&quot;toggleTagSearch(this)&quot; data-content=&quot;#include&quot;&gt;#include&lt;/a&gt;&amp;lt;instancesVertex&amp;gt;
  
  vec2 vertexOffset = vec2(
	remap(uv.x, 0.0, 1.0, -u_remap, 1.0),
	remap(uv.y, 0.0, 1.0, -u_remap, 1.0)
  );
  vertexOffset *= vec2(-1.0, 1.0);
  
  if (u_remap == 1.0) {
	vertexOffset = mix(vertexOffset, normalize(vertexOffset), u_normalize);
  }

  vec4 worldPosition = finalWorld * vec4(position, 1.0);
  vPositionW = worldPosition.xyz;
  
  vNormalW = normalize(vec3(finalWorld * vec4(normal, 0.0)));
  
  vec4 worldViewPosition = view * finalWorld * vec4(position, 1.0);
  worldViewPosition += vec4(mix(vec3(0.0), vec3(vertexOffset, 1.0), u_effectBlend), 0.0);

  vUV = uv;

  gl_Position = projection * worldViewPosition;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Applying those shaders to my mesh culminated in a beautiful tree:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://thomasburgess.dev/img/user/Dev%20Log/attachments/ezgif.com-video-to-gif-converted.gif&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can find a full, functional demo &lt;a href=&quot;https://playground.babylonjs.com/#XTNXFT#2&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I hope you find this tutorial helpful, and please feel free to reach out with any questions!&lt;/p&gt;
&lt;p&gt;&lt;em&gt;12.20.23&lt;/em&gt;&lt;br /&gt;
&lt;em&gt;Thomas Burgess, *@thomasburgess.dev&lt;/em&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/dev-log/converting-a-shader-from-three-js-to-babylon-js/"/>
  </entry><entry>
    <title>The Underappreciated UX of the ChatGPT Mobile Scroll Behavior</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/blog/the-underappreciated-ux-of-the-chat-gpt-mobile-scroll-behavior/</id>
    <content type="html">&lt;p&gt;The ChatGPT mobile app UX is severely underappreciated.&lt;/p&gt;
&lt;p&gt;Perhaps the best example of this is the scroll behavior. They&#39;ve put a lot of thought into something so deceptively simple that most people don&#39;t notice how complex it really is.&lt;/p&gt;
&lt;p&gt;I didn&#39;t realize myself, until I tried implementing it from scratch.&lt;/p&gt;
&lt;p&gt;It&#39;s easy to forget that ChatGPT is not a typical text message UI. The introduction of streamed responses immediately following the user&#39;s messages actually necessitates a lot of new behaviors.&lt;/p&gt;
&lt;p&gt;In a typical text message interface, when you send a message your message gets added to the bottom of the list of messages. When the person you&#39;re messaging responds, it also gets appended to the end. This behavior is very straightforward, and there are packages like &lt;a href=&quot;https://github.com/Shopify/flash-list&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;Shopify&#39;s FlashList&lt;/a&gt; that make adding this sort of feature plug-and-play.&lt;/p&gt;
&lt;p&gt;However, ChatGPT is very different. I&#39;m still discovering new states that affect the scroll behavior, but here are some of the ones I&#39;ve noticed so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Most of the time when you send a new message, it appears at the top of your screen (as opposed to the bottom in a normal texting interface). The response streams in below it and can go off the screen (no scrolling yet).&lt;/li&gt;
&lt;li&gt;However, if you send a message when the first response did &lt;em&gt;not&lt;/em&gt; go off the screen, it does not scroll so that your message appears at the top of the screen. Instead, your message appears below the response, and the subsequent response generates below it and also does not scroll.&lt;/li&gt;
&lt;li&gt;If a response goes off the screen and you send a new message without scrolling to read the end of the previous response, opening the keyboard overlays the messages and sending the new message &lt;em&gt;does&lt;/em&gt; scroll so that it is visible at the top of the screen.&lt;/li&gt;
&lt;li&gt;There is whitespace allowed below responses if they are short enough to not take up the full screen. However, if there is whitespace below a response and you scroll back in the conversation, when you return to the bottom of the conversation it does not allow you to scroll so far that the whitespace becomes visible again (the bottom of the response is at the bottom of the screen, and you can&#39;t scroll past it into whitespace).&lt;/li&gt;
&lt;li&gt;When you return to an old conversation, it shows the end of the last response at the bottom of the screen such that there is no whitespace below it.&lt;/li&gt;
&lt;li&gt;If you&#39;ve scrolled to the bottom and there&#39;s no whitespace below the last response, opening the keyboard &lt;em&gt;does&lt;/em&gt; make the conversation view shrink above it. If you&#39;re not scrolled to the bottom, it does not.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What&#39;s also interesting is none of the above applies to web. On web, your messages scroll to the top of the screen, and the responses stream below it without scrolling, even on really short responses. That&#39;s it.&lt;/p&gt;
&lt;p&gt;More and more apps are shipping their own chat interfaces, creating demand for a similar plug-and-play package like FlashList. I might release one myself once I have all the kinks ironed out in my own app, but if someone knows of something available already, would love to give it a try.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;12.22.25&lt;/em&gt;&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/blog/the-underappreciated-ux-of-the-chat-gpt-mobile-scroll-behavior/"/>
  </entry><entry>
    <title>2 Weeks of Tesla FSD after 2 Years with Comma&#39;s OpenPilot</title>
    <updated>2026-05-29T02:02:33Z</updated>
    <id>https://thomasburgess.dev/blog/2-weeks-of-tesla-fsd-after-2-years-with-comma-s-open-pilot/</id>
    <content type="html">&lt;p&gt;I&#39;ve had the Comma 3 device installed in my 2019 Hyundai Santa Fe for over two years and nearly 11,000 miles. The self-driving software it runs is called &amp;quot;openpilot,&amp;quot; and for most of that time, I&#39;ve been using a fork called &lt;a href=&quot;https://github.com/sunnypilot/sunnypilot&quot; target=&quot;_blank&quot; class=&quot;external-link&quot;&gt;sunnypilot&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Two weeks ago, I bought my wife a 2025 Tesla Model Y and subscribed to FSD (Supervised). Here are my first impressions comparing the two self-driving systems and what you can expect from each.&lt;/p&gt;
&lt;h2 id=&quot;highway-king-vs-nagging-driver&quot; tabindex=&quot;-1&quot;&gt;Highway King vs Nagging Driver&lt;/h2&gt;
&lt;p&gt;The Comma 3 excels at one thing: highways. openpilot is fantastic at keeping you in your lane and maintaining speed. If that&#39;s what you want, you&#39;ll be thrilled. Much more than that, and you&#39;ll be disappointed.&lt;/p&gt;
&lt;p&gt;Comma has long promised a full self driving experience complete with navigation, traffic light awareness, and 90-degree turns, but it continues to fall short on those counts. They hoped to have released a driving model that &amp;quot;learned e2e in the imagination of a transformer&amp;quot; by November. It&#39;s now December, and we&#39;re still waiting.&lt;/p&gt;
&lt;p&gt;That being said, on highways I prefer openpilot to FSD for one main reason: driver monitoring.&lt;/p&gt;
&lt;p&gt;Tesla&#39;s driver monitoring is clunky. They recently removed the requirement of having to hold the steering wheel the whole time, but the driver monitoring they&#39;ve replaced it with is unpolished.&lt;/p&gt;
&lt;p&gt;Theoretically it&#39;s supposed use the driver-facing camera and take into account several factors (the primary one being your eyes) to determine whether or not the driver is paying attention. If it thinks your attention has strayed, the screen nags you to &amp;quot;apply slight turning force&amp;quot; to the wheel. It&#39;s easy to overdo the &amp;quot;turning force&amp;quot; and disengage FSD entirely.&lt;/p&gt;
&lt;p&gt;Comma&#39;s approach is simpler: driver monitoring is based solely on head orientation. Are you looking forward? Great. Are you looking down or rubbernecking too long? Warning is triggered, and simply looking forward again dismisses it.&lt;/p&gt;
&lt;p&gt;Which actually brings us to another important distinction between FSD and openpilot: what happens when you touch the wheel. Like I mentioned before, applying any significant amount of steering force to the wheel in a Tesla disengages FSD entirely. With the Comma however, you can steer without disengaging, and with sunnypilot, you can even brake. This flexibility is crucial to how much I enjoy using it.&lt;/p&gt;
&lt;h2 id=&quot;comma-no-surprises&quot; tabindex=&quot;-1&quot;&gt;Comma: No Surprises&lt;/h2&gt;
&lt;p&gt;FSD can be nerve-racking on familiar routes because it occasionally does unexpected things, like stopping in the middle of the road. With openpilot, it’s more predictable. Coming up on a left-turn-only lane? I steer manually without disengaging, and openpilot keeps driving as expected.&lt;/p&gt;
&lt;p&gt;With openpilot, I always know it’ll stay in its lane and slow for obstacles. With FSD, I generally know what it’ll do, but the unexpected happens and is extremely jarring when it does.&lt;/p&gt;
&lt;h2 id=&quot;tesla-moonshot&quot; tabindex=&quot;-1&quot;&gt;Tesla: Moonshot&lt;/h2&gt;
&lt;p&gt;Tesla wants to remove the steering wheel entirely—any takeover by the driver is considered a failure. This bold approach of leaping to the final product means it can do some genuinely impressive things: get around slow drivers, park itself, or accelerate like a rocket at green lights. But the confidence with which it undertakes these maneuvers can be misplaced.&lt;/p&gt;
&lt;p&gt;Comma takes the opposite approach, adding capabilities iteratively. As their &amp;quot;make driving chill&amp;quot; slogan suggests, the last thing they want to do is stress you out. They handle the mundane, leaving you in control of the complexities. If you want the car to make an unprotected left turn, Comma isn&#39;t there yet.&lt;/p&gt;
&lt;p&gt;The ability to set a destination and watch FSD handle everything is incredible, and I hope Elon’s promises of full autonomy come true. For now, though, it’s still hit-and-miss.&lt;/p&gt;
&lt;h2 id=&quot;price-race-to-the-bottom&quot; tabindex=&quot;-1&quot;&gt;Price: Race to the Bottom&lt;/h2&gt;
&lt;p&gt;FSD peaked at $15,000, but now sells for $8,000, with most opting for the $99/month subscription. Since FSD is non-transferable between vehicles, you&#39;d have to keep the same vehicle for over six years to make buying outright worthwhile.&lt;/p&gt;
&lt;p&gt;When I bought the Comma 3, it cost $2,000. Last month, you could get the Comma 3X for $1,000 during Black Friday.&lt;/p&gt;
&lt;p&gt;I suspect Comma has bottomed-out on price, but wouldn&#39;t be shocked if the FSD price continued to drop as competitors advance.&lt;/p&gt;
&lt;h2 id=&quot;final-recommendation&quot; tabindex=&quot;-1&quot;&gt;Final Recommendation&lt;/h2&gt;
&lt;p&gt;Comma works with many new car models across most car brands, while FSD is Tesla-exclusive. For most people, Comma isn&#39;t the best option—it&#39;s the only option.&lt;/p&gt;
&lt;p&gt;If you own a Tesla, there&#39;s still a case for adding a Comma. But for my wife and me, we&#39;re still subscribing to FSD.&lt;/p&gt;
&lt;p&gt;I like monitoring the cutting edge, and FSD has a cool factor that Comma lacks. It&#39;s &lt;em&gt;fun&lt;/em&gt; watching the screen visualize your surroundings and seeing your car think through challenging situations. Does it feel safe or chill? I think &amp;quot;exciting&amp;quot; is the better term, which is not a trait everyone wants in their self-driving software.&lt;/p&gt;
&lt;p&gt;The Comma looks and feels like a dev device. It is running open-source software after all. You see a glitchy green line overlaying a camera feed of the lane in front of you, and that&#39;s it.&lt;/p&gt;
&lt;p&gt;Most people looking to add smarts to their car today should just get a Comma. FSD is more for those who want to get a taste of the future that isn&#39;t quite here.&lt;/p&gt;
&lt;h2 id=&quot;looking-forward&quot; tabindex=&quot;-1&quot;&gt;Looking Forward&lt;/h2&gt;
&lt;p&gt;Long-term, I&#39;m more confident in Tesla reaching true autonomy than Comma.&lt;/p&gt;
&lt;p&gt;The Comma 3 and 3X have 2 front-facing cameras and a single rear-facing camera, all mounted to your windshield. Comma says they could use their rear-facing driver-monitoring camera for side views, but that hasn’t happened, supposedly due to a lack of volunteer data labelers. I have significant doubts whether the rear-facing camera has sufficient coverage to be able to handle all situations.&lt;/p&gt;
&lt;p&gt;In terms of processing, Comma&#39;s hardware is essentially an old smartphone. It&#39;s worked so far, but whether it&#39;s sufficient for the next level of autonomy has yet to be seen.&lt;/p&gt;
&lt;p&gt;Tesla, with its HW4 board and full-coverage cameras is clearly better equipped for the vision-only approach. Elon seems confident that true FSD is achievable on HW4, but has begun expressing doubts about HW3, which he previously assured buyers was FSD-capable. HW5 is already in the works.&lt;/p&gt;
&lt;p&gt;For now, I&#39;m keeping the Comma in my Hyundai and FSD in the Tesla, rooting for both and glad for the competition that is pushing the technology further.&lt;/p&gt;
&lt;h3 id=&quot;tl-dr&quot; tabindex=&quot;-1&quot;&gt;TL;DR&lt;/h3&gt;
&lt;p&gt;Driving with openpilot is like collaborating with your car. Driving with FSD is like handing control over but having to yank it back occasionally.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;12.4.24&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;edit-6-24-25&quot; tabindex=&quot;-1&quot;&gt;EDIT 6.24.25&lt;/h3&gt;
&lt;p&gt;Tesla FSD has come a long way since this post, and most of the drawbacks I mentioned no longer apply. It no longer nags or attempts anything crazy. The only downside really is the price, but at this point I&#39;m happy to pay it.&lt;/p&gt;
</content>
    <link href="https://thomasburgess.dev/blog/2-weeks-of-tesla-fsd-after-2-years-with-comma-s-open-pilot/"/>
  </entry></feed>

