Привет! Я Максим Савченко, iOS‑разработчик в «Исходном Коде». В этой статье хочу показать небольшой Metal‑эксперимент, который у меня довольно быстро перестал быть просто красивой анимацией: аттрактор Лоренца на сотнях тысяч частиц, где и расчет движения, и отрисовка живут на GPU.
Мне этот пример понравился тем, что в нем почти сразу приходится столкнуться с тем, ради чего вообще имеет смысл лезть в Metal: compute‑шейдерами, буферами, порядком выполнения команд, численной устойчивостью и уже потом — с профилированием реального кадра.
Пойду от простой версии к рабочему проекту: сначала коротко разберу математику, затем — compute/render‑пайплайн и ping‑pong‑буферы, а в конце покажу GPU Capture и реальные замеры на iPhone.
Если коротко
Вся схема держится на одной идее: состояние частиц остается на GPU. Compute shader читает Buffer A, пишет новые позиции в Buffer B, после чего render‑pass сразу рисует Buffer B. На следующем кадре буферы меняются ролями. CPU в этот массив не лезет. |
С чего все началось
Вообще все началось не с Metal и даже не с аттрактора. Я занимался HLSL с ментором и на одном из занятий спросил, как можно сделать что‑то похожее на анимированную иконку Siri — не копию один в один, а такую же живую форму, которая все время течет и меняется. Он ответил: посмотри в сторону аттракторов.
До этого слово «аттрактор» у меня ассоциировалось скорее с красивыми математическими картинками. Когда полез разбираться, оказалось, что для практики с GPU это очень удобная задача: формула небольшая, частицы почти не зависят друг от друга, а визуальный результат сразу показывает, сломал ты что‑то или нет.
В итоге вопрос про один визуальный эффект превратился в отдельный эксперимент. Мне стало интересно уже не только «как это нарисовать», а сколько частиц потянет iPhone, как не гонять их состояние через CPU, нужен ли здесь RK4 и где на самом деле упрется кадр. Поэтому дальше будет не только картинка, но и устройство проекта, Frame Capture и замеры.
Почему аттрактор хорошо ложится на Metal
Когда я начал прикидывать реализацию, стало понятно, что одной отрисовкой тут лучше не ограничиваться. Metal умеет не только рисовать: через compute pipeline можно запускать один и тот же расчет сразу для большого массива данных. Для частиц это ровно то, что нужно.
У этой задачи есть несколько удобных свойств:
· каждая частица содержит небольшое состояние;
· новое положение частицы зависит только от ее текущего положения;
· один и тот же алгоритм выполняется для всех элементов;
· между частицами не требуется синхронизация;
· обновленный буфер можно сразу передать render‑пайплайну.
Для эксперимента я выбрал систему Лоренца — простую математическую модель, которая показывает, как из нескольких уравнений может получаться очень сложное и почти непредсказуемое движение. Именно она и формирует знакомую форму аттрактора, похожую на крылья бабочки.

Математика: здесь все довольно компактно
У частицы есть всего три координаты — x, y и z. Система Лоренца задает не следующую позицию напрямую, а скорость изменения этих координат:
Система Лоренца
dx/dt = σ(y − x) |
Классические параметры системы:
σ = 10 |
То есть из формулы нельзя просто получить «позицию на следующем кадре». Нужно взять производную и численно сделать маленький шаг dt. Тут и появляется выбор интегратора.
Самый дешевый вариант — обычный Euler:
nextPosition = position + derivative(position) × dt |
Он вызывает производную один раз и поэтому дешевый, но большой dt ему быстро портит жизнь: ошибка накапливается, траектория расползается, а в какой‑то момент можно получить NaN.
В основном режиме я оставил RK4:
k1 = f(p) |
Он в четыре раза чаще считает производную, зато заметно спокойнее переживает накопление ошибки и дает стабильную форму аттрактора.
Метод | Вычислений производной | Преимущество | Недостаток |
Euler | 1 | Минимальная стоимость | Быстро накапливает ошибку |
RK2 | 2 | Компромиссный вариант | Точность ниже RK4 |
RK4 | 4 | Стабильная визуализация | Более тяжелый compute‑шейдер |
На бумаге именно RK4 выглядит первым кандидатом на bottleneck. Ниже будет видно, что в моем тесте проблема оказалась вообще не там.
Как устроен один кадр
Сам кадр получился довольно прямолинейным. Сначала compute‑pass обновляет состояние частиц:
Particle Buffer A |
После этого тот же результат сразу идет в render‑pass:
Particle Buffer B |
На следующем кадре A и B просто меняются ролями:
Particle Buffer B |
Это обычный ping‑pong buffering. Я читаю старое состояние из одного буфера и пишу новое в другой, а на следующем кадре переворачиваю их. Так не приходится разбираться с чтением и записью одного и того же элемента в рамках одного обновления.

Оба прохода кодируются в один MTLCommandBuffer. Между compute и render я ничего не жду на CPU: порядок команд уже задан самим command buffer.
Состояние частицы: ничего лишнего
Для начала мне достаточно одной позиции:
Swift
import simd |
В Metal Shading Language она будет выглядеть аналогично:
Metal
struct Particle { |
Координат три, но в буфере я храню SIMD4<Float>. Это 16 байт на частицу и, что важнее, без сюрпризов совпадает по layout между Swift и Metal.
Цвет, яркость и размер отдельно в particle buffer не лежат — все это дешевле получить уже при рендеринге.
1 000 000 × 16 байт ≈ 15,3 МиБ |
На миллионе частиц эта мелочь быстро перестает быть мелочью: еще один SIMD4<Float> под цвет удвоил бы объем пары буферов примерно с 30,5 до 61 МиБ. Поэтому я оставил в состоянии только то, что действительно меняется в симуляции.
Как заселить аттрактор частицами
Если всем частицам дать одну и ту же стартовую координату, никакого облака не получится: они будут повторять одну траекторию. Поэтому стартовые позиции нужно чуть разнести.
Создадим небольшое облако рядом с координатой (0.1, 0, 0):
Swift
Скрытый текст
private func makeParticles(count: Int) -> [Particle] {
var particles = Array(
repeating: Particle(position: .zero),
count: count
)
for index in particles.indices {
let progress = Float(index) / Float(count)
let angle = progress Float.pi 2.0 233.0 let radius = 0.002 + 0.15 sqrtf(progress)
let x = 0.1 + cosf(angle) radius let y = sinf(angle) radius
let z = Float(index % 97) / 97.0 * 0.02 - 0.01
particles[index].position = SIMD4<Float>(x, y, z, 1)
}
return particles
}
Число 233 здесь не является параметром системы Лоренца. Оно просто несколько раз закручивает исходное облако и помогает равномернее распределить частицы.
Буферы и загрузка данных
После старта CPU координаты частиц мне больше не нужны. Начальное состояние я один раз загружаю через staging buffer, а дальше оба particle buffer живут на стороне GPU.
Swift
Скрытый текст
private func makeParticleBuffers(
particles: [Particle],
device: MTLDevice,
commandQueue: MTLCommandQueue
) -> [MTLBuffer] {
let byteCount = particles.count * MemoryLayout<Particle>.stride
guard let stagingBuffer = device.makeBuffer(
bytes: particles,
length: byteCount,
options: .storageModeShared
) else {
fatalError("Failed to create staging buffer")
}
let particleBuffers = (0..<2).map { _ -> MTLBuffer in
guard let buffer = device.makeBuffer(
length: byteCount,
options: .storageModePrivate
) else {
fatalError("Failed to create private particle buffer")
}
return buffer
}
guard
let commandBuffer = commandQueue.makeCommandBuffer(),
let blitEncoder = commandBuffer.makeBlitCommandEncoder()
else {
fatalError("Failed to create blit encoder")
}
for buffer in particleBuffers {
blitEncoder.copy(
from: stagingBuffer,
sourceOffset: 0,
to: buffer,
destinationOffset: 0,
size: byteCount
)
}
blitEncoder.endEncoding()
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
return particleBuffers
}
На этой одноразовой загрузке waitUntilCompleted() меня не пугает. В draw(in:) такой вызов уже был бы ошибкой: CPU начал бы ждать GPU на каждом кадре и вся схема потеряла бы смысл.
Параметры, которые меняются от кадра к кадру
Параметры симуляции и камеры передаю отдельными структурами с одинаковым layout в Swift и Metal.
Swift
Скрытый текст
struct SimulationUniforms {
// x: dt, y: sigma, z: rho, w: beta
var coefficients: SIMD4<Float>
// x: particleCount
var counts: SIMD4<UInt32>
}
struct RenderUniforms {
// x: rotation, y: scale, z: aspectRatio, w: pointSize
var view: SIMD4<Float>
}
Metal
Скрытый текст
struct SimulationUniforms {
float4 coefficients;
uint4 counts;
};
struct RenderUniforms {
float4 view;
};
Здесь SIMD‑векторы еще и убирают лишние вопросы с padding: меньше шансов случайно собрать разные структуры на CPU и GPU.
Compute shader: одна нить — одна частица
В compute‑шейдере схема максимально простая: глобальный id потока совпадает с индексом частицы.
Metal
Скрытый текст
#include <metal_stdlib>
using namespace metal;
struct Particle {
float4 position;
};
struct SimulationUniforms {
float4 coefficients;
uint4 counts;
};
float3 lorenzDerivative(
float3 position,
constant SimulationUniforms& uniforms
) {
const float sigma = uniforms.coefficients.y;
const float rho = uniforms.coefficients.z;
const float beta = uniforms.coefficients.w; return float3(
sigma (position.y - position.x),
position.x (rho - position.z) - position.y,
position.x position.y - beta position.z
);
}
kernel void updateParticles(
device const Particle* source [[buffer(0)]],
device Particle* destination [[buffer(1)]],
constant SimulationUniforms& uniforms [[buffer(2)]],
uint id [[thread_position_in_grid]]
) {
const uint particleCount = uniforms.counts.x;
if (id >= particleCount) {
return;
}
const float dt = uniforms.coefficients.x;
const float3 position = source[id].position.xyz;
const float3 k1 = lorenzDerivative(position, uniforms);
const float3 k2 = lorenzDerivative(
position + k1 dt 0.5f, uniforms
);
const float3 k3 = lorenzDerivative(
position + k2 dt 0.5f, uniforms
);
const float3 k4 = lorenzDerivative(
position + k3 dt, uniforms
);
float3 nextPosition = position
+ (k1 + 2.0f k2 + 2.0f k3 + k4)
(dt / 6.0f);
if (
!all(isfinite(nextPosition))
|| dot(nextPosition, nextPosition) > 100000.0f
) {
const float offset =
(float(id % 1024u) / 1024.0f - 0.5f) * 0.02f;
nextPosition = float3(
0.1f + offset,
offset,
0.0f
);
}
destination[id].position = float4(nextPosition, 1.0f);
}
thread_position_in_grid содержит глобальный индекс текущего GPU‑потока. Проверка id >= particleCount обязательна, потому что размер сетки обычно округляется до полного числа threadgroup.
Собираем compute pipeline
Swift
Скрытый текст
enum RendererError: Error {
case metalUnavailable
case functionNotFound(String)
}
let device: MTLDevice
let commandQueue: MTLCommandQueue
let computePipeline: MTLComputePipelineState
guard let metalDevice = MTLCreateSystemDefaultDevice() else {
throw RendererError.metalUnavailable
}
device = metalDevice
guard let queue = device.makeCommandQueue() else {
throw RendererError.metalUnavailable
}
commandQueue = queue
guard
let library = device.makeDefaultLibrary(),
let function = library.makeFunction(name: "updateParticles")
else {
throw RendererError.functionNotFound("updateParticles")
}
computePipeline = try device.makeComputePipelineState(
function: function
)
MTLComputePipelineState создаю один раз при инициализации рендерера. На кадре здесь уже нечего собирать — только выбрать ресурсы и сделать dispatch.
С размером threadgroup лучше не гадать
Я не стал безусловно зашивать 256 потоков в threadgroup. У pipeline уже есть threadExecutionWidth и maxTotalThreadsPerThreadgroup, так что размер можно подобрать из реальных ограничений конкретного GPU.
Swift
Скрытый текст
private func makeDispatchSize(
particleCount: Int,
pipeline: MTLComputePipelineState
) -> (groups: MTLSize, threadsPerGroup: MTLSize) {
let executionWidth = pipeline.threadExecutionWidth
let maximumThreads = pipeline.maxTotalThreadsPerThreadgroup
let targetWidth = min(maximumThreads, 256)
let alignedWidth =
targetWidth - targetWidth % executionWidth
let groupWidth = max(executionWidth, alignedWidth)
let groupCount =
(particleCount + groupWidth - 1) / groupWidth
return (
groups: MTLSize(width: groupCount, height: 1, depth: 1),
threadsPerGroup: MTLSize(width: groupWidth, height: 1, depth: 1)
)
}
256 в этом коде — только верхняя цель. Реальный groupWidth выравнивается под execution width текущего pipeline.
Рендер: point primitives вместо миллиона quad
Отдельный quad на каждую частицу здесь ни к чему. Я рисую весь массив одним draw call как point primitives:
Swift
Скрытый текст
renderEncoder.drawPrimitives(
type: .point,
vertexStart: 0,
vertexCount: particleCount
)
Vertex shader ставит точку на экран и задает ее размер, а fragment shader вырезает из квадратного point primitive мягкий светящийся кружок.
Metal
Скрытый текст
struct RenderUniforms {
float4 view;
};
struct VertexOutput {
float4 position [[position]];
float pointSize [[point_size]];
half4 color;
};
vertex VertexOutput particleVertex(
uint id [[vertex_id]],
device const Particle* particles [[buffer(0)]],
constant RenderUniforms& uniforms [[buffer(1)]]
) {
float3 position = particles[id].position.xyz;
position.z -= 25.0f;
const float rotation = uniforms.view.x;
const float scale = uniforms.view.y;
const float aspectRatio = max(uniforms.view.z, 0.001f);
const float cosine = cos(rotation);
const float sine = sin(rotation);
float3 rotated = float3(
cosine position.x + sine position.z,
position.y,
-sine position.x + cosine position.z
);
rotated = scale; rotated.x /= aspectRatio;
const float colorPosition = saturate( particles[id].position.z / 50.0f );
const half3 coldColor = half3(0.05h, 0.35h, 1.0h); const half3 warmColor = half3(1.0h, 0.15h, 0.03h);
VertexOutput output; output.position = float4(rotated.xy, 0.0f, 1.0f); output.pointSize = uniforms.view.w; output.color = half4( mix(coldColor, warmColor, half(colorPosition)), 0.04h
);
return output; }
fragment half4 particleFragment( VertexOutput input [[stage_in]], float2 pointCoordinate [[point_coord]] ) { const float2 centered = pointCoordinate 2.0f - 1.0f;
const float radiusSquared = dot(centered, centered);
if (radiusSquared > 1.0f) {
discard_fragment();
}
const half glow = half(exp(-3.0f radiusSquared)); const half alpha = input.color.a glow;
return half4(input.color.rgb * alpha, alpha);
}
Именно здесь легко незаметно сделать кадр дорогим: pointSize растет по стороне, а fragment work — по площади. Точка 8×8 покрывает уже в 16 раз больше пикселей, чем 2×2.
Свечение через additive blending
Swift
Скрытый текст
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(
name: "particleVertex"
)
descriptor.fragmentFunction = library.makeFunction(
name: "particleFragment"
)
let attachment = descriptor.colorAttachments[0]
attachment?.pixelFormat = view.colorPixelFormat
attachment?.isBlendingEnabled = true
attachment?.rgbBlendOperation = .add
attachment?.alphaBlendOperation = .add
attachment?.sourceRGBBlendFactor = .one
attachment?.destinationRGBBlendFactor = .one
attachment?.sourceAlphaBlendFactor = .one
attachment?.destinationAlphaBlendFactor = .one
let renderPipeline = try device.makeRenderPipelineState(
descriptor: descriptor
)
Additive blending как раз дает нужное свечение: там, где частицы накладываются, изображение становится ярче. Цена понятная — overdraw. Один и тот же пиксель может пройти fragment shader много раз за кадр.
MTKView и частота кадров
Swift
Скрытый текст
view.device = device
view.colorPixelFormat = .bgra8Unorm
view.clearColor = MTLClearColor(
red: 0, green: 0, blue: 0, alpha: 1
)
view.preferredFramesPerSecond = 60
view.delegate = self
В проекте я зафиксировал 60 FPS. Это дает примерно 16,7 мс на кадр и, что удобно для тестов, не заставляет сравнивать результаты при плавающей частоте экрана.
Собираем все в draw(in:)
В draw(in:) остается довольно мало логики: выбрать source/destination, закодировать compute, затем render, сделать present и commit. После этого destination становится source следующего кадра.
Swift
Скрытый текст
final class AttractorRenderer: NSObject, MTKViewDelegate {
private let commandQueue: MTLCommandQueue
private let computePipeline: MTLComputePipelineState
private let renderPipeline: MTLRenderPipelineState
private let particleBuffers: [MTLBuffer]
private let particleCount: Int
private var currentBufferIndex = 0
private var lastTimestamp = CACurrentMediaTime()
private var rotation: Float = 0
func draw(in view: MTKView) {
guard
let renderPassDescriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let commandBuffer = commandQueue.makeCommandBuffer()
else {
return
}
let now = CACurrentMediaTime()
let frameDelta = min(
Float(now - lastTimestamp),
1.0 / 30.0
)
lastTimestamp = now
rotation += frameDelta * 0.15
let sourceIndex = currentBufferIndex
let destinationIndex = 1 - currentBufferIndex
let sourceBuffer = particleBuffers[sourceIndex]
let destinationBuffer = particleBuffers[destinationIndex]
encodeSimulation(
commandBuffer: commandBuffer,
source: sourceBuffer,
destination: destinationBuffer,
frameDelta: frameDelta
)
let sourceIndex = currentBufferIndex
let destinationIndex = 1 - currentBufferIndex
let sourceBuffer = particleBuffers[sourceIndex]
let destinationBuffer = particleBuffers[destinationIndex]
encodeSimulation(
commandBuffer: commandBuffer,
source: sourceBuffer,
destination: destinationBuffer,
frameDelta: frameDelta
)
encodeRendering(
commandBuffer: commandBuffer,
descriptor: renderPassDescriptor,
particleBuffer: destinationBuffer,
view: view
)
commandBuffer.present(drawable)
commandBuffer.commit()
currentBufferIndex = destinationIndex
}
}
Кодируем compute‑pass
Swift
Скрытый текст
private func encodeSimulation(
commandBuffer: MTLCommandBuffer,
source: MTLBuffer,
destination: MTLBuffer,
frameDelta: Float
) {
guard let encoder =
commandBuffer.makeComputeCommandEncoder()
else {
return
}
encoder.label = "Lorenz Simulation"
encoder.setComputePipelineState(computePipeline)
encoder.setBuffer(source, offset: 0, index: 0)
encoder.setBuffer(destination, offset: 0, index: 1)
let simulationSpeed: Float = 0.35
let integrationStep = frameDelta * simulationSpeed
var uniforms = SimulationUniforms(
coefficients: SIMD4<Float>(
integrationStep,
10.0,
28.0,
8.0 / 3.0
),
counts: SIMD4<UInt32>(
UInt32(particleCount), 0, 0, 0
)
)
encoder.setBytes(
&uniforms,
length: MemoryLayout<SimulationUniforms>.stride,
index: 2
)
let dispatch = makeDispatchSize(
particleCount: particleCount,
pipeline: computePipeline
)
encoder.dispatchThreadgroups(
dispatch.groups,
threadsPerThreadgroup: dispatch.threadsPerGroup
)
encoder.endEncoding()
}
frameDelta я сверху ограничиваю 1/30. Это особенно полезно при отладке: остановка на breakpoint не должна превращаться в гигантский шаг интегратора на следующем кадре.
Кодируем render‑pass
Swift
Скрытый текст
private func encodeRendering(
commandBuffer: MTLCommandBuffer,
descriptor: MTLRenderPassDescriptor,
particleBuffer: MTLBuffer,
view: MTKView
) {
guard let encoder = commandBuffer.makeRenderCommandEncoder(
descriptor: descriptor
) else {
return
}
encoder.label = "Particle Rendering"
encoder.setRenderPipelineState(renderPipeline)
encoder.setVertexBuffer(particleBuffer, offset: 0, index: 0)
let width = max(Float(view.drawableSize.width), 1)
let height = max(Float(view.drawableSize.height), 1)
var uniforms = RenderUniforms(
view: SIMD4<Float>(
rotation,
0.035,
width / height,
2.0
)
)
encoder.setVertexBytes(
&uniforms,
length: MemoryLayout<RenderUniforms>.stride,
index: 1
)
encoder.drawPrimitives(
type: .point,
vertexStart: 0,
vertexCount: particleCount
)
encoder.endEncoding()
}
Ключевой момент здесь в том, что destinationBuffer после compute сразу становится входом vertex shader. Никакого [Particle] между ними нет и CPU вообще не знает текущие координаты миллиона точек.
Где я бы искал проблемы в первую очередь
1. Стоимость интегрирования
RK4 действительно выглядит дорого: на миллионе частиц получается четыре миллиона вызовов lorenzDerivative за кадр. Если compute когда‑нибудь станет bottleneck, варианты очевидны — RK2/Euler, меньше частиц или более редкое обновление симуляции. Но сначала это надо увидеть в профайлере.
2. Размер структуры
Каждый кадр один particle buffer читается, второй записывается. Поэтому лишние поля здесь оплачиваются постоянно, а не только при создании буфера. Цвет и прочую визуальную информацию я сознательно не тащу через эту память.
3. Размер point‑примитива
С миллионом частиц render‑pass очень легко обгоняет compute по стоимости. При point size 2×2 одна точка еще дешевая, при 8×8 — уже до 64 фрагментов до учета перекрытий. А в аттракторе перекрытий много по определению.
4. Лишняя точность
Для самой системы Лоренца я оставил float: ошибка здесь не просто локальная, она продолжает влиять на следующие шаги. А вот цвет и альфу во fragment shader вполне можно считать в half.
5. Ожидание GPU на CPU
Еще одна вещь, которую я бы сразу проверил в реальном проекте, — нет ли в кадре такого вызова:
Антипаттерн
commandBuffer.waitUntilCompleted() |
Если ждать command buffer синхронно каждый кадр, вся параллельность CPU/GPU исчезает. Здесь после commit CPU просто идет дальше.
Почему мне хватило двух буферов
В этой конкретной схеме CPU particle buffer не трогает, а команды идут через одну очередь. Поэтому пары A/B достаточно: A → B на одном кадре, B → A на следующем.
Три экземпляра понадобились бы скорее для динамических данных, которые одновременно обновляет CPU. Здесь такой конкуренции за состояние частиц нет.
Проверяем гипотезы через GPU Frame Capture
На этом месте FPS уже мало что говорит. Если MTKView ограничен 60 кадрами, число «60» означает только то, что мы пока укладываемся в бюджет. Чтобы понять, куда реально уходит время, я сделал GPU Frame Capture.
Для повторения достаточно:
запустить приложение на физическом устройстве;
выполнить захват GPU‑кадра;
отдельно посмотреть длительность compute‑ и render‑проходов;
проверить статистику выполнения шейдеров;
оценить количество fragment invocation и overdraw;
сравнить несколько конфигураций частиц, размера точки и интегратора.

Вот здесь результат оказался самым интересным. В захваченном кадре Xcode показывает 6,08% execution cost у compute‑pass и 93,92% у render‑pass. То есть RK4, который выглядит самым тяжелым местом в коде, в этой конфигурации вообще не главный потребитель. Основная цена — миллион point primitives, additive blending и overdraw. Это оценка одного конкретного capture, но направление она показывает очень хорошо.
Чтобы не делать вывод по одному режиму, я заложил несколько пресетов:
Режим | Частиц | Point size | Интегратор |
Low | 100 000 | 3 px | RK4 |
Medium | 250 000 | 2 px | RK4 |
High | 500 000 | 2 px | RK4 |
Ultra | 1 000 000 | 1–2 px | RK4 |
Compute test | 1 000 000 | 1 px | Euler |
Сам FPS в этой таблице вторичен. Полезнее смотреть, как меняется GPU time, что происходит с thermal state и остается ли запас до бюджета кадра.
Что получилось на реальном iPhone
Benchmark я прогнал на физическом iPhone15,2 с iOS 26.5.2. Во всех режимах — RK4 и point size 2 px. Thermal state во время замеров был fair, поэтому это не «идеальный холодный старт», а вполне обычное рабочее состояние устройства.
Частиц | FPS | GPU, мс | CPU encode, мс | Thermal |
100 000 | 59,9 | 3,805 | 0,789 | fair |
250 000 | 60,2 | 9,396 | 0,669 | fair |
500 000 | 60,6 | 11,301 | 0,671 | fair |
1 000 000 | 60,9 | 12,364 | 0,647 | fair |
Во всех четырех строках FPS держится около 60 — и это ожидаемо, потому что preferredFramesPerSecond выставлен в 60. Гораздо интереснее GPU time: от 3,805 мс на 100 тысячах он растет до 12,364 мс на миллионе. Даже последний режим все еще помещается в 16,7 мс.
CPU encode почти не меняется: 0,647–0,789 мс. Для меня это хороший sanity check архитектуры — увеличение массива в десять раз почти целиком отражается на GPU, а не на CPU‑кодировании команд.

Вместе benchmark и Frame Capture складываются в понятную картину: миллион частиц еще держит целевые 60 FPS, но запас уже не бесконечный, и первым я бы оптимизировал рендер — point size, overdraw и fragment shader. Упрощать RK4 в этих замерах смысла заметно меньше.
Параметры, с которыми интересно поиграть
Чтобы не пересобирать проект ради каждого эксперимента, основные параметры удобно вынести в SwiftUI:
Swift
struct AttractorParameters { |
sigma, rho и beta сильно меняют саму форму движения. После большого изменения коэффициентов я бы сбрасывал частицы: старое облако успело сформироваться уже в другой системе.
Кроме коэффициентов можно добавить:
вращение жестом;
масштабирование;
переключение Euler / RK2 / RK4;
изменение количества частиц;
выбор цветовой схемы;
паузу симуляции;
адаптивное снижение качества.
Если делать из демо что‑то продуктово‑похожее
Если такую штуку доводить не как демо, а как реальный эффект, я бы не фиксировал одно качество на всех устройствах. Проще держать несколько пресетов и переключаться по фактическому GPU time.
GPU time < 10 ms: |
Сами большие буферы при этом пересоздавать на лету не нужно. Можно один раз выделить память под максимум и менять только число реально используемых элементов.
Что в итоге получилось
За один кадр приложение выполняет следующий пайплайн:
CPU: |
Главное для этого проекта — массив частиц после загрузки вообще не возвращается на CPU. Compute обновил буфер, render тут же его прочитал. Если бы между ними пришлось каждый кадр таскать миллион позиций через Swift, вся затея выглядела бы совсем иначе.
Что я вынес из этого эксперимента
В итоге аттрактор оказался для меня гораздо полезнее, чем просто эффект для красивого GIF. На одном небольшом проекте пришлось пройти почти весь базовый путь Metal:
создание compute pipeline;
массовую параллельную обработку данных;
ping‑pong‑буферы;
последовательность compute‑ и render‑проходов;
подбор threadgroup;
управление GPU‑ресурсами;
additive blending;
поиск compute‑, bandwidth‑ и fill‑rate bottleneck;
профилирование в Xcode.
Самое полезное наблюдение — код не всегда подсказывает, где дорого. На глаз RK4 выглядит подозрительнее всего, а мой capture показывает почти противоположное: 93,92% execution cost пришлись на render‑pass.
Так что если бы я продолжал этот проект, следующий шаг был бы не «переписать интегратор попроще», а сначала уменьшить стоимость рендера и снова снять capture. Мне как раз за это и понравился этот эксперимент: начинаешь с вопроса про красивую иконку, а заканчиваешь вполне конкретным разговором про данные, overdraw и устройство кадра.
Источники и полезные материалы
Спасибо за внимание!

