Use Cases Compare Learn Blog Docs Open Studio

How to Save and Reload a Three.js Scene Without Losing Data

If you want to save a Three.js scene and reload it exactly as you left it, the two obvious tools will let you down. Export with GLTFExporter, re-import, and materials flatten. Call Object3D.toJSON() and load it back, and your parametric box is now a frozen vertex soup. This is the years-old three.js Discourse complaint that never fully goes away: "what I see as loaded scene is not what I previously saved."

The fix is not a better exporter. It is to stop serializing the Three.js object tree at all, and serialize your own semantic scene model instead. Here is why, and exactly how.

Why toJSON() and GLTFExporter lose data

Both built-in paths serialize the render tree — the live THREE.Object3D hierarchy the GPU draws. That tree is a lossy projection of what you actually authored. Once you round-trip through it, the intent is gone.

PathWhat survivesWhat you lose
Object3D.toJSON() + ObjectLoadernode tree, transforms, standard material propsparametric geometry params, custom userData links, app semantics, non-standard materials
GLTFExporter + GLTFLoaderrender-accurate geometry, glTF-PBR materials, animation clipsyour node organization, parametric params, non-PBR shaders, anything not in extras
Your own semantic modeleverything you definednothing — you own the schema

Two concrete failures show up over and over:

GLTFExporter is doing its job correctly — glTF is an interchange format, designed to render the same everywhere, not to preserve your editor's intent. It is the wrong tool for a working save file, and the right tool for shipping to Blender, Unity, or a client. Keep both. Just do not use interchange as your source of truth.

Serialize your own semantic graph instead

The durable approach: treat the Three.js objects as a derived view, and make a normalized object graph your source of truth. Every object is an id mapping to { transform, material params, geometry params, tags, parentId }. That graph is what you save. The Three.js scene is rebuilt from it on load, never the other way around.

This is exactly the store-as-source-of-truth pattern that also keeps React Three Fiber fast — see avoiding the R3F re-render storm for the subscription side of it.

Here is a schema that round-trips losslessly because you decide what it holds:

type Vec3 = [number, number, number]

interface SerializedObject { id: string name: string type: 'box' | 'sphere' | 'cylinder' | 'gltf' transform: { position: Vec3 rotation: Vec3 // Euler XYZ in radians scale: Vec3 } geometry: Record<string, number> // parametric dims, e.g. { width, height, depth } material: { color: string roughness: number metalness: number opacity: number emissive?: string map?: string // asset id or URL, never a live THREE.Texture } tags: string[] parentId: string | null }

interface SerializedScene { version: 2 objects: SerializedObject[] environment: { hdri: string; background: string } }

Note what is not here: no baked buffers, no THREE.Texture handles, no GPU state. Every field is plain JSON you fully control. The map field stores a URL or asset id, not the texture object — the texture is re-resolved on load.

Save and load

Saving is a projection of your store into the schema. If you keep scene state in a store keyed by id, this is a few lines:

function saveScene(): string {
  const { objects, objectOrder, environment } = useSceneStore.getState()
  const doc: SerializedScene = {
    version: 2,
    objects: objectOrder.map((id) => objects[id]),
    environment,
  }
  return JSON.stringify(doc)
}

Loading is the inverse, plus two safety passes:

function loadScene(json: string) {
  const doc = JSON.parse(json) as SerializedScene

const objects: Record<string, SerializedObject> = {} const objectOrder: string[] = [] for (const o of doc.objects) { objects[o.id] = o objectOrder.push(o.id) }

// Drop dangling parent links so nothing orphans on reload for (const o of Object.values(objects)) { if (o.parentId && !objects[o.parentId]) o.parentId = null }

// Replacing the store unmounts old meshes; R3F disposes the // geometries and materials it created for them. useSceneStore.setState({ objects, objectOrder, environment: doc.environment, }) }

Rebuild is just rendering

The elegant part: because the Three.js objects are derived, "reload" means "replace the store and let React rebuild the tree." In R3F you never manually reconstruct objects — you render them from the model, and the parametric geometry comes back as a real parametric geometry, not baked triangles:

function SceneObjectMesh({ id }: { id: string }) {
  const obj = useSceneStore((s) => s.objects[id])
  if (!obj) return null
  const { geometry: g, material: m, transform: t } = obj
  return (
    <mesh position={t.position} rotation={t.rotation} scale={t.scale}>
      {obj.type === 'box' && <boxGeometry args={[g.width, g.height, g.depth]} />}
      {obj.type === 'sphere' && <sphereGeometry args={[g.radius, 32, 32]} />}
      <meshStandardMaterial
        color={m.color}
        roughness={m.roughness}
        metalness={m.metalness}
        transparent={m.opacity < 1}
        opacity={m.opacity}
      />
    </mesh>
  )
}

Change g.width later and the box updates. That is the whole point — the semantic never got flattened.

Gotchas that bite in production

How Yugma handles this

Yugma's working format is a purpose-built serialized scene document over its editable semantic graph — per-object ids, parametric geometry params, material params, tags, and parent links, exactly the shape above. Save and reopen is byte-for-intent exact: a box is still a box, a group is still a group, and per-action undo history rides the same graph. GLB and USDZ export exist too, but they are outputs for interchange, not the save file. That separation is the honest lesson here: use glTF to hand your scene to another tool; use a semantic model you own to save your own work.

Frequently asked questions

Does GLTFExporter lose data when I re-import into Three.js?

For an editable working file, yes — it bakes parametric geometry into vertex buffers and maps every material onto glTF's metallic-roughness model, so custom shaders, non-PBR params, and your node organization do not survive. It is still the correct choice for exporting to other tools, because rendering-everywhere is exactly what it optimizes for.

Can I keep using Object3D.toJSON() for anything?

It is fine for a quick clipboard copy of a single node inside one session, or as a debug dump. As a persistent save format it loses your app semantics and parametric params, and ObjectLoader cannot reconstruct intent it was never given. Serialize your own model for anything you plan to reopen.

How do I store textures in a serialized scene?

Store a URL or an asset id in the object, plus a manifest that maps ids to files. On load, resolve those through a loader (which caches by URL) and set them on the material. Never serialize the live THREE.Texture or a raw pixel blob — you will bloat the file and still fail to round-trip it.

The same serialized document is also what you send to a new collaborator on join — see adding real-time multiplayer to a 3D web app for how the save format doubles as the sync snapshot.

Read the Yugma vs Three.js comparison →