Ondas animadas de fondo en la cabecera

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
dev 1 month ago
parent a69bf0479a
commit ba72469710

@ -545,6 +545,43 @@ página de un disco, donde el orden de las pistas es la información.
## El fondo de la portada
Encima de todo, en la cabecera, van **ondas animadas**
([`OndasWebgl`](src/lib/components/OndasWebgl.svelte)): WebGL a pelo, sin
biblioteca. Un shader de fondo es un triángulo y un programa de fragmentos;
traerse three.js para eso sería medio mega de dependencia en la primera pantalla
para dibujar dos triángulos.
El dibujo es _domain warping_: se calcula ruido, se usa 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.
La paleta sale de los colores de marca convertidos a **RGB lineal**, que es en
lo que mezcla el shader. El primer intento usó los cuatro fondos de `app.css`,
que caben en un pelo de claridad, y salió una plancha lisa: por bien que calcule
el ruido, sin recorrido de color no se ve nada.
Cuatro cosas que no son negociables, y por las que no vale con soltar un canvas:
- **Con `prefers-reduced-motion` no se monta.**
- **Se para cuando no se ve**, al salir de pantalla y al cambiar de pestaña. Un
shader a pantalla completa detrás de una página por la que ya has bajado es
batería tirada.
- **Si falla, no pasa nada**: sin WebGL o sin compilar, debajo queda la imagen.
Un fondo decorativo no puede tumbar una portada.
- **Solo lo lleva la cabecera.** En las tres láminas serían tres shaders
peleándose por la tarjeta gráfica para pintar lo mismo.
Las ondas resuelven además el **tema claro**, que no tiene imagen propia: las
pinta en azules pálidos, y el clip de reserva dejó de hacer falta. Donde no
pueden correr, el tema claro se queda con el color de fondo a secas hasta que
exista una imagen suya.
Las pruebas arrancan Chromium con SwiftShader —su rasterizador por software—,
porque si no solo se podría comprobar la ausencia del efecto.
## La imagen del fondo
Una imagen a sangre detrás del tocadiscos y del texto, en
`static/background/`. Se sirve en **tres anchos WebP** y el navegador coge el
que le toca: 10 kB en un móvil, 57 kB en un panel grande. El PNG original de

@ -12,15 +12,15 @@ test.describe('Navegación', () => {
await expect(page).toHaveURL('/musica');
});
test('la cabecera lleva un vídeo de fondo propio, no un incrustado', async ({ page }) => {
// Un iframe de terceros en la primera pantalla pesaría más que el resto
// de la página y dejaría cookies antes de que nadie pida ver nada.
test('el fondo de la cabecera es propio, no un incrustado', async ({ page }) => {
// Lo que se comprueba es la decisión, no la técnica con que esté hecha:
// un iframe de terceros en la primera pantalla pesaría más que el resto de
// la página y dejaría cookies antes de que nadie pida ver nada.
await page.goto('/');
const video = page.locator('section video');
await expect(video).toHaveCount(1);
await expect(video).toHaveJSProperty('muted', true);
await expect(video).toHaveJSProperty('loop', true);
await expect(page.locator('iframe')).toHaveCount(0);
// Y el fondo existe: hoy es un shader propio sobre una imagen propia.
await expect(page.locator('.fondo')).not.toHaveCount(0);
});
test('la cabecera acompaña al desplazamiento', async ({ page }) => {
@ -139,3 +139,58 @@ test.describe('Carrusel de la portada', () => {
await expect(page.getByRole('button', { name: 'Reanudar el paso automático' })).toBeVisible();
});
});
test.describe('Fondo animado de la portada', () => {
test('se pinta y arranca', async ({ page }) => {
await page.goto('/');
// La clase solo se pone cuando el programa ha compilado y está dibujando:
// que exista el canvas no significa que haya salido nada.
await expect(page.locator('canvas.activo')).toHaveCount(1);
});
test('con movimiento reducido no se monta', async ({ page }) => {
// Quien pide menos movimiento no quiere una primera pantalla que ondula.
// En tema oscuro, porque es donde hay imagen debajo que comprobar: en
// claro el fondo lo pinta el propio shader.
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' });
await page.goto('/');
await expect(page.locator('canvas.activo')).toHaveCount(0);
// Y debajo sigue el fondo de siempre, no un hueco.
await expect(page.locator('.fondo img').first()).toBeAttached();
});
test('deja de dibujar cuando ya no se ve', async ({ page }) => {
// Un shader a pantalla completa detrás de una página por la que ya has
// bajado es batería tirada.
await page.goto('/');
await expect(page.locator('canvas.activo')).toHaveCount(1);
await page.evaluate(() => {
(window as unknown as { cuadros: number }).cuadros = 0;
const original = window.requestAnimationFrame;
window.requestAnimationFrame = (cb) =>
original(() => {
(window as unknown as { cuadros: number }).cuadros++;
cb(performance.now());
});
});
await page.evaluate(() => window.scrollTo(0, 5000));
await page.waitForTimeout(600);
const antes = await page.evaluate(() => (window as unknown as { cuadros: number }).cuadros);
await page.waitForTimeout(800);
const despues = await page.evaluate(() => (window as unknown as { cuadros: number }).cuadros);
expect(despues - antes).toBe(0);
});
test('solo lo lleva la cabecera, no las tres láminas', async ({ page }) => {
await page.goto('/');
// Tres shaders a pantalla completa pintando lo mismo a la vez no aportan
// nada y cuestan el triple.
await expect(page.locator('canvas')).toHaveCount(1);
});
});

@ -2,6 +2,16 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'e2e',
/*
* WebGL por software. El Chromium de las pruebas no trae aceleración, y sin
* esto el fondo animado de la portada no se podría comprobar: solo se vería
* su reserva, que es justo lo contrario de lo que hay que probar.
*/
use: {
launchOptions: {
args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader']
}
},
testMatch: '**/*.e2e.{ts,js}',
webServer: {
// El build va aparte, en `npm run test:e2e`: en Windows el empaquetador

@ -14,6 +14,7 @@
* nadie haya pedido ver nada. Y no se arranca con `autoplay` sino desde
* código, para poder respetar `prefers-reduced-motion`.
*/
import OndasWebgl from './OndasWebgl.svelte';
import { heroe } from '$lib/site';
import { tema } from '$lib/tema.svelte';
@ -41,9 +42,23 @@
* color de fondo, que para una llamada a la acción sobra.
*/
clip?: boolean;
/**
* Si encima del fondo van las ondas animadas.
*
* Solo lo pide la cabecera: es la primera pantalla y donde ese movimiento
* dice algo. Repetirlo en las tres láminas serían tres shaders a pantalla
* completa peleándose por la tarjeta gráfica para pintar lo mismo.
*
* Las ondas también resuelven el tema claro, que no tiene imagen propia:
* el shader las pinta en azules pálidos y el clip de reserva dejó de hacer
* falta. Donde no pueden correr —sin WebGL o con movimiento reducido— el
* tema claro se queda con el color de fondo a secas, hasta que exista una
* imagen suya.
*/
ondas?: boolean;
}
let { velo = 'lateral', prioritario = false, clip = false }: Props = $props();
let { velo = 'lateral', prioritario = false, clip = false, ondas = false }: Props = $props();
const fondo = $derived(heroe.fondo[tema.actual]);
/*
@ -101,6 +116,16 @@
></video>
{/if}
<!--
Las ondas van encima de la imagen, no en su lugar: la imagen se pinta al
instante y el shader entra por encima cuando ha arrancado. Si no arranca
—sin WebGL, con movimiento reducido o si el programa no compila— debajo
queda el fondo de siempre y nadie ve un hueco.
-->
{#if ondas}
<OndasWebgl />
{/if}
<span class="velo" class:cerrado={velo === 'cerrado'}></span>
</div>
@ -134,11 +159,11 @@
background:
linear-gradient(
to right,
color-mix(in oklab, var(--c-bg) 86%, transparent) 0%,
color-mix(in oklab, var(--c-bg) 52%, transparent) 45%,
color-mix(in oklab, var(--c-bg) 8%, transparent) 100%
color-mix(in oklab, var(--c-bg) 80%, transparent) 0%,
color-mix(in oklab, var(--c-bg) 40%, transparent) 45%,
transparent 100%
),
linear-gradient(to top, var(--c-bg) 0%, transparent 30%);
linear-gradient(to top, var(--c-bg) 0%, transparent 28%);
}
/*

@ -56,7 +56,7 @@
</script>
<section class="heroe">
<FondoDePortada prioritario clip />
<FondoDePortada prioritario ondas />
<div class="container disposicion">
<div class="texto">

@ -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…
Cancel
Save

Powered by TurnKey Linux.