Skip to content

Your first scene

Here is a complete Orblit application. It draws a cube on a ground plane, lights it, and spins it.

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:orblit_filament/orblit_filament.dart';
import 'package:vector_math/vector_math_64.dart' hide Colors;
void main() => runApp(const SpinApp());
class SpinApp extends StatefulWidget {
const SpinApp({super.key});
@override
State<SpinApp> createState() => _SpinAppState();
}
class _SpinAppState extends State<SpinApp>
with SingleTickerProviderStateMixin {
late final Ticker _clock = createTicker((elapsed) {
setState(() => _seconds = elapsed.inMicroseconds / 1e6);
})..start();
double _seconds = 0;
@override
void dispose() {
_clock.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: OrblitView(scene: _scene()),
),
);
}
OrblitScene _scene() {
return OrblitScene(
camera: OrblitCamera(
position: Vector3(4, 3, 6),
target: Vector3(0, 1, 0),
),
objects: [
OrblitObject(
key: 1,
transform: Matrix4.rotationY(_seconds)
..setTranslation(Vector3(0, 1, 0)),
colour: Vector3(0.85, 0.42, 0.16),
),
OrblitObject(
key: 2,
transform: Matrix4.identity()
..setTranslation(Vector3(0, -1, 0))
..scaleByDouble(12, 1, 12, 1),
colour: Vector3(0.18, 0.19, 0.21),
),
],
lights: [
OrblitLight(
key: 10,
kind: OrblitLightKind.directional,
direction: Vector3(-0.4, -1, -0.6)..normalize(),
intensity: 100000,
),
],
);
}
}

Four parts of that are worth a closer look.

_scene() builds a whole OrblitScene from scratch, sixty times a second. That is not wasteful, and it is not a shortcut taken for the sake of an example. It is how the engine is meant to be used.

There is no scene.add(cube), and no handle to a cube that you hold on to and mutate. Every frame you say what the scene contains in full, and the renderer works out the difference. The objects carry keys, so it knows that object 1 this frame is the same object 1 as last frame, and it sends only what changed.

So the scene cannot drift out of sync with your game state, because it is your game state, read out. Flutter makes the same bargain about widgets, and it works here for the same reasons.

Stating a scene goes into what this costs and why it is cheap.

key: 1 and key: 2 are integers you pick. They only have to be stable across frames and unique within the scene. Lights take their keys from the same numbers as objects, so the sun here is 10 and not a second 1. If an object’s key changes, the renderer treats it as a different object: it destroys the old one and creates a new one. That is sometimes what you want, and usually a bug.

OrblitObject.mesh takes a path to a .gltf, .glb, .fbx or .obj file, or the name of bytes you have handed over. Leave it null and you get the built-in cube, which is why the example above needs no assets at all. Scale that same cube flat and it makes a good ground plane.

The cube is 2 m across, from -1 to 1 on each axis, and a transform places its centre. That is why the spinning one sits at a height of 1, to rest on the ground, and the ground sits at -1, so that its top is at 0.

A file that cannot be read is drawn as the cube too. The reason comes back through OrblitView.onSceneNotes, so it does not end up in a log you will never look at:

return OrblitView(
scene: _scene(),
onSceneNotes: (notes) {
// {'/path/to/thing.glb': 'no such file'}
for (final note in notes.entries) {
debugPrint('${note.key}: ${note.value}');
}
},
);

intensity: 100000 looks alarming until you know that a sun is stated in lux, and that 100,000 lux is roughly what real daylight comes to. Point and spot lights are in lumens, where a bright domestic bulb is about 1,600.

The camera has real units too: aperture, shutterSpeed and sensitivity, which default to f/16, 1/125s and ISO 100. That is a sensible daylight exposure. Between them, those three decide how bright the image is, exactly as they do on a camera. If your scene comes out black, the light is too dim for the exposure, and you can fix that from either end.

That is the point of photometric units: the numbers transfer. The value you read off the box a light fitting came in is the value you type.

Lighting a scene covers this properly, including orblit_light, which converts from the watts and metres an artist thinks in.

The view is a widget, so input is ordinary Flutter input. None of this is engine-specific:

import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:orblit_filament/orblit_filament.dart';
import 'package:vector_math/vector_math_64.dart' hide Colors;
class Orbit extends StatefulWidget {
const Orbit({super.key});
@override
State<Orbit> createState() => _OrbitState();
}
class _OrbitState extends State<Orbit> {
double _distance = 8;
double _yaw = 0;
@override
Widget build(BuildContext context) {
return Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
setState(() => _distance += event.scrollDelta.dy * 0.01);
}
},
child: GestureDetector(
onPanUpdate: (details) {
setState(() => _yaw += details.delta.dx * 0.01);
},
child: OrblitView(
scene: OrblitScene(
camera: OrblitCamera(
position: Vector3(
_distance * math.sin(_yaw),
3,
_distance * math.cos(_yaw),
),
target: Vector3.zero(),
),
objects: [
OrblitObject(
key: 1,
transform: Matrix4.identity(),
colour: Vector3(0.85, 0.42, 0.16),
),
],
),
),
),
);
}
}

Being a widget also means it can be laid out. Put a panel beside it, overlap it, clip it to a rounded rectangle, animate its size: the 3D content takes part in all of it. It does not float above the application in a window of its own.