javascript

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

  • среда, 15 апреля 2026 г. в 00:00:05
https://habr.com/ru/articles/1023432/
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>,     // Пустышка
}

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

Этап 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>