Compare commits

..

1 Commits

Author SHA1 Message Date
nineap e568ac76b8 matrix update 2026-07-04 22:40:12 +07:00
10 changed files with 616 additions and 9 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "netrunner-landing-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+6
View File
@@ -7,6 +7,7 @@ import { Shield, EyeOff } from "lucide-react";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { CyberButton } from "../ui/cyber-button"; import { CyberButton } from "../ui/cyber-button";
import { NetrunnerMatrix } from "../../matrix-engine/NetrunnerMatrix"; // Импорт из пакета import { NetrunnerMatrix } from "../../matrix-engine/NetrunnerMatrix"; // Импорт из пакета
import { GlassShatter } from "../../matrix-engine/GlassShatter";
import { useTheme } from "next-themes"; // <-- ДОБАВЛЕНО import { useTheme } from "next-themes"; // <-- ДОБАВЛЕНО
export function Hero({ export function Hero({
@@ -40,6 +41,11 @@ export function Hero({
движок матрицы (matrix-engine/) инициализируется один раз и сам не движок матрицы (matrix-engine/) инициализируется один раз и сам не
реагирует на смену пропсов лучше, чем через пересоздание инстанса */} реагирует на смену пропсов лучше, чем через пересоздание инстанса */}
<NetrunnerMatrix isSecure={isVpnOn} isDarkMode={isDark} key={lang} /> <NetrunnerMatrix isSecure={isVpnOn} isDarkMode={isDark} key={lang} />
{/* Стекло поверх матрицы, показывается только когда глаза закрыты
(isVpnOn = true) — бей кликом/тапом, трещины растут от удара,
после нескольких ударов стекло осыпается и через паузу собирается
заново. См. matrix-engine/GlassShatter.tsx. */}
<GlassShatter active={isVpnOn} />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_20%,_var(--background)_120%)] opacity-80 pointer-events-none" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_20%,_var(--background)_120%)] opacity-80 pointer-events-none" />
<div className="absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-background via-transparent to-transparent opacity-90 pointer-events-none" /> <div className="absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-background via-transparent to-transparent opacity-90 pointer-events-none" />
</div> </div>
+3 -3
View File
@@ -39,11 +39,11 @@
}, },
{ {
"title": "Multi-Platform Core", "title": "Multi-Platform Core",
"description": "Low-level Rust core ensures flawless compatibility and zero overhead across all major OS." "description": "Low-level Rust core ensures flawless compatibility and zero overhead on Android, Windows, and Linux. macOS and iOS support are in development."
}, },
{ {
"title": "Global Network", "title": "Own Infrastructure",
"description": "Private node network with flow multiplexing, providing ultimate anonymity and stable connection." "description": "We run our own servers instead of renting from major cloud providers, with flow multiplexing across nodes for a stable connection that's hard to blacklist."
} }
] ]
}, },
+3 -3
View File
@@ -39,11 +39,11 @@
}, },
{ {
"title": "Multi-Platform Core", "title": "Multi-Platform Core",
"description": "Низкоуровневое сетевое ядро на Rust гарантирует нулевой оверхед на iOS, Android, Windows и macOS." "description": "Низкоуровневое сетевое ядро на Rust гарантирует нулевой оверхед на Android, Windows и Linux. Поддержка macOS и iOS в разработке."
}, },
{ {
"title": "Global Network", "title": "Собственная инфраструктура",
"description": "Собственная сеть узлов по всему миру с мультиплексированием потоков для обеспечения максимальной анонимности." "description": "Мы используем собственные серверы, а не арендуем их у облачных провайдеров — с мультиплексированием потоков между узлами для стабильного соединения, которое сложно занести в чёрный список."
} }
] ]
}, },
+338
View File
@@ -0,0 +1,338 @@
"use client";
import React, { useEffect, useRef } from "react";
import { createProgram } from "./gl-utils.js";
/**
* Стеклянный оверлей поверх матрицы, показывается только когда `active`
* (состояние "глаза закрыты" — см. isVpnOn в Hero.tsx). Бить можно кликом/тапом
* в любом месте панели: каждый удар добавляет трещины, растущие из точки
* удара; после нескольких ударов стекло осыпается и через паузу собирается
* заново целым — эффект зациклен, стекло можно бить сколько угодно раз.
*
* Не связан с WASM-движком матрицы: работает на main thread (один
* полноэкранный quad, без инстансинга — GPU-нагрузка пренебрежимо мала),
* это отдельный DOM-канвас, наложенный поверх канваса матрицы обычным
* порядком отрисовки браузера (в отличие от фонового слоя глубины,
* встроенного внутрь matrix_worker.js — см. комментарий у fsBackground в
* shaders.js: слою СВЕРХУ не нужно бороться с alpha:false соседнего канваса,
* это ограничение работает только "сквозь" элемент, а не поверх него).
*/
const vs = `#version 300 es
in vec2 a_position;
out vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`.trim();
const fs = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 outColor;
uniform float u_time;
uniform vec2 u_resolution;
uniform vec3 u_impacts[6]; // xy - точка удара (uv, y вверх), z - момент удара (сек), <=0 - слот пуст
uniform float u_shardDisplace; // 0 = стекло целое, 1 = осколки полностью разлетелись/исчезли
uniform float u_paneAlpha; // общая непрозрачность панели (fade-in при пересборке)
vec2 hash2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453123);
}
float hash1(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453123); }
void main() {
vec2 uv = v_uv;
vec2 aspect = vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0);
// Едва заметный диагональный блик — стекло видно даже до первого удара.
float sheen = smoothstep(0.35, 0.0, abs(fract(uv.x + uv.y - u_time * 0.04) - 0.5));
vec3 col = vec3(1.0) * sheen * 0.035;
float alpha = sheen * 0.05 + 0.015;
// --- Ячейки Вороного = осколки стекла ---
vec2 cellSize = vec2(0.085);
vec2 gridUv = uv / cellSize;
vec2 gridId = floor(gridUv);
vec2 gridF = fract(gridUv);
float minDist1 = 8.0, minDist2 = 8.0;
vec2 cellSeed1 = vec2(0.0);
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 cell = gridId + neighbor;
vec2 point = neighbor + hash2(cell) - gridF;
float d = dot(point, point);
if (d < minDist1) {
minDist2 = minDist1;
minDist1 = d;
cellSeed1 = cell;
} else if (d < minDist2) {
minDist2 = d;
}
}
}
float cellEdge = sqrt(minDist2) - sqrt(minDist1);
// --- Влияние ударов: трещины растут кругом от каждой точки удара во времени ---
float influence = 0.0;
for (int i = 0; i < 6; i++) {
vec3 imp = u_impacts[i];
if (imp.z <= 0.0) continue;
float age = u_time - imp.z;
if (age < 0.0) continue;
// Трещина быстро "выстреливает" до локального радиуса и на нём
// застывает — один удар даёт компактное пятно, а не весь экран;
// полное покрытие достигается только накоплением НЕСКОЛЬКИХ ударов.
float radius = 0.15 * (1.0 - exp(-age * 8.0));
float d = length((uv - imp.xy) * aspect);
influence = max(influence, smoothstep(radius, radius - 0.06, d));
}
float crack = smoothstep(0.05, 0.0, cellEdge) * influence;
vec3 crackColor = vec3(0.85, 0.95, 1.0);
col = mix(col, crackColor, crack * 0.8);
alpha = max(alpha, crack * 0.55);
// Едва заметная "мозаика" осколков внутри уже треснувшей зоны.
col += (hash1(cellSeed1) - 0.5) * 0.06 * influence;
alpha += influence * 0.02;
// --- Разлёт осколков при полном разбитии ---
if (u_shardDisplace > 0.001) {
float fall = u_shardDisplace * u_shardDisplace;
alpha *= max(0.0, 1.0 - fall);
col += crackColor * fall * 0.3 * step(0.001, crack + influence * 0.3);
}
outColor = vec4(col, clamp(alpha, 0.0, 1.0) * u_paneAlpha);
}`.trim();
const MAX_IMPACTS = 6;
const SHATTER_AFTER_HITS = 6;
const BREAK_DURATION = 0.9;
const REFORM_DURATION = 0.7;
export interface GlassShatterProps {
active?: boolean;
}
export const GlassShatter: React.FC<GlassShatterProps> = ({
active = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
const glRef = useRef<WebGL2RenderingContext | null>(null);
const startTimeRef = useRef(0);
// Пул точек удара (как Shockwave-пул в lib.rs): t0 <= 0 значит слот свободен.
const impactsRef = useRef(
Array.from({ length: MAX_IMPACTS }, () => ({ x: 0, y: 0, t0: 0 })),
);
const hitCountRef = useRef(0);
const phaseRef = useRef<"idle" | "breaking" | "reforming">("idle");
const phaseStartRef = useRef(0);
useEffect(() => {
activeRef.current = active;
if (active) {
// Каждое новое появление стекла (переход в состояние "глаза закрыты") —
// свежая целая панель, а не продолжение прошлого разбития.
hitCountRef.current = 0;
phaseRef.current = "idle";
impactsRef.current.forEach((imp) => (imp.t0 = 0));
}
}, [active]);
useEffect(() => {
const container = containerRef.current;
if (!container || typeof window === "undefined") return;
const canvas = document.createElement("canvas");
Object.assign(canvas.style, {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
display: "block",
pointerEvents: "none",
transition: "opacity 0.6s ease-in-out",
opacity: "0",
});
container.appendChild(canvas);
const gl = canvas.getContext("webgl2", {
alpha: true,
premultipliedAlpha: false,
antialias: false,
});
if (!gl) return;
glRef.current = gl;
const program = createProgram(gl, vs, fs);
if (!program) return;
gl.useProgram(program);
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const uniforms = {
time: gl.getUniformLocation(program, "u_time"),
res: gl.getUniformLocation(program, "u_resolution"),
impacts: gl.getUniformLocation(program, "u_impacts[0]"),
shardDisplace: gl.getUniformLocation(program, "u_shardDisplace"),
paneAlpha: gl.getUniformLocation(program, "u_paneAlpha"),
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, container.clientWidth * dpr);
canvas.height = Math.max(1, container.clientHeight * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
};
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
startTimeRef.current = performance.now();
let visible = false;
let rafId = 0;
let paused = document.hidden;
const draw = (now: number) => {
rafId = requestAnimationFrame(draw);
if (paused) return;
const t = (now - startTimeRef.current) / 1000;
const isActive = activeRef.current;
if (isActive !== visible) {
visible = isActive;
canvas.style.opacity = visible ? "1" : "0";
}
if (!isActive) return;
const phase = phaseRef.current;
let shardDisplace = 0;
let paneAlpha = 1;
if (phase === "breaking") {
const dt = t - phaseStartRef.current;
shardDisplace = Math.min(1, dt / BREAK_DURATION);
if (shardDisplace >= 1) {
phaseRef.current = "reforming";
phaseStartRef.current = t;
impactsRef.current.forEach((imp) => (imp.t0 = 0));
hitCountRef.current = 0;
}
} else if (phase === "reforming") {
const dt = t - phaseStartRef.current;
paneAlpha = Math.min(1, dt / REFORM_DURATION);
if (dt >= REFORM_DURATION) phaseRef.current = "idle";
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(uniforms.time, t);
gl.uniform2f(uniforms.res, canvas.width, canvas.height);
gl.uniform1f(uniforms.shardDisplace, shardDisplace);
gl.uniform1f(uniforms.paneAlpha, paneAlpha);
const flat = new Float32Array(MAX_IMPACTS * 3);
impactsRef.current.forEach((imp, i) => {
flat[i * 3] = imp.x;
flat[i * 3 + 1] = imp.y;
flat[i * 3 + 2] = imp.t0;
});
gl.uniform3fv(uniforms.impacts, flat);
gl.drawArrays(gl.TRIANGLES, 0, 6);
};
rafId = requestAnimationFrame(draw);
const onVisibilityChange = () => {
paused = document.hidden;
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
if (container.contains(canvas)) container.removeChild(canvas);
glRef.current = null;
};
}, []);
// Клики/тапы по панели — окно, а не сам канвас (у канваса pointer-events:
// none, чтобы не перехватывать переключатель VPN и кнопки, см. тот же
// приём в NetrunnerMatrix.tsx).
useEffect(() => {
const container = containerRef.current;
if (!container || typeof window === "undefined") return;
const registerImpact = (clientX: number, clientY: number) => {
if (!activeRef.current || phaseRef.current !== "idle") return;
const rect = container.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
if (px < 0 || py < 0 || px > rect.width || py > rect.height) return;
const t = (performance.now() - startTimeRef.current) / 1000;
const impacts = impactsRef.current;
let slot = impacts.find((imp) => imp.t0 <= 0);
if (!slot) {
slot = impacts.reduce((oldest, imp) =>
imp.t0 < oldest.t0 ? imp : oldest,
);
}
slot.x = px / rect.width;
slot.y = 1 - py / rect.height;
slot.t0 = t;
hitCountRef.current += 1;
if (hitCountRef.current >= SHATTER_AFTER_HITS) {
phaseRef.current = "breaking";
phaseStartRef.current = t;
}
};
const handlePointerDown = (e: PointerEvent | TouchEvent) => {
if (
e.target instanceof Element &&
e.target.closest("button, a, input, textarea, label")
)
return;
const point = "touches" in e ? e.touches[0] : e;
if (!point) return;
registerImpact(point.clientX, point.clientY);
};
const options = { passive: true };
window.addEventListener("pointerdown", handlePointerDown as EventListener, options);
window.addEventListener("touchstart", handlePointerDown as EventListener, options);
return () => {
window.removeEventListener("pointerdown", handlePointerDown as EventListener);
window.removeEventListener("touchstart", handlePointerDown as EventListener);
};
}, []);
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden pointer-events-none"
></div>
);
};
+1 -1
View File
@@ -5,7 +5,7 @@ import React, { useRef, useEffect, useState, useCallback } from "react";
// Версия движка, подставляется query-параметром при загрузке воркера и WASM // Версия движка, подставляется query-параметром при загрузке воркера и WASM
// модуля (?v=...) — простой cache-busting при выкладке новой сборки // модуля (?v=...) — простой cache-busting при выкладке новой сборки
// matrix-engine, чтобы браузер/CDN не отдавали закэшированный старый файл. // matrix-engine, чтобы браузер/CDN не отдавали закэшированный старый файл.
const ENGINE_VERSION = "3.0.4"; const ENGINE_VERSION = "3.1.4";
export interface NetrunnerMatrixProps { export interface NetrunnerMatrixProps {
isSecure?: boolean; isSecure?: boolean;
+28
View File
@@ -126,3 +126,31 @@ wasm-pack build --target web --release
В Docker-образе аналогичная сборка (`wasm-pack build --target web В Docker-образе аналогичная сборка (`wasm-pack build --target web
--release` + копирование JS-файлов в `public/wasm-matrix/`) выполняется --release` + копирование JS-файлов в `public/wasm-matrix/`) выполняется
отдельным стейджем в корневом `Dockerfile`. отдельным стейджем в корневом `Dockerfile`.
## Дополнительные визуальные слои (депт-сцена и стекло)
Поверх исходной триады "Rust → Worker → WebGL2" добавлены два независимых
визуальных слоя, оба не трогают физику дождя/глаз:
1. **Фон-глубина (город/дорога/машины)**`fsBackground` в `shaders.js`,
подключён в `matrix_worker.js` (`bgProgram`/`vaoBg`). Рисуется ПЕРВЫМ
внутри `postFramebuffer`, до капель дождя, тем же `vsOverlay`-quad'ом,
что и постпроцесс. Это единственный способ дать дождю "глубину сзади":
канвас матрицы создаётся с `alpha:false` и каждый кадр полностью
перезаписывается, поэтому DOM-слой позади канваса был бы просто не виден
— сцена должна рисоваться внутри того же фреймбуфера, ДО капель, чтобы
дождь (уже блендящийся поверх фона) частично её перекрывал, а не скрывал
целиком. Минималистичный синтвейв: силуэты города, светящийся горизонт,
дорога с перспективой к точке схода и машины, едущие в противоположных
полосах (эффект двустороннего движения). Цвет акцента синхронизирован с
палитрой капель/глаз (циан выкл / фиолет вкл).
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` ударов стекло осыпается и через паузу собирается
заново — эффект зациклен.
+34 -2
View File
@@ -10,6 +10,7 @@ import {
vsGlitch, vsGlitch,
fsMatrix, fsMatrix,
fsGlitch, fsGlitch,
fsBackground,
postProcessSource, postProcessSource,
} from "./shaders.js"; } from "./shaders.js";
import { compileShader, createProgram } from "./gl-utils.js"; import { compileShader, createProgram } from "./gl-utils.js";
@@ -47,11 +48,12 @@ let mouseX = 0;
let mouseY = 0; let mouseY = 0;
let uMouseLoc; let uMouseLoc;
let postProgram, matrixProgram, glitchProgram; let postProgram, matrixProgram, glitchProgram, bgProgram;
let postFramebuffer, postTexture; let postFramebuffer, postTexture;
let instanceBuffer, quadBuffer, overlayQuadBuffer, glitchInstanceBuffer; let instanceBuffer, quadBuffer, overlayQuadBuffer, glitchInstanceBuffer;
let vaoMatrix, vaoOverlay, vaoGlitch; let vaoMatrix, vaoOverlay, vaoGlitch, vaoBg;
let uResLoc, uFontLoc, uAtlasSizeLoc; let uResLoc, uFontLoc, uAtlasSizeLoc;
let uBgTimeLoc, uBgResLoc, uBgDarkLoc, uBgVpnLoc;
let instanceBufferCapacity = 0; let instanceBufferCapacity = 0;
let uPostEyePosLLoc, let uPostEyePosLLoc,
@@ -80,6 +82,7 @@ function initWebGL() {
matrixProgram = createProgram(gl, vsMatrix, fsMatrix); matrixProgram = createProgram(gl, vsMatrix, fsMatrix);
postProgram = createProgram(gl, vsOverlay, postProcessSource); postProgram = createProgram(gl, vsOverlay, postProcessSource);
glitchProgram = createProgram(gl, vsGlitch, fsGlitch); glitchProgram = createProgram(gl, vsGlitch, fsGlitch);
bgProgram = createProgram(gl, vsOverlay, fsBackground);
quadBuffer = gl.createBuffer(); quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
@@ -155,6 +158,17 @@ function initWebGL() {
gl.enableVertexAttribArray(aPosLocOver); gl.enableVertexAttribArray(aPosLocOver);
gl.vertexAttribPointer(aPosLocOver, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(aPosLocOver, 2, gl.FLOAT, false, 0, 0);
// Отдельный VAO для фонового прохода (глубина/дорога): та же геометрия
// fullscreen-quad, что и у постпроцесса, но локация атрибута запрашивается
// у bgProgram отдельно — WebGL не гарантирует одинаковые location для
// одноимённого атрибута между разными слинкованными программами.
vaoBg = gl.createVertexArray();
gl.bindVertexArray(vaoBg);
gl.bindBuffer(gl.ARRAY_BUFFER, overlayQuadBuffer);
const aPosLocBg = gl.getAttribLocation(bgProgram, "a_position");
gl.enableVertexAttribArray(aPosLocBg);
gl.vertexAttribPointer(aPosLocBg, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null); gl.bindVertexArray(null);
uResLoc = gl.getUniformLocation(matrixProgram, "u_resolution"); uResLoc = gl.getUniformLocation(matrixProgram, "u_resolution");
@@ -162,6 +176,11 @@ function initWebGL() {
uAtlasSizeLoc = gl.getUniformLocation(matrixProgram, "u_atlasSize"); uAtlasSizeLoc = gl.getUniformLocation(matrixProgram, "u_atlasSize");
uMouseLoc = gl.getUniformLocation(matrixProgram, "u_mouse"); uMouseLoc = gl.getUniformLocation(matrixProgram, "u_mouse");
uBgTimeLoc = gl.getUniformLocation(bgProgram, "u_time");
uBgResLoc = gl.getUniformLocation(bgProgram, "u_resolution");
uBgDarkLoc = gl.getUniformLocation(bgProgram, "u_isDarkMode");
uBgVpnLoc = gl.getUniformLocation(bgProgram, "u_isVpnOn");
atlasTexture = gl.createTexture(); atlasTexture = gl.createTexture();
uShockwaveLoc = gl.getUniformLocation(postProgram, "u_shockwave"); uShockwaveLoc = gl.getUniformLocation(postProgram, "u_shockwave");
@@ -320,6 +339,19 @@ function render(currentTime) {
gl.clearColor(bgR, bgG, bgB, 1.0); gl.clearColor(bgR, bgG, bgB, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
// Фоновая сцена глубины (город/дорога/машины) рисуется ПЕРВОЙ, полностью
// непрозрачной, поверх clear-цвета — капли дождя рисуются следующим шагом
// поверх неё с уже включённым альфа-блендингом (см. initWebGL), так что
// дождь остаётся видуально таким же, но в промежутках между символами
// теперь видна сцена вместо плоского фона.
gl.useProgram(bgProgram);
gl.bindVertexArray(vaoBg);
gl.uniform1f(uBgTimeLoc, now / 1000);
gl.uniform2f(uBgResLoc, width, height);
gl.uniform1f(uBgDarkLoc, isDarkMode ? 1.0 : 0.0);
gl.uniform1f(uBgVpnLoc, isVpnOn ? 1.0 : 0.0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
const renderLen = engine.render_len(); const renderLen = engine.render_len();
if (renderLen > 0) { if (renderLen > 0) {
const instanceCount = renderLen / 5; const instanceCount = renderLen / 5;
+188
View File
@@ -169,6 +169,194 @@ void main() {
else { discard; } else { discard; }
}`.trim(); }`.trim();
/**
* Фоновый проход глубины: рисуется ПЕРВЫМ внутри postFramebuffer, до капель
* дождя (см. render() в matrix_worker.js) — минималистичная синтвейв-сцена
* (небо, силуэты города, дорога с перспективой к линии горизонта и машины,
* едущие в противоположных полосах). Существует отдельным проходом, а не
* как часть постпроцесса, по одной причине: канвас матрицы создаётся с
* `alpha: false` и каждый кадр полностью перезаписывается сверху, поэтому
* любой слой, размещённый в DOM ПОЗАДИ канваса, был бы невидим — единственный
* способ дать дождю "глубину сзади" — нарисовать эту сцену первой внутри того
* же фреймбуфера, чтобы капли (которые уже блендятся поверх фона через
* SRC_ALPHA/ONE_MINUS_SRC_ALPHA) частично её перекрывали, а не полностью
* скрывали.
* Использует vsOverlay (тот же полноэкранный quad, что и постпроцесс).
* Переиспользует vsOverlay — отдельный вершинный шейдер не нужен, оба прохода
* рисуют один и тот же fullscreen-quad в clip space.
*/
export const fsBackground = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 outColor;
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_isDarkMode;
uniform float u_isVpnOn;
float hash11(float p) { return fract(sin(p * 127.1) * 43758.5453123); }
float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
// SDF прямоугольника со скруглёнными углами (для корпуса машины).
float sdRoundBox(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
void main() {
vec2 uv = v_uv; // 0..1, y=0 низ экрана, y=1 верх (см. vsOverlay: v_uv = a_position*0.5+0.5)
bool isLight = u_isDarkMode < 0.5;
// Акцентный цвет синхронизирован с палитрой капель/глаз: циан выкл, фиолет вкл.
vec3 accent = mix(vec3(0.0, 0.85, 1.0), vec3(0.65, 0.15, 1.0), u_isVpnOn);
// Коррекция соотношения сторон (тот же приём, что в vsMatrix/postProcessSource):
// без него все SDF-формы (контуры зданий, кузов и фары машин) искажаются
// при смене aspect ratio экрана — например, машина, нормально выглядящая
// на широком десктопе, на узком мобильном портрете превращается в
// вытянутый вертикально силуэт, потому что uv.x и uv.y покрывают разное
// физическое расстояние на пиксель. Умножаем x-компоненту локальных
// координат на aspect.x перед любым SDF/distance-вычислением — тогда
// 1 условная единица по x и y соответствует одному и тому же физическому
// расстоянию независимо от формы экрана.
vec2 aspect = vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0);
float horizon = 0.46;
vec3 skyTop = isLight ? vec3(0.85, 0.87, 0.93) : vec3(0.03, 0.035, 0.06);
vec3 skyBottom = isLight ? vec3(0.90, 0.88, 0.90) : vec3(0.05, 0.04, 0.07);
vec3 col = mix(skyBottom, skyTop, smoothstep(horizon, 1.0, uv.y));
// --- Силуэты города: 7 процедурных зданий над горизонтом, каркасом (без
// заливки) — контур + редкие внутренние линии этажей/окон, тем же
// акцентным цветом, что и дождь/глаза. Здание не перекрывает небо, а
// "проступает" сквозь него, как голограмма — это заметно приглушённее и
// однороднее со стилем сетки в радужке глаз, чем плоская заливка.
for (int i = 0; i < 7; i++) {
float fi = float(i);
float bx = (fi + 0.5) / 7.0 + (hash11(fi * 3.1) - 0.5) * 0.12;
float bw = 0.045 + hash11(fi * 5.7) * 0.045;
float bh = horizon + 0.03 + hash11(fi * 9.3) * 0.16;
float halfW = bw * 0.5;
float halfH = (bh - horizon) * 0.5;
vec2 p = vec2(uv.x - bx, uv.y - (horizon + bh) * 0.5);
float lineW = 0.0022;
if (abs(p.x) < halfW + lineW * 3.0 && abs(p.y) < halfH + lineW * 3.0) {
// Всё, что ниже, — в изотропных (aspect-corrected) координатах:
// halfWA — реальная физическая полуширина в тех же единицах, что halfH.
float halfWA = halfW * aspect.x;
vec2 pA = vec2(p.x * aspect.x, p.y);
float d = max(abs(pA.x) - halfWA, abs(pA.y) - halfH);
float outline = smoothstep(lineW * 2.5, 0.0, abs(d));
float inside = step(d, 0.0);
float cols = 3.0 + floor(hash11(fi * 4.1) * 3.0);
float cellW = (2.0 * halfWA) / cols;
float colDist = (0.5 - abs(fract((pA.x + halfWA) / cellW) - 0.5)) * cellW;
float vLine = smoothstep(lineW * 2.0, 0.0, colDist) * inside;
float rows = 2.0 + floor(hash11(fi * 6.7) * 2.0);
float cellH = (2.0 * halfH) / rows;
float rowDist = (0.5 - abs(fract((pA.y + halfH) / cellH) - 0.5)) * cellH;
float hLine = smoothstep(lineW * 2.0, 0.0, rowDist) * inside;
float lineMask = max(outline, max(vLine, hLine) * 0.4);
// Дальние/невысокие здания чуть тусклее — лёгкая атмосферная перспектива.
float depthFade = mix(0.5, 1.0, smoothstep(horizon, horizon + 0.22, bh));
col += accent * lineMask * 0.4 * depthFade;
}
}
// Светящаяся полоса на линии горизонта — приглушена, чтобы не спорить с дождём.
col += accent * exp(-abs(uv.y - horizon) * 60.0) * 0.28;
if (uv.y < horizon) {
// --- Дорога: перспектива к точке схода на линии горизонта ---
float t = clamp(1.0 - uv.y / horizon, 0.0, 1.0);
float roadHalf = mix(0.006, 0.40, pow(t, 1.6));
float distFromCenter = abs(uv.x - 0.5);
// Асфальт — ровно цвет фона (без отдельного тёмного/синтвейв оттенка),
// с мелким зернистым шумом акцентным цветом поверх — асфальтная
// "крошка", а не гладкая заливка. Шум считается в пиксельных ячейках
// экрана, а не в uv, чтобы зерно было мелким и стабильным независимо
// от масштаба перспективы дороги.
vec3 asphalt = skyBottom;
float grain = hash21(floor(uv * u_resolution)) * 2.0 - 1.0;
asphalt += accent * grain * 0.035;
if (distFromCenter < roadHalf) {
col = asphalt;
float edgeWidth = mix(0.0015, 0.012, t);
float edge = smoothstep(edgeWidth, 0.0, roadHalf - distFromCenter);
col = mix(col, accent, edge * 0.55);
// Прерывистая осевая линия, "бегущая" на зрителя.
float laneW = mix(0.0008, 0.006, t);
if (distFromCenter < laneW && fract(t * 9.0 - u_time * 0.6) < 0.5) {
col = mix(col, accent, 0.6 * smoothstep(0.0, 0.08, t));
}
}
// --- Машины: чётные слоты едут НА зрителя по левой полосе, нечётные —
// ОТ зрителя по правой полосе (эффект двустороннего движения).
for (int i = 0; i < 6; i++) {
float fi = float(i);
bool approaching = mod(fi, 2.0) < 0.5;
float speed = 0.12 + hash11(fi * 7.0) * 0.05;
float seed = hash11(fi * 13.0) * 10.0;
float tc = fract(u_time * speed + seed);
if (!approaching) tc = 1.0 - tc;
float carRoadHalf = mix(0.006, 0.40, pow(tc, 1.6));
float cx = 0.5 + (approaching ? -0.45 : 0.45) * carRoadHalf;
float cy = horizon * (1.0 - tc);
float carW = mix(0.004, 0.028, tc);
// Стилизованный силуэт вместо однородного цветного пятна: тёмный
// корпус (кузов + более узкая "кабина" сверху) с двумя отдельными
// огоньками у нижних углов — фары (белые) едущим на зрителя,
// стоп-сигналы (красные) едущим от зрителя. Даёт узнаваемый
// "машинный" силуэт даже на маленьком размере, а не просто блик.
// carWA — та же полуширина, но в изотропных координатах (см. aspect
// выше): все формы машины строятся в этих единицах, поэтому кузов
// и фары остаются одной и той же формы на любом соотношении сторон.
float fade = smoothstep(0.0, 0.06, tc);
vec2 p = uv - vec2(cx, cy);
vec2 pA = vec2(p.x * aspect.x, p.y);
float carWA = carW * aspect.x;
float bodyD = sdRoundBox(pA, vec2(carWA, carWA * 0.55 * 0.55), carWA * 0.3);
float cabinD = sdRoundBox(pA - vec2(0.0, carWA * 0.55 * 0.32), vec2(carWA * 0.6, carWA * 0.55 * 0.4), carWA * 0.22);
float carD = min(bodyD, cabinD);
vec3 bodyCol = mix(skyBottom * 0.5, accent, 0.12);
if (carD < 0.0) col = mix(col, bodyCol, fade);
// Тонкий контурный блик кузова — та же "wireframe"-стилистика, что у зданий.
float edgeLine = smoothstep(carWA * 0.12, 0.0, abs(carD)) * fade;
col = mix(col, accent, edgeLine * 0.5);
// Огоньки: два маленьких кружка у нижних углов корпуса.
vec3 lightCol = approaching ? vec3(0.95, 0.98, 1.0) : vec3(1.0, 0.15, 0.2);
float lightR = carWA * 0.22;
vec2 lightOff = vec2(carWA * 0.62, -carWA * 0.55 * 0.4);
float dl = length(pA - vec2(-lightOff.x, lightOff.y)) - lightR;
float dr = length(pA - vec2(lightOff.x, lightOff.y)) - lightR;
float lightD = min(dl, dr);
float lightMask = smoothstep(lightR * 0.4, -lightR * 0.4, lightD) * fade;
col = mix(col, lightCol, lightMask);
// Приглушённый и компактный блум — только вокруг самих огоньков,
// не вокруг всего корпуса.
float glow = exp(-max(lightD, 0.0) / (carWA * 0.35)) * fade * 0.18;
col += lightCol * glow;
}
}
outColor = vec4(col, 1.0);
}
`.trim();
/** /**
* Полноэкранный фрагментный шейдер второго прохода — самый плотный файл * Полноэкранный фрагментный шейдер второго прохода — самый плотный файл
* пайплайна. Читает готовую картинку матрицы+глитчей из postTexture * пайплайна. Читает готовую картинку матрицы+глитчей из postTexture
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
msw: false
sharp: false
unrs-resolver: false