Files
netrunner-landing/matrix-engine/README.md
T
2026-07-05 03:42:57 +07:00

15 KiB
Raw Blame History

Matrix Engine

Движок анимированного фона в стиле "цифрового дождя" из Matrix с двумя интерактивными "кибер-глазами" по центру экрана. Глаза открыты/моргают, когда VPN выключен, и закрыты (с изменённой цветовой палитрой и паттерном радужки), когда VPN включён — визуальный индикатор состояния защиты прямо в фоне лендинга. Реагирует на движение мыши, тач/свайп, наклон телефона (гироскоп), клики (шоковая волна + глитч-эффекты) и подстраивает качество рендера под реальный FPS устройства.

Архитектура

Rust (физика/симуляция) --WASM--> Web Worker (JS) --WebGL2--> <canvas>

Три слоя, каждый со своей причиной существования:

  1. Rust -> WebAssembly (src/lib.rs). Вся симуляция — движение капель, параллакс, взаимодействие с курсором/тачем/наклоном, ударные волны, глитчи, логика взгляда и моргания глаз — считается в Rust и компилируется в WASM. Быстрый детерминированный PRNG и работа с плоскими Vec<f32> вместо объектов дают предсказуемую производительность на тысячах капель без пауз GC (в отличие от эквивалентной реализации на чистом JS).
  2. Web Worker (matrix_worker.js). Владеет OffscreenCanvas, вызывает engine.tick() и рендерит результат через WebGL2. Всё это происходит не на main thread — тяжёлый цикл "физика + рендер" не блокирует основной поток страницы (React-рендер, обработку кликов по UI, скролл), даже если сам кадр матрицы просядет по FPS.
  3. 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" добавлены два независимых визуальных слоя, оба не трогают физику дождя/глаз:

  1. Фон-глубина (дорога/машины)fsBackground в shaders.js, подключён в matrix_worker.js (bgProgram/vaoBg). Рисуется ПЕРВЫМ внутри postFramebuffer, до капель дождя, тем же vsOverlay-quad'ом, что и постпроцесс. Это единственный способ дать дождю "глубину сзади": канвас матрицы создаётся с alpha:false и каждый кадр полностью перезаписывается, поэтому DOM-слой позади канваса был бы просто не виден — сцена должна рисоваться внутри того же фреймбуфера, ДО капель, чтобы дождь (уже блендящийся поверх фона) частично её перекрывал, а не скрывал целиком. Минималистичная сцена: светящийся горизонт, дорога с перспективой к точке схода (асфальт — цвет фона с мелким зернистым шумом) и стилизованные машины, едущие в противоположных полосах (эффект двустороннего движения). Все формы аспект-корректны (см. aspect в шейдере) — не искажаются при смене соотношения сторон экрана. Цвет акцента синхронизирован с палитрой капель/глаз (циан выкл / фиолет вкл).
  2. Стекло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 ударов стекло осыпается и через паузу собирается заново — эффект зациклен.