WebGPU, библиотека Orillusion и кастомные шейдеры: как я создавал 4D Тессеракт
- среда, 15 апреля 2026 г. в 00:00:05

WebGPU — это новый стандарт для доступа к возможностям видеокарт, который я уже несколько лет хочу использовать в своем проекте. Два года, даже с включенными флагами, у меня не получалось с моей встроенной видеокартой это сделать. В отличие от WebGL, WebGPU создавался с нуля под архитектуры современных GPU, предоставляя разработчикам низкоуровневый контроль над вычислениями, поддержку compute-шейдеров и высокую производительность в браузерах.
Но сегодня эта технология выходит из экспериментального тестирования. На момент написания статьи webGPU уже доступна в Chrome, Edge и Firefox (под флагом). Все тестовые примеры начали запускаться у меня на компьютере и я решил глубже разобраться с этой технологией.
Прежде чем начать писать что то я посмотрел в интернете, какие библиотеки можно уже сейчас использовать для написания приложений. Ниже я сделал сравнительную таблицу с известными библиотеками, которые сейчас часто используются.
Характеристика | Orillusion | Three.js | Babylon.js |
|---|---|---|---|
Базовая технология | WebGPU (нативная) | WebGL/WebGPU | WebGL/WebGPU |
Архитектура | ECS | ООП | ООП |
Поддержка WebGPU | ✅ Полная | ⚠️ Экспериментальная | ✅ Стабильная |
Compute Shaders | ✅ Нативная | ⚠️ Ограниченная | ✅ Хорошая |
Производительность | Очень высокая | Высокая | Высокая |
Порог входа | Высокий | Низкий | Средний |
Документация | Развивающаяся | Отличная | Хорошая |
Сообщество | Маленькое | Огромное | Большое |
Несмотря на плюсы, от использования Three.js и Babylon.js я все таки остановился на Orillusion, потому что это современный движок, полностью построенный на WebGPU. Он создавался с нуля с учётом новых возможностей API.
Изучив документацию Orillusion, я заметил, что мне не хватает примеров создания кастомных шейдеров. Я нашел описание стандартных материалов, руководства по использованию готовых компонентов, но информации о том, как написать свой вершинный/фрагментный шейдер с нуля и подключить его к материалу, в документации сейчас нет. То, что нашел в разделе про Unlit Material, даёт общее представление о том как подключать, но нет полного примера. Непонятно, с каким синтаксисом создавать шейдеры, как правильно связывать атрибуты геометрии и как интегрировать compute-шейдеры.
Я решил это исправить. Моя цель была — научиться создавать объекты с кастомными шейдерами с нуля и разобраться в процессе изнутри. План для себя составил следующий:
Понять, как регистрировать кастомные шейдеры и связывать их с геометрией
Научиться работать с атрибутами и uniform-буферами
Интегрировать compute-шейдеры для GPU-вычислений
Разобраться с механикой инстансинга — как отрисовать множество объектов с разными параметрами (позиции, скорости вращения) за один draw call
Моей целью стало пройти весь путь создания сложной сцены с нуля, чтобы в будущем, на основе этого примера использовать эту последовательность в своем проекте.
Маленькое сообщество — найти готовые решения сложнее, чем для Three.js.
Развивающаяся документация — некоторые возможности ещё не полностью документированы. Примеров кастомных шейдеров практически нет.
Высокий порог входа — требует понимания WebGPU и современных графических концепций.
Но несмотря на это, нативная поддержка Compute Shaders, архитектура ECS и отсутствие легаси-кода WebGL перевесили минусы.
Я создал интерактивную 3D-сцену с пятью тессерактами (4D-гиперкубами), которые вращаются в четырёхмерном пространстве с использованием:
WebGPU (через библиотеку Orillusion)
Кастомных вершинных/фрагментных шейдеров на WGSL
Compute-шейдеров для GPU-вычислений
Для начала я создал обычный куб с кастомным шейдером. И столкнулся с несколькими ошибками.
Шейдер ожидал атрибуты, которые геометрия не предоставляла. В Orillusion атрибуты нужно явно регистрировать через setAttribute().
Color target has no corresponding fragment stage output
Оказалось, что в Orillusion фрагментный шейдер обязан выводить данные во все 4 render target слота:
struct FragmentOutput { @location(0) color: vec4<f32>, // Основной цвет @location(1) dummy1: vec4<f32>, // Пустышка @location(2) dummy2: vec4<f32>, // Пустышка @location(3) dummy3: vec4<f32>, // Пустышка } @fragment fn main(@location(0) v_color: vec4<f32>) -> FragmentOutput { var output: FragmentOutput; output.color = v_color; output.dummy1 = vec4<f32>(0.0); output.dummy2 = vec4<f32>(0.0); output.dummy3 = vec4<f32>(0.0); return output; }
Тессеракт имеет 16 вершин в 4D-пространстве (координаты x, y, z, w) и 32 ребра:
const vertices4D = [ [-1,-1,-1,-1], [ 1,-1,-1,-1], [ 1,-1, 1,-1], [-1,-1, 1,-1], [-1, 1,-1,-1], [ 1, 1,-1,-1], [ 1, 1, 1,-1], [-1, 1, 1,-1], [-1,-1,-1, 1], [ 1,-1,-1, 1], [ 1,-1, 1, 1], [-1,-1, 1, 1], [-1, 1,-1, 1], [ 1, 1,-1, 1], [ 1, 1, 1, 1], [-1, 1, 1, 1] ];
Для анимации я использовал метод onUpdate() из ComponentBase:
class TesseractComponent extends ComponentBase { onUpdate() { // Вызывается каждый кадр движком this.updateRotation(); } }
В 4D объект вращается в плоскости (например, XW). Формула:
x' = x·cos(α) — w·sin(α) w' = x·sin(α) + w·cos(α)
В шейдере применяются вращения в плоскостях XW, YW, ZW:
fn rotate4D(p: vec4<f32>, data: TransformData) -> vec4<f32> { var pos = p; let cosXW = cos(data.angleXW); let sinXW = sin(data.angleXW); let x1 = pos.x * cosXW - pos.w * sinXW; let w1 = pos.x * sinXW + pos.w * cosXW; pos.x = x1; pos.w = w1; // Аналогично для YW и ZW... return pos; }
fn project4D(pos4D: vec4<f32>, data: TransformData) -> vec3<f32> { let perspective = data.perspectiveDistance / (data.perspectiveDistance + pos4D.w * 0.4); return vec3<f32>( pos4D.x * perspective, pos4D.y * perspective, pos4D.z * perspective ); }
Углы вращения меняются каждый кадр. Вместо CPU-вычислений я использую GPU что бы данные никогда не покидали видеопамять.
Структура в StorageGPUBuffer должна быть кратной 16 байтам:
struct TransformData { time: f32, angleXW: f32, angleYW: f32, angleZW: f32, angleXY: f32, angleXZ: f32, angleYZ: f32, perspectiveDistance: f32, scale: f32, _pad1: f32, // паддинги до 48 байт _pad2: f32, _pad3: f32, }
@group(0) @binding(0) var<storage, read_write> transform: TransformData; @group(0) @binding(1) var<uniform> deltaTime: f32; @compute @workgroup_size(1, 1, 1) fn CsMain(@builtin(global_invocation_id) id: vec3<u32>) { var data = transform; data.time = data.time + deltaTime; data.angleXW = data.time * 0.8; data.angleYW = data.time * 0.6; data.angleZW = data.time * 0.4; data.perspectiveDistance = 3.5 + sin(data.time * 0.8) * 1.5; transform = data; }
onUpdate() { const dt = 0.016 * this.speedMultiplier; this.deltaBuffer.setFloat(0, dt); this.deltaBuffer.apply(); const commandEncoder = webGPUContext.device.createCommandEncoder(); const computePass = commandEncoder.beginComputePass(); this.computeShader.compute(computePass); computePass.end(); webGPUContext.device.queue.submit([commandEncoder.finish()]); }
Одна из ключевых задач для меня стала отрисовать 5 тессерактов на окружности, каждый с уникальной позицией и скоростью вращения, но с минимальными затратами.
Для этой задачи в библиотеке используется инстансинг. Он работает через механизм instance_index в шейдере:
@builtin(instance_index) instanceIndex: u32
Движок автоматически прокидывает матрицы трансформации для каждого инстанса через models.matrix[instanceIndex]. Мне оставалось только:
Создать 5 объектов Object3D с разными позициями на окружности
Добавить каждому компонент TesseractComponent с уникальной speedMultiplier
В шейдере умножить позицию вершины на соответствующую матрицу:
let worldPos = models.matrix[instanceIndex] * vec4<f32>(scaledPos, 1.0); output.position = globalUniform.projMat * globalUniform.viewMat * worldPos;
В результате все 5 тессерактов отрисовываются за один draw call, что даёт огромный выигрыш в производительности и что позволяет в браузере оперировать огромным количеством объектов.
Финальная сцена содержит 5 тессерактов на окружности радиусом 12 единиц. Каждый вращается с уникальной скоростью (0.5x, 1.0x, 1.5x, 2.0x, 1.2x).
WebGPU открывает новые возможности. Compute Shaders разгружают CPU и позволяют делать сложные эффекты.
Orillusion — хороший выбор для энтузиастов. Сообщество небольшое, документация неполная, но архитектура ECS и нативная поддержка WebGPU стоят того. При желании разобраться не сложно.
Кастомные шейдеры в orillusion — это страшно только вначале. Главное — понять систему атрибутов, выравнивание данных и требования к фрагментным выходам.
Инстансинг работает “из коробки”. Orillusion автоматически предоставляет models.matrix[instance_index], достаточно правильно передать атрибуты и использовать instanceIndex в шейдере.
Попробуйте сами: код требует браузер с поддержкой WebGPU (Chrome 113+, Edge 113+, Firefox Nightly). Управление: мышь
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>Gaia Star Map - 4D Тессеракт | WebGPU</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; margin: 0; padding: 0; } body { font-family: 'Segoe UI', 'Monaco', 'Menlo', 'Consolas', monospace; background: #000; position: fixed; top: 0; left: 0; right: 0; bottom: 0; } #canvas { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: block; outline: none; z-index: 1; pointer-events: auto; } @keyframes pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } } </style> </head> <body> <canvas id="canvas"></canvas> <script type="importmap"> { "imports": { "@orillusion/core": "https://unpkg.com/@orillusion/core@0.8.5-dev.9/dist/orillusion.es.js" } } </script> <script type="module"> import { Engine3D, Scene3D, Camera3D, Object3D, Vector3, View3D, AtmosphericComponent, DirectLight, HoverCameraController, GeometryBase, BoundingBox, ShaderLib, RenderShaderPass, Shader, Material, PassType, BlendMode, GPUCompareFunction, GPUCullMode, UniformGPUBuffer, StorageGPUBuffer, ComponentBase, ComputeShader, MeshRenderer, webGPUContext } from '@orillusion/core'; const vertices4D = [ [-1, -1, -1, -1], [ 1, -1, -1, -1], [ 1, -1, 1, -1], [-1, -1, 1, -1], [-1, 1, -1, -1], [ 1, 1, -1, -1], [ 1, 1, 1, -1], [-1, 1, 1, -1], [-1, -1, -1, 1], [ 1, -1, -1, 1], [ 1, -1, 1, 1], [-1, -1, 1, 1], [-1, 1, -1, 1], [ 1, 1, -1, 1], [ 1, 1, 1, 1], [-1, 1, 1, 1] ]; const edges = [ [0,1], [1,2], [2,3], [3,0], [4,5], [5,6], [6,7], [7,4], [0,4], [1,5], [2,6], [3,7], [8,9], [9,10], [10,11], [11,8], [12,13], [13,14], [14,15], [15,12], [8,12], [9,13], [10,14], [11,15], [0,8], [1,9], [2,10], [3,11], [4,12], [5,13], [6,14], [7,15] ]; class TesseractGeometry extends GeometryBase { constructor() { super(); const positions = []; const wCoords = []; const colors = []; const indices = []; let idx = 0; for (let i = 0; i < edges.length; i++) { const v1 = vertices4D[edges[i][0]]; const v2 = vertices4D[edges[i][1]]; positions.push(v1[0], v1[1], v1[2]); positions.push(v2[0], v2[1], v2[2]); wCoords.push(v1[3], v2[3]); const hue = i / edges.length; colors.push(0.5 + 0.5 * Math.sin(hue * Math.PI * 2), 0.3, 0.7, 1.0); colors.push(0.3, 0.5 + 0.5 * Math.sin(hue * Math.PI * 2), 0.7, 1.0); indices.push(idx, idx + 1); idx += 2; } this.setAttribute('a_position', new Float32Array(positions)); this.setAttribute('a_wCoord', new Float32Array(wCoords)); this.setAttribute('a_color', new Float32Array(colors)); this.setIndices(new Uint16Array(indices)); const subGeo = this.addSubGeometry({ indexStart: 0, indexCount: indices.length, vertexStart: 0, vertexCount: idx, firstStart: 0, index: 0, topology: 1 }); if (subGeo) { subGeo.lodLevels = [{ indexStart: 0, indexCount: indices.length, vertexStart: 0, vertexCount: idx, firstStart: 0, index: 0, topology: 1 }]; } const bounds = new BoundingBox(); bounds.min.set(-2.5, -2.5, -2.5); bounds.max.set(2.5, 2.5, 2.5); this.bounds = bounds; this.name = 'TesseractGeometry'; } } const VS_NAME = 'tesseract_final_vs'; const FS_NAME = 'tesseract_final_fs'; const COMPUTE_NAME = 'tesseract_final_compute'; const vsCode = ` #include "GlobalUniform" #include "WorldMatrixUniform" struct TransformData { time: f32, angleXW: f32, angleYW: f32, angleZW: f32, angleXY: f32, angleXZ: f32, angleYZ: f32, perspectiveDistance: f32, scale: f32, _pad1: f32, _pad2: f32, _pad3: f32, } @group(1) @binding(0) var<storage, read> transformData: TransformData; struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) v_color: vec4<f32>, } fn rotate4D(p: vec4<f32>, data: TransformData) -> vec4<f32> { var pos = p; let cosXW = cos(data.angleXW); let sinXW = sin(data.angleXW); let x1 = pos.x * cosXW - pos.w * sinXW; let w1 = pos.x * sinXW + pos.w * cosXW; pos.x = x1; pos.w = w1; let cosYW = cos(data.angleYW); let sinYW = sin(data.angleYW); let y1 = pos.y * cosYW - pos.w * sinYW; let w2 = pos.y * sinYW + pos.w * cosYW; pos.y = y1; pos.w = w2; let cosZW = cos(data.angleZW); let sinZW = sin(data.angleZW); let z1 = pos.z * cosZW - pos.w * sinZW; let w3 = pos.z * sinZW + pos.w * cosZW; pos.z = z1; pos.w = w3; return pos; } fn project4D(pos4D: vec4<f32>, data: TransformData) -> vec3<f32> { let perspective = data.perspectiveDistance / (data.perspectiveDistance + pos4D.w * 0.4); return vec3<f32>(pos4D.x * perspective, pos4D.y * perspective, pos4D.z * perspective); } @vertex fn main( @builtin(instance_index) instanceIndex: u32, @location(0) a_position: vec3<f32>, @location(1) a_wCoord: f32, @location(2) a_color: vec4<f32> ) -> VertexOutput { var output: VertexOutput; let pos4D = vec4<f32>(a_position.x, a_position.y, a_position.z, a_wCoord); let rotated4D = rotate4D(pos4D, transformData); let pos3D = project4D(rotated4D, transformData); let scaledPos = pos3D * transformData.scale; let worldPos = models.matrix[instanceIndex] * vec4<f32>(scaledPos, 1.0); output.position = globalUniform.projMat * globalUniform.viewMat * worldPos; let brightness = 0.6 + 0.4 * sin(transformData.time * 3.0 + rotated4D.w * 5.0); output.v_color = vec4<f32>( a_color.r * brightness, a_color.g * brightness, a_color.b * brightness, 1.0 ); return output; } `; const fsCode = ` struct FragmentOutput { @location(0) color: vec4<f32>, @location(1) dummy1: vec4<f32>, @location(2) dummy2: vec4<f32>, @location(3) dummy3: vec4<f32>, } @fragment fn main( @location(0) v_color: vec4<f32> ) -> FragmentOutput { var output: FragmentOutput; output.color = v_color; output.dummy1 = vec4<f32>(0.0); output.dummy2 = vec4<f32>(0.0); output.dummy3 = vec4<f32>(0.0); return output; } `; const computeCode = ` struct TransformData { time: f32, angleXW: f32, angleYW: f32, angleZW: f32, angleXY: f32, angleXZ: f32, angleYZ: f32, perspectiveDistance: f32, scale: f32, _pad1: f32, _pad2: f32, _pad3: f32, } @group(0) @binding(0) var<storage, read_write> transform: TransformData; @group(0) @binding(1) var<uniform> deltaTime: f32; @compute @workgroup_size(1, 1, 1) fn CsMain(@builtin(global_invocation_id) id: vec3<u32>) { var data = transform; data.time = data.time + deltaTime; data.angleXW = data.time * 0.8; data.angleYW = data.time * 0.6; data.angleZW = data.time * 0.4; data.angleXY = data.time * 0.5; data.angleXZ = data.time * 0.7; data.angleYZ = data.time * 0.3; data.perspectiveDistance = 3.5 + sin(data.time * 0.8) * 1.5; data.scale = 1.0; transform = data; } `; // ============================================================================ // КОМПОНЕНТ ТЕССЕРАКТА // ============================================================================ class TesseractComponent extends ComponentBase { constructor() { super(); this.geometry = null; this.transformBuffer = null; this.deltaBuffer = null; this.computeShader = null; this.speedMultiplier = 1.0; } start() { this.geometry = new TesseractGeometry(); this.transformBuffer = new StorageGPUBuffer(12 * 4); const initialData = new Float32Array(12); initialData[0] = 0; initialData[1] = 0; initialData[2] = 0; initialData[3] = 0; initialData[4] = 0; initialData[5] = 0; initialData[6] = 0; initialData[7] = 4.0; initialData[8] = 1.0; initialData[9] = 0; initialData[10] = 0; initialData[11] = 0; this.transformBuffer.outFloat32Array = initialData; this.transformBuffer.apply(); this.deltaBuffer = new UniformGPUBuffer(4); const renderPass = new RenderShaderPass(VS_NAME, FS_NAME); renderPass.passType = PassType.COLOR; renderPass.blendMode = BlendMode.NORMAL; renderPass.depthWriteEnabled = true; renderPass.depthCompare = GPUCompareFunction.less; renderPass.cullMode = GPUCullMode.none; renderPass.topology = 'line-list'; renderPass.setStorageBuffer('transformData', this.transformBuffer); const shader = new Shader(); shader.addRenderPass(renderPass); const material = new Material(); material.shader = shader; const renderer = this.object3D.addComponent(MeshRenderer); renderer.geometry = this.geometry; renderer.material = material; this.computeShader = new ComputeShader(computeCode); this.computeShader.setStorageBuffer('transform', this.transformBuffer); this.computeShader.setUniformBuffer('deltaTime', this.deltaBuffer); this.computeShader.workerSizeX = 1; this.computeShader.workerSizeY = 1; this.computeShader.workerSizeZ = 1; } onUpdate() { if (!this.computeShader || !this.deltaBuffer) return; const dt = 0.016 * this.speedMultiplier; this.deltaBuffer.setFloat(0, dt); this.deltaBuffer.apply(); const commandEncoder = webGPUContext.device.createCommandEncoder(); const computePass = commandEncoder.beginComputePass(); this.computeShader.compute(computePass); computePass.end(); webGPUContext.device.queue.submit([commandEncoder.finish()]); } destroy(force) { if (this.transformBuffer) this.transformBuffer.destroy(); if (this.deltaBuffer) this.deltaBuffer.destroy(); if (this.computeShader) this.computeShader.destroy(); super.destroy(force); } } function getCirclePosition(radius, angleDeg, yOffset = 0) { const angleRad = angleDeg * Math.PI / 180; return new Vector3( Math.cos(angleRad) * radius, yOffset, Math.sin(angleRad) * radius ); } let frameCount = 0; let lastTime = performance.now(); async function init() { console.log('🚀 Инициализация Engine3D...'); // Получаем canvas и устанавливаем размеры на 100% const canvas = document.getElementById('canvas'); canvas.style.width = '100vw'; canvas.style.height = '100vh'; canvas.width = window.innerWidth; canvas.height = window.innerHeight; await Engine3D.init({ canvasConfig: { canvas: canvas, devicePixelRatio: window.devicePixelRatio } }); console.log('✅ Engine3D инициализирован'); // Регистрация шейдеров ShaderLib.register(VS_NAME, vsCode); ShaderLib.register(FS_NAME, fsCode); ShaderLib.register(COMPUTE_NAME, computeCode); console.log('✅ Шейдеры зарегистрированы'); // Создание сцены const scene = new Scene3D(); scene.name = 'TesseractScene'; // Камера const cameraObj = new Object3D(); cameraObj.name = 'Camera'; const camera = cameraObj.addComponent(Camera3D); camera.perspective(60, Engine3D.aspect, 0.1, 1000); cameraObj.transform.localPosition = new Vector3(0, 5, 18); cameraObj.transform.lookAt(new Vector3(0, 0, 0), new Vector3(0, 1, 0)); scene.addChild(cameraObj); const controller = cameraObj.addComponent(HoverCameraController); controller.setCamera(0, -15, 18); scene.addComponent(AtmosphericComponent).sunY = 0.6; const light = new Object3D(); light.addComponent(DirectLight); scene.addChild(light); const radius = 12; const angles = [0, 72, 144, 216, 288]; const speeds = [0.5, 1.0, 1.5, 2.0, 1.2]; const scales = [0.8, 1.0, 1.2, 0.9, 1.1]; console.log('🔧 Создание 5 тессерактов...'); angles.forEach((angle, index) => { const position = getCirclePosition(radius, angle, 0); const container = new Object3D(); container.name = `Tesseract_${index}`; container.transform.localPosition = position; container.transform.localScale = new Vector3(scales[index], scales[index], scales[index]); const comp = container.addComponent(TesseractComponent); comp.speedMultiplier = speeds[index]; scene.addChild(container); console.log(` • Инстанс ${index}: угол ${angle}°, позиция (${position.x.toFixed(1)}, ${position.z.toFixed(1)}), скорость ${speeds[index]}x`); }); // Обновляем счётчик объектов const objCountElem = document.getElementById('objectCount'); if (objCountElem) objCountElem.textContent = `Объектов: ${angles.length}`; const view = new View3D(); view.scene = scene; view.camera = camera; Engine3D.startRenderView(view); window.addEventListener('resize', () => { const canvas = document.getElementById('canvas'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; camera.aspect = Engine3D.aspect; camera.perspective(60, Engine3D.aspect, 0.1, 1000); }); function updateFPS() { const now = performance.now(); const delta = now - lastTime; if (delta >= 1000) { const fps = Math.round((frameCount * 1000) / delta); const fpsElement = document.getElementById('fps'); if (fpsElement) fpsElement.textContent = `FPS: ${fps}`; frameCount = 0; lastTime = now; } frameCount++; requestAnimationFrame(updateFPS); } lastTime = performance.now(); requestAnimationFrame(updateFPS); console.log('✅ ТЕССЕРАКТЫ СОЗДАНЫ!'); console.log(' • 5 объектов на окружности'); console.log(' • 4D вращение через Compute Shader'); console.log(' • Разные скорости вращения'); } init().catch(console.error); </script> </body> </html>