Scene files
An OrblitScene is this frame, stated whole. A scene file is what somebody
authored: entities with ids, where each one sits in a tree, and what each one
is. Two packages handle them, and they are kept apart on purpose.
orblit_sceneis plain Dart with no Flutter in it. It reads, writes, migrates and diffs documents, so an importer or a command-line tool can use it without taking a renderer along.orblit_stageturns a document into anOrblitSceneand keeps it up to date as the document changes.
What a file looks like
Section titled “What a file looks like”{ "formatVersion": 5, "name": "Yard", "sky": "#1A2029", "ambient": 2000.0, "time": { "hour": 18.5 }, "entities": [ { "id": "camera", "name": "Camera", "components": { "transform": { "position": [0.0, 1.6, 6.0] }, "camera": { "fieldOfView": 45.0 } } }, { "id": "lamp", "name": "Lamp", "components": { "transform": { "position": [2.0, 0.0, -1.0], "rotation": [0.0, 30.0, 0.0] }, "mesh": { "asset": "models/lamp.glb" }, "light": { "kind": "point", "power": 40.0, "colour": "#FFD6A0" } } }, { "id": "shade", "name": "Shade", "parent": "lamp", "components": { "transform": { "position": [0.0, 1.8, 0.0] }, "mesh": { "asset": "models/shade.glb", "castShadows": false } } } ]}There is no kind on an entity. What it is comes from the components it has,
so the lamp above is a mesh and a light at once. A transform is relative to
the parent, and rotation is in degrees, applied Z, then Y, then X. The
components are transform, mesh, material, light, camera, splats,
sprite, tilemap, parallax, weather, canvas, data, prefab,
motion,
body,
joint and
terrain.
Beside sky, ambient and time, a file can name its physics layers with
layerNames. See Layer names.
A component this version doesn’t recognise is kept exactly as it arrived and written back out, so a newer copy of Orblit on one machine doesn’t lose work for an older one on another.
Reading and saving
Section titled “Reading and saving”SceneDocument.decode refuses three things outright, with a
SceneFormatException:
- A file with no
formatVersion: “This file does not say what format version it is, so it cannot be read safely.” - A file from a newer Orblit: “This scene was written by a newer Orblit (format 9; this one reads up to 5).”
- Something that isn’t JSON: “This is not a scene file: …”
Everything else is read as far as it will go, and what went wrong comes back
in SceneLoad.problems instead of costing you the whole file. An entity with
no id is left out. A second entity with the same id is dropped. An entity
whose parent isn’t in the file, or which ends up inside itself, moves to the
top level.
encode() writes every field, defaults included, with the keys in a fixed
order. Saving the same document twice gives the same bytes, so a scene in a
repository only shows a diff when something actually changed.
Migrations
Section titled “Migrations”An older file is brought up to format 5 as it is read, one step at a time.
Each step can add a note to problems, because a migration that quietly
changes how a scene looks is worse than one that says so.
| Version | What changed |
|---|---|
| 2 | A light’s power stopped being watts for everything. A sun is now watts per square metre, at the same brightness. |
| 3 | The fog moved off the scene and into a Weather entity. |
| 4 | An entity’s kind became the set of components it has. |
| 5 | A prefab instance stopped being a copy of the prefab. A copy saved at 4 is marked as one, and relinked the first time its prefab can be read, with what had been changed about it kept. |
SceneMigrations.ordered is the list, if you want to see what will run.
Prefab instances
Section titled “Prefab instances”A prefab is a subtree saved on its own as a .oprefab file, to be placed in
scenes as many times as you like. A placed one is an instance, and it is saved
as a single entity: a link to the prefab, and what is different about this
one. Here is a lamp placed at (2, 0, −1) with its shade turned up from 40 W to
80 W:
{ "id": "lamp", "name": "Lamp", "components": { "prefab": { "asset": "prefabs/lamp.oprefab", "overrides": [ {"op": "field", "id": "lamp", "type": "transform", "field": "position", "from": [0.0, 0.0, 0.0], "to": [2.0, 0.0, -1.0]}, {"op": "field", "id": "shade", "type": "light", "field": "power", "from": 40.0, "to": 80.0} ] } }}The overrides are a diff against the prefab, using the prefab’s own ids. The parts themselves aren’t in the file, so a street of a hundred lamps is a hundred short entries rather than a hundred lamps. Change the prefab and every instance changes. Change one instance and only it does.
expandInstances(document, prefabs) opens every instance, putting the
prefab’s parts into the document. foldInstances(document, prefabs) closes
them again for saving, and works out each one’s overrides from how its parts
differ from the prefab. Both take a PrefabSource, a function from a path to
a PrefabDocument, so where the prefabs come from is up to you.
The id is the path. An opened part’s id is the id it has in each document
it sits inside, joined by /. lamp/shade is the shade of the instance
lamp, and street1/lamp3/bulb is the bulb of the lamp lamp3 inside the
street prefab placed as street1. The root of an instance keeps the
instance’s own id. There is one id per document crossed and nothing for the
entities in between, so moving the bulb from one arm of the lamp to the other
inside the prefab doesn’t change its path. A parent link, a selection or an
animation track names a part the way it names anything else. EntityPath
holds the rules, and / is reserved in ids.
- Something hung on a part from outside, like a flag on the lamp’s shade, belongs to the scene. It is saved in the scene with its parent given by path, and stays there when the lamp is folded.
- A prefab that can’t be read leaves its instances folded, exactly as they
were saved, with a note in
problems. So does a prefab that contains itself. Nothing is lost, and the next open that can read the prefab opens them. - An override of a part the prefab no longer has is let go, with a note.
- A part moved outside its own instance can’t be said as a change to that instance, and folds back under the instance’s root. Unpack it first.
makePrefab turns a subtree into a prefab and the subtree into its first
instance. applyInstance writes an instance’s overrides into its prefab, and
refreshInstances brings the other instances of it up to date: each keeps
its own overrides and takes the rest. revertInstance throws an instance’s
overrides away but keeps where it stands, and unpackInstance turns it back
into ordinary entities that no longer follow the prefab. Each one returns the
ids it renamed, so a selection can follow.
Drawing one
Section titled “Drawing one”import 'dart:io';
import 'package:flutter/widgets.dart';import 'package:orblit_filament/orblit_filament.dart';import 'package:orblit_scene/orblit_scene.dart';import 'package:orblit_stage/orblit_stage.dart';import 'package:vector_math/vector_math_64.dart';
// Each prefab is read once, and kept for as long as the scene is open.PrefabSource prefabsIn(String projectRoot) { final read = <String, PrefabDocument?>{}; return (asset) => read.putIfAbsent(asset, () { final file = File('$projectRoot/$asset'); if (!file.existsSync()) return null; return PrefabDocument.decode(file.readAsStringSync()).prefab; });}
OrblitDocumentView open( String path, PrefabSource prefabs, { String? projectRoot,}) { final load = SceneDocument.decode(File(path).readAsStringSync()); final opened = expandInstances(load.document, prefabs); for (final problem in [...load.problems, ...opened.problems]) { print(problem); } return OrblitDocumentView(opened.document, projectRoot: projectRoot);}
// Raises an entity, keeping its rotation and scale.void lift(OrblitDocumentView view, String id, double metres) { final document = view.document; final entity = document[id]; final transform = entity?[SceneComponents.transform]; if (entity == null || transform is! TransformComponent) return;
final next = document.withEntity( id, entity.withComponent( SceneComponents.transform, TransformComponent( position: transform.position + Vector3(0, metres, 0), rotation: transform.rotation, scale: transform.scale, ), ), ); view.apply(SceneDiff.between(document, next));}
Widget draw(OrblitDocumentView view) => OrblitView(scene: view.scene);
void save(OrblitDocumentView view, String path, PrefabSource prefabs) => File( path, ).writeAsStringSync(foldInstances(view.document, prefabs).encode());projectRoot is joined to relative asset paths. Leave it null when the
assets are handed over as bytes,
because the renderer checks its resource store first and a path rewritten to
somewhere on disk would miss it.
Reading view.scene every frame is cheap: the objects are kept, and only the
lists are put together. Here is what each component turns into:
- mesh: an
OrblitObjectwith the file’s colour and shadow settings. Amaterialcomponent’s asset becomes its base colour texture. - light: a light in the renderer’s units. A hidden one is left out rather than sent dark.
- splats: an
OrblitSplatswith its budget as thelimitand its harmonics clamped to 0–3, or 2 if the file doesn’t say. - sprite: a layer of one sprite.
- camera: the first camera entity that isn’t hidden sets the view. With no camera, you look at the origin from (6, 4, 8).
- weather: sets the sky, the fog and anything falling.
SceneDiff.between(before, after) addresses entities by id, never by
position, and goes down to single fields. Lifting the shade 0.2 m with lift
above gives one operation:
{"op": "field", "id": "shade", "type": "transform", "field": "position", "from": [0.0, 1.8, 0.0], "to": [0.0, 2.0, 0.0]}A diff has an inverse, and inverse.applyTo(diff.applyTo(before)) is
before again. toJson and SceneDiff.fromJson round-trip it, which is all
an undo stack needs. OrblitDocumentView.apply rebuilds only the entities a
diff touches plus everything under them, and replace(next) works out the
diff for you. Over 200 random pairs of documents and a run of 150 edits, a
view moved by diffs matched one built from scratch, and 400 random pairs
applied and inverted cleanly.
What isn’t there yet
Section titled “What isn’t there yet”- Tilemap, parallax, canvas and data components are read, kept, diffed and saved, but the stage doesn’t draw them.
- An inner prefab can’t be applied from an outer instance. An edit to the lamp inside a placed street is an override of the street. To change the lamp prefab itself, open it on its own.
- Sprites draw their whole
textureoratlas. The stage doesn’t applyregionoranimation. - Meshes are drawn from their
asset. A mesh component holding its geometry in the file is drawn as the renderer’s built-in cube. - No component plays a model’s clips or picks its variant. Those are set
on
OrblitObjectdirectly, as Models shows. Amotioncomponent lists an entity’s.oclipclips and is read, kept, diffed and saved, but nothing plays it yet. See Animation. - Splat budgets don’t consult the device. A
splatscomponent with no budget draws every splat. PassOrblitDeviceProfile.splatBudgetyourself, as Splats does.
A document can also be written out as glTF, GLB or OBJ and read back — Exporting and importing scenes covers what each format carries and why the round trip comes back exact. What each surface wears is a material.
The Scene files example stages two documents written inline, one 3D and one of flat sprites, with no editor and no file on disk, and moves an entity with a slider. It has five tests, but no frame of it has been checked against a reference on any platform.
