Вычисления на GPU: полный пример C# и GLSL
Рабочие группы, storage buffer, привязки, синхронизация и чтение результата.
Вычислительный шейдер выполняет на GPU задачи общего назначения. Этот пример удваивает float-значения в буфере хранения и читает настоящий результат в C#. Каждый запуск обрабатывает отдельный элемент, поэтому атомарные операции и согласование между рабочими группами не нужны.
Условия и ожидаемый результат
Используйте рендерер на базе RenderingDevice, например Forward+ или Mobile. Пример выполнен на Windows Forward+/Vulkan; другие драйверы и устройства требуют своей проверки. В Compatibility нет RenderingDevice для этого способа работы. Если локальное устройство равно null, нужный контекст недоступен — продолжать с невалидными RID нельзя.
Сохраните класс ниже как ComputeDouble.cs. В _Ready своего Entity создайте собственный экземпляр через using var compute = new ComputeDouble();, затем вызовите float[] output = compute.Run(new float[] { 1, 2, 3 });. Ожидаемый результат — [2, 4, 6]. Для нескольких вызовов сохраняйте тот же экземпляр и освободите его после работы. Создание, Run и освобождение должны выполняться в потоке-владельце устройства; класс синхронный и не допускает одновременных вызовов.
Полный пример C#
Ядро передаётся как исходный GLSL через RDShaderSource.SourceCompute. Оно начинается с #version 450 и не содержит маркер импорта редактора #[compute]. Этот маркер относится к отдельному способу импорта .glsl-ресурса; передавать его как директиву GLSL этому компилятору не нужно.
using System;
using Reckfell;
public sealed class ComputeDouble : IDisposable
{
public const string Kernel = """
#version 450
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
layout(set = 0, binding = 0, std430) restrict buffer Values {
float data[];
} values;
void main() {
uint index = gl_GlobalInvocationID.x;
if (index >= uint(values.data.length())) return;
values.data[index] *= 2.0;
}
""";
private readonly RenderingDevice device;
private Rid shader;
private Rid pipeline;
private bool disposed;
public ComputeDouble()
{
device = RenderingServer.CreateLocalRenderingDevice()
?? throw new NotSupportedException("A local RenderingDevice is unavailable.");
try
{
if (device.LimitGet(RenderingDevice.Limit.MaxComputeWorkgroupSizeX) < 64 ||
device.LimitGet(RenderingDevice.Limit.MaxComputeWorkgroupInvocations) < 64)
throw new NotSupportedException("The kernel requires 64 invocations per group.");
using var source = new RDShaderSource { SourceCompute = Kernel };
using var spirV = device.ShaderCompileSpirVFromSource(source);
if (!string.IsNullOrEmpty(spirV.CompileErrorCompute))
throw new InvalidOperationException(spirV.CompileErrorCompute);
shader = device.ShaderCreateFromSpirV(spirV);
if (!shader.IsValid) throw new InvalidOperationException("Shader creation failed.");
pipeline = device.ComputePipelineCreate(shader);
if (!pipeline.IsValid) throw new InvalidOperationException("Pipeline creation failed.");
}
catch { Dispose(); throw; }
}
public float[] Run(float[] input)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(input);
if (input.Length == 0) return Array.Empty<float>();
uint groups = (uint)((input.Length + 63L) / 64L);
if (groups > device.LimitGet(RenderingDevice.Limit.MaxComputeWorkgroupCountX))
throw new ArgumentOutOfRangeException(nameof(input), "Too many workgroups.");
int byteCount = checked(input.Length * sizeof(float));
byte[] bytes = new byte[byteCount];
Buffer.BlockCopy(input, 0, bytes, 0, byteCount);
Rid buffer = default, uniformSet = default;
try
{
buffer = device.StorageBufferCreate((uint)byteCount, bytes);
if (!buffer.IsValid) throw new InvalidOperationException("Buffer creation failed.");
using var uniform = new RDUniform
{
UniformType = RenderingDevice.UniformType.StorageBuffer, Binding = 0
};
uniform.AddId(buffer);
var uniforms = new Reckfell.Collections.Array<RDUniform> { uniform };
using var uniformsOwner = (Reckfell.Collections.Array)uniforms;
uniformSet = device.UniformSetCreate(uniforms, shader, 0);
if (!uniformSet.IsValid) throw new InvalidOperationException("Uniform set creation failed.");
long list = device.ComputeListBegin();
device.ComputeListBindComputePipeline(list, pipeline);
device.ComputeListBindUniformSet(list, uniformSet, 0);
device.ComputeListDispatch(list, groups, 1, 1);
device.ComputeListEnd();
device.Submit();
device.Sync();
byte[] resultBytes = device.BufferGetData(buffer);
if (resultBytes.Length != byteCount)
throw new InvalidOperationException("Unexpected readback size.");
float[] result = new float[input.Length];
Buffer.BlockCopy(resultBytes, 0, result, 0, byteCount);
return result;
}
finally
{
if (uniformSet.IsValid) device.FreeRid(uniformSet);
if (buffer.IsValid) device.FreeRid(buffer);
}
}
public void Dispose()
{
if (disposed) return;
disposed = true;
if (pipeline.IsValid) device.FreeRid(pipeline);
if (shader.IsValid) device.FreeRid(shader);
device.Free();
}
}Класс владеет локальным устройством, шейдером и pipeline. Каждый Run создаёт буфер и uniform set, дожидается результата и освобождает RID этого вызова. Типизированная коллекция uniforms использует собственный нетипизированный Array; отдельный using владельца освобождает эту временную коллекцию. Коллекции вызывающего кода здесь не освобождаются.
Согласуйте размещение данных CPU и шейдера
| Условие | Значение в примере |
|---|---|
| Формат элемента | GLSL float и C# float: 32 бита на элемент. Подставлять double[] с прежним числом байтов нельзя. |
| Размещение в буфере | std430 и массив float без заданной длины, 4 байта на элемент. Структуры, vec3 и матрицы требуют отдельного расчёта выравнивания и шага. |
| Набор дескрипторов | GLSL set=0, UniformSetCreate shaderSet=0, ComputeListBindUniformSet setIndex=0. |
| Привязка | GLSL binding=0 и RDUniform.Binding=0. Номер binding не является номером set. |
| Вид ресурса | StorageBuffer в блоке GLSL и RDUniform. |
| Размер локальной группы | 64 запуска по X, по 1 по Y и Z. |
| Dispatch | (elementCount + 63) / 64 рабочих групп, а не отдельных потоков. |
Для 65 элементов запускаются 128 вычислений. Проверка границ в шейдере отбрасывает индексы 65–127 до чтения и записи. Округление числа групп вниз пропустит последний элемент; округление вверх без проверки выйдет за буфер. В движке проверены длины 1, 2, 63, 64, 65 и 257.
Рабочие группы выполняются независимо; рассчитывать на порядок их запуска нельзя. Они могут обращаться к общему storage buffer, но конкурирующие записи требуют специально выбранной синхронизации или атомарных операций. Барьер рабочей группы не является глобальным барьером всех групп. Поэлементный пример избегает таких зависимостей.
Запишите, отправьте, дождитесь, прочитайте
ComputeListBegin открывает список команд. Перед Dispatch привяжите compute pipeline и необходимые uniform sets, затем вызовите ComputeListEnd. Завершение списка записывает команды, но ещё не означает готовность результата для CPU.
У локального устройства Submit отправляет работу, а Sync ожидает эту отправку. Не вызывайте Sync до Submit, не отправляйте локальное устройство повторно до синхронизации и не переносите этот порядок управления на глобальное устройство, заимствованное у движка.
BufferGetData блокирует выполнение и сбрасывает/ожидает работу для чтения данных. Ожидание фиксированного количества игровых кадров не заменяет подтверждение завершения GPU. Этот небольшой синхронный пример проверяет правильность; постоянные передачи GPU→CPU могут убрать весь выигрыш от вычислений. По возможности сохраняйте данные на GPU и объединяйте задачи. Перед заявлением об ускорении измеряйте полную операцию: загрузку, синхронизацию и чтение результата.
Обновите или прочитайте часть буфера
Смещения и размеры BufferUpdate задаются в байтах. Выполненная проверка обновляет два float со смещения 4, размером 8, затем повторно использует буфер, uniform set и pipeline для следующего dispatch. Обновляйте данные вне активного compute/draw list, проверяйте длину переданных байтов и не выходите за выделенный буфер.
Для частичного чтения задайте размер явно: device.BufferGetData(buffer, 4, 8) читает два float начиная со второго элемента. В этой версии sizeBytes=0 выбирает полный размер буфера; это не «все оставшиеся байты после любого смещения». Для полного чтения по умолчанию используйте смещение 0.
Если результат неправильный
| Симптом | Что проверить |
|---|---|
| Локальное устройство равно null | Фактический рендерер, контекст и доступность устройства. |
| Ошибка компиляции | CompileErrorCompute; различие исходного GLSL и синтаксиса импорта редактора. Не создавайте шейдер из байткода с ошибкой. |
| Uniform set не создаётся | Номера set и binding, вид ресурса и принадлежность RID одному устройству. |
| Последние элементы не изменились | Округление числа групп вверх и проверку границ шейдером. |
| Значения перемешаны | Размер элемента CPU, число байтов и выравнивание/шаг шейдера. |
| GPU оказался медленнее CPU | Учитывайте создание, передачи, синхронизацию и чтение в измерении. |
| Ошибки после освобождения | Не используйте освобождённые RID, не передавайте их между устройствами и не освобождайте глобальное устройство движка. |
Проверки подтверждают настоящий результат GPU, повторное использование, неизменность входного массива C#, пустой/null/освобождённый вызов и обновление/чтение диапазонов байтов. Они не доказывают производительность, асинхронное чтение, взаимодействие с глобальным рендерингом и поддержку всех GPU/экспортов. Ограничивайте объём работы: слишком длинное ядро может вызвать тайм-аут драйвера платформы; универсальной длительности такого тайм-аута нет.