•8 min read•John McBride
WebAR Without an App: Putting a 1774 Fort on Your Lawn
Field-test lessons from building a no-app AR history tour: QR and GPS triggers, Scene Viewer intent URLs, a 40MB-to-21MB model diet, and one COOP/COEP trap.
webar3dgltfweb-developmentar
There's a historical marker in Western Pennsylvania for Cherry's Fort, a frontier stockade from 1774. The fort itself is long gone. I wanted anyone standing near that marker to pull out their phone, tap a link, and see the fort standing in front of them at full scale — fence posts on the actual grass.
The catch I gave myself: no app. Nobody downloads an app for a roadside historical marker. The whole thing had to run in the mobile browser, from a QR code or a GPS trigger, on whatever phone the visitor happened to bring.
I already had a 3D fort. I'd built it for a Three.js web game, so the model existed — a 40MB GLB with terrain, sky, and a few years of geometry cruft baked in. This post is what it took to get from that file to a working AR experience at a real site, including the parts that fell over during field testing.
## The trigger layer: QR codes and geofences
The tour has two ways in. A scan page opens the camera, decodes a QR code, and redirects to the matching site page. That's the version that works for a plaque bolted to a post: print the code, point the phone, done.
The second trigger is GPS. The landing page has a "use my location" toggle that starts `watchPosition` and runs a haversine distance check against each site's coordinates. Walk inside a site's radius and the page surfaces it. It's maybe forty lines of geofence code — no SDK, no map service, just the browser geolocation API and some trigonometry.
QR is the more reliable of the two. GPS accuracy near tree cover wanders enough that I treat the geofence as a discovery aid, not a gate. If you ever do want GPS to gate content (we're planning Pokemon-Go-style unlocks), require a reported accuracy under 30 meters and a dwell time before it counts. A drive-by ping is not a visit.
## Android: skip model-viewer's AR button, fire the intent yourself
Google's `
The fix was to stop asking model-viewer to launch AR at all. Android's Scene Viewer is directly addressable with an intent URL:
```
intent://arvr.google.com/scene-viewer/1.0?file=https://...fort-ar.glb
```
The site page's big gold "View in AR" button on Android fires that URL straight from a click handler. Scene Viewer takes over, loads the GLB, and the user taps to place the fort on a real surface. This sidesteps model-viewer's WebXR plumbing entirely, and it's been solid in field testing where the component's own button wasn't.
I kept both paths wired up, plus a diagnostics panel on every site page that logs model-viewer's progress, load, error, and ar-status events live, with manual override buttons. When you're debugging AR in a field with no laptop, an on-screen event log is the difference between learning something and going home confused.
## iOS: a different door entirely
Apple doesn't do intent URLs. The iOS path is AR Quick Look, and the canonical way in from the web is model-viewer's `activateAR()`, which hands the model to Quick Look — Apple's side auto-converts the GLB to USDZ at tap time.
That auto-conversion is the part I don't fully trust. Apple's converter is particular, and a 21MB GLB might simply time out. The contingency plan is to pre-generate a USDZ offline and serve it via `
Honest status: at field-test time, Android was confirmed working and iOS was still untested. If you're shipping something similar, budget a real iPhone test early. The two platforms share almost none of their AR launch path, so a green light on one tells you nothing about the other.
## Putting a 40MB game model on a phone diet
The game's `fort.glb` was never meant for AR. It's 40MB, the origin is offset out at the fort's world position, and it carries ground planes, sky geometry, and CSG cutter artifacts that have no business in someone's yard.
Rather than maintain a second hand-edited model, I wrote a build script around `gltf-transform` that derives the AR model from the game model on every deploy:
- Remove the ground, sky, and cutter nodes by name
- Cull loose boards more than 35 meters from the fort's center
- Recenter the scene so the fort sits at the origin instead of at (27.7, 0, 19)
- Scale to 0.3x, which lands the fort at roughly 9 meters wide — big enough to feel real, small enough to fit on a lawn
- Prune unused data and dedupe what's left
Output: `fort-ar.glb` at 21MB, about 46% smaller, regenerated automatically because the script runs ahead of `tsc && vite build` in the npm build. The artifact stays out of git; Netlify rebuilds it on every deploy. One source model, zero drift.
21MB is still heavy on cell data, and the next step is Draco mesh compression, which typically buys another 3–10x. The trade is a ~150KB decoder library on the client, which is a good deal when the target is 3–5MB.
One bug the pipeline didn't catch: the fort floats. On Android, the placed model sometimes anchors with its fence hovering above the floor. The cause is that the GLB's origin sits at the fort's center rather than its lowest vertex — a leftover from the Blender export. The fix belongs in the same build script: compute min-Y across all meshes and translate the scene so the lowest point sits exactly at Y=0. Then floor placement grounds correctly every time. If your AR model came from a game or a DCC tool, check this before your first field test, because nothing looks less convincing than a building levitating two feet off the grass.
## The COOP/COEP trap
This one cost me a deploy cycle. The game half of the site uses cross-origin isolation headers — `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` — and my Netlify config originally applied them site-wide.
Cross-origin isolation breaks the Scene Viewer hand-off. The intent URL navigation that bounces the user out to Google's AR viewer doesn't survive an isolated browsing context. The symptom is an AR button that does nothing, with no console error worth reading.
The fix was scoping: in `netlify.toml`, COOP/COEP now apply only to the game's `index.html`, and the `/ar/*` pages serve without them. If part of your site needs isolation (anything using `SharedArrayBuffer`, multithreaded WASM, that family) and another part needs to hand users off to native AR viewers, those parts cannot share headers. Scope them at the path level and keep them apart.
## Making it survive a field with one bar of signal
The fort site has weak cell coverage, which is exactly where the experience matters most. The tour registers a service worker scoped to `/ar/` with a split strategy: network-first for HTML navigations so code updates actually land, cache-first for assets so a 21MB model downloads once and works offline afterward. A web manifest makes the tour installable from "Add to Home Screen" for anyone who wants it to feel like an app — without the app store standing between a curious visitor and the content.
Visited-site progress lives in `localStorage`. No accounts, no backend, no sync infrastructure. For a three-site historical tour, a string set in local storage is the right amount of engineering.
## What I'd tell you before your first WebAR build
A few things I'd want someone to hand me before starting:
- **Don't trust model-viewer's AR button on Android.** Keep the inline viewer, but launch AR with a direct Scene Viewer `intent://` URL. When the component's path fails, it fails silently.
- **Treat iOS as a separate project.** AR Quick Look has its own launch path and its own USDZ conversion quirks. Test on a real iPhone early, and have a pre-generated `ios-src` USDZ as the fallback.
- **Derive your AR model in the build, don't hand-edit it.** A `gltf-transform` script that strips, recenters, scales, and prunes means the AR asset can never drift from the source model.
- **Put the origin at the lowest vertex.** Translate min-Y to zero or your model will float above the floor on placement.
- **Scope your COOP/COEP headers.** Cross-origin isolation kills the Scene Viewer hand-off. Isolate only the routes that need it.
- **Ship a diagnostics panel.** Live ar-status logs, a build tag, and a cache-clear button on the page itself. You will be debugging in a field.
None of this required an app, an account system, or a line of native code. A QR code, a browser, and a carefully shrunk GLB put a 250-year-old fort back on its own ground. That's the part that still gets me: the distance between "we have a 3D model" and "a stranger at a roadside marker can stand inside it" turned out to be a build script and a handful of hard-won platform quirks.