WebGPU, библиотека Orillusion и кастомные шейдеры: как я создавал 4D Тессеракт

от автора

orillusion Тессеракты

orillusion Тессеракты

WebGPU — это новый стандарт для доступа к возможностям видеокарт, который я уже несколько лет хочу использовать в своем проекте. Два года, даже с включенными флагами, у меня не получалось с моей встроенной видеокартой это сделать. В отличие от WebGL, WebGPU создавался с нуля под архитектуры современных GPU, предоставляя разработчикам низкоуровневый контроль над вычислениями, поддержку compute-шейдеров и высокую производительность в браузерах.

Но сегодня эта технология выходит из экспериментального тестирования. На момент написания статьи webGPU уже доступна в Chrome, Edge и Firefox (под флагом). Все тестовые примеры начали запускаться у меня на компьютере и я решил глубже разобраться с этой технологией.

Прежде чем начать писать что то я посмотрел в интернете, какие библиотеки можно уже сейчас использовать для написания приложений. Ниже я сделал сравнительную таблицу с известными библиотеками, которые сейчас часто используются.

Сравнительный анализ WebGPU/WebGL движков

Характеристика

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

Моей целью стало пройти весь путь создания сложной сцены с нуля, чтобы в будущем, на основе этого примера использовать эту последовательность в своем проекте.

Минусы, с которыми я столкнулся

  1. Маленькое сообщество — найти готовые решения сложнее, чем для Three.js.

  2. Развивающаяся документация — некоторые возможности ещё не полностью документированы. Примеров кастомных шейдеров практически нет.

  3. Высокий порог входа — требует понимания WebGPU и современных графических концепций.

Но несмотря на это, нативная поддержка Compute Shaders, архитектура ECS и отсутствие легаси-кода WebGL перевесили минусы.

Проект: 4D Тессеракт на WebGPU

Я создал интерактивную 3D-сцену с пятью тессерактами (4D-гиперкубами), которые вращаются в четырёхмерном пространстве с использованием:

  • WebGPU (через библиотеку Orillusion)

  • Кастомных вершинных/фрагментных шейдеров на WGSL

  • Compute-шейдеров для GPU-вычислений

Этап 1: Первый куб и ошибки шейдеров

Для начала я создал обычный куб с кастомным шейдером. И столкнулся с несколькими ошибками.

Ошибка 1: Несоответствие атрибутов геометрии

Шейдер ожидал атрибуты, которые геометрия не предоставляла. В Orillusion атрибуты нужно явно регистрировать через setAttribute().

Ошибка 2: Фрагментный шейдер должен выводить 4 слота

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>,     // Пустышка}@fragmentfn 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;}

Этап 2: Геометрия тессеракта

Тессеракт имеет 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]];

Этап 3: Анимация через ComponentBase

Для анимации я использовал метод onUpdate() из ComponentBase:

class TesseractComponent extends ComponentBase {    onUpdate() {        // Вызывается каждый кадр движком        this.updateRotation();    }}

Этап 4: 4D-трансформация в вершинном шейдере

В 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;}

Проекция 4D → 3D

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    );}

Этап 5: Compute-шейдер для GPU-вычислений

Зачем нужен Compute Shader?

Углы вращения меняются каждый кадр. Вместо 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,}

Код compute-шейдера

@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()]);}

Этап 6: Инстансинг

Одна из ключевых задач для меня стала отрисовать 5 тессерактов на окружности, каждый с уникальной позицией и скоростью вращения, но с минимальными затратами.

Для этой задачи в библиотеке используется инстансинг. Он работает через механизм instance_index в шейдере:

@builtin(instance_index) instanceIndex: u32

Движок автоматически прокидывает матрицы трансформации для каждого инстанса через models.matrix[instanceIndex]. Мне оставалось только:

  1. Создать 5 объектов Object3D с разными позициями на окружности

  2. Добавить каждому компонент TesseractComponent с уникальной speedMultiplier

  3. В шейдере умножить позицию вершины на соответствующую матрицу:

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).

Выводы

  1. WebGPU открывает новые возможности. Compute Shaders разгружают CPU и позволяют делать сложные эффекты.

  2. Orillusion — хороший выбор для энтузиастов. Сообщество небольшое, документация неполная, но архитектура ECS и нативная поддержка WebGPU стоят того. При желании разобраться не сложно.

  3. Кастомные шейдеры в orillusion — это страшно только вначале. Главное — понять систему атрибутов, выравнивание данных и требования к фрагментным выходам.

  4. Инстансинг работает “из коробки”. 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>

ссылка на оригинал статьи https://habr.com/ru/articles/1023432/