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, 0.5, 0),
),
objects: [
OrblitObject(
key: 1,
transform: Matrix4.rotationY(_seconds)
..setTranslation(Vector3(0, 0.5, 0)),
colour: Vector3(0.85, 0.42, 0.16),
),
OrblitObject(
key: 2,
transform: Matrix4.identity()
..setTranslation(Vector3(0, -0.5, 0))
..scaleByDouble(12, 1, 12, 1),
colour: Vector3(0.18, 0.19, 0.21),
),
],
lights: [
OrblitLight(
key: 1,
kind: OrblitLightKind.directional,
direction: Vector3(-0.4, -1, -0.6)..normalize(),
intensity: 100000,
),
],
);
}
}

Four things in that are worth stopping on.

_scene() builds a whole OrblitScene from scratch, sixty times a second. That isn’t wasteful, and it isn’t a simplification for the sake of an example. It’s 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. Because the objects carry keys, it knows that object 1 this frame is the same object 1 as last frame, and it only sends what actually changed.

Which means the scene can’t drift out of sync with your game state, because it is your game state, read out. This is the same bargain Flutter makes about widgets, and it holds for the same reasons.

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

key: 1 and key: 2 are arbitrary integers you choose. They only have to be stable across frames and unique within the scene. An object whose key changes is a different object as far as the renderer is concerned: the old one gets destroyed and a new one created. That’s occasionally 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’ve handed over. Leaving it null gets you the built-in cube, which is why the example above needs no assets at all. Scaled flat, the same cube is a perfectly good ground plane.

A file that can’t be read is drawn as the cube too, and the reason comes back through OrblitView.onSceneNotes rather than being logged somewhere you’ll never look:

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, defaulting to f/16, 1/125s and ISO 100, which 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 there are two ends you can fix that from.

This is the whole reason to use photometric units: the numbers transfer. A 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.

Because the view is a widget, input is ordinary Flutter input. Nothing about 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),
),
],
),
),
),
);
}
}

And because it’s a widget, 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, rather than floating above the application in a window of its own.