From 57767b7f27d8b8a5604c720d4e09a6c8b0ccb116 Mon Sep 17 00:00:00 2001 From: Mathias Agopian Date: Wed, 21 Jan 2026 21:03:49 -0800 Subject: [PATCH] Fix buffer overflow in RenderPass command batching (#9619) The command batching logic in RenderPass::Executor::execute calculates a conservative `maxCommandSizeInBytes` to determine how many commands can safely fit in the driver's circular buffer. However, this calculation failed to account for the dynamic memory allocation incurred by the Per-Material descriptor set binding (via `MaterialInstance::use`). This binding allocates a `DescriptorSetOffsetArray` in the command stream, which was unbudgeted. In scenarios with frequent material and scissor changes, the accumulated deficit from these unbudgeted allocations could exceed the buffer capacity, leading to a crash. This change: 1. Adds the missing accounting for the Per-Material dynamic allocation to `maxCommandSizeInBytes`. 2. Adds a 20% safety margin to the calculated size to robustly handle alignment padding and worst-case scenarios. FIXES=[474264976] --- filament/src/RenderPass.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/filament/src/RenderPass.cpp b/filament/src/RenderPass.cpp index 7b85ebe2dc..0735ee43e2 100644 --- a/filament/src/RenderPass.cpp +++ b/filament/src/RenderPass.cpp @@ -940,31 +940,32 @@ void RenderPass::Executor::execute(FEngine const& engine, DriverApi& driver, // Maximum space occupied in the CircularBuffer by a single `Command`. This must be // reevaluated when the inner loop below adds DriverApi commands or when we change the - // CommandStream protocol. Currently, the maximum is 248 bytes. + // CommandStream protocol. Currently, the maximum is 272 bytes. // The batch size is calculated by adding the size of all commands that can possibly be // emitted per draw call: constexpr size_t maxCommandSizeInBytes = sizeof(COMMAND_TYPE(scissor)) + sizeof(COMMAND_TYPE(bindDescriptorSet)) + - sizeof(COMMAND_TYPE(bindDescriptorSet)) + + sizeof(COMMAND_TYPE(bindDescriptorSet)) + backend::CustomCommand::align(sizeof(NoopCommand) + 4) + sizeof(COMMAND_TYPE(bindPipeline)) + sizeof(COMMAND_TYPE(bindRenderPrimitive)) + sizeof(COMMAND_TYPE(bindDescriptorSet)) + backend::CustomCommand::align(sizeof(NoopCommand) + 8) + sizeof(COMMAND_TYPE(setPushConstant)) + sizeof(COMMAND_TYPE(draw2)); + // Add 20% margin for safety + constexpr size_t safeCommandSizeInBytes = maxCommandSizeInBytes + (maxCommandSizeInBytes / 5); // Number of Commands that can be issued and guaranteed to fit in the current - // CircularBuffer allocation. In practice, we'll have tons of headroom especially if - // skinning and morphing aren't used. With a 2 MiB buffer (the default) a batch is - // 6553 commands (i.e. draw calls). - size_t const batchCommandCount = capacity / maxCommandSizeInBytes; + // CircularBuffer allocation. With a 2 MiB buffer (the default) a batch is + // 6432 commands (i.e. draw calls). + size_t const batchCommandCount = capacity / safeCommandSizeInBytes; while(first != last) { Command const* const batchLast = std::min(first + batchCommandCount, last); // actual number of commands we need to write (can be smaller than batchCommandCount) size_t const commandCount = batchLast - first; - size_t const commandSizeInBytes = commandCount * maxCommandSizeInBytes; + size_t const commandSizeInBytes = commandCount * safeCommandSizeInBytes; // check we have enough capacity to write these commandCount commands, if not, // request a new CircularBuffer allocation of `capacity` bytes.