diff --git a/examples/flutter_app/assets/testsrc.mp4 b/examples/flutter_app/assets/testsrc.mp4 new file mode 100644 index 00000000..48355b18 Binary files /dev/null and b/examples/flutter_app/assets/testsrc.mp4 differ diff --git a/examples/flutter_app/lib/example_external_texture.dart b/examples/flutter_app/lib/example_external_texture.dart new file mode 100644 index 00000000..bfb66120 --- /dev/null +++ b/examples/flutter_app/lib/example_external_texture.dart @@ -0,0 +1,374 @@ +import 'dart:math'; + +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_scene/scene.dart' hide Material; +import 'package:vector_math/vector_math.dart' as vm; +import 'package:video_player/video_player.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +import 'example_action_hint.dart'; +import 'example_overlay.dart'; +import 'example_settings.dart'; + +/// Which live source feeds the scene. +enum _SourceKind { + video('Video file'), + camera('Camera'); + + const _SourceKind(this.label); + final String label; +} + +/// Spins the owning node about its Y axis. +class _SpinComponent extends Component { + _SpinComponent(this.radiansPerSecond); + + final double radiansPerSecond; + + @override + void update(double deltaSeconds) { + node.localTransform = + node.localTransform * + vm.Matrix4.rotationY(radiansPerSecond * deltaSeconds); + } +} + +/// A video file or the device camera sampled directly by scene materials. +/// +/// [ExternalTexture] captures a plugin's platform texture by id, so the same +/// frames the `Texture` widget would show become an ordinary material texture. +/// The feed lands on a flat screen and on a spinning cube, and the material +/// controls act on it like any other texture, so the surface can be made +/// glossy, rough, or self-lit. +/// +/// The camera feed arrives in sensor orientation. The `camera` plugin's own +/// preview widget rotates it for the display, and that rotation is not part of +/// the texture, so a portrait phone shows a sideways feed here. +class ExampleExternalTexture extends StatefulWidget { + const ExampleExternalTexture({super.key}); + + @override + State createState() => _ExampleExternalTextureState(); +} + +class _ExampleExternalTextureState extends State { + final Scene scene = Scene(); + + ExternalTexture? _source; + VideoPlayerController? _video; + CameraController? _camera; + _SourceKind _kind = _SourceKind.video; + String? _error; + bool _switching = false; + + late final PhysicallyBasedMaterial _screenMaterial; + late final PhysicallyBasedMaterial _cubeMaterial; + + double _roughness = 0.35; + double _metallic = 0.0; + double _emissive = 0.8; + + @override + void initState() { + super.initState(); + + _screenMaterial = PhysicallyBasedMaterial() + ..metallicFactor = _metallic + ..roughnessFactor = _roughness; + _cubeMaterial = PhysicallyBasedMaterial() + ..metallicFactor = _metallic + ..roughnessFactor = _roughness; + _applyEmissive(); + + scene.add( + Node( + name: 'floor', + localTransform: vm.Matrix4.translation(vm.Vector3(0, -1.5, 0)), + mesh: Mesh( + PlaneGeometry(width: 12, depth: 12), + PhysicallyBasedMaterial() + ..baseColorFactor = vm.Vector4(0.16, 0.18, 0.22, 1.0) + ..metallicFactor = 0.0 + ..roughnessFactor = 0.25, + ), + ), + ); + + scene.add( + Node( + name: 'screen', + localTransform: + vm.Matrix4.translation(vm.Vector3(-1.5, 0.1, 0)) * + vm.Matrix4.rotationY(0.35), + mesh: Mesh(CuboidGeometry(vm.Vector3(2.4, 2.4, 0.12)), _screenMaterial), + ), + ); + + scene.add( + Node( + name: 'cube', + localTransform: vm.Matrix4.translation(vm.Vector3(1.9, 0.1, 0)), + mesh: Mesh(CuboidGeometry(vm.Vector3(1.5, 1.5, 1.5)), _cubeMaterial), + )..addComponent(_SpinComponent(0.7)), + ); + + _select(_SourceKind.video); + } + + void _applyEmissive() { + final factor = vm.Vector4(_emissive, _emissive, _emissive, 1.0); + _screenMaterial.emissiveFactor = factor; + _cubeMaterial.emissiveFactor = factor; + } + + /// Points both materials at [source], or at nothing when it is null. + void _bind(ExternalTexture? source) { + _screenMaterial + ..baseColorTexture = source + ..emissiveTexture = source; + _cubeMaterial + ..baseColorTexture = source + ..emissiveTexture = source; + } + + Future _select(_SourceKind kind) async { + if (_switching) return; + _switching = true; + setState(() { + _kind = kind; + _error = null; + }); + await _teardown(); + try { + switch (kind) { + case _SourceKind.video: + await _startVideo(); + case _SourceKind.camera: + await _startCamera(); + } + } catch (error) { + if (mounted) setState(() => _error = '$error'); + } finally { + _switching = false; + if (mounted) setState(() {}); + } + } + + Future _startVideo() async { + final controller = VideoPlayerController.asset('assets/testsrc.mp4'); + _video = controller; + await controller.initialize(); + await controller.setLooping(true); + await controller.setVolume(0); + await controller.play(); + if (!mounted) return; + + // video_player exposes a player id, not a texture id, so the texture id + // comes off the platform view it builds. A platform-view player has no + // texture id at all; capture a WidgetTexture around the preview widget in + // that case. + // ignore: invalid_use_of_visible_for_testing_member + final playerId = controller.playerId; + final view = VideoPlayerPlatform.instance.buildViewWithOptions( + VideoViewOptions(playerId: playerId), + ); + if (view is! Texture) { + throw StateError( + 'This platform builds video previews as a platform view, which has no ' + 'texture id to capture.', + ); + } + final size = controller.value.size; + _attach(view.textureId, size.width.round(), size.height.round()); + } + + Future _startCamera() async { + final cameras = await availableCameras(); + if (cameras.isEmpty) throw StateError('No cameras available.'); + final controller = CameraController( + cameras.first, + ResolutionPreset.medium, + enableAudio: false, + ); + _camera = controller; + await controller.initialize(); + if (!mounted) return; + final size = controller.value.previewSize!; + // cameraId is the texture id the plugin's own preview samples. + _attach(controller.cameraId, size.width.round(), size.height.round()); + } + + void _attach(int textureId, int width, int height) { + final source = ExternalTexture( + textureId: textureId, + width: width, + height: height, + ); + _source = source; + _bind(source); + } + + Future _teardown() async { + _bind(null); + _source?.dispose(); + _source = null; + final video = _video; + _video = null; + await video?.dispose(); + final camera = _camera; + _camera = null; + await camera?.dispose(); + } + + @override + void dispose() { + _teardown(); + super.dispose(); + } + + Widget _slider({ + required String label, + required double value, + required ValueChanged onChanged, + }) => Row( + children: [ + SizedBox( + width: 76, + child: Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + Expanded( + child: Slider( + value: value, + onChanged: (v) => setState(() => onChanged(v)), + ), + ), + SizedBox( + width: 32, + child: Text( + value.toStringAsFixed(2), + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + ), + ], + ); + + Widget _panel() { + final source = _source; + return Card( + color: Colors.black54, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const SizedBox( + width: 76, + child: Text( + 'Source', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ), + Expanded( + child: ExampleDropdown<_SourceKind>( + value: _kind, + triggerColor: Colors.white12, + padding: const EdgeInsets.symmetric(horizontal: 8), + isDense: true, + iconSize: 18, + style: const TextStyle(color: Colors.white, fontSize: 12), + items: [ + for (final kind in _SourceKind.values) + DropdownMenuItem(value: kind, child: Text(kind.label)), + ], + onChanged: (kind) { + if (kind != null) _select(kind); + }, + ), + ), + ], + ), + const SizedBox(height: 4), + _slider( + label: 'Roughness', + value: _roughness, + onChanged: (v) { + _roughness = v; + _screenMaterial.roughnessFactor = v; + _cubeMaterial.roughnessFactor = v; + }, + ), + _slider( + label: 'Metallic', + value: _metallic, + onChanged: (v) { + _metallic = v; + _screenMaterial.metallicFactor = v; + _cubeMaterial.metallicFactor = v; + }, + ), + _slider( + label: 'Emissive', + value: _emissive, + onChanged: (v) { + _emissive = v; + _applyEmissive(); + }, + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _error!, + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 11, + ), + ), + ) + else + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + source == null + ? 'Starting...' + : '${source.width}x${source.height}, ' + '${source.captureCount} captures, ' + 'last ${source.lastCaptureDuration.inMilliseconds} ms', + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + SceneView( + scene, + cameraBuilder: (elapsed) { + final t = elapsed.inMicroseconds / 1e6; + return PerspectiveCamera( + position: vm.Vector3(sin(t * 0.15) * 5.5, 1.6, 5.5), + target: vm.Vector3(0, 0, 0), + ); + }, + onTick: (elapsed, deltaSeconds) => exampleSettings.applyTo(scene), + ), + ExampleOverlay.bottomCenter( + child: SizedBox(width: 360, child: _panel()), + ), + ], + ); + } +} diff --git a/examples/flutter_app/lib/main.dart b/examples/flutter_app/lib/main.dart index f29a38cc..e659615e 100644 --- a/examples/flutter_app/lib/main.dart +++ b/examples/flutter_app/lib/main.dart @@ -38,6 +38,7 @@ import 'example_render_target.dart'; import 'example_settings.dart'; import 'example_shapes.dart'; import 'example_explosion.dart'; +import 'example_external_texture.dart'; import 'example_particles.dart'; import 'example_splats.dart'; import 'example_skybox.dart'; @@ -187,6 +188,7 @@ class _MyAppState extends State { 'Custom Skybox': (context) => const ExampleSkybox(), 'Audio': (context) => const ExampleAudio(), 'Widget Texture': (context) => const ExampleWidgetTexture(), + 'External Texture': (context) => const ExampleExternalTexture(), 'Accessibility': (context) => const ExampleAccessibility(), 'Render Targets': (context) => const ExampleRenderTarget(), 'Physics': (context) => FutureBuilder( diff --git a/examples/flutter_app/pubspec.yaml b/examples/flutter_app/pubspec.yaml index 415b120b..1e909d90 100644 --- a/examples/flutter_app/pubspec.yaml +++ b/examples/flutter_app/pubspec.yaml @@ -35,6 +35,9 @@ dependencies: archive: ^4.0.0 path_provider: ^2.1.0 vector_math: ^2.1.4 + video_player: ^2.13.0 + camera: ^0.12.0+2 + video_player_platform_interface: ^6.9.0 dev_dependencies: flutter_lints: ^5.0.0 @@ -46,6 +49,8 @@ flutter: assets: - assets/little_paris_eiffel_tower.png + # Looping test pattern for the External Texture example. + - assets/testsrc.mp4 # Generated test tones for the Audio example. - assets/sounds/ # Imported scenes are registered as DataAssets by buildScenes (loaded via diff --git a/packages/flutter_scene/CHANGELOG.md b/packages/flutter_scene/CHANGELOG.md index a3efd0a3..bd5d5daf 100644 --- a/packages/flutter_scene/CHANGELOG.md +++ b/packages/flutter_scene/CHANGELOG.md @@ -1,5 +1,6 @@ ## 0.20.1 +* `ExternalTexture` samples a platform texture as a material texture, so video, camera preview, and anything else a plugin registers with Flutter's texture registry can be drawn on scene geometry. Point it at a texture id and assign it to a texture slot; it captures on the frames that sample it. * `ShaderMaterial` owns the vertex stage too. Pass a `vertexShader` (per `MeshVariant`, so skinned and shadow passes can differ) and set uniforms and textures on either stage with `ShaderStage`, so a hand-written shader pair needs no subclassing. * Geometry can declare its own pipeline vertex layout with `setVertexLayout`, and `VertexAttributeDescriptor`/`VertexBufferDescriptor`/`VertexLayoutDescriptor` are public. * Custom vertex attributes work on skinned meshes. diff --git a/packages/flutter_scene/lib/scene.dart b/packages/flutter_scene/lib/scene.dart index e904fe92..eaa7e16e 100644 --- a/packages/flutter_scene/lib/scene.dart +++ b/packages/flutter_scene/lib/scene.dart @@ -191,6 +191,8 @@ export 'src/node.dart' show Node; export 'src/sprite.dart' show Sprite; export 'src/texture_atlas.dart' show TextureAtlas, generateSolidColorAtlasPixels; +export 'src/texture/external_texture.dart' + show ExternalTexture, ExternalTextureSampling, ExternalTextureUpdate; export 'src/texture/texture2d.dart' show Texture2D, TextureSource, TextureSampling, GpuTextureSource; export 'src/texture/texture_registry.dart' show loadTexture; diff --git a/packages/flutter_scene/lib/src/texture/external_texture.dart b/packages/flutter_scene/lib/src/texture/external_texture.dart new file mode 100644 index 00000000..e0b93433 --- /dev/null +++ b/packages/flutter_scene/lib/src/texture/external_texture.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; +import 'package:flutter_scene/src/texture/texture2d.dart'; + +/// When an [ExternalTexture] re-captures its source. +/// +/// The same shape as `WidgetUpdatePolicy` and `RenderTextureUpdate`, so every +/// live texture in the engine shares one mental model. +/// {@category Assets and loading} +sealed class ExternalTextureUpdate { + const ExternalTextureUpdate._(); + + /// Capture on every frame that draws this source (the default). + static const ExternalTextureUpdate everyFrame = _EveryFrameUpdate(); + + /// Capture at most once per [duration]. Use for a video on a distant + /// billboard, or any source that does not need to be current. + const factory ExternalTextureUpdate.interval(Duration duration) = + _IntervalUpdate; + + /// Capture only when [ExternalTexture.requestCapture] is called. + static const ExternalTextureUpdate manual = _ManualUpdate(); +} + +class _EveryFrameUpdate extends ExternalTextureUpdate { + const _EveryFrameUpdate() : super._(); +} + +class _IntervalUpdate extends ExternalTextureUpdate { + const _IntervalUpdate(this.duration) : super._(); + final Duration duration; +} + +class _ManualUpdate extends ExternalTextureUpdate { + const _ManualUpdate() : super._(); +} + +/// Sampling options used when a material samples an [ExternalTexture]. +/// +/// Defaults to bilinear with clamped edges, matching `RenderTextureSampling`. +/// Streamed frames carry no mip chain, so minification is not filtered; keep +/// the surface reasonably close to the camera or expect aliasing. +/// {@category Assets and loading} +class ExternalTextureSampling { + /// Creates sampling options. + const ExternalTextureSampling({ + this.filter = gpu.MinMagFilter.linear, + this.wrap = gpu.SamplerAddressMode.clampToEdge, + }); + + /// The minification/magnification filter. + final gpu.MinMagFilter filter; + + /// The addressing mode for texture coordinates outside `0..1`, applied to + /// both axes. + final gpu.SamplerAddressMode wrap; + + /// The equivalent sampler description. + @internal + gpu.SamplerOptions toSamplerOptions() => gpu.SamplerOptions( + minFilter: filter, + magFilter: filter, + widthAddressMode: wrap, + heightAddressMode: wrap, + ); +} + +/// A live [TextureSource] fed by a platform texture, so video, camera +/// preview, and any other native producer can be sampled by scene materials. +/// +/// Point it at the texture id a plugin registered with Flutter's texture +/// registry and assign it to a material slot. The frame is captured through +/// the same compositor path the `Texture` widget draws with, so whatever the +/// platform hands over (an Android external OES texture, a biplanar NV12 +/// buffer) arrives here as an ordinary RGBA texture that any material can +/// sample. +/// +/// ```dart +/// final video = ExternalTexture( +/// textureId: id, +/// width: 1920, +/// height: 1080, +/// ); +/// material.baseColorTexture = video; +/// // ... later +/// video.dispose(); +/// ``` +/// +/// [sampledTexture] is null until the first capture completes, and the +/// texture object is replaced on every capture, so read it through the +/// [TextureSource] each frame rather than caching it. Listeners fire after +/// each new frame is published. +/// +/// Captures are driven by drawing rather than by a ticker, so a source +/// nothing samples costs nothing, and a source sampled every frame keeps up +/// on its own. They are throttled to one in flight, so a source producing +/// frames faster than they can be captured skips ahead to the latest instead +/// of queueing. +/// +/// The capture is top-down (`v` of 0 is the top of the source), matching +/// [Texture2D]. +/// +/// Getting a texture id out of a plugin is not always possible. Some plugins +/// render through a platform view instead and expose no id, and some keep the +/// id private. Capture a `WidgetTexture` around the plugin's own preview +/// widget in that case; it costs an extra layout and paint but works for any +/// widget. +/// +/// Platform support follows the engine's ability to resolve a texture id +/// outside a live frame. Android works. The web has no platform textures at +/// all. macOS and iOS currently capture an empty frame, because the engine +/// resolves external textures for a snapshot without an Impeller context and +/// falls back to a Skia path that is inactive under Impeller; a debug build +/// warns when it sees this. Use a `WidgetTexture` around the plugin's preview +/// widget where that matters. +/// {@category Assets and loading} +class ExternalTexture extends ChangeNotifier implements TextureSource { + /// Creates a source capturing [textureId] at [width] x [height] pixels. + /// + /// [textureId] may be null when a plugin has not published one yet; set it + /// once it has. + ExternalTexture({ + int? textureId, + required int width, + required int height, + this.update = ExternalTextureUpdate.everyFrame, + this.sampling = const ExternalTextureSampling(), + }) : assert(width > 0 && height > 0, 'ExternalTexture size must be positive'), + _textureId = textureId, + _width = width, + _height = height; + + int? _textureId; + int _width; + int _height; + + /// When this source re-captures. See [ExternalTextureUpdate]. + ExternalTextureUpdate update; + + /// Sampling options used when a material samples this source. + ExternalTextureSampling sampling; + + gpu.Texture? _texture; + bool _captureInFlight = false; + bool _captureRequested = false; + bool _disposed = false; + DateTime? _lastCaptureStart; + Duration _lastCaptureDuration = Duration.zero; + int _captureCount = 0; + + // Cleared by the first capture that proves this platform cannot wrap a + // snapshot as a GPU texture (the web), so later frames stop trying. + bool _supported = true; + + /// The platform texture id being captured, or null if none is set yet. + int? get textureId => _textureId; + + set textureId(int? value) { + if (value == _textureId) return; + _textureId = value; + requestCapture(); + } + + /// The capture width in pixels. + int get width => _width; + + /// The capture height in pixels. + int get height => _height; + + /// The most recent frame, or null before the first capture completes. + /// + /// The object changes identity on every capture, so re-read it when drawing + /// rather than caching it. + gpu.Texture? get texture => _texture; + + /// The frame to sample this draw, kicking off the next capture when one is + /// due. Capture is driven from here rather than from a ticker, so a source + /// nothing samples costs nothing. + @override + gpu.Texture? get sampledTexture { + if (shouldCapture(DateTime.now())) unawaited(_capture()); + return _texture; + } + + @override + gpu.SamplerOptions get sampledSampler => sampling.toSamplerOptions(); + + /// Wall-clock duration of the last capture, for diagnostics. + Duration get lastCaptureDuration => _lastCaptureDuration; + + /// Total completed captures, for diagnostics. + int get captureCount => _captureCount; + + /// Captures at a new size. Takes effect on the next capture. + void resize(int width, int height) { + assert(width > 0 && height > 0, 'ExternalTexture size must be positive'); + if (width == _width && height == _height) return; + _width = width; + _height = height; + requestCapture(); + } + + /// Captures on the next draw that samples this source. The trigger for + /// [ExternalTextureUpdate.manual]; under the other policies it skips ahead + /// of the schedule. + void requestCapture() => _captureRequested = true; + + /// Whether a capture is due now, consuming a pending [requestCapture]. + @internal + bool shouldCapture(DateTime now) { + if (_disposed || !_supported || _textureId == null) return false; + if (_captureRequested) { + _captureRequested = false; + return true; + } + switch (update) { + case _EveryFrameUpdate(): + return true; + case _IntervalUpdate(:final duration): + final last = _lastCaptureStart; + return last == null || now.difference(last) >= duration; + case _ManualUpdate(): + return false; + } + } + + Future _capture() async { + final textureId = _textureId; + if (textureId == null || _captureInFlight || _disposed) return; + _captureInFlight = true; + final start = DateTime.now(); + _lastCaptureStart = start; + final stopwatch = Stopwatch()..start(); + try { + final width = _width; + final height = _height; + final builder = ui.SceneBuilder() + ..addTexture( + textureId, + width: width.toDouble(), + height: height.toDouble(), + filterQuality: ui.FilterQuality.none, + ); + final scene = builder.build(); + final ui.Image image; + try { + image = await scene.toImage(width, height); + } finally { + scene.dispose(); + } + try { + if (_disposed) return; + // The wrapper shares the image's storage and keeps it alive, so the + // image can be released as soon as it is wrapped. + final wrapped = gpu.Texture.fromImage(gpu.gpuContext, image); + _texture = wrapped; + _lastCaptureDuration = stopwatch.elapsed; + _captureCount++; + notifyListeners(); + if (kDebugMode && _captureCount == 1) { + await _debugWarnIfBlank(image, textureId); + } + } finally { + image.dispose(); + } + } on Exception catch (error) { + // Platform textures and snapshot wrapping are not available everywhere + // (the web has neither). Give up rather than retrying every frame. + _supported = false; + debugPrint( + 'ExternalTexture could not capture texture $textureId and is now ' + 'inactive. $error', + ); + } finally { + _captureInFlight = false; + } + } + + /// Warns once when the first capture came back fully transparent. + /// + /// A platform that cannot resolve a texture id into a snapshot still hands + /// back a well-formed empty image, which is indistinguishable from a source + /// that has not produced a frame yet, so the material just samples nothing + /// forever. Debug builds pay one readback to say so out loud. + Future _debugWarnIfBlank(ui.Image image, int textureId) async { + final bytes = await image.toByteData( + format: ui.ImageByteFormat.rawStraightRgba, + ); + if (bytes == null || _disposed) return; + for (var offset = 3; offset < bytes.lengthInBytes; offset += 4 * 64) { + if (bytes.getUint8(offset) != 0) return; + } + debugPrint( + 'ExternalTexture captured texture $textureId as a fully transparent ' + 'frame. The platform reported no error, so it most likely cannot ' + 'resolve platform textures into snapshots; check the logs for an ' + 'external texture error from the engine. Sample the plugin through a ' + 'WidgetTexture instead if this platform is required.', + ); + } + + @override + void dispose() { + _disposed = true; + _texture = null; + super.dispose(); + } +} diff --git a/packages/flutter_scene/test/external_texture_test.dart b/packages/flutter_scene/test/external_texture_test.dart new file mode 100644 index 00000000..635a7298 --- /dev/null +++ b/packages/flutter_scene/test/external_texture_test.dart @@ -0,0 +1,153 @@ +// Covers ExternalTexture: update-policy resolution (everyFrame/interval/ +// manual + requestCapture + resize forcing), the texture-id setter, sampler +// derivation, and disposal. The capture itself needs a live platform texture +// and a GPU, so it is exercised by the example app rather than here. + +import 'package:flutter_scene/scene.dart'; +import 'package:flutter_scene/src/gpu/gpu.dart' as gpu; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final t0 = DateTime(2026, 1, 1); + + ExternalTexture make({ + ExternalTextureUpdate update = ExternalTextureUpdate.everyFrame, + ExternalTextureSampling sampling = const ExternalTextureSampling(), + int? textureId = 7, + }) => ExternalTexture( + textureId: textureId, + width: 16, + height: 8, + update: update, + sampling: sampling, + ); + + group('update policy', () { + test('everyFrame captures on every check', () { + final source = make(); + expect(source.shouldCapture(t0), isTrue); + expect(source.shouldCapture(t0), isTrue); + source.dispose(); + }); + + test('manual only captures on request', () { + final source = make(update: ExternalTextureUpdate.manual); + expect(source.shouldCapture(t0), isFalse); + source.requestCapture(); + expect(source.shouldCapture(t0), isTrue); + expect(source.shouldCapture(t0), isFalse); + source.dispose(); + }); + + test('interval waits out the duration', () { + final source = make( + update: const ExternalTextureUpdate.interval(Duration(seconds: 1)), + ); + // Nothing captured yet, so the first check is always due. + expect(source.shouldCapture(t0), isTrue); + source.dispose(); + }); + + test('requestCapture overrides an interval that is not due', () { + final source = make( + update: const ExternalTextureUpdate.interval(Duration(hours: 1)), + ); + source.requestCapture(); + expect(source.shouldCapture(t0), isTrue); + source.dispose(); + }); + + test('resize forces a capture under a manual policy', () { + final source = make(update: ExternalTextureUpdate.manual); + expect(source.shouldCapture(t0), isFalse); + source.resize(32, 16); + expect(source.width, 32); + expect(source.height, 16); + expect(source.shouldCapture(t0), isTrue); + source.dispose(); + }); + + test('resize to the same size does not force a capture', () { + final source = make(update: ExternalTextureUpdate.manual); + source.resize(16, 8); + expect(source.shouldCapture(t0), isFalse); + source.dispose(); + }); + }); + + group('texture id', () { + test('may start null and be set later', () { + final source = make(textureId: null); + expect(source.textureId, isNull); + source.textureId = 3; + expect(source.textureId, 3); + source.dispose(); + }); + }); + + group('texture source contract', () { + test('samples nothing before the first capture', () { + final source = make(); + expect(source.sampledTexture, isNull); + expect(source.texture, isNull); + expect(source.captureCount, 0); + expect(source.lastCaptureDuration, Duration.zero); + source.dispose(); + }); + + test('is usable wherever a TextureSource is', () { + final source = make(); + expect(source, isA()); + source.dispose(); + }); + + test('derives the sampler from its sampling options', () { + final source = make( + sampling: const ExternalTextureSampling( + filter: gpu.MinMagFilter.nearest, + wrap: gpu.SamplerAddressMode.repeat, + ), + ); + final sampler = source.sampledSampler; + expect(sampler.minFilter, gpu.MinMagFilter.nearest); + expect(sampler.magFilter, gpu.MinMagFilter.nearest); + expect(sampler.widthAddressMode, gpu.SamplerAddressMode.repeat); + expect(sampler.heightAddressMode, gpu.SamplerAddressMode.repeat); + source.dispose(); + }); + + test('defaults to bilinear clamped sampling', () { + final source = make(); + final sampler = source.sampledSampler; + expect(sampler.minFilter, gpu.MinMagFilter.linear); + expect(sampler.magFilter, gpu.MinMagFilter.linear); + expect(sampler.widthAddressMode, gpu.SamplerAddressMode.clampToEdge); + source.dispose(); + }); + }); + + group('lifecycle', () { + test('rejects a non-positive size', () { + expect( + () => ExternalTexture(textureId: 1, width: 0, height: 4), + throwsAssertionError, + ); + }); + + test('drops its texture on dispose', () { + final source = make(); + source.dispose(); + expect(source.sampledTexture, isNull); + }); + + test('notifies listeners it is a ChangeNotifier', () { + final source = make(); + var calls = 0; + void listener() => calls++; + source.addListener(listener); + source.removeListener(listener); + expect(calls, 0); + source.dispose(); + }); + }); +}