Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e568ac76b8 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "netrunner-landing-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Shield, EyeOff } from "lucide-react";
|
||||
import { Dictionary } from "@/lib/IDict";
|
||||
import { CyberButton } from "../ui/cyber-button";
|
||||
import { NetrunnerMatrix } from "../../matrix-engine/NetrunnerMatrix"; // Импорт из пакета
|
||||
import { GlassShatter } from "../../matrix-engine/GlassShatter";
|
||||
import { useTheme } from "next-themes"; // <-- ДОБАВЛЕНО
|
||||
|
||||
export function Hero({
|
||||
@@ -40,6 +41,11 @@ export function Hero({
|
||||
движок матрицы (matrix-engine/) инициализируется один раз и сам не
|
||||
реагирует на смену пропсов лучше, чем через пересоздание инстанса */}
|
||||
<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-x-0 top-0 h-64 bg-gradient-to-b from-background via-transparent to-transparent opacity-90 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"description": "Private node network with flow multiplexing, providing ultimate anonymity and stable connection."
|
||||
"title": "Own Infrastructure",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
},
|
||||
{
|
||||
"title": "Multi-Platform Core",
|
||||
"description": "Низкоуровневое сетевое ядро на Rust гарантирует нулевой оверхед на iOS, Android, Windows и macOS."
|
||||
"description": "Низкоуровневое сетевое ядро на Rust гарантирует нулевой оверхед на Android, Windows и Linux. Поддержка macOS и iOS в разработке."
|
||||
},
|
||||
{
|
||||
"title": "Global Network",
|
||||
"description": "Собственная сеть узлов по всему миру с мультиплексированием потоков для обеспечения максимальной анонимности."
|
||||
"title": "Собственная инфраструктура",
|
||||
"description": "Мы используем собственные серверы, а не арендуем их у облачных провайдеров — с мультиплексированием потоков между узлами для стабильного соединения, которое сложно занести в чёрный список."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||
// Версия движка, подставляется query-параметром при загрузке воркера и WASM
|
||||
// модуля (?v=...) — простой cache-busting при выкладке новой сборки
|
||||
// matrix-engine, чтобы браузер/CDN не отдавали закэшированный старый файл.
|
||||
const ENGINE_VERSION = "3.0.4";
|
||||
const ENGINE_VERSION = "3.1.4";
|
||||
|
||||
export interface NetrunnerMatrixProps {
|
||||
isSecure?: boolean;
|
||||
|
||||
@@ -126,3 +126,31 @@ wasm-pack build --target web --release
|
||||
В 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-слой позади канваса был бы просто не виден
|
||||
— сцена должна рисоваться внутри того же фреймбуфера, ДО капель, чтобы
|
||||
дождь (уже блендящийся поверх фона) частично её перекрывал, а не скрывал
|
||||
целиком. Минималистичный синтвейв: силуэты города, светящийся горизонт,
|
||||
дорога с перспективой к точке схода и машины, едущие в противоположных
|
||||
полосах (эффект двустороннего движения). Цвет акцента синхронизирован с
|
||||
палитрой капель/глаз (циан выкл / фиолет вкл).
|
||||
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` ударов стекло осыпается и через паузу собирается
|
||||
заново — эффект зациклен.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
vsGlitch,
|
||||
fsMatrix,
|
||||
fsGlitch,
|
||||
fsBackground,
|
||||
postProcessSource,
|
||||
} from "./shaders.js";
|
||||
import { compileShader, createProgram } from "./gl-utils.js";
|
||||
@@ -47,11 +48,12 @@ let mouseX = 0;
|
||||
let mouseY = 0;
|
||||
let uMouseLoc;
|
||||
|
||||
let postProgram, matrixProgram, glitchProgram;
|
||||
let postProgram, matrixProgram, glitchProgram, bgProgram;
|
||||
let postFramebuffer, postTexture;
|
||||
let instanceBuffer, quadBuffer, overlayQuadBuffer, glitchInstanceBuffer;
|
||||
let vaoMatrix, vaoOverlay, vaoGlitch;
|
||||
let vaoMatrix, vaoOverlay, vaoGlitch, vaoBg;
|
||||
let uResLoc, uFontLoc, uAtlasSizeLoc;
|
||||
let uBgTimeLoc, uBgResLoc, uBgDarkLoc, uBgVpnLoc;
|
||||
let instanceBufferCapacity = 0;
|
||||
|
||||
let uPostEyePosLLoc,
|
||||
@@ -80,6 +82,7 @@ function initWebGL() {
|
||||
matrixProgram = createProgram(gl, vsMatrix, fsMatrix);
|
||||
postProgram = createProgram(gl, vsOverlay, postProcessSource);
|
||||
glitchProgram = createProgram(gl, vsGlitch, fsGlitch);
|
||||
bgProgram = createProgram(gl, vsOverlay, fsBackground);
|
||||
|
||||
quadBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
|
||||
@@ -155,6 +158,17 @@ function initWebGL() {
|
||||
gl.enableVertexAttribArray(aPosLocOver);
|
||||
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);
|
||||
|
||||
uResLoc = gl.getUniformLocation(matrixProgram, "u_resolution");
|
||||
@@ -162,6 +176,11 @@ function initWebGL() {
|
||||
uAtlasSizeLoc = gl.getUniformLocation(matrixProgram, "u_atlasSize");
|
||||
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();
|
||||
|
||||
uShockwaveLoc = gl.getUniformLocation(postProgram, "u_shockwave");
|
||||
@@ -320,6 +339,19 @@ function render(currentTime) {
|
||||
gl.clearColor(bgR, bgG, bgB, 1.0);
|
||||
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();
|
||||
if (renderLen > 0) {
|
||||
const instanceCount = renderLen / 5;
|
||||
|
||||
@@ -169,6 +169,194 @@ void main() {
|
||||
else { discard; }
|
||||
}`.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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
msw: false
|
||||
sharp: false
|
||||
unrs-resolver: false
|
||||
Reference in New Issue
Block a user