209 lines
8.7 KiB
JavaScript
209 lines
8.7 KiB
JavaScript
import { ShaderMount, meshGradientFragmentShader, getShaderColorFromString, defaultObjectSizing, ShaderFitOptions } from "/vendor/paper-shaders/index.js";
|
||
import { prefersReducedMotion } from "../shared/media.js";
|
||
|
||
// Живой mesh-gradient фон главной страницы (Paper Shaders, zero-dep WebGL).
|
||
// Цвета берутся из акцентной темы сайта (--theme-h/s/l), чтобы фон всегда
|
||
// совпадал с выбранным пользователем акцентом.
|
||
|
||
function hsl(h, s, l, a = 1) {
|
||
// getShaderColorFromString понимает "hsl(h, s%, l%)" — собираем ровно в этом виде.
|
||
return `hsl(${Math.round(h)}, ${Math.round(s)}%, ${Math.round(l)}%)`;
|
||
}
|
||
|
||
function paletteFromTheme(root) {
|
||
const cs = getComputedStyle(root);
|
||
const h = parseFloat(cs.getPropertyValue("--theme-h"));
|
||
const s = parseFloat(cs.getPropertyValue("--theme-s"));
|
||
const l = parseFloat(cs.getPropertyValue("--theme-l"));
|
||
if (![h, s, l].every(Number.isFinite)) return null;
|
||
// Монохромная палитра: все цвета — вариации акцента (±18°/±36° hue,
|
||
// без крупных сдвигов), чтобы фон оставался однотонным, не разноцветным.
|
||
const a1 = (h + 36) % 360;
|
||
const a2 = (h + 18) % 360;
|
||
const a3 = (h - 18 + 360) % 360;
|
||
return [
|
||
hsl(a1, s, Math.min(l + 2, 54)),
|
||
hsl(a2, s, Math.min(l, 50)),
|
||
hsl(a3, s, Math.min(l - 2, 46)),
|
||
hsl(h, s, Math.min(l + 6, 58)),
|
||
];
|
||
}
|
||
|
||
// Статичный CSS-fallback фона. Переписывает --page-bg напрямую, чтобы
|
||
// обновляться при смене акцента даже если WebGL отключён (мобильные).
|
||
function setStaticPageBg(root) {
|
||
const h = parseFloat(root.style.getPropertyValue("--theme-h")) || 210;
|
||
const s = parseFloat(root.style.getPropertyValue("--theme-s")) || 90;
|
||
const l = parseFloat(root.style.getPropertyValue("--theme-l")) || 40;
|
||
const isDark = root.dataset.theme === "dark";
|
||
const mixAndStop = isDark ? [[52, 62], [44, 64], [36, 68]] : [[38, 62], [32, 64], [26, 68]];
|
||
const hueShift = isDark ? 180 : 90; // тёмная: accent-square-3/+180, светлая: +90
|
||
const hues = [h + hueShift, h + 90, h + 270].map(x => ((x % 360) + 360) % 360);
|
||
const bg = `color-mix(in srgb, var(--bg) 100%, transparent)`;
|
||
let bgStr = "";
|
||
for (let i = 0; i < 3; i++) {
|
||
const [mix, stop] = mixAndStop[i];
|
||
const color = `hsl(${Math.round(hues[i])} calc(${s}% * 1) calc(${Math.round(l)}% * 1))`;
|
||
bgStr += `radial-gradient(calc(48% * 1) calc(56% * 1) at var(--mesh-a-x) var(--mesh-a-y), color-mix(in srgb, ${color}, ${bg}) ${mix}%, transparent ${stop}%)${i < 2 ? "," : ""}`;
|
||
}
|
||
root.style.setProperty("--page-bg", bgStr);
|
||
}
|
||
|
||
function colorVec4Array(colors) {
|
||
// Шейдер ждёт u_colors как vec4[10] → массив из 10 под-массивов [r,g,b,a].
|
||
// setUniformValues сам flatten'ит массив массивов в uniform4fv.
|
||
const out = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
const c = colors[i % colors.length];
|
||
out.push(getShaderColorFromString(c));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Сигнал готовности фона: проявляем hero (см. .hero в main.css).
|
||
// Ставится во всех ветках инициализации, чтобы hero никогда не висел скрытым.
|
||
function markMeshReady() {
|
||
document.documentElement.classList.add("mesh-bg-ready");
|
||
}
|
||
|
||
let mount = null;
|
||
let onTheme = null;
|
||
let pagehideBound = false;
|
||
let pageshowBound = false;
|
||
|
||
function disposeMount() {
|
||
if (onTheme) {
|
||
window.removeEventListener("site-theme-applied", onTheme);
|
||
onTheme = null;
|
||
}
|
||
if (mount) {
|
||
try { mount.dispose(); } catch { /* noop */ }
|
||
mount = null;
|
||
}
|
||
}
|
||
|
||
function updateCanvasVisibility(root) {
|
||
const host = root.querySelector(".page-mesh-bg canvas");
|
||
if (!host) return;
|
||
const shouldHide = root.dataset.meshBg === "off" || document.body.dataset.scrollHidden === "1";
|
||
host.style.visibility = shouldHide ? "hidden" : "visible";
|
||
}
|
||
|
||
function handleScroll(root) {
|
||
// Шэйдер закрывается, когда прокручено больше 1000px — в дизайне
|
||
// mesh-bg перекрывается фоном секций и всё равно не виден.
|
||
// Это освобождает GPU на весь остальной скролл.
|
||
if (document.body.scrollTop > 1000 || document.documentElement.scrollTop > 1000) {
|
||
root.dataset.scrollHidden = "1";
|
||
} else {
|
||
delete root.dataset.scrollHidden;
|
||
}
|
||
updateCanvasVisibility(root);
|
||
}
|
||
|
||
function buildMount(host, root) {
|
||
const sizing = () => ({
|
||
u_fit: ShaderFitOptions[defaultObjectSizing.fit],
|
||
u_scale: defaultObjectSizing.scale,
|
||
u_rotation: defaultObjectSizing.rotation,
|
||
u_offsetX: defaultObjectSizing.offsetX,
|
||
u_offsetY: defaultObjectSizing.offsetY,
|
||
u_originX: defaultObjectSizing.originX,
|
||
u_originY: defaultObjectSizing.originY,
|
||
u_worldWidth: defaultObjectSizing.worldWidth,
|
||
u_worldHeight: defaultObjectSizing.worldHeight,
|
||
});
|
||
|
||
const base = paletteFromTheme(root) || ["#241d9a", "#f75092", "#9f50d3", "#0e6b8f"];
|
||
|
||
const uniforms = {
|
||
...sizing(),
|
||
u_colors: colorVec4Array(base),
|
||
u_colorsCount: base.length,
|
||
u_distortion: 0.85,
|
||
u_swirl: 0.35,
|
||
u_grainMixer: 0.06,
|
||
u_grainOverlay: 0.04,
|
||
};
|
||
|
||
try {
|
||
mount = new ShaderMount(
|
||
host,
|
||
meshGradientFragmentShader,
|
||
uniforms,
|
||
{ antialias: true, alpha: true, preserveDrawingBuffer: true },
|
||
1.0, // speed
|
||
0, // frame
|
||
1, // minPixelRatio: ровно CSS-пиксели, без 2x апскейла (главный источник нагрузки)
|
||
2_500_000 // maxPixelCount: потолок ~1600x1000, фон размытый — качество не страдает
|
||
);
|
||
// не даём IntersectionObserver ставить паузу вне вьюпорта — фон на весь экран
|
||
try { mount.intersectionObserver?.disconnect(); } catch { /* noop */ }
|
||
|
||
// Первый кадр уже отрисован в конструкторе — отмечаем готовность,
|
||
// чтобы CSS плавно проявил canvas (fade-in) и hero.
|
||
host.classList.add("is-ready");
|
||
markMeshReady();
|
||
} catch (e) {
|
||
console.warn("Mesh shader mount failed, fallback to static bg", e);
|
||
markMeshReady();
|
||
mount = null;
|
||
return;
|
||
}
|
||
|
||
// Обновляем палитру при смене акцента/темы.
|
||
onTheme = () => {
|
||
const next = paletteFromTheme(root);
|
||
if (!next || !mount) {
|
||
setStaticPageBg(root);
|
||
updateCanvasVisibility(root);
|
||
return;
|
||
}
|
||
mount.setUniforms({
|
||
u_colors: colorVec4Array(next),
|
||
u_colorsCount: next.length,
|
||
});
|
||
setStaticPageBg(root);
|
||
updateCanvasVisibility(root);
|
||
};
|
||
window.addEventListener("site-theme-applied", onTheme, { passive: true });
|
||
}
|
||
|
||
export function initPageMeshShader() {
|
||
const host = document.querySelector(".page-mesh-bg");
|
||
if (!host) return markMeshReady(); // нет хоста — hero всё равно проявляем
|
||
if (prefersReducedMotion()) return markMeshReady(); // статичный CSS-fallback, hero сразу виден
|
||
|
||
// Повторный вызов (или восстановление из bfcache): убираем старый canvas/ mount,
|
||
// чтобы не накапливать мёртвые WebGL-контексты.
|
||
disposeMount();
|
||
host.classList.remove("is-ready");
|
||
const stale = host.querySelector("canvas");
|
||
if (stale) stale.remove();
|
||
|
||
buildMount(host, document.documentElement);
|
||
|
||
// Скролл-контроль видимости canvas: при скролле >1000px фон перекрывается
|
||
// секциями и не виден — закрываем canvas, чтобы не тратить GPU.
|
||
window.addEventListener("scroll", () => handleScroll(document.documentElement), { passive: true });
|
||
handleScroll(document.documentElement);
|
||
|
||
// Чистим ресурсы при нормальном уходе со страницы.
|
||
if (!pagehideBound) {
|
||
pagehideBound = true;
|
||
window.addEventListener("pagehide", () => {
|
||
disposeMount();
|
||
});
|
||
}
|
||
|
||
// Браузерная Back/Forward Cache (bfcache): при возврате на страницу модуль
|
||
// main.js НЕ перезапускается, а canvas был удалён в pagehide → фон пустой.
|
||
// pageshow с persisted=true = восстановление из bfcache → пересобираем шэйдер.
|
||
if (!pageshowBound) {
|
||
pageshowBound = true;
|
||
window.addEventListener("pageshow", (e) => {
|
||
if (e.persisted) initPageMeshShader();
|
||
});
|
||
}
|
||
}
|