[Flutter GPU] Add instanced draw support (#187359)

Progress on
[flutter/flutter#186307](https://github.com/flutter/flutter/issues/186307).
Related to
[flutter/flutter#156738](https://github.com/flutter/flutter/issues/156738).
Related to
[flutter/flutter#154145](https://github.com/flutter/flutter/issues/154145).

Adds Flutter GPU support for instanced draws on top of Impeller's
instance-rate vertex input support.

This adds `VertexStepMode` and `VertexBuffer.stepMode`, allowing a
vertex buffer slot to advance once per instance. `RenderPass.draw` and
`RenderPass.drawIndexed` now take an optional `instanceCount` named
parameter, defaulting to 1.

Example usage:

```dart
final gpu.RenderPipeline pipeline = gpu.gpuContext.createRenderPipeline(
  vertexShader,
  fragmentShader,
  vertexLayout: const gpu.VertexLayout(
    buffers: <gpu.VertexBuffer>[
      gpu.VertexBuffer(
        strideInBytes: 8,
        attributes: <gpu.VertexAttribute>[
          gpu.VertexAttribute(
            name: 'position',
            format: gpu.VertexFormat.float32x2,
          ),
        ],
      ),
      gpu.VertexBuffer(
        strideInBytes: 8,
        stepMode: gpu.VertexStepMode.instance,
        attributes: <gpu.VertexAttribute>[
          gpu.VertexAttribute(
            name: 'instance_offset',
            format: gpu.VertexFormat.float32x2,
          ),
        ],
      ),
    ],
  ),
);

renderPass
  ..bindPipeline(pipeline)
  ..bindVertexBuffer(vertexBuffer)
  ..bindVertexBuffer(instanceOffsets, slot: 1)
  ..draw(3, instanceCount: 10);
```

The implementation plumbs step mode through the custom `VertexLayout`
packing into Impeller's `VertexInputRate`, forwards instance counts to
the native render pass, and adds a Flutter GPU golden test that renders
four instances from one geometry buffer plus one instance-rate
offset/color buffer.

## 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.
- [x] 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/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/engine/src/flutter/impeller/fixtures/BUILD.gn b/engine/src/flutter/impeller/fixtures/BUILD.gn
index 821a5cf..efa6f00 100644
--- a/engine/src/flutter/impeller/fixtures/BUILD.gn
+++ b/engine/src/flutter/impeller/fixtures/BUILD.gn
@@ -151,6 +151,8 @@
     "blue_noise.png",
     "boston.jpg",
     "embarcadero.jpg",
+    "flutter_gpu_instanced.frag",
+    "flutter_gpu_instanced.vert",
     "flutter_gpu_texture.frag",
     "flutter_gpu_texture.vert",
     "flutter_gpu_unlit.frag",
@@ -204,6 +206,8 @@
 impellerc("flutter_gpu_shaders") {
   shaders = [
     # Temporarily build Flutter GPU test shaders as runtime stages.
+    "flutter_gpu_instanced.frag",
+    "flutter_gpu_instanced.vert",
     "flutter_gpu_unlit.frag",
     "flutter_gpu_unlit.vert",
     "flutter_gpu_texture.frag",
@@ -211,7 +215,7 @@
   ]
 
   fixtures = rebase_path("//flutter/impeller/fixtures")
-  shader_bundle = "{\"UnlitFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_unlit.frag\"}, \"UnlitVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_unlit.vert\"}, \"TextureFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_texture.frag\"}, \"TextureVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_texture.vert\"}}"
+  shader_bundle = "{\"InstancedFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_instanced.frag\"}, \"InstancedVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_instanced.vert\"}, \"UnlitFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_unlit.frag\"}, \"UnlitVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_unlit.vert\"}, \"TextureFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_texture.frag\"}, \"TextureVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_texture.vert\"}}"
   shader_bundle_output = "playground.shaderbundle"
 }
 
diff --git a/engine/src/flutter/impeller/fixtures/flutter_gpu_instanced.frag b/engine/src/flutter/impeller/fixtures/flutter_gpu_instanced.frag
new file mode 100644
index 0000000..fc61864
--- /dev/null
+++ b/engine/src/flutter/impeller/fixtures/flutter_gpu_instanced.frag
@@ -0,0 +1,10 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+in vec4 v_color;
+out vec4 frag_color;
+
+void main() {
+  frag_color = v_color;
+}
diff --git a/engine/src/flutter/impeller/fixtures/flutter_gpu_instanced.vert b/engine/src/flutter/impeller/fixtures/flutter_gpu_instanced.vert
new file mode 100644
index 0000000..c2de308
--- /dev/null
+++ b/engine/src/flutter/impeller/fixtures/flutter_gpu_instanced.vert
@@ -0,0 +1,18 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+uniform VertInfo {
+  mat4 mvp;
+}
+vert_info;
+
+in vec2 position;
+in vec2 instance_offset;
+in vec4 instance_color;
+out vec4 v_color;
+
+void main() {
+  v_color = instance_color;
+  gl_Position = vert_info.mvp * vec4(position + instance_offset, 0.0, 1.0);
+}
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 a4c7ad2..b9d6db4 100644
--- a/engine/src/flutter/lib/gpu/lib/src/render_pass.dart
+++ b/engine/src/flutter/lib/gpu/lib/src/render_pass.dart
@@ -311,12 +311,9 @@
   /// otherwise they throw a [StateError] naming the unbound slots. Slots
   /// can be bound in any order.
   ///
-  /// [slot] must be in `[0, 16)` (Impeller's HAL caps vertex buffer
-  /// bindings at 16). On the OpenGL ES backend, the per-pipeline limit on
-  /// the *total attribute count* across all bound buffers is whatever the
-  /// device reports for `GL_MAX_VERTEX_ATTRIBS` (minimum 8 on GL ES 2.0,
-  /// minimum 16 on GL ES 3.0+), and is enforced by the driver rather than
-  /// by this method.
+  /// [slot] must be in `[0, 16)` (the current Flutter GPU limit for vertex
+  /// buffer bindings). Some devices may also impose a lower limit on the
+  /// total number of vertex attributes used by a single pipeline.
   void bindVertexBuffer(BufferView bufferView, {int slot = 0}) {
     if (slot < 0 || slot >= _kMaxVertexBufferSlots) {
       throw RangeError.range(
@@ -499,22 +496,61 @@
     _setWindingOrder(windingOrder.index);
   }
 
-  /// Appends a non-indexed draw of [vertexCount] vertices, read from the
-  /// bound vertex buffers.
-  void draw(int vertexCount) {
+  /// Appends a non-indexed draw of [vertexCount] vertices.
+  ///
+  /// The vertices are read from the vertex buffers previously bound with
+  /// [bindVertexBuffer]. Buffers whose [VertexBuffer.stepMode] is
+  /// [VertexStepMode.vertex] advance once per vertex. Buffers whose
+  /// [VertexBuffer.stepMode] is [VertexStepMode.instance] advance once per
+  /// instance.
+  ///
+  /// Set [instanceCount] greater than 1 to repeat the same vertex range
+  /// multiple times while reading a new element from each instance-rate vertex
+  /// buffer for each instance. A [vertexCount] or [instanceCount] of 0 is
+  /// valid and records no drawing work.
+  ///
+  /// If the bound pipeline does not use any instance-rate vertex buffers, each
+  /// instance receives the same vertex buffer data. This is valid, but every
+  /// instance will produce the same geometry unless the shader varies its output
+  /// another way.
+  void draw(int vertexCount, {int instanceCount = 1}) {
     RangeError.checkNotNegative(vertexCount, 'vertexCount');
+    RangeError.checkNotNegative(instanceCount, 'instanceCount');
+    if (vertexCount == 0 || instanceCount == 0) {
+      return;
+    }
     _validateVertexBindings();
-    if (!_draw(vertexCount)) {
+    if (!_draw(vertexCount, instanceCount)) {
       throw Exception("Failed to append draw");
     }
   }
 
-  /// Appends an indexed draw of [indexCount] indices, read from the index
-  /// buffer bound with [bindIndexBuffer].
-  void drawIndexed(int indexCount) {
+  /// Appends an indexed draw of [indexCount] indices.
+  ///
+  /// Indices are read from the index buffer previously bound with
+  /// [bindIndexBuffer]. Vertex data is read from the vertex buffers previously
+  /// bound with [bindVertexBuffer]. Buffers whose [VertexBuffer.stepMode] is
+  /// [VertexStepMode.vertex] advance once per vertex selected by the index
+  /// buffer. Buffers whose [VertexBuffer.stepMode] is [VertexStepMode.instance]
+  /// advance once per instance.
+  ///
+  /// Set [instanceCount] greater than 1 to repeat the same indexed geometry
+  /// multiple times while reading a new element from each instance-rate vertex
+  /// buffer for each instance. An [indexCount] or [instanceCount] of 0 is valid
+  /// and records no drawing work.
+  ///
+  /// If the bound pipeline does not use any instance-rate vertex buffers, each
+  /// instance receives the same vertex and index buffer data. This is valid, but
+  /// every instance will produce the same geometry unless the shader varies its
+  /// output another way.
+  void drawIndexed(int indexCount, {int instanceCount = 1}) {
     RangeError.checkNotNegative(indexCount, 'indexCount');
+    RangeError.checkNotNegative(instanceCount, 'instanceCount');
+    if (indexCount == 0 || instanceCount == 0) {
+      return;
+    }
     _validateVertexBindings();
-    if (!_drawIndexed(indexCount)) {
+    if (!_drawIndexed(indexCount, instanceCount)) {
       throw Exception("Failed to append drawIndexed");
     }
   }
@@ -747,13 +783,13 @@
   )
   external void _setPolygonMode(int polygonMode);
 
-  @Native<Bool Function(Pointer<Void>, Int)>(
+  @Native<Bool Function(Pointer<Void>, Int, Int)>(
     symbol: 'InternalFlutterGpu_RenderPass_Draw',
   )
-  external bool _draw(int vertexCount);
+  external bool _draw(int vertexCount, int instanceCount);
 
-  @Native<Bool Function(Pointer<Void>, Int)>(
+  @Native<Bool Function(Pointer<Void>, Int, Int)>(
     symbol: 'InternalFlutterGpu_RenderPass_DrawIndexed',
   )
-  external bool _drawIndexed(int indexCount);
+  external bool _drawIndexed(int indexCount, int instanceCount);
 }
diff --git a/engine/src/flutter/lib/gpu/lib/src/render_pipeline.dart b/engine/src/flutter/lib/gpu/lib/src/render_pipeline.dart
index 95a2086..4ab266f 100644
--- a/engine/src/flutter/lib/gpu/lib/src/render_pipeline.dart
+++ b/engine/src/flutter/lib/gpu/lib/src/render_pipeline.dart
@@ -42,8 +42,9 @@
   /// Packs a [VertexLayout] into the three ByteData buffers expected by the
   /// C++ side:
   ///
-  /// * `bufferLayouts` (`Int32List`): `[strideInBytes, attributeCount]` per
-  ///   buffer entry. The buffer's binding slot is implicit in its position.
+  /// * `bufferLayouts` (`Int32List`): `[strideInBytes, attributeCount,
+  ///   stepMode]` per buffer entry. The buffer's binding slot is implicit in
+  ///   its position.
   /// * `attributes` (`Int32List`): `[offsetInBytes, formatIndex,
   ///   nameByteLength]` per attribute entry, flattened across buffers in
   ///   buffer-list order. Each buffer's `attributeCount` row tells the C++
@@ -60,11 +61,12 @@
       totalAttributeCount += layout.buffers[i].attributes.length;
     }
 
-    final Int32List buffersData = Int32List(2 * layout.buffers.length);
+    final Int32List buffersData = Int32List(3 * layout.buffers.length);
     for (int i = 0; i < layout.buffers.length; i++) {
       final VertexBuffer buf = layout.buffers[i];
-      buffersData[i * 2 + 0] = buf.strideInBytes;
-      buffersData[i * 2 + 1] = buf.attributes.length;
+      buffersData[i * 3 + 0] = buf.strideInBytes;
+      buffersData[i * 3 + 1] = buf.attributes.length;
+      buffersData[i * 3 + 2] = buf.stepMode.index;
     }
 
     // First pass: encode each name to bytes and compute the total length,
diff --git a/engine/src/flutter/lib/gpu/lib/src/vertex_layout.dart b/engine/src/flutter/lib/gpu/lib/src/vertex_layout.dart
index 03004bf..21ccb78 100644
--- a/engine/src/flutter/lib/gpu/lib/src/vertex_layout.dart
+++ b/engine/src/flutter/lib/gpu/lib/src/vertex_layout.dart
@@ -63,6 +63,28 @@
   final int componentCount;
 }
 
+/// Controls how a [VertexBuffer] advances through its elements while drawing.
+///
+/// The step mode is a property of the buffer slot, not of an individual
+/// attribute. All attributes in the same [VertexBuffer] advance together.
+enum VertexStepMode {
+  /// Advance to the next buffer element for each vertex.
+  ///
+  /// This is the default mode and is appropriate for ordinary mesh data such
+  /// as positions, normals, texture coordinates, and vertex colors. During an
+  /// instanced draw, a vertex-rate buffer supplies the same sequence of vertex
+  /// elements to every instance.
+  vertex,
+
+  /// Advance to the next buffer element for each instance.
+  ///
+  /// Use this for per-instance data such as an object offset, transform, or
+  /// tint color. During a draw with `instanceCount: n`, the first instance
+  /// reads element 0 from this buffer, the second instance reads element 1,
+  /// and so on.
+  instance,
+}
+
 /// Describes a single vertex attribute: which shader input it feeds (by name),
 /// its byte offset within the owning vertex buffer's element, and its format.
 ///
@@ -100,27 +122,33 @@
   final int offsetInBytes;
 }
 
-/// Describes one vertex buffer slot: its per-element stride and the
-/// attributes that are read from it.
+/// Describes one vertex buffer slot: its per-element stride, step mode, and
+/// the attributes that are read from it.
 ///
 /// A buffer's position in [VertexLayout.buffers] determines the binding
 /// slot it is bound to via [RenderPass.bindVertexBuffer]; the first buffer
 /// is slot 0, the second is slot 1, and so on. Sparse binding slots are
 /// not currently supported.
 ///
-/// Step mode (vertex vs instance) and instance step rate are not yet
-/// configurable; every buffer currently advances per vertex. Both options
-/// will be added later as named parameters with sensible defaults, and the
-/// addition will be a non-breaking change for existing call sites.
-// TODO(https://github.com/flutter/flutter/issues/186307): Allow specifying
-// vertex step mode and instance step rate.
+/// The [stepMode] determines whether the buffer advances once per vertex or
+/// once per instance. Keep the default [VertexStepMode.vertex] for mesh data
+/// that describes the geometry being repeated. Use [VertexStepMode.instance]
+/// for data that changes from one instance to the next when calling
+/// [RenderPass.draw] or [RenderPass.drawIndexed] with an `instanceCount`
+/// greater than 1.
+// TODO(https://github.com/flutter/flutter/issues/186307): Add support for
+// instance step rates greater than one.
 // TODO(https://github.com/flutter/flutter/issues/186308): Allow sparse
 // vertex buffer binding slots.
 final class VertexBuffer {
   /// Creates a vertex buffer slot description with the given per-element
   /// [strideInBytes] and the list of [attributes] that the vertex shader
   /// reads from this buffer.
-  const VertexBuffer({required this.strideInBytes, required this.attributes});
+  const VertexBuffer({
+    required this.strideInBytes,
+    required this.attributes,
+    this.stepMode = VertexStepMode.vertex,
+  });
 
   /// Byte distance from the start of one element to the start of the next
   /// element in this vertex buffer (not the gap between an element's end and
@@ -132,6 +160,13 @@
 
   /// Attributes read from this vertex buffer by the vertex shader.
   final List<VertexAttribute> attributes;
+
+  /// How this vertex buffer advances while drawing.
+  ///
+  /// Defaults to [VertexStepMode.vertex]. Set this to
+  /// [VertexStepMode.instance] when the buffer contains one element per
+  /// instance rather than one element per vertex.
+  final VertexStepMode stepMode;
 }
 
 /// A complete vertex input layout: zero or more vertex buffer slots and the
diff --git a/engine/src/flutter/lib/gpu/render_pass.cc b/engine/src/flutter/lib/gpu/render_pass.cc
index f7b76c5..643e219 100644
--- a/engine/src/flutter/lib/gpu/render_pass.cc
+++ b/engine/src/flutter/lib/gpu/render_pass.cc
@@ -152,10 +152,11 @@
 
   if (context.GetBackendType() == impeller::Context::BackendType::kOpenGLES &&
       !context.GetPipelineLibrary()->HasPipeline(pipeline_desc)) {
-    // For GLES, new pipeline creation must be done on the reactor (raster)
-    // thread. We're about the draw, so we need to synchronize with a raster
-    // task in order to get the new pipeline. Depending on how busy the raster
-    // thread is, this could hang the UI thread long enough to miss a frame.
+    // New pipeline creation for this backend must be done on the reactor
+    // (raster) thread. We're about the draw, so we need to synchronize with a
+    // raster task in order to get the new pipeline. Depending on how busy the
+    // raster thread is, this could hang the UI thread long enough to miss a
+    // frame.
 
     // Note that this branch is only called if a new pipeline actually needs to
     // be built.
@@ -181,7 +182,13 @@
   return pipeline;
 }
 
-bool RenderPass::Draw(size_t element_count, bool indexed) {
+bool RenderPass::Draw(size_t element_count,
+                      size_t instance_count,
+                      bool indexed) {
+  if (element_count == 0u || instance_count == 0u) {
+    return true;
+  }
+
   if (indexed && index_buffer_type == impeller::IndexType::kNone) {
     // drawIndexed was called without an index buffer bound.
     return false;
@@ -228,6 +235,7 @@
                                  impeller::IndexType::kNone);
   }
   render_pass_->SetElementCount(element_count);
+  render_pass_->SetInstanceCount(instance_count);
 
   render_pass_->SetStencilReference(stencil_reference);
 
@@ -649,14 +657,18 @@
 }
 
 bool InternalFlutterGpu_RenderPass_Draw(flutter::gpu::RenderPass* wrapper,
-                                        int vertex_count) {
-  // Guard the cast to the size_t element count; a negative value would wrap.
-  return vertex_count >= 0 && wrapper->Draw(vertex_count, /*indexed=*/false);
+                                        int vertex_count,
+                                        int instance_count) {
+  // Guard the casts to size_t; a negative value would wrap.
+  return vertex_count >= 0 && instance_count >= 0 &&
+         wrapper->Draw(vertex_count, instance_count, /*indexed=*/false);
 }
 
 bool InternalFlutterGpu_RenderPass_DrawIndexed(
     flutter::gpu::RenderPass* wrapper,
-    int index_count) {
-  // Guard the cast to the size_t element count; a negative value would wrap.
-  return index_count >= 0 && wrapper->Draw(index_count, /*indexed=*/true);
+    int index_count,
+    int instance_count) {
+  // Guard the casts to size_t; a negative value would wrap.
+  return index_count >= 0 && instance_count >= 0 &&
+         wrapper->Draw(index_count, instance_count, /*indexed=*/true);
 }
diff --git a/engine/src/flutter/lib/gpu/render_pass.h b/engine/src/flutter/lib/gpu/render_pass.h
index 23682163..a0b4c55 100644
--- a/engine/src/flutter/lib/gpu/render_pass.h
+++ b/engine/src/flutter/lib/gpu/render_pass.h
@@ -58,8 +58,8 @@
 
   /// Append a draw to the underlying render pass. [element_count] is the
   /// vertex count for a non-indexed draw, or the index count when
-  /// [indexed] is true.
-  bool Draw(size_t element_count, bool indexed);
+  /// [indexed] is true. [instance_count] is the number of instances to draw.
+  bool Draw(size_t element_count, size_t instance_count, bool indexed);
 
   struct BufferAndUniformSlot {
     impeller::ShaderUniformSlot slot;
@@ -81,10 +81,9 @@
   // Vertex buffers indexed by binding slot. Mirrors
   // `impeller::kMaxVertexBuffers`; Impeller's HAL caps vertex buffer
   // bindings at 16 per draw, and index `i` here corresponds to the binding
-  // declared at slot `i` in the active VertexLayout. On the OpenGL ES
-  // backend the per-pipeline limit on the *total attribute count* across
-  // all bound buffers is `GL_MAX_VERTEX_ATTRIBS` (spec minimum 8 on GLES
-  // 2.0, 16 on GLES 3.0+), enforced by the driver.
+  // declared at slot `i` in the active VertexLayout. Devices may impose a
+  // lower per-pipeline limit on the total attribute count across all bound
+  // buffers.
   static constexpr size_t kMaxVertexBufferSlots = 16;
   std::array<impeller::BufferView, kMaxVertexBufferSlots> vertex_buffers;
   // Highest slot index that has been bound on this pass (plus one).
@@ -291,12 +290,14 @@
 FLUTTER_GPU_EXPORT
 extern bool InternalFlutterGpu_RenderPass_Draw(
     flutter::gpu::RenderPass* wrapper,
-    int vertex_count);
+    int vertex_count,
+    int instance_count);
 
 FLUTTER_GPU_EXPORT
 extern bool InternalFlutterGpu_RenderPass_DrawIndexed(
     flutter::gpu::RenderPass* wrapper,
-    int index_count);
+    int index_count,
+    int instance_count);
 
 }  // extern "C"
 
diff --git a/engine/src/flutter/lib/gpu/render_pipeline.cc b/engine/src/flutter/lib/gpu/render_pipeline.cc
index 21cfcd2..8402ea8 100644
--- a/engine/src/flutter/lib/gpu/render_pipeline.cc
+++ b/engine/src/flutter/lib/gpu/render_pipeline.cc
@@ -83,9 +83,10 @@
 }};
 
 // Width of each "row" in the packed `bufferLayouts` ByteData passed from
-// Dart: `[strideInBytes, attributeCount]`. Each buffer's binding slot is
-// implicit in its position in the array (the first buffer is slot 0, etc.).
-constexpr size_t kBufferLayoutInts = 2;
+// Dart: `[strideInBytes, attributeCount, stepMode]`. Each buffer's binding
+// slot is implicit in its position in the array (the first buffer is slot 0,
+// etc.).
+constexpr size_t kBufferLayoutInts = 3;
 
 // Width of each "row" in the packed `attributes` ByteData passed from Dart:
 // `[offsetInBytes, formatIndex, nameByteLength]`. Attribute rows are
@@ -128,11 +129,18 @@
     const int32_t stride = buffer_layouts[buffer_index * kBufferLayoutInts + 0];
     const int32_t attr_count_in_buffer =
         buffer_layouts[buffer_index * kBufferLayoutInts + 1];
+    const int32_t step_mode =
+        buffer_layouts[buffer_index * kBufferLayoutInts + 2];
     if (stride <= 0) {
       return absl::InvalidArgumentError(
           absl::StrCat("VertexBuffer.strideInBytes must be positive (got ",
                        stride, ") on buffer at index ", buffer_index, "."));
     }
+    if (step_mode < 0 || step_mode > 1) {
+      return absl::InvalidArgumentError(
+          absl::StrCat("VertexBuffer.stepMode is out of range (got ", step_mode,
+                       ") on buffer at index ", buffer_index, "."));
+    }
     if (attr_count_in_buffer < 0 ||
         attr_cursor + static_cast<size_t>(attr_count_in_buffer) >
             attribute_count) {
@@ -140,7 +148,10 @@
           "Internal error: attribute count overruns the packed attributes "
           "blob.");
     }
-    stage_layouts.push_back({static_cast<size_t>(stride), buffer_index});
+    stage_layouts.push_back({static_cast<size_t>(stride), buffer_index,
+                             step_mode == 0
+                                 ? impeller::VertexInputRate::kVertex
+                                 : impeller::VertexInputRate::kInstance});
 
     // Track each attribute's byte range within this buffer so we can
     // detect overlaps after building them all.
@@ -229,13 +240,12 @@
             "shader."));
       }
       // Match the shader's scalar type class (float vs signed int vs
-      // unsigned int). Mirroring WebGPU, Vulkan, and Metal, component-count
-      // mismatches are NOT errors: the shader receives default substitution
-      // (missing components default to (0, 0, 0, 1)) when the buffer
-      // supplies fewer components than declared, and reads only the leading
-      // components when the buffer supplies more. The shipped enum only
-      // contains 32-bit formats, so checking `type` alone is sufficient
-      // until 8/16-bit formats are added.
+      // unsigned int). Component-count mismatches are NOT errors: the shader
+      // receives default substitution (missing components default to
+      // (0, 0, 0, 1)) when the buffer supplies fewer components than
+      // declared, and reads only the leading components when the buffer
+      // supplies more. The shipped enum only contains 32-bit formats, so
+      // checking `type` alone is sufficient until 8/16-bit formats are added.
       // TODO(https://github.com/flutter/flutter/issues/186309): Add
       // normalized, packed, half-float, BGRA-swizzled, and 64-bit vertex
       // attribute formats; the format check will need to also verify
diff --git a/engine/src/flutter/lib/ui/fixtures/shaders/BUILD.gn b/engine/src/flutter/lib/ui/fixtures/shaders/BUILD.gn
index d6faf1f..e6f6071 100644
--- a/engine/src/flutter/lib/ui/fixtures/shaders/BUILD.gn
+++ b/engine/src/flutter/lib/ui/fixtures/shaders/BUILD.gn
@@ -57,6 +57,8 @@
 
   impellerc("flutter_gpu_shaders") {
     shaders = [
+      "//flutter/impeller/fixtures/flutter_gpu_instanced.frag",
+      "//flutter/impeller/fixtures/flutter_gpu_instanced.vert",
       "//flutter/impeller/fixtures/flutter_gpu_unlit.frag",
       "//flutter/impeller/fixtures/flutter_gpu_unlit.vert",
       "//flutter/impeller/fixtures/flutter_gpu_unlit_alt_instance.frag",
@@ -65,7 +67,7 @@
     ]
 
     fixtures = rebase_path("//flutter/impeller/fixtures")
-    shader_bundle = "{\"UnlitFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_unlit.frag\"}, \"UnlitVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_unlit.vert\"}, \"UnlitFragmentAltInstance\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_unlit_alt_instance.frag\"}, \"TextureFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_texture.frag\"}, \"TextureVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_texture.vert\"}}"
+    shader_bundle = "{\"InstancedFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_instanced.frag\"}, \"InstancedVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_instanced.vert\"}, \"UnlitFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_unlit.frag\"}, \"UnlitVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_unlit.vert\"}, \"UnlitFragmentAltInstance\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_unlit_alt_instance.frag\"}, \"TextureFragment\": {\"type\": \"fragment\", \"file\": \"${fixtures}/flutter_gpu_texture.frag\"}, \"TextureVertex\": {\"type\": \"vertex\", \"file\": \"${fixtures}/flutter_gpu_texture.vert\"}}"
     shader_bundle_output = "test.shaderbundle"
   }
 
diff --git a/engine/src/flutter/testing/dart/gpu_test.dart b/engine/src/flutter/testing/dart/gpu_test.dart
index 850dfb5..94053c9 100644
--- a/engine/src/flutter/testing/dart/gpu_test.dart
+++ b/engine/src/flutter/testing/dart/gpu_test.dart
@@ -32,6 +32,15 @@
   ]);
 }
 
+ByteData mvpUBO(Matrix4 mvp) {
+  return float32(<double>[
+    mvp[0], mvp[1], mvp[2], mvp[3], //
+    mvp[4], mvp[5], mvp[6], mvp[7], //
+    mvp[8], mvp[9], mvp[10], mvp[11], //
+    mvp[12], mvp[13], mvp[14], mvp[15], //
+  ]);
+}
+
 gpu.RenderPipeline createUnlitRenderPipeline() {
   final gpu.ShaderLibrary? library = gpu.ShaderLibrary.fromAsset('test.shaderbundle');
   assert(library != null);
@@ -1048,6 +1057,67 @@
     await comparer.addGoldenImage(image, 'flutter_gpu_test_triangle.png');
   }, skip: !(impellerEnabled && flutterGpuEnabled));
 
+  test('Can render instanced triangles with instance-rate vertex buffer', () async {
+    const triangleVertexCount = 3;
+    const triangleInstanceCount = 4;
+    final RenderPassState state = createSimpleRenderPass();
+
+    final gpu.ShaderLibrary library = gpu.ShaderLibrary.fromAsset('test.shaderbundle')!;
+    final gpu.RenderPipeline pipeline = gpu.gpuContext.createRenderPipeline(
+      library['InstancedVertex']!,
+      library['InstancedFragment']!,
+      vertexLayout: const gpu.VertexLayout(
+        buffers: <gpu.VertexBuffer>[
+          gpu.VertexBuffer(
+            strideInBytes: 8,
+            attributes: <gpu.VertexAttribute>[
+              gpu.VertexAttribute(name: 'position', format: gpu.VertexFormat.float32x2),
+            ],
+          ),
+          gpu.VertexBuffer(
+            strideInBytes: 24,
+            stepMode: gpu.VertexStepMode.instance,
+            attributes: <gpu.VertexAttribute>[
+              gpu.VertexAttribute(name: 'instance_offset', format: gpu.VertexFormat.float32x2),
+              gpu.VertexAttribute(
+                name: 'instance_color',
+                offsetInBytes: 8,
+                format: gpu.VertexFormat.float32x4,
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+    state.renderPass.bindPipeline(pipeline);
+
+    final gpu.HostBuffer transients = gpu.gpuContext.createHostBuffer();
+    final gpu.BufferView vertices = transients.emplace(
+      float32(<double>[
+        -0.18, 0.18, //
+        0.0, -0.18, //
+        0.18, 0.18, //
+      ]),
+    );
+    final gpu.BufferView instances = transients.emplace(
+      float32(<double>[
+        -0.45, 0.45, 1, 0, 0, 1, //
+        0.45, 0.45, 0, 1, 0, 1, //
+        -0.45, -0.45, 0, 0, 1, 1, //
+        0.45, -0.45, 1, 1, 0, 1, //
+      ]),
+    );
+    final gpu.BufferView vertInfo = transients.emplace(mvpUBO(Matrix4.identity()));
+    state.renderPass.bindVertexBuffer(vertices);
+    state.renderPass.bindVertexBuffer(instances, slot: 1);
+    state.renderPass.bindUniform(pipeline.vertexShader.getUniformSlot('VertInfo'), vertInfo);
+    state.renderPass.draw(triangleVertexCount, instanceCount: triangleInstanceCount);
+    state.commandBuffer.submit();
+
+    final ui.Image image = state.renderTexture.asImage();
+    await comparer.addGoldenImage(image, 'flutter_gpu_test_instanced_triangles.png');
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
   test('createRenderPipeline rejects VertexLayout with wrong attribute format', () async {
     final gpu.ShaderLibrary library = gpu.ShaderLibrary.fromAsset('test.shaderbundle')!;
     try {
@@ -1178,6 +1248,32 @@
     );
   }, skip: !(impellerEnabled && flutterGpuEnabled));
 
+  test('draw calls reject negative instanceCount', () async {
+    final RenderPassState state = createSimpleRenderPass();
+    final gpu.RenderPipeline pipeline = createUnlitRenderPipeline();
+    state.renderPass.bindPipeline(pipeline);
+
+    final gpu.HostBuffer transients = gpu.gpuContext.createHostBuffer();
+    final gpu.BufferView vertices = transients.emplace(
+      float32(<double>[-0.5, 0.5, 0.0, -0.5, 0.5, 0.5]),
+    );
+    state.renderPass.bindVertexBuffer(vertices);
+
+    expect(() => state.renderPass.draw(3, instanceCount: -1), throwsRangeError);
+    expect(() => state.renderPass.drawIndexed(3, instanceCount: -1), throwsRangeError);
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
+  test('draw calls with zero count or instanceCount are no-ops', () async {
+    final RenderPassState state = createSimpleRenderPass();
+    final gpu.RenderPipeline pipeline = createUnlitRenderPipeline();
+    state.renderPass.bindPipeline(pipeline);
+
+    state.renderPass.draw(0);
+    state.renderPass.draw(3, instanceCount: 0);
+    state.renderPass.drawIndexed(0);
+    state.renderPass.drawIndexed(3, instanceCount: 0);
+  }, skip: !(impellerEnabled && flutterGpuEnabled));
+
   test('createRenderPipeline rejects VertexAttribute with unknown name', () async {
     final gpu.ShaderLibrary library = gpu.ShaderLibrary.fromAsset('test.shaderbundle')!;
     try {