Un shader de ondas en los azules de marca, sobre la imagen de la portada. Es WebGL a pelo: un fondo así es un triángulo y un programa de fragmentos, y traerse una biblioteca 3D para eso sería medio mega de dependencia en la primera pantalla del sitio. El dibujo es domain warping —ruido que desplaza el dominio de otro ruido—, con la escala horizontal mayor que la vertical para que las formas se tumben en bandas en vez de quedarse redondas. La paleta va en RGB lineal, que es en lo que mezcla el shader. El primer intento usó los cuatro fondos de `app.css` tal cual y salió una plancha lisa: caben en un pelo de claridad, y sin recorrido de color el ruido no se ve por bien que esté calculado. Con `prefers-reduced-motion` no se monta; se para al salir de pantalla y al cambiar de pestaña; y si no hay WebGL o el programa no compila, debajo queda la imagen de siempre. Solo lo lleva la cabecera: en las tres láminas serían tres shaders pintando lo mismo a la vez. De paso resuelve el tema claro, que no tenía imagen propia: lo pinta en azules pálidos, así que el clip de relleno se va. Las pruebas arrancan Chromium con SwiftShader para poder comprobar el efecto y no solo su ausencia. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
a69bf0479a
commit
ba72469710
@ -0,0 +1,298 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Fondo animado de la portada: ondas lentas de ruido en los azules de marca.
|
||||
*
|
||||
* Es WebGL a pelo, sin biblioteca. Un shader de fondo es un triángulo y un
|
||||
* programa de fragmentos; traerse tres·js para esto sería meter medio mega
|
||||
* de dependencia en la primera pantalla del sitio para dibujar dos
|
||||
* triángulos.
|
||||
*
|
||||
* El dibujo es el clásico *domain warping*: se calcula ruido, se usa ese
|
||||
* ruido para desplazar las coordenadas, y se vuelve a calcular. Dos vueltas
|
||||
* bastan para que las formas dejen de parecer nubes y empiecen a parecer
|
||||
* agua. La escala horizontal es mayor que la vertical, que es lo que las
|
||||
* tumba en bandas en vez de dejarlas redondas.
|
||||
*
|
||||
* Tres cosas que no son negociables, y por las que no vale con soltar un
|
||||
* canvas y olvidarse:
|
||||
*
|
||||
* - **Con `prefers-reduced-motion` no se monta.** Quien pide menos
|
||||
* movimiento no quiere una primera pantalla que ondula sin parar.
|
||||
* - **Se para cuando no se ve**: al salir de pantalla y al cambiar de
|
||||
* pestaña. Un shader a pantalla completa a sesenta cuadros por segundo
|
||||
* detrás de una página por la que ya has bajado es batería tirada.
|
||||
* - **Si algo falla, no pasa nada.** Sin WebGL, sin contexto o con un
|
||||
* programa que no compila, el componente no pinta y debajo sigue la imagen
|
||||
* de siempre. Un fondo decorativo no puede tumbar una portada.
|
||||
*/
|
||||
import { tema } from '$lib/tema.svelte';
|
||||
|
||||
/** Densidad máxima. En una pantalla 4K, pintar cada píxel real no compensa. */
|
||||
const DPR_MAXIMO = 1.5;
|
||||
|
||||
let lienzo = $state<HTMLCanvasElement | null>(null);
|
||||
let activo = $state(false);
|
||||
|
||||
/*
|
||||
* La paleta sale de los colores de marca, convertida a RGB lineal: el shader
|
||||
* mezcla en lineal, y hacerlo con los valores de pantalla ensucia los tonos
|
||||
* medios.
|
||||
*
|
||||
* Va del marino del fondo a un azul de acero claramente más claro. El primer
|
||||
* intento usó los cuatro fondos de `app.css`, que caben en un pelo de
|
||||
* claridad, y el resultado era una plancha lisa: por bien que calcule el
|
||||
* ruido, sin recorrido de color no se ve nada.
|
||||
*/
|
||||
const PALETAS = {
|
||||
oscuro: [
|
||||
[0.0012, 0.0031, 0.0115],
|
||||
[0.0056, 0.0132, 0.0411],
|
||||
[0.02, 0.0465, 0.1212],
|
||||
[0.0636, 0.1453, 0.3199]
|
||||
],
|
||||
claro: [
|
||||
[0.8849, 0.9147, 0.9744],
|
||||
[0.6679, 0.7334, 0.8648],
|
||||
[0.4187, 0.52, 0.7112],
|
||||
[0.1813, 0.2974, 0.5092]
|
||||
]
|
||||
} as const;
|
||||
|
||||
const VERTICES = `#version 300 es
|
||||
in vec2 posicion;
|
||||
void main() {
|
||||
gl_Position = vec4(posicion, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FRAGMENTOS = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 u_resolucion;
|
||||
uniform float u_tiempo;
|
||||
uniform vec3 u_paleta[4];
|
||||
|
||||
out vec4 color;
|
||||
|
||||
/* Ruido de valor: barato y de sobra para un fondo. */
|
||||
float aleatorio(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float ruido(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
/* Suavizado de Hermite: sin esto se ven las casillas de la rejilla. */
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(aleatorio(i), aleatorio(i + vec2(1.0, 0.0)), u.x),
|
||||
mix(aleatorio(i + vec2(0.0, 1.0)), aleatorio(i + vec2(1.0, 1.0)), u.x),
|
||||
u.y
|
||||
);
|
||||
}
|
||||
|
||||
/* Cinco octavas: más no se distinguen y cuestan igual. */
|
||||
float fbm(vec2 p) {
|
||||
float valor = 0.0;
|
||||
float amplitud = 0.5;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
valor += amplitud * ruido(p);
|
||||
p *= 2.02;
|
||||
amplitud *= 0.5;
|
||||
}
|
||||
return valor;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / u_resolucion;
|
||||
/* Se corrige la proporción para que las ondas no se estiren en pantallas anchas. */
|
||||
vec2 p = uv * vec2(u_resolucion.x / u_resolucion.y, 1.0);
|
||||
|
||||
/* Las ondas son anchas y bajas: mucha escala en X, poca en Y. */
|
||||
p *= vec2(1.1, 2.6);
|
||||
|
||||
float t = u_tiempo * 0.035;
|
||||
|
||||
/* Primera vuelta: hacia dónde se desplaza el dominio. */
|
||||
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, 1.3 - t)));
|
||||
|
||||
/* Segunda: el desplazamiento del desplazamiento. Aquí aparece el agua. */
|
||||
vec2 r = vec2(
|
||||
fbm(p + 3.0 * q + vec2(1.7, 9.2) + 0.15 * t),
|
||||
fbm(p + 3.0 * q + vec2(8.3, 2.8) - 0.12 * t)
|
||||
);
|
||||
|
||||
float n = fbm(p + 2.2 * r);
|
||||
n = clamp(n * 1.35 - 0.12, 0.0, 1.0);
|
||||
|
||||
/* Cuatro paradas de color, encadenadas. */
|
||||
vec3 c = mix(u_paleta[0], u_paleta[1], smoothstep(0.0, 0.42, n));
|
||||
c = mix(c, u_paleta[2], smoothstep(0.38, 0.68, n));
|
||||
c = mix(c, u_paleta[3], smoothstep(0.62, 0.95, n));
|
||||
|
||||
/* Las crestas se marcan un poco, para que se lean como ondas y no como niebla. */
|
||||
float cresta = smoothstep(0.55, 0.9, length(r));
|
||||
c += u_paleta[3] * cresta * 0.18;
|
||||
|
||||
/* De lineal a pantalla. */
|
||||
color = vec4(pow(c, vec3(1.0 / 2.2)), 1.0);
|
||||
}`;
|
||||
|
||||
function compilar(gl: WebGL2RenderingContext, tipo: number, fuente: string) {
|
||||
const sombreador = gl.createShader(tipo);
|
||||
if (!sombreador) return null;
|
||||
gl.shaderSource(sombreador, fuente);
|
||||
gl.compileShader(sombreador);
|
||||
if (!gl.getShaderParameter(sombreador, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(sombreador);
|
||||
return null;
|
||||
}
|
||||
return sombreador;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!lienzo) return;
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const gl = lienzo.getContext('webgl2', {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
// Un fondo no justifica encender la tarjeta dedicada de un portátil.
|
||||
powerPreference: 'low-power'
|
||||
});
|
||||
if (!gl) return;
|
||||
|
||||
const vs = compilar(gl, gl.VERTEX_SHADER, VERTICES);
|
||||
const fs = compilar(gl, gl.FRAGMENT_SHADER, FRAGMENTOS);
|
||||
if (!vs || !fs) return;
|
||||
|
||||
const programa = gl.createProgram();
|
||||
gl.attachShader(programa, vs);
|
||||
gl.attachShader(programa, fs);
|
||||
gl.linkProgram(programa);
|
||||
if (!gl.getProgramParameter(programa, gl.LINK_STATUS)) return;
|
||||
gl.useProgram(programa);
|
||||
|
||||
/*
|
||||
* Un solo triángulo que se sale de la pantalla, no dos que forman un
|
||||
* cuadrado: se pinta lo mismo con la mitad de vértices y sin la costura
|
||||
* de la diagonal, donde algunas tarjetas repiten fragmentos.
|
||||
*/
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
const atributo = gl.getAttribLocation(programa, 'posicion');
|
||||
gl.enableVertexAttribArray(atributo);
|
||||
gl.vertexAttribPointer(atributo, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const uResolucion = gl.getUniformLocation(programa, 'u_resolucion');
|
||||
const uTiempo = gl.getUniformLocation(programa, 'u_tiempo');
|
||||
const uPaleta = gl.getUniformLocation(programa, 'u_paleta');
|
||||
|
||||
const paleta = PALETAS[tema.actual] ?? PALETAS.oscuro;
|
||||
gl.uniform3fv(uPaleta, new Float32Array(paleta.flat()));
|
||||
|
||||
function medir() {
|
||||
if (!lienzo || !gl) return;
|
||||
const dpr = Math.min(devicePixelRatio || 1, DPR_MAXIMO);
|
||||
const ancho = Math.max(1, Math.round(lienzo.clientWidth * dpr));
|
||||
const alto = Math.max(1, Math.round(lienzo.clientHeight * dpr));
|
||||
if (lienzo.width === ancho && lienzo.height === alto) return;
|
||||
lienzo.width = ancho;
|
||||
lienzo.height = alto;
|
||||
gl.viewport(0, 0, ancho, alto);
|
||||
gl.uniform2f(uResolucion, ancho, alto);
|
||||
}
|
||||
|
||||
let cuadro = 0;
|
||||
let visible = true;
|
||||
/*
|
||||
* El tiempo se acumula solo mientras se dibuja. Si se contara desde el
|
||||
* principio, al volver de otra pestaña las ondas darían un salto.
|
||||
*/
|
||||
let transcurrido = 0;
|
||||
let anterior = 0;
|
||||
|
||||
function pintar(ahora: number) {
|
||||
cuadro = requestAnimationFrame(pintar);
|
||||
if (!gl) return;
|
||||
|
||||
transcurrido += Math.min((ahora - anterior) / 1000, 0.1);
|
||||
anterior = ahora;
|
||||
|
||||
medir();
|
||||
gl.uniform1f(uTiempo, transcurrido);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
function arrancar() {
|
||||
if (cuadro || !visible || document.hidden) return;
|
||||
anterior = performance.now();
|
||||
cuadro = requestAnimationFrame(pintar);
|
||||
}
|
||||
|
||||
function parar() {
|
||||
cancelAnimationFrame(cuadro);
|
||||
cuadro = 0;
|
||||
}
|
||||
|
||||
const observador = new IntersectionObserver(([entrada]) => {
|
||||
visible = entrada.isIntersecting;
|
||||
if (visible) arrancar();
|
||||
else parar();
|
||||
});
|
||||
observador.observe(lienzo);
|
||||
|
||||
function alCambiarDePestana() {
|
||||
if (document.hidden) parar();
|
||||
else arrancar();
|
||||
}
|
||||
document.addEventListener('visibilitychange', alCambiarDePestana);
|
||||
|
||||
medir();
|
||||
activo = true;
|
||||
arrancar();
|
||||
|
||||
return () => {
|
||||
parar();
|
||||
observador.disconnect();
|
||||
document.removeEventListener('visibilitychange', alCambiarDePestana);
|
||||
activo = false;
|
||||
gl.deleteProgram(programa);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
gl.deleteBuffer(buffer);
|
||||
// Libera el contexto en vez de esperar al recolector: los contextos de
|
||||
// WebGL están limitados por pestaña y no se sueltan solos.
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Se pinta siempre, pero solo se ve cuando el shader ha arrancado de verdad.
|
||||
Así, si no hay WebGL o el programa no compila, debajo queda la imagen y no un
|
||||
rectángulo vacío.
|
||||
-->
|
||||
<canvas bind:this={lienzo} class:activo aria-hidden="true"></canvas>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
/* Entra suave: aparecer de golpe al hidratar se ve como un parpadeo. */
|
||||
transition: opacity 0.8s var(--suave);
|
||||
}
|
||||
|
||||
canvas.activo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
canvas {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue