Docs · Optimizing assets
Optimizing assets for Internet Paradise
A model for a render can have a million triangles and 8K textures. A model for Internet Paradise is downloaded and drawn by every visitor's browser, often on a phone, with up to fifty people in the square. This page gives the budgets, a target for each kind of object, and the exact steps and commands to get there with free tools. It is written for creators and for the AI agents (Claude, Codex, ChatGPT, Cursor) that help them.
Every limit on this page is read from the site's own checks when the page is built. The targets by object type are recommendations: a model may use more, as long as the plot as a whole fits.
1. The budgets
| Budget | Hard limit (refused above) | Aim for |
|---|---|---|
| One GLB: file size | 20 MB | 3 MB for a whole building, far less for props |
| One GLB: triangles | 200,000 | 180,000 |
| One GLB: materials / draw primitives | 64 / 256 | 24 / 240, and one material where you can |
| One GLB: textures | 4,096 px a side, 16.8 megapixels in all; PNG, JPEG or WebP | 2,048 px, 12.6 megapixels, WebP |
| One 16 m cell | 500,000 model triangles, 128 model instances from 32 files, 500 objects | half the triangles, a few reused files |
| One plot | 50 MB and 200 files per cell, at most 200 MB and 800 files | well under, so the plot loads quickly |
- Triangles cost on every copy. A file placed ten times is downloaded once but drawn ten times: ten chairs of 5,000 triangles are 50,000 of the cell's 500,000.
- Draw calls matter as much as triangles. Each material on each mesh part is one draw call, doubled for shadows. One building of 40 parts and 30 materials can cost more than a 150,000-triangle sculpture with one material. Join parts that share a material and bake materials into an atlas.
- File size is not GPU memory. A 2048 × 2048 texture is a few hundred kilobytes as WebP but about 16.8 MB on the graphics card (plus a third for mip-maps). Halving the side quarters it.
- A visitor's device holds only so much in detail at once: a phone 600,000 model triangles and 160 draw calls, a desktop with memory to spare 1,600,000 and 360. Models beyond that wait until there is room. Light models are seen sooner and by more people.
2. Targets by object type
| Object | Triangles | Materials | Textures | File |
|---|---|---|---|---|
| Small prop: chair, lamp, vase, book pile | 500–3,000 | 1 | one 512–1024 px | under 200 KB |
| Large prop: table and chairs, fountain, bench, tree | 3,000–15,000 | 1–2 | one 1024 px atlas | under 600 KB |
| Sculpture of your own (scenery, not an NFT) | 10,000–50,000 | 1–2 | one 2048 px, normal map optional | under 1.5 MB |
| Room interior or small pavilion | 20,000–60,000 | up to 4 | one 2048 px atlas | under 1.5 MB |
| Whole gallery building | 50,000–150,000 (at most 180,000) | up to 8 (at most 24) | one or two 2048 px atlases | 3 MB at most |
| Everything in one 16 m cell | about 250,000 (at most 500,000) | as few as possible | — | 50 MB of files at most |
NFTs you hang from your wallet are not on this table: the site makes their display copies itself (limits for NFT copies). This page is for the buildings, furniture and scenery you model or bring.
3. For AI agents: guiding a creator step by step
Most creators who ask "I want to build in Internet Paradise, how?" are artists, not engineers. Ask what they have, then follow these steps. Run the commands yourself when you have a terminal; otherwise give them one at a time and wait for the result.
- Ask what they bring. A model they made (which program, roughly how heavy), a model they downloaded (check they may use it), or nothing yet (you can model it with Blender's Python).
- Install the free tools (below): Node.js 20 or newer and the Internet Paradise SDK; Blender 4.1 or newer if there is modelling, baking or a non-GLB file to convert.
- Measure first. Call
inspect_modelon every GLB. It prints triangles, draw primitives, materials, textures and size against the target and the hard limit, and says why the site would refuse it. - Reduce in Blender when the model is far over (millions of triangles, dozens of
materials, 4K–8K textures, modifiers, a non-GLB source): run the
Internet Paradise Blender script, with
--bakewhen there are many materials. - Finish with
optimize_model: the site's own import preparation, run on their computer. It writes<name>-optimized.glbbeside the original and reports before and after. - Check again with
inspect_modelon the optimized copy. Explain the result in plain words ("it is now 1.4 MB and 38,000 triangles; the site accepts it"). - Place it with
add_model(the optimized copy, never the original), thenpreviewanddescribe_sceneto see it in the plot. - Always run
validatebeforedeploy. It checks every cell and the plot's weight the way the site will. Thenreview_changesandpreview_link, and let the owner decide; only they publish, with their wallet.
Never upload a creator's files anywhere to optimize them: everything here runs on their own computer. Never ask for a seed phrase, private key or wallet password. Keep their originals; write optimized copies next to them.
4. Installing the free tools
- Node.js 20 or newer: from nodejs.org (the LTS installer).
Check with
node --version. - The Internet Paradise SDK: two files, no install.
Connect it to the agent as the SDK guide shows (for Claude Code:mkdir internet-paradise cd internet-paradise curl -O https://internetparadise.xyz/sdk/third-life-mcp.mjs curl -O https://internetparadise.xyz/sdk/third-life-pack.mjs curl -O https://internetparadise.xyz/sdk/teztopia_optimize.pyclaude mcp add internet-paradise -- node "$PWD/third-life-mcp.mjs" --scene "$PWD/my-world.json"). - Textures in
optimize_model:npm install sharpin the same folder. Without it the geometry is still optimized, but textures are left as they are and the report names them. Addnpm install draco3dgltfto open Draco-compressed GLBs. - Blender 4.1 or newer (free): from blender.org. It
opens
.blend,.fbx,.objand.glbfiles and exports GLB. An agent can drive it without opening its window:blender -b --python script.py. On Windows the program is"C:\Program Files\Blender Foundation\Blender 4.5\blender.exe"; on macOS/Applications/Blender.app/Contents/MacOS/Blender. - Optional command-line tools: glTF-Transform runs with no install through
npx @gltf-transform/cli; gltfpack from the meshoptimizer releases (commands below).
5. The Blender script
teztopia_optimize.py takes a render-weight scene to a
town-weight GLB in one command. It was run on Blender 4.1, 4.5 and 5.2.
# A house: at most 100,000 triangles, textures 2048 px
blender -b --python teztopia_optimize.py -- house.blend house.glb --triangles 100000 --texture 2048
# A chair from an FBX: 3,000 triangles, every material baked into one 1024 px atlas
blender -b --python teztopia_optimize.py -- chair.fbx chair.glb --triangles 3000 --texture 1024 --bake
- Removes lights and cameras (a model cannot light the shared town).
- Applies every modifier (subdivision, mirror, array, bevel) and merges duplicate vertices.
- Decimates every mesh by one ratio until the whole model is under
--triangles. - Joins meshes that use the same materials, so each material is drawn once.
- With
--bake: unwraps the joined model, bakes the colour of every material into one atlas with Cycles and gives the model one material. - Shrinks every texture to
--texturepixels on its longest side. - Exports a GLB with WebP textures, no Draco, no lights or cameras, modifiers applied.
Then run optimize_model on the result: it adds meshopt compression and the site's own
checks. Baking flattens lighting-dependent effects into colour: check the result in preview.
teztopia_optimize.py the whole script
"""Internet Paradise: make a Blender scene light enough for the town, and export it as a GLB.
Run it without opening Blender's window (Blender 4.1 or newer; tested with 4.1, 4.5 and 5.2):
blender -b --python teztopia_optimize.py -- house.blend house.glb --triangles 100000 --texture 2048
blender -b --python teztopia_optimize.py -- chair.fbx chair.glb --triangles 3000 --texture 1024 --bake
The input may be a .blend, .glb, .gltf, .fbx or .obj. What it does, in order:
1. Removes lights and cameras (models cannot light the shared town).
2. Applies every modifier, then merges duplicate vertices.
3. Decimates every mesh by the same ratio until the whole model is under --triangles.
4. Joins meshes that use the same materials, so each material is drawn once.
5. With --bake: bakes every material's colour into ONE atlas texture and one material.
6. Shrinks every texture to at most --texture pixels on its longest side.
7. Exports a GLB: WebP textures, no Draco, no lights or cameras, modifiers applied.
Then check the result with the Internet Paradise MCP tool inspect_model (or https://internetparadise.xyz/docs/check).
"""
import sys
import bmesh
import bpy
argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
if len(argv) < 2:
raise SystemExit("Usage: blender -b --python teztopia_optimize.py -- INPUT OUTPUT.glb "
"[--triangles N] [--texture PX] [--bake]")
source, output = argv[0], argv[1]
option = lambda name, default: int(argv[argv.index(name) + 1]) if name in argv else default
TRIANGLES = option("--triangles", 100_000) # Internet Paradise refuses a GLB above 200,000
TEXTURE = option("--texture", 2048) # Internet Paradise's import aims for 2048 px; 4096 is the hard limit
BAKE = "--bake" in argv
# 0. Open or import the source into an empty scene.
if source.lower().endswith(".blend"):
bpy.ops.wm.open_mainfile(filepath=source)
else:
bpy.ops.wm.read_factory_settings(use_empty=True)
ext = source.lower().rsplit(".", 1)[-1]
if ext in ("glb", "gltf"):
bpy.ops.import_scene.gltf(filepath=source)
elif ext == "fbx":
bpy.ops.import_scene.fbx(filepath=source)
elif ext == "obj":
bpy.ops.wm.obj_import(filepath=source)
else:
raise SystemExit(f"Cannot read .{ext}; use .blend, .glb, .gltf, .fbx or .obj")
scene = bpy.context.scene
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
def meshes():
return [o for o in scene.objects if o.type == "MESH"]
def triangles(objects):
total = 0
for o in objects:
o.data.calc_loop_triangles()
total += len(o.data.loop_triangles)
return total
def select(objects, active=None):
bpy.ops.object.select_all(action="DESELECT")
for o in objects:
o.select_set(True)
bpy.context.view_layer.objects.active = active or (objects[0] if objects else None)
# 1. No lights or cameras.
for o in [o for o in scene.objects if o.type in ("LIGHT", "CAMERA")]:
bpy.data.objects.remove(o, do_unlink=True)
# 2. Apply modifiers (convert to mesh) and merge duplicate vertices.
select(meshes())
if meshes():
bpy.ops.object.convert(target="MESH")
for o in meshes():
bm = bmesh.new()
bm.from_mesh(o.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
bm.to_mesh(o.data)
bm.free()
before = triangles(meshes())
# 3. Decimate everything by one ratio until the model fits. Shape keys block Decimate.
for _ in range(3):
total = triangles(meshes())
if total <= TRIANGLES:
break
ratio = max(0.01, TRIANGLES / total * 0.98)
for o in meshes():
if o.data.shape_keys or len(o.data.polygons) < 50:
continue
mod = o.modifiers.new("Internet Paradise decimate", "DECIMATE")
mod.decimate_type = "COLLAPSE"
mod.ratio = ratio
mod.use_collapse_triangulate = True
select(meshes())
bpy.ops.object.convert(target="MESH")
# 4. Join meshes that share the same materials (rigged or shape-keyed meshes stay apart).
groups = {}
for o in meshes():
if o.data.shape_keys or o.find_armature():
continue
key = tuple(sorted(m.name for m in o.data.materials if m))
groups.setdefault(key, []).append(o)
for group in groups.values():
if len(group) > 1:
select(group)
bpy.ops.object.join()
# 5. Optional: bake all colour into one atlas with Cycles, one material for the whole model.
if BAKE and meshes():
select(meshes())
if len(meshes()) > 1:
bpy.ops.object.join()
target = bpy.context.view_layer.objects.active
uv = target.data.uv_layers.new(name="Atlas")
target.data.uv_layers.active = uv
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(island_margin=0.003)
bpy.ops.object.mode_set(mode="OBJECT")
atlas = bpy.data.images.new("Atlas", TEXTURE, TEXTURE, alpha=False)
for slot in target.material_slots:
material = slot.material
if not material:
continue
material.use_nodes = True
node = material.node_tree.nodes.new("ShaderNodeTexImage")
node.image = atlas
material.node_tree.nodes.active = node # the bake writes into the active image node
scene.render.engine = "CYCLES"
scene.cycles.samples = 16
scene.render.bake.use_pass_direct = False
scene.render.bake.use_pass_indirect = False
scene.render.bake.margin = 4
bpy.ops.object.bake(type="DIFFUSE", pass_filter={"COLOR"}, use_clear=True)
atlas.pack()
baked = bpy.data.materials.new("Atlas")
baked.use_nodes = True
nodes, links = baked.node_tree.nodes, baked.node_tree.links
image = nodes.new("ShaderNodeTexImage")
image.image = atlas
coords = nodes.new("ShaderNodeUVMap")
coords.uv_map = "Atlas"
links.new(coords.outputs["UV"], image.inputs["Vector"])
links.new(image.outputs["Color"], nodes["Principled BSDF"].inputs["Base Color"])
nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.8
target.data.materials.clear()
target.data.materials.append(baked)
for layer in [l for l in target.data.uv_layers if l.name != "Atlas"]:
target.data.uv_layers.remove(layer)
target.data.uv_layers["Atlas"].active_render = True
# 6. No texture larger than --texture on its longest side.
for image in bpy.data.images:
w, h = image.size
if image.users and max(w, h) > TEXTURE:
k = TEXTURE / max(w, h)
image.scale(max(1, round(w * k)), max(1, round(h * k)))
image.pack()
# 7. Export a GLB the town accepts.
select(meshes())
bpy.ops.export_scene.gltf(
filepath=output,
export_format="GLB",
export_apply=True,
export_yup=True,
export_image_format="WEBP",
export_draco_mesh_compression_enable=False,
export_cameras=False,
export_lights=False,
)
after = triangles(meshes())
materials = len({m.name for o in meshes() for m in o.data.materials if m})
print(f"Internet Paradise: {before:,} -> {after:,} triangles, {len(meshes())} meshes, "
f"{materials} materials. Wrote {output}")
Modelling with Blender's Python
An agent that models for a creator (a house, a pergola, a bench) writes a bpy script and runs it
with blender -b --python. Model for the town from the start:
- Metres. One Blender unit is one metre; a door is at least 0.8 × 1.82 m, a visitor 1.72 m tall. Put the origin at the centre of the base.
- Few, large faces. Walls are boxes, not bricks; detail goes in the texture. Avoid subdivision and bevels with many segments; a bevel of 1–2 segments on visible edges is enough.
- Few materials. One material per surface kind (stone, plaster, roof tiles, wood); share them across parts. Old-European materials: stone, plaster, brick, terracotta tiles, wood.
- No hidden faces. Delete the bottoms of things standing on the ground and faces inside walls.
- Export with
bpy.ops.export_scene.gltf(filepath="house.glb", export_format="GLB", export_apply=True, export_image_format="WEBP", export_draco_mesh_compression_enable=False, export_cameras=False, export_lights=False), or run the script above on the saved.blend.
Coming from a render scene
- Subdivision and displacement: apply at level 0 or 1, and bake the fine detail into a normal map (Cycles: Bake › Normal, Selected to Active from the high-poly copy onto the low-poly one).
- 8K and 4K textures: 2048 px at most, 1024 or 512 for small things; one atlas instead of many maps.
- Glass, clearcoat, sheen, subsurface: the import removes
KHR_materials_clearcoat,KHR_materials_sheen,KHR_materials_transmission,KHR_materials_volume,KHR_materials_iridescence,KHR_materials_anisotropy,KHR_materials_dispersion,KHR_materials_variants,KHR_materials_pbrSpecularGlossiness,KHR_xmp_json_ldfrom scenery and draws plain PBR (NFT sculptures keep their effects). For glass, use a material with Alpha Blend and low opacity. - Hair, particles, volumes, procedural shaders: do not export. Convert hair to a few textured cards, bake procedural shaders into image textures.
- Rigs and shape keys: refused for scenery. Apply the pose and export without the armature.
6. inspect_model and optimize_model
Two tools of the SDK's local MCP server (SDK guide), working on files on the creator's computer. Nothing is uploaded.
inspect_model{ "path": "statue.glb" }: a table of file size, triangles, draw primitives, draws once batched, materials, textures and nodes, each against the target and the hard limit; what share of a cell it takes; and every reason the site would refuse it. Changes nothing.optimize_model{ "path": "statue.glb", "targetTriangles": 30000, "maxTextureSize": 1024 }: writesstatue-optimized.glb(oroutput) and returns the same table before and after.targetTrianglescan only lower the default 180,000;maxTextureSizeis 256, 512, 1024 or 2048. The original is never changed.
What the site's import does automatically
optimize_model runs the same preparation as the plot editor's import in the browser. In order:
- Decodes Draco, removes
KHR_materials_clearcoat,KHR_materials_sheen,KHR_materials_transmission,KHR_materials_volume,KHR_materials_iridescence,KHR_materials_anisotropy,KHR_materials_dispersion,KHR_materials_variants,KHR_materials_pbrSpecularGlossiness,KHR_xmp_json_ld(spec/gloss is converted to metal/rough), and names what it removed. - Welds identical vertices, merges duplicate data and removes repeated faces.
- Turns flat-coloured materials into vertex colours, so they share one draw.
- Packs textured materials into atlases when there are more than 24 materials or 64 textures.
- Joins static parts that share a material, in sections of 8 m so distant ones can still be culled.
- Simplifies geometry to 180,000 triangles (or your target) with meshoptimizer, moving no vertex further than 2 % of the model's size, keeping UV seams, borders and the model's outer box.
- Resizes textures to 2,048 px and 12.6 megapixels in all: colour as WebP, normal, roughness and occlusion maps as lossless PNG; over 3 MB, a second pass at 1,024 px.
- Packs the geometry with meshopt compression. The plot editor also builds a distant copy of up to
30,000 triangles;
optimize_modelwrites only the near copy.
Nodes whose names start with ART_, DISPLAY_, FRAME_,
PLAQUE_, COLLIDER_ or COLLISION_ are kept as they are: never joined or
simplified. Animated models are not simplified. What still does not fit is reported, never hidden.
The SDK's add_model does none of this by itself: it places the file as given and warns.
7. glTF-Transform and gltfpack
For creators who prefer the command line. These flags keep the result inside the site's rules.
npx @gltf-transform/cli inspect model.glb
npx @gltf-transform/cli optimize model.glb model-web.glb --compress meshopt --texture-compress webp --texture-size 2048 --instance false --sparse false
--instance false: the default writes GPU instancing (EXT_mesh_gpu_instancing), which the site refuses.--sparse false: the site refuses sparse accessors.- Add
--simplify-ratio 0.5 --simplify-error 0.001to aim for half the vertices within 0.1 % of the model's size;--simplify falseto leave the geometry alone;--join-named falseto keep named parts (exhibition slots) separate. - Never
--compress dracoor--texture-compress ktx2.
Step by step, when you want control:
npx @gltf-transform/cli weld in.glb welded.glb
npx @gltf-transform/cli dedup welded.glb dedup.glb
npx @gltf-transform/cli simplify dedup.glb simple.glb --ratio 0.5 --error 0.001
npx @gltf-transform/cli palette simple.glb palette.glb
npx @gltf-transform/cli resize palette.glb sized.glb --width 2048 --height 2048
npx @gltf-transform/cli webp sized.glb webp.glb --quality 90
npx @gltf-transform/cli meshopt webp.glb final.glb
palette merges flat-coloured materials into one small texture and one material, the usual case for
kit-built architecture. gltfpack does most of this in one step:
gltfpack -i model.glb -o model-web.glb -c -tw -si 0.5 -kn
-c meshopt, -tw WebP textures, -si 0.5 half the triangles, -kn keep named
nodes. Never -tc or -tu (KTX2), -cz or -mi.
More: limits and optimising.
8. Checking the result
inspect_model(agent) or Check my model (browser, nothing uploaded): the model against the site's own profile.npx @gltf-transform/cli inspect model.glb: every mesh, material and texture with its size, to find where the weight is.validate(agent) ornode third-life-pack.mjs scene.json --check: the whole plot, every cell's totals and the plot's weight, before anything is deployed.previewandpreview_link: look at it, then walk it on the plot.
9. What gets refused
| In the GLB | What happens | What to do |
|---|---|---|
KTX2 / Basis textures (KHR_texture_basisu) | Refused, also by the plot editor's import | Re-export with WebP, PNG or JPEG textures |
Draco (KHR_draco_mesh_compression) | Refused by the SDK and in published files; the plot editor's import decodes it, and optimize_model with draco3dgltf installed | Export with Compression off, or run optimize_model |
GPU instancing, KHR_meshopt_compression, any other required extension | Refused | --instance false; no gltfpack -mi or -cz |
A .gltf with separate files, external textures | Refused | Export glTF Binary (.glb), textures embedded |
| Over 200,000 triangles, 20 MB, 64 materials or 256 draw primitives | Refused as it is; the import reduces it where it can | The Blender script, optimize_model, or split the model |
| A texture over 4,096 px or 16.8 megapixels in all | Refused as it is; the import resizes it | --texture 2048 in the Blender script, or optimize_model |
| Rigs (skins), morph targets, lights, sparse accessors | Refused for scenery and buildings | Apply the pose, export without armature, shape keys or lights |
| Clearcoat, sheen, transmission and similar effects | Removed by the import; drawn as plain PBR | Bake or simplify the material |
Accepted extensions: EXT_meshopt_compression, KHR_mesh_quantization, EXT_texture_webp, KHR_materials_emissive_strength, KHR_materials_unlit, KHR_materials_specular, KHR_materials_ior, KHR_texture_transform.