MORE RE-ORGANIZING

This commit is contained in:
2026-09-08 14:00:27 -07:00
parent 0c1ea70738
commit ad41f0e140
248 changed files with 1091 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
# Structuring a flutter_scene app
Which API to build the scene with, and how to combine them. The short version is in SKILL.md;
this is the depth, with patterns that compile against 0.22.0.
flutter_scene exposes the same scene graph two ways. The declarative widgets describe it as Flutter
widgets that rebuild-diff into the graph; the imperative API hands you the retained `Scene`/`Node`/
`Component` graph directly. They are not competing renderers, they drive the same engine. The choice
is about how your app tracks state, and it is worth making deliberately because reworking a large
scene from one to the other is a rewrite.
---
## The decision
Go **declarative** when app state maps directly onto a fixed set of shown objects and nothing
simulates. A product configurator, a data-driven diagram, a few models whose transforms follow some
`setState` values. Flutter already owns the state, and the widgets keep the scene tracking it for
free.
Go **imperative** when the scene simulates. Complex physics, network replication, a character
walking around under input, procedural generation. Any one of these means imperative. Here the
scene's state is the app's state, it changes every frame, and you want to own the loop rather than
express each frame as a widget rebuild.
If you are unsure, ask whether anything in the scene changes on its own between user actions. If yes,
imperative. If the scene only changes when the user changes a value, declarative.
---
## Declarative
`SceneView.declarative` owns an internal `Scene`; its `children` are the whole scene description.
```dart
class Configurator extends StatefulWidget {
const Configurator({super.key});
@override
State<Configurator> createState() => _ConfiguratorState();
}
class _ConfiguratorState extends State<Configurator> {
// Build engine objects once, not per rebuild. Constructing GPU resources
// every build is the main performance hazard of the declarative layer.
final Geometry _geometry = CuboidGeometry(vm.Vector3(1, 1, 1));
final PhysicallyBasedMaterial _material = PhysicallyBasedMaterial();
double _spin = 0;
@override
Widget build(BuildContext context) {
return Column(children: [
Expanded(
child: SceneView.declarative(
camera: PerspectiveCamera(position: vm.Vector3(2, 2, -4)),
children: [
SceneMesh(
geometry: _geometry,
material: _material,
rotation: vm.Quaternion.axisAngle(vm.Vector3(0, 1, 0), _spin),
),
],
),
),
Slider(
value: _spin,
max: 6.28,
onChanged: (v) => setState(() => _spin = v),
),
]);
}
}
```
The scene tracks widget state through the normal rebuild path. Note two things the example shows:
engine objects (`geometry`, `material`) are created once and held as fields (they are diffed by
identity, and rebuilding them every frame is the classic mistake), while cheap value props
(`rotation`) are fine to pass fresh each build.
Declarative building blocks (all under `SceneView.declarative` or a `SceneView` with `children`):
- `SceneMesh(geometry:, material:, ...)` a node with a mesh.
- `SceneNode(...)` a bare transform node, for grouping children.
- `SceneModel('assets/x.glb', animations: [...])` a loaded model (runtime glTF path).
- `SceneSubtree(parent:, children:)` mounts children under a given imperative `Node`.
- Every node widget takes `position`/`rotation`/`scale` (or a full `transform`), `visible`,
`components:` (attach imperative `Component`s), `controller:` (a `SceneNodeController` handle), and
`children:`.
---
## Imperative
Own the `Scene`, add `Node`s, attach `Component`s, display with `SceneView(scene, camera:, onTick:)`.
For anything beyond a demo, do not scatter this across a `StatefulWidget`. Put it in a plain Dart
class that owns the scene and the game state, and keep the widget thin.
```dart
// Pure Dart, no Flutter import. Owns the scene and the game state.
class Game {
final Scene scene = Scene();
late final Node player;
Future<void> load() async {
await Scene.initializeStaticResources();
// The camera lives in the scene as a node, not on the widget. A camera
// node's transform is its view: the translation is the eye, local +Z is
// the look direction, +Y is up. lookAtFrom sets both at once, so there is
// no view-matrix math to hand-roll.
final cameraNode = Node()
..addComponent(CameraComponent(activateOnMount: true))
..lookAtFrom(vm.Vector3(0, 3, -8), vm.Vector3.zero());
scene.add(cameraNode);
// activateOnMount makes this the scene's primary camera when the node
// mounts, so SceneView needs no `camera:` argument. (The first mounted
// camera auto-promotes anyway; this states the intent explicitly, and is
// how you pick one when several cameras exist.)
player = Node(mesh: Mesh(CuboidGeometry(vm.Vector3(1, 1, 1)),
PhysicallyBasedMaterial()));
player.addComponent(PlayerController());
scene.add(player);
}
// Per-frame app logic that is not tied to one node. Component updates run
// on their own (see below), so this is for whole-game concerns.
void tick(double dt) {
// advance timers, spawn waves, read input, etc.
}
}
```
```dart
// Thin widget: builds the game, forwards ticks, renders the scene.
class GameView extends StatefulWidget {
const GameView({super.key});
@override
State<GameView> createState() => _GameViewState();
}
class _GameViewState extends State<GameView> {
final Game game = Game();
bool _ready = false;
@override
void initState() {
super.initState();
game.load().then((_) {
if (mounted) setState(() => _ready = true);
});
}
@override
Widget build(BuildContext context) {
if (!_ready) return const SizedBox.expand();
// No `camera:` here: the view resolves the scene's active camera, which is
// the CameraComponent added in Game.load. Resolution order is the explicit
// `camera:` (absent), then `cameraBuilder`, then `scene.camera` (the active
// CameraComponent), then a default camera.
return SceneView(
game.scene,
onTick: (elapsed, dt) => game.tick(dt),
);
}
}
```
### The active camera
The scene owns which camera is active, and there are three levers:
- **`CameraComponent(activateOnMount: true)`** (above) selects this camera when its node mounts.
- **`cameraComponent.makeActive()`** switches to it at runtime, for example a chase-cam to a
cutscene camera. Before its node mounts the choice is deferred and applied on mount.
- **`scene.camera = someCamera`** sets any `Camera` as the override directly, and `scene.camera`
reads the active one back.
With no camera set at all, the first mounted `CameraComponent` auto-promotes, and a scene with none
still renders through a default camera. Move or rotate a `CameraComponent`'s node to move the view;
the `NodeCamera` reads the node's world transform live each frame. Aim it with `node.lookAt(target)`
(rotate toward a world point) or `node.lookAtFrom(eye, target)` (position and aim in one call); +Z is
the forward axis, so the same helpers aim lights and imported models. A follow-cam is then a one-line
component that calls `node.lookAtFrom(...)` in `update` each frame.
### Camera controllers (interactive cameras)
For a user-controlled camera, do not hand-roll the drag/scroll/key math: attach a camera controller
component to the camera node. `OrbitCameraController` (turntable around a target, drag rotates, scroll
dollies), `FlyCameraController` (WASD + drag free flight, `moveVertical: false` gives grounded
first-person), and `FollowCameraController` (third-person that eases behind a target node). Each holds
the camera state, eases toward it with frame-rate-independent smoothing, clamps pitch so the view
never flips, and writes the node via `lookAtFrom`.
Wire input with the `CameraControls` widget wrapping the view; it forwards Flutter gestures and keys
to the controller. `SceneView` itself has no camera-input knobs, so nothing camera-specific leaks into
it.
```dart
final camera = Node()
..addComponent(CameraComponent(activateOnMount: true))
..addComponent(OrbitCameraController(target: vm.Vector3.zero(), distance: 8));
scene.add(camera);
// In build:
return CameraControls(
controller: camera.getComponent<OrbitCameraController>()!,
child: SceneView(scene),
);
```
The controllers also expose intent methods (`orbitBy`, `dollyBy`, `panBy`, `look`), so an app with its
own input handling can drive them without the widget.
### Behavior lives in components, not in the tick
The bulk of per-object logic should be custom `Component`s, not a giant `onTick`. A component is
attached to a node and the engine runs it through the lifecycle. Crucially, component ticks are
driven automatically by the render path, so you do not call them yourself, and `onTick` is only for
game-wide concerns that do not belong to a single node.
```dart
class PlayerController extends Component {
vm.Vector3 velocity = vm.Vector3.zero();
@override
void onMount() {
// node is available here; wire up input, cache references.
}
@override
void update(double deltaSeconds) {
// `node` is the node this component is attached to.
node.mutateLocalTransform(
(m) => m.translateByVector3(velocity * deltaSeconds),
);
}
}
```
Component lifecycle hooks (subclass `Component`, override what you need):
- `onAttach()` added to a node, before it is in a live scene.
- `Future<void> onLoad()` async setup (await assets); mount waits for it.
- `onMount()` the node entered a live scene; `node` is usable.
- `update(double deltaSeconds)` per rendered frame.
- `fixedUpdate(double fixedDt)` fixed-step, driven by the physics accumulator when a `PhysicsWorld`
is present. Put physics-coupled logic here, not in `update`.
- `onUnmount()` / `onDetach()` teardown.
This is the structure that scales. A character is a node with a controller component; an enemy is a
node with an AI component; a pickup is a node with a trigger component. The `Game` class holds what
is genuinely global (score, wave state, the input map), and everything spatial is a component on a
node.
---
## Hybrid interop
The two APIs share one graph, so you can mix them at the seam that suits the app.
**Declarative shell, imperative pockets.** A declarative node accepts `components:`, so an otherwise
declarative scene can attach imperative behavior to any node without leaving the widget tree.
```dart
SceneMesh(
geometry: _geometry,
material: _material,
components: [Spinner()], // a custom Component, ticked by the engine
)
```
**Imperative scene, declarative subtrees.** `SceneView(scene, children: [...])` mounts declarative
widgets over an app-owned scene. Use `SceneSubtree(parent: someNode, children: [...])` to attach a
declarative subtree under a specific imperative node, for example UI-like markers that follow a
game object.
```dart
SceneView(
game.scene,
camera: PerspectiveCamera(position: vm.Vector3(0, 3, -8)),
children: [
SceneSubtree(
parent: game.player,
children: [SceneModel('assets/hat.glb')],
),
],
)
```
**Reaching an imperative node from a declarative widget.** Pass a `SceneNodeController` as
`controller:` and read `controller.node` for the managed `Node` (null while unmounted). This is the
escape hatch when a declarative node needs an imperative handle for a one-off operation.
The rule of thumb: pick the mode that matches how the *majority* of the scene is driven, then use the
seam above for the exceptions. Do not build a whole simulation out of declarative widgets to avoid
the imperative API, and do not hand-roll a diffing layer over the imperative graph to avoid the
declarative one.