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_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": 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.
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 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.
Migrations
Section titled “Migrations”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.
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';
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
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.
- 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. - Splat budgets don’t consult the device. A
splatscomponent with no budget draws every splat. PassOrblitDeviceProfile.splatBudgetyourself, 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.
