This commit is contained in:
2026-07-02 23:13:24 +07:00
parent 31f354d9ca
commit c04194bb85
36 changed files with 1005 additions and 41 deletions
+125 -12
View File
@@ -1,5 +1,11 @@
use wasm_bindgen::prelude::*;
/// Детерминированный быстрый генератор псевдослучайных чисел (xorshift32).
/// Используется вместо `rand`, чтобы не тащить в WASM лишние зависимости и
/// не платить за криптографическую случайность — здесь важна только скорость,
/// так как `next_u32()` вызывается тысячи раз за кадр. Состояние не может
/// быть равно 0 (иначе xorshift зациклится на нуле), поэтому `new()` подменяет
/// нулевой seed на 1.
struct FastRng {
state: u32,
}
@@ -42,6 +48,14 @@ impl FastRng {
}
}
/// Одна "капля" символов матричного дождя. Живёт в своей колонке (`base_x`)
/// и падает вниз по `y` в логических единицах (не в пикселях — реальная
/// экранная позиция считается как `y * font_size * parallax_factor`, см. `tick()`).
/// `offset_x`/`vel_x` — горизонтальное смещение и скорость смещения от точки
/// покоя, которые дают капле "оттолкнуться" от мыши/свайпа/шоквейва и потом
/// демпфированно вернуться на место (пружинная модель, см. блок с `-0.12 * offset_x`).
/// `layer` задаёт параллакс-слой (0 = far, 1 = mid, 2 = near — но за пределами
/// mobile-режима, где слой far отключён совсем).
struct Drop {
base_x: f32,
y: f32,
@@ -52,6 +66,9 @@ struct Drop {
glitch_timer: f32,
}
/// Непрозрачный цветной прямоугольник-осколок для эффекта глитча (VHS-подобный
/// сбой изображения). Живёт `life` кадров и рендерится в отдельном шейдере
/// (`vsGlitch`/`fsGlitch`), поэтому не связан со шрифтовым атласом капель.
struct GlitchBlock {
x: f32,
y: f32,
@@ -91,6 +108,12 @@ impl GlitchBlock {
}
}
}
/// Кольцевая ударная волна от клика/тапа/EMP-импульса. Пул из 5 штук
/// переиспользуется (см. `shockwaves` в `MatrixEngine::new`) вместо
/// динамического создания/удаления — `trigger_shockwave` просто ищет
/// первый неактивный слот (`!s.active`). `radius` растёт каждый тик,
/// `life` тикает вниз до 0, после чего волна деактивируется.
struct Shockwave {
x: f32,
y: f32,
@@ -100,6 +123,10 @@ struct Shockwave {
active: bool,
}
/// Декоративный "осыпающийся" символ, который на короткое время появляется
/// рядом с курсором при взаимодействии (эффект битого стекла/осыпающихся
/// пикселей). Не связан с основными каплями дождя и рендерится отдельным
/// буфером (`glass_buffer`).
struct GlassSymbol {
x: f32,
y: f32,
@@ -109,8 +136,32 @@ struct GlassSymbol {
velocity_y: f32,
}
// Количество f32-полей на одну каплю в `instance_buffer`: x, y, char_idx,
// atlas_row, scale. Должно совпадать со stride, который читает WebGL-сторона
// в `matrix_worker.js` (`strideBytes = 5 * 4`) — при изменении набора полей
// нужно синхронно поправить оба места.
const STRIDE: usize = 5;
/// Главный движок симуляции матричного дождя и "глаз".
///
/// # Протокол памяти WASM <-> JS
/// Все производимые за кадр данные (позиции капель, глитч-блоки, "стеклянные"
/// символы, состояние глаз) пишутся в плоские `Vec<f32>` буферы
/// (`instance_buffer`, `glitch_buffer`, `glass_buffer`, `eye_buffer`) с
/// фиксированным шагом (stride) полей на один элемент. wasm-bindgen НЕ
/// возвращает эти `Vec<f32>` напрямую в JS (это потребовало бы копирования
/// всего буфера в JS-кучу на каждый кадр), вместо этого экспортируются пары
/// методов `*_ptr()`/`*_len()`, возвращающие сырой указатель в линейную
/// память WASM и длину в элементах. JS-сторона (`matrix_worker.js`) оборачивает
/// эту память в `Float32Array` напрямую поверх `wasm.memory.buffer` без
/// копирования и сразу заливает во WebGL-буфер через `bufferSubData`.
/// Это горячий путь (60 раз в секунду на тысячах капель), поэтому
/// избежание лишних аллокаций/копий здесь критично для производительности.
///
/// Важно: указатель, возвращённый `render_ptr()` и т.п., валиден только до
/// следующего вызова любого `&mut self` метода движка (например, `tick()`),
/// так как рост `Vec` может переместить буфер в памяти — JS обязан прочитать
/// данные сразу после `tick()`, не сохраняя указатель между кадрами.
#[wasm_bindgen]
pub struct MatrixEngine {
width: u32,
@@ -124,12 +175,19 @@ pub struct MatrixEngine {
glass_symbols: Vec<GlassSymbol>,
glitch_blocks: Vec<GlitchBlock>,
// Буфер рендера: [x, y, char_idx, atlas_row, scale] (Stride = 5)
// Буфер рендера капель: [x, y, char_idx, atlas_row, scale] на каждую
// видимую каплю (Stride = STRIDE = 5). Переиспользуется между кадрами —
// не пересоздаётся, а перезаписывается по индексу, чтобы не аллоцировать.
instance_buffer: Vec<f32>,
visible_drops: usize, // Счетчик видимых элементов
visible_drops: usize, // Сколько капель реально записано в instance_buffer в этом кадре (после frustum culling)
// Буфер глитч-блоков: [x, y, w, h, color_type] на блок (Stride = 5).
glitch_buffer: Vec<f32>,
// Буфер "стеклянных" символов: [x, y, char_idx, alpha] на символ (Stride = 4).
glass_buffer: Vec<f32>,
// Буфер состояния глаз: [cx, cy, gaze_x, gaze_y, 0.0, eye_closedness].
// Всегда ровно 6 элементов (один взгляд на пару глаз), 5-й слот зарезервирован
// (сейчас всегда 0.0) — JS читает его по фиксированным индексам, см. eyeData[] в matrix_worker.js.
eye_buffer: Vec<f32>,
mouse_x: f32,
@@ -138,28 +196,33 @@ pub struct MatrixEngine {
is_mobile: bool,
is_vpn_on: bool,
tick_count: u32,
// EMP-импульс (триггерится вместе со шоквейвом в trigger_glitch): временно
// "стирает" капли рядом с точкой удара (см. is_void/is_emp_ring/is_scrambled в tick()).
emp_timer: f32,
emp_x: f32,
emp_y: f32,
// Горизонтальная "трещина" экрана — редкий декоративный сбой не на мобильных.
tear_y: f32,
tear_timer: f32,
blink_timer: f32,
last_interaction: f32,
gaze_target_x: f32,
// --- Состояние глаз: моргание и взгляд ---
blink_timer: f32, // >0, пока веки закрыты в рамках обычного моргания
last_interaction: f32, // Кадров с последнего движения мыши/тача — используется и для отсчёта моргания, и для "блуждающего" взгляда на мобильных
gaze_target_x: f32, // Куда должен смотреть взгляд (обычно — позиция курсора/тача)
gaze_target_y: f32,
gaze_x: f32,
gaze_x: f32, // Текущее сглаженное смещение зрачка (интерполируется к цели с инерцией)
gaze_y: f32,
eye_closedness: f32,
eye_closedness_vel: f32,
eye_anchor_y: f32,
mobile_wander_timer: f32,
eye_closedness: f32, // 0 = открыты, 1 = закрыты; демпфированная пружина к target_closedness
eye_closedness_vel: f32, // "скорость" пружины eye_closedness
eye_anchor_y: f32, // Y-координата центра глаз, приходит из DOM (см. set_eye_anchor / #eye-anchor в React)
mobile_wander_timer: f32, // Таймер для автономного блуждания взгляда, когда на мобильном давно не было тача
tilt_x: f32,
// --- Наклон телефона и кинетический свайп ---
tilt_x: f32, // deviceorientation.gamma, нормализованный к [-1, 1] — используется для лёгкого 3D-параллакса капель
tilt_y: f32,
swipe_x: f32,
swipe_y: f32,
swipe_timer: f32,
swipe_timer: f32, // >0 короткое время после свайпа — капли в этой точке отталкиваются, как от мыши
}
#[wasm_bindgen]
@@ -225,6 +288,10 @@ impl MatrixEngine {
engine
}
/// (Пере)генерирует всю популяцию капель под текущие width/font_size.
/// Вызывается из `new()` и из `resize()` — сетка колонок целиком зависит
/// от ширины экрана, поэтому при ресайзе капли просто создаются заново,
/// а не переиспользуются. Кортеж на слой — `(layer_id, scale, min_speed, max_speed)`.
fn init_drops(&mut self) {
self.drops.clear();
@@ -333,6 +400,10 @@ impl MatrixEngine {
self.update_mouse(x, y);
}
/// EMP-импульс: сильный сбой (шоквейв + всплеск глитч-блоков + временная
/// "дыра" в поле капель вокруг точки, см. is_void/is_emp_ring/is_scrambled
/// в tick()). Вызывается воркером как один из двух случайных вариантов
/// реакции на DRAW_START наравне с обычным trigger_shockwave.
pub fn trigger_glitch(&mut self, x: f32, y: f32) {
self.emp_timer = 40.0; // Чуть больше белое пятно
self.emp_x = x;
@@ -354,6 +425,14 @@ impl MatrixEngine {
}
self.update_mouse(x, y);
}
/// Главный шаг симуляции, вызывается воркером ровно раз на кадр рендера
/// (~60 FPS). Пересчитывает всё состояние и заполняет буферы заново
/// (instance/glitch/glass/eye) — движок не хранит "дельту" между кадрами,
/// JS-сторона каждый раз читает свежий снимок через *_ptr()/*_len().
/// Логически tick() состоит из блоков: обновление шоквейвов -> логика
/// взгляда и моргания глаз -> редкие декоративные глитчи/трещины ->
/// EMP-геометрия -> основной проход по каплям (движение, взаимодействия,
/// запись в instance_buffer) -> обновление глитч-блоков и "стеклянных" символов.
pub fn tick(&mut self) {
self.tick_count = self.tick_count.wrapping_add(1);
self.last_interaction += 1.0;
@@ -363,6 +442,7 @@ impl MatrixEngine {
self.glass_buffer.clear();
self.eye_buffer.clear();
// --- Обновление активных ударных волн ---
// Скорость расхождения волны снижена
let sw_speed = if self.is_mobile { 3.0 } else { 8.0 };
@@ -379,6 +459,11 @@ impl MatrixEngine {
}
// --- Логика Глаз (Остается прежней) ---
// Взгляд плавно едет к gaze_target_{x,y} (позиция курсора/тача, либо
// случайная точка "блуждания" на мобильных при простое), моргание —
// это отдельная демпфированная пружина eye_closedness с VPN как
// "постоянно закрытыми глазами" (это выключенный тоггл = закрытые глаза,
// см. target_closedness ниже).
let cx = self.width as f32 / 2.0;
let cy = if self.eye_anchor_y > 0.0 {
self.eye_anchor_y
@@ -465,6 +550,12 @@ impl MatrixEngine {
self.tear_timer -= 1.0;
}
// EMP расширяется наружу по мере убывания emp_timer (25 кадров жизни),
// формируя три концентрические зоны вокруг точки удара, проверяемые
// ниже как quadrance (dist_sq) без sqrt ради скорости:
// [0, emp_r80_sq) — is_void: капли полностью гасятся (эпицентр)
// [emp_r80_sq, emp_r20_sq) — is_scrambled: капли получают случайный символ
// [emp_r20_sq, emp_rad_sq) — is_emp_ring: яркое кольцо-фронт волны
let emp_rad = (25.0 - self.emp_timer) * 10.0;
let emp_rad_sq = emp_rad * emp_rad;
let emp_r20_sq = if emp_rad > 20.0 {
@@ -479,6 +570,13 @@ impl MatrixEngine {
};
// --- Итерация по каплям ---
// Для каждой капли: (1) горизонтальный wrap колонки при выходе за
// экран, (2) frustum culling по Y с ре-спауном сверху/снизу,
// (3) реакция на активные шоквейвы/мышь-свайп/EMP/трещину,
// (4) пружинное затухание offset_x к нулю, (5) выбор символа и его
// "яркости" (atlas_row) через хэш от индекса+Y+времени — эмулирует
// случайное мерцание символов без re-roll каждого символа отдельно,
// (6) запись итоговых 5 полей в instance_buffer.
for i in 0..self.drops.len() {
let drop = &mut self.drops[i];
@@ -610,6 +708,14 @@ impl MatrixEngine {
drop.glitch_timer = 5.0;
}
// Пространственный хэш (индекс капли + ячейка Y + медленно
// тикающий сдвиг времени) вместо rng.next_u32(): даёт детерминированный
// "случайный" символ для конкретной позиции капли, который стабилен
// между кадрами (не мерцает каждый символ каждый тик) и меняется
// только когда сама капля сдвигается на новую Y-ячейку либо раз
// в 10 тиков через time_shift. 73856093/19349663 — стандартные
// простые числа для склейки координат в hash-grid (частая практика
// в процедурной генерации, минимизирует коллизии решётки).
let cell_y = drop.y as i32 as u32;
let time_shift = self.tick_count / 10;
let hash = (i as u32)
@@ -706,6 +812,13 @@ impl MatrixEngine {
self.glass_symbols.retain(|s| s.life > 0.0);
}
// --- Доступ к буферам для JS (см. doc-комментарий структуры MatrixEngine
// про протокол памяти WASM<->JS). Каждый буфер — пара ptr()/len(): ptr()
// отдаёт сырой указатель в линейную память WASM, len() — актуальную длину
// в элементах f32 (не в байтах и не в количестве "объектов"). instance_buffer
// физически может быть больше, чем нужно рендерить в этом кадре (ёмкость
// растёт, но не всегда используется целиком), поэтому render_len() возвращает
// не capacity буфера, а именно visible_drops * STRIDE.
pub fn render_ptr(&self) -> *const f32 {
self.instance_buffer.as_ptr()
}