flutter3d
Showcase Changelog 38 packages API reference

Levels of detail

since 0.7.0 Scene and geometry

A mesh can lose small details as it shrinks on screen. A model can carry a coarser stand-in for exactly that moment, and LodGroup is what switches to it and back as the camera moves.

Step 1: Build the levels as parts #

Three spheres share the same radius and origin but use 48, 20, and 8 segments around the equator. Each becomes a ModelPart: a device mesh plus the material it draws with. This is the same shape a decoded file would arrive in, before anything ties the parts together.

final List<(String, SphereShape, Vector4)> definitions =
    <(String, SphereShape, Vector4)>[
      (
        'fine',
        const SphereShape(radius: 1.6, segments: 48, rings: 24),
        Vector4(0.16, 0.68, 0.92, 1.0),
      ),
      (
        'medium',
        const SphereShape(radius: 1.6, segments: 20, rings: 10),
        Vector4(0.95, 0.62, 0.16, 1.0),
      ),
      (
        'coarse',
        const SphereShape(radius: 1.6, segments: 8, rings: 4),
        Vector4(0.84, 0.24, 0.28, 1.0),
      ),
    ];
final List<ModelPart> parts = <ModelPart>[
  for (final (String name, SphereShape shape, Vector4 color) in definitions)
    ModelPart(
      mesh: DeviceMesh.upload(context.device, shape.build()),
      material: Material(
        name: '$name material',
        baseColor: color,
        roughness: 0.52,
      ),
      name: name,
    ),
];

Step 2: Describe one node with two lower levels #

A ModelNode names its base surface and, in lods, the surfaces that replace it below a screen fraction. parts[0] is the node's own surface; parts[1] and parts[2] are named by index inside the two ModelLod entries. Wrapping the parts and the node in a ModelAsset is what makes this a model rather than three unrelated meshes.

final ModelNode node = ModelNode(
  name: 'sphere lod',
  surfaces: const <int>[0],
  lods: const <ModelLod>[
    ModelLod(surfaceIndices: <int>[1], maxScreenFraction: 0.34),
    ModelLod(surfaceIndices: <int>[2], maxScreenFraction: 0.15),
  ],
);
final ModelAsset asset = ModelAsset(
  name: 'sphere lod asset',
  parts: parts,
  nodes: <ModelNode>[node],
  roots: const <int>[0],
  localBounds: parts.first.mesh.bounds,
);

Step 3: Instantiate it #

ModelAsset.instantiate builds the scene nodes for every part. A node whose surfaces and every level are exactly one mesh each is built as a LodGroup automatically, so there is nothing else to wire up. The group registers itself with the scene, which is where the page reads it back.

final Scene scene = Scene()
  ..ambientColor = Vector3(0.42, 0.5, 0.68)
  ..ambientIntensity = 0.14
  ..add(
    LightNode(name: 'sun', intensity: 3.2)
      ..setLocalForward(Vector3(-0.46, -0.82, -0.34)),
  );
asset.instantiate(scene);
_group = scene.lodGroups.single;

Step 4: Let screen size decide #

The group measures the mesh bounding sphere through the active projection. Perspective distance and field of view therefore affect the result together; an orthographic view uses its visible height instead.

Orbit normally and scroll to zoom. Blue is fine, orange is medium, and red is coarse.

double _screenFraction(Scene scene) =>
    _group.screenFraction(scene.cameras.single);

Step 5: Check the selected level #

The page confirms that the group is registered, its active index is valid, and exactly one child is visible. It also checks that the measured screen fraction is positive and the chosen mesh reached the frame.

final int visibleLevels = _group.levels
    .where((LodLevel level) => level.node.visible)
    .length;
final double fraction = _screenFraction(scene);
if (scene.lodGroups.single != _group ||
    _group.activeLevel < 0 ||
    _group.activeLevel >= _group.levels.length ||
    visibleLevels != 1 ||
    fraction <= 0.0 ||
    frame.drawCalls < 1) {
  throw StateError('the LOD group did not select one visible mesh');
}