Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

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.

Prompt
≈11.177 tokens · con Lite
Librerías
ninguna
Demo
27,4 KB · ligero
Prueba en frío
pendiente

Antes de copiar

Tu marca, dentro del prompt

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.

Colores

Sin cambios: se copia con los valores de la demo.

Gratis y entero

El prompt, entero y a la vista

Qué trae el prompt, por apartados

  1. 01Qué es
  2. 02Librerías y esqueleto
  3. 03Tokens de diseño
  4. 04Estructura y contenido
  5. 05Movimiento
  6. 06Código clave
  7. 07Interacción
  8. 08Adaptación a móvil y tablet
  9. 09Accesibilidad y movimiento reducido
  10. 10Rendimiento
  11. 11Personalización
  12. 12Comprobación final
Completo
5.824 palabras · ≈11.177 tokens
Lite
1.054 palabras · ≈1.817 tokens
Librerías
ninguna
Peso de la demo
27,4 KB (ligero)
Resolución máxima (DPR)
2
Se pausa fuera de pantalla
sí
Respeta «menos movimiento»
sí
Alternativa sin WebGL
sí

Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.

prompt.md
# 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

La 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

Cómo usarlo en 4 pasos

  1. 01

    Personaliza

    Rellena el formulario de arriba con el nombre, los colores y los textos de tu cliente. Se meten en el prompt al copiar.
  2. 02

    Copia el Completo o el Lite

    El Completo, para Claude, ChatGPT, Gemini o Cursor. El Lite, más corto, para Lovable, Bolt o v0, que aceptan menos texto.
  3. 03

    Pega e itera

    Pégalo en una conversación nueva. Si quieres cambiar algo, pídelo con una frase corta:
    • «Abre más los brazos (giro -0.22) para una espiral más suelta»
    • «Quita el título y las paletas para usarla de fondo detrás de un titular»
    • «Haz que gire el doble de rápido»
    • «Cambia la paleta por defecto a dorado y verde esmeralda»
  4. 04

    Publica

    Guarda el resultado como HTML (o en tu proyecto) y súbelo a Vercel, Netlify o tu hosting de siempre. Tienes los pasos en la guía.

Guías por herramientaClaudeChatGPTGeminiCursorLovableBoltv0

Licencia en claro

Qué puedes hacer con esta pieza

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

  • Usarlo en tus webs y en las de tus clientes, también si cobras por ellas.
  • Cambiarlo todo lo que quieras.
  • Publicar el resultado sin citarnos.

No puedes

  • Revender o regalar el prompt, la demo o el código como plantilla o biblioteca.
  • Compartir tu cuenta Pro.

Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.

Preguntas frecuentes

Lo que suele preguntarse

¿Puedo poner mi titular encima?
Sí. Borra el nombre, la pista y el selector de paletas y coloca tu texto en una capa fija encima del canvas; la galaxia sigue girando y respondiendo al cursor. Si el texto va centrado, baja la exposición o aleja la cámara un poco para que no compita con el núcleo.
¿Va bien en móvil?
Sí. En móvil usa menos estrellas (unas 113.000), limita la resolución a 1,5× y encuadra la galaxia a lo ancho. En táctil se inclina arrastrando y deja hacer scroll vertical de la página.
¿Necesita alguna librería?
No. Es WebGL2 escrito a mano en un único HTML de unos 28 KB, sin three.js ni dependencias. Sin WebGL2 muestra una galaxia hecha con degradados CSS en los mismos colores.
¿Cómo cambio los colores?
Edita la paleta por defecto (fondo, núcleo, brazos y nebulosas) o sustituye las tres paletas del array PALETAS. Los colores se funden en un segundo al cambiar.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈11177 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈1817 tokens) resume lo esencial para herramientas que aceptan menos texto, como Lovable, Bolt o v0.

Versión 1.0 · publicada el 30-09-2026

Cópialo desde aquí

Tu navegador no nos deja escribir en el portapapeles. El texto ya está seleccionado: pulsa Ctrl + C (o ⌘ + C) o el botón.