flutter3d 0.4.0 copy "flutter3d: ^0.4.0" to clipboard
flutter3d: ^0.4.0 copied to clipboard

A 3D engine on Flutter GPU: scene graph, glTF/OBJ/.f3d loading, PBR, shadows, bloom, skinning.

example/lib/main.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter3d/flutter3d.dart' as engine;
import 'package:flutter3d/flutter3d.dart';
import 'package:flutter3d_particles/flutter3d_particles.dart';
import 'package:vector_math/vector_math.dart' show Aabb3, Vector3, Vector4;

import 'src/spike/backend.dart';
import 'src/spike/control_panel.dart';
import 'src/spike/error_panels.dart';
import 'src/spike/frame_capture.dart';
import 'src/spike/golden_extras.dart';
import 'src/spike/golden_runner.dart';
import 'src/spike/orbit_gestures.dart';
import 'src/spike/sample_sources.dart';
import 'src/spike/scene_surface.dart';

void main() => runApp(const Flutter3dApp());

class Flutter3dApp extends StatelessWidget {
  const Flutter3dApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'flutter3d spike',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(useMaterial3: true),
      home: const SpikePage(),
    );
  }
}

class SpikePage extends StatefulWidget {
  const SpikePage({super.key});

  @override
  State<SpikePage> createState() => _SpikePageState();
}

class _SpikePageState extends State<SpikePage>
    with SingleTickerProviderStateMixin {
  Renderer? _renderer;

  /// Kept because the asset loader needs one long after `initState`.
  GraphicsDevice? _device;
  Object? _initError;
  StackTrace? _initStack;

  TextureHandle? _checkerAlbedo;

  late final Scene _scene;
  late final SceneNode _modelPivot;
  late final CameraNode _camera;
  late final LightNode _sun;
  late final LightNode _light;
  late final LightNode _fill;

  /// Additional shadow-casting point lights, only for the multi-row golden.
  final List<LightNode> _extraPoints = <LightNode>[];

  /// Frames elapsed, for the golden whose caster has to move. See build().
  int _moverFrame = 0;
  late final LightNode _spot;

  /// A plane under the model, so the shadow has somewhere to land.
  late final MeshNode _ground;
  bool _showGround = GoldenRunner.fromEnvironment()?.scene.ground ?? true;

  /// Set when the ground was wanted but the scene could not be measured yet.
  bool _groundPending = false;
  late final OrbitController _orbit;
  late final RenderView _view;

  /// Nullable, because the backend may never open.
  ///
  /// It used to be `late final`, assigned only at the end of `_openScene` — and
  /// `dispose` called it unconditionally. Tear the page down while the backend
  /// was still opening, or after it had failed, and the
  /// `LateInitializationError` thrown from `dispose` skipped every release
  /// below it, `super.dispose()` included. The platformer's `_ticker` learned
  /// the same lesson first.
  Ticker? _ticker;
  Duration _elapsed = Duration.zero;

  /// Ref-counted cache, so returning to a source neither re-decodes nor
  /// re-uploads it, and two quick selections of the same model share one load.
  late final ResourceCache<String, ModelAsset> _assets;
  ResourceHandle<ModelAsset>? _held;

  /// Wall-clock time of the last load, to show that decoding moved off this
  /// isolate.
  int _lastLoadMillis = 0;

  int _sourceIndex = 0;
  ModelAsset? _asset;
  ModelInstance? _instance;
  String? _loadError;

  /// Frame time of the previous tick, so the animation player gets a delta
  /// rather than an absolute time — that is what lets speed and ping-pong work.
  Duration _lastTick = Duration.zero;

  LightingModel _lighting =
      GoldenRunner.fromEnvironment()?.scene.lighting ?? LightingModel.pbr;
  double _roughness = 0.35;
  double _metallic = 0.0;
  double _specular = 1.0;
  double _exposure = 1.6;
  bool _wireframe = false;
  // A golden must not move between the frame it is recorded on and the frame it
  // is compared on, so the turntable is off for every scene.
  bool _spinning =
      GoldenRunner.fromEnvironment() == null && startupSpinFromEnvironment();
  bool _culling = true;
  DebugDrawOptions _debug =
      GoldenRunner.fromEnvironment()?.scene.debug ?? debugDrawFromEnvironment();

  /// Set only by a golden that wants to look at the surface buffer.
  final bool _showSurfaceBuffer =
      GoldenRunner.fromEnvironment()?.scene.surfaceBuffer ?? false;

  /// The sky the golden scene asks for, or none at all — which is what every
  /// scene but one asks for, and what this application shows when it is being
  /// driven by hand rather than by the golden runner.
  final SkySettings _sky =
      GoldenRunner.fromEnvironment()?.scene.sky ?? const SkySettings();

  final bool _showShadowMap =
      GoldenRunner.fromEnvironment()?.scene.shadowMap ?? false;

  BloomSettings _bloom = BloomSettings(
    enabled: GoldenRunner.fromEnvironment()?.scene.bloom ?? true,
  );
  ShadowSettings _shadows = ShadowSettings(
    enabled:
        GoldenRunner.fromEnvironment()?.scene.shadows ??
        startupShadowsFromEnvironment(),
  );
  FrameResult? _lastFrame;

  /// Set only when `--dart-define=FLUTTER3D_CAPTURE=...` asked for a PNG.
  final FrameCapture? _capture = FrameCapture.fromEnvironment();

  /// Set only when `--dart-define=FLUTTER3D_GOLDEN=...` named a scene.
  final GoldenRunner? _golden = GoldenRunner.fromEnvironment();

  /// Reused across taps: picking allocates nothing per cast, and the result
  /// object is owned by the caster.
  final Raycaster _raycaster = Raycaster();
  final List<SceneNode> _selection = <SceneNode>[];
  String? _pickDescription;

  /// Flutter's own frame timings, which are the numbers that actually say
  /// whether the app is dropping frames. The renderer's `cpuMicros` is only a
  /// slice of `buildDuration`, and neither of them is the GPU time.
  int _uiMicros = 0;
  int _rasterMicros = 0;

  @override
  void initState() {
    super.initState();
    unawaited(_openScene());
  }

  /// Builds the backend and the whole demo scene under it.
  ///
  /// Asynchronous for one reason: loading the shader bundle is, since
  /// flutter_gpu 3.47. Everything after the first line runs exactly as it did
  /// when `initState` ran it directly, in the same order, and the ticker at the
  /// end is what puts the finished scene on screen.
  Future<void> _openScene() async {
    // The backend, before anything that needs one. It used to be built inline
    // at `Renderer.create`, which was fine while meshes and textures reached a
    // global to upload themselves; now that they take a device, it has to exist
    // first. A failure here is the same failure as a missing shader bundle, so
    // it lands in the same place and `build` shows the same panel.
    final GraphicsDevice device;
    try {
      device = await createBackend(width: 480, height: 360);
    } catch (error, stack) {
      // Inside `setState` because the failing frame is no longer the first
      // one: `build` has already run and shown the empty gap.
      if (mounted) {
        setState(() {
          _initError = error;
          _initStack = stack;
        });
      }
      return;
    }
    if (!mounted) return;
    _device = device;

    _scene = Scene(name: 'demo');

    // A pivot the model hangs under, so "spin" animates the scene rather than
    // being baked into the renderer.
    _modelPivot = _scene.add(SceneNode(name: 'model pivot'));

    // Added before the camera's key light so it is the first directional in the
    // registry, and therefore the one that casts. A shadow from a
    // camera-parented light would swing with the orbit, which makes it useless
    // for judging whether the pass is right.
    // The dominant source, and the only one that casts. It has to out-light the
    // other three put together, or its shadow is a few percent of the total and
    // reads as nothing — which is exactly what happened when it was set to 1.6
    // against a combined 17.
    _sun = LightNode(
      type: LightType.directional,
      color: Vector3(1.0, 0.95, 0.85),
      intensity: 4.0,
      name: 'sun',
    );
    _scene.add(_sun);
    // Low and oblique, roughly 30 degrees above the horizon. A sun overhead
    // drops the shadow directly under the object, where the object itself
    // covers it — the shadow is still being cast, it is just never visible. An
    // oblique sun throws it out to the side, which is the only reason to have a
    // ground plane in the demo at all.
    _sun.setLocalForward(Vector3(-0.85, -0.5, -0.2));

    _ground = MeshNode(
      // Uploaded, not a CpuMesh: this one is drawn, and the renderer refuses
      // geometry that never reached the GPU rather than skipping it quietly.
      DeviceMesh.upload(device, const PlaneShape().build()),
      engine.Material(
        lighting: LightingModel.pbr,
        // Mid grey, not white: a white floor under a lit model saturates and
        // the shadow lands on a surface with no headroom to darken.
        baseColor: Vector4(0.45, 0.45, 0.47, 1.0),
        roughness: 0.9,
      ),
      name: 'ground',
    );
    // Receives shadows without casting one: a plane's own back face in the
    // shadow map would fight the surface it is meant to darken.
    _ground.castsShadow = false;

    _camera = _scene.add(CameraNode(name: 'main camera'));

    // The key light is a CHILD OF THE CAMERA, which is the whole point of lights
    // being scene nodes: it follows the orbit for free, so whatever the user
    // turns towards stays lit. A world-fixed light would leave the far side of
    // the model in near-darkness, since ambient is deliberately low.
    _light = LightNode(
      type: LightType.directional,
      color: Vector3(1.0, 0.97, 0.92),
      // Dim, because a light parented to the camera is a headlight: it fills in
      // every shadow the viewer can see, which is the one place a shadow needs
      // to survive. It earns its keep as a fill, not as a key.
      intensity: 0.35,
      name: 'key light',
    );
    _camera.add(_light);
    // Local direction, so it is relative to wherever the camera looks: shining
    // forward, from the upper left of the view.
    _light.setLocalForward(Vector3(0.35, -0.45, -0.82));

    // Two world-fixed lights of the other two types, so the demo actually
    // exercises attenuation and the spot cone rather than only the directional
    // path. They are placed from the model's bounds on load, because a scene
    // that fits in one unit and one that spans two hundred need very different
    // distances for the same look.
    _fill = LightNode(
      type: LightType.point,
      color: Vector3(0.35, 0.62, 1.0),
      intensity: 4.0,
      name: 'fill light',
      // Set only by the golden that wants to look at the cube atlas, because
      // the atlas costs six views of the scene and nothing else here needs it.
      castsShadow: GoldenRunner.fromEnvironment()?.scene.pointShadow ?? false,
    );
    _spot = LightNode(
      type: LightType.spot,
      color: Vector3(1.0, 0.45, 0.25),
      intensity: 12.0,
      innerConeAngle: 0.25,
      outerConeAngle: 0.5,
      name: 'spot light',
      // Same reasoning as the fill light above: a caster takes a row of the
      // cube atlas, and only the golden that wants to look at one asks for it.
      castsShadow: GoldenRunner.fromEnvironment()?.scene.spotShadow ?? false,
    );
    _scene
      ..add(_fill)
      ..add(_spot);

    // Extra point casters for the golden that checks more than one atlas row.
    // Placed in _placeSceneLights with the others, since the distance that
    // suits a model depends on its bounds.
    final extras = _golden?.scene.extraPointShadows ?? 0;
    for (var i = 0; i < extras; i++) {
      final light = LightNode(
        type: LightType.point,
        color: Vector3(1.0, 0.72, 0.4),
        intensity: 4.0,
        name: 'extra point $i',
        castsShadow: true,
      );
      _extraPoints.add(light);
      _scene.add(light);
    }

    final enabled = _golden?.scene.lights ?? startupLightsFromEnvironment();
    if (enabled.isNotEmpty) {
      for (final light in <LightNode>[_light, _fill, _spot]) {
        light.visible = enabled.contains(light.name?.toLowerCase());
      }
    }

    _orbit = OrbitController(_camera, distance: 3.0, yaw: 0.6, pitch: 0.35);
    _view = RenderView(camera: _camera);

    try {
      _checkerAlbedo = const CheckerboardTexture().upload(device);
      _renderer = Renderer.create(device: device);
    } catch (error, stack) {
      _initError = error;
      _initStack = stack;
    }

    // What a golden draws besides the world. Registered here rather than per
    // frame, because a plugin is a property of the renderer now — and built
    // from GoldenExtras, where every input is fixed, because a reference image
    // of a random burst compares against nothing.
    final goldenScene = _golden?.scene;
    final renderer = _renderer;
    if (goldenScene != null && renderer != null) {
      if (goldenScene.name == 'particles-textured') {
        renderer.addContributor(
          ParticleContributor(
            GoldenExtras.texturedParticles(),
            texture: GoldenExtras.particleSprite(device),
          ),
        );
      } else if (goldenScene.name == 'particles-mesh') {
        // A different contributor, not a mode of the other one: the mesh path
        // binds two vertex buffers where the billboard path binds one.
        renderer.addContributor(
          MeshParticleContributor(
            GoldenExtras.meshParticles(),
            mesh: GoldenExtras.meshParticleShape(device),
          ),
        );
      } else if (goldenScene.particles) {
        renderer.addContributor(
          ParticleContributor(switch (goldenScene.name) {
            'particle-one' => GoldenExtras.oneParticle(),
            'particles-recycled' => GoldenExtras.recycled(),
            'particle-stack' => GoldenExtras.stackedParticles(),
            _ => GoldenExtras.burst(),
          }),
        );
      }
      if (goldenScene.viewModel) {
        renderer.addNode(GoldenExtras.viewModel(device));
      }
    }

    _assets = ResourceCache<String, ModelAsset>(
      load: (label) {
        final source = kSources.firstWhere((s) => s.label == label);
        return source.load(device: _device!, checkerAlbedo: _checkerAlbedo!);
      },
    );

    // Deliberately not awaited: the first frame must not wait for a model to
    // decode. `unawaited` says so rather than leaving it to be read as a
    // forgotten `await`.
    if (_renderer != null) unawaited(_selectSource(_startupSourceIndex()));

    if (const bool.fromEnvironment('FLUTTER3D_MRT_PROBE')) {
      unawaited(_renderer?.probeMultipleRenderTargets().then(debugPrint));
    }

    SchedulerBinding.instance.addTimingsCallback(_onFrameTimings);

    _ticker = createTicker((elapsed) {
      setState(() => _elapsed = elapsed);
    })..start();
  }

  /// Selects whatever the tap landed on, or clears the selection.
  ///
  /// The ray is built from logical widget coordinates, not physical pixels: the
  /// two differ by the device pixel ratio, and the ray only depends on the
  /// aspect ratio, which they share.
  void _handleTap(Offset position, Size size) {
    _raycaster.setFromScreen(
      _camera,
      position.dx,
      position.dy,
      width: size.width,
      height: size.height,
    );
    final hit = _raycaster.intersectScene(_scene);

    setState(() {
      _selection.clear();
      if (hit == null) {
        _pickDescription = null;
        return;
      }
      _selection.add(hit.requireNode);
      _pickDescription =
          '${hit.requireNode.name ?? 'mesh'} · '
          'tri ${hit.triangleIndex} · '
          '${hit.distance.toStringAsFixed(2)} away · '
          'uv ${hit.uv.x.toStringAsFixed(2)},${hit.uv.y.toStringAsFixed(2)}'
          '${hit.approximate ? ' (bounds only)' : ''}';
    });
  }

  /// Index of the model named by `FLUTTER3D_SOURCE`, or 0.
  ///
  /// Matched case-insensitively on a substring so a capture command can say
  /// `teapot` instead of quoting the full chip label.
  int _startupSourceIndex() {
    final wanted = (_golden?.scene.source ?? startupSourceFromEnvironment())
        .trim()
        .toLowerCase();
    if (wanted.isEmpty) return 0;
    for (var i = 0; i < kSources.length; i++) {
      if (kSources[i].label.toLowerCase().contains(wanted)) return i;
    }
    debugPrint(
      'FLUTTER3D_SOURCE: no model matches "$wanted"; using the first.',
    );
    return 0;
  }

  /// Records the most recent frame's UI and raster durations.
  ///
  /// Deliberately without `setState`: the ticker already rebuilds every frame,
  /// and asking for another build from inside a timings callback schedules a
  /// frame from within frame reporting.
  void _onFrameTimings(List<FrameTiming> timings) {
    if (timings.isEmpty) return;
    final last = timings.last;
    _uiMicros = last.buildDuration.inMicroseconds;
    _rasterMicros = last.rasterDuration.inMicroseconds;
  }

  @override
  void dispose() {
    SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
    _ticker?.dispose();
    _held?.release();
    // The rest is keyed on the device, in dependency order: the cache, the
    // checker texture and the renderer all hold objects the device owns, so
    // the device goes last. One guard rather than one per field, because
    // everything below the device in `_openScene` is assigned synchronously
    // once it exists — a null device means none of those `late final`s were
    // ever touched.
    final device = _device;
    if (device != null) {
      _assets.clear();
      final checker = _checkerAlbedo;
      if (checker != null) device.releaseTexture(checker);
      _renderer?.dispose();
      device.dispose();
    }
    super.dispose();
  }

  Future<void> _selectSource(int index) async {
    final renderer = _renderer;
    if (renderer == null) return;

    final source = kSources[index];
    setState(() {
      _sourceIndex = index;
      _loadError = null;
    });

    final stopwatch = Stopwatch()..start();
    final ResourceHandle<ModelAsset> handle;
    try {
      handle = await _assets.acquire(source.label);
    } catch (error) {
      if (!mounted) return;
      // Log as well as show: a message that only reaches the UI is invisible when
      // the failure happens during automated checks.
      debugPrint('Model load failed for ${source.label}: $error');
      if (_sourceIndex == index) setState(() => _loadError = '$error');
      return;
    }
    stopwatch.stop();

    // A late load must not replace what the user has since selected — but the
    // reference still has to go back, or the cache would hold it forever.
    if (!mounted || _sourceIndex != index) {
      handle.release();
      return;
    }

    _held?.release();
    _held = handle;
    final asset = handle.value;
    _lastLoadMillis = stopwatch.elapsedMilliseconds;

    setState(() {
      _instance?.removeFromScene();
      _selection.clear();
      _pickDescription = null;

      final instance = asset.instantiate(_scene, parent: _modelPivot);
      _instance = instance;
      _asset = asset;

      // An animated model plays by default: a viewer that loads a clip and then
      // shows a still frame looks broken. A capture can pin it instead.
      final frozen =
          _golden?.scene.animationTime ?? startupAnimationTimeFromEnvironment();
      if (frozen != null) {
        instance.player
          ?..play()
          ..pause()
          ..seek(frozen);
      } else {
        instance.player?.play();
      }

      // Every material a newly loaded model brought with it arrives on the
      // engine default, which is PBR. Pushing the chosen model onto them here
      // rather than only from the control panel is what makes the lighting
      // switchable at all from outside the UI — and its absence is why five of
      // the six lighting goldens recorded byte-identical PBR images and then
      // passed against each other's references.
      for (final mesh in _scene.meshes) {
        mesh.material.lighting = _lighting;
      }

      // Show what the model actually uses, so the numbers on the sliders are not
      // a lie the moment a new model loads. A file with no materials at all — the
      // teapot, for instance — lands on the engine defaults, which is exactly the
      // case where the sliders are most useful.
      final first = _scene.meshes.isEmpty ? null : _scene.meshes.first.material;
      if (first != null) {
        _roughness = first.roughness;
        _metallic = first.metallic;
      }

      // Measure the model in its rest pose, whatever the pivot happens to be
      // holding right now.
      //
      // The floor, the camera distance and the scene lights are all derived
      // from these bounds, once, here — on whichever build the asynchronous
      // load happened to finish on. `cube-shadow-mover` turns the pivot a
      // little on every build, so the bounds it was measured by used to be a
      // function of how long the load took, and the whole frame moved with
      // them. Measured: the load normally lands on build 3 and the camera sits
      // at 13.994; delayed by 400 ms it lands on build 14, the camera goes to
      // 15.884, and 9878 of 172800 pixels disagree with the reference — 5.7%
      // against a 0.2% limit. It is a race that this machine simply keeps
      // winning the same way.
      //
      // Zeroing the pivot makes the measurement a function of the model
      // instead. Nothing else reads the pivot in this method, and the next
      // build puts the turn straight back — see the mover block in build().
      _modelPivot.setRotationYawPitchRoll(0.0, 0.0, 0.0);

      // Frame the newly placed model, and tie the depth range to it so small
      // models do not z-fight.
      // The ground is sized from the model, so it must not be in the bounds the
      // model is measured by — nor in the ones the camera frames, or every
      // scene would be viewed from far enough away to fit a floor six times its
      // width.
      _ground.removeFromParent();
      final bounds = _scene.computeBounds();
      _placeGround(bounds);

      _orbit.frameBounds(bounds);
      // After framing, because frameBounds sets the distance but leaves the
      // angles alone — a capture that names an angle has to keep it.
      final golden = _golden?.scene;
      final orbit = golden != null
          ? (yaw: golden.yaw, pitch: golden.pitch)
          : startupOrbitFromEnvironment();
      if (orbit != null) {
        _orbit
          ..yaw = orbit.yaw
          ..pitch = orbit.pitch
          ..apply();
      }
      _orbit.syncProjectionDepth(_camera);
      _placeSceneLights(bounds);
    });
  }

  /// Sits the ground plane just under the model and scales it to suit.
  ///
  /// Recomputed per model because the scenes range from a one-unit cube to a
  /// two-hundred-unit wall, and a fixed plane would either be invisible or fill
  /// the frame.
  void _placeGround(Aabb3 bounds) {
    _ground.removeFromParent();
    if (!_showGround) {
      _groundPending = false;
      return;
    }
    if (!bounds.min.x.isFinite) {
      // Nothing measurable yet. A skinned mesh has no world bounds until its
      // skeleton has been posed, and the pose happens during the first draw —
      // so a rigged model installed here measures as empty and, before this
      // flag existed, silently never got a floor at all. Which floor a frame
      // had then depended on load timing, and the golden for the rigged figure
      // failed about one run in three.
      _groundPending = true;
      return;
    }
    _groundPending = false;
    if (!bounds.min.x.isFinite) return;

    final centre = (bounds.min + bounds.max)..scale(0.5);
    final extent = (bounds.max - bounds.min)..scale(0.5);
    final radius = math.max(extent.length, 1e-3);

    // Dropped for the golden that needs a gap between caster and receiver; see
    // GoldenScene.groundDrop. Widened with it so the shadow still lands on it.
    final drop = radius * (_golden?.scene.groundDrop ?? 0.0);
    _scene.add(_ground);
    _ground
      ..setPosition(centre.x, bounds.min.y - radius * 0.02 - drop, centre.z)
      ..setScale(radius * (3.0 + drop), 1.0, radius * (3.0 + drop));
  }

  /// Puts the point and spot lights at a sensible distance for this model.
  ///
  /// Scaled by the model rather than fixed: inverse-square falloff means a
  /// distance that flatters a one-unit cube leaves a two-hundred-unit scene in
  /// the dark, and the intensity would have to be retuned per model instead.
  void _placeSceneLights(Aabb3 bounds) {
    final centre = (bounds.min + bounds.max)..scale(0.5);
    final radius = ((bounds.max - bounds.min)..scale(0.5)).length;
    final distance = radius <= 0.0 ? 1.5 : radius * 2.0;

    _fill.setPosition(
      centre.x - distance,
      centre.y + distance * 0.35,
      centre.z + distance * 0.6,
    );
    // Intensity is photometric-ish: with inverse-square falloff it has to grow
    // with the square of the distance to keep the same brightness on the model.
    _fill
      ..intensity = 1.0 * distance * distance
      ..range = distance * 6.0;

    // Spread around the model, each at a different distance and height, so no
    // two rows of the atlas hold the same view.
    //
    // The radius **shrinks** with the index, which is the whole point when
    // there are more casters than rows: the lights added last are the nearest,
    // so choosing by relevance and choosing by scene order pick different sets.
    // The first attempt had the radius fixed and the height rising with the
    // index, which made scene order agree with distance — and
    // `cube-shadow-crowded` passed with the ranking stubbed out to a constant,
    // pinning nothing at all.
    for (var i = 0; i < _extraPoints.length; i++) {
      final angle = (i + 1) * math.pi * 2.0 / (_extraPoints.length + 1);
      final radius = distance * (1.7 - 0.22 * i);
      _extraPoints[i]
        ..setPosition(
          centre.x + math.cos(angle) * radius,
          centre.y + distance * (0.9 - 0.12 * i),
          centre.z + math.sin(angle) * radius,
        )
        ..intensity = 1.0 * distance * distance
        ..range = distance * 6.0;
    }

    _spot.setPosition(
      centre.x + distance * 0.4,
      centre.y + distance * 1.6,
      centre.z + distance * 0.4,
    );
    _spot
      ..lookAt(centre)
      ..intensity = 2.5 * distance * distance
      ..range = distance * 6.0;
  }

  void _applyLighting(LightingModel model) {
    setState(() {
      _lighting = model;
      for (final mesh in _scene.meshes) {
        mesh.material.lighting = model;
      }
    });
  }

  /// Pushes the slider values onto every material in the scene.
  ///
  /// Deliberately unconditional: a viewer's sliders are overrides, and blocking
  /// them for files that ship materials was wrong twice over — it left a dead
  /// control, and it did so even for files with no materials at all, where there
  /// was nothing to protect.
  ///
  /// The trade-off is that a multi-material model gets flattened to one roughness
  /// while dragging. Reselecting the model restores the authored values, since
  /// materials are rebuilt per asset.
  void _applyMaterialSliders() {
    for (final mesh in _scene.meshes) {
      mesh.material
        ..roughness = _roughness
        ..metallic = _metallic;
    }
  }

  @override
  Widget build(BuildContext context) {
    final renderer = _renderer;
    final initError = _initError;
    if (renderer == null) {
      return Scaffold(
        backgroundColor: const Color(0xFF0E1014),
        // No renderer and no error is the gap while the backend is being
        // built, which exists because loading the shader bundle became
        // asynchronous. It used to be impossible, and `_initError!` used to say
        // so; an empty frame is the honest thing to show for the one frame it
        // usually lasts.
        body: SafeArea(
          child: initError == null
              ? const SizedBox.shrink()
              : ErrorPanel(error: initError, stack: _initStack),
        ),
      );
    }

    // Animate the pivot, not the renderer: the spin is now a property of the
    // scene, so one object could spin while another stays put.
    final seconds = _elapsed.inMicroseconds / Duration.microsecondsPerSecond;
    if (_spinning) {
      _modelPivot.setRotationYawPitchRoll(seconds * 0.7, 0.0, 0.0);
    }

    // A caster that moves, for the golden that has to prove a moving shadow
    // follows it. Driven by a frame count and then held still, not by the
    // clock: a golden must be identical on the frame it is compared on, which
    // is why the turntable above is off for every scene. Counting frames and
    // stopping well before the capture gives motion *and* a settled pose, so
    // the atlas must have been redrawn after the opening one.
    if (_golden?.scene.moverFrames case final int frames when frames > 0) {
      _moverFrame++;
      final held = _moverFrame < frames ? _moverFrame : frames;
      _modelPivot.setRotationYawPitchRoll(held * 0.02, 0.0, 0.0);
    }

    // The model's own clips advance on the same clock. A delta rather than the
    // elapsed total, so pausing the player actually pauses it instead of making
    // it jump on resume.
    final delta =
        (_elapsed - _lastTick).inMicroseconds / Duration.microsecondsPerSecond;
    _lastTick = _elapsed;
    if (delta > 0.0 && delta < 0.5) _instance?.player?.update(delta);

    return Scaffold(
      backgroundColor: const Color(0xFF0E1014),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) => Column(
            children: <Widget>[
              Expanded(
                child: _loadError != null
                    ? LoadErrorPanel(message: _loadError!)
                    : OrbitGestureDetector(
                        controller: _orbit,
                        onChanged: () => setState(() {
                          _orbit.syncProjectionDepth(_camera);
                        }),
                        onTapPoint: _handleTap,
                        child: SceneSurface(
                          renderer: renderer,
                          scene: _scene,
                          view: _view,
                          // A golden renders at a size it names, or the
                          // reference depends on the window it was recorded on
                          // and no two machines agree.
                          fixedSize: _golden == null
                              ? null
                              : Size(
                                  _golden.scene.width.toDouble(),
                                  _golden.scene.height.toDouble(),
                                ),
                          settings: RenderSettings(
                            specular: _specular,
                            exposure: _exposure,
                            wireframe: _wireframe,
                            backfaceCulling: _culling,
                            debug: _debug,
                            highlighted: _selection,
                            bloom: _bloom,
                            shadows: _shadows,
                            // The normals view is not light, so the display
                            // transform would corrupt it: a normal encoded as
                            // RGB has no business being rolled off or exposed.
                            tonemap: _lighting != LightingModel.normals,
                            showSurfaceBuffer: _showSurfaceBuffer,
                            showShadowMap: _showShadowMap,
                            sky: _sky,
                          ),
                          onFrame: (frame) {
                            _lastFrame = frame;
                            if (_groundPending) {
                              // Retried rather than given up on. By now the
                              // first draw has posed any skeleton, so the
                              // bounds are real.
                              //
                              // In the rest pose, for the same reason as at the
                              // load site above: which frame the measurement
                              // lands on must not decide how big the floor is.
                              _modelPivot.setRotationYawPitchRoll(
                                0.0,
                                0.0,
                                0.0,
                              );
                              _placeGround(_scene.computeBounds());
                            }
                            _capture?.offer(renderer.device, frame);
                            // Missing this was why the first recording run
                            // never finished: the scene's settings were being
                            // applied, but nothing counted frames, so the app
                            // simply ran for ever.
                            _golden?.offer(renderer.device, frame);
                          },
                        ),
                      ),
              ),
              // Bounded and scrollable: the panel grows with every feature, and an
              // unbounded one squeezed the viewport down to a single pixel row on
              // a short window — which looks exactly like a renderer that stopped
              // drawing.
              ConstrainedBox(
                constraints: BoxConstraints(
                  maxHeight: constraints.maxHeight * 0.55,
                ),
                child: SingleChildScrollView(
                  child: ControlPanel(
                    sources: kSources,
                    sourceIndex: _sourceIndex,
                    onSource: _selectSource,
                    lighting: _lighting,
                    onLighting: _applyLighting,
                    roughness: _roughness,
                    onRoughness: (v) => setState(() {
                      _roughness = v;
                      _applyMaterialSliders();
                    }),
                    metallic: _metallic,
                    onMetallic: (v) => setState(() {
                      _metallic = v;
                      _applyMaterialSliders();
                    }),
                    specular: _specular,
                    onSpecular: (v) => setState(() => _specular = v),
                    exposure: _exposure,
                    onExposure: (v) => setState(() => _exposure = v),
                    ambient: _scene.ambientIntensity,
                    onAmbient: (v) =>
                        setState(() => _scene.ambientIntensity = v),
                    wireframe: _wireframe,
                    onWireframe: (v) => setState(() => _wireframe = v),
                    spinning: _spinning,
                    onSpinning: (v) => setState(() => _spinning = v),
                    culling: _culling,
                    onCulling: (v) => setState(() => _culling = v),
                    debug: _debug,
                    onDebug: (v) => setState(() => _debug = v),
                    lights: <LightNode>[_sun, _light, _fill, _spot],
                    onLightsChanged: () => setState(() {}),
                    bloom: _bloom,
                    onBloom: (v) => setState(() => _bloom = v),
                    shadows: _shadows,
                    onShadows: (v) => setState(() => _shadows = v),
                    ground: _showGround,
                    onGround: (v) => setState(() {
                      _showGround = v;
                      _ground.removeFromParent();
                      _placeGround(_scene.computeBounds());
                    }),
                    uiMicros: _uiMicros,
                    rasterMicros: _rasterMicros,
                    pick: _pickDescription,
                    player: _instance?.player,
                    onPlayerChanged: () => setState(() {}),
                    onFrameAll: () => setState(() {
                      _orbit.frameBounds(_scene.computeBounds());
                      _orbit.syncProjectionDepth(_camera);
                    }),
                    renderer: renderer,
                    scene: _scene,
                    asset: _asset,
                    frame: _lastFrame,
                    loadMillis: _lastLoadMillis,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
2
likes
160
points
130
downloads

Documentation

API reference

Publisher

verified publisherpleion.dev

Weekly Downloads

A 3D engine on Flutter GPU: scene graph, glTF/OBJ/.f3d loading, PBR, shadows, bloom, skinning.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#graphics #game-engine #rendering #gpu #flutter-gpu

License

MIT (license)

Dependencies

flutter, flutter3d_hardware, vector_math

More

Packages that depend on flutter3d