Fix project structure
This commit is contained in:
124
flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md
Normal file
124
flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: flutter_scene-idioms
|
||||
version: 4
|
||||
description: Write correct flutter_scene code. Use this whenever building 3D with the flutter_scene Dart/Flutter engine (rendering a scene, geometry, materials, lighting, loading a .glb model, animation, custom shaders). It corrects the wrong assumptions models carry from three.js, Godot, and Unity, and names the APIs and traps that are specific to this engine.
|
||||
---
|
||||
|
||||
# Building with flutter_scene
|
||||
|
||||
flutter_scene is a realtime 3D engine for Flutter, built on Flutter GPU. It has a retained scene graph (`Scene` holds `Node`s, nodes carry a `Mesh`), physically based materials, image-based lighting, and a deep post-processing stack.
|
||||
|
||||
**The one thing to internalize: this is not three.js, Godot, or Unity, and the API diverges from all three in specific ways.** Most first-attempt failures come from reaching for another engine's spelling. The corrections below are the highest-value part of this skill; read them before writing code.
|
||||
|
||||
## Do not reach for these (they do not exist or will break the build)
|
||||
|
||||
- **Not the master channel.** flutter_scene runs on **Flutter 3.47 stable or newer**. Do not run `flutter channel master`; it resolves worse, not better.
|
||||
- **Not `--enable-impeller`, not `--enable-experiment=native-assets`.** The run flag is **`--enable-flutter-gpu`** and nothing else. `--enable-experiment=native-assets` actively breaks the build on Dart 3.10+.
|
||||
- **Not `package:vector_math/vector_math_64.dart`.** flutter_scene uses **`package:vector_math/vector_math.dart`**. The `_64` types are a different, incompatible `Vector3`.
|
||||
- **Not `Node.fromAsset(...)`, not `loadModel(...)`.** Load a preprocessed model with **`loadScene('assets/x.glb')`** (returns `Future<Node>`), or a runtime glTF with `Node.fromGlbAsset` / `Node.fromGlbBytes`.
|
||||
- **Not a hand-rolled `CustomPainter` + `Ticker`.** Display a scene with the **`SceneView`** widget; it drives the per-frame loop for you.
|
||||
- **Not `node.position.set(x, y, z)`.** See transforms below.
|
||||
- **Not `.model` files or `buildModels`.** The offline format is `.fsceneb`, produced by the `flutter_scene:init` build hook; you load it by source path with `loadScene`.
|
||||
- **Not the removed `Environment` class.** Environment lighting is `EnvironmentMap` on `Scene.environment`.
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
flutter pub add flutter_scene
|
||||
dart run flutter_scene:init # installs the build hook, sets up assets
|
||||
flutter run --enable-flutter-gpu # native; add -d chrome for web
|
||||
```
|
||||
|
||||
`flutter_scene:init` is required setup, not optional. Rendering is gated on `Scene.initializeStaticResources()`; until it completes the engine prints "Flutter Scene is not ready to render. Skipping frame."
|
||||
|
||||
## Minimal scene (this compiles as-is)
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_scene/scene.dart';
|
||||
import 'package:vector_math/vector_math.dart' as vm;
|
||||
|
||||
void main() => runApp(const MaterialApp(home: CubeView()));
|
||||
|
||||
class CubeView extends StatefulWidget {
|
||||
const CubeView({super.key});
|
||||
@override
|
||||
State<CubeView> createState() => _CubeViewState();
|
||||
}
|
||||
|
||||
class _CubeViewState extends State<CubeView> {
|
||||
final Scene scene = Scene();
|
||||
bool ready = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Geometry and materials touch the shader bundle, so build them only
|
||||
// after the engine's static resources are up.
|
||||
Scene.initializeStaticResources().then((_) {
|
||||
scene.add(Node(
|
||||
mesh: Mesh(CuboidGeometry(vm.Vector3(1, 1, 1)), PhysicallyBasedMaterial()),
|
||||
));
|
||||
if (mounted) setState(() => ready = true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!ready) return const SizedBox.expand();
|
||||
return SceneView(scene, camera: PerspectiveCamera(position: vm.Vector3(2, 2, -4)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An unset `Scene.environment` still gives image-based lighting (a default studio map is resolved at render), so a bare `PhysicallyBasedMaterial()` is lit without any light setup.
|
||||
|
||||
## Choosing declarative or imperative
|
||||
|
||||
flutter_scene has two ways to build a scene, and picking the wrong one is a structural decision that is expensive to undo later. Choose up front by what the app does, not by which reads nicer.
|
||||
|
||||
**Declarative (inline widgets).** Describe the scene as Flutter widgets under `SceneView.declarative(children: [...])`, using `SceneMesh`, `SceneNode`, and `SceneModel`. Flutter's own rebuild diffing keeps the rendered scene in sync with your widget state, the same way it keeps the UI in sync. Reach for this when app state maps cleanly onto a fixed set of objects on screen and nothing is simulation-like, for example a product configurator where a few `SceneMesh`es track some `setState` values.
|
||||
|
||||
**Imperative (retained scene graph).** Own a `Scene`, add `Node`s, attach `Component`s, and display it with `SceneView(scene, camera: ..., onTick: ...)`. Track state the way you would in another game engine. The cleanest shape is a plain Dart `Game` class that owns the `Scene` and holds the game state, with the scene build and per-frame tick routed into it, and behavior living in custom `Component`s attached to nodes that the engine runs through the component lifecycle hooks. Reach for this for anything with real simulation.
|
||||
|
||||
**Go imperative when** the scene has complex physics, network replication, a character walking around, or procedural generation. Any one of these means imperative.
|
||||
**Stay declarative when** state maps directly to a fixed set of shown objects and nothing ticks or simulates.
|
||||
|
||||
The two interoperate. A mostly-declarative scene can drop to an imperative node where it needs one, and an imperative scene can mount declarative subtrees. See `references/architecture.md` for the `Game`-class pattern, component-driven nodes, and the hybrid seam.
|
||||
|
||||
## The API shape (where it diverges from what you expect)
|
||||
|
||||
**Transforms.** `Node` has `position`, `rotation` (a `Quaternion`), and `scale`, but they are whole-value get/set, not the mutable spelling other engines use. Assign the whole vector (`node.position = vm.Vector3(0, 1, 0)` or `node.position += ...`). The getters return copies, so `node.position.x = 5` does nothing and throws in debug. For a raw matrix edit use `node.localTransform = matrix` or `node.mutateLocalTransform((m) => m.translateByVector3(...))`; a bare in-place edit of `node.localTransform` never moves the node, because the cache is not told.
|
||||
|
||||
**Geometry.** Ten built-in primitives (`CuboidGeometry`, `SphereGeometry`, `IcosphereGeometry`, `CylinderGeometry` with separate top/bottom radii so cones are free, `CapsuleGeometry`, `TorusGeometry`, `PlaneGeometry`, `DiscGeometry`, `RingGeometry`, `WedgeGeometry`), plus swept geometry (`ExtrudeGeometry`, `TubeGeometry`, `RibbonGeometry`), lines (`PolylineGeometry`), and `GeometryBuilder`/`MeshData` for custom meshes. Do not hand-pack a `ByteData` vertex buffer before checking these.
|
||||
|
||||
**Materials.** `PhysicallyBasedMaterial` (base color, metallic, roughness, normal, emissive, plus clearcoat/sheen/transmission/etc.), `UnlitMaterial`, `ShaderMaterial` for custom shaders. Texture slots take a `TextureSource` (from `loadTexture(path)`), not a raw `gpu.Texture`.
|
||||
|
||||
**Camera.** `PerspectiveCamera(position: ..., target: ...)`. There is no orthographic camera built in.
|
||||
|
||||
## What you are probably underestimating (it is all here)
|
||||
|
||||
Models trained on older or thinner information assume flutter_scene has no lighting, no shadows, and no post-processing. It has all of it. Before hand-rolling any of these, know they exist: **directional/point/spot/area lights, shadows (PCSS, contact shadows), GTAO ambient occlusion, screen-space reflections, parallax-corrected reflection probes, SSGI, depth of field, god rays, fog, auto exposure, LUT color grading, bloom, lens flares, MSAA/SMAA/FXAA, tone mapping, instancing, LOD.** See `references/what-exists.md` for the full surface with the class names.
|
||||
|
||||
## Traps that fail silently (wrong pixels, no error)
|
||||
|
||||
- **Custom `ShaderMaterial` output is linear HDR premultiplied by alpha.** No tone mapping or gamma in your shader; the `ResolvePass` applies exposure, tone mapping, and the display transform. Linearize sRGB texture samples yourself. See `MATERIALS.md`.
|
||||
- **Never hand-roll a per-triangle winding flip to fix glTF orientation.** The importers handle the coordinate conversion; a manual flip leaves normals and IBL wrong.
|
||||
- **Do not emit a vertex buffer at the wrong stride.** Unskinned is 72 bytes/vertex, skinned is 104; the attribute order is fixed. Use `GeometryBuilder`, do not guess the layout.
|
||||
|
||||
## More depth
|
||||
|
||||
- `references/architecture.md` for the declarative-vs-imperative choice in depth, the `Game`-class pattern, component-driven nodes, and hybrid interop.
|
||||
- `references/what-exists.md` for the full API surface (the false-absence fix).
|
||||
- `references/traps.md` for the complete silent-failure list.
|
||||
- The repo-root `MATERIALS.md` for the custom-shader contract.
|
||||
|
||||
## Keeping this skill current
|
||||
|
||||
This skill ships inside the flutter_scene package, so upgrading flutter_scene can carry a newer revision of it than the copy installed in the project. To check, run:
|
||||
|
||||
```sh
|
||||
dart run flutter_scene:skills --check
|
||||
```
|
||||
|
||||
It reports the installed and bundled skill versions and exits non-zero when an update is available. If the installed flutter_scene ships a newer skill than what is installed, tell the user, since they are working against out-of-date guidance, and offer to update it with `dart run flutter_scene:skills` (which touches only the skill, not their build hook or pubspec). Worth a check when you start substantial flutter_scene work or when the user mentions upgrading the package.
|
||||
@@ -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`).
|
||||
146
flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md
Normal file
146
flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: flutter_scene-kit
|
||||
version: 3
|
||||
description: Build interactive 3D gameplay, character controllers, camera rigs, dynamic day/night cycles, water surfaces, audio, pooling, and debug overlays in flutter_scene. Use when creating game mechanics, camera controls, NPC behaviors, atmospheric environments, or diagnostic HUDs.
|
||||
---
|
||||
|
||||
# Gameplay, camera, and atmosphere kit in flutter_scene
|
||||
|
||||
Flutter Scene provides high-level gameplay components and ergonomic building blocks in `package:flutter_scene/kit.dart` so games and interactive experiences do not need to re-implement standard mechanics from scratch.
|
||||
|
||||
When choosing components, consider existing engine alternatives:
|
||||
- For physics-driven character navigation with collider capsules, wall sliding, and autostep, use `KinematicCharacterController` from `package:flutter_scene/physics.dart`.
|
||||
- For interactive mouse/touch orbit cameras with inertia, use `OrbitCameraController` or `FollowCameraController`.
|
||||
- For framing a standalone `PerspectiveCamera`, use `PerspectiveCamera.framing`. Use `BoundsFraming` when computing a transform for a `NodeCamera` mounted in the scene graph.
|
||||
|
||||
## Imports
|
||||
|
||||
```dart
|
||||
import 'package:flutter_scene/scene.dart';
|
||||
import 'package:flutter_scene/kit.dart';
|
||||
import 'package:vector_math/vector_math.dart' as vm;
|
||||
```
|
||||
|
||||
## Camera rigs and smoothing
|
||||
|
||||
### SpringArmComponent
|
||||
|
||||
`SpringArmComponent` attaches to a target character node and mounts a camera node at the arm's socket. It casts rays against the scene hierarchy to prevent geometry clipping, smoothly pulling the camera inward when colliding with walls.
|
||||
|
||||
Note on offsets: `targetOffset` is applied in world space from the character node's origin, and `socketOffset` acts in the camera socket's local plane along X (right) and Y (up).
|
||||
|
||||
```dart
|
||||
final characterNode = Node();
|
||||
final cameraNode = Node();
|
||||
final cameraArm = SpringArmComponent(
|
||||
targetLength: 5.0,
|
||||
targetOffset: vm.Vector3(0, 1.6, 0), // Eye height
|
||||
socketOffset: vm.Vector3(0.5, 0, 0), // Over-the-shoulder
|
||||
enablePositionLag: true,
|
||||
positionLagSpeed: 8.0,
|
||||
cameraNode: cameraNode,
|
||||
);
|
||||
|
||||
characterNode.addComponent(cameraArm);
|
||||
scene.root.add(characterNode);
|
||||
scene.root.add(cameraNode);
|
||||
```
|
||||
|
||||
### CameraShake
|
||||
|
||||
`CameraShake` implements a trauma-decay model driven by deterministic simplex noise for organic multi-axis camera shake (explosions, footsteps, hits).
|
||||
|
||||
```dart
|
||||
final shake = CameraShake(decayRate: 1.2, frequency: 25.0);
|
||||
|
||||
// Add trauma on hit
|
||||
shake.addTrauma(0.6);
|
||||
|
||||
// Inside game loop
|
||||
final offset = shake.update(deltaSeconds);
|
||||
cameraNode.localTransform = baseTransform * offset.toMatrix4();
|
||||
```
|
||||
|
||||
## Character movement and steering
|
||||
|
||||
### ThirdPersonControllerComponent
|
||||
|
||||
`ThirdPersonControllerComponent` handles kinematic movement, sprint multipliers, turn smoothing, ground snapping with raycasts, slope sliding, coyote time, and buffered jumps. Input expects `+Y` as forward in 3D.
|
||||
|
||||
```dart
|
||||
final playerNode = Node();
|
||||
final controller = ThirdPersonControllerComponent(
|
||||
walkSpeed: 4.5,
|
||||
runMultiplier: 1.8,
|
||||
jumpVelocity: 7.0,
|
||||
groundPlaneHeight: 0.0, // Optional fallback floor
|
||||
);
|
||||
playerNode.addComponent(controller);
|
||||
|
||||
// When using VirtualJoystick (where up is -Y in screen space), invert Y:
|
||||
// controller.setMoveInput(vm.Vector2(joystickDir.x, -joystickDir.y), isRunning: isSprinting);
|
||||
if (jumpPressed) controller.jump();
|
||||
```
|
||||
|
||||
### Autonomous Steering Behaviors
|
||||
|
||||
`Steering` provides math helpers for NPC navigation, flocking, and crowd dynamics.
|
||||
|
||||
```dart
|
||||
// Seek target
|
||||
final seekForce = Steering.seek(npcPos, npcVel, targetPos, maxSpeed: 4.0);
|
||||
|
||||
// Arrive smoothly
|
||||
final arriveForce = Steering.arrive(npcPos, npcVel, targetPos, slowingRadius: 3.0);
|
||||
|
||||
// Flocking separation
|
||||
final sepForce = Steering.separation(npcPos, npcVel, neighborPositions, desiredDistance: 1.5);
|
||||
```
|
||||
|
||||
## Dynamic environments and atmosphere
|
||||
|
||||
### DayNightCycleComponent
|
||||
|
||||
`DayNightCycleComponent` moves the sun along a realistic solar arc given latitude and time of day, evaluating sun colors, intensities, and ambient lighting transitions.
|
||||
|
||||
```dart
|
||||
final sunLight = DirectionalLight();
|
||||
final sunNode = Node()..addComponent(DirectionalLightComponent(sunLight));
|
||||
scene.root.add(sunNode);
|
||||
|
||||
final skyCycle = DayNightCycleComponent(
|
||||
timeOfDay: 14.5, // 2:30 PM
|
||||
timeSpeed: 0.1, // Progress 0.1 hours per second
|
||||
latitude: 34.0,
|
||||
sunLightNode: sunNode,
|
||||
);
|
||||
scene.root.addComponent(skyCycle);
|
||||
```
|
||||
|
||||
### WaterSurfaceComponent
|
||||
|
||||
`WaterSurfaceComponent` evaluates multi-harmonic Gerstner trochoidal waves for water surfaces and floating buoyancy queries.
|
||||
|
||||
```dart
|
||||
final water = WaterSurfaceComponent();
|
||||
final surface = water.evaluateAt(vm.Vector2(playerPos.x, playerPos.z));
|
||||
final waterHeight = surface.displacement.y;
|
||||
final waterNormal = surface.normal;
|
||||
```
|
||||
|
||||
## Immediate-mode debug visualization
|
||||
|
||||
`DebugDraw` provides static immediate-mode line, ray, box, sphere, and axis drawing utilities for physics debugging and AI visualizers.
|
||||
|
||||
```dart
|
||||
DebugDraw.line(startPos, endPos, color: vm.Vector4(1, 0, 0, 1));
|
||||
DebugDraw.box(aabb, color: vm.Vector4(0, 1, 0, 1));
|
||||
DebugDraw.sphere(center, 1.0, color: vm.Vector4(0, 0, 1, 1));
|
||||
DebugDraw.axes(node.globalTransform, size: 2.0);
|
||||
|
||||
// Render debug lines
|
||||
final debugMesh = DebugDraw.flushMesh();
|
||||
if (debugMesh != null) {
|
||||
debugNode.mesh = Mesh(debugMesh, UnlitMaterial());
|
||||
}
|
||||
```
|
||||
166
flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md
Normal file
166
flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
name: flutter_scene-looks
|
||||
version: 4
|
||||
description: Give a flutter_scene render a deliberate, polished look. Use this whenever a scene looks flat, dull, or washed out, or whenever the ask is to make it look good, because a good look is lighting plus post-processing, not geometry. Ships copy-paste EnvironmentSettings presets that configure the whole stack coherently.
|
||||
---
|
||||
|
||||
# Making flutter_scene look good
|
||||
|
||||
The single most common reason a flutter_scene render looks amateur is that the post-processing stack was left at defaults. flutter_scene has a deep lit-and-post pipeline (image-based lighting, tone mapping, bloom, lens flares, ambient occlusion, screen-space reflections, fog, god rays, depth of field, color grading, vignette, film grain). Out of the box almost all of it is off, so a bare scene is technically correct and visually flat.
|
||||
|
||||
**The insight: a polished look is lighting and post-processing, not geometry.** Better meshes will not fix a flat render. Do not spend effort on modeling detail when the scene reads dull; spend it on the look. And a look is a *coherent* set of choices, not twelve knobs turned independently. Turning bloom, AO, SSR, fog, grain, and grading up one at a time, each by feel, lands in muddy incoherent territory. Pick one deliberate preset and paste it whole.
|
||||
|
||||
## Apply a look in one line
|
||||
|
||||
Every scene-wide look field lives on one aggregate value, `EnvironmentSettings`, assigned through a single setter:
|
||||
|
||||
```dart
|
||||
scene.environmentSettings = EnvironmentSettings(/* fields below */);
|
||||
```
|
||||
|
||||
That one assignment configures tone mapping, exposure, image-based-lighting intensity, and the entire post stack, **including** fog, god rays, depth of field, and auto exposure. You do not need to touch `scene.fog`, `scene.godRays`, `scene.depthOfField`, or `scene.autoExposure` separately; those are the live per-effect objects, but `EnvironmentSettings` carries all of their fields and applies them for you. Every effect is off by default, so a preset only names the fields it turns on.
|
||||
|
||||
`EnvironmentSettings` is also a blendable snapshot. Read the current look with `scene.environmentSettings`, and cross-fade two looks with `EnvironmentSettings.lerp(a, b, t)` driven from an animation. That is how you transition day to night or ramp an effect in.
|
||||
|
||||
## Lights are separate, and still matter
|
||||
|
||||
`EnvironmentSettings` covers image-based lighting (the `environment` map) and the whole post stack, but **direct lights are not part of it.** Shadows, god rays, and any strong key light come from the scene's lights, set separately:
|
||||
|
||||
```dart
|
||||
scene.directionalLight = DirectionalLight(
|
||||
direction: vm.Vector3(-0.4, -1.0, -0.3),
|
||||
intensity: 4.0,
|
||||
castsShadow: true, // shadows are off until you ask
|
||||
);
|
||||
```
|
||||
|
||||
An unset `scene.environment` still resolves to a default studio IBL, so a `PhysicallyBasedMaterial` is always lit. But a scene with no direct light is soft and shadowless. God rays require a shadow-casting `DirectionalLight`; shadows require `castsShadow: true` on the light.
|
||||
|
||||
## The four looks
|
||||
|
||||
Paste one whole. Each is a real `EnvironmentSettings` literal; import `package:vector_math/vector_math.dart as vm` for the `Vector3` color fields. AO, SSR, god rays, and depth of field require a `PerspectiveCamera` (the only built-in camera).
|
||||
|
||||
### showcase
|
||||
|
||||
Clean, bright product-viz beauty. Punchy tone mapping, soft bloom on highlights, grounded contact occlusion, and real reflections. The default reach-for-it look.
|
||||
|
||||
```dart
|
||||
scene.environmentSettings = EnvironmentSettings(
|
||||
toneMapping: ToneMappingMode.aces,
|
||||
exposure: 1.0,
|
||||
bloomEnabled: true,
|
||||
bloomThreshold: 1.1,
|
||||
bloomIntensity: 0.2,
|
||||
bloomScatter: 0.7,
|
||||
ambientOcclusionEnabled: true,
|
||||
ambientOcclusionMethod: AmbientOcclusionMethod.groundTruth,
|
||||
ambientOcclusionBentNormals: true,
|
||||
ambientOcclusionSpecularMode: SpecularAmbientOcclusionMode.bentCone,
|
||||
ambientOcclusionIntensity: 1.0,
|
||||
screenSpaceReflectionsEnabled: true,
|
||||
screenSpaceReflectionsIntensity: 1.0,
|
||||
vignetteEnabled: true,
|
||||
vignetteIntensity: 0.25,
|
||||
);
|
||||
```
|
||||
|
||||
### stylized
|
||||
|
||||
Vivid and graphic. Saturated, slightly warm, glowing, flatter shading (no heavy occlusion or reflections). For playful or illustrative scenes.
|
||||
|
||||
```dart
|
||||
scene.environmentSettings = EnvironmentSettings(
|
||||
toneMapping: ToneMappingMode.aces,
|
||||
colorGradingEnabled: true,
|
||||
saturation: 1.25,
|
||||
contrast: 1.1,
|
||||
brightness: 1.05,
|
||||
temperature: 0.1,
|
||||
bloomEnabled: true,
|
||||
bloomThreshold: 0.9,
|
||||
bloomIntensity: 0.28,
|
||||
bloomScatter: 0.8,
|
||||
vignetteEnabled: true,
|
||||
vignetteIntensity: 0.2,
|
||||
);
|
||||
```
|
||||
|
||||
### moody
|
||||
|
||||
Dark, cinematic, atmospheric. Lower exposure, cool graded, foggy, heavy vignette, subtle grain and aberration, deep occlusion. God rays if the scene has a shadow-casting sun. Use a cool horizon-colored fog.
|
||||
|
||||
```dart
|
||||
scene.environmentSettings = EnvironmentSettings(
|
||||
toneMapping: ToneMappingMode.aces,
|
||||
exposure: 0.8,
|
||||
colorGradingEnabled: true,
|
||||
contrast: 1.15,
|
||||
saturation: 0.9,
|
||||
temperature: -0.1,
|
||||
fogEnabled: true,
|
||||
fogMode: FogMode.exponential,
|
||||
fogColor: vm.Vector3(0.05, 0.06, 0.09),
|
||||
fogDensity: 0.03,
|
||||
ambientOcclusionEnabled: true,
|
||||
ambientOcclusionMethod: AmbientOcclusionMethod.groundTruth,
|
||||
ambientOcclusionIntensity: 1.2,
|
||||
ambientOcclusionPower: 1.8,
|
||||
vignetteEnabled: true,
|
||||
vignetteIntensity: 0.6,
|
||||
vignetteRadius: 0.6,
|
||||
filmGrainEnabled: true,
|
||||
filmGrainIntensity: 0.25,
|
||||
chromaticAberrationEnabled: true,
|
||||
chromaticAberrationIntensity: 0.15,
|
||||
godRaysEnabled: true, // needs scene.directionalLight with castsShadow: true
|
||||
godRaysIntensity: 1.0,
|
||||
godRaysDensity: 0.6,
|
||||
godRaysColor: vm.Vector3(1.0, 0.95, 0.85),
|
||||
);
|
||||
```
|
||||
|
||||
### clean
|
||||
|
||||
Neutral and honest. Minimal post, no grading, no bloom, no vignette, just correct tone mapping and gentle grounding occlusion. The right look for an editor, an inspector, a UI-embedded viewer, or anywhere you want an accurate read of the actual material.
|
||||
|
||||
```dart
|
||||
scene.environmentSettings = EnvironmentSettings(
|
||||
toneMapping: ToneMappingMode.pbrNeutral, // the engine default
|
||||
exposure: 1.0,
|
||||
ambientOcclusionEnabled: true,
|
||||
ambientOcclusionIntensity: 0.8,
|
||||
ambientOcclusionHalfResolution: true,
|
||||
);
|
||||
```
|
||||
|
||||
## Tuning from a preset
|
||||
|
||||
Start from the nearest look, then move one field at a time.
|
||||
|
||||
- **Too dim or too bright overall.** Change `exposure` (default 1.0), not per-light intensity. Or turn on `autoExposureEnabled: true` to let the scene meter itself.
|
||||
- **Highlights not glowing.** Lower `bloomThreshold` toward 1.0 or below, or raise `bloomIntensity`. `bloomScatter` widens the glow.
|
||||
- **Want a lens flare off a bright source.** Turn on `lensFlareEnabled` (needs `bloomEnabled`). Keep `lensFlareIntensity` modest and drop `lensFlareHaloIntensity` first if the flare washes the frame; the halo is the broad wash, the ghosts are the crisp chain.
|
||||
- **Reads flat and ungrounded.** `ambientOcclusionEnabled: true`. Use `AmbientOcclusionMethod.groundTruth` for quality, keep `ambientOcclusionHalfResolution: true` for cost.
|
||||
- **Colors feel wrong.** Turn on `colorGradingEnabled` and reach for `saturation`, `contrast`, `temperature`, `tint` before anything else.
|
||||
- **Wrong tone-map feel.** `ToneMappingMode.aces` is contrasty and filmic, `pbrNeutral` (default) preserves hue and saturation, `agx` is the most neutral highlight rolloff. Do not reintroduce an `exposure: 2.0` hack; that was an artifact of an older renderer.
|
||||
|
||||
## Micro-surface metrics and anti-waxiness tuning
|
||||
|
||||
When tuning procedural materials or custom shaders, use surface metrics and exposure discipline to eliminate waxiness, plastic reads, or harsh aliasing:
|
||||
|
||||
- **Surface reads like smooth plastic.** Increase high-frequency micro-scale variation. Low 1-pixel luminance gradients `(|dL/dx| + |dL/dy|) / 2` indicate untextured or overly smooth surfaces.
|
||||
- **Blotchy macro clouds.** Balance high-frequency and low-frequency energy ratios (`hf/lf`). High variance with low fine detail indicates large-scale noise patches without adequate surface grain.
|
||||
- **Harsh normal-map glitter or torn noise.** Check terminator crossing behavior under low grazing light angles (sun low on the horizon). Over-amplified normal maps flip adjacent pixels between full light and full shadow.
|
||||
- **Colors look washed out in bright areas.** Tone mapping curves compress channel differences near the shoulder. A surface reading high brightness with low saturation is often over-exposed rather than under-pigmented; reduce exposure to restore natural material saturation.
|
||||
|
||||
## Look tools that are not on EnvironmentSettings
|
||||
|
||||
Anti-aliasing, reflection probes, and planar reflectors affect the look but are set outside `EnvironmentSettings`, so a preset does not turn them on.
|
||||
|
||||
- **Anti-aliasing** is a `Scene` field. The default `AntiAliasingMode.auto` already picks `msaa` where the backend supports it and `fxaa` otherwise, so edges are handled. Reach for `scene.antiAliasingMode = AntiAliasingMode.smaa` when `fxaa` looks mushy (it blurs texture detail) and `msaa` is unavailable; SMAA keeps edges clean without the blur, at ~3x fxaa cost.
|
||||
- **Reflection probes** capture true local reflections that SSR cannot, because SSR only reflects what is on screen. Attach a `ReflectionProbeComponent` to a node placed at the reflective spot (a room, a mirror ball); it captures the surroundings into a parallax-corrected box and blends with the environment. The capture renders the scene six times, so it happens once on activate (or on an explicit `requestCapture()`), never per frame.
|
||||
- **Planar reflectors** render a true per-frame mirror for one flat surface (a mirror, a glossy floor), which neither SSR nor a probe can produce. Attach a `PlanarReflectorComponent` to the mirror node and give the surface a `.fmat` material declaring `engine_inputs: [ planar_reflection ]`; the component renders the scene once more per frame from the reflected camera and the material samples it with `GetPlanarReflection()`. That extra scene render is the cost, so bound it with `resolutionScale` (default 0.5) and `layerMask`. See `references/looks.md` for all three.
|
||||
|
||||
## Cost
|
||||
|
||||
The post stack is not free. AO, SSR, depth of field, and god rays each add screen-space passes, and mobile and web are the budget. Keep `ambientOcclusionHalfResolution: true`, prefer `DepthOfFieldQuality.low` on mobile, and do not stack SSR plus god rays plus depth of field on a low-end target without profiling. The `clean` look is nearly free; `moody` is the heaviest. See `references/looks.md` for the full per-effect knob reference, defaults, and cost notes.
|
||||
@@ -0,0 +1,290 @@
|
||||
# The look stack, knob by knob
|
||||
|
||||
Every field here is a constructor argument on `EnvironmentSettings` (`lib/src/environment_settings.dart`). Assigning `scene.environmentSettings = EnvironmentSettings(...)` applies all of them at once. Defaults are the constructor defaults; a preset only names what it changes. Direct lights (`scene.directionalLight` and friends) are separate and not covered by `EnvironmentSettings`.
|
||||
|
||||
Color fields are `vm.Vector3` (import `package:vector_math/vector_math.dart as vm`).
|
||||
|
||||
---
|
||||
|
||||
## Base look
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `toneMapping` | `ToneMappingMode.pbrNeutral` | HDR to display operator. `pbrNeutral` preserves hue/saturation, `aces` is contrasty and filmic, `agx` has the gentlest highlight rolloff, `reinhard`/`linear` are simpler references. |
|
||||
| `exposure` | `1.0` | Linear scene exposure multiplier. The one knob for overall brightness. Do not use `2.0`; that was an old-renderer hack. |
|
||||
| `environmentIntensity` | `1.0` | Scales image-based lighting (the environment map) contribution. |
|
||||
| `agxWhite` | `16.29` | AgX white point, only meaningful with `ToneMappingMode.agx`. |
|
||||
| `agxContrast` | `1.25` | AgX contrast, only with `agx`. |
|
||||
|
||||
`environment` (an `EnvironmentMap?`) and the sky fields are also on `EnvironmentSettings`, but building environment maps is the domain of the idioms skill; a null environment resolves to a default studio IBL.
|
||||
|
||||
## Auto exposure (`autoExposure*`)
|
||||
|
||||
Meters the frame and multiplies on top of `exposure`. Off by default.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `autoExposureEnabled` | `false` | Turn metering on. |
|
||||
| `autoExposureStrength` | `0.55` | How fully it drives toward the metered target (0 = none, 1 = full). |
|
||||
| `autoExposureCompensation` | `0.0` | EV bias applied after metering. |
|
||||
| `autoExposureMinEv` / `autoExposureMaxEv` | `-4.0` / `4.0` | Clamp range for the adaptation. |
|
||||
| `autoExposureSpeedUp` / `autoExposureSpeedDown` | `3.0` / `1.0` | Adaptation rate brightening vs darkening. |
|
||||
|
||||
## Bloom (`bloom*`)
|
||||
|
||||
Blooms bright pixels. Off by default. The cheapest way to make a scene feel lit rather than rendered.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `bloomEnabled` | `false` | Turn bloom on. |
|
||||
| `bloomThreshold` | `1.0` | Brightness above which a pixel blooms. Lower for more glow. |
|
||||
| `bloomIntensity` | `0.15` | Strength of the added glow. |
|
||||
| `bloomScatter` | `0.7` | Spread of the glow, wider values feel dreamier. |
|
||||
|
||||
### Lens flares (`lensFlare*`)
|
||||
|
||||
Ghost chains and a halo ring off the bloom pyramid, for bright emissive sources and sun disks. Rides the bloom chain, so `bloomEnabled` must be on and the flare scales with `bloomIntensity`. Off by default. A little goes a long way; a strong source with high intensity/halo washes the frame.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `lensFlareEnabled` | `false` | Turn flares on. Needs `bloomEnabled`. |
|
||||
| `lensFlareIntensity` | `1.0` | Strength of the flare features relative to the bloom. |
|
||||
| `lensFlareGhostCount` | `4` | Internal-reflection ghosts along the line through the screen center (clamped to 8 at render). |
|
||||
| `lensFlareGhostSpacing` | `0.3` | Spacing between ghosts, as a fraction of the distance to the center. |
|
||||
| `lensFlareHaloRadius` | `0.35` | Halo ring radius in screen UV units. |
|
||||
| `lensFlareHaloIntensity` | `1.0` | Halo strength relative to the ghosts. `0` disables the halo. The halo is what washes the whole frame, so tame it first. |
|
||||
| `lensFlareChromaticAberration` | `0.005` | Radial color dispersion of the flare features. |
|
||||
|
||||
## Color grading (`colorGrading*`)
|
||||
|
||||
Post-tone-map color shaping. Off by default. Reach here to change the *mood* of the color rather than the exposure.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `colorGradingEnabled` | `false` | Turn grading on. |
|
||||
| `brightness` | `1.0` | Multiplicative brightness. |
|
||||
| `contrast` | `1.0` | Contrast around mid-gray. |
|
||||
| `saturation` | `1.0` | Color saturation. Above 1 is vivid, below 1 desaturates toward gray. |
|
||||
| `temperature` | `0.0` | Warm (positive) to cool (negative) white balance. |
|
||||
| `tint` | `0.0` | Green to magenta balance. |
|
||||
| `lift` / `gamma` / `gain` | `Vector3(0)` / `Vector3(1)` / `Vector3(1)` | Per-channel shadow/mid/highlight color control (lift-gamma-gain). |
|
||||
| `colorGradingLut` | `null` | A `ColorLut` (from a `.cube` file) applied after tone mapping. Independent of `colorGradingEnabled`. |
|
||||
| `colorGradingLutBlend` | `1.0` | LUT mix amount. |
|
||||
|
||||
## Vignette (`vignette*`)
|
||||
|
||||
Darkens the frame edges. Off by default. Small amounts read as cinematic; large amounts as a peephole.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `vignetteEnabled` | `false` | Turn vignette on. |
|
||||
| `vignetteIntensity` | `0.5` | Darkening strength at the edge. |
|
||||
| `vignetteRadius` | `0.75` | How far in the darkening starts (smaller = tighter, more closed-in). |
|
||||
| `vignetteSmoothness` | `0.5` | Falloff softness of the edge. |
|
||||
|
||||
## Chromatic aberration (`chromaticAberration*`)
|
||||
|
||||
Splits color channels toward the edges. Off by default. A touch adds a lens feel; too much looks broken.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `chromaticAberrationEnabled` | `false` | Turn it on. |
|
||||
| `chromaticAberrationIntensity` | `0.2` | Channel-separation strength. |
|
||||
|
||||
## Film grain (`filmGrain*`)
|
||||
|
||||
Adds animated grain. Off by default. Sells a moody or analog look and hides banding in dark gradients.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `filmGrainEnabled` | `false` | Turn it on. |
|
||||
| `filmGrainIntensity` | `0.3` | Grain strength. |
|
||||
|
||||
## Ambient occlusion (`ambientOcclusion*`)
|
||||
|
||||
Screen-space contact darkening. Off by default. Requires a `PerspectiveCamera`. The single biggest upgrade for "grounded" vs "floating". Half-resolution by default to stay affordable.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `ambientOcclusionEnabled` | `false` | Turn AO on. |
|
||||
| `ambientOcclusionMethod` | `AmbientOcclusionMethod.obscurance` | `obscurance` is the cheap default; `groundTruth` (GTAO) is higher quality and needed for bent normals. |
|
||||
| `ambientOcclusionIntensity` | `1.0` | Darkening strength. |
|
||||
| `ambientOcclusionRadius` | `0.33` | World-space sampling radius. |
|
||||
| `ambientOcclusionPower` | `1.5` | Contrast of the occlusion curve. |
|
||||
| `ambientOcclusionBias` | `0.07` | Self-occlusion rejection. |
|
||||
| `ambientOcclusionBentNormals` | `false` | Compute bent normals (needs `groundTruth`); improves indirect lighting direction and enables `bentCone` specular AO. |
|
||||
| `ambientOcclusionSpecularMode` | `SpecularAmbientOcclusionMode.none` | `simple` occludes reflections cheaply; `bentCone` is directional and needs bent normals. |
|
||||
| `ambientOcclusionHalfResolution` | `true` | Compute at half res. Keep on unless AO edges look too coarse. |
|
||||
| `ambientOcclusionIndirectLight` | `0.0` | Above 0 turns on screen-space global illumination (SSGI) bounce; expensive. Its radiance history reprojects, so the bounce stays put under camera motion (object motion still lags). |
|
||||
| `ambientOcclusionMultiBounce` | `0.0` | Approximate multi-bounce darkening recovery. |
|
||||
| `ambientOcclusionSampleCount` | `16` | Samples for the `obscurance` method. |
|
||||
| `ambientOcclusionSliceCount` / `ambientOcclusionStepsPerSlice` | `3` / `3` | GTAO slice sampling. |
|
||||
| `ambientOcclusionDetail` | `0.5` | Fine-detail term weight (obscurance). |
|
||||
| `ambientOcclusionHorizonAngle` | `0.06` | Horizon rejection angle. |
|
||||
| `ambientOcclusionThickness` / `ambientOcclusionThicknessHeuristic` | `0.5` / `0.004` | Depth thickness assumptions for occlusion. |
|
||||
| `ambientOcclusionDirectLightAffect` | `0.0` | How much AO also dims direct light. |
|
||||
| `ambientOcclusionVisibilityBitmask` | `false` | Bitmask visibility estimator. |
|
||||
| `ambientOcclusionDepthMipChain` | `false` | Build a depth mip chain for wide-radius sampling. |
|
||||
|
||||
## Screen-space reflections (`screenSpaceReflections*`)
|
||||
|
||||
Reflects on-screen geometry. Off by default. Requires a `PerspectiveCamera`. Adds realism to floors, water, and glossy surfaces, but only reflects what is on screen.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `screenSpaceReflectionsEnabled` | `false` | Turn SSR on. |
|
||||
| `screenSpaceReflectionsIntensity` | `1.0` | Reflection strength. |
|
||||
| `screenSpaceReflectionsMaxDistance` | `24.4` | Max world-space ray distance. |
|
||||
| `screenSpaceReflectionsThickness` | `0.46` | Assumed surface thickness for hit tests. |
|
||||
| `screenSpaceReflectionsStride` | `9.0` | March step size (larger = faster, coarser). |
|
||||
| `screenSpaceReflectionsMaxSteps` | `90` | Ray-march step budget. |
|
||||
| `screenSpaceReflectionsBlur` | `0.3` | Roughness-based blur of the reflection. |
|
||||
| `screenSpaceReflectionsDistanceFadeStart` | `0.0` | Where reflections start fading with distance. |
|
||||
| `screenSpaceReflectionsResolutionScale` | `1.0` | Compute resolution scale; drop below 1 to save cost. |
|
||||
|
||||
## Fog (`fog*`)
|
||||
|
||||
Distance and height fog, evaluated in linear HDR before tone mapping. Off by default. Needs both `fogEnabled` and a non-`none` `fogMode`. Applies to lit and unlit materials; the skybox is left unfogged, so set `fogColor` to your horizon color for distant blending.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `fogEnabled` | `false` | Turn fog on. |
|
||||
| `fogMode` | `FogMode.exponential` | `none`, `linear`, `exponential`, `exponentialSquared`. Must be non-`none` to render. |
|
||||
| `fogColor` | `Vector3(0.6, 0.7, 0.8)` | Fog tint. Match your sky/horizon. |
|
||||
| `fogDensity` | `0.02` | Density for the exponential modes. |
|
||||
| `fogStart` / `fogEnd` | `0.0` / `200.0` | Near/far bounds for `linear` mode. |
|
||||
| `fogSkyColorInfluence` | `0.0` | Blend fog color toward the sky color. |
|
||||
| `fogMaxOpacity` | `1.0` | Cap on how opaque fog gets. |
|
||||
| `fogHeight` / `fogHeightFalloff` | `0.0` / `0.0` | Height-fog band and falloff. |
|
||||
| `fogSunInScatter` / `fogSunInScatterExponent` | `0.0` / `8.0` | Sun in-scatter glow through the fog. |
|
||||
| `fogCutoffDistance` | `0.0` | Distance beyond which fog stops accumulating. |
|
||||
|
||||
## God rays (`godRays*`)
|
||||
|
||||
Volumetric light shafts. Off by default. Requires a shadow-casting `DirectionalLight` and a `PerspectiveCamera`; without a shadow-casting sun there is nothing to shaft.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `godRaysEnabled` | `false` | Turn shafts on. |
|
||||
| `godRaysIntensity` | `1.0` | Shaft strength. |
|
||||
| `godRaysDensity` | `0.5` | Medium density the light scatters through. |
|
||||
| `godRaysAnisotropy` | `0.7` | Forward-scatter bias (higher = tighter shafts toward the sun). |
|
||||
| `godRaysStepCount` | `24` | March steps; higher is smoother and costlier. |
|
||||
| `godRaysMaxDistance` | `200.0` | Max shaft distance. |
|
||||
| `godRaysJitter` | `1.0` | Dither to hide banding. |
|
||||
| `godRaysColor` | `Vector3(1)` | Shaft tint. |
|
||||
|
||||
## Depth of field (`depthOfField*`)
|
||||
|
||||
Physically parameterized lens blur. Off by default. Requires a `PerspectiveCamera`. Great for a hero shot, wasteful for a full interactive scene.
|
||||
|
||||
| Field | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `depthOfFieldEnabled` | `false` | Turn DoF on. |
|
||||
| `depthOfFieldFocusDistance` | `10.0` | World distance in sharp focus. |
|
||||
| `depthOfFieldFStop` | `2.8` | Aperture; lower = shallower focus, more blur. |
|
||||
| `depthOfFieldFocalLength` | `0.0` | Lens focal length (0 derives from FOV). |
|
||||
| `depthOfFieldSensorHeight` | `0.024` | Sensor height in meters (35mm-ish). |
|
||||
| `depthOfFieldBlurScale` | `1.0` | Overall blur multiplier. |
|
||||
| `depthOfFieldMaxForegroundBlur` / `depthOfFieldMaxBackgroundBlur` | `24.0` / `32.0` | Blur radius caps. |
|
||||
| `depthOfFieldBladeCount` | `0` | Aperture blades for bokeh shape (0 = round). |
|
||||
| `depthOfFieldBladeRotation` / `depthOfFieldBladeCurvature` | `0.0` / `0.0` | Bokeh blade shaping. |
|
||||
| `depthOfFieldQuality` | `DepthOfFieldQuality.medium` | `low` (16 taps, mobile/web), `medium` (32 taps + postfilter), `high` (48 taps). |
|
||||
|
||||
---
|
||||
|
||||
## Not on `EnvironmentSettings`
|
||||
|
||||
Two look-affecting settings live outside the `EnvironmentSettings` snapshot: anti-aliasing is a `Scene` field, and reflection probes are a scene-graph component. Set them directly.
|
||||
|
||||
### Anti-aliasing (`Scene.antiAliasingMode`)
|
||||
|
||||
Edge anti-aliasing. `AntiAliasingMode.auto` is the default: it picks `msaa` where the backend supports it and `fxaa` otherwise. Set it directly, not through `EnvironmentSettings`.
|
||||
|
||||
```dart
|
||||
scene.antiAliasingMode = AntiAliasingMode.smaa;
|
||||
```
|
||||
|
||||
| Mode | What it does |
|
||||
| --- | --- |
|
||||
| `none` | No anti-aliasing; native-resolution edges. |
|
||||
| `msaa` | 4x MSAA on the scene pass, the best geometry-edge quality and cheap on mobile GPUs, but not supported on every Flutter GPU backend (falls back to `fxaa`). Check `Scene.isAntiAliasingModeSupported` and read `Scene.effectiveAntiAliasingMode`. |
|
||||
| `fxaa` | One post pass over the tone-mapped image, supported everywhere, but softens all high-contrast edges including texture detail. |
|
||||
| `smaa` | SMAA 1x, three post passes, supported everywhere. Reconstructs edge shapes so edges are cleaner than `fxaa` with far less texture blurring, at roughly 3x the `fxaa` cost. Reach for it when `fxaa` looks mushy and `msaa` is unavailable. |
|
||||
| `auto` | `msaa` where supported, else `fxaa`. |
|
||||
|
||||
### Reflection probes (`ReflectionProbeComponent`)
|
||||
|
||||
SSR only reflects what is currently on screen. A reflection probe captures the surroundings from a point into a local, parallax-corrected environment, so off-screen geometry reflects correctly inside a bounded box (a mirror ball, a glossy floor in a room). It is a `Component` attached to a `Node`, not an `EnvironmentSettings` field.
|
||||
|
||||
```dart
|
||||
final probe = Node()
|
||||
..localTransform = vm.Matrix4.translation(vm.Vector3(0, 1, 0)); // reflective spot
|
||||
probe.addComponent(ReflectionProbeComponent(
|
||||
extents: vm.Vector3(4, 3, 4), // box half-extents (the influence + parallax volume)
|
||||
));
|
||||
scene.add(probe);
|
||||
```
|
||||
|
||||
| Constructor arg | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `extents` | `Vector3.all(5.0)` | Half-extents of the world-axis-aligned box that is both the influence volume and the parallax proxy. |
|
||||
| `blendDistance` | `1.0` | Distance over which the probe cross-fades with the environment at the box edge. |
|
||||
| `priority` | `10.0` | Which probe wins where several overlap. |
|
||||
| `weight` | `1.0` | Contribution scale in the blend. |
|
||||
| `faceResolution` | `128` | Cubemap face resolution of the capture. |
|
||||
| `captureOnActivate` | `true` | Capture once when the probe joins the scene. Call `requestCapture()` to re-capture after the scene changes; the capture is a static snapshot otherwise. |
|
||||
|
||||
For a one-shot environment capture with no node or parallax (e.g. to hand a captured `EnvironmentMap` to another material), `Scene.captureEnvironment(position: ...)` returns an `EnvironmentMap` directly.
|
||||
|
||||
### Planar reflectors (`PlanarReflectorComponent`)
|
||||
|
||||
A true mirror for one flat surface, re-rendered every frame the surface is visible: the engine renders the scene from the view camera reflected across the surface's plane (near plane clamped to the mirror, so nothing behind it leaks in) and hands the capture to the surface's material. Use it for mirrors and glossy floors where SSR's on-screen-only reflections or a probe's static capture are not enough.
|
||||
|
||||
Two pieces pair up. The component goes on the mirror node (the plane is the node's local `+Y` through its transform, or an explicit `localNormal`):
|
||||
|
||||
```dart
|
||||
final mirror = Node(mesh: Mesh(PlaneGeometry(width: 10, depth: 10), mirrorMaterial))
|
||||
..addComponent(PlanarReflectorComponent());
|
||||
scene.add(mirror);
|
||||
```
|
||||
|
||||
And the surface's material is a `.fmat` that declares the `planar_reflection` engine input and samples `GetPlanarReflection()` (mirrored scene color in rgb, `a` 1 while a capture is bound; fall back to the environment reflection at `a == 0`). A worked mirror lives at `examples/flutter_app/assets/planar_mirror.fmat`.
|
||||
|
||||
| Constructor arg | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `resolutionScale` | `0.5` | Capture resolution relative to the view (clamped `0.1..1.0`). The fragment-cost lever. |
|
||||
| `layerMask` | all layers | What renders into the capture. The draw-cost lever. |
|
||||
| `reflectionGroupId` | `-1` | Co-planar surfaces sharing a non-negative id share one capture per frame; `-1` means an own capture. |
|
||||
| `clipBias` | `1e-3` | World-space offset of the clip plane in front of the mirror, keeping the surface itself out of the capture. |
|
||||
| `localNormal` | local `+Y` | The mirror plane's facing direction in node space. |
|
||||
|
||||
The capture is a second scene submission per reflection group per frame: its CPU and draw-call cost scales with scene complexity, not just resolution. It reuses the frame's shadow atlas and runs without screen-space post; reflectors seen inside a capture draw their base look, so captures never recurse.
|
||||
|
||||
---
|
||||
|
||||
## Cost and budget
|
||||
|
||||
Post effects are screen-space passes; they cost per output pixel, not per triangle. Rough order, cheapest first:
|
||||
|
||||
- **Nearly free.** Tone mapping, exposure, color grading, vignette, chromatic aberration, film grain, bloom (with lens flares). The `clean` and `stylized` looks live here.
|
||||
- **Moderate.** Ambient occlusion (keep `ambientOcclusionHalfResolution: true`), fog, `fxaa`, `smaa` (~3x `fxaa`). `msaa` is nearly free on mobile GPUs but costs more elsewhere.
|
||||
- **Expensive.** SSR, god rays, depth of field, and especially SSGI (`ambientOcclusionIndirectLight > 0`). Each adds ray-marching or gather passes. A reflection probe's capture renders the scene six times, so capture on activate or an occasional `requestCapture()`, never per frame.
|
||||
|
||||
Budget guidance:
|
||||
|
||||
- **Mobile and web are the ceiling.** A look that is smooth on desktop can tank a phone. Profile the target, do not assume.
|
||||
- **Keep AO at half resolution** unless the coarse edges actually show. Full-res AO rarely earns its cost.
|
||||
- **Do not stack the expensive effects blindly.** SSR plus god rays plus depth of field together on a low-end device needs profiling; drop one or lower its resolution/step budget (`screenSpaceReflectionsResolutionScale`, `godRaysStepCount`, `DepthOfFieldQuality.low`).
|
||||
- **Depth of field is a hero-shot tool.** It reads as intentional on a framed still and as a smear on a free-moving interactive camera. Prefer it where the camera is controlled.
|
||||
- **Bloom and grading buy the most look per cost.** When you need "flashier" cheaply, reach for these before the screen-space passes.
|
||||
|
||||
## Why each look is built the way it is
|
||||
|
||||
**showcase** aims for a clean, believable, flattering render, the default for showing a model off. `aces` tone mapping gives contrast and pop; soft bloom (threshold just above 1.0) lights the highlights without haze; GTAO with bent normals and `bentCone` specular AO grounds the object and tightens reflections in its cavities; SSR adds real floor and surface reflections; a light vignette focuses the eye. Every choice supports "look at this object", nothing calls attention to itself.
|
||||
|
||||
**stylized** trades realism for graphic punch. It leans on color (saturation up, a warm push, contrast up) and glow (a lower bloom threshold so more of the frame blooms) and deliberately *omits* AO and SSR, because heavy occlusion and reflection read as realism and fight the flatter, poppier intent. Cheap to run, since it is all near-free passes.
|
||||
|
||||
**moody** is the atmospheric, cinematic end. Lower exposure and cool grading set a somber base; exponential fog with a dark horizon color adds depth and hides the far plane; strong AO deepens the shadows; a tight heavy vignette closes the frame; film grain and a touch of chromatic aberration add texture and a lens feel; god rays (given a shadow-casting sun) add drama. It is the most expensive look; on a tight budget, drop god rays first, then SSR if present.
|
||||
|
||||
**clean** is the honest look, for when the render must show the *actual* material and lighting without editorializing: an editor viewport, an inspector, a UI-embedded preview. Default `pbrNeutral` tone mapping preserves hue and saturation, and the only effect on is gentle half-res AO for grounding. No bloom, grading, or vignette, because each of those changes what the color and brightness actually are, which is exactly what an accurate preview must not do. Also the cheapest look by far.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: flutter_scene-performance
|
||||
version: 2
|
||||
description: Make a flutter_scene app hit frame budget. Use whenever a scene janks, stutters, or drops frames, or when the ask is to make it faster or run on mobile or web, because code-driven scenes are reliably slow and the fix depends on which thread is over budget, not on a guessed poly count.
|
||||
---
|
||||
|
||||
# Making flutter_scene fast
|
||||
|
||||
A code-driven flutter_scene scene is reliably slow, and the usual reason is that nothing told you the budget or what to fix first, so optimization starts as guesswork. Guessing wastes iterations and often makes the wrong thread slower. This skill replaces the guessing with a budget, a way to measure it, and a fixed order to apply fixes in.
|
||||
|
||||
**The one thing to internalize: measure the actual frame, find which of the two threads is over budget, then fix that thread. Do not target a triangle or draw-call number from memory.** There is no built-in poly budget, and the same scene can be fast on desktop and jank on a phone. The number that matters is milliseconds per frame on the real target.
|
||||
|
||||
## The budget
|
||||
|
||||
A frame has a fixed wall-clock budget set by the refresh rate.
|
||||
|
||||
- **60 fps is 16.6 ms per frame. 120 fps is 8.3 ms.** Miss it and the frame janks.
|
||||
- That budget is split across **two threads**, and either one blowing it drops the frame:
|
||||
- **UI thread** runs your Dart. flutter_scene walks the scene graph, culls, updates components, and builds the render here.
|
||||
- **Raster thread** is where Impeller draws the built frame on the GPU. The whole post-processing stack (ambient occlusion, reflections, depth of field, god rays, bloom) lands here.
|
||||
- **Mobile and web are the real constraint.** Desktop GPUs hide a lot; a scene that runs smooth on a laptop can miss budget badly on a phone or in a browser. Profile on the lowest target you must support.
|
||||
|
||||
## Measure first (do not skip this)
|
||||
|
||||
flutter_scene has **no built-in stats API**. There is no `scene.frameTime`, no draw-call counter. The editor MCP `get_app_state` reports only lifecycle (launching/running), not frame timing. So measurement is Flutter's own tooling.
|
||||
|
||||
1. **Run in profile mode.** `flutter run --profile --enable-flutter-gpu`. Debug-mode timings are meaningless for performance (assertions, no JIT-to-AOT optimization, extra checks), so never judge speed in debug.
|
||||
2. **Read the frame chart.** Open DevTools, go to the Performance view, and read **per-frame UI time vs raster time**. The jank frames are flagged. This single view tells you which thread is over budget, which decides everything below.
|
||||
3. **Or use the performance overlay** for a quick in-app read of the two thread graphs without DevTools.
|
||||
4. **Stopwatch as a coarse fallback.** A `Stopwatch` around the per-frame work gives a rough UI-thread number when you cannot open DevTools. It sees nothing on the raster thread.
|
||||
|
||||
Which thread is over budget names the fix. UI over budget means too much graph/CPU work (steps a, b, c below). Raster over budget means too much GPU work (steps d, e). See `references/performance.md` for the symptom to thread diagnosis.
|
||||
|
||||
## The fixed remediation order
|
||||
|
||||
Apply top-down. Each step lists the real API. Do the measured-over-budget thread's steps first, but the order within is deliberate, the earlier fixes are the bigger wins.
|
||||
|
||||
1. **Instancing** (UI thread). Many copies of one mesh collapse into one draw and one cull test. Build an `InstancedMesh(geometry:, material:)`, add a transform per copy with `addInstance(matrix, {color})`, and mount it with `InstancedMeshComponent`. The single biggest win for repeated geometry (foliage, crowds, tiles, debris). Per-instance frustum culling is off by default (`cullInstances: false`), so the batch is one cull test as a unit. See the `flutter_scene-procedural` skill for the full scatter pattern.
|
||||
|
||||
2. **Level of detail** (both threads). `LodComponent(List<LodLevel>)` swaps cheaper meshes as an object shrinks on screen. Each `LodLevel(geometry:, material:, screenSize:)` gives a threshold (projected size as a fraction of viewport height, highest detail first, last is the cull floor). Fewer triangles for distant objects on the GPU, and nothing drawn below the floor. Note the shadow and depth passes always draw the highest-detail level, so LOD does not lighten shadow cost.
|
||||
|
||||
3. **Culling** (UI thread). `Node.frustumCulled` (default true) skips off-screen subtrees; leave it on. Set it `false` only where the cached bound is known-stale or unbounded (procedural terrain you regenerate). For whole sets, `RenderView.cullingPlanes` adds extra clip planes, and `node.layers` (default `kRenderLayerDefault`) against `RenderView.layerMask` (default `kRenderLayerAll`) skips entire layers a view should not draw.
|
||||
|
||||
4. **Shrink the post stack** (raster thread). This is usually where raster time goes. Turn off effects you do not need via the `EnvironmentSettings` `*Enabled` flags, keep `ambientOcclusionHalfResolution: true`, lower `depthOfFieldQuality` toward `DepthOfFieldQuality.low`, drop `RenderView.renderScale` (or `Scene.renderScale`) below `1.0` to render fewer pixels, and step `Scene.antiAliasingMode` down (`msaa` to `fxaa` to `none`). See the `flutter_scene-looks` skill for what each knob does to the look.
|
||||
|
||||
5. **Texture and material consolidation** (raster thread). Fewer distinct textures and materials means fewer state changes and binds per frame. `TextureAtlas` (with `generateSolidColorAtlasPixels` for placeholders) packs many tiles into one texture so one material covers them all; share a single `Material` instance across many nodes instead of constructing one per node; use `MaterialsVariantsComponent` to switch a model between named material sets rather than duplicating materials.
|
||||
|
||||
6. **Static shadows** (raster thread). `Node.shadowStatic = true` promises a caster's geometry, material coverage, and world transform will not change while mounted, so the engine renders it into cached shadow-map tiles reused across frames instead of re-encoding every caster every frame. A large static world becomes dramatically cheaper to shadow. Flag only genuinely static content; a static node that moves shows stale shadows until its render item re-registers.
|
||||
|
||||
## More depth
|
||||
|
||||
`references/performance.md` expands each step with the full API and when it helps, the UI-vs-raster symptom map (what a wrong frame time points to), and the honest note on what measurement tooling actually exists.
|
||||
@@ -0,0 +1,128 @@
|
||||
# flutter_scene performance reference
|
||||
|
||||
Companion to the `flutter_scene-performance` skill. The skill states the budget, the measure-first rule, and the fixed remediation order. This file expands each step with the real API, when it helps and when it does not, the diagnosis that maps a wrong frame time to a thread, and an honest account of the measurement tooling.
|
||||
|
||||
Verify any symbol here against `lib/src` before relying on it; the inventory in the `flutter_scene-idioms` skill (`references/what-exists.md`) is the fuller API map.
|
||||
|
||||
## The two threads, concretely
|
||||
|
||||
A frame is built on the UI thread and drawn on the raster thread, and they overlap across frames (frame N rasters while frame N+1 builds). Either thread over budget drops the frame.
|
||||
|
||||
- **UI thread work** is Dart. flutter_scene walks the scene graph, computes world transforms, runs frustum culling, ticks every component's `update`, and encodes the draw list. Cost scales with node count, component count, and how much per-frame Dart you run in `onTick` or component `update`.
|
||||
- **Raster thread work** is the GPU. Impeller executes the encoded passes, the shadow pass, the main color pass, and every enabled screen-space post-processing pass. Cost scales with pixels drawn, overdraw, shadow-map resolution, and how many post passes are on.
|
||||
|
||||
## Diagnosis, symptom to thread
|
||||
|
||||
Read the two thread graphs in DevTools (or the performance overlay) and match the over-budget one to a cause.
|
||||
|
||||
| Observation | Over-budget thread | Likely cause | Go to step |
|
||||
| --- | --- | --- | --- |
|
||||
| UI time high, raster fine | UI | Too many nodes/draws or heavy per-frame Dart | 1 instancing, 3 culling |
|
||||
| UI time scales with object count | UI | Thousands of separate nodes for one repeated mesh | 1 instancing |
|
||||
| UI time high with a huge static world | UI | No culling; whole graph walked every frame | 3 culling |
|
||||
| Raster time high, UI fine | Raster | Too many pixels or post passes | 4 post stack, 5 consolidation |
|
||||
| Raster time high, and it tracks resolution | Raster | Fill-bound; too many pixels | 4 `renderScale`, AA |
|
||||
| Raster spikes only when shadows are on | Raster | Every caster re-encoded per frame | 6 static shadows |
|
||||
| Raster time tracks the number of distinct materials | Raster | State-change churn from per-node materials/textures | 5 consolidation |
|
||||
| Jank only on phone or web, smooth on desktop | Whichever is over budget there | Desktop GPU was hiding it | measure on the real target |
|
||||
|
||||
If both threads are near budget, fix the UI thread first (steps 1 to 3); a lighter draw list also lightens the raster thread.
|
||||
|
||||
## Measurement tooling, honestly
|
||||
|
||||
There is **no built-in frame-stats API in flutter_scene**. No `scene.frameTime`, no draw-call count, no visible-triangle count. Do not invent one or claim one exists.
|
||||
|
||||
- **Profile mode is mandatory.** `flutter run --profile --enable-flutter-gpu`. Debug builds carry assertions and skip AOT optimization, so their timings do not reflect a release build. A number taken in debug mode is not a performance number.
|
||||
- **DevTools Performance view** is the primary tool. It shows per-frame UI time and raster time as two tracks, flags janky frames, and lets you expand a frame's timeline. This is what tells you which thread is over budget.
|
||||
- **The performance overlay** gives the same two thread graphs in-app for a quick read without attaching DevTools.
|
||||
- **A `Stopwatch`** around the per-frame work (the `onTick` body, or a component `update`) is a coarse UI-thread fallback. It cannot see the raster thread at all, so a good stopwatch number does not clear a raster-bound jank.
|
||||
- **Editor MCP.** `get_app_state` reports lifecycle only (launching/running), not timing. The one place per-pass GPU timings surface is a render-graph capture (`Scene.captureRenderGraph`, or the editor MCP capture tool), whose result carries per-pass timing and lets you see which post pass is expensive. That is per-pass GPU detail, not a whole-frame counter, and the editor MCP is not connected in every project. When it is not, the DevTools loop above is fully sufficient.
|
||||
|
||||
## Step 1, instancing
|
||||
|
||||
`InstancedMesh` holds one `geometry`/`material` pair and one transform per copy. The whole set encodes as a single draw and, by default, a single frustum cull test.
|
||||
|
||||
```dart
|
||||
final mesh = InstancedMesh(
|
||||
geometry: someGeometry,
|
||||
material: sharedMaterial, // one material for the whole batch
|
||||
);
|
||||
for (final placement in placements) {
|
||||
mesh.addInstance(placement.transform, color: placement.tint); // matrix is cloned
|
||||
}
|
||||
scene.add(Node()..addComponent(InstancedMeshComponent(mesh)));
|
||||
```
|
||||
|
||||
- `addInstance(Matrix4, {Vector4? color})` returns an index; the matrix is cloned, so mutating your copy afterward is safe. Per-instance `color` is a linear RGBA multiplier.
|
||||
- Edit later with `setInstanceTransform(i, m)`, `setInstanceColor(i, color)`, `removeInstanceAt(i)`, `clearInstances()`, or move the whole batch in one pass with `updateInstanceTransforms((list) { ... })`.
|
||||
- **Culling default.** `cullInstances` defaults to `false`, so the batch is culled as one unit against its combined bounds, not per instance. Turn `cullInstances: true` on only for a batch spread across a large area where many instances are off-screen, since per-instance culling adds CPU work.
|
||||
- **When it helps.** Repeated geometry, foliage, crowds, tiles, debris, particles-as-meshes. It is the largest UI-thread win available, because N separate nodes become one. It does nothing for a scene of distinct meshes.
|
||||
- **Winding trap.** A mirrored (negative-determinant) instance edited with `updateInstanceTransforms(recomputeWinding: false)` renders inside-out. Keep instance edits orientation-preserving, or let winding recompute.
|
||||
|
||||
See the `flutter_scene-procedural` skill for the full scatter-on-terrain pattern.
|
||||
|
||||
## Step 2, level of detail
|
||||
|
||||
`LodComponent(List<LodLevel>)` draws one of several mesh variants per frame, chosen from how large the object appears on screen.
|
||||
|
||||
```dart
|
||||
node.addComponent(LodComponent([
|
||||
LodLevel(geometry: high, material: mat, screenSize: 0.4),
|
||||
LodLevel(geometry: mid, material: mat, screenSize: 0.15),
|
||||
LodLevel(geometry: low, material: mat, screenSize: 0.04), // set 0.0 to never cull
|
||||
]));
|
||||
```
|
||||
|
||||
- `screenSize` is the projected bounding-sphere diameter as a fraction of viewport height. Levels are highest detail first, strictly descending. The engine draws the highest-detail level whose threshold the object still meets, and draws nothing below the last threshold (the cull floor).
|
||||
- Selection is screen-size based, so it is field-of-view aware and resolution independent, and it is per view (a split-screen frame can pick different levels per view).
|
||||
- `LodComponent(levels, {lodBias = 1.0, hysteresis = 0.1, blendRange = 0.0})`. `lodBias` above `1` keeps detail farther away; `hysteresis` is a dead-band so an object on a boundary does not flip-flop; `blendRange` above `0` dither-cross-fades adjacent levels to remove the pop (honored by the built-in lit and unlit materials).
|
||||
- **Limitation that matters for shadows.** The shadow and depth-prepass passes always draw the highest-detail level and ignore the LOD cull. So LOD lightens the color pass, not shadow or depth cost. A shadow-heavy scene needs step 6, not LOD.
|
||||
- **Not for instanced draws.** A `LodComponent` draws a single mesh and picks one level for the whole node; it does not combine with hardware instancing.
|
||||
|
||||
## Step 3, culling
|
||||
|
||||
Skip work for things the camera cannot see.
|
||||
|
||||
- **`Node.frustumCulled`** (default `true`) skips a subtree whose `combinedLocalBounds` do not intersect the camera frustum. Leave it on. Set it `false` only where the cached bound is known-stale or misleading (procedural geometry you regenerate, large terrain pieces). A subtree that reports no bound (skinned content, geometry without a computable bound) is treated as always visible regardless of the flag.
|
||||
- **`RenderView.cullingPlanes`** (`List<Plane>`, default empty) adds extra clip planes beyond the frustum, for portal or region culling.
|
||||
- **Layers.** `node.layers` (a 32-bit mask, default `kRenderLayerDefault` which is layer 0, not inherited by children) against `RenderView.layerMask` (default `kRenderLayerAll`) decides whether a view draws a node at all, when `node.layers & view.layerMask != 0`. Put editor gizmos, an inset viewport's contents, or a minimap's set on their own layer and give each view the mask it needs, so a view skips whole sets cheaply.
|
||||
- **When it helps.** Large worlds where much of the graph is off-screen each frame. Culling is a UI-thread win (fewer nodes encoded) that also lightens the raster thread (fewer draws).
|
||||
|
||||
## Step 4, shrink the post stack
|
||||
|
||||
Every screen-space effect is a raster-thread pass. Turning off what you do not need is the most direct raster win. All the scene-wide look fields live on `EnvironmentSettings` (see the `flutter_scene-looks` skill); each effect has an `*Enabled` flag, off by default.
|
||||
|
||||
- **Turn effects off.** `ambientOcclusionEnabled`, `screenSpaceReflectionsEnabled`, `godRaysEnabled`, `depthOfFieldEnabled`, `bloomEnabled`, and the rest default `false`. A preset the app copied may have turned several on; drop the ones the scene does not visibly need. Ambient occlusion, screen-space reflections, god rays, and depth of field are the heavy ones.
|
||||
- **Half-resolution AO.** `ambientOcclusionHalfResolution` defaults `true`; keep it. Full-resolution AO roughly doubles that pass's cost for little visible gain on most content.
|
||||
- **Cheaper depth of field.** `depthOfFieldQuality` (`DepthOfFieldQuality.low`/`medium`/`high`, default `medium`) trades gather taps and cleanup passes for time. Step it down to `low` on mobile.
|
||||
- **Render fewer pixels.** `Scene.renderScale` (default `1.0`), or per-view `RenderView.renderScale`, renders the scene at a fraction of resolution and upscales. Dropping to `0.75` cuts fill cost by nearly half and is often barely visible after anti-aliasing. This is the biggest lever for a fill-bound (resolution-tracking) raster time.
|
||||
- **Step anti-aliasing down.** `Scene.antiAliasingMode`. `AntiAliasingMode.auto` picks `msaa` where supported else `fxaa`. `msaa` is cheap on mobile tilers and highest quality; `fxaa` is a single post pass on every backend; `smaa` is cleaner than `fxaa` but three post passes (~3x its cost), so step it down to `fxaa` or `none` on a raster-bound target; `none` is free. Read what actually runs with `Scene.effectiveAntiAliasingMode`.
|
||||
- **Do not re-capture reflection probes per frame.** A `ReflectionProbeComponent` capture (or `Scene.captureEnvironment`) renders the scene six times, a large one-frame spike. Let it capture once on activate and only call `requestCapture()` when the scene visibly changes, never every frame.
|
||||
- **When it helps.** Any raster-bound scene. The `clean` look in the looks skill is nearly free; a full `moody` stack (AO plus SSR plus god rays plus DoF plus grain) is the heaviest. Do not stack all of those on a low-end target without profiling.
|
||||
|
||||
## Step 5, texture and material consolidation
|
||||
|
||||
Every distinct material and texture is a potential state change and bind on the raster thread. Fewer of them means a shorter, cheaper draw list.
|
||||
|
||||
- **`TextureAtlas`** packs many equally sized tiles (voxel faces, sprite sheets, terrain tiles) into one texture, so a single material and draw call cover every tile. Resolve a tile's UV box with `tileBounds(index)` or map a within-tile coordinate with `tileUv(index, u, v)`, write those into the mesh's texture coordinates, and build the bound material with `toMaterial()`. `generateSolidColorAtlasPixels(tileColors:, columns:, tileSize:, padding:)` builds placeholder pixels to bring the atlas path up before real art exists.
|
||||
- **Share one `Material` instance** across many nodes rather than constructing a new one per node. Identical materials that are separate objects still churn binds; the same object does not. Build the material once and reuse the reference.
|
||||
- **`MaterialsVariantsComponent`** switches an imported model between its named `KHR_materials_variants` sets in place (`MaterialsVariantsComponent.of(model)?.select('name')`), instead of duplicating a model per look. Read `variants` for the declared names; `select(null)` restores defaults.
|
||||
- **When it helps.** Scenes whose raster time tracks the count of distinct materials or textures, tile-based worlds, and models shown in several finishes.
|
||||
|
||||
## Step 6, static shadows
|
||||
|
||||
Shadow casting re-encodes every caster into the shadow map each frame by default. `Node.shadowStatic = true` promises a caster will not change and lets the engine cache its shadow-map tiles across frames.
|
||||
|
||||
```dart
|
||||
staticWorldNode.shadowStatic = true; // set per mesh-bearing node; not inherited
|
||||
```
|
||||
|
||||
- **The contract.** The node's geometry, material coverage, and world transform must not change while mounted. In return, the engine renders it into cached shadow-map tiles reused across frames instead of re-encoding it every frame. Dynamic nodes (the default) still cast per-frame shadows on top of the cache, so a moving character over a static world works.
|
||||
- **Not inherited.** Set it on each mesh-bearing node, not once on a root.
|
||||
- **Stale-shadow caveat.** A static node that does change (moves, remeshes, edits material coverage) shows stale shadows until its render item re-registers. Flag only genuinely static content.
|
||||
- **Displacement caveat.** A material with a `vertex { }` displacement stage should stay dynamic, since its cached shadow would not follow a camera-dependent displacement.
|
||||
- **When it helps.** Large static worlds with a shadow-casting `DirectionalLight`. The win scales with how many static casters you have; a mostly static level with a few moving actors is the ideal case.
|
||||
|
||||
## Order and stopping
|
||||
|
||||
Fix the measured over-budget thread first, top-down within it, and re-measure after each change so cause and effect stay legible. Stop when the frame chart clears budget on the real target; there is no reason to keep optimizing a thread that is already under budget while the other one janks. The whole point of measuring first is to avoid spending a step's effort on the thread that was never the problem.
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: flutter_scene-procedural
|
||||
version: 2
|
||||
description: Build flutter_scene content from code instead of asset files. Use when generating terrain, scattering vegetation or crowds, building oceans and Gerstner waves, setting up procedural skies and trees, assembling modular kits, or driving a scene from noise and instancing rather than loading a .glb.
|
||||
---
|
||||
|
||||
# Procedural content in flutter_scene
|
||||
|
||||
A lot of 3D work does not need an artist's `.glb` at all. Terrain, scattered foliage, debris fields, crowds, and modular buildings are cheaper and more flexible built from code, and flutter_scene has the whole path in the box. Reach for it before wiring up an asset pipeline.
|
||||
|
||||
**The insight: for a code-driven scene, generate geometry and draw it instanced. That path is more reliable than loading external assets, because it has no import step, no coordinate-conversion traps, no missing-file failure modes, and one draw call for thousands of copies.** Three pieces cover almost everything:
|
||||
|
||||
- **`GeometryBuilder`** (and the built-in primitives and swept paths) build custom meshes without a model file.
|
||||
- **`FastNoiseLite`** drives heightmaps, placement, and displacement deterministically.
|
||||
- **`InstancedMesh`** draws thousands of copies of one mesh as a single render item.
|
||||
|
||||
Do not hand-pack a `ByteData` vertex buffer. The vertex layout is fixed (72 bytes unskinned, a specific attribute order) and a wrong stride fails silently with washed-out or see-through geometry. `GeometryBuilder` and `MeshGeometry.fromArrays` interleave the layout for you.
|
||||
|
||||
## Imports
|
||||
|
||||
Geometry and instancing live in the main barrel. **Noise is a separate barrel** and is easy to forget:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_scene/scene.dart'; // GeometryBuilder, MeshGeometry, InstancedMesh, ...
|
||||
import 'package:flutter_scene/noise.dart'; // FastNoiseLite, bakeNoiseTexture, noiseCurl3
|
||||
import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64
|
||||
```
|
||||
|
||||
## Terrain from a noise heightmap
|
||||
|
||||
Sample `FastNoiseLite` on a grid, add each vertex, and wind the two triangles per cell so the lit surface faces up. Omitting normals lets the builder derive them from the actual face slopes, which is what you want for terrain.
|
||||
|
||||
```dart
|
||||
MeshGeometry buildTerrain({int cols = 128, int rows = 128, double spacing = 0.5}) {
|
||||
final noise = FastNoiseLite(seed: 1337)
|
||||
..noiseType = NoiseType.openSimplex2
|
||||
..fractalType = FractalType.fbm // stack octaves for natural detail
|
||||
..octaves = 5
|
||||
..frequency = 0.02; // world units are multiplied by this
|
||||
|
||||
final builder = GeometryBuilder();
|
||||
|
||||
// One vertex per grid point. getNoise2 returns roughly -1..1.
|
||||
for (var r = 0; r < rows; r++) {
|
||||
for (var c = 0; c < cols; c++) {
|
||||
final x = c * spacing;
|
||||
final z = r * spacing;
|
||||
final height = noise.getNoise2(x, z) * 6.0;
|
||||
builder.addVertex(vm.Vector3(x, height, z));
|
||||
}
|
||||
}
|
||||
|
||||
// Two triangles per cell, wound Counter-Clockwise (CCW) so the front face points +Y (up).
|
||||
for (var r = 0; r < rows - 1; r++) {
|
||||
for (var c = 0; c < cols - 1; c++) {
|
||||
final v00 = r * cols + c;
|
||||
final v10 = v00 + 1;
|
||||
final v01 = v00 + cols;
|
||||
final v11 = v01 + 1;
|
||||
builder
|
||||
..addTriangle(v00, v01, v10)
|
||||
..addTriangle(v10, v01, v11);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
```
|
||||
|
||||
Attach it like any mesh:
|
||||
|
||||
```dart
|
||||
final terrain = Node(mesh: Mesh(buildTerrain(), PhysicallyBasedMaterial()..roughnessFactor = 1.0));
|
||||
scene.add(terrain);
|
||||
```
|
||||
|
||||
If a hand-built surface renders inside-out (visible only from below, dark where lit), reverse each triangle's index order. flutter_scene's front faces wind Counter-Clockwise (CCW) in model space, matching glTF and standard conventions; never fix orientation with a per-triangle flip on an imported model, but for geometry you author yourself the winding is yours to set.
|
||||
|
||||
## Natural formations and landscape recipes
|
||||
|
||||
To achieve documentary realism rather than generic procedural lumps:
|
||||
|
||||
1. **Footpaths are scoured trenches, not flat stripes.** A real trail is the lowest line across terrain because water and foot traffic erode it downwards. When generating heightfields, cut the trail path profile down into the terrain with banks rising away on both sides.
|
||||
2. **Ridged noise for valley walls and cliffs.** Standard `FractalType.fbm` makes rolling mounds. Use `FractalType.ridged` for valley walls, mountain spurs, and cliffs to produce sharp erosion creases.
|
||||
3. **Free-end Worley rock cracks.** Standard Worley noise (`F2 - F1`) creates closed polygonal loops like bathroom tile. To produce weathered rock fractures with natural free ends, multiply the cell border by a low-frequency region mask and a high-frequency grain breaker.
|
||||
4. **Noise-modulated pitting.** A constant threshold radius across Worley cells places a pit in every cell, producing an artificial grid lattice. Modulate the threshold radius with an underlying Perlin field so pores vary in size and only appear in exposed weathering pockets.
|
||||
5. **Macro massing for scattered gravel.** Soil wears in 0.5m to 2m zones. Modulate multi-scale pebble instances with a low-frequency massing field so gravel clusters into realistic water scour lines rather than uniform sandpaper noise.
|
||||
6. **Sunk block settling and ground contact staining.** Place boulders and masonry courses 1/3 to 2/3 submerged into the sampled ground height. Use vertex colors or shader ground distance to stain the bottom 20cm of rock near the soil boundary, creating a smooth moisture transition.
|
||||
7. **Oceans and Gerstner waves.** Sum 4 to 8 directional Gerstner trochoidal waves that pull vertices horizontally toward crests, producing sharp peaks and wide flat troughs. Use Beer-Lambert depth absorption (exp(-sigma_a * d)) via scene depth for turquoise to deep navy transitions, Jacobian folding for peak foam, and darken/smooth tidal sand within the shoreline wash.
|
||||
8. **Trees and foliage translucency.** Extrude branch splines using `TubeGeometry` or `ExtrudeGeometry`, conserving cross-sectional area across splits (d_parent^2 = sum d_child^2). Set `Material.doubleSided = true` and add diffuse transmission in custom leaf shaders so backlit canopies glow. Apply quadratic cantilever displacement (delta_p proportional to h^2) for organic wind sway.
|
||||
9. **Procedural skies and IBL synchronization.** Use `PhysicalSkySource` (`lib/src/sky_sources.dart`) with analytic Rayleigh and Mie scattering. Assign `SkyEnvironment` to `Scene.skyEnvironment` or call `EnvironmentMap.fromSky` to bake prefiltered radiance and SH-9 diffuse coefficients into the scene's IBL automatically, and assign the source to `Scene.skybox` for matching background visuals.
|
||||
10. **Islands and coastal erosion.** Multiply radial distance falloff with domain-warped FBM to form organic bays, sandbars, and lagoons. Use analytical surface slopes to strip topsoil on steep cliffs while depositing golden sand and reef shoals on shallow coastal planes.
|
||||
|
||||
## Scattering thousands of copies
|
||||
|
||||
`InstancedMesh` holds one geometry/material pair and a transform per copy. The whole set is one pipeline and one cull test. Place instances by sampling the same terrain height so they sit on the ground.
|
||||
|
||||
```dart
|
||||
final rng = math.Random(7);
|
||||
final scatter = InstancedMesh(
|
||||
geometry: CylinderGeometry(bottomRadius: 0.0, topRadius: 0.15, height: 1.2), // a cone
|
||||
material: PhysicallyBasedMaterial()..baseColorFactor = vm.Vector4(0.2, 0.5, 0.15, 1),
|
||||
);
|
||||
|
||||
for (var i = 0; i < 4000; i++) {
|
||||
final x = rng.nextDouble() * 64;
|
||||
final z = rng.nextDouble() * 64;
|
||||
final y = noise.getNoise2(x, z) * 6.0; // same field as the terrain
|
||||
final transform = vm.Matrix4.translation(vm.Vector3(x, y, z))
|
||||
..rotateY(rng.nextDouble() * math.pi * 2);
|
||||
scatter.addInstance(transform); // the matrix is cloned; mutating it later is safe
|
||||
}
|
||||
|
||||
// InstancedMesh rides on a component, not Node(mesh:).
|
||||
final node = Node()..addComponent(InstancedMeshComponent(scatter));
|
||||
scene.add(node);
|
||||
```
|
||||
|
||||
`addInstance(matrix, {color})` returns an index; edit later with `setInstanceTransform(i, m)` or move the whole batch at once through `updateInstanceTransforms((list) { ... })`. Per-instance `color` is a linear RGBA multiplier. Keep instance edits orientation-preserving, a mirrored (negative-determinant) instance edited with `updateInstanceTransforms(recomputeWinding: false)` renders inside-out.
|
||||
|
||||
## The web noise trap
|
||||
|
||||
The Dart `FastNoiseLite` relies on 32-bit integer math. On the web (dart2js) a Dart `int` is a JavaScript double, exact only to 53 bits, so the hash loses its low bits and 3D noise can overflow, producing wrong values. This is silent, you get a plausible-looking but incorrect field, and only on web.
|
||||
|
||||
For web targets:
|
||||
|
||||
- Prefer the **GLSL side** (`#include <noise.glsl>` in a `.fmat` block), which is correct on every backend including WebGL2 and matches the Dart algorithms table-for-table.
|
||||
- Or **bake** the field once with `bakeNoiseTexture(noise, width: ..., height: ...)` at build time or in a native isolate, then sample the texture. `bakeNoisePixels` is pure CPU with no engine imports, so it runs in a build hook or background isolate.
|
||||
|
||||
Native platforms are unaffected. `noiseHash2`/`noiseHash3` are the bit-exact CPU/GPU-agreeing integer path for decisions that must never disagree (world generation, placement), but they carry the same web-overflow caveat, so make the decision once and share it rather than re-deriving it on both sides.
|
||||
|
||||
## More depth
|
||||
|
||||
`references/procedural.md` has the full `GeometryBuilder` and `MeshData` API (including off-isolate meshing), the complete `FastNoiseLite` config reference, natural rock, ocean, tree, sky, and island formation recipes, the instancing API in full, modular-kit assembly from the built-in primitives, and the web-noise caveat expanded.
|
||||
@@ -0,0 +1,499 @@
|
||||
# Procedural content, the full API
|
||||
|
||||
Everything for building flutter_scene content from code, custom meshes, noise, instancing, and modular kits. All symbols verified against the package source. Import geometry and instancing from the main barrel, noise from its own barrel:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_scene/scene.dart';
|
||||
import 'package:flutter_scene/noise.dart';
|
||||
import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GeometryBuilder
|
||||
|
||||
The incremental way to build a custom triangle mesh. You add vertices one at a time, each carrying whatever attributes are currently set, then reference them by returned index to form triangles.
|
||||
|
||||
```dart
|
||||
class GeometryBuilder {
|
||||
GeometryBuilder({bool deduplicate = true});
|
||||
|
||||
// Sticky attribute setters (return `this`, so cascade or chain them).
|
||||
GeometryBuilder normal(vm.Vector3 value);
|
||||
GeometryBuilder texCoord(vm.Vector2 value);
|
||||
GeometryBuilder texCoord1(vm.Vector2 value); // secondary UV set
|
||||
GeometryBuilder color(vm.Vector4 value); // linear RGBA
|
||||
GeometryBuilder tangent(vm.Vector4 value); // xyz + handedness in w
|
||||
|
||||
int addVertex(vm.Vector3 position); // returns the vertex index
|
||||
GeometryBuilder addTriangle(int a, int b, int c); // throws RangeError on a bad index
|
||||
|
||||
int get vertexCount;
|
||||
int get triangleCount;
|
||||
|
||||
Uint8List packVertices(); // pure, no GPU context needed
|
||||
MeshGeometry build({
|
||||
GeometryStorage storage = GeometryStorage.fixed,
|
||||
GeometryBufferArena? bufferArena,
|
||||
bool retainCpuData = true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### The sticky-attribute model
|
||||
|
||||
The attribute setters do not apply to one vertex, they set state that every following `addVertex` inherits until you change it. This makes flat-shaded faces and per-region colors natural:
|
||||
|
||||
```dart
|
||||
final geometry = (GeometryBuilder()
|
||||
..color(vm.Vector4(1, 0, 0, 1)) // every vertex below is red...
|
||||
..addVertex(vm.Vector3(0, 0, 0))
|
||||
..addVertex(vm.Vector3(1, 0, 0))
|
||||
..color(vm.Vector4(0, 1, 0, 1)) // ...until this changes it to green
|
||||
..addVertex(vm.Vector3(0, 1, 0))
|
||||
..addTriangle(0, 1, 2))
|
||||
.build();
|
||||
```
|
||||
|
||||
### Normals, generated or authored
|
||||
|
||||
If you never call `normal()`, the builder generates area-weighted vertex normals from the faces you wound. That is the right default for most procedural geometry, terrain, extrusions, anything where the surface shape defines the normal.
|
||||
|
||||
**Calling `normal()` even once opts the whole mesh out of generated normals.** After that, any vertex you add without an explicit normal keeps the default `(0, 0, 1)`, which is almost never what you want. So either author a normal for every vertex, or author none and let generation run. Do not mix.
|
||||
|
||||
### Deduplication
|
||||
|
||||
With `deduplicate: true` (the default), `addVertex` merges a vertex equal to one already added and returns the existing index, so a shared grid corner is stored once. Pass `deduplicate: false` when you want every call to produce a distinct vertex (flat shading with per-face normals, or per-vertex data that must not collapse).
|
||||
|
||||
### Winding
|
||||
|
||||
flutter_scene's front faces wind **counter-clockwise in model space**, matching glTF and standard conventions. For a surface that should face +Y (a heightmap, a floor), match the built-in plane's winding: for a cell with corners `v00`(x,z), `v10`(x+1,z), `v01`(x,z+1), `v11`(x+1,z+1), emit `addTriangle(v00, v01, v10)` and `addTriangle(v10, v01, v11)`.
|
||||
|
||||
If a mesh renders inside-out (invisible from the front, visible and inverted-lit from behind), reverse each triangle's index order. Because generated normals follow the winding, fixing the winding fixes the normals too. This freedom is only for geometry you author. Never apply a per-triangle winding flip to an imported model to correct its orientation, that leaves its normals and image-based lighting wrong.
|
||||
|
||||
### From builder to scene
|
||||
|
||||
`build()` returns a `MeshGeometry`, which is a `Geometry`. Wrap it in a `Mesh` with a material, hang the mesh on a `Node`, add the node:
|
||||
|
||||
```dart
|
||||
final node = Node(mesh: Mesh(geometry, PhysicallyBasedMaterial()));
|
||||
scene.add(node);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MeshGeometry.fromArrays, the bulk path
|
||||
|
||||
When you already have attributes as flat arrays (a generator that fills typed lists), skip the per-vertex calls and hand `MeshGeometry.fromArrays` structure-of-arrays data directly. Same result, less overhead for large meshes.
|
||||
|
||||
```dart
|
||||
MeshGeometry.fromArrays({
|
||||
required Float32List positions, // 3 floats/vertex, required
|
||||
Float32List? normals, // 3/vertex; omitted -> generated for triangle lists
|
||||
Float32List? texCoords, // 2/vertex; omitted -> (0, 0)
|
||||
Float32List? texCoords1, // 2/vertex
|
||||
Float32List? colors, // 4/vertex; omitted -> opaque white
|
||||
Float32List? tangents, // 4/vertex
|
||||
List<int>? indices, // omitted -> vertex count must be a multiple of 3
|
||||
gpu.PrimitiveType primitiveType = gpu.PrimitiveType.triangle,
|
||||
Aabb3? bounds, // skips the position scan; MUST cover every vertex
|
||||
GeometryStorage storage = GeometryStorage.fixed,
|
||||
GeometryBufferArena? bufferArena,
|
||||
bool retainCpuData = true,
|
||||
});
|
||||
```
|
||||
|
||||
Notes that bite:
|
||||
|
||||
- Every supplied optional array must match the vertex count implied by `positions`.
|
||||
- Out-of-range `indices` are **not** validated here (unlike `GeometryBuilder.addTriangle`), and produce stray triangles or holes silently. Keep every index in `0..vertexCount-1`.
|
||||
- A `bounds` you pass that does not enclose every vertex makes the mesh over-cull and pop out of view at some angles. Omit it (the constructor scans positions) unless you computed it correctly off-thread.
|
||||
- `retainCpuData: false` drops the CPU copy after upload, saving memory, but then the mesh cannot be raycast or read back with `extractMeshData`.
|
||||
|
||||
### Updatable geometry (animated meshes)
|
||||
|
||||
Pass `storage: GeometryStorage.updatable` to get a mesh you can mutate in place each frame without reallocating. The in-place updaters replace one attribute when the vertex count is unchanged:
|
||||
|
||||
```dart
|
||||
final water = MeshGeometry.fromArrays(positions: p, storage: GeometryStorage.updatable);
|
||||
// later, per frame:
|
||||
water.updatePositions(newPositions); // also updateNormals/TexCoords/Colors/Tangents
|
||||
// or replace everything (may change the count):
|
||||
water.rebuild(positions: p2, indices: i2);
|
||||
```
|
||||
|
||||
An updatable mesh fixes its indexed-or-not state at construction, if you built it with `indices`, `rebuild` requires them thereafter, and vice versa. To start empty and fill later, pass a zero-length `positions` with `updatable`. Updatable geometry must retain CPU data and cannot use a buffer arena.
|
||||
|
||||
`GeometryBufferArena({int blockSizeInBytes = 16 * 1024 * 1024})` lets many fixed meshes share immutable GPU buffer blocks, worth it when you build a large number of small static meshes.
|
||||
|
||||
---
|
||||
|
||||
## MeshData, meshing off the render isolate
|
||||
|
||||
Heavy generation (remeshing a voxel chunk, a large marching-cubes surface) should not block the render isolate. `MeshData` is a pure, isolate-transferable snapshot, build it on a background isolate with `compute`, send it back, upload it there.
|
||||
|
||||
```dart
|
||||
factory MeshData.build({
|
||||
required Float32List positions,
|
||||
Float32List? normals, // omitted -> generated for triangle lists (the win here)
|
||||
Float32List? texCoords,
|
||||
Float32List? texCoords1,
|
||||
Float32List? colors,
|
||||
Float32List? tangents,
|
||||
List<int>? indices,
|
||||
gpu.PrimitiveType primitiveType = gpu.PrimitiveType.triangle,
|
||||
Map<String, MeshAttributeData> customAttributes = const {},
|
||||
});
|
||||
```
|
||||
|
||||
Recipe:
|
||||
|
||||
```dart
|
||||
// Top-level or static, runs on the background isolate.
|
||||
MeshData buildChunk(ChunkInput input) {
|
||||
final positions = /* your generator */;
|
||||
final indices = /* ... */;
|
||||
return MeshData.build(positions: positions, indices: indices);
|
||||
}
|
||||
|
||||
// On the render isolate:
|
||||
final data = await compute(buildChunk, input);
|
||||
final geometry = MeshGeometry.fromMeshData(data);
|
||||
// or, to feed an existing updatable mesh in place:
|
||||
existing.applyMeshData(data);
|
||||
```
|
||||
|
||||
The normal generation is the expensive part, and running it inside `MeshData.build` is exactly the work you moved off the render isolate.
|
||||
|
||||
Pure derivations on a `MeshData` (all off-isolate safe): `transformed(Matrix4)` (moves positions, carries normals by the inverse transpose so a non-uniform scale stays correct, reverses winding on a mirror), `unweld({attributes})`, `extractEdges({creaseAngleDegrees})`, static `MeshData.merge(parts)`, plus `triangleCount`/`triangles`. `Geometry.extractMeshData()` reads a loaded mesh back into one.
|
||||
|
||||
---
|
||||
|
||||
## FastNoiseLite
|
||||
|
||||
One configurable object evaluating several noise algorithms, sampled with `getNoise2`/`getNoise3`. Output is roughly in `[-1, 1]`.
|
||||
|
||||
```dart
|
||||
final noise = FastNoiseLite(seed: 1337)
|
||||
..frequency = 0.01 // coords are multiplied by this before eval
|
||||
..noiseType = NoiseType.openSimplex2
|
||||
..fractalType = FractalType.fbm
|
||||
..octaves = 5;
|
||||
|
||||
final h = noise.getNoise2(x, z); // 2D
|
||||
final d = noise.getNoise3(x, y, z); // 3D
|
||||
```
|
||||
|
||||
### Config reference
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `seed` | 1337 | Seed for every noise type. |
|
||||
| `frequency` | 0.01 | Input coordinates are scaled by this. Bigger = finer features. |
|
||||
| `noiseType` | `openSimplex2` | Base algorithm (see below). |
|
||||
| `fractalType` | `none` | How octaves layer (see below). |
|
||||
| `octaves` | 3 | Number of fractal layers. More detail, more cost. |
|
||||
| `lacunarity` | 2.0 | Frequency multiplier between octaves. |
|
||||
| `gain` | 0.5 | Amplitude multiplier between octaves. |
|
||||
| `weightedStrength` | 0.0 | Biases octave amplitude toward stronger detail. |
|
||||
| `pingPongStrength` | 2.0 | Warp strength for `FractalType.pingPong`. |
|
||||
| `cellularDistanceFunction` | `euclideanSq` | Distance metric for `NoiseType.cellular`. |
|
||||
| `cellularReturnType` | `distance` | What cellular returns. |
|
||||
| `cellularJitterModifier` | 1.0 | Cell-point jitter; above 1 causes artifacts. |
|
||||
| `domainWarpType` | `openSimplex2` | Warp algorithm for `domainWarp2`/`domainWarp3`. |
|
||||
| `domainWarpAmp` | 1.0 | Max warp distance. |
|
||||
| `domainWarpFractalType` | `none` | Octave layering for domain warp. |
|
||||
|
||||
Enums:
|
||||
|
||||
- `NoiseType` = `openSimplex2` | `openSimplex2S` | `cellular` | `perlin` | `value`.
|
||||
- `FractalType` = `none` | `fbm` (classic layered fractal, the usual terrain choice) | `ridged` (sharp ridges, mountains) | `pingPong`.
|
||||
- `CellularDistanceFunction` = `euclidean` | `euclideanSq` | `manhattan` | `hybrid`.
|
||||
- `CellularReturnType` = `cellValue` | `distance` | `distance2` | `distance2Add` | `distance2Sub` | `distance2Mul` | `distance2Div`.
|
||||
- `DomainWarpType` = `openSimplex2` | `openSimplex2Reduced` | `basicGrid`.
|
||||
- `DomainWarpFractalType` = `none` | `progressive` | `independent`.
|
||||
|
||||
### Domain warp
|
||||
|
||||
`domainWarp2`/`domainWarp3` distort the input coordinates before sampling, breaking up the regular look of raw fractal noise. The reference version mutates in place, this port returns the warped position for you to feed back in:
|
||||
|
||||
```dart
|
||||
final w = noise.domainWarp2(x, z); // ({double x, double y})
|
||||
final v = noise.getNoise2(w.x, w.y);
|
||||
```
|
||||
|
||||
### Curl noise
|
||||
|
||||
`noiseCurl3(x, y, z, {int seed = 1337, double epsilon = 0.25})` returns a divergence-free 3D vector `({x, y, z})` from a seeded potential field, for advecting particles so they swirl without clumping. Coordinates are taken pre-scaled (no frequency parameter), matching the GLSL `NoiseCurl3`. Advect by adding `curl * speed * dt`. A smaller `epsilon` sharpens the field and amplifies CPU/GPU divergence.
|
||||
|
||||
### Baking noise to a texture
|
||||
|
||||
Sampling many octaves per fragment is expensive. When the field is static, bake it once and sample the texture instead:
|
||||
|
||||
```dart
|
||||
Texture2D bakeNoiseTexture(
|
||||
FastNoiseLite noise, {
|
||||
required int width,
|
||||
required int height,
|
||||
double originX = 0.0,
|
||||
double originY = 0.0,
|
||||
double cellSize = 1.0,
|
||||
TextureSampling sampling = const TextureSampling(),
|
||||
});
|
||||
```
|
||||
|
||||
It bakes `getNoise2` over a `width` x `height` grid into a grayscale `Texture2D` (content is linear `data`, so mipmaps average cleanly) ready to bind as a material sampler. It must run where GPU resources are created (the raster thread). The CPU half, `bakeNoisePixels(noise, {width, height, originX, originY, cellSize})`, returns `Uint8List` RGBA and has no engine imports, so it runs in a build hook or a background isolate, then `Texture2D.fromPixels` uploads the result.
|
||||
|
||||
---
|
||||
|
||||
### Natural formations: rocks, cliffs, trails, and scatter recipes
|
||||
|
||||
Composing raw noise into realistic natural terrain and geology requires specific math patterns to avoid telltale procedural artifacts.
|
||||
|
||||
### 1. Free-end Worley rock cracks (avoiding closed cell loops)
|
||||
|
||||
Standard cellular Worley distance (`F2 - F1`) creates a continuous polygon network like bathroom tile or dry mud. To create natural weathering cracks with free ends, mask the cell borders with a low-frequency macro patch and a high-frequency grain breaker:
|
||||
|
||||
```glsl
|
||||
// GLSL shader bake or .fmat surface
|
||||
// Cellular Worley noise returning F2 - F1 distance
|
||||
float cwl = NoiseCellular2(p * 3.0, 1337, kNoiseCellularEuclidean, kNoiseCellularDistance2Sub, 1.0);
|
||||
float net = smoothstep(0.08, -0.80, cwl);
|
||||
float region = smoothstep(-0.2, 0.4, NoiseFbm2(p * 1.0, 1338, 3, 2.0, 0.5));
|
||||
float breaker = smoothstep(-0.4, 0.2, NoiseFbm2(p * 6.0, 1339, 2, 2.0, 0.5));
|
||||
float crack = net * region * breaker; // produces isolated segments with natural start/end points
|
||||
```
|
||||
|
||||
### 2. Noise-modulated pitting (avoiding regular dot lattices)
|
||||
|
||||
Thresholding Worley noise at a constant radius places a pit in every single cell, creating an artificial grid lattice. Modulate the threshold radius with an underlying Perlin field so pores vary in size and only appear in exposed weathering pockets:
|
||||
|
||||
```glsl
|
||||
float sizeVar = (NoiseFbm2(p * 2.5, 1337, 3, 2.0, 0.5) + 1.0) * 0.5;
|
||||
float pw = NoiseCellular2(p * 5.0, 1338, kNoiseCellularEuclidean, kNoiseCellularDistance, 1.0);
|
||||
float pit = smoothstep(0.05 + 0.24 * sizeVar * sizeVar, 0.005, pw)
|
||||
* smoothstep(-0.1, 0.5, NoiseFbm2(p * 1.5, 1339, 3, 2.0, 0.5));
|
||||
```
|
||||
|
||||
### 3. Incised trail heightfields (scours vs flat stripes)
|
||||
|
||||
Footpaths are formed by water and foot traffic compressing and eroding soil downwards. Sample the path polyline once (`trail.sample(n, evenlySpaced: true)`) and compute the minimum point-to-segment distance to cut the path profile into the terrain heightfield with raised spoil banks:
|
||||
|
||||
```dart
|
||||
double computeTerrainHeight(double x, double z, FastNoiseLite noise, List<vm.Vector3> trailPoints) {
|
||||
final baseHeight = noise.getNoise2(x, z) * 8.0;
|
||||
|
||||
// Find minimum distance from (x, z) to the sampled 2D path segments
|
||||
var minDist = double.infinity;
|
||||
final p = vm.Vector2(x, z);
|
||||
for (var i = 0; i < trailPoints.length - 1; i++) {
|
||||
final a = vm.Vector2(trailPoints[i].x, trailPoints[i].z);
|
||||
final b = vm.Vector2(trailPoints[i + 1].x, trailPoints[i + 1].z);
|
||||
final ab = b - a;
|
||||
final t = ((p - a).dot(ab) / ab.length2).clamp(0.0, 1.0);
|
||||
final dist = (p - (a + ab * t)).length;
|
||||
if (dist < minDist) minDist = dist;
|
||||
}
|
||||
|
||||
const pathWidth = 1.8;
|
||||
const pathDepth = 0.45;
|
||||
const bermHeight = 0.25;
|
||||
|
||||
// Carve central path trough
|
||||
final trench = (1.0 - (minDist / pathWidth).clamp(0.0, 1.0)) * pathDepth;
|
||||
// Build gentle spoil berm along the verge
|
||||
final verge = ((minDist - pathWidth * 0.8) / (pathWidth * 0.8)).clamp(0.0, 1.0);
|
||||
final berm = math.sin(verge * math.pi) * bermHeight;
|
||||
|
||||
return baseHeight - trench + berm;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Macro-massed pebble scatter (avoiding uniform sandpaper noise)
|
||||
|
||||
Gravel and pebbles cluster into water-washed scour lines rather than spreading evenly over an entire level. Gate multi-scale pebble instances with a low-frequency macro massing field and key the hash off integer cell coordinates:
|
||||
|
||||
```dart
|
||||
void scatterPebbles(InstancedMesh finePebbles, InstancedMesh largeStones, FastNoiseLite terrainNoise) {
|
||||
final macroNoise = FastNoiseLite(seed: 42)..frequency = 0.05;
|
||||
const step = 0.8;
|
||||
const cells = 80; // 64m / 0.8m
|
||||
for (var ix = 0; ix < cells; ix++) {
|
||||
for (var iz = 0; iz < cells; iz++) {
|
||||
final x = ix * step;
|
||||
final z = iz * step;
|
||||
// Deterministic coordinate jitter keyed off integer cell indices
|
||||
final h = noiseHash2(1337, ix, iz);
|
||||
final jx = x + ((h & 0xFF) / 255.0 - 0.5) * 0.6;
|
||||
final jz = z + (((h >> 8) & 0xFF) / 255.0 - 0.5) * 0.6;
|
||||
|
||||
final mass = (macroNoise.getNoise2(jx, jz) + 1.0) * 0.5;
|
||||
if (mass > 0.65) {
|
||||
final y = terrainNoise.getNoise2(jx, jz) * 8.0;
|
||||
final matrix = vm.Matrix4.translation(vm.Vector3(jx, y, jz));
|
||||
if (((h >> 16) & 0xFF) > 180) {
|
||||
largeStones.addInstance(matrix);
|
||||
} else {
|
||||
finePebbles.addInstance(matrix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Oceans and Gerstner waves
|
||||
|
||||
Trochoidal Gerstner waves pull vertices horizontally toward wave peaks, creating sharp crests and wide flat troughs. Sum multiple directional waves and compute normals analytically:
|
||||
|
||||
```glsl
|
||||
// GLSL Gerstner wave displacement
|
||||
struct Wave { vec2 dir; float amp; float freq; float speed; float steepness; };
|
||||
|
||||
// Caller seeds accumulators with tangent = vec3(1.0, 0.0, 0.0) and binormal = vec3(0.0, 0.0, 1.0).
|
||||
vec3 evaluateGerstner(vec2 pos, float time, Wave w, float numWaves, inout vec3 tangent, inout vec3 binormal) {
|
||||
vec2 d = normalize(w.dir);
|
||||
float phase = dot(d, pos) * w.freq + time * w.speed;
|
||||
float c = cos(phase);
|
||||
float s = sin(phase);
|
||||
float q = w.steepness / (w.amp * w.freq * numWaves);
|
||||
|
||||
tangent += vec3(-q * d.x * d.x * w.amp * w.freq * s,
|
||||
d.x * w.amp * w.freq * c,
|
||||
-q * d.x * d.y * w.amp * w.freq * s);
|
||||
binormal += vec3(-q * d.x * d.y * w.amp * w.freq * s,
|
||||
d.y * w.amp * w.freq * c,
|
||||
-q * d.y * d.y * w.amp * w.freq * s);
|
||||
|
||||
return vec3(q * w.amp * d.x * c,
|
||||
w.amp * s,
|
||||
q * w.amp * d.y * c);
|
||||
}
|
||||
```
|
||||
|
||||
For shallow water transitions and shorelines:
|
||||
- **Beer-Lambert Depth Extinction**: Declare `engine_inputs: [ depth ]` in the `.fmat` to sample linear opaque scene depth (`RenderInput.depth`). Compute water depth `d = sceneDepth - surfaceDepth` and attenuate color with `C = C_deep + (C_shallow - C_deep) * exp(-sigma_a * d)`.
|
||||
- **Tidal Wet Sand**: Reduce sand roughness to 0.15 and multiply albedo by 0.6 within the wave wash zone to produce glistening wet shorelines.
|
||||
|
||||
### 6. Trees, branching splines, and backlit foliage
|
||||
|
||||
Trunk and branch structures follow Leonardo da Vinci's rule: total cross-sectional area is conserved across splits (d_parent^2 = sum d_child^2). Extrude branches along swept spline tubes using `TubeGeometry` (sweeping a round cross-section along a `ScenePath`) or `ExtrudeGeometry`:
|
||||
|
||||
- **Backlit Leaf Translucency**: Set `Material.doubleSided = true` for two-sided rendering. In a custom leaf shader, add a diffuse transmission term so backlit foliage glows rather than rendering as a dark silhouette:
|
||||
```glsl
|
||||
// In custom leaf shader
|
||||
float NdotL = dot(normal, lightDir);
|
||||
float backLight = max(0.0, -NdotL) * leafTransmissionFactor;
|
||||
vec3 litColor = albedo * (max(0.0, NdotL) + backLight * leafTranslucentColor);
|
||||
```
|
||||
- **Quadratic Cantilever Wind**: Displace leaf and branch vertices in world space proportional to height squared (delta_p = windVec * (h / h_max)^2 * sin(omega * t - k * p)) so tips sway vigorously while roots remain anchored.
|
||||
|
||||
### 7. Procedural skies and runtime IBL synchronization
|
||||
|
||||
Use `PhysicalSkySource` (`lib/src/sky_sources.dart`) with analytic Rayleigh and Mie scattering. Assign `SkyEnvironment` to `Scene.skyEnvironment` or call `EnvironmentMap.fromSky` to bake prefiltered radiance and SH-9 diffuse coefficients into the scene's IBL automatically, and assign the source to `Scene.skybox` for matching background visuals.
|
||||
|
||||
### 8. Islands, coastal bays, and sand dunes
|
||||
|
||||
To form natural island topographies:
|
||||
- **Domain-Warped Island Mask**: Multiply a radial distance falloff (1.0 - (r / R)^2) with domain-warped FBM to form organic bays, sandbars, and peninsulas rather than symmetrical circular cones.
|
||||
- **Slope-Based Sediment Stripping**: Compute heightfield slope sqrt((dh/dx)^2 + (dh/dz)^2). Steep cliffs strip topsoil to expose rock strata, while gentle coastal planes accumulate golden beach sand.
|
||||
- **Anisotropic Wind Dune Ripples**: Layer 8:1 anisotropically stretched noise perpendicular to the prevailing wind direction to generate fine ripple crests across sand surfaces.
|
||||
|
||||
---
|
||||
|
||||
## The web noise caveat, expanded
|
||||
|
||||
The Dart `FastNoiseLite` port relies on 32-bit integer arithmetic. On native platforms this is exact. On the web (dart2js), a Dart `int` is a JavaScript double, exact only to 53 bits, so the integer hash loses its low bits and 3D noise can overflow. The result is a plausible-looking but wrong field, silent, and web-only. A web-safe integer multiply for the Dart side is a planned follow-up.
|
||||
|
||||
The GLSL half of the module is unaffected, it is correct on every backend including WebGL2, and implements the same algorithms with the same tables and seeds, so a field sampled on the CPU (native) and evaluated in a shader agree. The agreement has two tiers:
|
||||
|
||||
- **Bit-exact**: `noiseHash2`/`noiseHash3` (and GLSL `NoiseHash2`/`NoiseHash3`) are pure integer math and match bit for bit across backends. Use them for decisions that must never disagree between machines (world generation, deterministic placement).
|
||||
- **Float-close**: the float noise functions match within a small tolerance (float32 rounding differs per GPU), imperceptible visually. Do not re-derive a hard threshold from float noise on both the CPU and GPU sides, make the decision once and share the result.
|
||||
|
||||
Both tiers carry the web-overflow caveat on the Dart side. Strategy by target:
|
||||
|
||||
- **Native only**: use the Dart `FastNoiseLite` freely, on the render isolate or a background one.
|
||||
- **Web, per-fragment noise**: move it to the GLSL side (`#include <noise.glsl>` in a `.fmat` block).
|
||||
- **Web, a static field**: bake it with `bakeNoiseTexture` (or `bakeNoisePixels` in a build hook / native isolate) and sample the texture. This sidesteps the overflow because the baking happens where `int` is 64-bit.
|
||||
|
||||
---
|
||||
|
||||
## InstancedMesh, thousands of copies for one draw
|
||||
|
||||
One geometry/material pair drawn many times, each placed by its own model transform. The whole set is one render item, one pipeline, one cull test. This is how you scatter foliage, crowds, debris, or a grid of the same prop without a node per copy.
|
||||
|
||||
```dart
|
||||
class InstancedMesh {
|
||||
InstancedMesh({
|
||||
required Geometry geometry,
|
||||
required Material material,
|
||||
bool cullInstances = false, // per-instance cull after the aggregate pass
|
||||
bool sortTransparentInstances = true,
|
||||
});
|
||||
|
||||
int get instanceCount;
|
||||
|
||||
int addInstance(vm.Matrix4 transform, {vm.Vector4? color}); // matrix is CLONED; returns index
|
||||
void setInstanceTransform(int index, vm.Matrix4 transform);
|
||||
void updateInstanceTransforms(
|
||||
void Function(List<vm.Matrix4> transforms) update, {
|
||||
bool recomputeWinding = true,
|
||||
});
|
||||
void setInstanceColor(int index, vm.Vector4 color); // linear RGBA multiplier
|
||||
void removeInstanceAt(int index); // shifts later indices down
|
||||
void clearInstances();
|
||||
}
|
||||
```
|
||||
|
||||
Attach it to a node with an `InstancedMeshComponent` (it does not go on `Node(mesh:)`):
|
||||
|
||||
```dart
|
||||
final mesh = InstancedMesh(geometry: geo, material: mat);
|
||||
for (final placement in placements) {
|
||||
mesh.addInstance(placement); // a Matrix4 in the instanced mesh's local space
|
||||
}
|
||||
final node = Node()..addComponent(InstancedMeshComponent(mesh));
|
||||
scene.add(node);
|
||||
```
|
||||
|
||||
Practical notes:
|
||||
|
||||
- `addInstance` clones the matrix, so reusing one scratch `Matrix4` across the loop is fine.
|
||||
- The node the component is on transforms the entire batch. Instance transforms compose under it.
|
||||
- To animate all instances cheaply, use `updateInstanceTransforms`, which invalidates the batch once instead of per call. Mutate the matrices in the callback list; do not add, remove, or replace entries.
|
||||
- `updateInstanceTransforms(recomputeWinding: false)` skips the parity refresh. Only pass it when no edit changes a transform's winding. A mirrored (negative-determinant) edit under it renders those instances inside-out.
|
||||
- `cullInstances: true` pays for per-instance culling, worth it for a large spatial spread whose instances enter view at different times; leave it off for a small compact clump that the single aggregate cull already handles.
|
||||
- Set `cullInstances` per instanced mesh based on that trade; it is not a global.
|
||||
|
||||
---
|
||||
|
||||
## Modular kits from the built-in primitives
|
||||
|
||||
Before authoring a mesh, remember the ten primitives assemble a surprising amount by composition, no builder needed. Each is a `Geometry`, so each goes on its own `Node`, and a parent node groups a kit piece you can clone and place.
|
||||
|
||||
| Class | Constructor | Notes |
|
||||
| --- | --- | --- |
|
||||
| `CuboidGeometry` | `CuboidGeometry(vm.Vector3 extents)` | Box from `-extents/2` to `+extents/2`. Positional. |
|
||||
| `SphereGeometry` | `SphereGeometry({radius = 0.5, segments = 32, rings = 16})` | UV sphere. |
|
||||
| `IcosphereGeometry` | `IcosphereGeometry({radius = 0.5, subdivisions = 2})` | Even triangle distribution. |
|
||||
| `CylinderGeometry` | `CylinderGeometry({bottomRadius = 0.5, topRadius = 0.5, height = 1.0, ...})` | `topRadius: 0` makes a cone; different radii make a frustum. |
|
||||
| `CapsuleGeometry` | `CapsuleGeometry({radius = 0.5, height = 1.0, ...})` | `height` is the mid-section; total Y is `height + 2*radius`. |
|
||||
| `TorusGeometry` | `TorusGeometry({radius = 0.5, tubeRadius = 0.2, ...})` | Lies in XZ. |
|
||||
| `PlaneGeometry` | `PlaneGeometry({width = 1.0, depth = 1.0, segmentsX = 1, segmentsZ = 1})` | XZ plane, faces +Y. |
|
||||
| `DiscGeometry` | `DiscGeometry({radius = 0.5, segments = 32})` | Filled circle, XZ, faces +Y. |
|
||||
| `RingGeometry` | `RingGeometry({innerRadius = 0.25, outerRadius = 0.5, segments = 32})` | Annulus, XZ, +Y. |
|
||||
| `WedgeGeometry` | `WedgeGeometry(vm.Vector3 size)` | Triangular prism; base on `y = 0` (not Y-centered). |
|
||||
|
||||
Because a cone is just `CylinderGeometry(topRadius: 0)`, a tree is a green cone on a brown cylinder, a fence is repeated thin cuboids, a table is a plane on four cylinders. Assemble each piece as a parented `Node` subtree, then `clone()` and place it, or feed the placements to an `InstancedMesh` when the same piece repeats many times.
|
||||
|
||||
Every primitive except `PlaneGeometry` exposes a `Shape get collisionShape` for the physics package, so a code-built kit gets colliders for free.
|
||||
|
||||
### Swept geometry for shapes primitives cannot make
|
||||
|
||||
For paths, tubes, and profiles, sweep a `ScenePath` (`BezierPath`, `CatmullRomPath`, `PolylinePath`):
|
||||
|
||||
- `TubeGeometry(path, {radius = 0.5, radialSegments = 12, stations = 64, caps = true})` for pipes, cables, vines.
|
||||
- `ExtrudeGeometry(path, {required List<vm.Vector2> profile, stations = 64, caps = true})` sweeps a 2D profile along the path (railings, moldings, extruded logos).
|
||||
- `RibbonGeometry(path, {width = 1.0, stations = 64, alignment = RibbonAlignment.ground})` for flat strips (roads, trails).
|
||||
|
||||
These build detailed shapes from a curve and a few parameters, often replacing an imported model outright.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: flutter_scene-verification-loop
|
||||
version: 2
|
||||
description: Close the visual-iteration loop when building or debugging a flutter_scene 3D app so you see your own output and self-correct. Use whenever a change affects what renders (geometry, materials, lighting, shaders, post-processing) or a frame looks wrong (black, washed-out, see-through, missing geometry).
|
||||
---
|
||||
|
||||
# Verifying flutter_scene visually
|
||||
|
||||
flutter_scene renders 3D. A rendering change you cannot see is a guess, and guessing at pixels is the single biggest waste of iterations. The highest-value habit is a closed visual loop plus judgment that does not drift. This skill is that loop.
|
||||
|
||||
**The one thing to internalize: run it, let it settle, look at the frame AND the console, localize before you edit, repeat.** Do not change code off a hypothesis you did not confirm from the actual output.
|
||||
|
||||
## The loop
|
||||
|
||||
1. **Run.** Launch the app with `flutter run --enable-flutter-gpu` (native; the flag is mandatory and it is the only run flag, see the idioms skill). Where the editor MCP is connected, `run_project` launches the managed session instead.
|
||||
2. **Settle.** A live frame is a moving target. Auto-exposure is still ramping, particles have a random phase, an animation is mid-clip, IBL re-bakes after the first present. Let the scene reach steady state (a few frames, or until the image stops changing) before you trust a capture. Do not screenshot the first frame and reason from it.
|
||||
3. **Capture the frame AND read the console.** A screenshot alone hides errors that print; the console alone hides wrong pixels. Take both every time. Baseline path is a screenshot plus the run log; with the editor MCP it is `screenshot_viewport` plus `get_console`.
|
||||
4. **Localize before editing.** When something is wrong, find where it goes wrong before you touch code. Read an intermediate buffer, read a single pixel's exact value, or scan for non-finite values. A NaN or Inf propagates silently into black or garbage downstream, so the first pass that produced it is the culprit, not the pass where you see the black. `references/loop.md` has the tool table and a symptom to action map.
|
||||
5. **Correct, repeat.** Make one change, run the loop again. One change per iteration keeps cause and effect legible.
|
||||
|
||||
## The readiness gate (do not debug through it)
|
||||
|
||||
Rendering is gated on `Scene.initializeStaticResources()`. Until that Future completes, every frame is skipped and the engine prints exactly:
|
||||
|
||||
```
|
||||
Flutter Scene is not ready to render. Skipping frame.
|
||||
```
|
||||
|
||||
If you see that line, the scene is not broken, it is not ready. Wait for readiness (build geometry and materials inside `initializeStaticResources().then(...)`, gate the widget on `Scene.isReadyToRender`) before you diagnose anything else. A black frame while that line prints is the gate, not your code.
|
||||
|
||||
## Judge blind, never self-score (the load-bearing rule)
|
||||
|
||||
When deciding whether a change improved the look, **do not assign the frame a quality score.** Self-assigned scores drift upward, because the model is grading its own trajectory and wants to have made progress. That drift is how a session convinces itself a regression is an improvement.
|
||||
|
||||
Instead, **compare two frames and return a binary pick.** Put the new frame next to a reference (a known-good target) or the previous frame, and answer only "which of these two is better", A or B. No number, no "8/10", no "looks pretty good now". A blind pairwise pick does not inflate the way a solo score does. This applies to every visual review, including the ones that feel obvious.
|
||||
|
||||
If you have no reference at all, say so and describe the concrete difference between the two frames (this one is brighter here, that one has an artifact there) rather than inventing a score.
|
||||
|
||||
## Empirical verification rules
|
||||
|
||||
These rules prevent false diagnoses, hollow passes, and measurement traps during visual iteration:
|
||||
|
||||
1. **Liveness before ablation.** A negative result is evidence only if the removed or modified term was actually live in the draw pass. Diff pixels for liveness before trusting an ablation.
|
||||
2. **Tools must fail loud on empty measurements.** A tool that measures an empty population, zero pixels, or non-finite data must fail loud rather than returning a default passing number.
|
||||
3. **Population discipline.** Always quote the population window, crop rectangle, brightness threshold, and rendering resolution beside any color or lighting figure.
|
||||
4. **Baselines expire quickly.** Two captures taken hours apart in a changing tree reflect multiple edits; isolate paired A/B captures with temporary snapshots or git worktrees.
|
||||
5. **Look at the raw frame before quoting numbers.** Inspect the actual captured frame before taking numbers off it; metrics can yield valid-looking numbers on corrupt frames.
|
||||
6. **Negative control requirement.** A metric that returns the same score for positive and negative control populations cannot serve as evidence for either.
|
||||
7. **Explanations are hypotheses, not evidence.** Before adopting an explanatory mechanism, identify the specific numerical observation that would differ if the mechanism were false.
|
||||
8. **Attribution must reach the triangle.** When diagnosing geometry defects, trace down to the specific triangle indices and edge lengths rather than stopping at the mesh component.
|
||||
9. **Symmetric domain clamping.** Clamping parametric lookup domains must be verified at both boundaries, and derived slope or heading accessors must clamp both sample points.
|
||||
10. **Multi-band octave tables for tiling.** Two-band ratios like `hf/lf` are blind to regular mid-frequency patterns; inspect multi-octave energy tables to detect periodic tiling.
|
||||
11. **Physical discrimination over naming.** A class-discriminating physical observation (such as missing shadow terminators) outranks code comments or variable names.
|
||||
12. **Grazing light terminator crossing.** Under low-angle grazing lighting, prioritize terminator-crossing fractions over slope RMS to detect normal map over-amplification.
|
||||
13. **Resolution scaling.** High-frequency energy (`hf/lf`) and relative contrast scale with resolution; compare them only at equal pixel resolutions.
|
||||
14. **Paired capture isolation.** Snapshot source files or freeze environment state when capturing before/after pairs so background modifications cannot corrupt the comparison.
|
||||
|
||||
## Be honest about tooling
|
||||
|
||||
The richest observation tooling lives behind the editor MCP (`flutter_scene_mcp`): screenshots, console, NaN scans, render-graph capture, per-pass and per-pixel readback, viewport debug modes. A general-purpose observation server for an arbitrary running game is still being built, so do not assume those tools exist for every project. When the MCP is not connected, the baseline loop is still fully usable: `flutter run --enable-flutter-gpu`, read the console, take a screenshot. Do not claim a capability you cannot reach in the current project.
|
||||
|
||||
## More depth
|
||||
|
||||
- `references/loop.md` for the full editor-MCP tool table, the symptom to action map (black frame, washed-out, see-through, missing geometry), and the settle details.
|
||||
- The `flutter_scene-idioms` skill (`references/traps.md`) for the underlying mistakes each symptom points back to (wrong vertex layout, hand-rolled winding flip, transform-in-place, the blank-frame causes).
|
||||
@@ -0,0 +1,145 @@
|
||||
# The verification loop in detail
|
||||
|
||||
The core loop, the readiness gate, and the blind-judgment rule are in `SKILL.md`. This file has the
|
||||
tool table (what exists where), the settle details, and the symptom to action map.
|
||||
|
||||
---
|
||||
|
||||
## Two tooling tiers
|
||||
|
||||
### Baseline (any project, no MCP)
|
||||
|
||||
This always works and needs nothing installed beyond the package setup.
|
||||
|
||||
- Launch: `flutter run --enable-flutter-gpu` (native; add `-d chrome` for web). The flag is mandatory.
|
||||
- Console: read the run log. The readiness line, `debugPrint` output, asserts, and the 0.22.0
|
||||
blank-frame diagnostic all land here.
|
||||
- Frame: take a screenshot of the running app after it settles.
|
||||
|
||||
That is the whole loop when there is no editor. Run, settle, screenshot, read the log, correct.
|
||||
|
||||
### Editor MCP (`flutter_scene_mcp`, when connected)
|
||||
|
||||
The editor exposes richer observation. Tool names below are exact. Do not assume they exist unless
|
||||
the MCP is actually connected for the current project.
|
||||
|
||||
| Tool | What it does | Reach for it when |
|
||||
| --- | --- | --- |
|
||||
| `run_project` | Launch the editor-managed Play session (a managed `flutter run`). | Starting a session under the editor. |
|
||||
| `build_project` | Start the selected build config; output streams to the console. | You want a build without launching. |
|
||||
| `stop_project` | Stop the running session. | Ending or restarting cleanly. |
|
||||
| `hot_reload` | Hot reload the running debug session. | A Dart-only change, fastest turnaround. |
|
||||
| `hot_restart` | Hot restart the session. | State or startup changed, or reload did not take. |
|
||||
| `get_console` | The build/run console tail plus building/running flags. | EVERY iteration, paired with a screenshot. |
|
||||
| `screenshot_viewport` | The viewport as a PNG, what the user sees. | EVERY iteration, paired with the console. |
|
||||
| `describe_scene` | The scene-graph tree (ids, paths, names, component types). | Confirming a node/mesh is actually in the scene. |
|
||||
| `scan_for_nans` | Capture a frame and scan every float render target for NaN/Inf in pass order. | A black or garbage frame with no error. Find where non-finite values start. |
|
||||
| `capture_render_graph` | Capture the next frame's graph with thumbnails. | You need to see intermediate buffers. |
|
||||
| `list_render_passes` | The executed passes in order with CPU timings and the buffer keys each read/wrote, plus target formats and sizes. No images. | Learning which pass owns which buffer, and the key names to read. |
|
||||
| `get_pass_output` | Render one captured buffer (a key like `scene_color`, `linear_depth`) as a PNG. NaN paints magenta, Inf yellow, negative blue. | Eyeballing an intermediate buffer to see which stage broke. |
|
||||
| `read_pass_pixel` | One pixel's exact float RGBA from a captured buffer, with NaN/Inf flags. | Confirming an exact value (is this really 0, or NaN, or negative). |
|
||||
| `list_viewport_debug_modes` | The available debug outputs (final, HDR color, linear depth, normals, AO, shadow atlas, ...) and which is active. | Seeing what debug views exist. |
|
||||
| `set_viewport_debug_mode` | Render one debug output full-viewport. Set `final` to restore. | Inspecting depth/normals/AO live, paired with `screenshot_viewport`. |
|
||||
|
||||
Render-graph capture (`capture_render_graph`, `list_render_passes`, `get_pass_output`,
|
||||
`read_pass_pixel`, `scan_for_nans`) is gated on `Scene.debugAllowRenderGraphCapture`. It is a debug
|
||||
opt-in, so a release build or a scene that never armed it returns nothing. The editor arms it for you;
|
||||
outside the editor, set `Scene.debugAllowRenderGraphCapture = true` and call
|
||||
`Scene.captureRenderGraph(...)` directly.
|
||||
|
||||
---
|
||||
|
||||
## Settle, do not seed
|
||||
|
||||
Frames differ from run to run for benign reasons. That is normal, not a bug to eliminate.
|
||||
|
||||
- **Auto-exposure** (`Scene.autoExposure`) ramps toward the target over `speedUp`/`speedDown` seconds,
|
||||
so the first second is darker or brighter than the settled image.
|
||||
- **Particles and trails** carry a random phase, so a `ParticleSystem` looks different every launch.
|
||||
- **Animations** are mid-clip unless you seek them, so a screenshot lands on an arbitrary frame.
|
||||
- **Image-based lighting** re-bakes after the first present on some paths, so reflections dim in for
|
||||
a frame before they are correct.
|
||||
|
||||
So let the scene settle before you trust a capture. Watch until the image stops changing, or advance
|
||||
a fixed few frames, then screenshot. Judge the settled frame, not the first one.
|
||||
|
||||
**Seeding is a different job.** Strict determinism (a fixed random seed, a pinned animation time, a
|
||||
frozen exposure) is what you set up for pixel-exact regression comparison, where two runs must be
|
||||
byte-identical. You do not need it for ordinary observation. For "does this change look right", settle
|
||||
and look. Reserve the seeding work for when you are building a golden or diffing two runs at the pixel
|
||||
level.
|
||||
|
||||
---
|
||||
|
||||
## Symptom to action map
|
||||
|
||||
Localize before editing. Each row says what to capture first and the mistakes it usually points back
|
||||
to. The mistakes are detailed in the `flutter_scene-idioms` skill's `references/traps.md`; this map
|
||||
routes a symptom to the right one.
|
||||
|
||||
### Entirely black frame
|
||||
|
||||
1. Read the console FIRST. If `Flutter Scene is not ready to render. Skipping frame.` is printing, it
|
||||
is the readiness gate, not your scene. Wait for `Scene.initializeStaticResources()`. Stop here.
|
||||
2. In 0.22.0 a frame that issues zero draws prints once in debug naming the likely cause (not ready,
|
||||
empty region, no views, no visible meshes, or a layer mask matching nothing). Read that line.
|
||||
3. If draws are happening but the image is black, `scan_for_nans`. A NaN or Inf anywhere upstream
|
||||
collapses the final image to black, and the scan names the first offending pass. Then
|
||||
`get_pass_output` on that pass's buffer (NaN shows magenta) to confirm.
|
||||
4. Common non-NaN causes: a degenerate camera (target equals position, `up` parallel to the view
|
||||
direction on a top-down camera, FOV passed in degrees not radians), `layerMask: 0`, an oversized
|
||||
environment texture that failed to allocate on the device. See traps #23 and #16.
|
||||
|
||||
### Washed-out, low-contrast, or too-bright color
|
||||
|
||||
1. `screenshot_viewport` after settling, and check whether auto-exposure has finished ramping (a
|
||||
too-bright first second is just the ramp).
|
||||
2. If it persists, suspect a shader-output contract break. A custom `ShaderMaterial`/`PostEffect`/sky
|
||||
shader must output linear HDR premultiplied by alpha. Tone-mapping or gamma-encoding in the shader
|
||||
gets applied a second time by the resolve pass, giving exactly this washed-out look. See traps #37
|
||||
and the root `MATERIALS.md`.
|
||||
3. Also check for a non-color texture bound as color (a normal or metallic-roughness map without the
|
||||
right `TextureContent`), which reads wrong and distance-dependent. Trap #2.
|
||||
4. Hand-packed vertex data at the wrong stride also washes out color (the color attribute lands at the
|
||||
wrong offset). `describe_scene` plus trap #17.
|
||||
|
||||
### See-through or inside-out faces
|
||||
|
||||
1. `set_viewport_debug_mode` to normals (or `get_pass_output` on the normals buffer) and look at the
|
||||
orientation. Inverted normals confirm a winding problem.
|
||||
2. Cause is almost always clockwise hand-built triangles. flutter_scene front faces wind
|
||||
COUNTER-CLOCKWISE (CCW) in model space, matching glTF and standard conventions. Ensure triangle
|
||||
indices wind CCW around the outward face normal, or omit normals and let the constructor derive
|
||||
them. NEVER fix orientation with a per-triangle winding flip on an imported model; that leaves
|
||||
normals and IBL wrong. Traps #13 and #17.
|
||||
3. For an imported model rendered mirrored, check you did not overwrite the runtime importer's
|
||||
`scale(1, 1, -1)` handedness root. Trap #5.
|
||||
|
||||
### Missing or popping geometry
|
||||
|
||||
1. `describe_scene` to confirm the node is actually in the graph. If it is absent, it is a scene-build
|
||||
bug, not a render bug.
|
||||
2. If it is present but invisible, check the layer mask (`Node.layers` is a bitmask, NOT inherited,
|
||||
and must match the view's `layerMask`; `layers = 2` means `1 << 1`, not "layer 2"). Trap #10.
|
||||
3. If it appears and disappears with camera angle, the bounds do not cover the geometry (a
|
||||
caller-supplied `bounds` or `setLocalBounds` that is too small, or a swapped primitive geometry on
|
||||
an older version). Widen or omit the bounds. Traps #8 and #24.
|
||||
4. A moved skinned mesh that will not move is the skinned-node transform being ignored by design; move
|
||||
the skeleton root instead. Trap #4.
|
||||
|
||||
### A value looks numerically wrong (not visually)
|
||||
|
||||
Use `read_pass_pixel` on the relevant buffer to read the exact float RGBA at a coordinate, with NaN/Inf
|
||||
flags. This settles "is this pixel actually 0.5, or is it NaN, or negative" without eyeballing a PNG
|
||||
that the display remap has already clamped.
|
||||
|
||||
---
|
||||
|
||||
## Judgment, restated
|
||||
|
||||
The blind-pairwise rule from `SKILL.md` is the part most likely to be skipped, so it bears repeating
|
||||
here. When you have a before and an after, put them side by side and pick the better one as a binary
|
||||
A-or-B choice against a reference or the previous frame. Do not narrate a score. A solo score climbs
|
||||
on its own because you are grading your own progress; a blind pick between two concrete frames does
|
||||
not. Every visual review runs through a pairwise pick, including the ones that feel too obvious to
|
||||
bother with.
|
||||
Reference in New Issue
Block a user