Skip to content

Commit

Permalink
Automatic batching/instancing of draw commands (#9685)
Browse files Browse the repository at this point in the history
# Objective

- Implement the foundations of automatic batching/instancing of draw
commands as the next step from #89
- NOTE: More performance improvements will come when more data is
managed and bound in ways that do not require rebinding such as mesh,
material, and texture data.

## Solution

- The core idea for batching of draw commands is to check whether any of
the information that has to be passed when encoding a draw command
changes between two things that are being drawn according to the sorted
render phase order. These should be things like the pipeline, bind
groups and their dynamic offsets, index/vertex buffers, and so on.
  - The following assumptions have been made:
- Only entities with prepared assets (pipelines, materials, meshes) are
queued to phases
- View bindings are constant across a phase for a given draw function as
phases are per-view
- `batch_and_prepare_render_phase` is the only system that performs this
batching and has sole responsibility for preparing the per-object data.
As such the mesh binding and dynamic offsets are assumed to only vary as
a result of the `batch_and_prepare_render_phase` system, e.g. due to
having to split data across separate uniform bindings within the same
buffer due to the maximum uniform buffer binding size.
- Implement `GpuArrayBuffer` for `Mesh2dUniform` to store Mesh2dUniform
in arrays in GPU buffers rather than each one being at a dynamic offset
in a uniform buffer. This is the same optimisation that was made for 3D
not long ago.
- Change batch size for a range in `PhaseItem`, adding API for getting
or mutating the range. This is more flexible than a size as the length
of the range can be used in place of the size, but the start and end can
be otherwise whatever is needed.
- Add an optional mesh bind group dynamic offset to `PhaseItem`. This
avoids having to do a massive table move just to insert
`GpuArrayBufferIndex` components.

## Benchmarks

All tests have been run on an M1 Max on AC power. `bevymark` and
`many_cubes` were modified to use 1920x1080 with a scale factor of 1. I
run a script that runs a separate Tracy capture process, and then runs
the bevy example with `--features bevy_ci_testing,trace_tracy` and
`CI_TESTING_CONFIG=../benchmark.ron` with the contents of
`../benchmark.ron`:
```rust
(
    exit_after: Some(1500)
)
```
...in order to run each test for 1500 frames.

The recent changes to `many_cubes` and `bevymark` added reproducible
random number generation so that with the same settings, the same rng
will occur. They also added benchmark modes that use a fixed delta time
for animations. Combined this means that the same frames should be
rendered both on main and on the branch.

The graphs compare main (yellow) to this PR (red).

### 3D Mesh `many_cubes --benchmark`

<img width="1411" alt="Screenshot 2023-09-03 at 23 42 10"
src="https://github.com/bevyengine/bevy/assets/302146/2088716a-c918-486c-8129-090b26fd2bc4">
The mesh and material are the same for all instances. This is basically
the best case for the initial batching implementation as it results in 1
draw for the ~11.7k visible meshes. It gives a ~30% reduction in median
frame time.

The 1000th frame is identical using the flip tool:

![flip many_cubes-main-mesh3d many_cubes-batching-mesh3d 67ppd
ldr](https://github.com/bevyengine/bevy/assets/302146/2511f37a-6df8-481a-932f-706ca4de7643)

```
     Mean: 0.000000
     Weighted median: 0.000000
     1st weighted quartile: 0.000000
     3rd weighted quartile: 0.000000
     Min: 0.000000
     Max: 0.000000
     Evaluation time: 0.4615 seconds
```

### 3D Mesh `many_cubes --benchmark --material-texture-count 10`

<img width="1404" alt="Screenshot 2023-09-03 at 23 45 18"
src="https://github.com/bevyengine/bevy/assets/302146/5ee9c447-5bd2-45c6-9706-ac5ff8916daf">
This run uses 10 different materials by varying their textures. The
materials are randomly selected, and there is no sorting by material
bind group for opaque 3D so any batching is 'random'. The PR produces a
~5% reduction in median frame time. If we were to sort the opaque phase
by the material bind group, then this should be a lot faster. This
produces about 10.5k draws for the 11.7k visible entities. This makes
sense as randomly selecting from 10 materials gives a chance that two
adjacent entities randomly select the same material and can be batched.

The 1000th frame is identical in flip:

![flip many_cubes-main-mesh3d-mtc10 many_cubes-batching-mesh3d-mtc10
67ppd
ldr](https://github.com/bevyengine/bevy/assets/302146/2b3a8614-9466-4ed8-b50c-d4aa71615dbb)

```
     Mean: 0.000000
     Weighted median: 0.000000
     1st weighted quartile: 0.000000
     3rd weighted quartile: 0.000000
     Min: 0.000000
     Max: 0.000000
     Evaluation time: 0.4537 seconds
```

### 3D Mesh `many_cubes --benchmark --vary-per-instance`

<img width="1394" alt="Screenshot 2023-09-03 at 23 48 44"
src="https://github.com/bevyengine/bevy/assets/302146/f02a816b-a444-4c18-a96a-63b5436f3b7f">
This run varies the material data per instance by randomly-generating
its colour. This is the worst case for batching and that it performs
about the same as `main` is a good thing as it demonstrates that the
batching has minimal overhead when dealing with ~11k visible mesh
entities.

The 1000th frame is identical according to flip:

![flip many_cubes-main-mesh3d-vpi many_cubes-batching-mesh3d-vpi 67ppd
ldr](https://github.com/bevyengine/bevy/assets/302146/ac5f5c14-9bda-4d1a-8219-7577d4aac68c)

```
     Mean: 0.000000
     Weighted median: 0.000000
     1st weighted quartile: 0.000000
     3rd weighted quartile: 0.000000
     Min: 0.000000
     Max: 0.000000
     Evaluation time: 0.4568 seconds
```

### 2D Mesh `bevymark --benchmark --waves 160 --per-wave 1000 --mode
mesh2d`

<img width="1412" alt="Screenshot 2023-09-03 at 23 59 56"
src="https://github.com/bevyengine/bevy/assets/302146/cb02ae07-237b-4646-ae9f-fda4dafcbad4">
This spawns 160 waves of 1000 quad meshes that are shaded with
ColorMaterial. Each wave has a different material so 160 waves currently
should result in 160 batches. This results in a 50% reduction in median
frame time.

Capturing a screenshot of the 1000th frame main vs PR gives:

![flip bevymark-main-mesh2d bevymark-batching-mesh2d 67ppd
ldr](https://github.com/bevyengine/bevy/assets/302146/80102728-1217-4059-87af-14d05044df40)

```
     Mean: 0.001222
     Weighted median: 0.750432
     1st weighted quartile: 0.453494
     3rd weighted quartile: 0.969758
     Min: 0.000000
     Max: 0.990296
     Evaluation time: 0.4255 seconds
```

So they seem to produce the same results. I also double-checked the
number of draws. `main` does 160000 draws, and the PR does 160, as
expected.

### 2D Mesh `bevymark --benchmark --waves 160 --per-wave 1000 --mode
mesh2d --material-texture-count 10`

<img width="1392" alt="Screenshot 2023-09-04 at 00 09 22"
src="https://github.com/bevyengine/bevy/assets/302146/4358da2e-ce32-4134-82df-3ab74c40849c">
This generates 10 textures and generates materials for each of those and
then selects one material per wave. The median frame time is reduced by
50%. Similar to the plain run above, this produces 160 draws on the PR
and 160000 on `main` and the 1000th frame is identical (ignoring the fps
counter text overlay).

![flip bevymark-main-mesh2d-mtc10 bevymark-batching-mesh2d-mtc10 67ppd
ldr](https://github.com/bevyengine/bevy/assets/302146/ebed2822-dce7-426a-858b-b77dc45b986f)

```
     Mean: 0.002877
     Weighted median: 0.964980
     1st weighted quartile: 0.668871
     3rd weighted quartile: 0.982749
     Min: 0.000000
     Max: 0.992377
     Evaluation time: 0.4301 seconds
```

### 2D Mesh `bevymark --benchmark --waves 160 --per-wave 1000 --mode
mesh2d --vary-per-instance`

<img width="1396" alt="Screenshot 2023-09-04 at 00 13 53"
src="https://github.com/bevyengine/bevy/assets/302146/b2198b18-3439-47ad-919a-cdabe190facb">
This creates unique materials per instance by randomly-generating the
material's colour. This is the worst case for 2D batching. Somehow, this
PR manages a 7% reduction in median frame time. Both main and this PR
issue 160000 draws.

The 1000th frame is the same:

![flip bevymark-main-mesh2d-vpi bevymark-batching-mesh2d-vpi 67ppd
ldr](https://github.com/bevyengine/bevy/assets/302146/a2ec471c-f576-4a36-a23b-b24b22578b97)

```
     Mean: 0.001214
     Weighted median: 0.937499
     1st weighted quartile: 0.635467
     3rd weighted quartile: 0.979085
     Min: 0.000000
     Max: 0.988971
     Evaluation time: 0.4462 seconds
```

### 2D Sprite `bevymark --benchmark --waves 160 --per-wave 1000 --mode
sprite`

<img width="1396" alt="Screenshot 2023-09-04 at 12 21 12"
src="https://github.com/bevyengine/bevy/assets/302146/8b31e915-d6be-4cac-abf5-c6a4da9c3d43">
This just spawns 160 waves of 1000 sprites. There should be and is no
notable difference between main and the PR.

### 2D Sprite `bevymark --benchmark --waves 160 --per-wave 1000 --mode
sprite --material-texture-count 10`

<img width="1389" alt="Screenshot 2023-09-04 at 12 36 08"
src="https://github.com/bevyengine/bevy/assets/302146/45fe8d6d-c901-4062-a349-3693dd044413">
This spawns the sprites selecting a texture at random per instance from
the 10 generated textures. This has no significant change vs main and
shouldn't.

### 2D Sprite `bevymark --benchmark --waves 160 --per-wave 1000 --mode
sprite --vary-per-instance`

<img width="1401" alt="Screenshot 2023-09-04 at 12 29 52"
src="https://github.com/bevyengine/bevy/assets/302146/762c5c60-352e-471f-8dbe-bbf10e24ebd6">
This sets the sprite colour as being unique per instance. This can still
all be drawn using one batch. There should be no difference but the PR
produces median frame times that are 4% higher. Investigation showed no
clear sources of cost, rather a mix of give and take that should not
happen. It seems like noise in the results.

### Summary

| Benchmark  | % change in median frame time |
| ------------- | ------------- |
| many_cubes  | 🟩 -30%  |
| many_cubes 10 materials  | 🟩 -5%  |
| many_cubes unique materials  | 🟩 ~0%  |
| bevymark mesh2d  | 🟩 -50%  |
| bevymark mesh2d 10 materials  | 🟩 -50%  |
| bevymark mesh2d unique materials  | 🟩 -7%  |
| bevymark sprite  | 🟥 2%  |
| bevymark sprite 10 materials  | 🟥 0.6%  |
| bevymark sprite unique materials  | 🟥 4.1%  |

---

## Changelog

- Added: 2D and 3D mesh entities that share the same mesh and material
(same textures, same data) are now batched into the same draw command
for better performance.

---------

Co-authored-by: robtfm <50659922+robtfm@users.noreply.github.com>
Co-authored-by: Nicola Papale <nico@nicopap.ch>
  • Loading branch information
3 people authored Sep 21, 2023
1 parent e60249e commit 5c884c5
Show file tree
Hide file tree
Showing 31 changed files with 772 additions and 303 deletions.
6 changes: 4 additions & 2 deletions assets/shaders/custom_gltf_2d.wgsl
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#import bevy_sprite::mesh2d_view_bindings globals
#import bevy_sprite::mesh2d_bindings mesh
#import bevy_sprite::mesh2d_functions mesh2d_position_local_to_clip
#import bevy_sprite::mesh2d_functions get_model_matrix, mesh2d_position_local_to_clip

struct Vertex {
@builtin(instance_index) instance_index: u32,
@location(0) position: vec3<f32>,
@location(1) color: vec4<f32>,
@location(2) barycentric: vec3<f32>,
Expand All @@ -17,7 +18,8 @@ struct VertexOutput {
@vertex
fn vertex(vertex: Vertex) -> VertexOutput {
var out: VertexOutput;
out.clip_position = mesh2d_position_local_to_clip(mesh.model, vec4<f32>(vertex.position, 1.0));
let model = get_model_matrix(vertex.instance_index);
out.clip_position = mesh2d_position_local_to_clip(model, vec4<f32>(vertex.position, 1.0));
out.color = vertex.color;
out.barycentric = vertex.barycentric;
return out;
Expand Down
26 changes: 22 additions & 4 deletions crates/bevy_core_pipeline/src/core_2d/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub mod graph {
}
pub const CORE_2D: &str = graph::NAME;

use std::ops::Range;

pub use camera_2d::*;
pub use main_pass_2d_node::*;

Expand All @@ -35,7 +37,7 @@ use bevy_render::{
render_resource::CachedRenderPipelineId,
Extract, ExtractSchedule, Render, RenderApp, RenderSet,
};
use bevy_utils::FloatOrd;
use bevy_utils::{nonmax::NonMaxU32, FloatOrd};

use crate::{tonemapping::TonemappingNode, upscaling::UpscalingNode};

Expand Down Expand Up @@ -83,7 +85,8 @@ pub struct Transparent2d {
pub entity: Entity,
pub pipeline: CachedRenderPipelineId,
pub draw_function: DrawFunctionId,
pub batch_size: usize,
pub batch_range: Range<u32>,
pub dynamic_offset: Option<NonMaxU32>,
}

impl PhaseItem for Transparent2d {
Expand Down Expand Up @@ -111,8 +114,23 @@ impl PhaseItem for Transparent2d {
}

#[inline]
fn batch_size(&self) -> usize {
self.batch_size
fn batch_range(&self) -> &Range<u32> {
&self.batch_range
}

#[inline]
fn batch_range_mut(&mut self) -> &mut Range<u32> {
&mut self.batch_range
}

#[inline]
fn dynamic_offset(&self) -> Option<NonMaxU32> {
self.dynamic_offset
}

#[inline]
fn dynamic_offset_mut(&mut self) -> &mut Option<NonMaxU32> {
&mut self.dynamic_offset
}
}

Expand Down
70 changes: 59 additions & 11 deletions crates/bevy_core_pipeline/src/core_3d/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub mod graph {
}
pub const CORE_3D: &str = graph::NAME;

use std::cmp::Reverse;
use std::{cmp::Reverse, ops::Range};

pub use camera_3d::*;
pub use main_opaque_pass_3d_node::*;
Expand All @@ -50,7 +50,7 @@ use bevy_render::{
view::ViewDepthTexture,
Extract, ExtractSchedule, Render, RenderApp, RenderSet,
};
use bevy_utils::{FloatOrd, HashMap};
use bevy_utils::{nonmax::NonMaxU32, FloatOrd, HashMap};

use crate::{
prepass::{
Expand Down Expand Up @@ -135,7 +135,8 @@ pub struct Opaque3d {
pub pipeline: CachedRenderPipelineId,
pub entity: Entity,
pub draw_function: DrawFunctionId,
pub batch_size: usize,
pub batch_range: Range<u32>,
pub dynamic_offset: Option<NonMaxU32>,
}

impl PhaseItem for Opaque3d {
Expand Down Expand Up @@ -164,8 +165,23 @@ impl PhaseItem for Opaque3d {
}

#[inline]
fn batch_size(&self) -> usize {
self.batch_size
fn batch_range(&self) -> &Range<u32> {
&self.batch_range
}

#[inline]
fn batch_range_mut(&mut self) -> &mut Range<u32> {
&mut self.batch_range
}

#[inline]
fn dynamic_offset(&self) -> Option<NonMaxU32> {
self.dynamic_offset
}

#[inline]
fn dynamic_offset_mut(&mut self) -> &mut Option<NonMaxU32> {
&mut self.dynamic_offset
}
}

Expand All @@ -181,7 +197,8 @@ pub struct AlphaMask3d {
pub pipeline: CachedRenderPipelineId,
pub entity: Entity,
pub draw_function: DrawFunctionId,
pub batch_size: usize,
pub batch_range: Range<u32>,
pub dynamic_offset: Option<NonMaxU32>,
}

impl PhaseItem for AlphaMask3d {
Expand Down Expand Up @@ -210,8 +227,23 @@ impl PhaseItem for AlphaMask3d {
}

#[inline]
fn batch_size(&self) -> usize {
self.batch_size
fn batch_range(&self) -> &Range<u32> {
&self.batch_range
}

#[inline]
fn batch_range_mut(&mut self) -> &mut Range<u32> {
&mut self.batch_range
}

#[inline]
fn dynamic_offset(&self) -> Option<NonMaxU32> {
self.dynamic_offset
}

#[inline]
fn dynamic_offset_mut(&mut self) -> &mut Option<NonMaxU32> {
&mut self.dynamic_offset
}
}

Expand All @@ -227,7 +259,8 @@ pub struct Transparent3d {
pub pipeline: CachedRenderPipelineId,
pub entity: Entity,
pub draw_function: DrawFunctionId,
pub batch_size: usize,
pub batch_range: Range<u32>,
pub dynamic_offset: Option<NonMaxU32>,
}

impl PhaseItem for Transparent3d {
Expand Down Expand Up @@ -255,8 +288,23 @@ impl PhaseItem for Transparent3d {
}

#[inline]
fn batch_size(&self) -> usize {
self.batch_size
fn batch_range(&self) -> &Range<u32> {
&self.batch_range
}

#[inline]
fn batch_range_mut(&mut self) -> &mut Range<u32> {
&mut self.batch_range
}

#[inline]
fn dynamic_offset(&self) -> Option<NonMaxU32> {
self.dynamic_offset
}

#[inline]
fn dynamic_offset_mut(&mut self) -> &mut Option<NonMaxU32> {
&mut self.dynamic_offset
}
}

Expand Down
48 changes: 46 additions & 2 deletions crates/bevy_core_pipeline/src/prepass/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

pub mod node;

use std::cmp::Reverse;
use std::{cmp::Reverse, ops::Range};

use bevy_ecs::prelude::*;
use bevy_reflect::Reflect;
Expand All @@ -36,7 +36,7 @@ use bevy_render::{
render_resource::{CachedRenderPipelineId, Extent3d, TextureFormat},
texture::CachedTexture,
};
use bevy_utils::FloatOrd;
use bevy_utils::{nonmax::NonMaxU32, FloatOrd};

pub const DEPTH_PREPASS_FORMAT: TextureFormat = TextureFormat::Depth32Float;
pub const NORMAL_PREPASS_FORMAT: TextureFormat = TextureFormat::Rgb10a2Unorm;
Expand Down Expand Up @@ -83,6 +83,8 @@ pub struct Opaque3dPrepass {
pub entity: Entity,
pub pipeline_id: CachedRenderPipelineId,
pub draw_function: DrawFunctionId,
pub batch_range: Range<u32>,
pub dynamic_offset: Option<NonMaxU32>,
}

impl PhaseItem for Opaque3dPrepass {
Expand All @@ -109,6 +111,26 @@ impl PhaseItem for Opaque3dPrepass {
// Key negated to match reversed SortKey ordering
radsort::sort_by_key(items, |item| -item.distance);
}

#[inline]
fn batch_range(&self) -> &Range<u32> {
&self.batch_range
}

#[inline]
fn batch_range_mut(&mut self) -> &mut Range<u32> {
&mut self.batch_range
}

#[inline]
fn dynamic_offset(&self) -> Option<NonMaxU32> {
self.dynamic_offset
}

#[inline]
fn dynamic_offset_mut(&mut self) -> &mut Option<NonMaxU32> {
&mut self.dynamic_offset
}
}

impl CachedRenderPipelinePhaseItem for Opaque3dPrepass {
Expand All @@ -128,6 +150,8 @@ pub struct AlphaMask3dPrepass {
pub entity: Entity,
pub pipeline_id: CachedRenderPipelineId,
pub draw_function: DrawFunctionId,
pub batch_range: Range<u32>,
pub dynamic_offset: Option<NonMaxU32>,
}

impl PhaseItem for AlphaMask3dPrepass {
Expand All @@ -154,6 +178,26 @@ impl PhaseItem for AlphaMask3dPrepass {
// Key negated to match reversed SortKey ordering
radsort::sort_by_key(items, |item| -item.distance);
}

#[inline]
fn batch_range(&self) -> &Range<u32> {
&self.batch_range
}

#[inline]
fn batch_range_mut(&mut self) -> &mut Range<u32> {
&mut self.batch_range
}

#[inline]
fn dynamic_offset(&self) -> Option<NonMaxU32> {
self.dynamic_offset
}

#[inline]
fn dynamic_offset_mut(&mut self) -> &mut Option<NonMaxU32> {
&mut self.dynamic_offset
}
}

impl CachedRenderPipelinePhaseItem for AlphaMask3dPrepass {
Expand Down
3 changes: 2 additions & 1 deletion crates/bevy_gizmos/src/pipeline_2d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ fn queue_line_gizmos_2d(
draw_function,
pipeline,
sort_key: FloatOrd(f32::INFINITY),
batch_size: 1,
batch_range: 0..1,
dynamic_offset: None,
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion crates/bevy_gizmos/src/pipeline_3d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ fn queue_line_gizmos_3d(
draw_function,
pipeline,
distance: 0.,
batch_size: 1,
batch_range: 0..1,
dynamic_offset: None,
});
}
}
Expand Down
32 changes: 31 additions & 1 deletion crates/bevy_math/src/affine3.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use glam::{Affine3A, Mat3, Vec3};
use glam::{Affine3A, Mat3, Vec3, Vec3Swizzles, Vec4};

/// Reduced-size version of `glam::Affine3A` for use when storage has
/// significant performance impact. Convert to `glam::Affine3A` to do
Expand All @@ -10,6 +10,36 @@ pub struct Affine3 {
pub translation: Vec3,
}

impl Affine3 {
/// Calculates the transpose of the affine 4x3 matrix to a 3x4 and formats it for packing into GPU buffers
#[inline]
pub fn to_transpose(&self) -> [Vec4; 3] {
let transpose_3x3 = self.matrix3.transpose();
[
transpose_3x3.x_axis.extend(self.translation.x),
transpose_3x3.y_axis.extend(self.translation.y),
transpose_3x3.z_axis.extend(self.translation.z),
]
}

/// Calculates the inverse transpose of the 3x3 matrix and formats it for packing into GPU buffers
#[inline]
pub fn inverse_transpose_3x3(&self) -> ([Vec4; 2], f32) {
let inverse_transpose_3x3 = Affine3A::from(self).inverse().matrix3.transpose();
(
[
(inverse_transpose_3x3.x_axis, inverse_transpose_3x3.y_axis.x).into(),
(
inverse_transpose_3x3.y_axis.yz(),
inverse_transpose_3x3.z_axis.xy(),
)
.into(),
],
inverse_transpose_3x3.z_axis.z,
)
}
}

impl From<&Affine3A> for Affine3 {
fn from(affine: &Affine3A) -> Self {
Self {
Expand Down
Loading

0 comments on commit 5c884c5

Please sign in to comment.