Matrix Engine
Движок анимированного фона в стиле "цифрового дождя" из Matrix с двумя интерактивными "кибер-глазами" по центру экрана. Глаза открыты/моргают, когда VPN выключен, и закрыты (с изменённой цветовой палитрой и паттерном радужки), когда VPN включён — визуальный индикатор состояния защиты прямо в фоне лендинга. Реагирует на движение мыши, тач/свайп, наклон телефона (гироскоп), клики (шоковая волна + глитч-эффекты) и подстраивает качество рендера под реальный FPS устройства.
Архитектура
Rust (физика/симуляция) --WASM--> Web Worker (JS) --WebGL2--> <canvas>
Три слоя, каждый со своей причиной существования:
- Rust -> WebAssembly (
src/lib.rs). Вся симуляция — движение капель, параллакс, взаимодействие с курсором/тачем/наклоном, ударные волны, глитчи, логика взгляда и моргания глаз — считается в Rust и компилируется в WASM. Быстрый детерминированный PRNG и работа с плоскимиVec<f32>вместо объектов дают предсказуемую производительность на тысячах капель без пауз GC (в отличие от эквивалентной реализации на чистом JS). - Web Worker (
matrix_worker.js). ВладеетOffscreenCanvas, вызываетengine.tick()и рендерит результат через WebGL2. Всё это происходит не на main thread — тяжёлый цикл "физика + рендер" не блокирует основной поток страницы (React-рендер, обработку кликов по UI, скролл), даже если сам кадр матрицы просядет по FPS. - WebGL2 + GPU-инстансинг. Каждая капля — это не отдельный DOM-узел и
не отдельный draw call, а один инстанс в
drawArraysInstanced: тысячи символов рисуются одним вызовом на GPU, с данными на каждый инстанс (позиция, символ, яркость, масштаб), приходящими прямо из памяти WASM.
Итог: тяжёлые вычисления идут в нативно-скомпилированном Rust, тяжёлый рендер — на GPU одним батчем, а JS на воркере выступает тонкой склейкой между ними и не трогает основной поток вовсе.
Файлы
| Файл | Роль |
|---|---|
Cargo.toml |
Манифест Rust-крейта matrix-engine (cdylib+rlib), зависимости wasm-bindgen, console_error_panic_hook. |
src/lib.rs |
Вся физика/симуляция на Rust: FastRng (xorshift32), структуры Drop/GlitchBlock/Shockwave/GlassSymbol, главная структура MatrixEngine с методом tick(). Пишет результат каждого кадра в плоские Vec<f32> буферы, которые JS читает напрямую из линейной памяти WASM (см. раздел ниже). |
NetrunnerMatrix.tsx |
React client-компонент. Создаёт <canvas>, один раз передаёт его в Web Worker через transferControlToOffscreen(), дальше управляет воркером message-протоколом (см. таблицу ниже) и слушает DOM-события (мышь/тач/гироскоп/видимость вкладки/ресайз контейнера). |
matrix_worker.js |
Код, реально исполняющийся в Web Worker. initWebGL() собирает 3 шейдерные программы, buildAtlas() рисует текстурный атлас символов на OffscreenCanvas 2D, updateDynamicScaling() адаптирует разрешение канваса под реальный FPS, render() — главный requestAnimationFrame-луп (рендер матрицы+глитчей в offscreen framebuffer, затем полноэкранный пост-процесс). self.onmessage — приёмная сторона message-протокола. |
gl-utils.js |
Небольшие хелперы компиляции шейдеров и линковки WebGL-программ (compileShader, createProgram) с логированием ошибок компиляции/линковки. |
shaders.js |
Все GLSL ES 3.00 шейдеры как template-строки: vsMatrix/fsMatrix (рендер символов-капель с лёгким притяжением к курсору), vsGlitch/fsGlitch (цветные глитч-блоки), vsOverlay/postProcessSource (полноэкранный пост-процесс — CRT-дисторсия, хроматическая аберрация, шум/тиры и процедурный SDF-рендер двух кибер-глаз). |
pkg/ |
Сгенерировано wasm-pack build. Не редактировать вручную — перезаписывается при каждой сборке. |
public/wasm-matrix/ (в корне проекта) |
Сгенерировано. Финальное место, откуда браузер грузит WASM-модуль и JS-файлы движка в рантайме (см. раздел "Сборка"). |
repomix-output.xml и Cargo.lock — служебные файлы, не требуют
документирования и не должны редактироваться вручную.
Message-протокол: NetrunnerMatrix.tsx <-> matrix_worker.js
Main thread -> Worker:
| Тип | Когда отправляется | Назначение |
|---|---|---|
INIT |
Один раз при монтировании компонента | Передаёт OffscreenCanvas (transferable) и стартовые параметры: размеры, pixelScale (DPR), isMobile, isVpnOn, isDarkMode, fontSize, путь до ассетов и т.д. |
PAUSE / RESUME |
По событию visibilitychange документа |
Останавливает/возобновляет requestAnimationFrame-луп, когда вкладка уходит в фон — экономит батарею и CPU/GPU. |
RESIZE |
По ResizeObserver контейнера |
Пересчитывает физический размер канваса, DPR-скейл, пересоздаёт капли под новую ширину (engine.resize()) и перестраивает атлас. |
THEME |
При смене пропсов isSecure/isDarkMode |
Сообщает воркеру новое состояние VPN и тему — перекрашивает атлас символов и запускает VPN-тир-глитч на экране. |
TILT |
По deviceorientation (гироскоп телефона) |
Передаёт нормализованный наклон по оси gamma для лёгкого 3D-параллакса капель. |
SHOCKWAVE / DRAW_START / DRAW_END / KINETIC_SWIPE / MOUSE_MOVE |
По pointer/touch-событиям на window |
Интеракции указателя: ударная волна и глитч-всплеск от клика/тапа, кинетический свайп и обычное перемещение курсора (влияют на положение символов и взгляд глаз). |
Worker -> Main thread:
| Тип | Когда отправляется | Назначение |
|---|---|---|
READY |
После загрузки WASM-модуля и первого прогретого кадра | Сигнал компоненту, что можно плавно проявить канвас (opacity transition), избегая вспышки нерасставленных капель. |
Протокол памяти WASM <-> JS
MatrixEngine::tick() каждый кадр перезаписывает несколько плоских
Vec<f32> буферов внутри WASM. Вместо того чтобы возвращать их в JS через
wasm-bindgen (что означало бы копирование всего буфера в JS-кучу 60 раз в
секунду), движок экспортирует пары методов *_ptr() / *_len():
*_ptr()— сырой указатель (*const f32) на начало буфера в линейной памяти WASM;*_len()— актуальная длина в элементахf32(не в байтах).
JS-сторона (matrix_worker.js) оборачивает эту память в Float32Array
напрямую поверх wasm.memory.buffer, без копирования, и сразу заливает во
WebGL-буфер через bufferSubData. Такой TypedArray валиден только до
следующего вызова engine.tick() (рост внутреннего Vec в Rust может
переместить память) — поэтому view создаётся заново на каждый кадр и не
хранится между кадрами.
Буферы и их stride (шаг полей на один элемент):
| Буфер | ptr()/len() |
Stride | Поля на элемент |
|---|---|---|---|
| Капли дождя | render_ptr() / render_len() |
5 | x, y, char_idx, atlas_row, scale |
| Глитч-блоки | glitch_ptr() / glitch_len() |
5 | x, y, w, h, color_type |
| "Стеклянные" символы | glass_ptr() / glass_len() |
4 | x, y, char_idx, alpha |
| Состояние глаз | eye_ptr() / eye_len() |
6 (фикс.) | cx, cy, gaze_x, gaze_y, 0.0 (резерв), eye_closedness |
Для капель char_idx и atlas_row вместе выбирают конкретный глиф в
текстурном атласе символов (11 строк: 0..9 — градации яркости символа,
10 — полностью белая "голова" капли), который buildAtlas() заранее
отрисовывает на OffscreenCanvas 2D в matrix_worker.js.
Сборка
Из папки matrix-engine/:
wasm-pack build --target web --release
Либо через готовый скрипт из корня проекта (собирает WASM и раскладывает воркер/шейдеры/хелперы рядом с ним):
./build-wasm.sh
Результат (pkg/ + скопированные matrix_worker.js, shaders.js,
gl-utils.js) кладётся в public/wasm-matrix/ в корне проекта — оттуда
браузер грузит WASM-модуль и JS-файлы движка в рантайме
(см. assetsPath в NetrunnerMatrix.tsx, по умолчанию /wasm-matrix).
В Docker-образе аналогичная сборка (wasm-pack build --target web --release + копирование JS-файлов в public/wasm-matrix/) выполняется
отдельным стейджем в корневом Dockerfile.
Дополнительные визуальные слои (депт-сцена и стекло)
Поверх исходной триады "Rust → Worker → WebGL2" добавлены два независимых визуальных слоя, оба не трогают физику дождя/глаз:
- Фон-глубина (дорога/машины) —
fsBackgroundвshaders.js, подключён вmatrix_worker.js(bgProgram/vaoBg). Рисуется ПЕРВЫМ внутриpostFramebuffer, до капель дождя, тем жеvsOverlay-quad'ом, что и постпроцесс. Это единственный способ дать дождю "глубину сзади": канвас матрицы создаётся сalpha:falseи каждый кадр полностью перезаписывается, поэтому DOM-слой позади канваса был бы просто не виден — сцена должна рисоваться внутри того же фреймбуфера, ДО капель, чтобы дождь (уже блендящийся поверх фона) частично её перекрывал, а не скрывал целиком. Минималистичная сцена: светящийся горизонт, дорога с перспективой к точке схода (асфальт — цвет фона с мелким зернистым шумом) и стилизованные машины, едущие в противоположных полосах (эффект двустороннего движения). Все формы аспект-корректны (см.aspectв шейдере) — не искажаются при смене соотношения сторон экрана. Цвет акцента синхронизирован с палитрой капель/глаз (циан выкл / фиолет вкл). - Стекло —
matrix-engine/GlassShatter.tsx, отдельный React-компонент ВНЕ воркера и WASM (простой fullscreen-quad на main thread, GPU-нагрузка пренебрежимо мала). Рендерится, в отличие от фона, обычным DOM-канвасом ПОВЕРХ канваса матрицы — сюда ограничениеalpha:falseне применяется, оно работает только "сквозь" элемент, а не поверх него. Показывается только когдаactive(=isVpnOn, "глаза закрыты"). Клик/тап по панели добавляет трещины (Voronoi-ячейки + growing-радиус вокруг точки удара, пул из 6 слотов по аналогии сShockwaveвlib.rs); послеSHATTER_AFTER_HITSударов стекло осыпается и через паузу собирается заново — эффект зациклен.