flutter3d
Showcase Changelog 38 packages API reference

Thirty-two lights and the fade band

since 0.7.0 Shading and materials

Eight is the cap on one draw, not on a scene. Past that, the renderer picks the eight lights that matter most to each object and hands the rest, up to twenty-four more, through a texture-backed light list. Eight slots plus a twenty-four light tail is where "thirty-two lights on one draw" in the CHANGELOG comes from, and only a torch past that thirty-second one is actually left unlit.

Step 1: A tiled floor and a ring of torches #

Forty point lights arranged in a ring, over a floor built as a grid of tiles rather than one giant plane. A single draw's bounding sphere wide enough to reach every torch would score them all alike — a light inside an object's sphere scores that object's ceiling no matter where inside it sits, so nothing would tell one torch from another. A tile small enough that a torch's own range can fall outside it makes "which torches reach this one" a real question with a real answer, tile by tile.

// A grid of tiles, not one plane. A single 20x20 draw's own bounding
// sphere (radius about 14) reaches every torch on the ring below, six
// units out — every one of them lands *inside* it, and `_relevanceIn`
// scores a light inside an object's sphere at that object's ceiling
// regardless of exactly where inside it sits. Selection then cannot
// tell one torch from another, and `lightFadeBand` has nothing to
// soften: every candidate ties, so the torch that finally overflows the
// list is exactly as strong as the thirty-two that did not. Twenty-five
// four-by-four tiles each carry a bounding sphere small enough, next to
// a torch's own wide range, that how far a tile sits from the ring is a
// real question with a real answer — the tile at the very centre is
// furthest from every torch at once, and is where the band has
// something to soften.
final DeviceMesh tile = DeviceMesh.upload(
  context.device,
  PlaneShape(width: _tileSize, depth: _tileSize).build(),
);
final Material floorMaterial = Material(
  baseColor: Vector4(0.5, 0.5, 0.55, 1.0),
  roughness: 0.85,
  doubleSided: true,
);
for (var row = 0; row < _tilesPerSide; row++) {
  for (var col = 0; col < _tilesPerSide; col++) {
    final double x = (col - (_tilesPerSide - 1) / 2) * _tileSize;
    final double z = (row - (_tilesPerSide - 1) / 2) * _tileSize;
    scene.add(
      MeshNode(tile, floorMaterial, name: 'floor $row,$col')
        ..setPosition(x, 0.0, z),
    );
  }
}
for (var i = 0; i < _torchCount; i++) {
  final double angle = i / _torchCount * 2.0 * math.pi;
  scene.add(
    LightNode(
      name: 'torch $i',
      type: LightType.point,
      intensity: 2.5,
      range: _torchRange,
    )..setPosition(math.cos(angle) * 6.0, 0.6, math.sin(angle) * 6.0),
  );
}

Step 2: The fade band #

Every tile has more than thirty-two torches within reach, so every one of them turns some away — the tile at the very centre of the floor turns away the most, being the single farthest point from every torch on the ring at once. lightFadeBand turns the cliff where a turned-away torch's contribution ends into a ramp: drag it up and the centre tile — lit by nothing but the weakest of its list — dims towards black, while the ring itself, whose nearest torches sit nowhere near that cliff, stays exactly as bright.

lightFadeBand: fadeBand,

Step 3: What the frame reports #

FrameResult.lightsDropped is how many lights a scene actually loses: past the eight direct slots and the twenty-four-light tail, not past the eight alone. With forty torches and a cap of thirty-two, eight of them are genuinely unlit, and this page's test reads that number back off the frame.

if (scene.lights.length != _torchCount) {
  throw StateError(
    'expected $_torchCount torches, found '
    '${scene.lights.length}',
  );
}
final int expectedDropped = math.max(
  _torchCount - LightBuffer.maxLights - LightBuffer.maxExtraLights,
  0,
);
if (expectedDropped == 0) {
  throw StateError(
    'this scene needs more torches than the cap to prove '
    'anything is actually dropped',
  );
}
if (frame.lightsDropped != expectedDropped) {
  throw StateError(
    'expected $expectedDropped lights past the eight slots and the '
    'twenty-four-light tail, the frame reports ${frame.lightsDropped}',
  );
}
final int tileCount = _tilesPerSide * _tilesPerSide;
if (scene.meshes.length != tileCount) {
  throw StateError(
    'expected $tileCount floor tiles, found ${scene.meshes.length} — a '
    'floor back to one plane gives every torch the same bounding-sphere '
    'ceiling and the fade band nothing to soften',
  );
}
if (frame.drawCalls < tileCount) {
  throw StateError('fewer than $tileCount tiles were drawn');
}