flutter3d
Showcase Changelog 38 packages API reference

Cascades that keep what stands still

since 0.8.0 Shadows

The sun's shadow is an atlas of three tiles, one per cascade, and the near tiles follow the camera. Drawn from nothing, every tile a camera moves has to draw every caster inside it again, even when not one of them moved.

Since 0.8.0 each tile keys on its own matrix and on the casters its volume holds, so a tile whose matrix and casters are unchanged is kept as it is. Casters marked shadowIsStatic go further: they live in an atlas of their own, and each frame's tile starts from a copy of it with only the things that move drawn on top. When the camera walks, that static atlas is scrolled by whole texels and only the strips that came into view are drawn.

Step 1: A field that never moves #

Sixty blocks in twelve columns and five rows, all sharing one mesh, all marked shadowIsStatic. The flag is off by default: a mover wrongly marked static leaves its old shadow behind, while a static block left unmarked only costs a redraw.

final DeviceMesh block = DeviceMesh.upload(
  context.device,
  CuboidShape(size: Vector3(0.4, 0.8, 0.4)).build(),
);
final Material stone = Material(
  name: 'stone',
  baseColor: Vector4(0.5, 0.48, 0.45, 1.0),
  roughness: 0.85,
);
_blocks = <MeshNode>[
  for (var i = 0; i < 12; i++)
    for (var j = 0; j < 5; j++)
      MeshNode(block, stone, name: 'block $i $j')
        ..setPosition(-6.0 + i * 1.1, 0.4, -3.0 + j * 1.2)
        ..shadowIsStatic = true,
];
_blocks.forEach(scene.add);

Step 2: One thing that does move #

A ball rolls back and forth in the gap between two columns. It stays dynamic, so it is drawn into the frame's tile over the copy of the blocks, and the blocks are not drawn with it.

_ball = MeshNode(
  DeviceMesh.upload(
    context.device,
    const SphereShape(radius: 0.25, segments: 24, rings: 12).build(),
  ),
  Material(
    name: 'clay',
    baseColor: Vector4(0.6, 0.32, 0.22, 1.0),
    roughness: 0.7,
  ),
  name: 'ball',
)..setPosition(0.05, 0.3, -0.6);
scene.add(_ball);
if (roll) {
  _rolled += dt;
  _ball.setPosition(0.05, 0.3, -0.6 + 2.2 * math.sin(0.8 * _rolled));
}

The ball's path stays inside the field. The last cascade covers every caster and is fitted to their bounds, so a ball that wandered outside them would change that tile's matrix, and the whole tile would be drawn again.

Step 3: A sun and a walk #

The sun is a directional light, which asks for a shadow map by default. The ground receives shadows but casts none. A ground that cast would not make a tile redraw by standing still, but it would cost twice. The last cascade is fitted to the bounds of everything that casts, so an eighteen metre ground would widen it and coarsen its texels. And the ground lies in every tile, so every tile that is drawn again would draw the ground as well, one more draw than the check in Step 5 allows.

scene.add(
  LightNode(name: 'sun', intensity: 1.8)
    ..setLocalForward(Vector3(-4.0, -5.0, -0.01).normalized()),
);

The camera slides from side to side. A cascade's centre is snapped to whole texels in the light's frame, so as the camera walks the near tiles move by whole texels, which is what lets the static atlas be scrolled instead of drawn again.

if (walk) {
  _walked += dt;
  context.orbit.target.x = 3.0 * math.sin(0.35 * _walked);
  context.orbit.apply();
}
shadows: const ShadowSettings(cascades: _cascades),
showShadowMap: showAtlas,

Switch on Show the sun's atlas to see the three tiles side by side. With the blocks static, what you see is the copy of the kept blocks with the ball drawn over it.

Step 4: Flip the flag #

Switch Blocks are static off and the blocks join the ball in the frame's own tiles. The picture looks the same. The difference is the work: every tile the camera moves now draws every block inside it, where before it drew the strips at its edge.

A tile's key lists its casters by which half they belong to, so flipping the flag is noticed on the next frame without anything else to call.

if (blocksAreStatic != _blocksWere) {
  for (final MeshNode block in _blocks) {
    block.shadowIsStatic = blocksAreStatic;
  }
  _blocksWere = blocksAreStatic;
}

Step 5: What the frame reports #

The page has no counter on screen, but the frame keeps one. FrameResult.passes has an entry named directional shadows, and its drawCalls counts every draw into the sun's atlas: the casters, the copies of the static tiles, and the draws that blank a tile before it is drawn again.

/// What the frame says the sun's atlas cost: every draw into it, casters,
/// copies and resets alike.
static int _shadowDraws(FrameResult frame) => frame.passes
    .where((FramePass p) => p.name == 'directional shadows')
    .fold(0, (int sum, FramePass p) => sum + p.drawCalls);

The page's own check reads it twice. The first frame makes at least one draw per block, since the last cascade holds all sixty. Then the ball moves a little with the camera still, and the next frame may draw no more than a copy and the ball in each cascade.

// The camera stays where it is and only the ball moves.
_ball.translate(0.0, 0.0, 0.2);
final int next = _shadowDraws(
  _context.renderer.render(
    width: 320,
    height: 180,
    scene: scene,
    views: views(_context),
    settings: settings(_context),
  ),
);
// A copy of the kept tile and the ball, in each cascade at most.
if (blocksAreStatic && next > 2 * _cascades) {
  throw StateError(
    'moving the ball drew $next things into the sun\'s atlas, '
    'more than a copy and the ball in each of $_cascades cascades',
  );
}

Note. The engine's changelog measures a walk over sixty static blocks at a dozen casters a frame, where the same walk used to draw sixty-eight.