Пишем GPU-шейдеры на TypeScript с помощью TypeGPU
Создавайте GPU-шейдеры на TypeScript с TypeGPU. Настройте типизированные буферы, запустите вычислительный шейдер и выявляйте ошибки привязки при компиляции.
TypeGPU — это библиотека для TypeScript, которая оборачивает WebGPU в типизированные примитивы. Схемы буферов, раскладки bind group и GPU-функции проверяет компилятор TypeScript, поэтому ошибки в них не всплывают во время выполнения на GPU.
Если вы проходили какой-нибудь туториал по WebGPU, то знаете, как это бывает. На стороне JavaScript проверка типов проходит без замечаний, а в WGSL-строке поле структуры смещено на один float. В результате вы получаете буфер с мусором и ни одного сообщения об ошибке. Это руководство рассчитано на тех, кто уже знаком с WebGPU. Если это не так, начните со статей о добавлении WebGPU-эффектов в UI и о том, чем WebGPU отличается от WebGL. Здесь вы настроите TypeGPU, создадите типизированные буферы, от начала до конца реализуете один вычислительный шейдер и увидите ошибку типов, которую «голый» WebGPU выдать не может.
Ключевые выводы
- Чтобы написать шейдер TypeGPU в виде функции TypeScript, пометьте её директивой
'use gpu'и подключите плагин сборкиunplugin-typegpu. В остальных случаях этот плагин для библиотеки необязателен. - Каждый буфер TypeGPU создаётся по схеме, например
d.arrayOf(d.u32, 16). Схема задаёт размер буфера в байтах, способ преобразования значений JavaScript в бинарный вид и типы значений, которые принимает.write(). - В массиве WGSL каждый элемент
vec3fзанимает 16 байт. Обычные массивы и объекты TypeGPU выравнивает сам, аFloat32Arrayкопирует побайтово, так что выравнивание придётся добавить вручную. - Если передать в
root.createBindGroupбуфер, схема или флаги использования которого не соответствуют раскладке, TypeScript отклонит его на этапе компиляции. - TypeGPU ещё не достигла версии 1.0 (текущая ветка — 0.12.x), и в минорных релизах устаревшие API удалялись.
Почему большинство ошибок WebGPU возникает в WGSL и на границе между JS и GPU
Большинство ошибок в WebGPU вызваны не логикой шейдеров, а тем, что происходит на границе между JS и GPU. Шейдеры WebGPU пишутся на WGSL — отдельном языке, который инструменты TypeScript воспринимают как непрозрачную строку. Байтовую раскладку буферов, числовые индексы привязок и соответствие каждой bind group объявлениям в шейдере приходится синхронизировать вручную. Стоит ошибиться в чём-то одном, и вместо ошибки компиляции вы, как правило, молча получите неверные данные.
TypeGPU переносит обе стороны в TypeScript. Одна схема описывает данные и на CPU, и на GPU, а функции шейдеров тоже пишутся на TypeScript.
Настройка TypeGPU с Vite
Установите рантайм, а также плагин сборки и определения типов WebGPU как dev-зависимости:
npm install typegpu
npm install --save-dev unplugin-typegpu @webgpu/types
Зарегистрируйте типы WebGPU в tsconfig.json. Если TypeScript не знает о типах WebGPU, таких как GPUDevice, раздел о решении проблем в руководстве Getting Started отсылает именно к этой настройке.
{ "compilerOptions": { "types": ["@webgpu/types"] } }
unplugin-typegpu не обязателен для работы с TypeGPU в целом, но он нужен, чтобы писать шейдеры в виде функций TypeScript с директивой 'use gpu'. Плагин построен на unplugin, поэтому помимо Vite у него есть версии для esbuild, Farm, Rolldown, Rollup, Rspack и webpack, а также отдельный плагин Babel для React Native.
// vite.config.ts
import { defineConfig } from 'vite';
import typegpu from 'unplugin-typegpu/vite';
export default defineConfig({
plugins: [typegpu()],
});
Во время выполнения вызов await tgpu.init() возвращает объект root. Все буферы, bind group и конвейеры в примерах ниже создаются через него.
Типизированные буферы и схемы
Буфер TypeGPU всегда создаётся по схеме, построенной с помощью конструкторов d.*. Как поясняется в справочнике по буферам TypeGPU, по схеме TypeGPU определяет нужный размер буфера, способ преобразования значений JavaScript в байты и обратно, а также типы, которые принимают и возвращают его методы .write() и .read().
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const Point = d.struct({
position: d.vec3f,
weight: d.f32,
});
const points = root
.createBuffer(d.arrayOf(Point, 2), [
{ position: d.vec3f(0, 0, 0), weight: 1 },
{ position: d.vec3f(1, 2, 3), weight: 0.5 },
])
.$usage('storage');
points.write([
{ position: d.vec3f(4, 5, 6), weight: 2 },
{ position: d.vec3f(7, 8, 9), weight: 3 },
]);
console.log(await points.read());
.$usage('storage') устанавливает флаг использования WebGPU и одновременно фиксирует его в типе буфера. Если буфер нельзя отобразить в память, .read() копирует его содержимое через временный промежуточный (staging) буфер, поэтому писать mapAsync самостоятельно не нужно.
По правилам выравнивания WGSL элемент массива vec3f занимает 16 байт: 12 байт данных и 4 байта выравнивания. Если передать в .write() обычные массивы или объекты, TypeGPU добавит выравнивание сам. Если же передать Float32Array, .write() скопирует байты как есть, поэтому выравнивание уже должно быть в массиве:
const dirs = root.createBuffer(d.arrayOf(d.vec3f, 2)).$usage('storage');
dirs.write([[1, 0, 0], [0, 1, 0]]); // padded for you
dirs.write(new Float32Array([1, 0, 0, 0, 0, 1, 0, 0])); // every 4th float is padding
Вычислительный шейдер от начала до конца
Ниже приведена полная программа на TypeGPU, которая удваивает на GPU массив из восьми целых чисел и считывает результат. Функция с директивой 'use gpu', переданная в root.createGuardedComputePipeline, получает индекс потока. Вызов dispatchThreads(n) запускает n логических потоков, и индекс принимает каждое значение от 0 до n − 1 ровно один раз.
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const N = 8;
const values = root
.createBuffer(d.arrayOf(d.u32, N), [1, 2, 3, 4, 5, 6, 7, 8])
.$usage('storage');
const layout = tgpu.bindGroupLayout({
values: { storage: d.arrayOf(d.u32, N), access: 'mutable' },
});
const bindGroup = root.createBindGroup(layout, { values });
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
double.with(bindGroup).dispatchThreads(N);
console.log(await values.read()); // [2, 4, 6, 8, 10, 12, 14, 16]
Раскладка (layout) объявляет, что ожидает шейдер, а bind group предоставляет конкретный буфер. Вам не приходится ни вычислять количество рабочих групп, ни создавать промежуточный буфер. Если подменять буферы не требуется, можно воспользоваться более лаконичной альтернативой с фиксированным ресурсом — root.createMutable(schema): шейдер обращается к нему через .$, а результаты считываются с помощью .read().
Раскладки bind group и типобезопасность на этапе компиляции
Именно проверкой bind group на этапе компиляции TypeGPU сильнее всего отличается от «голого» WebGPU. Раскладка bind group описывает ресурсы, которые ожидает шейдер, а сама bind group предоставляет конкретные буферы. В TypeGPU буфер с неподходящей схемой или флагами использования не пройдёт проверку типов уже при создании bind group. Это поведение описано в руководстве по bind group.
По умолчанию записи типа storage доступны только для чтения, поэтому в раскладке выше указано access: 'mutable'. Поскольку шейдер обращается к layout.$, а не к конкретному буферу, один и тот же конвейер можно запускать с разными данными:
const more = root
.createBuffer(d.arrayOf(d.u32, N), [10, 20, 30, 40, 50, 60, 70, 80])
.$usage('storage');
const bindGroupB = root.createBindGroup(layout, { values: more });
double.with(bindGroupB).dispatchThreads(N); // same shader, new data
const floats = root.createBuffer(d.arrayOf(d.f32, N)).$usage('storage');
root.createBindGroup(layout, {
values: floats, // type error: d.f32 elements don't match the d.u32 layout entry
});
Bind group неизменяемы. Создавайте по одной на каждую комбинацию ресурсов, а если набор комбинаций небольшой и фиксированный, кешируйте их. В «голом» WebGPU компилятор понятия не имеет, что объявлено в WGSL, поэтому привязка буфера с float там, где шейдер ожидает u32, компилируется без ошибок и незаметно приводит к неверным числам.
Один и тот же шейдер на WGSL и на TypeGPU
Шейдер на TypeGPU и его написанный вручную аналог на WGSL примерно одинаковы по объёму. Разница — в коде хоста вокруг них. Написанная вручную WGSL-версия шейдера удвоения выглядит так:
@group(0) @binding(0) var<storage, read_write> values: array<u32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
if (id.x >= arrayLength(&values)) {
return;
}
values[id.x] = values[id.x] * 2u;
}
А вот версия на TypeGPU из предыдущего примера:
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
Чтобы запустить WGSL-версию через «голый» WebGPU, весь этот код хоста придётся написать вручную:
device.createShaderModuleсо строкой исходного кода WGSL;device.createComputePipeline;- записи bind group с числовыми индексами
binding, которые должны совпадать с@binding(0); - вычисление количества рабочих групп для
dispatchWorkgroups; - промежуточный буфер с
MAP_READ, команду копирования иmapAsyncдля чтения результатов.
В TypeGPU объект раскладки заменяет числовые индексы, а .read() берёт на себя работу с промежуточным буфером.
Ограничения, обходные пути и поддержка браузерами
TypeGPU ещё не достигла версии 1.0. Ветка релизов 0.12 вышла 12 августа 2026 года, а в минорных версиях устаревшие API удалялись, поэтому перед обновлением изучите руководство по миграции на 0.12 и последующие руководства. Библиотека распространяется по лицензии MIT, её разрабатывает Software Mansion.
При необходимости переходите на «голый» WebGPU. Существующий GPUBuffer можно обернуть с помощью root.createBuffer(schema, existingBuffer) и получить типизированные чтение и запись. В этом случае управление жизненным циклом буфера остаётся за вами, и вы должны следить, чтобы его содержимое соответствовало схеме. Кроме того, для вызова .write() буферу нужен флаг COPY_DST:
const raw = root.device.createBuffer({
size: 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const counter = root.createBuffer(d.u32, raw);
counter.write(42);
Проверяйте индексы. WebGPU не позволяет обращениям за пределы массива затронуть память вне привязки, но корректными такие обращения от этого не становятся. Проверяйте индекс всякий раз, когда размер диспетчеризации может превышать размер массива.
Избегайте чтения данных других потоков. Порядок выполнения потоков на GPU не гарантирован. Если один поток читает элемент, который в той же диспетчеризации записывает другой поток, результат недетерминирован:
// Nondeterministic: thread x reads index x + 1 while thread x + 1 may be writing it
const smear = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x + 1 >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x + 1];
});
Кроме того, WebGPU ограничивает количество рабочих групп, которые можно запустить по одной оси. Для больших сеток распределяйте работу по осям x и y.
Поддержка браузерами. TypeGPU работает везде, где работает WebGPU. Проверьте текущую поддержку WebGPU на caniuse для вашей целевой аудитории.
Понимайте, когда библиотека не подходит. Документация TypeGPU сама рекомендует использовать фреймворк для рендеринга, если нужно разместить на странице интерактивную 3D-модель. Для этой задачи лучше подойдёт создание 3D-анимаций с помощью Three.js.
Заключение
TypeGPU решает проблемы той части WebGPU, где ошибки отследить труднее всего, — границы между данными JavaScript и памятью шейдера. Для этого обе стороны описываются одной схемой TypeScript, а каждая привязка проходит проверку типов. Для начала добавьте unplugin-typegpu в проект на Vite и запустите пример с удвоением. Затем попробуйте передать в createBindGroup неподходящий буфер и убедитесь, что компилятор это обнаружит.
Часто задаваемые вопросы
Можно ли использовать существующие WGSL-шейдеры с TypeGPU?
Да. CLI-утилита tgpu-gen читает существующие файлы WGSL-шейдеров и генерирует файлы TypeScript с соответствующими определениями TypeGPU. Она поддерживает glob-шаблоны и режим наблюдения (watch), в котором определения перегенерируются при изменении шейдеров. Кроме того, оболочки функций TypeGPU принимают реализацию либо в виде WGSL-строки, либо в виде функции JavaScript, поэтому кодовую базу можно переносить постепенно, по одной функции.
Может ли функция с 'use gpu' выполняться и на CPU?
Да. Функция, помеченная 'use gpu', остаётся обычной функцией TypeScript, поэтому её можно вызвать напрямую в JavaScript и получить результат. Её также можно передать в tgpu.resolve, чтобы сгенерировать WGSL, или запустить на GPU через конвейер. Это позволяет покрывать вспомогательную логику шейдеров модульными тестами в привычном тест-раннере JavaScript ещё до запуска на GPU.
Как получить сгенерированный WGSL из TypeGPU?
Вызовите tgpu.resolve и передайте нужные ресурсы TypeGPU, например функции с 'use gpu' и схемы. Метод вернёт готовую к использованию WGSL-строку со всеми транзитивными зависимостями. Это удобно, когда другой движок на WebGPU или система материалов ожидает «сырой» WGSL. При использовании конвейеров TypeGPU вызывать resolve самостоятельно не нужно: они разрешают зависимости и компилируют шейдер внутри.
Работает ли TypeGPU в React Native?
Да. TypeGPU работает в React Native через пакет react-native-webgpu. Чтобы писать шейдеры с 'use gpu', добавьте unplugin-typegpu/babel в список plugins в babel.config.js и при необходимости очистите кеш Metro. React Native WebGPU не работает в Expo Go, поэтому для проекта на Expo потребуется expo prebuild. Кроме того, в симуляторе iOS нужно отключить Metal API Validation в Xcode.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k