Skip to content

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_scene is 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_stage turns a document into an OrblitScene and keeps it up to date as the document changes.
{
"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.

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.

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.

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.

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 OrblitObject with the file’s colour and shadow settings. A material component’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 OrblitSplats with its budget as the limit and 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.

  • 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 texture or atlas. The stage doesn’t apply region or animation.
  • 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 OrblitObject directly, as Models shows. A motion component lists an entity’s .oclip clips and is read, kept, diffed and saved, but nothing plays it yet. See Animation.
  • Splat budgets don’t consult the device. A splats component with no budget draws every splat. Pass OrblitDeviceProfile.splatBudget yourself, 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.