Getting a glTF Room Under 3MB
Every arthis.land room ships between 2.3 and 3.4 MB, and almost all of that is texture — here is how the budget is spent and defended.
Each room on arthis.land is one .glb file authored in Blender, dropped into a room folder, and handed to a shared Unity WebGL viewer. The finished files sit between 2.3 and 3.4 MB. That number is not a stylistic preference; it is the only part of the room pipeline anyone actually controls. The Unity build itself is 28–30 MB compressed and is never rebuilt in the web repo, so a room that doubles in size is a room that doubles the only cost still on the table.
The 3 MB line is arbitrary in the way all budgets are arbitrary. What matters is that it exists, that it is checked before a room ships, and that everyone knows which lever moves it.
Where the bytes actually are
Open a room that came in at 4 MB and the distribution is dull and predictable: textures are 80–90% of it, vertex attributes are most of the rest, and animation tracks are a rounding error unless somebody baked a per-frame curve on 400 bones. Geometry almost never saves you. Halving a 30,000-triangle prop buys perhaps 200 KB of positions and normals. Dropping one 4096×4096 albedo you cannot distinguish from a 1024 at run time buys ten times that.
So the single most common waste in a room is a 4K albedo sitting on a prop that occupies 180 pixels of screen. It happens because Blender does not punish it. The viewport samples whatever mip it needs and looks identical; the exporter writes the full image; the artist sees no signal at all until the file lands on a phone in Jaipur on a 3G connection. This is the general shape of the problem: the authoring tool hides cost, so cost has to be measured somewhere else.
A texture budget by role
Resolution should follow what a texture is for, not what it was painted at. A room-scale prop — something you can walk up to but not press your face against — is served by 1024 in almost every case. Anything background gets 512. Anything that is a flat colour with a bit of grain gets 256, or gets deleted and replaced with a material factor.
| Role | Resolution | Format | Rough size |
|---|---|---|---|
| Hero albedo (prop you approach) | 1024 | KTX2 / UASTC + zstd | 260–380 KB |
| Secondary albedo (background prop) | 512 | KTX2 / ETC1S | 25–45 KB |
| Packed ORM (AO + rough + metal) | 1024 | KTX2 / ETC1S | 45–80 KB |
| Normal map, hero | 1024 | KTX2 / UASTC | 280–400 KB |
| Normal map, secondary | 512 | KTX2 / UASTC | 70–110 KB |
| Emissive | 256–512 | KTX2 / ETC1S | 8–25 KB |
| Tiling floor / wall detail | 512, tiled | KTX2 / ETC1S | 25–45 KB |
| Baked room-shell lightmap | 2048 | KTX2 / UASTC | 0.9–1.2 MB |
That last row is the one to argue about. A single 2K lightmap eats a third of the entire budget, and it is usually there because someone wanted soft shadows without paying for real-time lights. If a room needs it, the room gets fewer unique props. Budgets only mean anything when something is allowed to lose.
Note the asymmetry between the two encodings. Normal maps go through UASTC because ETC1S mangles them — the two-channel correlation it assumes for colour data is exactly wrong for a tangent-space vector, and you get visible blocky faceting on smooth curvature. Albedo and packed masks survive ETC1S fine. Picking one format for the whole room is the lazy answer and it costs either quality or half a megabyte.
Channel packing, which is the highest-value single change
Ambient occlusion, roughness and metallic are each a single channel of data, and glTF's metallic-roughness model already expects roughness in green and metallic in blue. Shipping three greyscale PNGs where one RGB image would do is paying three times for image headers, three texture slots, three samplers, three decode passes and three cache lines.
Packing them — AO in red, roughness in green, metallic in blue — typically removes 40–60% of a material's non-albedo texture weight and reduces the sampler count in the shader at the same time. If you change one thing after reading this, change this. It costs an hour of pipeline work once and then applies to every asset forever.
Supercompression: KTX2 and Basis
PNG and JPEG are wire formats. The GPU cannot sample either, so the runtime decompresses them to raw RGBA and uploads that — a 1024×1024 albedo that travelled as a 400 KB PNG occupies 4 MB of VRAM the moment it lands. KTX2 with Basis supercompression is the fix: the texture stays block-compressed all the way into GPU memory, transcoding to whatever the device supports rather than being fully decoded.
It is not free. Encoding UASTC is slow — minutes, not seconds, for a room's worth of textures, which means it belongs in a build step and not in an artist's save loop. And the runtime needs a transcoder present. Unity handles KTX2 via a package that has to actually be included in the build, and the same constraint shapes the decision differently on the web — one of the concrete differences in Three.js versus Unity WebGL is that Three.js makes you wire up the transcoder yourself and pay for its download, where Unity has already paid for it inside a 28 MB binary you were shipping regardless.
gltf-transform dedup room.glb room.opt.glb \
&& gltf-transform prune room.opt.glb room.opt.glb \
&& gltf-transform resize --width 1024 --height 1024 room.opt.glb room.opt.glb \
&& gltf-transform uastc room.opt.glb room.ktx.glb \
--slots "{normalTexture,baseColorTexture}" --level 4 --rdo 4 --zstd 18 \
&& gltf-transform etc1s room.ktx.glb room.final.glb \
--slots "!{normalTexture,baseColorTexture}" --quality 160
# gltfpack, one shot, when you want mesh work in the same pass:
gltfpack -i room.glb -o room.final.glb -tc -tq 8 -si 1.0 -noq
Run prune and dedup before anything expensive. Encoding textures that the next step deletes is the most common way to waste twenty minutes of build time.
Draco is not a free win
Draco compresses mesh attributes hard — 4–8× on positions and normals is routine. The wire saving is real and the tradeoff is equally real: the client must download a decoder and then spend CPU rebuilding every vertex buffer before a single frame draws.
For a room whose geometry is 300 KB of a 3 MB file, Draco might recover 220 KB while adding a decoder fetch and 80–200 ms of single-threaded decode on a mid-range Android handset. That is a net loss. The device that most needs the smaller download is the same device least able to afford the decode, and it pays the CPU cost on every visit while the bandwidth saving is paid once and then cached.
Draco earns its place on geometry-heavy assets — scanned meshes, dense terrain, anything north of a megabyte of vertex data. On a texture-dominated room it is usually a worse experience dressed up as a smaller number. Measure time-to-first-frame on a real phone, not file size in a listing.
What the exporter silently keeps
A Blender scene accumulates debris and the glTF exporter is loyal to all of it: second and third UV maps nobody sampled, vertex colour layers from a long-abandoned bake, hidden objects that are still enabled for render, unapplied modifiers that quietly quadruple a subdivided mesh, camera and light nodes the viewer will never read, and materials referenced by nothing. None of this shows up in the viewport. All of it ships.
import bpy
KEEP_UV = "UVMap"
for ob in list(bpy.data.objects):
if ob.hide_render or ob.type in {"CAMERA", "LIGHT", "EMPTY"}:
bpy.data.objects.remove(ob, do_unlink=True)
continue
if ob.type != "MESH":
continue
me = ob.data
# Extra UV layers survive export silently and cost 8 bytes per vertex each.
for uv in [u.name for u in me.uv_layers if u.name != KEEP_UV]:
me.uv_layers.remove(me.uv_layers[uv])
# Colour attributes are written as full float sets unless removed here.
while me.color_attributes:
me.color_attributes.remove(me.color_attributes[0])
# Orphans are only collected on save, so force the sweep before exporting.
bpy.ops.outliner.orphans_purge(do_recursive=True)
On a typical room that script alone removes 200–600 KB before any encoder runs, and it removes it deterministically, which matters more than the amount.
Merging draw calls versus keeping objects addressable
Joining every static mesh into one object cuts draw calls and shrinks the node hierarchy. It also destroys the ability to reference anything by name at run time. Rooms that need per-object interaction — a door, a screen, something the viewer highlights — have to keep those objects separate; everything else merges by material. The rule that holds up is: merge by material, split only where behaviour requires a handle. Details of how the loader resolves those names are in the Unity build that loads these rooms.
Measure with the file, not the viewport
Blender's statistics overlay tells you about triangles, which is the number that matters least. The only honest measurement is the exported file on disk, inspected by something that reads the actual buffers. gltf-transform inspect prints per-mesh vertex counts and a per-texture table with resolution and byte size, which is where the forgotten 4K albedo finally becomes visible. Make that report part of the export step so nobody has to remember to look.
The ten-minute cache window
There is one deployment quirk that constrains this whole pipeline. Unity appends .glb to any path that does not already end in it, so a room URL cannot carry a ?v=<hash> fingerprint the way thumbnails do — the query string breaks the suffix check, and thumbnails get fingerprinted precisely because they can.
Room files are therefore replaced in place at the same URL, which makes the cache header the only control available. They are served max-age=0, s-maxage=600, stale-while-revalidate=86400. The browser revalidates every time, so a user never sees a stale room after a hard reload; shared caches hold the file for ten minutes and may serve it stale for a day while refreshing behind the request. The earlier policy of no-cache, must-revalidate was correct for a browser and ruinous for a CDN, asking every edge to phone the origin on every request.
The practical consequence: replacing a room in place takes up to ten minutes to reach everyone. Plan around it rather than fighting it — if a change must be instant, it needs a new room slug, which is a new URL, which caches like any other new file.
Keep a lossless source; the .glb is a build artefact
The .blend file holds full-resolution textures, unapplied modifiers, the lighting rig, everything. The .glb is output. It is never edited, never re-imported to fix something, never treated as the master. The moment someone opens a shipped .glb, tweaks a material and re-exports, quality is gone permanently — you cannot un-encode ETC1S.
That discipline pays off beyond the web build. The same lossless source is what makes turning the same model into a paper build possible at all, because unfolding needs clean topology and real UV seams, not a merged decimated mesh with transcoded textures. One source, many build targets, each with its own budget. The 3 MB room is one of those targets, not the truth.
Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.
Arthis