Files
site-portfolio/public/js/modules/ui/page-mesh-shader.js
T

159 lines
6.2 KiB
JavaScript
Raw 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)),
];
}
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 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) return;
mount.setUniforms({
u_colors: colorVec4Array(next),
u_colorsCount: next.length,
});
};
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);
// Чистим ресурсы при нормальном уходе со страницы.
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();
});
}
}