The share loop was dying at the App Store page. Fixing it meant a fourth runtime — and finding out there were two barriers, not one.
The last post in this series ended on a line I believed: the recipe is portable; the render is not. One JSON effect stack, three renderers — a browser CPU engine, a headless renderer that imports the same file, and 63 GPU shaders running live on a phone camera.
What it didn't say is that all three of those runtimes sat behind an app install.
That's fine for the person who bought the shirt. It is fatal for the person standing next to them. The entire premise of merch-bound AR is that the object travels — a hangtag, a mural, a hoodie in a bar — and the experience travels with it. Instead, every share ended the same way: a link, a store page, a download, and a stranger deciding they'd rather not.
I'd built a distribution mechanism whose last step was a tollbooth.
This is what it took to remove it, what it cost, and the second barrier I didn't know existed until the first one was gone.
What's in here
- Install is a tax, and you are the one charging it
- The fourth runtime, and the decision that made it cheap
- What the browser actually runs
- The share link is anonymous on purpose
- The second barrier: the browser inside the app
- The link that must not be a Universal Link
- Mural AR, where the marker gets compiled in the browser
- The gap list
- What I'd tell someone starting this
1. Install is a tax, and you are the one charging it
Worth being precise about what an install actually costs, because “friction” is too soft a word for it.
A share link that lands on a store page asks the recipient for: a store app switch, an account they may not be signed into, a download over whatever connection they're on, an app open, a permission prompt, and then the experience. Six steps, each with its own drop-off, to see a thing that lasts a few seconds.
Worse, it inverts the value order. The person hasn't seen the thing yet. You're asking for the largest commitment at the moment of lowest interest. Every product instinct says to reverse that, and for a paid app there's at least an argument about qualifying the audience. There is no such argument here. The lens is the ad. Gating the ad behind the install is charging admission to your own marketing.
So the target wasn't “add a web version.” It was:
The share link has to be the experience, on the first tap, for someone who has never heard of us.
Everything below follows from taking that literally.
2. The fourth runtime, and the decision that made it cheap
The obvious way to build a browser version of a shader pipeline is to write the shaders again in GLSL ES for WebGL2. Sixty-one effects. A few weeks, and a permanent second source of truth.
The last post's best decision was refusing exactly that move on the headless path — importing the browser's engine.js verbatim instead of porting it, so server output matches browser output by construction. The same instinct applies here, one layer down.
Flutter's Impeller shaders and WebGL2 ES 300 fragment shaders are not the same language, but they're close enough that the delta is mechanical:
function convertFlutterFrag(src) {
let s = src.replace(/\r\n/g, "\n");
s = s.replace(/^\s*#version[^\n]*\n/, "");
s = s.replace(/^\s*#include\s*<flutter\/runtime_effect\.glsl>\s*\n?/gm, "");
// FlutterFragCoord().xy / u_size → v_uv
s = s.replace(/FlutterFragCoord\(\)\.xy\s*\/\s*u_size/g, "v_uv");
// Remaining FlutterFragCoord().xy → v_uv * u_size
s = s.replace(/FlutterFragCoord\(\)\.xy/g, "(v_uv * u_size)");
return `#version 300 es
precision highp float;
in vec2 v_uv;
${s.trim()}
`;
}
That is the entire port. A hundred and sixty-eight lines of build script reads all 63 .frag files out of the shared engine package, strips the Flutter preamble, rewrites FlutterFragCoord() in terms of an interpolated v_uv, and emits a TypeScript module of shader strings.
It runs on every build, before typecheck:
"build": "node scripts/gen-shaders.mjs && tsc --noEmit && vite build"
The generated file is in the repo, and it is never edited. Touch a .frag for the phone and the browser gets the same change on the next build, or the build breaks. There is no browser shader to keep in sync, because there is no browser shader — there's a transform.
This is the same principle as importing the engine file under Node, and it's worth stating in the general form, because it's the thing that keeps generalizing:
When two runtimes need the same logic, share the artifact or share a transform of it. Never share a specification. A spec is a promise that two humans will keep making the same decision. They won't.
The honest caveat
A regex-based transpiler works because the shaders were written in a deliberately boring subset. No Impeller-specific intrinsics, no sampler tricks that WebGL2 handles differently, no precision qualifiers doing real work. That subset wasn't chosen with this port in mind — it was chosen because the shaders started as ports of CPU effects — but it's the reason five regexes are enough. Written more idiomatically, this would be a compiler.
3. What the browser actually runs
Sixty-one effects, live, on a camera feed. The pipeline is a ping-pong of framebuffer objects:
drawCover(camTex, ping.fb); // camera → canvas aspect
let src = ping.tex;
let writePing = false;
for (const pass of passes) {
const dest = writePing ? ping : pong;
const fxTex = applyPass(pass, src, dest, time);
// ...blend combine into the other buffer when blend !== normal
src = fxTex;
writePing = !writePing;
}
draw(copyProg, src, null, () => {}); // → default framebuffer
Four FBOs total: ping and pong for the chain, auxA and auxB for the effects that need scratch. Blur is a separable two-direction pass through auxA. Bloom is extract → separable blur → combine, using both. Sharpen blurs into auxA and subtracts. Exactly the shapes the phone path uses, which is not a coincidence — they're the same shaders, so they need the same plumbing.
Two details that mattered more than they look.
The cover-crop pass
The camera gives you 1280×720. The canvas is whatever a phone in portrait says it is. If you letterbox, every effect that reads screen position — scanlines, stripes, halftone, mirror — computes against dead black bars and the composition falls apart. So the first pass isn't an effect, it's a UV remap that center-crops the camera into the canvas aspect:
vec2 uv = v_uv * u_uv_scale + u_uv_offset;
frag_color = texture(u_texture, clamp(uv, 0.0, 1.0));
Effects only ever see a full frame.
The resolution law, per frame
The law from the last post — scale the choices, never the physics — isn't a print-time concern. It's a per-frame concern here, because the canvas resizes when a phone rotates or a URL bar collapses:
const longEdge = Math.max(canvas.width, canvas.height);
const design = planned.designDim ?? longEdge;
const pxScale = design > 0 ? longEdge / design : 1;
const passes = planned.passes.map((p) => ({
...p,
params: scalePxParams(p.effect, p.params, pxScale),
}));
A recipe authored at 2048px, rendered on a 393pt viewport at 2× pixel density, gets its pixel-denominated params scaled by 0.38. Without that line the tears are technically present and visually gone.
Pixel density is capped at 2. A 3× phone rendering an eight-pass chain at native resolution is how you turn a lens into a hand warmer.
4. The share link is anonymous on purpose
Here's the part that's a product decision wearing an engineering costume.
Creating a share requires a session, and checks entitlement. Viewing one requires nothing:
app.post("/v1/lenses/:id/shares", requireSession, async (c) => {
const visible = await listEntitledLenses(userId, { ... });
const lens = visible.find((l) => l.id === id);
if (!lens) return c.json({ error: "forbidden_or_missing" }, 404);
...
});
app.get("/v1/shares/:token", async (c) => { // ← no requireSession
...
});
No auth, no account, no cookie. A 24-character token from 18 random bytes, a 90-day expiry, revocable by its creator or a staff admin, rate-limited at 120 payload reads and 300 asset reads per IP per minute.
The entitlement check moved to the creation of the link, not its consumption. Which means an entitled owner can hand the experience to anyone, and that is the intended behavior, not a leak I'm tolerating.
If someone photographs the code card inside a shirt and posts it, that's distribution, not theft. The card is the ad.
A share link is the same object with a shorter fuse: 90 days, revocable, and it never grants an entitlement. The recipient gets the experience, not the lens. If they want it in their pocket, bound to their account, geofenced to the mural — that's what the app is for, and now they've seen the thing before being asked to install it.
One piece of unglamorous plumbing worth naming, because it's where an afternoon went: every asset URL in the share payload is rewritten to a same-origin proxy path. The tracking compiler and the 3D texture loader both need CORS-clean pixels, and bucket CORS policies are a class of problem I would rather not debug in someone else's browser at a mural on a Saturday. The API streams the bytes, scoped to the share token, and the whole category disappears.
5. The second barrier: the browser inside the app
I shipped the WebGL path, sent myself a link through a social DM, and got a black rectangle.
Your share link does not open in a browser. It opens in whatever webview the messaging app feels like using.
Instagram, TikTok, Facebook, Snapchat, LinkedIn, Twitter/X, Pinterest, Line — every one of them wraps an embedded webview, and their support for camera access ranges from “works,” through “works but never decodes a frame,” to “silently resolves to nothing.”
For a share-driven camera experience this is not an edge case. It is the modal case. The whole point is that the link travels through social apps.
So there's a gate — user-agent sniffing, which I dislike, used here for the one thing it's actually good at: detecting a container rather than a capability. There's no feature test for “this webview will hand you a camera track and then not decode frames.” The API exists in most of them. It just doesn't work.
When the gate trips, the viewer doesn't try and fail. It explains, and gives the user a real exit:
Android ONE TAP OUT
An intent:// URL that reopens the same path in Chrome.
iOS NO EQUIVALENT
iOS has no intent mechanism, so it gets an instruction — “Tap Share → Open in Safari” — and a Copy link button with a legacy fallback for the webviews where the modern clipboard API is also unavailable.
Both TRY ANYWAY
Because my sniffing list will be wrong about somebody's browser, and a hard block on a false positive is worse than a failed camera prompt.
That last one is the bullet I'd defend hardest. A capability gate built on a denylist is guaranteed to be wrong at the edges. Giving the user an override converts a dead end into a shrug.
Why this one is worse than the install
Install failure is legible — you can see the store-page bounce. Webview failure is silent. The user sees a black rectangle, assumes the thing is broken, and closes it. No error, no event, no support ticket. The barriers you can measure are the ones you've already half-solved.
6. The link that must not be a Universal Link
The instinct, when you have an app and a web page at the same URL, is to claim the URL. Register it in your app-site-association file, add it to your Android asset links, and the OS will route it into the app for anyone who has the app.
Do that here and you have destroyed the feature.
The share link exists for people who don't have the app. Claiming the route optimizes the path for the one audience that was never the problem, and adds an OS-level redirect for everyone else to get confused by. So the association file claims exactly one path, and it isn't this one:
applinks: {
details: [{ appID: `${team}.${bundleId}`, paths: ["/r/*"] }],
}
/r/* is the redemption-code landing — a link printed on packaging, held by someone who bought the product, who probably does have the app. That one should open natively. The share route carries a comment in the router that says the same thing in four words: browser-first; not App Links.
Users who do have the app aren't stranded — they get an Open app button, which is a deliberately ugly piece of code and I don't know a better one:
window.location.href = opts.androidIntent || opts.deepLink;
window.setTimeout(goFallback, 1500);
Fire the custom scheme, wait 1500ms, and if the page is still visible — meaning nothing took over, meaning the app isn't installed — send them to the store. Visibility, page-hide, and blur events all set a flag that cancels the fallback. It is a timing heuristic standing in for an OS capability that doesn't exist, it has been the standard workaround for a decade, and it will break the day a platform changes its backgrounding behavior.
Alongside it, always, is a plain link to a real https install URL — App Store on iOS, Play on Android, marketing site otherwise. Never a bare custom-scheme link that dead-ends into a system dialog for the majority of visitors.
7. Mural AR, where the marker gets compiled in the browser
Glitch lenses are a camera and a shader chain. Murals are tracking, and tracking is where WebAR earns its reputation for being the lesser option.
The web mural viewer is MindAR for image tracking plus three.js for the scene graph. The interesting decision isn't the library, it's when the tracking target gets built.
MindAR normally consumes a precompiled target file — you run the marker image through a compiler ahead of time and ship the binary. That works when your markers are known at build time. Ours arrive in the share payload at runtime, because a share can point at any published mural.
So the browser compiles it:
const compiler = new Compiler();
await compiler.compileImageTargets([img], (p) => onProgress?.(p));
const exported = await compiler.exportData();
This takes real seconds on a phone, which is why it happens exactly once. The result goes into IndexedDB, keyed on the asset's content hash when there is one:
if (opts.contentHash) return `hash:${opts.contentHash}`;
return `id:${opts.assetId}:${opts.url}`;
Content-hash keying means re-publishing the same marker under a new asset ID is a cache hit, and changing the marker image is a cache miss. Both correct, for free. Every cache read and write is wrapped in try/catch and falls through to recompiling, because private-mode browsers will happily throw at you for touching IndexedDB.
The unit conversion is the part I'd have gotten wrong without the shared contract. A mural experience positions nodes in meters, marker-local, Y-up — because that's what ARKit and ARCore speak. MindAR's anchor space is denominated in target widths. The mural's real-world size lives on the marker, and the conversion is a division that belongs in exactly one place: the shared engine package, imported by the web viewer rather than reimplemented in it.
Same coordinate contract, two unit systems, one conversion. A node placed 40cm above the mural's center is 40cm above the mural's center on all three platforms, and nobody is multiplying by a magic number in a view controller.
8. The gap list
The series rule: the number is not the truth. Here's what the browser path doesn't do.
World AR HONEST STUB
Tap-a-surface placement needs plane detection, which MindAR doesn't do. The viewer could have faked it with a fixed-distance plane. It doesn't — the source comment reads “honest open-app stub (no fake MindAR).” A share of a world lens is a dead end on the web, and that's a worse outcome for exactly one metric and a better one for every other metric I care about.
iOS-only 3D nodes DROPPED
Model nodes ship USDZ for iOS and GLB for Android. three.js loads GLB, so the web viewer serves the Android asset. A node authored iOS-only drops out and surfaces as a banner. The practical rule: if you want a model on the web, author the GLB.
skippedAudioNodeIds DEAD FIELD
Declared in the API type, initialized, returned, typed on the client, and checked in the viewer to render a banner. Never pushed to. Audio does play on the web path — behind a tap-to-unlock, because autoplay policy — so the array is correctly empty and the banner can never fire. Harmless, and exactly the kind of thing that turns into a lie in a code review two years from now.
The badge I said should exist HALF FIXED
Last post: unsupported effects are silently dropped; it should be a visible badge in the UI, and currently isn't. On the web it now is — a banner listing what got skipped. The phone still drops silently. I fixed the complaint on the runtime I happened to be building.
The parameter table WORSE
Three hand-maintained copies now, in three languages. See below.
The wart got worse
Last post named the hand-transcribed Dart copy of the pixel-parameter table — 17 entries mirrored across two languages with no test asserting they match — as “the first thing I'd fix.” I did not fix it. I added a third copy, in TypeScript.
There's a parity checker in the repo that asserts the live effect catalog agrees across five places: the shared engine package, the phone client, both shader directories, and the asset manifest. It does not check the web app. The web build script carries its own hand-maintained 61-entry effect list that nothing validates.
I ran the comparison while writing this. All three tables agree — 17 keys, same values. The web catalog matches the engine catalog exactly, same order. They are in sync today. Nothing asserts they will be in sync tomorrow, and the checker's existence makes it worse, because it creates a false sense that this class of drift is covered.
Two lines in that script would close it. It's now two posts in a row where I've written the same confession, which is roughly how technical debt gets paid: publicly, out of embarrassment.
9. What I'd tell someone starting this
Count the install as a tax on your funnel, not a feature of your product. Six steps between a link and an experience, and you're charging them at the moment of lowest interest. If the thing you're sharing is the marketing, gating it behind the install means charging admission to your own ad.
Share the artifact, or share a transform of it. Importing the engine file for the CPU runtime. A 168-line regex transpiler for the shaders. Both of them beat a second implementation, and both of them beat a specification, because a specification is a promise that two humans will keep making the same decision.
Solving the install reveals the next barrier, not the last one. The webview problem was invisible until the install problem was gone, and it's arguably the bigger one — because it's silent, it's platform-specific, and it hits precisely the channels your link travels through. Budget for a second barrier you can't see yet.
Don't claim the URL. The reflex to register your app for every path it can serve optimizes for users you already have. Ask who the route is for before you put it in the association file.
Ship the honest stub. A card that says “this one needs the app, here's why” costs you a conversion. A fake tracking mode that drifts and jitters costs you the belief that any of it works. The gap list is a feature of the product, not an apology for it.
A parity checker that doesn't cover every runtime is worse than none. It converts “I should check this by hand” into “this is handled.” Mine is one runtime short and I've now written that sentence twice.
Remove the tollbooth. Then go looking for the next one.
Aura Labs and Aura Lenses are in active development. Effect counts, gap lists, and confessions are current as of this writing and will move.