From 5cbd89f2774cafbe10c438db5814262423b20245 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Tue, 18 Aug 2026 01:33:22 -0700 Subject: [PATCH 1/6] Tell the user why a frame drew nothing Four mistakes all produce an identical blank frame and only one said anything. A debug diagnostic on the zero-draw path names the cause it can distinguish, and degenerate cameras assert with the fix, including the degrees-for-radians field of view every other engine invites. All inside assert, so release pays nothing. --- packages/flutter_scene/lib/src/camera.dart | 28 ++- packages/flutter_scene/lib/src/scene.dart | 172 ++++++++++++++++++ .../test/blank_frame_diagnostic_test.dart | 112 ++++++++++++ .../test/camera_guards_test.dart | 85 +++++++++ 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 packages/flutter_scene/test/blank_frame_diagnostic_test.dart create mode 100644 packages/flutter_scene/test/camera_guards_test.dart diff --git a/packages/flutter_scene/lib/src/camera.dart b/packages/flutter_scene/lib/src/camera.dart index ccff81a3..9323042c 100644 --- a/packages/flutter_scene/lib/src/camera.dart +++ b/packages/flutter_scene/lib/src/camera.dart @@ -135,7 +135,21 @@ abstract class Camera { } Matrix4 _matrix4LookAt(Vector3 position, Vector3 target, Vector3 up) { - Vector3 forward = (target - position).normalized(); + final Vector3 viewDirection = target - position; + assert( + viewDirection.length2 > 1e-12, + 'Camera target equals its position, so the view direction is undefined and ' + 'the scene renders empty. Move target away from position.', + ); + assert( + up.cross(viewDirection).length2 > 1e-12, + 'Camera up is parallel to the view direction (position toward target), so ' + 'the view basis is degenerate and the scene renders empty. Use an up vector ' + 'that is not parallel to the view direction; for a top-down or bottom-up ' + 'camera use Vector3(0, 0, 1) or Vector3(0, 0, -1) in place of ' + 'Vector3(0, 1, 0).', + ); + Vector3 forward = viewDirection.normalized(); Vector3 right = up.cross(forward).normalized(); up = forward.cross(right).normalized(); @@ -165,6 +179,18 @@ Matrix4 _matrix4Perspective( double zNear, double zFar, ) { + assert( + fovRadiansY > 0 && fovRadiansY < pi, + 'fovRadiansY is $fovRadiansY, which is not a valid vertical field of view ' + 'in RADIANS (it must be between 0 and pi). A value like 60 is degrees; pass ' + '60 * degrees2Radians instead.', + ); + assert( + zNear > 0 && zFar > zNear, + 'The camera frustum is degenerate (near $zNear, far $zFar). near must be ' + 'greater than 0 and far must be greater than near, or the depth mapping ' + 'collapses and the scene renders empty or Z-fights.', + ); double height = tan(fovRadiansY * 0.5); double width = height * aspectRatio; diff --git a/packages/flutter_scene/lib/src/scene.dart b/packages/flutter_scene/lib/src/scene.dart index 5ad8028e..766d0389 100644 --- a/packages/flutter_scene/lib/src/scene.dart +++ b/packages/flutter_scene/lib/src/scene.dart @@ -173,6 +173,11 @@ base class Scene implements SceneGraph { AntiAliasingMode _antiAliasingMode = AntiAliasingMode.auto; bool _warnedUnsupportedAntiAliasing = false; + // Latches the "nothing was drawn" diagnostic so a one-frame layout transient + // does not spam. Reset whenever a frame draws, so a later regression prints + // again. See _reportBlankFrame. + bool _warnedBlankFrame = false; + /// The requested anti-aliasing strategy for this [Scene]. /// /// Defaults to [AntiAliasingMode.auto]. The requested mode is always @@ -1038,6 +1043,14 @@ base class Scene implements SceneGraph { final drawArea = region ?? canvas.getLocalClipBounds(); if (drawArea.isEmpty || views.isEmpty) { + assert(() { + _reportBlankFrame( + const [], + regionEmpty: drawArea.isEmpty, + noViews: views.isEmpty, + ); + return true; + }()); return; } @@ -1221,8 +1234,167 @@ base class Scene implements SceneGraph { // A frame has now been submitted; the next one runs on a warm context (see // the rebuild near the environment resolution above). _hasPresentedFrame = true; + + assert(() { + _reportBlankFrame(ordered, regionEmpty: false, noViews: false); + return true; + }()); + } + + // Debug-only. Detects a frame that issued zero draw calls and, once per + // occurrence, prints a message naming the causes it can tell apart, so a + // blank frame is not left unexplained. The latch resets whenever a frame + // draws, so a later regression prints again. Called only inside asserts, so + // it never runs in a release build; even in debug it stops at the first + // visible on-layer node, doing no extra work on a frame that draws. + void _reportBlankFrame( + List screenViews, { + required bool regionEmpty, + required bool noViews, + }) { + // Cheap draw test. A skybox fills the frame; otherwise a frame draws when + // some on-screen view has a visible node on its layer mask. A blank frame + // from a degenerate camera basis or a frustum-excluded camera aim is left + // to the camera asserts (a per-frame frustum cull here would tax the + // drawing path), so this test intentionally ignores the frustum. + var drewSomething = false; + if (!regionEmpty && !noViews && screenViews.isNotEmpty) { + if (skybox != null) { + drewSomething = true; + } else { + outer: + for (final view in screenViews) { + if (view.layerMask == 0) continue; + for (final item in renderScene.items) { + if (item.visible && (item.layers & view.layerMask) != 0) { + drewSomething = true; + break outer; + } + } + } + } + } + + // Only tallied when nothing drew (the broken case); skipped entirely on a + // frame that drew. + var visibleMeshCount = 0; + var visibleLayersUnion = 0; + if (!drewSomething) { + for (final item in renderScene.items) { + if (item.visible) { + visibleMeshCount++; + visibleLayersUnion |= item.layers; + } + } + } + + final result = debugEmptyFrameDiagnosis( + warned: _warnedBlankFrame, + drewSomething: drewSomething, + regionEmpty: regionEmpty, + noViews: noViews, + noScreenViews: !regionEmpty && !noViews && screenViews.isEmpty, + meshCount: renderScene.items.length, + visibleMeshCount: visibleMeshCount, + anyLayerMaskZero: screenViews.any((v) => v.layerMask == 0), + screenViewMasks: [for (final v in screenViews) v.layerMask], + visibleLayersUnion: visibleLayersUnion, + ); + _warnedBlankFrame = result.warned; + final message = result.message; + if (message != null) debugPrint(message); } + /// Diagnoses a frame that issued zero draw calls, for [renderViews]. + /// + /// Returns the message to print (null when nothing should print) and the + /// next state of the once-only latch. A frame that drew ([drewSomething]) + /// clears the latch so a later regression prints again; a blank frame prints + /// one message the first time and stays silent until a frame draws. The + /// caller invokes this inside an assert, so neither it nor the scan feeding + /// it costs anything in a release build. + @visibleForTesting + static ({String? message, bool warned}) debugEmptyFrameDiagnosis({ + required bool warned, + required bool drewSomething, + required bool regionEmpty, + required bool noViews, + required bool noScreenViews, + required int meshCount, + required int visibleMeshCount, + required bool anyLayerMaskZero, + required List screenViewMasks, + required int visibleLayersUnion, + }) { + if (drewSomething) return (message: null, warned: false); + if (warned) return (message: null, warned: true); + final cause = _blankFrameCause( + regionEmpty: regionEmpty, + noViews: noViews, + noScreenViews: noScreenViews, + meshCount: meshCount, + visibleMeshCount: visibleMeshCount, + anyLayerMaskZero: anyLayerMaskZero, + screenViewMasks: screenViewMasks, + visibleLayersUnion: visibleLayersUnion, + ); + return ( + message: 'Flutter Scene rendered a blank frame (zero draw calls). $cause', + warned: true, + ); + } + + // Names the most specific blank-frame cause that the given facts pin down. + // Every rung is a fact the diagnostic actually checked, in priority order, + // so the message never guesses. A degenerate camera basis and a + // frustum-excluded camera aim are handled by the camera asserts, not here. + static String _blankFrameCause({ + required bool regionEmpty, + required bool noViews, + required bool noScreenViews, + required int meshCount, + required int visibleMeshCount, + required bool anyLayerMaskZero, + required List screenViewMasks, + required int visibleLayersUnion, + }) { + if (noViews) { + return 'No RenderViews were supplied. Pass at least one RenderView to ' + 'renderViews, or use render for the single-camera case.'; + } + if (regionEmpty) { + return 'The draw region is zero-sized, so there is nowhere to draw. Pass ' + 'an explicit region to renderViews, or check the widget constraints ' + '(a collapsed or unconstrained layout gives an empty canvas clip).'; + } + if (noScreenViews) { + return 'Every RenderView has a target, so nothing composites to the ' + 'screen. Add a RenderView whose target is null for the on-screen ' + 'view.'; + } + if (meshCount == 0) { + return 'The scene graph holds no meshes. Add a Node with a Mesh under ' + 'the scene root before rendering.'; + } + if (visibleMeshCount == 0) { + return 'Every mesh in the scene is hidden. Node.visible is false on each ' + 'mesh or on an ancestor; set visible to true on the nodes to draw.'; + } + if (anyLayerMaskZero) { + return 'A RenderView.layerMask is 0, which matches no Node.layers, so ' + 'that view draws nothing. Use kRenderLayerAll to see every layer, or ' + 'a bitmask such as (1 << 2) to select layer 2.'; + } + final masks = screenViewMasks.map(_hexMask).join(', '); + return 'No visible node is on any view layerMask (view masks $masks, node ' + 'layers ${_hexMask(visibleLayersUnion)}). Node.layers is not inherited ' + 'by children, so set it on each node the view should see, or widen the ' + 'view mask.'; + } + + static String _hexMask(int mask) => + '0x${(mask & 0xFFFFFFFF).toRadixString(16)}'; + // Whether at least one frame has been rendered and presented, so the web // GL context is warm enough for a correct radiance prefilter. static bool _hasPresentedFrame = false; diff --git a/packages/flutter_scene/test/blank_frame_diagnostic_test.dart b/packages/flutter_scene/test/blank_frame_diagnostic_test.dart new file mode 100644 index 00000000..32bf3db4 --- /dev/null +++ b/packages/flutter_scene/test/blank_frame_diagnostic_test.dart @@ -0,0 +1,112 @@ +// The "nothing was drawn" frame diagnostic. Four distinct mistakes all +// produce an identical blank frame; when a frame issues zero draw calls the +// engine prints once, naming the cause it can tell apart. Scene.renderViews +// needs a GPU context, so the cause attribution and the once-only latch are +// tested here through the pure Scene.debugEmptyFrameDiagnosis, the same logic +// the render path calls inside an assert. + +import 'package:flutter_scene/scene.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Facts for a frame that drew nothing with visible meshes present, so each +// test overrides only the field for the cause it exercises. +({String? message, bool warned}) diagnose({ + bool warned = false, + bool drewSomething = false, + bool regionEmpty = false, + bool noViews = false, + bool noScreenViews = false, + int meshCount = 1, + int visibleMeshCount = 1, + bool anyLayerMaskZero = false, + List screenViewMasks = const [0xFFFFFFFF], + int visibleLayersUnion = 0x1, +}) { + return Scene.debugEmptyFrameDiagnosis( + warned: warned, + drewSomething: drewSomething, + regionEmpty: regionEmpty, + noViews: noViews, + noScreenViews: noScreenViews, + meshCount: meshCount, + visibleMeshCount: visibleMeshCount, + anyLayerMaskZero: anyLayerMaskZero, + screenViewMasks: screenViewMasks, + visibleLayersUnion: visibleLayersUnion, + ); +} + +void main() { + group('cause attribution', () { + test('no views supplied', () { + final r = diagnose(noViews: true); + expect(r.message, contains('No RenderViews were supplied')); + }); + + test('empty draw region', () { + final r = diagnose(regionEmpty: true); + expect(r.message, contains('draw region is zero-sized')); + }); + + test('no on-screen views (every view has a target)', () { + final r = diagnose(noScreenViews: true); + expect(r.message, contains('Every RenderView has a target')); + }); + + test('scene holds no meshes', () { + final r = diagnose(meshCount: 0, visibleMeshCount: 0); + expect(r.message, contains('holds no meshes')); + }); + + test('every mesh hidden', () { + final r = diagnose(visibleMeshCount: 0); + expect(r.message, contains('Every mesh in the scene is hidden')); + }); + + test('a layer mask of zero', () { + final r = diagnose(anyLayerMaskZero: true, screenViewMasks: const [0]); + expect(r.message, contains('layerMask is 0')); + }); + + test('layer mask matched no visible node', () { + final r = diagnose(screenViewMasks: const [0x4], visibleLayersUnion: 0x1); + expect(r.message, contains('No visible node is on any view layerMask')); + // The message names both sides so the mismatch is fixable from it. + expect(r.message, contains('0x4')); + expect(r.message, contains('0x1')); + }); + + test('every blank cause is labelled a zero-draw frame', () { + final r = diagnose(noViews: true); + expect(r.message, contains('zero draw calls')); + }); + }); + + group('latch', () { + test('a blank frame warns once, then stays silent', () { + final first = diagnose(warned: false, meshCount: 0); + expect(first.message, isNotNull); + expect(first.warned, isTrue); + + final second = diagnose(warned: first.warned, meshCount: 0); + expect(second.message, isNull); + expect(second.warned, isTrue); + }); + + test('a drawing frame prints nothing and clears the latch', () { + final r = diagnose(warned: true, drewSomething: true); + expect(r.message, isNull); + expect(r.warned, isFalse); + }); + + test('a regression after a drawing frame warns again', () { + // draws -> latch clear + final drew = diagnose(warned: true, drewSomething: true); + expect(drew.warned, isFalse); + // blank again -> warns once more + final blankAgain = diagnose(warned: drew.warned, meshCount: 0); + expect(blankAgain.message, isNotNull); + expect(blankAgain.warned, isTrue); + }); + }); +} diff --git a/packages/flutter_scene/test/camera_guards_test.dart b/packages/flutter_scene/test/camera_guards_test.dart new file mode 100644 index 00000000..9c553132 --- /dev/null +++ b/packages/flutter_scene/test/camera_guards_test.dart @@ -0,0 +1,85 @@ +// Debug-only asserts guarding the camera against silent blank-frame +// mistakes: a degenerate view basis (up parallel to the view direction, or +// target == position) and a degenerate or degrees-valued frustum. These run +// per view per frame, so they live in asserts and are exercised here by +// building the matrices directly. + +import 'dart:math'; + +import 'package:flutter_scene/scene.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +void main() { + group('view basis', () { + test('target equal to position throws', () { + final camera = PerspectiveCamera( + position: Vector3(1, 2, 3), + target: Vector3(1, 2, 3), + ); + expect(camera.getViewMatrix, throwsAssertionError); + }); + + test('up parallel to the view direction throws', () { + // Looking straight down the +Y axis with the default +Y up. + final camera = PerspectiveCamera( + position: Vector3(0, 10, 0), + target: Vector3(0, 0, 0), + up: Vector3(0, 1, 0), + ); + expect(camera.getViewMatrix, throwsAssertionError); + }); + + test('a non-parallel up passes', () { + final camera = PerspectiveCamera( + position: Vector3(0, 10, 0), + target: Vector3(0, 0, 0), + up: Vector3(0, 0, 1), + ); + expect(camera.getViewMatrix(), isA()); + }); + + test('the default placement passes', () { + expect(PerspectiveCamera().getViewMatrix(), isA()); + }); + }); + + group('frustum', () { + Matrix4 project(PerspectiveCamera camera) => + camera.projection.getProjectionMatrix(1.0); + + test('a degrees-valued field of view throws', () { + final camera = PerspectiveCamera(fovRadiansY: 60); + expect(() => project(camera), throwsAssertionError); + }); + + test('a zero near plane throws', () { + final camera = PerspectiveCamera(fovNear: 0.0); + expect(() => project(camera), throwsAssertionError); + }); + + test('a negative near plane throws', () { + final camera = PerspectiveCamera(fovNear: -1.0); + expect(() => project(camera), throwsAssertionError); + }); + + test('far behind near throws', () { + final camera = PerspectiveCamera(fovNear: 100.0, fovFar: 10.0); + expect(() => project(camera), throwsAssertionError); + }); + + test('a valid frustum passes', () { + final camera = PerspectiveCamera( + fovRadiansY: 45 * degrees2Radians, + fovNear: 0.1, + fovFar: 1000.0, + ); + expect(project(camera), isA()); + }); + + test('a near-pi field of view still passes', () { + final camera = PerspectiveCamera(fovRadiansY: pi - 0.01); + expect(project(camera), isA()); + }); + }); +} From e07c0d30157703a81f0ea91801a479709ebe530a Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Tue, 18 Aug 2026 01:33:22 -0700 Subject: [PATCH 2/6] Throw on a vertex buffer that does not match its count A skinned upload and setCustomAttribute took any length and rendered garbage. Both check the byte count now. A mesh also recomputes bounds when its primitive geometry is replaced, instead of over-culling until a manual dirty call. --- .../lib/src/geometry/geometry.dart | 37 +++++++++ packages/flutter_scene/lib/src/mesh.dart | 37 ++++++--- packages/flutter_scene/test/bounds_test.dart | 21 +++-- .../test/geometry_validation_test.dart | 82 +++++++++++++++++++ 4 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 packages/flutter_scene/test/geometry_validation_test.dart diff --git a/packages/flutter_scene/lib/src/geometry/geometry.dart b/packages/flutter_scene/lib/src/geometry/geometry.dart index 576118c7..96c6669a 100644 --- a/packages/flutter_scene/lib/src/geometry/geometry.dart +++ b/packages/flutter_scene/lib/src/geometry/geometry.dart @@ -301,6 +301,18 @@ abstract class Geometry { 'must be between 1 and 4', ); } + // _vertexCount is 0 until the first vertex upload, so a mismatch can only + // be judged once the count is known. Setting the attribute first is a + // legitimate ordering, covered by the message clause below. + if (_vertexCount > 0 && data.length != _vertexCount * components) { + throw ArgumentError( + 'Custom attribute "$name" has ${data.length} floats, but this geometry ' + 'has $_vertexCount vertices at $components components each, so it needs ' + '${_vertexCount * components}. Set the attribute after uploading ' + 'vertices, and re-set it after any rebuild that changes the vertex ' + 'count.', + ); + } final bytes = ByteData.sublistView(data); final buffer = gpu.gpuContext.createDeviceBuffer( gpu.StorageMode.hostVisible, @@ -350,6 +362,20 @@ abstract class Geometry { ByteData? indices, { gpu.IndexType indexType = gpu.IndexType.int16, }) { + final stride = _expectedVertexStrideInBytes; + if (stride != null && vertices.lengthInBytes != vertexCount * stride) { + throw ArgumentError( + 'uploadVertexData got ${vertices.lengthInBytes} bytes for $vertexCount ' + 'vertices, but $runtimeType packs a $stride-byte vertex, so it needs ' + '${vertexCount * stride} bytes. Repack at $stride bytes per vertex ' + '(position 3, normal 3, tex_coords_0 2, tex_coords_1 2, color 4, ' + 'tangent 4' + '${stride == kSkinnedPerVertexSize ? ', joints 4, weights 4' : ''}, ' + 'all float32, in that order), or supply attributes as separate arrays ' + 'with MeshGeometry.fromArrays.', + ); + } + _cpuVertices = vertices; _cpuIndices = indices; @@ -644,6 +670,11 @@ abstract class Geometry { /// importer didn't bake one (notably the runtime GLB importer). bool get _autoScanBoundsOnUpload => true; + /// The exact interleaved vertex stride [uploadVertexData] expects, or + /// null for a caller-defined layout it does not police. The unskinned + /// and skinned subclasses override it with their fixed strides. + int? get _expectedVertexStrideInBytes => null; + /// Scan the position attribute (the first 12 bytes of each vertex, /// shared across the unskinned and skinned layouts) /// to populate [_localBounds] and [_localBoundingSphere]. @@ -929,6 +960,9 @@ class UnskinnedGeometry extends Geometry { // not flip this (they never change how the built-in attributes are stored). bool get _isDeInterleaved => _vertexStreams.length >= 2; + @override + int get _expectedVertexStrideInBytes => kUnskinnedPerVertexSize; + @override List _vertexStreamBytes(ByteData vertices, int vertexCount) { final streams = InterleavedLayoutAdapter.splitUnskinnedAttributes( @@ -1152,6 +1186,9 @@ class SkinnedGeometry extends Geometry { @override bool get _autoScanBoundsOnUpload => false; + @override + int get _expectedVertexStrideInBytes => kSkinnedPerVertexSize; + @override void setJointsTexture(gpu.Texture? texture, int width) { _jointsTexture = texture; diff --git a/packages/flutter_scene/lib/src/mesh.dart b/packages/flutter_scene/lib/src/mesh.dart index a2d2a3a9..e1ce3c3d 100644 --- a/packages/flutter_scene/lib/src/mesh.dart +++ b/packages/flutter_scene/lib/src/mesh.dart @@ -61,17 +61,17 @@ base class Mesh { vm.Aabb3? _localBoundsCache; bool _localBoundsCached = false; List? _cachedBoundsVersions; + List? _cachedGeometries; /// Local-space union of every primitive's [Geometry.localBounds], or /// `null` when no primitive has computable bounds. /// - /// Cached. The cache refreshes itself when a primitive's geometry - /// reports a new [Geometry.localBoundsVersion], so an updatable - /// geometry that is mutated in place stays correct without an explicit - /// invalidation. Call [markLocalBoundsDirty] after replacing a - /// primitive's geometry. + /// Cached. The cache refreshes itself when a primitive's geometry is + /// replaced, or reports a new [Geometry.localBoundsVersion], so swapping + /// or mutating a primitive's geometry stays correct without an explicit + /// invalidation. vm.Aabb3? get localBounds { - if (_localBoundsCached && _boundsVersionsUnchanged()) { + if (_localBoundsCached && _boundsCacheStillValid()) { return _localBoundsCache; } vm.Aabb3? result; @@ -89,27 +89,40 @@ base class Mesh { _cachedBoundsVersions = [ for (final p in primitives) p.geometry.localBoundsVersion, ]; + _cachedGeometries = [for (final p in primitives) p.geometry]; return result; } - bool _boundsVersionsUnchanged() { - final snapshot = _cachedBoundsVersions; - if (snapshot == null || snapshot.length != primitives.length) { + // Valid only if the primitive list still holds the same geometry instances + // (identity) at the same bounds versions the cache was built from. The + // identity compare is what catches a replaced primitive geometry, whose + // fresh instance can share the old one's version number. + bool _boundsCacheStillValid() { + final versions = _cachedBoundsVersions; + final geometries = _cachedGeometries; + if (versions == null || + geometries == null || + versions.length != primitives.length) { return false; } for (var i = 0; i < primitives.length; i++) { - if (snapshot[i] != primitives[i].geometry.localBoundsVersion) { + final geometry = primitives[i].geometry; + if (!identical(geometries[i], geometry) || + versions[i] != geometry.localBoundsVersion) { return false; } } return true; } - /// Invalidate the cached [localBounds]. Call this after replacing a - /// primitive's geometry. + /// Invalidate the cached [localBounds]. Rarely needed, since the cache + /// already detects a replaced primitive geometry and a bumped + /// [Geometry.localBoundsVersion]; call this only after mutating a + /// geometry's bounds through a path that leaves its version unchanged. void markLocalBoundsDirty() { _localBoundsCache = null; _localBoundsCached = false; _cachedBoundsVersions = null; + _cachedGeometries = null; } } diff --git a/packages/flutter_scene/test/bounds_test.dart b/packages/flutter_scene/test/bounds_test.dart index 702c6e8a..068e53d8 100644 --- a/packages/flutter_scene/test/bounds_test.dart +++ b/packages/flutter_scene/test/bounds_test.dart @@ -124,19 +124,30 @@ void main() { expect(m.localBounds!.max, Vector3(2, 2, 2)); }); - test('caches the result and rebuilds on markLocalBoundsDirty', () { + test('self-invalidates when a primitive geometry is replaced', () { final p = _primWithBounds(_aabb(Vector3(0, 0, 0), Vector3(1, 1, 1))); final m = Mesh.primitives(primitives: [p]); // Prime the cache. expect(m.localBounds!.max, Vector3(1, 1, 1)); - // Swap in a different geometry; without invalidation, the cache - // would still report the old extents. + // Swap in a different geometry instance. Both stubs report the same + // localBoundsVersion (1), so identity is what invalidates the cache; + // no markLocalBoundsDirty call is needed. p.geometry = _StubGeometry( aabb: _aabb(Vector3(0, 0, 0), Vector3(5, 5, 5)), ); - expect(m.localBounds!.max, Vector3(1, 1, 1), reason: 'cache hit'); + expect(m.localBounds!.max, Vector3(5, 5, 5)); + }); + + test('markLocalBoundsDirty still forces a rebuild', () { + final p = _primWithBounds(_aabb(Vector3(0, 0, 0), Vector3(1, 1, 1))); + final m = Mesh.primitives(primitives: [p]); + expect(m.localBounds!.max, Vector3(1, 1, 1)); + p.geometry.setLocalBounds( + _aabb(Vector3(0, 0, 0), Vector3(9, 9, 9)), + Sphere.centerRadius(Vector3.zero(), 1), + ); m.markLocalBoundsDirty(); - expect(m.localBounds!.max, Vector3(5, 5, 5), reason: 'after dirty'); + expect(m.localBounds!.max, Vector3(9, 9, 9)); }); test('refreshes when a geometry mutates its bounds in place', () { diff --git a/packages/flutter_scene/test/geometry_validation_test.dart b/packages/flutter_scene/test/geometry_validation_test.dart new file mode 100644 index 00000000..e71e6790 --- /dev/null +++ b/packages/flutter_scene/test/geometry_validation_test.dart @@ -0,0 +1,82 @@ +// Upload- and construction-time validation on the low-level geometry entry +// points. These checks throw before any GPU access, so they run headlessly +// without a Flutter GPU context. + +import 'dart:typed_data'; + +import 'package:flutter_scene/src/geometry/geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('uploadVertexData stride validation', () { + test('skinned upload throws on a wrong stride', () { + // 96 bytes per vertex (a legacy layout missing UV1), where the skinned + // layout is 104. Would otherwise upload and render as washed-out colors + // and see-through faces. + final wrong = ByteData(96 * 3); + expect( + () => SkinnedGeometry().uploadVertexData(wrong, 3, null), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('104-byte'), contains('joints 4, weights 4')), + ), + ), + ); + }); + + test('unskinned upload throws on a wrong stride', () { + // 72-byte layout given 80 bytes per vertex. + final wrong = ByteData(80 * 4); + expect( + () => UnskinnedGeometry().uploadVertexData(wrong, 4, null), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('72-byte'), + ), + ), + ); + }); + + test('a too-long buffer is rejected, not just a too-short one', () { + // Right stride, wrong vertexCount: 104 * 5 bytes described as 3 vertices. + final tooLong = ByteData(104 * 5); + expect( + () => SkinnedGeometry().uploadVertexData(tooLong, 3, null), + throwsArgumentError, + ); + }); + }); + + group('setCustomAttribute arity validation', () { + test('throws when data length does not match vertexCount * components', () { + final g = UnskinnedGeometry(); + // Set the vertex count headlessly (no GPU); an empty stream list binds + // nothing but records the count the arity check reads. + g.setVertexStreams(const [], 4); + // 4 vertices at 2 components needs 8 floats; supply 9. + expect( + () => g.setCustomAttribute('a_wind', Float32List(9), components: 2), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('9 floats'), contains('needs 8')), + ), + ), + ); + }); + + test('the components guard still throws for an out-of-range count', () { + final g = UnskinnedGeometry(); + g.setVertexStreams(const [], 4); + expect( + () => g.setCustomAttribute('a', Float32List(20), components: 5), + throwsArgumentError, + ); + }); + }); +} From c0ebdbfd793ee1aef6987f88c6f9b7c3b28b71cb Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Tue, 18 Aug 2026 01:33:22 -0700 Subject: [PATCH 3/6] Match native by throwing on a bind to a missing shader slot The web shim swallowed a bind to a uniform or texture name the shader does not declare, so a typo sampled whatever was bound last, wrong on web only. It throws now, from the same reflection native reads. --- .../lib/src/gpu/web/render_pass.dart | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/flutter_scene/lib/src/gpu/web/render_pass.dart b/packages/flutter_scene/lib/src/gpu/web/render_pass.dart index 211c4af6..1a13f969 100644 --- a/packages/flutter_scene/lib/src/gpu/web/render_pass.dart +++ b/packages/flutter_scene/lib/src/gpu/web/render_pass.dart @@ -558,7 +558,19 @@ base class RenderPass { throw StateError('bindUniform called before bindPipeline'); } final struct = slot.shader._uniformStructs[slot.uniformName]; - if (struct == null) return; + if (struct == null) { + // Match the native backend, which throws when the shader has no uniform + // slot of this name. Swallowing it silently binds nothing and the draw + // reads stale uniform state, wrong only on web and only for some draw + // orders. The lookup reads the shader's live reflection, so it stays + // correct across hot reload. + throw StateError( + 'Failed to bind uniform. This shader declares no uniform block named ' + '"${slot.uniformName}". A block binds by its type name, not its ' + 'instance name, and a block nothing in the shader reads is optimized ' + 'out by the compiler and reflects as absent.', + ); + } final gl = _gpuContext._gl; @@ -680,7 +692,20 @@ base class RenderPass { throw StateError('bindTexture called before bindPipeline'); } final unit = pipeline._samplerUnits[slot.uniformName]; - if (unit == null) return; + if (unit == null) { + // Match the native backend, which throws when the shader has no sampler + // of this name. Swallowing it silently leaves the unit unbound and the + // draw samples whatever texture that unit last held, wrong only on web + // and varying with draw order. _samplerUnits comes from the shader's + // live reflection, not GL introspection, so a reflected-but-unused + // sampler still resolves and only a truly absent name throws. + throw StateError( + 'Failed to bind texture. This shader declares no texture named ' + '"${slot.uniformName}". Check the sampler name spelling, and note ' + 'that a sampler nothing in the shader reads is optimized out by the ' + 'compiler and reflects as absent.', + ); + } final gl = _gpuContext._gl; final target = texture.glTarget; From cc1ed98c08319482e17dccdf73345e21b04b1637 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Tue, 18 Aug 2026 01:33:22 -0700 Subject: [PATCH 4/6] Assert when an animation clip binds none of its channels A clip bound to the wrong node plays while nothing moves. Binding zero of a non-empty clip's channels now asserts in debug, naming the wanted nodes so a name mismatch is visible. Partial binds stay silent. --- .../lib/src/animation/animation_clip.dart | 26 ++++++++ .../test/animation_clip_test.dart | 62 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/flutter_scene/lib/src/animation/animation_clip.dart b/packages/flutter_scene/lib/src/animation/animation_clip.dart index e7251938..5f322315 100644 --- a/packages/flutter_scene/lib/src/animation/animation_clip.dart +++ b/packages/flutter_scene/lib/src/animation/animation_clip.dart @@ -170,6 +170,32 @@ class AnimationClip { if (channelTarget == null) continue; _bindings.add(_ChannelBinding(channel, channelTarget)); } + assert(_checkAnyChannelBound(target)); + } + + // Debug-only. Fires when the bind resolves zero channels against a + // non-empty animation, which almost always means the clip was bound to the + // wrong node (the mesh instead of the scene root) or the node names do not + // match the animation's targets. A partial bind (some channels hit, some + // miss) is a supported retarget onto a subset of the rig, so it stays + // silent rather than risk a false positive on a legitimate use. + bool _checkAnyChannelBound(Node target) { + if (_bindings.isNotEmpty || _animation.channels.isEmpty) { + return true; + } + final wanted = { + for (final channel in _animation.channels) channel.bindTarget.nodeName, + }; + final sample = wanted.take(5).join(', '); + final more = wanted.length > 5 ? ', and ${wanted.length - 5} more' : ''; + throw StateError( + 'AnimationClip bound 0 of ${_animation.channels.length} channels against ' + '"${target.name}", so the clip will play and nothing will move. None of ' + 'the nodes this animation targets exist in that subtree; bind to the ' + 'subtree root that holds the animated nodes (usually the imported scene ' + 'root, not the mesh node), and check the names match. Nodes wanted: ' + '$sample$more.', + ); } /// Evaluates each bound channel at [playbackTime] and accumulates the diff --git a/packages/flutter_scene/test/animation_clip_test.dart b/packages/flutter_scene/test/animation_clip_test.dart index 42b955a8..cbec2df3 100644 --- a/packages/flutter_scene/test/animation_clip_test.dart +++ b/packages/flutter_scene/test/animation_clip_test.dart @@ -35,6 +35,21 @@ AnimationClip _makeEmptyClip(Node node) { return node.createAnimationClip(animation); } +/// A translation channel targeting the node named [nodeName]. +AnimationChannel _translationChannel(String nodeName) => AnimationChannel( + bindTarget: BindKey(nodeName: nodeName), + resolver: PropertyResolver.makeTranslationTimeline( + [0.0, 1.0], + [Vector3.zero(), Vector3(1, 0, 0)], + ), +); + +/// An animation whose channels target [nodeNames]. +Animation _animationTargeting(List nodeNames) => Animation( + name: 'test', + channels: [for (final name in nodeNames) _translationChannel(name)], +); + void main() { group('initial state', () { test('clip starts paused at time 0 with weight 1', () { @@ -236,4 +251,51 @@ void main() { expect(clip.playbackTime, 0); }); }); + + group('zero-bind diagnostic', () { + test('throws when every channel targets a missing node', () { + final root = Node(name: 'root')..add(Node(name: 'hip')); + final animation = _animationTargeting(['spine', 'head', 'tail']); + expect( + () => AnimationClip(animation, root), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('bound 0 of 3 channels'), + contains('spine'), + contains('root'), + ), + ), + ), + ); + }); + + test('does not throw on a legitimate partial bind', () { + final root = Node(name: 'root')..add(Node(name: 'hip')); + final animation = _animationTargeting(['hip', 'spine', 'head']); + expect(() => AnimationClip(animation, root), returnsNormally); + }); + + test('is silent on a full successful bind', () { + final root = Node(name: 'root') + ..add(Node(name: 'hip')) + ..add(Node(name: 'spine')); + final animation = _animationTargeting(['hip', 'spine']); + expect(() => AnimationClip(animation, root), returnsNormally); + }); + + test('is silent on an empty animation', () { + final animation = Animation(name: 'empty', channels: []); + expect(() => AnimationClip(animation, Node(name: 'n')), returnsNormally); + }); + + test('rebind onto a wrong subtree throws', () { + final good = Node(name: 'root')..add(Node(name: 'hip')); + final clip = AnimationClip(_animationTargeting(['hip']), good); + final wrong = Node(name: 'mesh'); + expect(() => clip.rebind(wrong), throwsA(isA())); + }); + }); } From 591ae3a9b61c7df90d6ddd2cc7d360800220c8a5 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Tue, 18 Aug 2026 01:33:22 -0700 Subject: [PATCH 5/6] Explain why the native build ships an unused GLES bundle The redundant hook invocation is byte-identical to a web build, which needs that bundle, so it cannot be skipped at this layer. Document the constraint and lock it with a test that flips if the invoker ever names the platform. --- .../lib/src/fmat/target_shader_bundle.dart | 22 +++++-- .../test/generated_target_isolation_test.dart | 64 +++++++++++++++++-- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/flutter_scene/lib/src/fmat/target_shader_bundle.dart b/packages/flutter_scene/lib/src/fmat/target_shader_bundle.dart index 969a2371..a3a6256b 100644 --- a/packages/flutter_scene/lib/src/fmat/target_shader_bundle.dart +++ b/packages/flutter_scene/lib/src/fmat/target_shader_bundle.dart @@ -164,14 +164,22 @@ Future buildTargetShaderBundleJson({ /// Returns the backend set needed by [buildInput]. /// /// A config with no code assets names no target OS, which is web and also the -/// second invocation `flutter run` makes for a native target. Both resolve to -/// GLES, so the native one must never overwrite the target build's outputs; -/// [shaderBundleTargetKey] is what keeps them apart in the tree. +/// data-asset-only invocation `flutter run` makes for a native target. Both +/// resolve to GLES, so the native one must never overwrite the target build's +/// outputs; [shaderBundleTargetKey] is what keeps them apart in the tree. /// -/// TODO(hook-target-invocations): that second invocation still compiles and -/// ships a GLES set no native app loads, roughly 1.5 MB in the bundle, because -/// nothing in one hook input distinguishes it from a web build. Skipping it -/// needs the invoker to say which it is. +/// That native data-only pass still compiles and ships a GLES set no native app +/// loads, roughly 1.5 MB. It cannot be dropped here. Its hook input is +/// byte-identical to a real web build's (both just `data_assets/data`, no code +/// config, no target OS), and web genuinely needs the GLES set, so nothing the +/// hook can read tells the wasteful native pass from the required web one. The +/// invoker knows the target platform but never puts it on a data-asset-only +/// input. `generated_target_isolation_test.dart` locks that indistinguishability. +/// +/// TODO(hook-target-invocations): dropping the waste needs an upstream change, +/// the invoker naming the target platform (or final artifact) on data-asset-only +/// inputs, or flutter_tools not making the redundant native data pass. Revisit +/// if a Flutter release adds such a field. Set shaderBundleBackendsForBuild(BuildInput buildInput) => shaderBundleBackendsForOS( buildInput.config.buildCodeAssets diff --git a/packages/flutter_scene/test/generated_target_isolation_test.dart b/packages/flutter_scene/test/generated_target_isolation_test.dart index 16669f61..25ca3fa9 100644 --- a/packages/flutter_scene/test/generated_target_isolation_test.dart +++ b/packages/flutter_scene/test/generated_target_isolation_test.dart @@ -1,15 +1,17 @@ /// One `flutter run` invokes the hook twice, once with the target's code-asset -/// config and once with no asset types at all, and a pub-cache tree is shared -/// by every project on the machine. So several builds write one generated tree -/// with different graphics backends in mind, and an output compiled for one -/// backend is unreadable on another. Every such output is separated by target, -/// in its file name and in the manifest, and the runtime reads back only the -/// target it runs on. +/// config and once with only data assets and no target OS, and a pub-cache tree +/// is shared by every project on the machine. So several builds write one +/// generated tree with different graphics backends in mind, and an output +/// compiled for one backend is unreadable on another. Every such output is +/// separated by target, in its file name and in the manifest, and the runtime +/// reads back only the target it runs on. library; +import 'dart:convert'; import 'dart:io'; import 'package:code_assets/code_assets.dart'; +import 'package:data_assets/data_assets.dart'; import 'package:flutter_scene/src/fmat/target_shader_bundle.dart'; import 'package:flutter_scene/src/generated_assets/generated_asset_lookup.dart'; import 'package:flutter_scene/src/generated_assets/generated_assets.dart'; @@ -47,6 +49,24 @@ BuildInput _input(Uri packageRoot, {OS? targetOS}) { return builder.build(); } +/// The data-asset-only hook input `flutter run` makes for both a native +/// build's second pass (code assets off) and a web build. flutter_tools reduces +/// `MacOSAssetTarget` (with no code assets) and `WebAssetTarget` to this same +/// single data-asset extension, so the two inputs are constructed identically. +BuildInput _dataOnlyInput(Uri packageRoot) { + final builder = BuildInputBuilder() + ..setupShared( + packageRoot: packageRoot, + packageName: 'app', + outputDirectoryShared: packageRoot.resolve('.dart_tool/hook/'), + outputFile: packageRoot.resolve('.dart_tool/hook/output.json'), + ); + DataAssetsExtension().setupBuildInput(builder); + builder.config.setupBuild(linkingEnabled: false); + builder.setupBuildInput(); + return builder.build(); +} + GeneratedAssetTree _tree(Directory temp) => GeneratedAssetTree.open(temp.uri, 'app'); @@ -74,6 +94,38 @@ void main() { ); }); + test( + 'a native build\'s data-only pass is indistinguishable from a web build', + () { + // The wasteful native GLES compile cannot be skipped at this layer. The + // data-asset-only input carries no target OS, so it resolves to GLES + // exactly like a web build, which genuinely needs that set. Nothing the + // hook can read separates the two. If a Flutter release starts naming the + // platform on a data-only input, this flips and the skip becomes possible. + final dataOnly = _dataOnlyInput(temp.uri); + expect(dataOnly.config.buildCodeAssets, isFalse); + expect(shaderBundleBackendsForBuild(dataOnly), { + ShaderBundleBackend.openglEs, + }); + final config = jsonEncode(dataOnly.config.json).toLowerCase(); + for (final os in const [ + 'macos', + 'ios', + 'android', + 'linux', + 'windows', + 'web', + 'fuchsia', + ]) { + expect( + config, + isNot(contains(os)), + reason: 'a data-only input naming $os would let the native pass skip', + ); + } + }, + ); + test('two targets write different files in one tree', () { final tree = _tree(temp); Uri output(String target) => tree.fileUri( From 4e2fd0bfa00e1decdb6faa02a00ba1099cd4a6fc Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Tue, 18 Aug 2026 01:33:22 -0700 Subject: [PATCH 6/6] Note the silent-failure fixes in the changelog --- packages/flutter_scene/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/flutter_scene/CHANGELOG.md b/packages/flutter_scene/CHANGELOG.md index 4e9e232e..a4261a15 100644 --- a/packages/flutter_scene/CHANGELOG.md +++ b/packages/flutter_scene/CHANGELOG.md @@ -1,6 +1,12 @@ ## 0.22.0 * `Node.position`, `Node.rotation`, and `Node.scale` read and write the local transform one component at a time, and editing a returned copy in place throws in debug builds rather than silently doing nothing. +* A frame that draws nothing now prints once in debug builds naming the likely cause (not ready, empty region, no views composite to screen, no visible meshes, or a layer mask matching nothing). +* Degenerate cameras now assert in debug builds, catching a view direction of zero length, an `up` parallel to it, and a field of view passed in degrees. +* The web backend now throws on a bind to a shader uniform or texture name that does not exist, matching native, instead of silently sampling whatever was bound last. +* `SkinnedGeometry.uploadVertexData` and `setCustomAttribute` now throw on a buffer whose length does not match the vertex count, instead of rendering garbage. +* A `Mesh` whose primitive geometry is replaced now recomputes its bounds on its own, rather than over-culling until `markLocalBoundsDirty` is called. +* An `AnimationClip` that binds zero of its channels now asserts in debug builds, naming the wanted nodes, since it otherwise plays while nothing moves. * Fixed the second and every later `flutter run` rendering nothing, from two hook invocations writing the same shader bundle filenames with different backend trims. * Fixed `Scene.initializeStaticResources()` reporting ready when the shader bundle loaded but held nothing this engine can read. * Fixed `dart run flutter_scene:init` then `flutter run` failing on a new app with "Flutter failed to list directory".