Files

209 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
}
}