[Flutter GPU] Allow attaching specific texture mip levels and slices for rendering (#187685)

Fixes flutter/flutter#150455

Surfaces the Impeller HAL support for rendering into a specific texture
mip level and slice (landed in #187470) to Flutter GPU.

- Adds `mipLevel` and `slice` (both default 0) to `ColorAttachment` and
`DepthStencilAttachment`. They select the subresource of the attachment
texture to render into: a non-zero mip level, a cube map face, or both.
- Validates, in Dart and unconditionally, that each attachment's
`mipLevel` and `slice` are in range (including the MSAA resolve texture)
and that all attachments resolve to the same size. Out-of-range
subresources and size mismatches are undefined behavior in release,
where the engine-side checks are compiled out.
- Adds `GpuContext.doesSupportFramebufferRenderMipmap`, lifted onto the
base `Capabilities` interface so it can be queried. Rendering into a
cube face is always available. Rendering into a non-zero mip level
returns true on Metal and Vulkan and false on GLES: the GLES texture
storage path yields an incomplete framebuffer for a non-base mip
attachment, so the capability is conservative until that is reworked
(tracked separately). Cube face rendering on GLES is unaffected.

Tests cover rendering into a cube slice, rendering into a non-zero mip
level (gated on the capability), and the range and size validation.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[AI contribution guidelines]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc
index 5c0f467..200f6d9 100644
--- a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc
+++ b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc
@@ -51,9 +51,6 @@
 static const constexpr char* kTextureCompressionAstcHdrExt =
     "GL_KHR_texture_compression_astc_hdr";
 
-// https://registry.khronos.org/OpenGL/extensions/OES/OES_fbo_render_mipmap.txt
-static const constexpr char* kFboRenderMipmapExt = "GL_OES_fbo_render_mipmap";
-
 CapabilitiesGLES::CapabilitiesGLES(const ProcTableGLES& gl) {
   {
     GLint value = 0;
@@ -193,12 +190,6 @@
       desc->HasExtension(kTextureCompressionAstcOesExt);
   supports_texture_compression_etc2_ =
       desc->IsES() && desc->GetGlVersion().major_version >= 3;
-
-  // Non-zero mip levels are renderable on desktop GL, ES 3.0+, or ES 2.0 with
-  // GL_OES_fbo_render_mipmap.
-  supports_fbo_render_mipmap_ = !desc->IsES() ||
-                                desc->GetGlVersion().major_version >= 3 ||
-                                desc->HasExtension(kFboRenderMipmapExt);
 }
 
 bool CapabilitiesGLES::IsES() const {
@@ -206,7 +197,13 @@
 }
 
 bool CapabilitiesGLES::SupportsFramebufferRenderMipmap() const {
-  return supports_fbo_render_mipmap_;
+  // Rendering into a non-zero mip level is not yet supported on the GLES
+  // backend. The texture storage path allocates levels with mutable, lazily
+  // allocated glTexImage2D storage, which yields an incomplete framebuffer
+  // when a non-base mip level is attached. Until that is reworked, do not
+  // advertise the capability so callers fall back instead of failing to
+  // create the framebuffer. Rendering into a cube map face is unaffected.
+  return false;
 }
 
 size_t CapabilitiesGLES::GetMaxTextureUnits(ShaderStage stage) const {
diff --git a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h
index 21c0036..28d5fab 100644
--- a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h
+++ b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h
@@ -80,10 +80,10 @@
   /// @brief Whether this is an ES GL variant or (if false) desktop GL.
   bool IsES() const;
 
-  /// @brief Whether a non-zero mip level of a texture can be attached to a
-  ///        framebuffer. Core ES 2.0 only allows mip level 0; ES 3.0+ and the
-  ///        GL_OES_fbo_render_mipmap extension lift that restriction.
-  bool SupportsFramebufferRenderMipmap() const;
+  // |Capabilities|
+  /// Always false. Rendering into a non-zero mip level is not yet implemented
+  /// on the GLES backend; see SupportsFramebufferRenderMipmap in the .cc file.
+  bool SupportsFramebufferRenderMipmap() const override;
 
   // |Capabilities|
   bool SupportsOffscreenMSAA() const override;
@@ -159,7 +159,6 @@
   bool supports_offscreen_msaa_ = false;
   bool supports_implicit_msaa_ = false;
   bool supports_32bit_primitive_indices_ = false;
-  bool supports_fbo_render_mipmap_ = false;
   bool is_angle_ = false;
   bool is_es_ = false;
   bool supports_texture_compression_bc_ = false;
diff --git a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc
index e1cb178..4c00888 100644
--- a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc
+++ b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc
@@ -696,8 +696,8 @@
   const auto& gl = reactor_->GetProcTable();
   if (mip_level > 0 &&
       !gl.GetCapabilities()->SupportsFramebufferRenderMipmap()) {
-    VALIDATION_LOG << "Attaching a non-zero mip level requires OpenGL ES 3.0 "
-                      "or the GL_OES_fbo_render_mipmap extension.";
+    VALIDATION_LOG << "Rendering into a non-zero mip level is not supported on "
+                      "the GLES backend.";
     return false;
   }
   if (!EnsureSliceMipLevelStorage(slice, mip_level)) {
diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc
index 17716a3..191fc8c 100644
--- a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc
+++ b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc
@@ -881,6 +881,10 @@
   return false;
 }
 
+bool CapabilitiesVK::SupportsFramebufferRenderMipmap() const {
+  return true;
+}
+
 bool CapabilitiesVK::SupportsTextureCompression(
     CompressedTextureFamily family) const {
   switch (family) {
diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h
index ece2f51..223fe2b 100644
--- a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h
+++ b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h
@@ -280,6 +280,9 @@
       CompressedTextureFamily family) const override;
 
   // |Capabilities|
+  bool SupportsFramebufferRenderMipmap() const override;
+
+  // |Capabilities|
   PixelFormat GetDefaultColorFormat() const override;
 
   // |Capabilities|
diff --git a/engine/src/flutter/impeller/renderer/capabilities.cc b/engine/src/flutter/impeller/renderer/capabilities.cc
index 7442349..1851d80 100644
--- a/engine/src/flutter/impeller/renderer/capabilities.cc
+++ b/engine/src/flutter/impeller/renderer/capabilities.cc
@@ -104,6 +104,9 @@
   }
 
   // |Capabilities|
+  bool SupportsFramebufferRenderMipmap() const override { return true; }
+
+  // |Capabilities|
   bool SupportsTextureCompression(
       CompressedTextureFamily family) const override {
     switch (family) {
diff --git a/engine/src/flutter/impeller/renderer/capabilities.h b/engine/src/flutter/impeller/renderer/capabilities.h
index f7d1ecb..3a89fe4 100644
--- a/engine/src/flutter/impeller/renderer/capabilities.h
+++ b/engine/src/flutter/impeller/renderer/capabilities.h
@@ -134,6 +134,12 @@
   virtual bool SupportsTextureCompression(
       CompressedTextureFamily family) const = 0;
 
+  /// @brief Whether a non-zero mip level of a texture can be attached as a
+  ///        render target. Rendering into a cube map face or array layer is
+  ///        always supported. Metal and Vulkan support this; the GLES backend
+  ///        does not yet, so it returns false there.
+  virtual bool SupportsFramebufferRenderMipmap() const = 0;
+
   /// @brief The minimum alignment of uniform value offsets in bytes.
   virtual size_t GetMinimumUniformAlignment() const = 0;
 
diff --git a/engine/src/flutter/impeller/renderer/testing/mocks.h b/engine/src/flutter/impeller/renderer/testing/mocks.h
index c753bf5..32ad994 100644
--- a/engine/src/flutter/impeller/renderer/testing/mocks.h
+++ b/engine/src/flutter/impeller/renderer/testing/mocks.h
@@ -252,6 +252,7 @@
   MOCK_METHOD(bool, SupportsPrimitiveRestart, (), (const override));
   MOCK_METHOD(bool, Supports32BitPrimitiveIndices, (), (const override));
   MOCK_METHOD(bool, SupportsExtendedRangeFormats, (), (const override));
+  MOCK_METHOD(bool, SupportsFramebufferRenderMipmap, (), (const override));
   MOCK_METHOD(bool,
               SupportsTextureCompression,
               (CompressedTextureFamily),
diff --git a/engine/src/flutter/lib/gpu/context.cc b/engine/src/flutter/lib/gpu/context.cc
index 7a6e81f..5945187 100644
--- a/engine/src/flutter/lib/gpu/context.cc
+++ b/engine/src/flutter/lib/gpu/context.cc
@@ -140,6 +140,13 @@
   return flutter::gpu::SupportsNormalOffscreenMSAA(wrapper->GetContext());
 }
 
+extern bool InternalFlutterGpu_Context_GetSupportsFramebufferRenderMipmap(
+    flutter::gpu::Context* wrapper) {
+  return wrapper->GetContext()
+      .GetCapabilities()
+      ->SupportsFramebufferRenderMipmap();
+}
+
 extern bool InternalFlutterGpu_Context_SupportsTextureCompression(
     flutter::gpu::Context* wrapper,
     int family) {
diff --git a/engine/src/flutter/lib/gpu/context.h b/engine/src/flutter/lib/gpu/context.h
index 56672a00..b6693dc 100644
--- a/engine/src/flutter/lib/gpu/context.h
+++ b/engine/src/flutter/lib/gpu/context.h
@@ -83,6 +83,10 @@
     flutter::gpu::Context* wrapper);
 
 FLUTTER_GPU_EXPORT
+extern bool InternalFlutterGpu_Context_GetSupportsFramebufferRenderMipmap(
+    flutter::gpu::Context* wrapper);
+
+FLUTTER_GPU_EXPORT
 extern bool InternalFlutterGpu_Context_SupportsTextureCompression(
     flutter::gpu::Context* wrapper,
     int family);
diff --git a/engine/src/flutter/lib/gpu/lib/src/context.dart b/engine/src/flutter/lib/gpu/lib/src/context.dart
index dd67e48..5273e13 100644
--- a/engine/src/flutter/lib/gpu/lib/src/context.dart
+++ b/engine/src/flutter/lib/gpu/lib/src/context.dart
@@ -58,6 +58,15 @@
     return _getSupportsOffscreenMSAA();
   }
 
+  /// Whether the backend can attach a non-zero mip level of a texture as a
+  /// render target (see [ColorAttachment.mipLevel]). Rendering into a cube map
+  /// face or array layer is always supported; only non-zero mip levels are
+  /// gated. True on Metal and Vulkan; currently false on the GLES backend,
+  /// where rendering into non-zero mip levels is not yet implemented.
+  bool get doesSupportFramebufferRenderMipmap {
+    return _getSupportsFramebufferRenderMipmap();
+  }
+
   /// Whether this device supports the given family of block-compressed
   /// texture formats. Hardware support is granted on a per-family basis.
   ///
@@ -263,6 +272,11 @@
   )
   external bool _getSupportsOffscreenMSAA();
 
+  @Native<Bool Function(Pointer<Void>)>(
+    symbol: 'InternalFlutterGpu_Context_GetSupportsFramebufferRenderMipmap',
+  )
+  external bool _getSupportsFramebufferRenderMipmap();
+
   @Native<Bool Function(Pointer<Void>, Int)>(
     symbol: 'InternalFlutterGpu_Context_SupportsTextureCompression',
   )
diff --git a/engine/src/flutter/lib/gpu/lib/src/render_pass.dart b/engine/src/flutter/lib/gpu/lib/src/render_pass.dart
index b9d6db4..4ade61c 100644
--- a/engine/src/flutter/lib/gpu/lib/src/render_pass.dart
+++ b/engine/src/flutter/lib/gpu/lib/src/render_pass.dart
@@ -13,6 +13,8 @@
     vm.Vector4? clearValue = null,
     required this.texture,
     this.resolveTexture = null,
+    this.mipLevel = 0,
+    this.slice = 0,
   }) : clearValue = clearValue ?? vm.Vector4.zero();
 
   LoadAction loadAction;
@@ -22,6 +24,19 @@
   Texture texture;
   Texture? resolveTexture;
 
+  /// The mip level of [texture] to render into. Must be in the range
+  /// `[0, texture.mipLevelCount)`.
+  ///
+  /// Rendering into a non-zero mip level is supported on Metal and Vulkan but
+  /// not currently on the GLES backend. See
+  /// [GpuContext.doesSupportFramebufferRenderMipmap].
+  int mipLevel;
+
+  /// The slice of [texture] to render into. For cubemap textures this selects
+  /// the face in the order `+X, -X, +Y, -Y, +Z, -Z`. Must be in the range
+  /// `[0, texture.sliceCount)` (always 0 for non-cubemap textures).
+  int slice;
+
   void _validate() {
     if (resolveTexture != null) {
       if (resolveTexture!.format != texture.format) {
@@ -69,6 +84,8 @@
     this.stencilStoreAction = StoreAction.dontCare,
     this.stencilClearValue = 0,
     required this.texture,
+    this.mipLevel = 0,
+    this.slice = 0,
   });
 
   LoadAction depthLoadAction;
@@ -81,6 +98,14 @@
 
   Texture texture;
 
+  /// The mip level of [texture] to render into. Must match the mip level of
+  /// the color attachments so all attachments share the same size. See
+  /// [ColorAttachment.mipLevel].
+  int mipLevel;
+
+  /// The slice of [texture] to render into. See [ColorAttachment.slice].
+  int slice;
+
   void _validate() {
     if (texture.storageMode == StorageMode.deviceTransient) {
       if (depthLoadAction == LoadAction.load) {
@@ -219,10 +244,84 @@
     }
   }
 
+  /// Validates attachment mip levels, slices, and sizes. Out-of-range
+  /// subresources and mismatched sizes are undefined behavior in the engine,
+  /// and the engine-side checks are compiled out in release builds, so this
+  /// runs unconditionally.
+  void _validateAttachments() {
+    // The size of the first attachment, against which the rest are checked.
+    // Each attachment renders into its mip level, clamped to a minimum of 1x1.
+    int? width;
+    int? height;
+    void accumulate(Texture texture, int mipLevel) {
+      final int w = texture.width >> mipLevel;
+      final int h = texture.height >> mipLevel;
+      final int mipWidth = w < 1 ? 1 : w;
+      final int mipHeight = h < 1 ? 1 : h;
+      if (width == null) {
+        width = mipWidth;
+        height = mipHeight;
+      } else if (width != mipWidth || height != mipHeight) {
+        throw Exception(
+          "All render target attachments must render into the same size. "
+          "Check that all color and depth-stencil attachments use matching "
+          "texture sizes and mip levels.",
+        );
+      }
+    }
+
+    for (final color in colorAttachments) {
+      _validateAttachmentSubresource(
+        color.texture,
+        color.mipLevel,
+        color.slice,
+        "ColorAttachment",
+      );
+      if (color.resolveTexture != null) {
+        _validateAttachmentSubresource(
+          color.resolveTexture!,
+          color.mipLevel,
+          color.slice,
+          "ColorAttachment resolve texture",
+        );
+      }
+      accumulate(color.texture, color.mipLevel);
+    }
+
+    final ds = depthStencilAttachment;
+    if (ds != null) {
+      _validateAttachmentSubresource(
+        ds.texture,
+        ds.mipLevel,
+        ds.slice,
+        "DepthStencilAttachment",
+      );
+      accumulate(ds.texture, ds.mipLevel);
+    }
+  }
+
   final List<ColorAttachment> colorAttachments;
   final DepthStencilAttachment? depthStencilAttachment;
 }
 
+void _validateAttachmentSubresource(
+  Texture texture,
+  int mipLevel,
+  int slice,
+  String label,
+) {
+  if (mipLevel < 0 || mipLevel >= texture.mipLevelCount) {
+    throw Exception(
+      "$label mipLevel ($mipLevel) must be in the range [0, ${texture.mipLevelCount - 1}] for this texture",
+    );
+  }
+  if (slice < 0 || slice >= texture.sliceCount) {
+    throw Exception(
+      "$label slice ($slice) must be in the range [0, ${texture.sliceCount - 1}] for textures of type ${texture.textureType}",
+    );
+  }
+}
+
 base class RenderPass extends NativeFieldWrapperClass1 {
   /// The maximum number of vertex buffer slots that can be bound to a single
   /// draw. Matches `flutter::gpu::RenderPass::kMaxVertexBufferSlots` on the
@@ -246,6 +345,7 @@
     CommandBuffer commandBuffer,
     RenderTarget renderTarget,
   ) {
+    renderTarget._validateAttachments();
     assert(() {
       renderTarget._validate();
       return true;
@@ -265,6 +365,8 @@
         color.clearValue.a,
         color.texture,
         color.resolveTexture,
+        color.mipLevel,
+        color.slice,
       );
       if (error != null) {
         throw Exception(error);
@@ -280,6 +382,8 @@
         ds.stencilStoreAction.index,
         ds.stencilClearValue,
         ds.texture,
+        ds.mipLevel,
+        ds.slice,
       );
       if (error != null) {
         throw Exception(error);
@@ -593,6 +697,8 @@
       Float,
       Pointer<Void>,
       Handle,
+      Int,
+      Int,
     )
   >(symbol: 'InternalFlutterGpu_RenderPass_SetColorAttachment')
   external String? _setColorAttachment(
@@ -606,6 +712,8 @@
     double clearColorA,
     Texture texture,
     Texture? resolveTexture,
+    int mipLevel,
+    int slice,
   );
 
   @Native<
@@ -618,6 +726,8 @@
       Int,
       Int,
       Pointer<Void>,
+      Int,
+      Int,
     )
   >(symbol: 'InternalFlutterGpu_RenderPass_SetDepthStencilAttachment')
   external String? _setDepthStencilAttachment(
@@ -628,6 +738,8 @@
     int stencilStoreAction,
     int stencilClearValue,
     Texture texture,
+    int mipLevel,
+    int slice,
   );
 
   @Native<Handle Function(Pointer<Void>, Pointer<Void>)>(
diff --git a/engine/src/flutter/lib/gpu/lib/src/texture.dart b/engine/src/flutter/lib/gpu/lib/src/texture.dart
index 5903621..06ff2f4 100644
--- a/engine/src/flutter/lib/gpu/lib/src/texture.dart
+++ b/engine/src/flutter/lib/gpu/lib/src/texture.dart
@@ -154,13 +154,13 @@
   void overwrite(ByteData sourceBytes, {int mipLevel = 0, int slice = 0}) {
     if (mipLevel < 0 || mipLevel >= mipLevelCount) {
       throw Exception(
-        'mipLevel ($mipLevel) must be in the range [0, $mipLevelCount) for this texture',
+        'mipLevel ($mipLevel) must be in the range [0, ${mipLevelCount - 1}] for this texture',
       );
     }
     final int slices = sliceCount;
     if (slice < 0 || slice >= slices) {
       throw Exception(
-        'slice ($slice) must be in the range [0, $slices) for textures of type $textureType',
+        'slice ($slice) must be in the range [0, ${slices - 1}] for textures of type $textureType',
       );
     }
     final int expectedSize = getMipLevelSizeInBytes(mipLevel);
diff --git a/engine/src/flutter/lib/gpu/render_pass.cc b/engine/src/flutter/lib/gpu/render_pass.cc
index 643e219..c355a58 100644
--- a/engine/src/flutter/lib/gpu/render_pass.cc
+++ b/engine/src/flutter/lib/gpu/render_pass.cc
@@ -275,13 +275,17 @@
     float clear_color_b,
     float clear_color_a,
     flutter::gpu::Texture* texture,
-    Dart_Handle resolve_texture_wrapper) {
+    Dart_Handle resolve_texture_wrapper,
+    int mip_level,
+    int slice) {
   impeller::ColorAttachment desc;
   desc.load_action = flutter::gpu::ToImpellerLoadAction(load_action);
   desc.store_action = flutter::gpu::ToImpellerStoreAction(store_action);
   desc.clear_color = impeller::Color(clear_color_r, clear_color_g,
                                      clear_color_b, clear_color_a);
   desc.texture = texture->GetTexture();
+  desc.mip_level = mip_level;
+  desc.slice = slice;
   if (!Dart_IsNull(resolve_texture_wrapper)) {
     flutter::gpu::Texture* resolve_texture =
         tonic::DartConverter<flutter::gpu::Texture*>::FromDart(
@@ -308,13 +312,17 @@
     int stencil_load_action,
     int stencil_store_action,
     int stencil_clear_value,
-    flutter::gpu::Texture* texture) {
+    flutter::gpu::Texture* texture,
+    int mip_level,
+    int slice) {
   {
     impeller::DepthAttachment desc;
     desc.load_action = flutter::gpu::ToImpellerLoadAction(depth_load_action);
     desc.store_action = flutter::gpu::ToImpellerStoreAction(depth_store_action);
     desc.clear_depth = depth_clear_value;
     desc.texture = texture->GetTexture();
+    desc.mip_level = mip_level;
+    desc.slice = slice;
     wrapper->GetRenderTarget().SetDepthAttachment(desc);
   }
   {
@@ -324,6 +332,8 @@
         flutter::gpu::ToImpellerStoreAction(stencil_store_action);
     desc.clear_stencil = stencil_clear_value;
     desc.texture = texture->GetTexture();
+    desc.mip_level = mip_level;
+    desc.slice = slice;
     wrapper->GetRenderTarget().SetStencilAttachment(desc);
   }
 
diff --git a/engine/src/flutter/lib/gpu/render_pass.h b/engine/src/flutter/lib/gpu/render_pass.h
index a0b4c55..4a80aa6 100644
--- a/engine/src/flutter/lib/gpu/render_pass.h
+++ b/engine/src/flutter/lib/gpu/render_pass.h
@@ -142,7 +142,9 @@
     float clear_color_b,
     float clear_color_a,
     flutter::gpu::Texture* texture,
-    Dart_Handle resolve_texture_wrapper);
+    Dart_Handle resolve_texture_wrapper,
+    int mip_level,
+    int slice);
 
 FLUTTER_GPU_EXPORT
 extern Dart_Handle InternalFlutterGpu_RenderPass_SetDepthStencilAttachment(
@@ -153,7 +155,9 @@
     int stencil_load_action,
     int stencil_store_action,
     int stencil_clear_value,
-    flutter::gpu::Texture* texture);
+    flutter::gpu::Texture* texture,
+    int mip_level,
+    int slice);
 
 FLUTTER_GPU_EXPORT
 extern Dart_Handle InternalFlutterGpu_RenderPass_Begin(
diff --git a/engine/src/flutter/testing/dart/gpu_test.dart b/engine/src/flutter/testing/dart/gpu_test.dart
index 94053c9..8a57c95 100644
--- a/engine/src/flutter/testing/dart/gpu_test.dart
+++ b/engine/src/flutter/testing/dart/gpu_test.dart
@@ -676,7 +676,7 @@
       texture.overwrite(Int32List.fromList(<int>[red.value]).buffer.asByteData(), mipLevel: 2);
       fail('Exception not thrown for out-of-range mipLevel.');
     } catch (e) {
-      expect(e.toString(), contains('mipLevel (2) must be in the range [0, 2)'));
+      expect(e.toString(), contains('mipLevel (2) must be in the range [0, 1]'));
     }
   }, skip: !(impellerEnabled && flutterGpuEnabled));
 
@@ -690,7 +690,7 @@
       );
       fail('Exception not thrown for out-of-range slice.');
     } catch (e) {
-      expect(e.toString(), contains('slice (1) must be in the range [0, 1)'));
+      expect(e.toString(), contains('slice (1) must be in the range [0, 0]'));
     }
   }, skip: !(impellerEnabled && flutterGpuEnabled));
 
@@ -850,6 +850,110 @@
     await comparer.addGoldenImage(image, 'flutter_gpu_test_clear_color.png');
   }, skip: !(impellerEnabled && flutterGpuEnabled));
 
+  test('GpuContext.doesSupportFramebufferRenderMipmap returns a bool', () async {
+    expect(gpu.gpuContext.doesSupportFramebufferRenderMipmap, isA<bool>());
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
+  test('Can render into a cube map slice', () async {
+    final gpu.Texture texture = gpu.gpuContext.createTexture(
+      gpu.StorageMode.devicePrivate,
+      4,
+      4,
+      textureType: gpu.TextureType.textureCube,
+    );
+    expect(texture.sliceCount, 6);
+
+    final gpu.CommandBuffer commandBuffer = gpu.gpuContext.createCommandBuffer();
+    final renderTarget = gpu.RenderTarget.singleColor(
+      gpu.ColorAttachment(texture: texture, slice: 2, clearValue: Colors.lime),
+    );
+    commandBuffer.createRenderPass(renderTarget);
+    commandBuffer.submit();
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
+  test('Can render into a non-zero mip level', () async {
+    // Rendering into a non-zero mip level needs ES 3.0 or
+    // GL_OES_fbo_render_mipmap on the GLES backend.
+    if (!gpu.gpuContext.doesSupportFramebufferRenderMipmap) {
+      markTestSkipped('Backend does not support rendering into non-zero mip levels.');
+      return;
+    }
+    final gpu.Texture texture = gpu.gpuContext.createTexture(
+      gpu.StorageMode.devicePrivate,
+      8,
+      8,
+      mipLevelCount: 3,
+    );
+
+    final gpu.CommandBuffer commandBuffer = gpu.gpuContext.createCommandBuffer();
+    final renderTarget = gpu.RenderTarget.singleColor(
+      gpu.ColorAttachment(texture: texture, mipLevel: 1, clearValue: Colors.lime),
+    );
+    commandBuffer.createRenderPass(renderTarget);
+    commandBuffer.submit();
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
+  test('ColorAttachment throws for an out-of-range mipLevel', () async {
+    final gpu.Texture texture = gpu.gpuContext.createTexture(
+      gpu.StorageMode.devicePrivate,
+      4,
+      4,
+      mipLevelCount: 2,
+    );
+    final gpu.CommandBuffer commandBuffer = gpu.gpuContext.createCommandBuffer();
+    final renderTarget = gpu.RenderTarget.singleColor(
+      gpu.ColorAttachment(texture: texture, mipLevel: 2),
+    );
+    try {
+      commandBuffer.createRenderPass(renderTarget);
+      fail('Exception not thrown for out-of-range mipLevel.');
+    } catch (e) {
+      expect(e.toString(), contains('mipLevel (2) must be in the range [0, 1]'));
+    }
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
+  test('ColorAttachment throws for an out-of-range slice', () async {
+    final gpu.Texture texture = gpu.gpuContext.createTexture(gpu.StorageMode.devicePrivate, 4, 4);
+    final gpu.CommandBuffer commandBuffer = gpu.gpuContext.createCommandBuffer();
+    final renderTarget = gpu.RenderTarget.singleColor(
+      gpu.ColorAttachment(texture: texture, slice: 1),
+    );
+    try {
+      commandBuffer.createRenderPass(renderTarget);
+      fail('Exception not thrown for out-of-range slice.');
+    } catch (e) {
+      expect(e.toString(), contains('slice (1) must be in the range [0, 0]'));
+    }
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
+  test('RenderTarget throws when attachment sizes do not match', () async {
+    // The color attachment renders into mip 1 (4x4) while the depth-stencil
+    // attachment renders into mip 0 (8x8).
+    final gpu.Texture color = gpu.gpuContext.createTexture(
+      gpu.StorageMode.devicePrivate,
+      8,
+      8,
+      mipLevelCount: 2,
+    );
+    final gpu.Texture depthStencil = gpu.gpuContext.createTexture(
+      gpu.StorageMode.deviceTransient,
+      8,
+      8,
+      format: gpu.gpuContext.defaultDepthStencilFormat,
+    );
+    final gpu.CommandBuffer commandBuffer = gpu.gpuContext.createCommandBuffer();
+    final renderTarget = gpu.RenderTarget.singleColor(
+      gpu.ColorAttachment(texture: color, mipLevel: 1),
+      depthStencilAttachment: gpu.DepthStencilAttachment(texture: depthStencil),
+    );
+    try {
+      commandBuffer.createRenderPass(renderTarget);
+      fail('Exception not thrown for mismatched attachment sizes.');
+    } catch (e) {
+      expect(e.toString(), contains('must render into the same size'));
+    }
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
   // Regression test for https://github.com/flutter/flutter/issues/157324
   test('Can bind uniforms in range', () async {
     final RenderPassState state = createSimpleRenderPass();