Docs Enter the town

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

From packages/protocol/glb.ts, model-preparation.ts, plot-scene.ts and plot-limits.ts. NFTs hung from a wallet do not count.
BudgetHard limit (refused above)Aim for
One GLB: file size20 MB3 MB for a whole building, far less for props
One GLB: triangles200,000180,000
One GLB: materials / draw primitives64 / 25624 / 240, and one material where you can
One GLB: textures4,096 px a side, 16.8 megapixels in all; PNG, JPEG or WebP2,048 px, 12.6 megapixels, WebP
One 16 m cell500,000 model triangles, 128 model instances from 32 files, 500 objectshalf the triangles, a few reused files
One plot50 MB and 200 files per cell, at most 200 MB and 800 fileswell under, so the plot loads quickly

2. Targets by object type

Aim for these; the hard limits are in the table above.
ObjectTrianglesMaterialsTexturesFile
Small prop: chair, lamp, vase, book pile500–3,0001one 512–1024 pxunder 200 KB
Large prop: table and chairs, fountain, bench, tree3,000–15,0001–2one 1024 px atlasunder 600 KB
Sculpture of your own (scenery, not an NFT)10,000–50,0001–2one 2048 px, normal map optionalunder 1.5 MB
Room interior or small pavilion20,000–60,000up to 4one 2048 px atlasunder 1.5 MB
Whole gallery building50,000–150,000 (at most 180,000)up to 8 (at most 24)one or two 2048 px atlases3 MB at most
Everything in one 16 m cellabout 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.

  1. 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).
  2. 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.
  3. Measure first. Call inspect_model on 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.
  4. 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 --bake when there are many materials.
  5. Finish with optimize_model: the site's own import preparation, run on their computer. It writes <name>-optimized.glb beside the original and reports before and after.
  6. Check again with inspect_model on the optimized copy. Explain the result in plain words ("it is now 1.4 MB and 38,000 triangles; the site accepts it").
  7. Place it with add_model (the optimized copy, never the original), then preview and describe_scene to see it in the plot.
  8. Always run validate before deploy. It checks every cell and the plot's weight the way the site will. Then review_changes and preview_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

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
  1. Removes lights and cameras (a model cannot light the shared town).
  2. Applies every modifier (subdivision, mirror, array, bevel) and merges duplicate vertices.
  3. Decimates every mesh by one 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: unwraps the joined model, bakes the colour of every material into one atlas with Cycles and gives the model one material.
  6. Shrinks every texture to --texture pixels on its longest side.
  7. 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:

Coming from a render scene

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.

What the site's import does automatically

optimize_model runs the same preparation as the plot editor's import in the browser. In order:

  1. 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.
  2. Welds identical vertices, merges duplicate data and removes repeated faces.
  3. Turns flat-coloured materials into vertex colours, so they share one draw.
  4. Packs textured materials into atlases when there are more than 24 materials or 64 textures.
  5. Joins static parts that share a material, in sections of 8 m so distant ones can still be culled.
  6. 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.
  7. 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.
  8. Packs the geometry with meshopt compression. The plot editor also builds a distant copy of up to 30,000 triangles; optimize_model writes 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

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

9. What gets refused

In the GLBWhat happensWhat to do
KTX2 / Basis textures (KHR_texture_basisu)Refused, also by the plot editor's importRe-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 installedExport with Compression off, or run optimize_model
GPU instancing, KHR_meshopt_compression, any other required extensionRefused--instance false; no gltfpack -mi or -cz
A .gltf with separate files, external texturesRefusedExport glTF Binary (.glb), textures embedded
Over 200,000 triangles, 20 MB, 64 materials or 256 draw primitivesRefused as it is; the import reduces it where it canThe Blender script, optimize_model, or split the model
A texture over 4,096 px or 16.8 megapixels in allRefused as it is; the import resizes it--texture 2048 in the Blender script, or optimize_model
Rigs (skins), morph targets, lights, sparse accessorsRefused for scenery and buildingsApply the pose, export without armature, shape keys or lights
Clearcoat, sheen, transmission and similar effectsRemoved by the import; drawn as plain PBRBake 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.