From 1c7370f76cec4055a1dd59f13277bdb07d6d90f0 Mon Sep 17 00:00:00 2001 From: Robin Jouffroy Date: Wed, 29 Jul 2026 19:47:55 +0200 Subject: [PATCH 1/3] Invalidate the lighting bind memo in the instanced draw path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SceneEncoder._encodeInstanced clears the render pass bindings without the paired EngineLightingUniforms.invalidateBindMemo() that _encode performs at the equivalent site. EngineLightingUniforms memoises its bind set on (pass, shader, lighting, environment) and documents that the encoder invalidates that memo whenever it clears the pass; the instanced path did not, so the memo kept reporting bindings that had just been wiped. In a run of same-pipeline opaque draws only the first instanced draw is therefore fully bound. Every later one — and any non-instanced mesh encoded after it — loses prefiltered_radiance, brdf_lut, shadow_map, sh_coefficients, punctual_lights, ssao_texture and FogInfo, and shades black. The geometry is still submitted and still writes depth, so it reads as missing rather than unlit. A single InstancedMesh escapes this: its clear hits an empty pass and the memo misses anyway on a fresh RenderPass, so everything binds, and a following mesh sharing the pipeline skips its own clear and inherits the live bindings. Two or more instanced meshes is where it starts. Reproduced with cuboid instances split across K PhysicallyBasedMaterial InstancedMeshes plus a ground plane: correct at K=1, and at K>=2 only one type and no ground on Impeller's Android GLES backend. Vulkan and WebGL2 tolerate the missing binds and render correctly, so the defect is latent there rather than absent. Setting a non-zero emissiveFactor — which travels in FragInfo and is re-bound on every draw — makes the missing geometry reappear, confirming the draws execute and only the memoised engine bindings are lost. --- packages/flutter_scene/lib/src/scene_encoder.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flutter_scene/lib/src/scene_encoder.dart b/packages/flutter_scene/lib/src/scene_encoder.dart index 454e8fd5..9e457a8f 100644 --- a/packages/flutter_scene/lib/src/scene_encoder.dart +++ b/packages/flutter_scene/lib/src/scene_encoder.dart @@ -439,6 +439,7 @@ base class SceneEncoder { double fade, ) { _renderPass.clearBindings(); + EngineLightingUniforms.invalidateBindMemo(); _bindPipeline(pipeline); material.lodFade = fade; final materialVertex = material.materialVertexShader( From 6d1a88f9cf49f365b436cab4130caea0dce7c49c Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Wed, 29 Jul 2026 21:01:40 -0700 Subject: [PATCH 2/3] Route the encoder's binding clears through one helper Pairs `clearBindings` with the engine-lighting memo invalidation in a single `_clearBindings`, so the instanced path cannot drop the invalidation again. Adds an instanced_lighting smoke scene (two instanced meshes plus a plain mesh sharing a lit pipeline), which reproduced the loss on Impeller GLES and now matches Metal and WebGL2. Also corrects the web shim's clearBindings comment, which claimed to drop uniform and texture bindings it actually leaves bound. --- examples/smoke_render/lib/smoke_scenes.dart | 51 +++++++++++++++++++ packages/flutter_scene/CHANGELOG.md | 4 ++ .../lib/src/gpu/web/render_pass.dart | 15 +++--- .../flutter_scene/lib/src/scene_encoder.dart | 15 ++++-- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/examples/smoke_render/lib/smoke_scenes.dart b/examples/smoke_render/lib/smoke_scenes.dart index 41fceae9..4502bca1 100644 --- a/examples/smoke_render/lib/smoke_scenes.dart +++ b/examples/smoke_render/lib/smoke_scenes.dart @@ -211,6 +211,57 @@ final List kSmokeScenes = [ scene.add(node); return (scene: scene, camera: _camera()); }), + // Two instanced meshes plus a plain mesh, all sharing a lit pipeline. + // Covers hardware instancing and, because the draws share a pipeline, the + // engine-lighting bind bookkeeping between instanced and non-instanced + // draws; a lost bind reads as black or missing geometry here. + SmokeScene('instanced_lighting', () { + final scene = Scene(); + scene.add( + Node()..addComponent( + DirectionalLightComponent( + DirectionalLight(direction: vm.Vector3(-0.4, -1.0, -0.35)), + ), + ), + ); + // Non-instanced receiver, drawn from the same pipeline as the instances. + scene.add( + Node( + mesh: Mesh( + PlaneGeometry(width: 4.0, depth: 4.0), + PhysicallyBasedMaterial() + ..baseColorFactor = vm.Vector4(0.78, 0.78, 0.80, 1.0) + ..metallicFactor = 0.0 + ..roughnessFactor = 0.9 + ..vertexColorWeight = 0.0, + ), + )..localTransform = vm.Matrix4.translation(vm.Vector3(0, -0.6, 0)), + ); + // Two separate instanced meshes, so the second one draws after a + // same-pipeline run has already started. + for (var mesh = 0; mesh < 2; mesh++) { + final instanced = InstancedMesh( + geometry: CuboidGeometry(vm.Vector3(0.5, 0.5, 0.5)), + material: PhysicallyBasedMaterial() + ..baseColorFactor = mesh == 0 + ? vm.Vector4(0.85, 0.35, 0.20, 1.0) + : vm.Vector4(0.20, 0.55, 0.90, 1.0) + ..metallicFactor = 0.1 + ..roughnessFactor = 0.45 + ..vertexColorWeight = 0.0, + ); + for (var i = 0; i < 3; i++) { + instanced.addInstance( + vm.Matrix4.translation( + vm.Vector3((i - 1) * 0.85, mesh * 0.75, mesh * 0.6 - 0.3), + ) * + vm.Matrix4.rotationY(0.5 + i * 0.3), + ); + } + scene.add(Node()..addComponent(InstancedMeshComponent(instanced))); + } + return (scene: scene, camera: _camera()); + }), // A directional light casting a shadow from a floating cuboid onto a // ground plane. Exercises the ShadowPass (a depth-only shadow-map pass) // and the lit material's shadow sampling, which the other scenes don't. diff --git a/packages/flutter_scene/CHANGELOG.md b/packages/flutter_scene/CHANGELOG.md index 8780ee74..98bea3b7 100644 --- a/packages/flutter_scene/CHANGELOG.md +++ b/packages/flutter_scene/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.20.1 + +* Fixed instanced meshes losing their lighting, shadow, and fog bindings on GLES, which drew them (and any mesh sharing their pipeline) black. + ## 0.20.0 * The `.fscene` document core (document model, stable ids, JSON/binary serialization, prefab composition, diffing) moved to the new pure-Dart `scene` package; `flutter_scene` re-exports it, so existing imports are unchanged. 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 31e8ee80..873a2120 100644 --- a/packages/flutter_scene/lib/src/gpu/web/render_pass.dart +++ b/packages/flutter_scene/lib/src/gpu/web/render_pass.dart @@ -767,12 +767,15 @@ base class RenderPass { } void clearBindings() { - // Clears per-draw resource bindings (vertex/index buffers, uniforms, - // textures) but NOT the bound pipeline - matching flutter_gpu, where the - // pipeline persists until the next bindPipeline. flutter_scene's encoder - // relies on this: it caches the last pipeline and skips re-binding it - // across consecutive draws with the same material, so nulling it here - // would leave later draws with no pipeline. + // Drops the pending vertex and index bindings, leaving the bound pipeline + // in place (matching flutter_gpu, where the pipeline persists until the + // next bindPipeline; the encoder skips rebinding a pipeline it already + // bound, so nulling it here would leave later draws without one). + // TODO(web-clear-bindings): uniforms and textures go straight to GL + // program and texture-unit state, so they survive this call while + // flutter_gpu drops them. A draw that relies on the clear renders from + // stale bindings instead of breaking, hiding bind-lifetime bugs on web. + // Track the bound uniform and sampler slots and reset them here. final gl = _gpuContext._gl; if (_vao != null) { gl.bindVertexArray(null); diff --git a/packages/flutter_scene/lib/src/scene_encoder.dart b/packages/flutter_scene/lib/src/scene_encoder.dart index 9e457a8f..2ff33908 100644 --- a/packages/flutter_scene/lib/src/scene_encoder.dart +++ b/packages/flutter_scene/lib/src/scene_encoder.dart @@ -358,6 +358,15 @@ base class SceneEncoder { _boundPipeline = pipeline; } + // Drops the pass's bindings. The engine-lighting memo tracks what is + // already bound on the pass, so it has to be forgotten here or the next + // draw skips rebinding slots that were just wiped; never call + // `clearBindings` directly. + void _clearBindings() { + _renderPass.clearBindings(); + EngineLightingUniforms.invalidateBindMemo(); + } + void _encode( gpu.RenderPipeline pipeline, Matrix4 worldTransform, @@ -376,8 +385,7 @@ base class SceneEncoder { // FFI, and re-issuing the full set per item dominated main-thread frame // time in draw-heavy scenes. if (!identical(_boundPipeline, pipeline)) { - _renderPass.clearBindings(); - EngineLightingUniforms.invalidateBindMemo(); + _clearBindings(); } _bindPipeline(pipeline); // The material reads its cross-fade coverage from this transient field as @@ -438,8 +446,7 @@ base class SceneEncoder { bool windingFlipped, double fade, ) { - _renderPass.clearBindings(); - EngineLightingUniforms.invalidateBindMemo(); + _clearBindings(); _bindPipeline(pipeline); material.lodFade = fade; final materialVertex = material.materialVertexShader( From 12b09531e05f5e6ab14e7586696e84081dfc3d38 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Wed, 29 Jul 2026 23:41:35 -0700 Subject: [PATCH 3/3] Release uniform and texture bindings in the web shim's clearBindings The shim only dropped vertex and index bindings. Uniform blocks and texture units are GL context state, so they stayed attached and a draw that should have rebound a slot silently read the previous draw's resource. That is why the instanced lighting loss rendered correctly on WebGL2 while breaking on GLES. The pass now records the binding points it hands out and releases them on clear, so a missing rebind fails on web the way it does natively. --- .../lib/src/gpu/web/render_pass.dart | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 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 873a2120..211c4af6 100644 --- a/packages/flutter_scene/lib/src/gpu/web/render_pass.dart +++ b/packages/flutter_scene/lib/src/gpu/web/render_pass.dart @@ -195,6 +195,12 @@ base class RenderPass { /// through the VAO cache when the draw is issued: (view, slot, whether the /// stream is instance-rate). final List<(BufferView, int, bool)> _pendingVertexBindings = []; + + /// Uniform-block binding points, and texture units mapped to the GL target + /// bound there, occupied since the last [clearBindings]. Recorded so the + /// clear can release exactly what it handed out. + final Set _boundUniformBlocks = {}; + final Map _boundTextureUnits = {}; PrimitiveType _primitiveType = PrimitiveType.triangle; BufferView? _inlineVertexBufferView; @@ -582,6 +588,7 @@ base class RenderPass { bufferView.offsetInBytes, lengthInBytes, ); + _boundUniformBlocks.add(blockBinding); return; } @@ -679,6 +686,7 @@ base class RenderPass { final target = texture.glTarget; gl.activeTexture(web.WebGL2RenderingContext.TEXTURE0 + unit); gl.bindTexture(target, texture.glTexture); + _boundTextureUnits[unit] = target; if (sampler != null) { // Sampler parameters are per-texture-object GL state; skip the ones // already applied to this texture. Per-draw texParameteri calls are @@ -767,19 +775,33 @@ base class RenderPass { } void clearBindings() { - // Drops the pending vertex and index bindings, leaving the bound pipeline - // in place (matching flutter_gpu, where the pipeline persists until the - // next bindPipeline; the encoder skips rebinding a pipeline it already - // bound, so nulling it here would leave later draws without one). - // TODO(web-clear-bindings): uniforms and textures go straight to GL - // program and texture-unit state, so they survive this call while - // flutter_gpu drops them. A draw that relies on the clear renders from - // stale bindings instead of breaking, hiding bind-lifetime bugs on web. - // Track the bound uniform and sampler slots and reset them here. + // Drops every per-draw resource binding, leaving the bound pipeline in + // place (matching flutter_gpu, where the pipeline persists until the next + // bindPipeline; the encoder skips rebinding a pipeline it already bound, + // so nulling it here would leave later draws without one). + // + // Releasing the uniform blocks and texture units matters beyond tidiness. + // They are GL context state rather than per-draw descriptors, so leaving + // them attached lets a draw that should have rebound a slot read the + // previous draw's resource and render correctly, which hides bind + // lifetime bugs here that break on the native backends. final gl = _gpuContext._gl; if (_vao != null) { gl.bindVertexArray(null); } + for (final binding in _boundUniformBlocks) { + gl.bindBufferBase( + web.WebGL2RenderingContext.UNIFORM_BUFFER, + binding, + null, + ); + } + _boundUniformBlocks.clear(); + for (final entry in _boundTextureUnits.entries) { + gl.activeTexture(web.WebGL2RenderingContext.TEXTURE0 + entry.key); + gl.bindTexture(entry.value, null); + } + _boundTextureUnits.clear(); _inlineVertexBufferView = null; _indexBufferView = null; _pendingVertexBindings.clear();