Your first scene
Here is a complete Orblit application. It draws a cube on a ground plane, lights it, and spins it.
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.
The scene is a value, not a graph
Section titled “The scene is a value, not a graph”_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.
Keys are yours to assign
Section titled “Keys are yours to assign”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.
No mesh means the built-in cube
Section titled “No mesh means the built-in cube”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}'); } },);Lights are in real units
Section titled “Lights are in real units”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.
Making it interactive
Section titled “Making it interactive”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.
