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’re 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": 4,
"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’s 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 and prefab.

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 4).”
  • 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 rather than 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 4 as it’s 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.

SceneMigrations.ordered is the list, if you want to see what will run.

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';
OrblitDocumentView open(String path, {String? projectRoot}) {
final load = SceneDocument.decode(File(path).readAsStringSync());
for (final problem in load.problems) {
print(problem);
}
return OrblitDocumentView(load.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) =>
File(path).writeAsStringSync(view.document.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 assembled. Here’s 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.
  • 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.
  • Splat budgets don’t consult the device. A splats component with no budget draws every splat. Pass OrblitDeviceProfile.splatBudget yourself, as Splats does.

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.