
Gota de aceite
GratisFluidos · three.js
La pieza
Una galaxia espiral de gran diseño que gira despacio en el vacío. Cada estrella recorre su propia elipse alrededor del núcleo y los brazos aparecen solos donde esas órbitas se agolpan, así que el centro gira más rápido que el borde sin que la espiral se deshaga. Las nebulosas rosas solo se encienden al cruzar un brazo, las bandas de polvo oscurecen su borde interior y el núcleo cálido se sobreexpone con un halo suave. Se pinta en HDR con bloom y mapeo de tonos cinematográfico. Al cargar se inclina hasta unos 58°, y el cursor, el dedo o las flechas la reorientan con suavidad; si nadie la toca, deriva sola. Trae tres paletas conmutables (violeta, ámbar y glaciar). Encaja como cabecera de estudios creativos, productos tecnológicos, eventos o portfolios, o como fondo sin la interfaz.
Encaja bien en webs de
Antes de copiar
Cambia lo que quieras y copia: el prompt sale ya con el nombre, los colores y los textos de tu cliente. Lo que dejes en blanco se queda como en la demo.
Gratis y entero
Qué trae el prompt, por apartados
Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.
# Recrea en un único archivo HTML: Galaxia espiral
Una galaxia espiral de gran diseño con unas 150.000 estrellas que gira despacio en el vacío, con núcleo cálido sobreexpuesto, brazos azulados salpicados de nebulosas rosas y bandas de polvo oscuro; el cursor la inclina como si la sostuvieras en la mano.
Eres un desarrollador front-end creativo senior. Genera un único `index.html` autocontenido que
reproduzca exactamente la pieza descrita: mismo layout, textos, colores, movimiento e interacción.
HTML, CSS y JS en un solo archivo, sin build ni frameworks. Librerías solo por CDN y en las versiones
indicadas. Todos los valores numéricos de este documento son definitivos: úsalos tal cual.
Responde solo con el archivo completo.
## Qué es
Una escena 3D a sangre (100 % del ancho y `100svh` de alto) sobre fondo casi negro `#05040b`. En el centro, una galaxia de dos brazos vista con unos 58° de inclinación respecto a la vista cenital, ligeramente girada en pantalla (−0,2 rad de balanceo) para que la composición sea diagonal. No es una landing: solo hay tres detalles de interfaz, discretos, en las esquinas (nombre de la galaxia arriba a la izquierda, pista de interacción abajo a la izquierda y un selector de tres paletas abajo a la derecha).
La técnica es propia y hay que respetarla, porque es lo que hace que la galaxia se vea «de verdad» y no como un remolino de partículas:
1. **Teoría de ondas de densidad con elipses anidadas.** Cada estrella recorre una elipse centrada en el núcleo. El semieje mayor `a` es el radio de la estrella; el cociente entre ejes `q = b/a` depende del radio (órbitas casi circulares en el centro, más excéntricas a media distancia). Cada elipse está girada un ángulo que crece linealmente con el radio: `θ = θ0 + giro·a + patrón·t`. Al girar las elipses unas respecto a otras, sus extremos se agolpan formando dos brazos espirales **sin colocar ninguna estrella en un brazo a mano**.
2. **Las estrellas se mueven de verdad.** Cada estrella avanza por su elipse con velocidad angular de curva de rotación plana `ω(a) = vel / √(a² + núcleo²)`: el centro gira rápido y el borde despacio, pero el dibujo de los brazos se mantiene porque lo forman las elipses, no las estrellas. Todo el movimiento se calcula en el vertex shader: la CPU solo sube los datos una vez.
3. **Los brazos se iluminan al pasar.** El brillo de cada estrella se multiplica por `mix(mínimo, 1, |cos t|^potencia)`, siendo `t` su posición en la elipse: vale 1 en los extremos del eje mayor (donde está el brazo) y cae fuera. Las estrellas jóvenes (disco), las nebulosas (regiones) y el polvo usan potencias distintas, así que las nebulosas rosas solo «se encienden» dentro del brazo y se apagan al salir, como la formación estelar real.
4. **Render HDR + bloom + ACES.** Se pinta en un framebuffer de coma flotante (RGBA16F), con mezcla aditiva para la luz y mezcla multiplicativa para el polvo; luego bloom en 4 niveles, mapeo de tonos ACES, viñeta y fondo en mezcla «trama».
5. **Bruma a baja resolución.** La luz difusa del disco (16.000 manchas grandes) no se pinta en la escena, sino directamente en el tercer nivel del bloom (1/8 de resolución) antes de desenfocarlo: sale un resplandor liso sin grumos. El polvo se vuelve a pintar ahí para que también apague la bruma.
Capas (todas son `gl.POINTS` con el mismo programa, cambiando un entero `uCapa`):
| Capa | uCapa | Nº escritorio / móvil | Qué es |
|---|---|---|---|
| cielo | 5 | 2.600 / 2.600 | estrellas de fondo en una esfera de radio 90, con centelleo |
| disco | 0 | 120.000 / 90.000 | estrellas del disco, color del núcleo al de los brazos |
| bulbo | 1 | 24.000 / 18.000 | bulbo gaussiano cálido, órbitas circulares |
| polvo | 3 | 15.000 / 11.000 | manchas oscuras en el borde interior de los brazos (θ0 + 0,17) |
| regiones | 2 | 6.500 / 5.000 | nebulosas de formación estelar, rosas, solo visibles en el brazo |
| bruma | 4 | 16.000 / 12.000 | luz difusa, pintada a 1/8 de resolución dentro del bloom |
El contador del subtítulo muestra disco + bulbo + regiones con formato `es-ES` (150.500 en escritorio, 113.000 en móvil).
## Librerías y esqueleto
Sin librerías de JS: WebGL2 a mano (unas 460 líneas en total). Fuentes de Google exactas:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
```
`<html lang="es">`, `<meta charset="utf-8">`, `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`, `<title>Galaxia espiral · Remolino austral</title>`. Todo el JS va en un único `<script>` clásico al final del `<body>`, dentro de una IIFE con `"use strict"`.
## Tokens de diseño
CSS completo (literal):
```css
:root{
--fondo:#05040b;
--nucleo:#ffe2b8;
--brazos:#7f8dff;
--regiones:#ff5fa2;
--tinta:#f4f1ea;
--tinta-suave:rgba(244,241,234,.72);
--linea:rgba(244,241,234,.16);
--sans:"Inter Tight","Helvetica Neue",Arial,sans-serif;
--mono:"JetBrains Mono",ui-monospace,Menlo,monospace;
--ease:cubic-bezier(.22,1,.36,1);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--fondo);color:var(--tinta);overflow:hidden}
body{font-family:var(--mono);-webkit-font-smoothing:antialiased;transition:background-color .8s var(--ease)}
.escena{position:fixed;inset:0;height:100svh;width:100%;outline:none;touch-action:pan-y;cursor:crosshair}
.escena:focus-visible{box-shadow:inset 0 0 0 2px var(--tinta-suave)}
canvas{display:block;width:100%;height:100%}
.respaldo{position:absolute;inset:0;display:none;background:
radial-gradient(ellipse 9% 6% at 50% 50%,var(--nucleo) 0%,transparent 100%),
radial-gradient(ellipse 22% 13% at 50% 50%,color-mix(in srgb,var(--nucleo) 55%,transparent) 0%,transparent 100%),
conic-gradient(from 20deg at 50% 50%,transparent 0 8%,color-mix(in srgb,var(--brazos) 40%,transparent) 14%,transparent 24% 58%,color-mix(in srgb,var(--brazos) 40%,transparent) 64%,transparent 74%);
-webkit-mask:radial-gradient(ellipse 46% 26% at 50% 50%,#000 30%,transparent 100%);mask:radial-gradient(ellipse 46% 26% at 50% 50%,#000 30%,transparent 100%);filter:blur(6px)}
.sin-webgl .respaldo{display:block}
.sin-webgl canvas{display:none}
.hud{position:fixed;z-index:2;pointer-events:none}
.titulo{top:clamp(20px,4vw,40px);left:clamp(16px,4vw,44px)}
.titulo h1{font-family:var(--sans);font-weight:600;font-size:clamp(26px,3.2vw,40px);line-height:1;letter-spacing:-.03em}
.titulo p{margin-top:10px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave)}
.pista{bottom:clamp(20px,4vw,36px);left:clamp(16px,4vw,44px);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--tinta-suave);display:flex;align-items:center;gap:10px}
.pista::before{content:"";width:22px;height:1px;background:var(--tinta-suave)}
.paletas{bottom:clamp(16px,4vw,30px);right:clamp(16px,4vw,44px);pointer-events:auto;display:flex;gap:4px;padding:4px;border:1px solid var(--linea);border-radius:999px;background:rgba(5,4,11,.42);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
.paletas button{font:500 11px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-suave);background:none;border:0;border-radius:999px;padding:9px 12px 9px 10px;display:flex;align-items:center;gap:8px;cursor:pointer;transition:color .3s,background-color .3s}
.paletas button:hover{color:var(--tinta)}
.paletas button[aria-checked="true"]{color:#0b0a10;background:var(--tinta)}
.paletas button:focus-visible{outline:2px solid var(--tinta);outline-offset:2px}
.paletas i{width:10px;height:10px;border-radius:50%;background:var(--c);box-shadow:0 0 0 1px rgba(255,255,255,.25) inset}
.aparece{opacity:0;transform:translateY(8px);animation:aparece 1.2s var(--ease) forwards}
.titulo.aparece{animation-delay:1.1s}.pista.aparece{animation-delay:1.6s}.paletas.aparece{animation-delay:1.8s}
@keyframes aparece{to{opacity:1;transform:none} }
@media (max-width:640px){
.pista{bottom:auto;top:calc(clamp(20px,4vw,40px) + 76px)}
.paletas{left:50%;right:auto;transform:translateX(-50%)}
.paletas.aparece{transform:translate(-50%,8px)}
@keyframes aparece{to{opacity:1;transform:translate(-50%,0)} }
.titulo.aparece,.pista.aparece{animation-name:aparece2}
@keyframes aparece2{to{opacity:1;transform:none} }
.paletas button{padding:9px 10px 9px 9px}
}
@media (prefers-reduced-motion:reduce){
.aparece{animation:none;opacity:1;transform:none}
@media (max-width:640px){.paletas.aparece{transform:translateX(-50%)} }
body{transition:none}
}
```
Paletas (se usan en JS en sRGB; el shader trabaja en lineal con `pow(c, 2.2)`):
| Paleta | fondo | núcleo | brazos | regiones |
|---|---|---|---|---|
| Violeta (por defecto) | `#05040b` | `#ffe2b8` | `#7f8dff` | `#ff5fa2` |
| Ámbar | `#080504` | `#fff0d8` | `#ffab5e` | `#ff5a3c` |
| Glaciar | `#02070b` | `#e8f6ff` | `#56d6ff` | `#a47dff` |
## Estructura y contenido
Marcado literal del `<body>` (el script va a continuación):
```html
<main>
<div class="escena" id="escena" tabindex="0" role="img" aria-label="Galaxia espiral de dos brazos que gira lentamente, con un núcleo cálido, polvo oscuro y nebulosas rosadas. Usa las flechas del teclado para inclinarla.">
<canvas id="lienzo" aria-hidden="true"></canvas>
<div class="respaldo" aria-hidden="true"></div>
</div>
<header class="hud titulo aparece">
<h1>Remolino austral</h1>
<p>Espiral de gran diseño · <span id="cuenta">150.500</span> estrellas</p>
</header>
<p class="hud pista aparece" id="pista">Mueve el cursor para inclinarla</p>
<div class="hud paletas aparece" role="radiogroup" aria-label="Paleta de color">
<button type="button" role="radio" aria-checked="true" data-p="0" style="--c:#7f8dff"><i></i>Violeta</button>
<button type="button" role="radio" aria-checked="false" data-p="1" style="--c:#ffab5e"><i></i>Ámbar</button>
<button type="button" role="radio" aria-checked="false" data-p="2" style="--c:#56d6ff"><i></i>Glaciar</button>
</div>
</main>
```
Textos exactos: título «Remolino austral»; subtítulo «Espiral de gran diseño · 150.500 estrellas» (el número lo reescribe el JS); pista «Mueve el cursor para inclinarla» (en dispositivos táctiles, `pointer: coarse`, se cambia por «Arrastra para inclinarla»); botones «Violeta», «Ámbar», «Glaciar».
## Movimiento
| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Galaxia (cabeceo) | ángulo desde la vista cenital | 25 % → 100 % de 1,02 rad (0,9 en retrato) | 3,4 s | `1 − (1 − x)^4` | 0 | carga |
| Galaxia (distancia) | distancia de cámara | ×1,35 → ×1 | 3,4 s | igual | 0 | carga |
| Galaxia (escala) | radio en el plano | ×0,72 → ×1 | 3,4 s | igual | 0 | carga |
| Estrellas | encendido | de dentro afuera: `clamp(intro·13 − a, 0, 1)` | 3,4 s | igual | 0 | carga |
| Estrellas | órbita | `t = fase + ω(a)·tiempo`, `ω = 0,26/√(a² + 1,3²)` rad/s | continuo | lineal | — | siempre |
| Patrón espiral | giro | 0,018 rad/s | continuo | lineal | — | siempre |
| Cámara | giro alrededor del eje | 0,6 + 0,012·tiempo rad | continuo | lineal | — | siempre |
| Cielo | centelleo | `0,75 + 0,25·sin(t·(0,6 + 2·semilla) + 40·semilla)` | continuo | — | — | siempre |
| Inclinación | cabeceo / giro / balanceo | objetivo ±0,2 / ±0,32 / ∓0,07 rad | amortiguado | `1 − e^(−2,6·dt)` | — | puntero, teclado o deriva |
| Deriva automática | objetivo x, y | `sin(0,11·t)·0,45`, `sin(0,083·t + 1,3)·0,4` | continuo | — | 2,5 s sin puntero | sin puntero |
| Paleta | colores | actual → nueva | amortiguado | `1 − e^(−3·dt)` | — | clic en paleta o teclas 1–3 |
| Título | opacidad, translateY | 0, 8 px → 1, 0 | 1,2 s | `cubic-bezier(.22,1,.36,1)` | 1,1 s | carga |
| Pista | igual | igual | 1,2 s | igual | 1,6 s | carga |
| Paletas | igual | igual | 1,2 s | igual | 1,8 s | carga |
El avance de la intro va por reloj (`performance.now()` desde el primer fotograma), no por fotogramas; `dt` se limita a 0,05 s.
## Código clave
El script completo, literal. Contiene los datos procedurales (semilla `20260930`, generador mulberry32), los seis shaders, la cadena de render y la interacción. No lo simplifiques: los números están ajustados a ojo con capturas.
```js
(() => {
"use strict";
// ───────── Paletas (sRGB) ─────────
const PALETAS = [
{ fondo:"#05040b", nucleo:"#ffe2b8", brazos:"#7f8dff", regiones:"#ff5fa2" },
{ fondo:"#080504", nucleo:"#fff0d8", brazos:"#ffab5e", regiones:"#ff5a3c" },
{ fondo:"#02070b", nucleo:"#e8f6ff", brazos:"#56d6ff", regiones:"#a47dff" }
];
// ───────── Parámetros de la galaxia ─────────
const G = {
radio: 9.5, // radio del disco (unidades de mundo)
giro: -0.3, // torsión de las elipses (rad por unidad de radio): brazos que se arrastran
vel: 0.26, // velocidad orbital (curva de rotación plana)
nucleoC: 1.3, // radio del núcleo de la curva de rotación
patron: 0.018, // velocidad del patrón espiral (rad/s)
fov: 38
};
const escena = document.getElementById("escena");
const canvas = document.getElementById("lienzo");
const reducido = matchMedia("(prefers-reduced-motion: reduce)").matches;
const tactil = matchMedia("(pointer: coarse)").matches;
const movil = tactil || Math.min(innerWidth, innerHeight) < 600;
if (tactil) document.getElementById("pista").textContent = "Arrastra para inclinarla";
const gl = canvas.getContext("webgl2", { antialias:false, alpha:false, depth:false, powerPreference:"high-performance" });
if (!gl) { document.documentElement.classList.add("sin-webgl"); initBotones(null); return; }
const hdr = !!gl.getExtension("EXT_color_buffer_float");
const FMT = hdr ? [gl.RGBA16F, gl.RGBA, gl.HALF_FLOAT] : [gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE];
// ───────── Azar con semilla ─────────
let s = 20260930;
const rnd = () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const gauss = () => Math.sqrt(-2 * Math.log(1 - rnd())) * Math.cos(6.2831853 * rnd());
const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
const expo = (h, max) => -h * Math.log(1 - rnd() * (1 - Math.exp(-max / h)));
// Excentricidad de las órbitas según el radio: circular en el centro, máxima a media distancia
const excentr = a => 0.42 * smooth(0.7, 2.8, a) * (1 - 0.45 * smooth(5.0, 10.0, a));
// Cada punto: [a, q (b/a), theta0, fase, z, tamaño, semilla, brillo]
function capa(n, fn) { const d = new Float32Array(n * 8); for (let i = 0; i < n; i++) fn(d, i * 8); return d; }
const N = { disco: movil ? 90000 : 120000, bulbo: movil ? 18000 : 24000, regiones: movil ? 5000 : 6500, polvo: movil ? 11000 : 15000, bruma: movil ? 12000 : 16000, cielo: 2600 };
const datos = {
bruma: capa(N.bruma, (d, o) => { const a = Math.sqrt(expo(3.2, 9.8) ** 2 + 0.4); d.set([a, 1 - excentr(a) * 0.9, gauss() * 0.12, rnd() * 6.2832, gauss() * 0.2, 0.3 + rnd() * 0.25, rnd(), 1], o); }),
disco: capa(N.disco, (d, o) => {
const a = Math.sqrt(expo(3.3, G.radio) ** 2 + 0.35 ** 2);
const q = 1 - excentr(a) + gauss() * 0.025;
const z = gauss() * (0.07 + 0.02 * a);
const r = rnd(); const tam = r > 0.985 ? 0.06 + rnd() * 0.03 : 0.016 + Math.pow(rnd(), 3) * 0.03;
d.set([a, q, gauss() * 0.07, rnd() * 6.2832, z, tam, rnd(), 0.35 + Math.pow(rnd(), 2.5) * 1.3], o);
}),
bulbo: capa(N.bulbo, (d, o) => {
const x = gauss() * 0.75, y = gauss() * 0.75, a = Math.hypot(x, y) + 0.02;
d.set([a, 1, 0, Math.atan2(y, x), gauss() * 0.5 * Math.exp(-a * 0.35), 0.018 + rnd() * 0.02, rnd(), 0.5 + rnd() * 0.8], o);
}),
polvo: capa(N.polvo, (d, o) => { const a = 1.1 + expo(3.2, 7.6); d.set([a, 1 - excentr(a), 0.17 + gauss() * 0.06, rnd() * 6.2832, gauss() * 0.05, 0.3 + rnd() * 0.3, rnd(), 1], o); }),
regiones: capa(N.regiones, (d, o) => { const a = 2.2 + 7 * Math.pow(rnd(), 0.75); d.set([a, 1 - excentr(a), gauss() * 0.04, rnd() * 6.2832, gauss() * 0.06, 0.026 + rnd() * 0.04, rnd(), 0.6 + rnd()], o); }),
cielo: capa(N.cielo, (d, o) => {
const u = rnd() * 2 - 1, f = rnd() * 6.2832, k = Math.sqrt(1 - u * u);
d.set([k * Math.cos(f), k * Math.sin(f), u, 0, 0, 0.5 + Math.pow(rnd(), 4) * 1.6, rnd(), 0.25 + Math.pow(rnd(), 3)], o);
})
};
document.getElementById("cuenta").textContent = (N.disco + N.bulbo + N.regiones).toLocaleString("es-ES");
// ───────── Shaders ─────────
const VS_ORB = `#version 300 es
precision highp float;
layout(location=0) in vec4 aOrb; // a, q, theta0, fase
layout(location=1) in vec4 aVar; // z, tamaño, semilla, brillo
uniform mat4 uView, uProj;
uniform float uTime, uGiro, uVel, uNucleo, uPatron, uPx, uIntro, uTam, uGan, uBrazoPot, uBrazoMin;
uniform int uCapa; // 0 disco · 1 bulbo · 2 regiones · 3 polvo · 4 bruma · 5 cielo
uniform vec3 uCNucleo, uCBrazos, uCRegiones;
out vec3 vCol; out float vA;
void main(){
vec3 p; float brazo = 1.0; float a = aOrb.x;
if (uCapa == 5) {
p = aOrb.xyz * 90.0;
} else {
float w = uVel / sqrt(a*a + uNucleo*uNucleo); // velocidad angular
float t = aOrb.w + w * uTime; // posición en la elipse
float th = aOrb.z + uGiro * a + uPatron * uTime; // orientación de la elipse
vec2 e = vec2(a * cos(t), a * aOrb.y * sin(t));
float c = cos(th), s = sin(th);
p = vec3(c*e.x - s*e.y, s*e.x + c*e.y, aVar.x);
brazo = mix(uBrazoMin, 1.0, pow(abs(cos(t)), uBrazoPot)); // apocentro = brazo
p.xy *= mix(0.72, 1.0, uIntro);
}
vec4 mv = uView * vec4(p, 1.0);
gl_Position = uProj * mv;
float px = uCapa == 5 ? aVar.y * uTam : aVar.y * uTam * uPx / max(-mv.z, 0.01);
float amp = 1.0;
if (uCapa == 3 || uCapa == 4) { float mx = uPx * 0.017; if (px > mx) { if (uCapa == 4) amp = px * px / (mx * mx); px = mx; } } // sprites grandes: se limitan y se reparte la luz
float sub = clamp(px, 0.0, 1.0); // bajo 1 px: se apaga en vez de parpadear
gl_PointSize = clamp(px, 1.0, 110.0);
float enc = uCapa == 5 ? uIntro : clamp(uIntro * 13.0 - a, 0.0, 1.0); // se enciende de dentro afuera
vec3 col;
float sem = aVar.z;
if (uCapa == 0) {
col = mix(uCNucleo, uCBrazos, smoothstep(1.0, 5.5, a));
if (sem < 0.12) col = mix(col, vec3(1.0), 0.55);
else if (sem > 0.94) col = mix(col, uCRegiones, 0.6);
} else if (uCapa == 1) col = uCNucleo * mix(1.35, 0.9, clamp(a / 2.2, 0.0, 1.0));
else if (uCapa == 2) col = mix(uCRegiones, vec3(1.0), sem * 0.25);
else if (uCapa == 4) col = mix(uCNucleo, uCBrazos, smoothstep(0.6, 6.0, a)) * mix(0.35, 1.0, smoothstep(9.5, 4.0, a)) * mix(0.35, 1.0, smoothstep(0.4, 2.8, a));
else if (uCapa == 5) col = mix(vec3(0.85, 0.9, 1.0), uCBrazos, sem * 0.4) * (0.75 + 0.25 * sin(uTime * (0.6 + sem * 2.0) + sem * 40.0));
else col = vec3(1.0);
vCol = col * aVar.w * uGan * brazo * sub * enc * amp;
vA = aVar.w * uGan * brazo * enc * amp;
}`;
const FS_ORB = `#version 300 es
precision highp float;
in vec3 vCol; in float vA;
uniform highp int uCapa;
out vec4 o;
void main(){
vec2 q = gl_PointCoord * 2.0 - 1.0; float r2 = dot(q, q);
if (r2 > 1.0) discard;
if (uCapa == 3) { float g = (1.0 - r2); o = vec4(0.0, 0.0, 0.0, g * g * vA); return; }
float g = uCapa == 4 ? (1.0 - r2) * (1.0 - r2) * (1.0 - r2) : exp(-r2 * 5.0) + 0.08 * (1.0 - r2);
o = vec4(vCol * g, 1.0);
}`;
const VS_Q = `#version 300 es
out vec2 vUv;
void main(){ vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); vUv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`;
const FS_DOWN = `#version 300 es
precision highp float;
in vec2 vUv; uniform sampler2D uTex; uniform vec2 uTexel; uniform float uUmbral; out vec4 o;
void main(){
vec3 c = (texture(uTex, vUv + uTexel * vec2(-1.0,-1.0)).rgb + texture(uTex, vUv + uTexel * vec2(1.0,-1.0)).rgb
+ texture(uTex, vUv + uTexel * vec2(-1.0, 1.0)).rgb + texture(uTex, vUv + uTexel * vec2(1.0, 1.0)).rgb) * 0.25;
if (uUmbral > 0.0) { float l = max(c.r, max(c.g, c.b)); c *= max(l - uUmbral, 0.0) / max(l, 1e-4); }
o = vec4(c, 1.0);
}`;
const FS_BLUR = `#version 300 es
precision highp float;
in vec2 vUv; uniform sampler2D uTex; uniform vec2 uDir; out vec4 o;
void main(){
vec3 c = texture(uTex, vUv).rgb * 0.2270270;
c += (texture(uTex, vUv + uDir * 1.3846154).rgb + texture(uTex, vUv - uDir * 1.3846154).rgb) * 0.3162162;
c += (texture(uTex, vUv + uDir * 3.2307692).rgb + texture(uTex, vUv - uDir * 3.2307692).rgb) * 0.0702703;
o = vec4(c, 1.0);
}`;
const FS_FINAL = `#version 300 es
precision highp float;
in vec2 vUv;
uniform sampler2D uEsc, uB1, uB2, uB3, uB4;
uniform vec3 uFondo; uniform float uExp, uBloom, uTime; uniform vec2 uRes;
out vec4 o;
vec3 aces(vec3 x){ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }
void main(){
vec3 c = texture(uEsc, vUv).rgb;
vec3 b = texture(uB1, vUv).rgb * 0.45 + texture(uB2, vUv).rgb * 0.6 + texture(uB3, vUv).rgb * 0.8 + texture(uB4, vUv).rgb * 1.1;
c = pow(aces((c + b * uBloom) * uExp), vec3(1.0 / 2.2));
vec2 d = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0);
float vig = 1.0 - smoothstep(0.25, 1.15, length(d));
vec3 fondo = uFondo * (0.55 + 0.75 * vig);
c = 1.0 - (1.0 - c) * (1.0 - fondo);
c *= mix(0.78, 1.0, vig);
float n = fract(sin(dot(gl_FragCoord.xy + fract(uTime) * 91.0, vec2(12.9898, 78.233))) * 43758.5453);
o = vec4(c + (n - 0.5) / 255.0, 1.0);
}`;
function prog(vs, fs) {
const p = gl.createProgram();
for (const [t, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
const sh = gl.createShader(t); gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
gl.attachShader(p, sh);
}
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const nm = gl.getActiveUniform(p, i).name; u[nm] = gl.getUniformLocation(p, nm); }
return { p, u };
}
let P;
try {
P = { orb: prog(VS_ORB, FS_ORB), down: prog(VS_Q, FS_DOWN), blur: prog(VS_Q, FS_BLUR), fin: prog(VS_Q, FS_FINAL) };
} catch (e) { console.warn("WebGL: " + e.message); document.documentElement.classList.add("sin-webgl"); initBotones(null); return; }
const vaos = {};
for (const k in datos) {
const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, datos[k], gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 32, 0);
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 32, 16);
vaos[k] = { vao, n: datos[k].length / 8 };
}
gl.bindVertexArray(null);
const vaoVacio = gl.createVertexArray();
// ───────── Destinos de render ─────────
function destino(w, h) {
const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, FMT[0], w, h, 0, FMT[1], FMT[2], null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const f = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, f);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
return { t, f, w, h };
}
let W = 1, H = 1, esc = null, niveles = [];
function liberar(d) { gl.deleteTexture(d.t); gl.deleteFramebuffer(d.f); }
function redimensionar() {
const dpr = Math.min(devicePixelRatio || 1, movil ? 1.5 : 2);
const w = Math.max(2, Math.round(escena.clientWidth * dpr)), h = Math.max(2, Math.round(escena.clientHeight * dpr));
if (w === W && h === H && esc) return;
W = w; H = h; canvas.width = W; canvas.height = H;
if (esc) { liberar(esc); niveles.forEach(l => { liberar(l.a); liberar(l.b); }); }
esc = destino(W, H); niveles = [];
let lw = W, lh = H;
for (let i = 0; i < 4; i++) { lw = Math.max(1, lw >> 1); lh = Math.max(1, lh >> 1); niveles.push({ a: destino(lw, lh), b: destino(lw, lh) }); }
if (reducido) pintar();
}
// ───────── Matrices (columna mayor) ─────────
const mul = (a, b) => { const o = new Float32Array(16); for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) { let v = 0; for (let k = 0; k < 4; k++) v += a[k * 4 + j] * b[i * 4 + k]; o[i * 4 + j] = v; } return o; };
const rotX = t => { const c = Math.cos(t), s = Math.sin(t); return new Float32Array([1,0,0,0, 0,c,s,0, 0,-s,c,0, 0,0,0,1]); };
const rotZ = t => { const c = Math.cos(t), s = Math.sin(t); return new Float32Array([c,s,0,0, -s,c,0,0, 0,0,1,0, 0,0,0,1]); };
const tras = (x, y, z) => new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1]);
const persp = (fy, as, n, f) => { const k = 1 / Math.tan(fy / 2); return new Float32Array([k/as,0,0,0, 0,k,0,0, 0,0,(f+n)/(n-f),-1, 0,0,2*f*n/(n-f),0]); };
const hex = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255);
const lin = c => c.map(v => Math.pow(v, 2.2));
// ───────── Estado ─────────
const col = { fondo: hex(PALETAS[0].fondo), nucleo: lin(hex(PALETAS[0].nucleo)), brazos: lin(hex(PALETAS[0].brazos)), regiones: lin(hex(PALETAS[0].regiones)) };
let objetivo = PALETAS[0];
const cam = { cab: 0, inc: 0, bal: 0 }; // cabeceo, inclinación lateral, balanceo (suavizados)
const punt = { x: 0, y: 0, activo: false, ultimo: -1e9 };
const teclas = { x: 0, y: 0 };
let tiempo = 0, intro = reducido ? 1 : 0, reloj = 0;
function pintar() {
const retrato = W / H < 0.9;
const eIn = 1 - Math.pow(1 - intro, 4);
const cabeceo = (retrato ? 0.9 : 1.02) * (0.25 + 0.75 * eIn) + cam.cab; // ≈58° desde la vista cenital
const fy = G.fov * Math.PI / 180, hH = Math.tan(fy / 2), hW = hH * W / H;
const dW = G.radio / (hW * (retrato ? 1.5 : 0.94));
const dH = (G.radio * Math.cos(cabeceo) + 1.6) / (hH * (retrato ? 1.25 : 0.8));
const dist = Math.max(dW, dH) * (1 + 0.35 * (1 - eIn));
const vista = mul(mul(mul(tras(0, retrato ? 0.3 : 0.35, -dist), rotZ(-0.2 + cam.bal)), rotX(-cabeceo)), rotZ(0.6 + tiempo * 0.012 + cam.inc));
const proy = persp(fy, W / H, 0.1, 400);
const px = H / (2 * hH);
const comp = Math.min(1.6, dist / 21); // tamaño aparente constante
gl.bindFramebuffer(gl.FRAMEBUFFER, esc.f); gl.viewport(0, 0, W, H);
gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.BLEND);
const o = P.orb, u = o.u; gl.useProgram(o.p);
gl.uniformMatrix4fv(u.uView, false, vista); gl.uniformMatrix4fv(u.uProj, false, proy);
gl.uniform1f(u.uTime, tiempo); gl.uniform1f(u.uGiro, G.giro); gl.uniform1f(u.uVel, G.vel); gl.uniform1f(u.uNucleo, G.nucleoC);
gl.uniform1f(u.uPatron, G.patron); gl.uniform1f(u.uPx, px); gl.uniform1f(u.uIntro, eIn);
gl.uniform3fv(u.uCNucleo, col.nucleo); gl.uniform3fv(u.uCBrazos, col.brazos); gl.uniform3fv(u.uCRegiones, col.regiones);
const G2 = hdr ? 1 : 0.6;
// capa, datos, tamaño, ganancia, potencia de brazo, mínimo de brazo, mezcla
const capas = [
[5, "cielo", 1, 0.55, 1, 1, "suma"],
[0, "disco", comp, 2.1, 10, 0.1, "suma"],
[1, "bulbo", comp, 0.17, 1, 1, "suma"],
[3, "polvo", comp, 0.3, 8, 0.1, "resta"],
[2, "regiones", comp, 1.7, 30, 0.0, "suma"]
];
for (const [c, k, tam, gan, bp, bm, mz] of capas) {
if (mz === "suma") gl.blendFunc(gl.ONE, gl.ONE); else gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
gl.uniform1i(u.uCapa, c); gl.uniform1f(u.uTam, c === 5 ? H / 900 : tam);
gl.uniform1f(u.uGan, gan * (mz === "suma" ? G2 : 1)); gl.uniform1f(u.uBrazoPot, bp); gl.uniform1f(u.uBrazoMin, bm);
gl.bindVertexArray(vaos[k].vao); gl.drawArrays(gl.POINTS, 0, vaos[k].n);
}
gl.disable(gl.BLEND);
gl.bindVertexArray(vaoVacio);
// Bloom: bajada con umbral + desenfoque separable en 4 niveles
let src = esc;
niveles.forEach((l, i) => {
gl.useProgram(P.down.p); gl.bindFramebuffer(gl.FRAMEBUFFER, l.a.f); gl.viewport(0, 0, l.a.w, l.a.h);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, src.t); gl.uniform1i(P.down.u.uTex, 0);
gl.uniform2f(P.down.u.uTexel, 1 / src.w, 1 / src.h); gl.uniform1f(P.down.u.uUmbral, i === 0 ? (hdr ? 0.9 : 0.5) : 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (i === 2) {
// Bruma: luz difusa del disco pintada a 1/8 de resolución, antes del desenfoque → resplandor sin grumos
gl.useProgram(o.p); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
gl.uniform1i(u.uCapa, 4); gl.uniform1f(u.uPx, px * l.a.h / H); gl.uniform1f(u.uTam, comp);
gl.uniform1f(u.uGan, 0.05 * G2); gl.uniform1f(u.uBrazoPot, 4); gl.uniform1f(u.uBrazoMin, 0.25);
gl.bindVertexArray(vaos.bruma.vao); gl.drawArrays(gl.POINTS, 0, vaos.bruma.n);
gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA); // el polvo también apaga la bruma
gl.uniform1i(u.uCapa, 3); gl.uniform1f(u.uGan, 0.22); gl.uniform1f(u.uBrazoPot, 8); gl.uniform1f(u.uBrazoMin, 0.1);
gl.bindVertexArray(vaos.polvo.vao); gl.drawArrays(gl.POINTS, 0, vaos.polvo.n);
gl.disable(gl.BLEND); gl.bindVertexArray(vaoVacio);
}
gl.useProgram(P.blur.p); gl.uniform1i(P.blur.u.uTex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, l.b.f); gl.bindTexture(gl.TEXTURE_2D, l.a.t); gl.uniform2f(P.blur.u.uDir, 1 / l.a.w, 0); gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindFramebuffer(gl.FRAMEBUFFER, l.a.f); gl.bindTexture(gl.TEXTURE_2D, l.b.t); gl.uniform2f(P.blur.u.uDir, 0, 1 / l.a.h); gl.drawArrays(gl.TRIANGLES, 0, 3);
src = l.a;
});
// Composición final
const f = P.fin; gl.useProgram(f.p);
gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H);
[esc, ...niveles.map(l => l.a)].forEach((d, i) => { gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, d.t); });
gl.uniform1i(f.u.uEsc, 0); gl.uniform1i(f.u.uB1, 1); gl.uniform1i(f.u.uB2, 2); gl.uniform1i(f.u.uB3, 3); gl.uniform1i(f.u.uB4, 4);
gl.uniform3fv(f.u.uFondo, col.fondo); gl.uniform1f(f.u.uExp, hdr ? 1.0 : 1.3); gl.uniform1f(f.u.uBloom, 0.5);
gl.uniform1f(f.u.uTime, tiempo); gl.uniform2f(f.u.uRes, W, H);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.activeTexture(gl.TEXTURE0);
}
// ───────── Bucle ─────────
let raf = 0, ultimo = 0, visible = true, inicio = 0;
const acercar = (v, o, k, dt) => v + (o - v) * (1 - Math.exp(-k * dt));
function paso(now) {
raf = requestAnimationFrame(paso);
const dt = Math.min(0.05, ultimo ? (now - ultimo) / 1000 : 0.016); ultimo = now;
tiempo += dt; reloj += dt;
if (intro < 1) { if (!inicio) inicio = now; intro = Math.min(1, (now - inicio) / 3400); } // por reloj, no por fotogramas
// Objetivo de inclinación: puntero, teclado o deriva automática
let tx = teclas.x, ty = teclas.y;
if (punt.activo || reloj - punt.ultimo < 2.5) { tx += punt.x; ty += punt.y; }
else { tx += Math.sin(reloj * 0.11) * 0.45; ty += Math.sin(reloj * 0.083 + 1.3) * 0.4; }
cam.cab = acercar(cam.cab, -ty * 0.2, 2.6, dt);
cam.inc = acercar(cam.inc, tx * 0.32, 2.6, dt);
cam.bal = acercar(cam.bal, -tx * 0.07, 2.6, dt);
for (const k of ["nucleo", "brazos", "regiones"]) { const d = lin(hex(objetivo[k])); col[k] = col[k].map((v, i) => acercar(v, d[i], 3, dt)); }
const df = hex(objetivo.fondo); col.fondo = col.fondo.map((v, i) => acercar(v, df[i], 3, dt));
pintar();
}
function arrancar() { if (!raf && !reducido && visible && !document.hidden) { ultimo = 0; raf = requestAnimationFrame(paso); } }
function parar() { cancelAnimationFrame(raf); raf = 0; }
new IntersectionObserver(e => { visible = e[0].isIntersecting; visible ? arrancar() : parar(); }).observe(escena);
document.addEventListener("visibilitychange", () => document.hidden ? parar() : arrancar());
new ResizeObserver(redimensionar).observe(escena);
redimensionar();
if (reducido) { tiempo = 4; pintar(); } else arrancar();
// ───────── Interacción ─────────
const mover = e => {
const r = escena.getBoundingClientRect();
punt.x = ((e.clientX - r.left) / r.width) * 2 - 1; punt.y = ((e.clientY - r.top) / r.height) * 2 - 1;
punt.ultimo = reloj;
};
escena.addEventListener("pointermove", e => { if (e.pointerType === "mouse" || e.buttons) { punt.activo = true; mover(e); } });
escena.addEventListener("pointerdown", e => { punt.activo = true; mover(e); });
const soltar = e => { if (e.pointerType !== "mouse") punt.activo = false; };
escena.addEventListener("pointerup", soltar);
escena.addEventListener("pointercancel", soltar);
escena.addEventListener("pointerleave", () => { punt.activo = false; punt.x = punt.y = 0; });
escena.addEventListener("keydown", e => {
const m = { ArrowLeft: [-0.35, 0], ArrowRight: [0.35, 0], ArrowUp: [0, -0.35], ArrowDown: [0, 0.35] }[e.key];
if (!m) return; e.preventDefault();
teclas.x = Math.max(-1, Math.min(1, teclas.x + m[0])); teclas.y = Math.max(-1, Math.min(1, teclas.y + m[1]));
if (reducido) { cam.cab = -teclas.y * 0.2; cam.inc = teclas.x * 0.32; cam.bal = -teclas.x * 0.07; pintar(); }
});
initBotones(i => {
objetivo = PALETAS[i];
if (reducido) { col.nucleo = lin(hex(objetivo.nucleo)); col.brazos = lin(hex(objetivo.brazos)); col.regiones = lin(hex(objetivo.regiones)); col.fondo = hex(objetivo.fondo); pintar(); }
});
function initBotones(cb) {
const bs = [...document.querySelectorAll(".paletas button")];
const elegir = i => {
bs.forEach((b, j) => { b.setAttribute("aria-checked", String(i === j)); b.tabIndex = i === j ? 0 : -1; });
const p = PALETAS[i], r = document.documentElement.style;
r.setProperty("--fondo", p.fondo); r.setProperty("--nucleo", p.nucleo); r.setProperty("--brazos", p.brazos); r.setProperty("--regiones", p.regiones);
if (cb) cb(i);
};
bs.forEach((b, i) => {
b.tabIndex = i === 0 ? 0 : -1;
b.addEventListener("click", () => elegir(i));
b.addEventListener("keydown", e => {
const d = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!d) return; e.preventDefault(); const n = (i + d + bs.length) % bs.length; elegir(n); bs[n].focus();
});
});
addEventListener("keydown", e => { if (["1", "2", "3"].includes(e.key) && !e.metaKey && !e.ctrlKey) elegir(+e.key - 1); });
}
})();
```
Puntos delicados que no hay que tocar:
- `uniform highp int uCapa;` en el fragment shader: sin `highp` la precisión no coincide con la del vertex shader y el programa no enlaza.
- Estrellas por debajo de 1 px: `gl_PointSize` se fija a 1 y la luz se multiplica por el tamaño real (`sub`), así no parpadean al moverse.
- Polvo y bruma: su tamaño en píxeles se limita a `uPx·0,017`; en la bruma, la luz se multiplica por `(px/máx)²` para conservar la energía. En el polvo no (su alfa debe quedar por debajo de 1).
- La bruma y el polvo se pintan también en el nivel 3 del bloom (índice 2, 1/8 de resolución) con `uPx` escalado a esa resolución; la ganancia de la bruma allí es 0,05 porque cada texel cubre 64 píxeles.
- Sin `EXT_color_buffer_float` se usa RGBA8, ganancias ×0,6, umbral de bloom 0,5 y exposición 1,3.
- `comp = min(1,6, dist/21)` mantiene el tamaño aparente de las estrellas aunque la cámara se aleje (móvil vertical).
## Interacción
- **Ratón**: la posición normalizada (−1…1) es el objetivo de inclinación: horizontal → giro alrededor del eje (±0,32 rad) y balanceo (∓0,07 rad); vertical → cabeceo (∓0,2 rad). Al salir del lienzo vuelve a 0 y, 2,5 s después, entra la deriva automática.
- **Táctil**: arrastrar con el dedo hace lo mismo (`pointermove` con `buttons`). `touch-action: pan-y` deja hacer scroll vertical de la página que contenga la pieza; `pointercancel` suelta el control.
- **Teclado**: el lienzo es focusable (`tabindex="0"`, foco visible con borde interior); las flechas suman ±0,35 al objetivo (limitado a ±1). Las paletas son un `radiogroup` con itinerancia por flechas; las teclas 1, 2 y 3 eligen paleta desde cualquier sitio.
- **Paletas**: al elegir una, se actualizan las variables CSS (`--fondo`, `--nucleo`, `--brazos`, `--regiones`) y los colores del shader se acercan a la nueva paleta en ~1 s.
## Adaptación a móvil y tablet
- Encaje de cámara: `dW = radio / (tan(fovx/2)·k)` y `dH = (radio·cos(cabeceo) + 1,6) / (tan(fovy/2)·k')`, distancia = el mayor. En horizontal `k = 0,94`, `k' = 0,8`; en retrato (ancho/alto < 0,9) `k = 1,5`, `k' = 1,25` (la galaxia desborda un poco los lados, a propósito) y el cabeceo base baja a 0,9 rad. Desplazamiento vertical de cámara: 0,35 en horizontal, 0,3 en retrato.
- `max-width: 640px`: la pista sube bajo el título (top = margen + 76 px) y el selector de paletas se centra abajo.
- Móvil (`pointer: coarse` o lado corto < 600 px): menos puntos (90.000 disco, 18.000 bulbo, 5.000 regiones, 11.000 polvo, 12.000 bruma) y DPR máximo 1,5.
- Sin scroll horizontal de 360 a 1920 px: todo es `position: fixed` dentro del viewport.
## Accesibilidad y movimiento reducido
- El lienzo tiene `role="img"` y un `aria-label` descriptivo; el canvas y el respaldo llevan `aria-hidden="true"`.
- Texto en `rgba(244,241,234,.72)` o más sobre fondo casi negro: contraste AA.
- `prefers-reduced-motion: reduce`: no hay bucle. Se pinta un único fotograma con la intro terminada y `tiempo = 4`; se vuelve a pintar solo al redimensionar, al cambiar de paleta (cambio inmediato) o al inclinar con las flechas. Las entradas CSS se anulan.
## Rendimiento
- DPR máximo 2 (1,5 en móvil). Buffers estáticos: todo el movimiento está en el vertex shader.
- El bucle `requestAnimationFrame` se detiene con `IntersectionObserver` (pieza fuera de pantalla) y con `visibilitychange` (pestaña oculta).
- `ResizeObserver` sobre el contenedor: recrea el framebuffer HDR y los 4 niveles de bloom solo si cambia el tamaño en píxeles.
- **Fallback sin WebGL2** (o si falla un shader): clase `sin-webgl` en `<html>`, se oculta el canvas y se muestra `.respaldo`, una galaxia en CSS (degradados radiales y cónico con los colores de la paleta, máscara elíptica y `blur(6px)`). Las paletas siguen funcionando.
## Personalización
- **Nombre**: `Remolino austral` en el `<h1>` y en el `<title>`.
- **Colores**: la paleta por defecto son `#05040b`, `#ffe2b8`, `#7f8dff` y `#ff5fa2` (en `PALETAS[0]`, en `:root` y en el botón «Violeta»). Para añadir o cambiar paletas, edita el array `PALETAS` y los botones (`data-p` = índice, `--c` = color de muestra).
- **Forma**: `G.giro` (−0,3) abre o cierra los brazos (más negativo = más enrollados); `excentr()` controla cuánto se marcan; `G.vel` la velocidad de giro; `G.patron` la velocidad del dibujo espiral.
- **Densidad**: los contadores de `N`. Por encima de 200.000 puntos, vigila el rendimiento en portátiles.
- **Sin interfaz**: borra `header`, `.pista` y `.paletas` para usarla como fondo; la escena sigue funcionando sola.
## Comprobación final
1. Un solo `index.html`, sin librerías, WebGL2 a mano, cero errores en consola.
2. A los 3,4 s la galaxia ha terminado de inclinarse a ~58° y se ven dos brazos claros, con nebulosas rosas solo dentro de ellos.
3. Bandas de polvo oscuras en el borde interior de los brazos, bien visibles junto al núcleo.
4. El núcleo es cálido y sobreexpuesto, con halo suave (bloom) y sin bordes duros.
5. La luz difusa del disco es lisa, sin manchas redondas visibles.
6. Las estrellas giran (el centro más rápido que el borde) y los brazos no se deshacen con el tiempo.
7. El cursor inclina la galaxia con suavidad; sin puntero, deriva sola a los 2,5 s.
8. Las tres paletas cambian con fundido de ~1 s, también con teclas 1–3 y flechas en el grupo.
9. En 390 × 844 la galaxia ocupa el ancho, la pista va bajo el título y las paletas centradas abajo.
10. Con movimiento reducido hay un único fotograma estático y legible.
11. Sin WebGL2 aparece el respaldo CSS con los mismos colores.
12. El bucle se para fuera de pantalla y con la pestaña oculta.Prueba en frío
Aún sin publicar para esta pieza
En la prueba en frío pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos lo que genera con la demo. Cuando esté hecha, aquí verás la captura, el veredicto y la fecha.
Paso a paso
Personaliza
Copia el Completo o el Lite
Pega e itera
Publica
Licencia en claro
Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.
Puedes
No puedes
Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.
Preguntas frecuentes
Versión 1.0 · publicada el 30-09-2026