init
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,602 @@
|
||||
# flutter_scene silent-failure traps
|
||||
|
||||
Mistakes that produce wrong pixels with no exception and no console message. Each entry gives the
|
||||
mistake, what you see, and what to do instead. Sorted worst-first (most likely to hit, hardest to
|
||||
diagnose from the symptom).
|
||||
|
||||
Some of these are now caught by the engine in version 0.22.0. Those are tagged **[0.22.0 catches
|
||||
this]** with what the engine does, so if you see that error you know what it means. The rest are
|
||||
still silent, so you have to recognize them yourself.
|
||||
|
||||
---
|
||||
|
||||
## 1. Editing a transform in place instead of assigning it
|
||||
|
||||
**Mistake.** `node.localTransform.setTranslation(v)`, `node.localTransform..rotateY(t)`,
|
||||
`node.position.x = 5`, or any edit of the matrix/vector a getter returns. This is the natural
|
||||
`vector_math` style and the first thing most people reach for.
|
||||
|
||||
**Symptom.** The node does not move. Not "moves wrong", nothing happens, forever, including its
|
||||
children and bounds. Reading `node.localTransform`/`node.position` back shows the value you wrote, so
|
||||
the state looks correct while the render disagrees.
|
||||
|
||||
**Do instead.** Assign a fresh value (`node.position = ...`, `node.localTransform = node.localTransform.clone()..translateByVector3(v)`),
|
||||
use the component setters `node.position`/`node.rotation`/`node.scale`, or edit the raw matrix
|
||||
through `node.mutateLocalTransform((m) => m.translateByVector3(v))`, which dirties the cache for you.
|
||||
|
||||
**[0.22.0 catches this]** Debug builds throw a `StateError` naming the node and the fix, both for an
|
||||
in-place `localTransform` edit and for editing a copy returned by `position`/`rotation`/`scale`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Passing a normal or metallic-roughness map as `TextureContent.color`
|
||||
|
||||
**Mistake.** `material.normalTexture = await Texture2D.fromAsset('brick_normal.png')` without
|
||||
`content: TextureContent.normal`. The `content` parameter defaults to `color`.
|
||||
|
||||
**Symptom.** The base mip is fine, so it looks right up close and progressively wrong with distance:
|
||||
normals flatten and skew, roughness reads too smooth at range, specular shimmers. A
|
||||
distance-dependent symptom is nearly the worst case for screenshot-driven iteration.
|
||||
|
||||
**Do instead.** Build non-color maps with the right content: `Texture2D.fromAsset(path, content:
|
||||
TextureContent.normal)` for normal maps, `TextureContent.data` for metallic-roughness, AO, and other
|
||||
linear data. Still silent, so this is on you.
|
||||
|
||||
---
|
||||
|
||||
## 3. Non-uniform scale on a lit mesh
|
||||
|
||||
**Mistake.** Any non-uniform scale on the node or an ancestor, e.g. `node.scale = Vector3(1, 3, 1)`.
|
||||
|
||||
**Symptom.** Lighting, specular, and reflections are wrong across the whole mesh. It reads as a
|
||||
shading or material bug, so it sends you into the materials, never the transform.
|
||||
|
||||
**Do instead.** Use a uniform scale, or bake the non-uniform scale into the geometry with
|
||||
`MeshData.transformed(matrix)` and build a fresh `MeshGeometry` from it. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Moving a skinned mesh node
|
||||
|
||||
**Mistake.** `skinnedNode.localTransform = Matrix4.translation(v)` on a node that carries a `Skin`.
|
||||
|
||||
**Symptom.** The mesh does not move. Worse, it *does* move when the node you transformed happens to
|
||||
be an ancestor of the skeleton's joints, so it looks intermittent across models.
|
||||
|
||||
**Do instead.** glTF requires a skinned mesh node's own transform to be ignored, so the engine passes
|
||||
identity. Move the skeleton root (the common ancestor of `skin.joints`) instead, or parent both the
|
||||
mesh node and the skeleton under a shared node and move that. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Replacing the transform of a runtime-imported model root
|
||||
|
||||
**Mistake.**
|
||||
```dart
|
||||
final model = await Node.fromGlbAsset('assets/ship.glb');
|
||||
model.localTransform = Matrix4.translation(v); // wipes the handedness flip
|
||||
```
|
||||
|
||||
**Symptom.** The model renders mirrored through Z (asymmetric geometry reversed, text backwards) with
|
||||
normals and IBL wrong for the mirrored orientation. It still draws and is not obviously broken.
|
||||
|
||||
**Do instead.** The runtime glTF importer synthesizes a root carrying a `scale(1, 1, -1)` handedness
|
||||
flip. Do not overwrite it. Parent that node under a new `Node` and transform the parent, or
|
||||
pre-multiply your transform by the existing `localTransform`. (The offline `.fscene`/`loadScene` path
|
||||
bakes handedness into the vertices, so its roots are identity and do not have this trap.) Still
|
||||
silent.
|
||||
|
||||
---
|
||||
|
||||
## 6. `EnvironmentMap.fromGpuTextures` with a raw panorama
|
||||
|
||||
**Mistake.**
|
||||
```dart
|
||||
final tex = await gpuTextureFromAsset('assets/panorama.png');
|
||||
scene.environment = EnvironmentMap.fromGpuTextures(prefilteredRadiance: tex);
|
||||
```
|
||||
|
||||
**Symptom.** Every reflective surface (and the sky) shows the top 1/8 of the panorama stretched 8x
|
||||
vertically, cross-fading between slices as roughness varies. Diffuse is fully black.
|
||||
|
||||
**Do instead.** `fromGpuTextures` expects an already-prefiltered radiance atlas, not a plain image.
|
||||
Run the source through `prefilterEquirectRadiance()` first, or just use
|
||||
`EnvironmentMap.fromEquirectImageAsset(assetPath: ...)`/`fromUIImages`, which prefilter and project
|
||||
SH for you. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 7. `ShaderMaterial(cullingMode: none)` for a double-sided custom material
|
||||
|
||||
**Mistake.** `ShaderMaterial(cullingMode: gpu.CullMode.none)`, or setting `doubleSided = true` on a
|
||||
`ShaderMaterial`. The two do not agree.
|
||||
|
||||
**Symptom.** `cullingMode.none` draws back faces in color but leaves them out of the depth prepass, so
|
||||
SSAO, SSR, and contact shadows sample the wrong surface exactly where a back face shows: dark halos,
|
||||
wrong reflections, occlusion bleeding through a leaf card. `doubleSided = true` does the opposite: the
|
||||
color pass still culls while the prepass draws the extra faces.
|
||||
|
||||
**Do instead.** For a truly double-sided `ShaderMaterial`, understand that the color cull and the
|
||||
prepass cull are driven separately today; keep the mesh single-sided where the depth-based effects
|
||||
need to match, or split it. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 8. Caller-supplied `bounds` that do not cover the geometry
|
||||
|
||||
**Mistake.** `MeshGeometry.fromArrays(positions: p, bounds: someAabb)` where the AABB does not contain
|
||||
every position, or `setLocalBounds` with a guessed or stale box.
|
||||
|
||||
**Symptom.** The mesh pops out of existence at some camera angles and reappears at others; part of a
|
||||
large mesh vanishes; shadows disappear before the caster does. Intermittent and view-dependent, so a
|
||||
single screenshot can look fine.
|
||||
|
||||
**Do instead.** Widen the bounds to cover every vertex, or just omit the `bounds` argument and let the
|
||||
constructor scan the positions. An absent bounds is safe (it means always-visible); only a wrong one
|
||||
is dangerous. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 9. Binding a mipless texture to a material
|
||||
|
||||
**Mistake.** `material.baseColorTexture = GpuTextureSource(await gpuTextureFromAsset('brick.png'))`.
|
||||
The helper's own doc even recommends this.
|
||||
|
||||
**Symptom.** Severe minification aliasing: crawling and shimmering on any surface at an angle or
|
||||
distance, sparkling on a metallic-roughness map. `Texture2D.fromAsset` on the same file looks fine, so
|
||||
two seemingly equivalent APIs disagree visually.
|
||||
|
||||
**Do instead.** Use `Texture2D.fromAsset`/`fromImage`/`fromPixels`, which generate a mip chain, or
|
||||
supply a texture you mipped yourself. Reserve `gpuTextureFromAsset` for non-material uses. Still
|
||||
silent.
|
||||
|
||||
---
|
||||
|
||||
## 10. `RenderView.layerMask`/`Node.layers` mismatch, or a zero mask
|
||||
|
||||
**Mistake.** Putting a node on a non-default layer and forgetting the view side (or vice versa), or
|
||||
`layerMask: 0`, or `Node.layers = 2` meaning to select "layer 2" (which is actually `1 << 2 == 4`).
|
||||
|
||||
**Symptom.** A node, a group, or the whole scene is simply absent, with no hint a mask is involved. A
|
||||
`layerMask` of 0 renders nothing.
|
||||
|
||||
**Do instead.** `Node.layers` is a bitmask and is NOT inherited by children, so set it on each node
|
||||
you want the view to see. Use `kRenderLayerAll` to see everything, or a bitmask like `(1 << 2)`.
|
||||
Match the view's `layerMask` to the nodes' `layers`. Still silent (but see #23 for the
|
||||
draws-nothing diagnostic).
|
||||
|
||||
---
|
||||
|
||||
## 11. Mutating a `TextureTransform` in place
|
||||
|
||||
**Mistake.** `material.baseColorTextureTransform.offset.x = 0.5` instead of assigning a fresh
|
||||
`TextureTransform`. Same shape as trap #1, for materials.
|
||||
|
||||
**Symptom.** UV scroll/rotation animation freezes at the first value, but ONLY for materials on the
|
||||
physical-variant path (any material with clearcoat/sheen/transmission/etc). The identical code works
|
||||
on a plain PBR material, so it reads as a shader bug.
|
||||
|
||||
**Do instead.** Assign a new transform each frame: `material.baseColorTextureTransform =
|
||||
TextureTransform(offset: ...)`. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 12. Environment image that is not 2:1 equirectangular
|
||||
|
||||
**Mistake.** Passing a cube cross, a 1:1 angular light probe, or a cropped panorama to any environment
|
||||
entry point. HDRI downloads are not reliably 2:1.
|
||||
|
||||
**Symptom.** The scene is lit from wildly wrong directions, reflections show mirrored or duplicated
|
||||
content, the sky is smeared.
|
||||
|
||||
**Do instead.** Re-project the source to a 2:1 latitude-longitude panorama before loading. Cube
|
||||
crosses and angular probes are not supported. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 13. Hand-built triangles wound clockwise
|
||||
|
||||
**Mistake.** Generating triangles with clockwise winding instead of the standard Counter-Clockwise (CCW)
|
||||
right-handed convention when feeding `MeshGeometry.fromArrays` or `GeometryBuilder`.
|
||||
|
||||
**Symptom.** The mesh is invisible from outside and visible from inside; a closed shape looks hollow
|
||||
or inside-out; lighting is inverted where it shows. ("See-through faces.")
|
||||
|
||||
**Do instead.** flutter_scene's front faces wind COUNTER-CLOCKWISE (CCW) in model space, matching glTF
|
||||
and standard 3D conventions. Ensure triangle indices wind CCW around the outward face normal, or omit
|
||||
`normals` and let `GeometryBuilder` derive them from your winding. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 14. Out-of-range indices in `fromArrays`
|
||||
|
||||
**Mistake.** `MeshGeometry.fromArrays(positions: p /* 100 verts */, indices: [0, 1, 100])`, e.g. from
|
||||
an off-by-one or an index list built against a different vertex array.
|
||||
|
||||
**Symptom.** Stray triangles stretching to the origin or infinity, holes, flicker. On some backends
|
||||
the fetch is clamped and on others it reads adjacent memory, so the symptom differs per backend.
|
||||
|
||||
**Do instead.** Keep every index in `0 .. vertexCount - 1`. (`GeometryBuilder.addTriangle` range-checks
|
||||
for you and throws; the `fromArrays` index path does not.) Still silent on the `fromArrays` path.
|
||||
|
||||
---
|
||||
|
||||
## 15. A `vertexCount` that does not match the buffer in `setVertices`
|
||||
|
||||
**Mistake.** `geometry.setVertices(bufferView, vertexCount)` where `vertexCount` is a byte count, a
|
||||
float count, or a triangle count rather than a vertex count.
|
||||
|
||||
**Symptom.** Too small: part of the mesh is missing. Too large: the draw reads past the buffer, giving
|
||||
stray geometry or a dropped draw depending on backend. The buffer is fine, so the investigation goes
|
||||
to the packing code.
|
||||
|
||||
**Do instead.** `vertexCount` is a count of vertices. Prefer `uploadVertexData` (which validates the
|
||||
stride, see #17) or `fromArrays` over the caller-managed `setVertices` path unless you really own the
|
||||
GPU buffer. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 16. Oversized texture on a low-end device
|
||||
|
||||
**Mistake.** `EnvironmentMap.fromEquirectImageAsset(assetPath: 'pano_16k.hdr', maxWidth: 16384)` or
|
||||
`EnvironmentMap.radianceCubeSize = 4096` on a device whose max texture size is lower.
|
||||
|
||||
**Symptom.** A completely black environment: no IBL, no reflections, black sky. Works on the dev
|
||||
machine, black on a phone.
|
||||
|
||||
**Do instead.** Keep environment and texture sizes within the device limit; lower `maxWidth` or
|
||||
`radianceCubeSize`. Test on the lowest-end target you support. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 17. Hand-packing vertex bytes at the wrong stride
|
||||
|
||||
**Mistake.** `SkinnedGeometry()..uploadVertexData(bytes, vertexCount, indices)` with the wrong stride
|
||||
(a common one is 96 bytes having forgotten UV1, or the legacy 80-byte layout).
|
||||
|
||||
**Symptom.** Washed-out colors, see-through faces, geometry smeared toward the origin.
|
||||
|
||||
**Do instead.** Unskinned vertices are 72 bytes (position 3, normal 3, tex_coords 2, tex_coords_1 2,
|
||||
color 4, tangent 4, all float32), skinned are 104 (+ joints 4, weights 4). Better, do not hand-pack:
|
||||
use `MeshGeometry.fromArrays`, `fromMeshData`, or `GeometryBuilder`.
|
||||
|
||||
**[0.22.0 catches this]** `uploadVertexData` on both `SkinnedGeometry` and `UnskinnedGeometry` now
|
||||
throws an `ArgumentError` when the byte length does not match `vertexCount * stride`, naming the
|
||||
expected layout.
|
||||
|
||||
---
|
||||
|
||||
## 18. Custom attribute length not matching the vertex count
|
||||
|
||||
**Mistake.** `geometry.setCustomAttribute('a_wind', data, components: 3)` where `data` has the wrong
|
||||
length, or set before uploading vertices, or not re-set after a `rebuild` changed the count.
|
||||
|
||||
**Symptom.** The attribute is read at the wrong stride, so every vertex gets a neighbor's value: a
|
||||
displacement shader shears the mesh, a color attribute smears. Nearly right, so hard to spot.
|
||||
|
||||
**Do instead.** `data.length` must equal `vertexCount * components`. Set the attribute after uploading
|
||||
vertices, and re-set it after any rebuild. Also note custom attributes are not fetched by depth/shadow
|
||||
passes, so an attribute-driven displacement will not show in shadows.
|
||||
|
||||
**[0.22.0 catches this]** `setCustomAttribute` now throws an `ArgumentError` on a length mismatch
|
||||
(once the vertex count is known).
|
||||
|
||||
---
|
||||
|
||||
## 19. `UnlitMaterial` with `AlphaMode.mask`
|
||||
|
||||
**Mistake.** `UnlitMaterial(colorTexture: foliage)..alphaMode = AlphaMode.mask` for cutout foliage.
|
||||
|
||||
**Symptom.** No alpha test. Cutout edges render soft and blended, the material goes through the
|
||||
translucent pass, writes no depth, sorts badly against itself, and casts no cutout shadow.
|
||||
|
||||
**Do instead.** `UnlitMaterial` does not implement `mask` (it behaves as `blend`). Use
|
||||
`PhysicallyBasedMaterial` for cutouts, or a `.fmat` unlit material that discards below your cutoff.
|
||||
Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 20. `vertexColorWeight` on a material that took a physical variant
|
||||
|
||||
**Mistake.**
|
||||
```dart
|
||||
final m = PhysicallyBasedMaterial()..vertexColorWeight = 0.0;
|
||||
m.clearcoat = 1.0; // or sheen/transmission/anisotropy/ior != 1.5/any extension texture
|
||||
```
|
||||
|
||||
**Symptom.** Vertex colors snap back to full strength the moment an unrelated extension is enabled.
|
||||
On a vertex-colored import, an abrupt tint change with no plausible cause. (The same gap silently
|
||||
drops `specularAntiAliasingVariance` and `specularAntiAliasingThreshold` on the variant path.)
|
||||
|
||||
**Do instead.** Leave `vertexColorWeight` at 1.0 when using any advanced PBR feature, or drop the
|
||||
extension. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 21. Vertex-stage binding on a `ShaderMaterial` with no vertex shader
|
||||
|
||||
**Mistake.**
|
||||
```dart
|
||||
final m = ShaderMaterial(fragmentShader: frag);
|
||||
m.setUniformBlock('WaveInfo', bytes, stage: ShaderStage.vertex); // never set a vertex shader
|
||||
```
|
||||
|
||||
**Symptom.** The vertex-stage parameter has no effect; geometry stays undisplaced while the fragment
|
||||
stage looks right. Reads as "my vertex shader is not running."
|
||||
|
||||
**Do instead.** Pass a `vertexShader` (and `skinnedVertexShader`/`depthVertexShader` for those mesh
|
||||
kinds) to the constructor before binding vertex-stage blocks, or bind the block on
|
||||
`ShaderStage.fragment`. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 22. A `ShaderMaterial` vertex shader on line/trail/polyline geometry
|
||||
|
||||
**Mistake.** Attaching a `ShaderMaterial` that supplies a vertex shader to a `LineSegmentsGeometry`, a
|
||||
trail, or a polyline.
|
||||
|
||||
**Symptom.** Lines vanish or explode into garbage. The unskinned vertex shader is paired with the
|
||||
line-segments instanced layout and never does the ribbon expansion.
|
||||
|
||||
**Do instead.** These geometries do their vertex expansion in the engine's own shader; a material
|
||||
vertex shader cannot be used with them. Drop the vertex shader for line/trail/polyline geometry, or
|
||||
use a mesh geometry. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 23. Four different causes of a blank frame
|
||||
|
||||
**Mistake.** Any of: a degenerate camera (target equals position, or `up` parallel to the view
|
||||
direction, e.g. a top-down camera left at the default `up`), a field of view passed in degrees
|
||||
(`fovRadiansY: 60`), an inverted or zero frustum, `layerMask: 0`, a zero-area draw region, or
|
||||
rendering before `Scene.isReadyToRender`.
|
||||
|
||||
**Symptom.** The entire scene is empty. Every one of these looks identical, so it is easy to "fix"
|
||||
lighting, materials, and geometry for many iterations before suspecting the camera or the mask.
|
||||
|
||||
**Do instead.** For a top-down/bottom-up camera set `up` to `Vector3(0, 0, 1)` or `Vector3(0, 0,
|
||||
-1)`, not the default `(0, 1, 0)`. Pass FOV in radians (`60 * degrees2Radians`). Keep `near > 0` and
|
||||
`far > near`. Give the view a non-zero `layerMask` and a non-empty draw region.
|
||||
|
||||
**[0.22.0 catches most of this]** Degenerate cameras (zero view direction, parallel `up`, degrees-valued
|
||||
FOV, degenerate near/far) assert in debug. And a frame that issues zero draw calls now prints once in
|
||||
debug naming the likely cause (not ready, empty region, no views, no visible meshes, or a layer mask
|
||||
matching nothing).
|
||||
|
||||
---
|
||||
|
||||
## 24. Missing bounds after swapping a primitive's geometry
|
||||
|
||||
**Mistake.** `mesh.primitives[0].geometry = newGeometry` for hand LOD, a rebuilt procedural mesh, or a
|
||||
variant swap.
|
||||
|
||||
**Symptom.** The new geometry is culled against the old geometry's bounds; if it is larger or
|
||||
displaced, it pops in and out exactly like trap #8.
|
||||
|
||||
**Do instead.** Nothing extra is needed anymore.
|
||||
|
||||
**[0.22.0 catches this]** A `Mesh` now recomputes its bounds on its own when a primitive's geometry
|
||||
identity changes, so the manual `markLocalBoundsDirty()` is no longer required.
|
||||
|
||||
---
|
||||
|
||||
## 25. A `.fmat` material that overruns the 15-sampler budget
|
||||
|
||||
**Mistake.** A `lit` or `physical` `.fmat` declaring several `sampler2d` parameters plus
|
||||
`engine_inputs: [scene_color, scene_depth]`, on top of the lit framework's own textures.
|
||||
|
||||
**Symptom.** Geometry disappears on a mid-range Android device while everything is correct on Metal,
|
||||
with no build-time signal. The draw is rejected on GLES drivers reporting the 16-unit minimum.
|
||||
|
||||
**Do instead.** The lit fragment shader budgets 15 fragment samplers. Pack channels into one texture
|
||||
(an ORM-style atlas), drop an `engine_input`, or make the material unlit. Still silent (fails at
|
||||
runtime on the device, not at build).
|
||||
|
||||
---
|
||||
|
||||
## 26. `RenderView.viewport` with a `target` set
|
||||
|
||||
**Mistake.** `RenderView(camera: cam, target: myRenderTexture, viewport: Rect.fromLTWH(0, 0, 0.5, 1))`
|
||||
expecting a half-width render into the texture.
|
||||
|
||||
**Symptom.** The view fills the entire render texture; the passed `viewport` is ignored. Reads as
|
||||
"my viewport math is off."
|
||||
|
||||
**Do instead.** `viewport` is ignored when `target` is set. Size the `RenderTexture` to the region you
|
||||
want, or drop the target to render a sub-rect of the screen. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 27. Scaled or mirrored camera node
|
||||
|
||||
**Mistake.** Attaching a `CameraComponent` to a scaled node, or parenting a camera node under a scaled
|
||||
one.
|
||||
|
||||
**Symptom.** A uniform scale rescales the world in view; a negative scale mirrors the view, so every
|
||||
surface goes back-facing and the scene renders inside out. The camera's reported `forward`/`up` look
|
||||
correct, which makes it hard.
|
||||
|
||||
**Do instead.** A camera node must carry only rotation and translation, and no ancestor may be scaled.
|
||||
Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 28. Hand-built `Skin` with mismatched joints and inverse-bind matrices
|
||||
|
||||
**Mistake.** `skin.joints.add(n)` without a matching `skin.inverseBindMatrices.add(...)` (both are
|
||||
plain mutable lists).
|
||||
|
||||
**Symptom.** Extra inverse bind matrices are silently ignored and the mesh deforms wrongly. (Too few
|
||||
throws a `RangeError`, so only the extra-matrices direction is silent.)
|
||||
|
||||
**Do instead.** Keep the two lists parallel: one inverse bind matrix per joint (`Matrix4.identity()`
|
||||
if the joint's rest pose is the mesh's model space). Imported skins are validated; hand-built ones are
|
||||
not. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 29. Cloning a mesh node whose skeleton is a sibling
|
||||
|
||||
**Mistake.** `meshNode.clone()` when the skeleton lives outside the cloned subtree.
|
||||
|
||||
**Symptom.** The clone renders collapsed or in bind-pose garbage. There is a `debugPrint`, but it says
|
||||
only "Index path formation failed" and names neither the skin nor the consequence.
|
||||
|
||||
**Do instead.** Clone the common ancestor of the mesh node and its skeleton, not the mesh node alone.
|
||||
Still effectively silent.
|
||||
|
||||
---
|
||||
|
||||
## 30. `updateInstanceTransforms(recomputeWinding: false)` with a mirroring edit
|
||||
|
||||
**Mistake.** Editing an instance transform to a negative determinant while asking the engine to skip
|
||||
the parity refresh.
|
||||
|
||||
**Symptom.** Those instances render inside out (front faces culled, back faces lit).
|
||||
|
||||
**Do instead.** Drop `recomputeWinding: false`, or keep every instance edit orientation-preserving
|
||||
(no negative/mirrored scale). Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 31. Flipbook frame count vs atlas grid mismatch
|
||||
|
||||
**Mistake.** A `FlipbookModule(frameCount: 16)` without `emitter.flipbookColumns = 4;
|
||||
emitter.flipbookRows = 4`.
|
||||
|
||||
**Symptom.** Particles sample the wrong atlas cells, or only the first cell; the effect animates but
|
||||
shows the wrong art.
|
||||
|
||||
**Do instead.** Set `flipbookColumns * flipbookRows` equal to the module's `frameCount`. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 32. `LodComponent` blend bands overlapping
|
||||
|
||||
**Mistake.** A `blendRange` larger than the gap between adjacent LOD thresholds.
|
||||
|
||||
**Symptom.** An object sits permanently in the wrong cross-fade pair, dither-blending two levels that
|
||||
should not blend, or skipping a level.
|
||||
|
||||
**Do instead.** Keep `blendRange` smaller than the smallest gap between adjacent `screenSize`
|
||||
thresholds. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 33. `TextureAtlas` grid not matching its texture
|
||||
|
||||
**Mistake.** `TextureAtlas(columns: 16, rows: 16, tileSize: 32, padding: 2, baseColor: eightBySix)`
|
||||
where the grid does not match the image, or an out-of-range tile `index`.
|
||||
|
||||
**Symptom.** Every UV points at the wrong tile. With the default `repeat` addressing, an out-of-range
|
||||
index in release wraps to a different valid-looking tile rather than failing.
|
||||
|
||||
**Do instead.** Make the grid parameters produce exactly the texture's dimensions
|
||||
(`columns * (tileSize + 2*padding)` etc), keep tile indices in range, and set
|
||||
`TextureSampling.maxMipmapLevels` so tiles do not bleed across the padding gutter at high mips. Still
|
||||
silent.
|
||||
|
||||
---
|
||||
|
||||
## 34. `useEnvironment` sky with no cube-radiance variant
|
||||
|
||||
**Mistake.** `ShaderSkySource(fragmentShader: myShader, useEnvironment: true)` with
|
||||
`radianceCubeFragmentShader` left null.
|
||||
|
||||
**Symptom.** The sky contributes no image-based specular on any backend that builds the cube layout
|
||||
(the default nearly everywhere), so the scene loses its reflections.
|
||||
|
||||
**Do instead.** Supply `radianceCubeFragmentShader`, the entry built with
|
||||
`FLUTTER_SCENE_RADIANCE_CUBE`. Debug builds warn about this at bind; release builds are silent, so do
|
||||
not rely on the warning.
|
||||
|
||||
---
|
||||
|
||||
## 35. `radianceCubeFragmentShader` that is not the cube build
|
||||
|
||||
**Mistake.** `ShaderMaterial(fragmentShader: f, radianceCubeFragmentShader: f)` (the same shader
|
||||
twice), or naming the non-cube entry as the cube twin.
|
||||
|
||||
**Symptom.** The engine binds a cubemap into a shader whose sampler is a `sampler2D`: nothing on some
|
||||
backends, garbage specular on others.
|
||||
|
||||
**Do instead.** The cube variant must be the entry compiled with `FLUTTER_SCENE_RADIANCE_CUBE`, whose
|
||||
`prefiltered_radiance` sampler is a `samplerCube`. Pass the distinct `...Cube` entry from your bundle.
|
||||
(The engine can only catch the identical-shader case, and only in debug.) Still effectively silent.
|
||||
|
||||
---
|
||||
|
||||
## 36. Reading `int`/`bool`/`uint` shader members through `setUniformBlockFromFloats`
|
||||
|
||||
**Mistake.** `setUniformBlockFromFloats('FragInfo', [1.0, 0.5])` where the shader declares `int mode;
|
||||
float amount;`.
|
||||
|
||||
**Symptom.** The shader reads `mode` as the float bit pattern of `1.0` (a huge integer), so every
|
||||
`if (mode == 1)` branch misses and the material takes its fallback path. The block size is correct, so
|
||||
nothing complains.
|
||||
|
||||
**Do instead.** Pack integer members with `ByteData.setInt32` at the member's offset, or use a `.fmat`
|
||||
material whose `MaterialParameters` type-checks every assignment. Unenforceable at runtime.
|
||||
|
||||
---
|
||||
|
||||
## 37. A custom fragment shader that tone-maps or writes straight alpha
|
||||
|
||||
**Mistake.** Ending a `ShaderMaterial`/`ShaderSkySource`/`beforeTonemap` `PostEffect` fragment
|
||||
shader with `frag_color = vec4(color, alpha)` (straight alpha) or `pow(color, vec3(1.0/2.2))`
|
||||
(gamma-encoded).
|
||||
|
||||
**Symptom.** Straight alpha gives edge halos and over-bright overlaps. sRGB output is tone-mapped and
|
||||
EOTF-encoded a second time by the resolve pass, giving washed-out low-contrast color that looks like a
|
||||
bad exposure.
|
||||
|
||||
**Do instead.** Output linear HDR premultiplied by alpha. Exposure, tone mapping, and the display
|
||||
encode are applied later by the full-screen resolve pass. When sampling an sRGB texture, linearize
|
||||
first. `.fmat` materials get the premultiply for free. Unenforceable at runtime.
|
||||
|
||||
---
|
||||
|
||||
## 38. `MaterialParameters.copyStateFrom` across a changed layout
|
||||
|
||||
**Mistake.** Applying a re-realized material onto a live instance whose shader layout changed (an
|
||||
editor hot reload where the `.fmat` gained or lost a parameter).
|
||||
|
||||
**Symptom.** Every parameter reverts to its sidecar default while `assignedValues` still reports your
|
||||
overrides, so the inspector shows the right numbers and the render shows the wrong ones.
|
||||
|
||||
**Do instead.** `copyStateFrom` needs both sides to come from the same compiled shader entry; remap by
|
||||
name through `updateFromMetadata` across a layout change instead. Still silent.
|
||||
|
||||
---
|
||||
|
||||
## 39. Environment or widget textures with sub-255 alpha
|
||||
|
||||
**Mistake.** Passing an equirect image carrying alpha below 255 (an unfilled sky dome, a masked
|
||||
panorama) to `fromUIImages`/`fromEquirectImageAsset`. Or, for `WidgetTexture`/`WidgetComponent`,
|
||||
simply using a widget with anti-aliased or translucent edges.
|
||||
|
||||
**Symptom.** For environments, diffuse ambient comes out darker than the specular reflections of the
|
||||
same environment, so objects look lit by two environments. For widget textures, dark halos around
|
||||
anti-aliased text and rounded corners on the zero-copy path (correct on the web readback path, so it
|
||||
reads as a platform quirk).
|
||||
|
||||
**Do instead.** Use opaque (alpha 255) environment sources. The widget-alpha double-multiply is a
|
||||
backend difference you cannot fully control from the API; keep widget content opaque where you can.
|
||||
Still silent.
|
||||
|
||||
---
|
||||
|
||||
## Now caught by the engine, in one place
|
||||
|
||||
For quick reference, these traps became loud in 0.22.0. If you hit one you get an error, not silent
|
||||
wrong pixels:
|
||||
|
||||
- In-place edit of `localTransform`/`position`/`rotation`/`scale` -> throws in debug (#1).
|
||||
- Degenerate camera and a frame that draws nothing -> asserts/prints once in debug (#23).
|
||||
- `uploadVertexData` and `setCustomAttribute` length mismatches -> throw always (#17, #18).
|
||||
- A `Mesh` whose primitive geometry is swapped -> recomputes bounds itself (#24).
|
||||
- An `AnimationClip` binding zero of its channels -> asserts in debug naming the wanted nodes.
|
||||
- A web-backend bind to a shader uniform/texture name the shader does not declare -> throws (matches
|
||||
native), instead of silently sampling whatever was bound last.
|
||||
- Also fixed outright: `Node.clone()` sharing the original's matrix, the skinning joints texture being
|
||||
too narrow for small joint counts, and `ParticleSystem.reset()` not restarting its random stream.
|
||||
@@ -0,0 +1,474 @@
|
||||
# What exists in flutter_scene
|
||||
|
||||
Complete public API inventory (package version 0.22.0). flutter_scene has lights, shadows, PBR
|
||||
materials, instancing, LOD, skeletal animation, and a full post-processing stack. If you think a
|
||||
feature is missing, it is almost certainly here under the name below. Look before you hand-roll.
|
||||
|
||||
The public surface is the explicit `show` lists in `lib/scene.dart` (plus the separate barrels
|
||||
`gpu.dart`, `fscene.dart`, `build_hooks.dart`, `physics.dart`, `audio.dart`). Nothing under
|
||||
`lib/src` is public unless a barrel shows it.
|
||||
|
||||
Import:
|
||||
```dart
|
||||
import 'package:flutter_scene/scene.dart';
|
||||
import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Node and scene graph
|
||||
|
||||
`Node` (`base class Node implements SceneGraph`). Construct `Node({String name = '', Matrix4?
|
||||
localTransform, Mesh? mesh})`. A non-null `mesh` is wrapped in a `MeshComponent`.
|
||||
|
||||
Transform API (0.22.0 added the component properties; older docs say only `localTransform` exists):
|
||||
|
||||
| Member | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `position` | `Vector3` get/set | Getter returns a copy; editing the copy in place throws in debug. Assign to move. |
|
||||
| `rotation` | `Quaternion` get/set | Same copy rule. |
|
||||
| `scale` | `Vector3` get/set | Same copy rule. |
|
||||
| `localTransform` | `Matrix4` get/set | Getter returns the LIVE matrix; in-place edit throws in debug on next read. Assign a fresh matrix. |
|
||||
| `mutateLocalTransform(void Function(Matrix4) edit)` | method | Edits in place AND dirties the cache. Correct raw-matrix path. |
|
||||
| `globalTransform` | `Matrix4` get/set | Cached world transform; setter solves for the needed local. |
|
||||
| `lookAt(target, {up})` | method | Orients the node's forward axis (local +Z) at a world-space target; preserves world position and scale. |
|
||||
| `lookAtFrom(eye, target, {up})` | method | Positions at `eye` and aims +Z at `target` in one call (the imperative camera one-liner). |
|
||||
| `Node.lookAtTransform(eye, target, {up})` | static -> `Matrix4` | The `lookAt` basis as a local transform, for `Node(localTransform:)` and declarative `transform:`. |
|
||||
|
||||
+Z is the forward axis engine-wide (cameras, directional/spot lights, imported models), so the
|
||||
lookAt helpers aim any of them. Compose a plain matrix with `vm.Matrix4.translation(v)`,
|
||||
`vm.Matrix4.rotationY(a)`, `vm.Matrix4.compose(t, q, s)`. There is no `translate`/`rotateX` on Node.
|
||||
|
||||
Hierarchy (`SceneGraph` is a mixin): `add`, `addAll`, `addMesh`, `remove`, `removeAll`. `add` throws
|
||||
if the child already has a parent. `parent`, `children`, `detach()`, `getRoot()`, `getDepth()`.
|
||||
|
||||
Lookup: `getChildByName(name, {excludeAnimationPlayers})`, `getChildByNamePath`,
|
||||
`getChildByIndexPath`, static `getNamePath`/`getIndexPath`, `meshNodes`, `clone({recursive = true})`.
|
||||
|
||||
Per-node flags: `visible` (true), `frustumCulled` (true), `layers` (`kRenderLayerDefault`, a 32-bit
|
||||
mask, NOT inherited), `castsShadows` (true, not inherited), `shadowStatic` (false), `raycastable`
|
||||
(true), `highlightColor` (`Vector4?`), `skin` (`Skin?`, set by importers).
|
||||
|
||||
Bounds: `combinedLocalBounds`, `combinedWorldBounds`, `markBoundsDirty()`, `isVisibleTo(camera,
|
||||
size)`. A `null` bounds means always-visible.
|
||||
|
||||
Loading models (see Assets): `Node.fromGlbAsset`, `Node.fromGlbBytes`, `Node.fromGltfBytes`.
|
||||
|
||||
Geometry readback: `extractMeshData({Matrix4? transform})` flattens the subtree to one `MeshData`.
|
||||
Throws on instanced meshes, non-triangle primitives, caller-managed geometry, or an empty subtree.
|
||||
|
||||
`Scene` (`base class Scene implements SceneGraph`, cannot be subclassed). See the render/lighting/
|
||||
post sections. `Mesh(geometry, material)`/`Mesh.primitives({primitives})`; `MeshPrimitive(geometry,
|
||||
material)`; `Mesh.clone()` (shallow, shares geometry+material); `Mesh.localBounds`,
|
||||
`Mesh.markLocalBoundsDirty()`.
|
||||
|
||||
### Camera
|
||||
|
||||
- `PerspectiveCamera({double fovRadiansY = 45 * degrees2Radians, Vector3? position /*(0,0,-5)*/,
|
||||
Vector3? target /*(0,0,0)*/, Vector3? up /*(0,1,0)*/, double fovNear = 0.1, double fovFar =
|
||||
1000.0})`. Field names are `fovNear`/`fovFar`, NOT `near`/`far`.
|
||||
- `PerspectiveCamera.framing(Aabb3 bounds, {direction, fovRadiansY, up, margin = 1.1})`.
|
||||
- `PerspectiveProjection({fovRadiansY, near = 0.1, far = 1000.0})` and abstract `CameraProjection`,
|
||||
`Camera`. Camera helpers: `screenPointToRay`, `worldToScreen`, `getViewMatrix`, `getFrustum`.
|
||||
- There is NO `OrthographicCamera`. Implement `CameraProjection`/`Camera` for other projections.
|
||||
- Node-driven: `CameraComponent({CameraProjection? projection, activateOnMount = false})` ->
|
||||
`toCamera()` gives a `NodeCamera`. Camera node must not be scaled.
|
||||
- Interactive cameras: `CameraController` components attached to the camera node. `OrbitCameraController`
|
||||
(turntable around `target`; `orbitBy`/`dollyBy`/`panBy`/`frame`), `FlyCameraController` (WASD + drag
|
||||
free flight; `moveVertical: false` = grounded first-person; `look`), `FollowCameraController`
|
||||
(third-person easing behind `followTarget` node; `orbitBy`/`dollyBy`). All ease with frame-rate
|
||||
independent `smoothing` (settle seconds), clamp pitch short of vertical, and write the node via
|
||||
`lookAtFrom`. Wire input with the `CameraControls({required controller, enabled, autofocus, child})`
|
||||
widget (Focus + gestures + wheel); `SceneView` has no camera-input params by design.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
`abstract class Component`. Lifecycle hooks (exact names): `onAttach`, `onLoad` (async), `onMount`,
|
||||
`update(double deltaSeconds)` (NOT `onUpdate`), `fixedUpdate(double)`, `onUnmount`, `onDetach`,
|
||||
`cloneFor(Node)`. Node side: `addComponent`, `removeComponent`, `getComponent<T>()`,
|
||||
`getComponents<T>()`.
|
||||
|
||||
| Component | Constructor/notes |
|
||||
| --- | --- |
|
||||
| `MeshComponent` | `MeshComponent(mesh)`; `mesh` get/set, `refreshMaterials()` |
|
||||
| `InstancedMeshComponent` | `InstancedMeshComponent(instancedMesh)` |
|
||||
| `LodComponent` | `LodComponent(List<LodLevel>, {lodBias = 1.0, hysteresis = 0.1, blendRange = 0.0})`; extends MeshComponent |
|
||||
| `CameraComponent`, `NodeCamera` | see Camera |
|
||||
| `DirectionalLightComponent` | `(light)` aims down node local +Z; `.aimed(light, localDir)`; `.fromLightDirection(light)` |
|
||||
| `PointLightComponent` | `(light)`; `worldPosition` |
|
||||
| `SpotLightComponent` | `(light)`; `worldPosition`, `worldDirection` |
|
||||
| `RectAreaLightComponent` | `(light)`; `worldPosition`, `worldRight`, `worldUp` |
|
||||
| `EnvironmentVolumeComponent` | `({required settings, shape = box, extents, radius = 5.0, blendDistance = 1.0, priority = 0.0, weight = 1.0})` |
|
||||
| `ReflectionProbeComponent` | `({extents = Vector3.all(5), blendDistance = 1.0, priority = 10.0, weight = 1.0, faceResolution = 128, captureOnActivate = true})`; parallax-corrected local reflections in the box; `requestCapture()` re-captures |
|
||||
| `MaterialsVariantsComponent` | No public ctor. `MaterialsVariantsComponent.of(root)`/`.allOf(root)`, then `select(name)`, `variants`, `selected` |
|
||||
| `SemanticsComponent` | `({label, value, hint, button, onTap, ... boundsOverride, properties})` |
|
||||
| `WidgetComponent` | `({required Widget child, required Size size, pixelRatio = 1.0, worldHeight = 1.0, update = everyFrame, input = automatic, ...})`; `.bindOnly(...)` |
|
||||
| `SplatComponent` | `SplatComponent(GaussianSplats)`; `opacity`, `splatScale`, `tint`, `shDegree`, `cropBox`, `cropMode` |
|
||||
| `ParticleEmitterComponent` | `({required system, SpriteMaterial? material})`; `facing`, `flipbookColumns/Rows/Blend`, `paused` |
|
||||
| `MeshParticleEmitterComponent` | `({required system, required List<Geometry> geometries, required material, facing = tumble})` |
|
||||
| `TrailComponent` | `({width = 0.25, lifetime = 0.6, minVertexDistance = 0.05, maxPoints = 48, ...})`; `emitting`, `clear()` |
|
||||
|
||||
`SemanticsComponent`, `SplatComponent`, particle emitters, `TrailComponent`, `WidgetInput`,
|
||||
`MeshParticleFacing`, `LodLevel`, `EnvironmentVolumeShape` are all exported.
|
||||
|
||||
---
|
||||
|
||||
## Geometry
|
||||
|
||||
### Primitives (`primitives.dart`, all factory constructors, all `extends MeshGeometry`)
|
||||
|
||||
| Class | Constructor | Facing/notes |
|
||||
| --- | --- | --- |
|
||||
| `CuboidGeometry` | `CuboidGeometry(Vector3 extents, {debugColors = false})` | positional extents; box `-extents/2..+extents/2`; debugColors off |
|
||||
| `WedgeGeometry` | `WedgeGeometry(Vector3 size)` | triangular prism; base on `y=0`, not Y-centered |
|
||||
| `PlaneGeometry` | `({width = 1.0, depth = 1.0, segmentsX = 1, segmentsZ = 1})` | XZ plane, faces +Y; no collisionShape |
|
||||
| `SphereGeometry` | `({radius = 0.5, segments = 32, rings = 16})` | UV sphere |
|
||||
| `CylinderGeometry` | `({bottomRadius = 0.5, topRadius = 0.5, height = 1.0, radialSegments = 32, heightSegments = 1, bottomCap = true, topCap = true})` | topRadius 0 = cone |
|
||||
| `CapsuleGeometry` | `({radius = 0.5, height = 1.0, radialSegments = 32, capRings = 8})` | `height` is the mid-section; total Y = height + 2*radius |
|
||||
| `TorusGeometry` | `({radius = 0.5, tubeRadius = 0.2, radialSegments = 32, tubularSegments = 16})` | XZ plane |
|
||||
| `DiscGeometry` | `({radius = 0.5, segments = 32})` | XZ, faces +Y |
|
||||
| `RingGeometry` | `({innerRadius = 0.25, outerRadius = 0.5, segments = 32})` | annulus, XZ, +Y |
|
||||
| `IcosphereGeometry` | `({radius = 0.5, subdivisions = 2})` | subdivided icosahedron |
|
||||
|
||||
Every primitive except `PlaneGeometry` has a `Shape get collisionShape`.
|
||||
|
||||
### Swept/procedural (sweep a `ScenePath`; also `BezierPath`, `CatmullRomPath`, `PolylinePath`)
|
||||
|
||||
- `RibbonGeometry(path, {width = 1.0, stations = 64, alignment = RibbonAlignment.ground, up, storage = fixed})`; `updatePath(path)`. `RibbonAlignment` = `ground` | `path`.
|
||||
- `TubeGeometry(path, {radius = 0.5, radialSegments = 12, stations = 64, caps = true, storage})`.
|
||||
- `ExtrudeGeometry(path, {required List<Vector2> profile, stations = 64, caps = true, storage})`.
|
||||
- `PolylineGeometry(List<Vector3> points, {width = 8.0, widthMode = screenPixels, cap = butt, dash, perVertexWidth, perVertexColor})`. INERT until `updateForCamera(camera, viewportSize)` is called every frame. `PolylineWidthMode` = `screenPixels` | `worldUnits`; `PolylineCap` = `butt` | `round`; `DashPattern({dashLength, gapLength, cap})`.
|
||||
- `LineSegmentsGeometry(LineSegmentData segments, {width = 0.01, normalOffset = 0.0})`. `extends Geometry`, GPU-expanded, no per-frame CPU work. For large independent-segment sets.
|
||||
- `BillboardGeometry({capacity = 256})`. `floatsPerInstance = 14`; `BillboardFacing` = `spherical` | `axisLocked` | `velocityStretched`.
|
||||
|
||||
### MeshGeometry, GeometryBuilder, MeshData
|
||||
|
||||
`MeshGeometry.fromArrays({required Float32List positions, Float32List? normals, texCoords,
|
||||
texCoords1, colors, tangents, List<int>? indices, primitiveType = triangle, Aabb3? bounds, storage =
|
||||
fixed, GeometryBufferArena? bufferArena, retainCpuData = true})`. Components per vertex: positions 3,
|
||||
normals 3, texCoords/texCoords1 2, colors/tangents 4. Omitted normals on a triangle list are
|
||||
generated. Omitted indices need a vertex count divisible by 3. `bounds` skips the position scan (it
|
||||
must actually cover every vertex).
|
||||
|
||||
`MeshGeometry.fromMeshData(MeshData data, {storage, bufferArena, retainCpuData})`.
|
||||
|
||||
In-place update (require `GeometryStorage.updatable`, all take `{dirtyStart, dirtyCount}`):
|
||||
`updatePositions`, `updateNormals`, `updateTexCoords`, `updateTexCoords1`, `updateColors`,
|
||||
`updateTangents`. `rebuild({positions, normals, ...})` may change the vertex/index count.
|
||||
`applyMeshData(data)`. `GeometryStorage` = `fixed` | `updatable`.
|
||||
|
||||
`GeometryBuilder({deduplicate = true})`: `normal(v)`, `texCoord(v)`, `texCoord1(v)`, `color(v)`,
|
||||
`tangent(v)`, `addVertex(Vector3) -> int`, `addTriangle(a, b, c)` (throws RangeError on bad index),
|
||||
`packVertices()`, `build({storage, bufferArena, retainCpuData})`. Attribute setters are STICKY.
|
||||
Calling `normal()` once disables generated normals for the whole mesh.
|
||||
|
||||
`MeshData` (isolate-transferable, pure): `MeshData({required positions, required vertexCount,
|
||||
normals, ..., customAttributes})`, `MeshData.build({required positions, ...})` (derives vertexCount,
|
||||
generates normals). Derivations: `triangleCount`, `triangles`, `transformed(Matrix4)` (inverse
|
||||
transpose for normals; a mirror flips winding), `toTriMeshShape()` (hollow static collider),
|
||||
`toConvexHullShape()` (dynamic body), `unweld({attributes})`, `extractEdges({creaseAngleDegrees})`,
|
||||
static `merge(parts)`. `MeshAttributeData(data, {components})`; `UnweldAttribute` = `centroid` |
|
||||
`seed` | `triangleIndex` | `barycentric`; `LineSegmentData({positions, normals})`.
|
||||
|
||||
`Geometry` base: `primitiveType`, `localBounds`, `localBoundingSphere`, `setLocalBounds(aabb,
|
||||
sphere)`, `setVertices(BufferView, vertexCount)`, `setIndices(BufferView, indexType)`,
|
||||
`setCustomAttribute(name, Float32List, {required components})` (1..4; not fetched by depth passes so
|
||||
it does not affect shadows), `uploadVertexData(ByteData, vertexCount, ByteData? indices, {indexType =
|
||||
int16})`, `isReadable`, `extractMeshData()`, `setVertexShader`/`setVertexShaderName`,
|
||||
`setVertexLayout(descriptor, {bindsModelTransform = true})`, `draw(pass, {instanceCount = 1})`.
|
||||
`SkinnedGeometry`/`UnskinnedGeometry` subclasses. `GeometryBufferArena({blockSizeInBytes = 16MB})`.
|
||||
|
||||
Vertex layout: unskinned 72 bytes/18 floats = position(3) normal(3) texture_coords(2)
|
||||
texture_coords_1(2) color(4) tangent(4). Skinned 104 bytes/26 floats = + joints(4) weights(4). Do
|
||||
not hand-pack; use `fromArrays`/`fromMeshData`/`GeometryBuilder`.
|
||||
|
||||
### Instancing and LOD
|
||||
|
||||
`InstancedMesh({required geometry, required material, cullInstances = false,
|
||||
sortTransparentInstances = true})`: `instanceCount`, `addInstance(Matrix4, {Vector4? color}) -> int`
|
||||
(clones the matrix), `setInstanceTransform(i, m)`, `updateInstanceTransforms(update,
|
||||
{recomputeWinding = true})`, `setInstanceColor(i, color)`, `removeInstanceAt(i)`, `clearInstances()`.
|
||||
Attach via `InstancedMeshComponent`.
|
||||
|
||||
`LodLevel({required geometry, required material, required double screenSize})` (screenSize = projected
|
||||
bounding-sphere diameter as a fraction of viewport height, descending, last is the cull floor).
|
||||
Attach via `LodComponent`. Shadow/depth passes always draw level 0.
|
||||
|
||||
---
|
||||
|
||||
## Materials and textures
|
||||
|
||||
`Material` (abstract): `name`, `doubleSided` (false), `depthBias` (0.0), `setFragmentShader`,
|
||||
`setFragmentShaderName(name, {cubeName})`, `setRadianceCubeFragmentShader`, `isOpaque()`.
|
||||
|
||||
### UnlitMaterial
|
||||
|
||||
`UnlitMaterial({TextureSource? colorTexture})`. `baseColorTexture` (field name differs from the ctor
|
||||
arg), `baseColorTextureTransform`, `baseColorTextureTexCoord` (0), `alphaMode` (`opaque`; `mask` not
|
||||
implemented, behaves as blend), `baseColorFactor` (white), `vertexColorWeight` (1.0). Fog applies.
|
||||
|
||||
### PhysicallyBasedMaterial
|
||||
|
||||
`PhysicallyBasedMaterial({baseColorTexture, metallicRoughnessTexture, normalTexture, emissiveTexture,
|
||||
occlusionTexture, EnvironmentMap? environment})`. Every texture slot is a `TextureSource?` with a
|
||||
`<slot>TextureTransform` and `<slot>TextureTexCoord`.
|
||||
|
||||
Core: `baseColorFactor` (white), `vertexColorWeight` (1.0), `metallicFactor` (1.0), `roughnessFactor`
|
||||
(1.0), `normalScale` (1.0), `emissiveFactor` (`Vector4.zero()`), `emissiveStrength` (1.0),
|
||||
`occlusionStrength` (1.0), `environment` (null, falls back to `Scene.environment`), `alphaMode`
|
||||
(opaque), `alphaCutoff` (0.5), `specularAntiAliasingVariance` (0.15), `specularAntiAliasingThreshold`
|
||||
(0.2).
|
||||
|
||||
Advanced KHR_materials_* (setting any flips onto an internal physical-variant shader; each has a
|
||||
`<name>Texture`): `specular` (1.0), `specularColor`, `ior` (1.5), `clearcoat` (0.0),
|
||||
`clearcoatRoughness` (0.0), `clearcoatNormalScale`, `sheenColor` (zero), `sheenRoughness` (0.0),
|
||||
`transmission` (0.0), `diffuseTransmission`, `diffuseTransmissionColor`, `thickness` (0.0),
|
||||
`attenuationDistance` (inf), `attenuationColor`, `dispersion` (0.0), `iridescence` (0.0),
|
||||
`iridescenceIor` (1.3), `iridescenceThicknessMinimum` (100.0), `iridescenceThicknessMaximum` (400.0),
|
||||
`anisotropy` (0.0), `anisotropyRotation` (0.0).
|
||||
|
||||
`isOpaque()` is false when `transmission > 0`, `alphaMode == blend`, or `baseColorFactor.a < 1.0`.
|
||||
|
||||
`AlphaMode` = `opaque` | `mask` | `blend`. `TextureTransform({offset, scale, rotation})` (glTF
|
||||
KHR_texture_transform order).
|
||||
|
||||
### SpriteMaterial
|
||||
|
||||
`SpriteMaterial({TextureSource? colorTexture})`: `colorTexture`, `tint` (white), `blendMode`
|
||||
(`SpriteBlendMode.alpha` | `additive`), `softDepthFade` (0.0), `cameraNearFade` (0.0), `sampler`.
|
||||
Always non-opaque, always cull none.
|
||||
|
||||
### ShaderMaterial (raw GLSL escape hatch)
|
||||
|
||||
`ShaderMaterial({gpu.Shader? fragmentShader, radianceCubeFragmentShader, vertexShader,
|
||||
skinnedVertexShader, depthVertexShader, useEnvironment = false, cullingMode = backFace, windingOrder =
|
||||
counterClockwise, isOpaqueOverride = true})`. `setVertexShader(shader, {variant = unskinned})`,
|
||||
`vertexShaderFor(variant)`, `setUniformBlock(name, ByteData?, {stage = fragment})`,
|
||||
`setUniformBlockFromFloats(name, List<double>, {stage})`, `getUniformBlock`, `uniformBlockNames`,
|
||||
`setTexture(name, texture, {sampler, stage})` (accepts `gpu.Texture`/`Texture2D`/`RenderTexture`),
|
||||
`getTexture`, `textureNames`. `ShaderStage` = `vertex` | `fragment`; `MeshVariant` = `unskinned` |
|
||||
`skinned` | `depth`.
|
||||
|
||||
Fragment shaders MUST output linear HDR premultiplied by alpha (exposure, tone mapping, and the
|
||||
display encode are applied later by the resolve pass). Same contract for `ShaderSkySource` and
|
||||
`PostInsertion.beforeTonemap` effects. std140 packing is by hand.
|
||||
|
||||
### .fmat (declarative, recommended over ShaderMaterial)
|
||||
|
||||
`loadFmatMaterial(sourcePath) -> PreprocessedMaterial`, `loadFmatSky(...) -> PreprocessedSky`.
|
||||
`PreprocessedMaterial`: `parameters` (`MaterialParameters`), `shadingModel`, `environment`.
|
||||
`MaterialParameters` (typed, reflection-backed, throws on wrong type/name): `setFloat`, `setInt`,
|
||||
`setVec2/3/4`, `setMat4`, `setColor(name, Color)`, `setTexture(name, gpu.Texture, {sampler})`,
|
||||
`operator []=`, `parameterNames`, `samplerNames`, `hasUniformBlock`.
|
||||
|
||||
### Textures
|
||||
|
||||
`TextureSource` (interface): implementers are `Texture2D`, `RenderTexture`, `GpuTextureSource`. Every
|
||||
built-in material slot takes a `TextureSource`, not a raw `gpu.Texture`.
|
||||
|
||||
`Texture2D` (factories, generates a mip chain): `Texture2D.fromPixels(Uint8List, w, h, {content =
|
||||
color, sampling})`, `fromImage(ui.Image, {...})`, `fromAsset(String, {content = color, sampling,
|
||||
bundle})`. `TextureContent` = `color` (sRGB) | `data` (linear, e.g. metallic-roughness/AO) | `normal`
|
||||
(vector-averaged). `TextureSampling({mipmaps = true, maxMipmapLevels, minFilter = linear, magFilter =
|
||||
linear, mipFilter = linear, maxAnisotropy = 8, addressMode = repeat})`.
|
||||
|
||||
`GpuTextureSource(gpu.Texture, {sampler})` adapts a raw texture. Barrel helpers:
|
||||
`gpuTextureFromImage`, `gpuTextureFromAsset` (mipless, aliases on materials), `imageFromAsset`,
|
||||
`imageFromBytes`. Cooked `.fstex`: `loadTexture(sourcePath, {package, bundle, sampling}) ->
|
||||
TextureSource`, `releaseTexture`, `clearTextureCache`.
|
||||
|
||||
Custom-shader GPU barrel (`package:flutter_scene/gpu.dart`): `Shader`, `ShaderLibrary`,
|
||||
`loadShaderLibraryAsync` (use this, not `ShaderLibrary.fromAsset` which throws on web),
|
||||
`resolveShaderBundleKey`, `Texture`, `SamplerOptions`, `MinMagFilter`, `MipFilter`,
|
||||
`SamplerAddressMode`, `IndexType`, `VertexFormat`, `VertexStepMode`.
|
||||
|
||||
---
|
||||
|
||||
## Lighting and environment
|
||||
|
||||
Lights (all in `light.dart`, all fields mutable):
|
||||
|
||||
- `DirectionalLight({direction /*(-0.3,-1,-0.2)*/, color, intensity = 3.0, priority = 0, castsShadow
|
||||
= false, cacheStaticShadows = true, shadowFadeRange = 2.0, shadowSoftness = 0.08, shadowCascadeCount
|
||||
= 4, shadowMaxDistance = 150.0, shadowCascadeSplitLambda = 0.6, shadowMapResolution = 1024,
|
||||
shadowDepthBias = 0.02, shadowNormalBias = 0.02, shadowAmbientStrength = 0.0, shadowFilter =
|
||||
rotatedPoisson, shadowCasterFaces = front, contactShadows = false, contactShadowDistance = 0.3,
|
||||
angularRadius = 0.005})`.
|
||||
- `PointLight({color, intensity = 1.0, range = 0.0, falloffExponent = 2.0})`. No shadows.
|
||||
- `SpotLight({color, intensity = 1.0, range = 0.0, falloffExponent = 2.0, direction /*(0,-1,0)*/,
|
||||
innerConeAngle = 0.0, outerConeAngle = pi/4, castsShadow = false, ...})`.
|
||||
- `RectAreaLight({color, intensity = 1.0, width = 1.0, height = 1.0, range = 0.0})`. Local XY plane,
|
||||
emits along +Z, no shadows.
|
||||
- `SunLight(SunSky source, {castsShadow = true, ...})` drives `Scene.directionalLight` from a sky.
|
||||
|
||||
`ShadowCasterFaces` = `front` | `back` | `both`. `DirectionalShadowFilter` = `rotatedPoisson` |
|
||||
`fixedPcf` | `pcss`. `ShadowCascade`, `Lighting` (per-draw state) are exported.
|
||||
|
||||
Scene lighting: `Scene.directionalLight` (`DirectionalLight?`, null = IBL only; honors `direction`;
|
||||
highest-priority one gets cascaded shadows), `Scene.sunLight`, `Scene.environment` (`EnvironmentMap?`,
|
||||
null falls back to `EnvironmentMap.studio()`; for genuinely no IBL use `EnvironmentMap.empty()`),
|
||||
`Scene.environmentIntensity` (1.0), `Scene.environmentTransform` (`Matrix3.identity()`),
|
||||
`Scene.skybox` (`Skybox?`, null = transparent), `Scene.skyEnvironment`.
|
||||
|
||||
### EnvironmentMap
|
||||
|
||||
Carries a prefiltered specular radiance atlas AND SH-9 diffuse coefficients (one texture path, no
|
||||
separate radiance/irradiance). Factories: `.empty()`, `.constantDiffuse(ambientRadiance)`,
|
||||
`.fromGpuTextures({required prefilteredRadiance, diffuseSphericalHarmonics, diffuseShTexture})` (the
|
||||
texture must already be prefiltered), `.fromUIImages({required radianceImage, ...})`,
|
||||
`.fromEquirectHdr({required Float32List linearPixels, w, h, ...})`,
|
||||
`.fromEquirectImageAsset({required assetPath, maxWidth = 4096, ...})` (auto-detects .hdr/.exr/LDR),
|
||||
`.fromEquirectImageBytes(...)`, `.fromSky(SkySource, {...})`, `.studio()` (zero-config default).
|
||||
Deprecated: `.fromAssets` (use `.fromEquirectImageAsset`). Env images must be equirect 2:1.
|
||||
`prefilterEquirectRadiance` is exported. `Scene.loadEnvironment(assetPath, {showSkybox = true,
|
||||
skyBlur = 0.0, intensity, exposure, rotationY, maxWidth = 4096, bundle})` is one-call setup.
|
||||
|
||||
### Skybox/sky sources
|
||||
|
||||
`Skybox(SkySource source, {intensity = 1.0})`. `SkySource` implementers: `EnvironmentSkySource({blurriness
|
||||
= 0.0})`, `ShaderSkySource({fragmentShader, fragmentShaderName, radianceCubeFragmentShader,
|
||||
useEnvironment = false})`, `GradientSkySource({zenithColor, horizonColor, groundColor, sunDirection,
|
||||
sunColor, sunSharpness = 400.0})`, `PhysicalSkySource({sunDirection, sunAngularRadius = 0.0175,
|
||||
rayleighCoefficient = 2.0, mieCoefficient = 0.005, turbidity = 10.0, energy = 1.0, ...})`.
|
||||
`SkyEnvironment(ShaderSkySource, {refresh = manual, interval, faceResolution = 128, equirectWidth =
|
||||
512})`; `SkyEnvironmentRefresh` = `manual` | `interval` | `everyFrame`.
|
||||
|
||||
### Exposure and tone mapping
|
||||
|
||||
`Scene.exposure` (1.0; not 2.0), `Scene.toneMapping` (`ToneMappingMode.pbrNeutral`; also `aces`,
|
||||
`reinhard`, `linear`, `agx`), `Scene.agxWhite` (16.29), `Scene.agxContrast` (1.25). Static
|
||||
`Scene.physicalCameraExposure({required aperture, shutterSpeed, iso})` returns a multiplier to assign
|
||||
to `exposure`.
|
||||
|
||||
---
|
||||
|
||||
## Post-processing
|
||||
|
||||
Every effect is a settings object on `Scene`, off by default, turned on with `enabled`. Environment
|
||||
looks blend via `EnvironmentSettings` (snapshot/lerp of the whole look) and `EnvironmentVolume` /
|
||||
`EnvironmentVolumeComponent` (spatial).
|
||||
|
||||
| Scene field | Type | Key fields (default) | Requires |
|
||||
| --- | --- | --- | --- |
|
||||
| `ambientOcclusion` | `AmbientOcclusionSettings` | `method` (obscurance/`groundTruth`), `radius` (0.33), `intensity` (1.0), `power` (1.5), `bentNormals` (false), `halfResolution` (true), `indirectLight` (0.0 = SSGI), `specularMode` | perspective camera |
|
||||
| `screenSpaceReflections` | `ScreenSpaceReflectionsSettings` | `intensity` (1.0), `maxDistance` (24.4), `thickness` (0.46), `stride` (9.0), `maxSteps` (90), `blur` (0.3), `debugView` | perspective camera |
|
||||
| `fog` | `Fog` | `mode` (`FogMode.exponential`; also none/linear/exponentialSquared), `color`, `density` (0.02), `start`/`end`, needs both `enabled` AND non-none `mode` | any camera |
|
||||
| `godRays` | `GodRaysSettings` | `intensity` (1.0), `density` (0.5), `anisotropy` (0.7), `stepCount` (24), `maxDistance` (200), `color` | shadow-casting DirectionalLight + perspective camera |
|
||||
| `depthOfField` | `DepthOfField` | `focusDistance` (10.0), `fStop` (2.8), `focalLength`, `sensorHeight` (0.024), `bladeCount`, `quality` (low/medium/high) | perspective camera |
|
||||
| `autoExposure` | `AutoExposureSettings` | `strength` (0.55), `compensation`, `minEv` (-4), `maxEv` (4), `speedUp` (3.0), `speedDown` (1.0); multiplies on top of `exposure` | none |
|
||||
| `postProcess` | `PostProcessSettings` | see below | none |
|
||||
|
||||
`AmbientOcclusionMethod` = `obscurance` (McGuire SAO) | `groundTruth` (GTAO). `SpecularAmbientOcclusionMode`
|
||||
= `none` | `simple` | `bentCone`. `SsrDebugView` = composite/reflectedUv/hitMask/normal/confidence/depth.
|
||||
|
||||
`PostProcessSettings` (all sub-settings off by default; mutate the nested objects):
|
||||
- `colorGrading` (`ColorGradingSettings`): `brightness` (1.0), `contrast` (1.0), `saturation` (1.0),
|
||||
`temperature`, `tint`, `lift`/`gamma`/`gain`, `lut` (`ColorLut?`, applies after tone mapping,
|
||||
independent of `enabled`), `lutBlend` (1.0).
|
||||
- `chromaticAberration` (`intensity` 0.2), `vignette` (`intensity` 0.5, `radius` 0.75, `smoothness`
|
||||
0.5), `filmGrain` (`intensity` 0.3), `bloom` (`threshold` 1.0, `intensity` 0.15, `scatter` 0.7,
|
||||
and `lensFlare`: `enabled` false, `intensity` 1.0, `ghostCount` 4, `ghostSpacing` 0.3, `haloRadius`
|
||||
0.35, `haloIntensity` 1.0, `chromaticAberration` 0.005; rides the bloom, needs bloom enabled).
|
||||
- `customEffects` (`List<PostEffect>`).
|
||||
|
||||
`ColorLut.fromCubeString`/`.fromCubeAsset` (Adobe `.cube`, edge 2..64).
|
||||
|
||||
Custom post: `PostEffect({gpu.Shader? fragmentShader, insertion = beforeTonemap, enabled = true,
|
||||
useFrameInfo = false})`, added to `scene.postProcess.customEffects`. Engine binds `uniform sampler2D
|
||||
input_color` at `in vec2 v_uv`. `PostInsertion` = `beforeTonemap` (linear HDR premultiplied) |
|
||||
`afterTonemap` (display-referred).
|
||||
|
||||
---
|
||||
|
||||
## Scene, render, and widgets
|
||||
|
||||
`Scene()` (no args; calls `initializeStaticResources()`, needs a live Flutter GPU context). Methods:
|
||||
`add`, `addAll`, `addMesh`, `remove`, `removeAll`, `update(dt)` (optional), `render(camera, canvas,
|
||||
{viewport, pixelRatio})`, `renderViews(views, canvas, {region, pixelRatio})`, `warmUp(views,
|
||||
{includeOffscreen})`, `raycast(ray, {maxDistance, layerMask, where, includeInvisible})`,
|
||||
`raycastAll(...)`, `addRenderPass`/`removeRenderPass`, `captureRenderGraph({viewIndex, request,
|
||||
timeout})`, `captureEnvironment({required position, faceResolution = 128, equirectWidth = 512,
|
||||
layerMask})` -> `EnvironmentMap` (one-shot static capture; use `ReflectionProbeComponent` for a
|
||||
node-anchored, parallax-corrected, auto-blended probe). Statics: `Scene.initializeStaticResources()`,
|
||||
`Scene.isReadyToRender`, `Scene.physicalCameraExposure`, `Scene.isAntiAliasingModeSupported`,
|
||||
`Scene.effectiveAntiAliasingMode`.
|
||||
|
||||
`Scene.antiAliasingMode` (`AntiAliasingMode.auto` -> msaa or fxaa; also `none`, `msaa`, `fxaa`, `smaa`),
|
||||
`Scene.renderScale` (1.0), `Scene.filterQuality` (`FilterQuality.medium`), `Scene.views`
|
||||
(`List<RenderView>` for RenderTexture targets).
|
||||
|
||||
`RenderView({required Camera camera, RenderTexture? target, Rect? viewport /*normalized 0..1,
|
||||
ignored when target set*/, int layerMask = kRenderLayerAll, order = 0, AntiAliasingMode?
|
||||
antiAliasingMode, double? renderScale, FilterQuality? filterQuality, List<Plane> cullingPlanes})`.
|
||||
`kRenderLayerDefault = 1`, `kRenderLayerAll = 0xFFFFFFFF`.
|
||||
|
||||
`RenderTexture`, `RenderTextureSampling`, `RenderTextureUpdate`, `RenderTextureView(renderTexture,
|
||||
{fit = contain, filterQuality = medium, followLayout = false})`.
|
||||
|
||||
Widgets:
|
||||
- `SceneView(Scene scene, {Camera? camera, SceneCameraBuilder? cameraBuilder, SceneViewsBuilder?
|
||||
viewsBuilder, autoTick = true, pixelRatio, onTick, loading, loadingBuilder, revealMinDuration,
|
||||
warmUp = false, children})`. App-owned scene; does not write scene properties. `camera`,
|
||||
`cameraBuilder`, `viewsBuilder` are mutually exclusive.
|
||||
- `SceneView.declarative({environment, environmentIntensity = 1.0, exposure = 1.0, toneMapping =
|
||||
pbrNeutral, camera, cameraBuilder, viewsBuilder, children, ...})`. View-owned scene.
|
||||
- `SceneViewsBuilder` is exported as of 0.22.0 (older docs list it as a trap; it is public now).
|
||||
- Declarative widgets: `SceneNode`, `SceneMesh`, `SceneModel`, `SceneSubtree`, `SceneNodeHost`,
|
||||
`SceneNodeController`, `SceneModelSource`, `AssetModelSource`, `MemoryModelSource`,
|
||||
`SceneAnimationSpec`. `WidgetTexture`, `WidgetTextureController`, `WidgetUpdatePolicy`. `SceneScope`.
|
||||
- Camera resolution precedence: `camera` -> `cameraBuilder(elapsed)` -> `scene.camera` (or first
|
||||
mounted `CameraComponent`) -> default `PerspectiveCamera()`.
|
||||
|
||||
`CustomRenderPass`, `RenderInput`, `RenderPassContext`, `RenderStage`, `TransientWriter`,
|
||||
`NodeFilter`, `HighlightStyle`, render-graph capture types (`CapturedPass`, `CapturedResource`,
|
||||
`RenderGraphCaptureRequest`, `RenderGraphCaptureResult`) are all exported.
|
||||
|
||||
---
|
||||
|
||||
## Assets and animation
|
||||
|
||||
Setup: `flutter pub add flutter_scene` then `dart run flutter_scene:init`. Enable Flutter GPU with
|
||||
`flutter run --enable-flutter-gpu` (native only; nothing for web). Requires Flutter 3.47 stable+, NOT
|
||||
master. Impeller is default; do not pass `--enable-impeller`. Never pass
|
||||
`--enable-experiment=native-assets` (breaks the build on Dart 3.10+).
|
||||
|
||||
Two model-loading paths, do not conflate:
|
||||
|
||||
- Pipeline (preferred, needs the `buildScenes` hook): `loadScene(sourcePath, {package, bundle,
|
||||
registry, onReload, applyStageTo}) -> Future<Node>`. `sourcePath` is the SOURCE path relative to
|
||||
the package root (e.g. `'assets/level.glb'`), NOT a generated name. Companions:
|
||||
`loadSceneSubtree`, `releaseScene`, `clearSceneTemplateCache`.
|
||||
- Runtime glTF (no hook, parses every load): `Node.fromGlbAsset(assetPath)`,
|
||||
`Node.fromGlbBytes(bytes)`, `Node.fromGltfBytes(gltfJson, {required resolveUri})`. Each synthesizes
|
||||
a root node.
|
||||
|
||||
Sibling loaders: `loadTexture` (`.fstex`), `loadFmatMaterial`/`loadFmatSky` (`.fmat`).
|
||||
|
||||
Build hooks (`package:flutter_scene/build_hooks.dart`): `buildScenes({buildInput, buildOutput,
|
||||
inputFilePaths, discoveryRoot = 'assets/', assetMode = generatedTree, compressTextures = false})`,
|
||||
`buildMaterials({...})`, `buildTextures({..., required textures, contents})`, `buildEngineAssets`,
|
||||
`buildTargetShaderBundleJson`. Outputs land in `flutter_scene_generated/` (never commit
|
||||
`.fsceneb`/`.shaderbundle`/`.fmat.json`/`.fstex`). Removed 0.21.0: `legacyOnly`,
|
||||
`dataAssetsIfAvailable`, `outputDirectory`.
|
||||
|
||||
Animation (`Animation`, `AnimationClip`, `AnimationPlayer` exported):
|
||||
|
||||
- Off a loaded model: `node.parsedAnimations`, `node.findAnimationByName(name)`,
|
||||
`node.createAnimationClip(animation)`, `node.removeAnimationClip(clip)`. Clips start paused at t=0;
|
||||
call `play()`.
|
||||
- `AnimationClip`: `playbackTime` (assignment is seek), `playbackTimeScale` (1; negative reverses),
|
||||
`weight` (0..1), `playing`, `loop`. `play()`, `pause()`, `stop()`, `replay()`, `gotoAndPlay(t)`,
|
||||
`seek(t)`, `advance(dt)`, `rebind(newTarget, {animation})`. Channels bind by node NAME; channels
|
||||
whose node is absent from the subtree are dropped (0.22.0 asserts in debug when ALL channels drop).
|
||||
- `AnimationPlayer`: `createAnimationClip(animation, bindTarget)` (a second call with the same
|
||||
`Animation.name` replaces), `getClipByName`, `rebind`, `update(dt)` (auto-driven per frame).
|
||||
- `Animation({name, channels})`, `AnimationChannel`, `BindKey({required nodeName, property =
|
||||
translation})`, `AnimationProperty` = `translation` | `rotation` | `scale`.
|
||||
- Declarative: `SceneModel(assetPath, animations: [SceneAnimationSpec(name, {playing = true, loop =
|
||||
true, weight = 1.0, speed = 1.0})])`. Note `SceneModel` loads via the runtime glTF path.
|
||||
|
||||
The engine-agnostic scene-document core is a separate package `scene` (0.2.0), re-exported through
|
||||
`package:flutter_scene/fscene.dart`. `flutter_scene_importer` and `flutter_gpu_shim` no longer exist
|
||||
(folded in). Physics and audio are separate barrels (`physics.dart`, `audio.dart`).
|
||||
Reference in New Issue
Block a user