Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

Más de 8.000 estrellas repartidas en profundidad avanzan despacio hacia el espectador sobre una nebulosa de polvo y gas pintada con ruido fractal. Las cercanas son grandes, con halo y destello en cruz; las lejanas, puntos que titilan. El cursor desplaza la cámara y las capas se separan con paralaje de verdad: las próximas se mueven mucho y la nebulosa apenas. Mantener pulsado acelera el vuelo y abre el campo de visión. Sin puntero, la cámara deriva sola. Trae tres paletas conmutables (índigo, brasa y glaciar), un titular mínimo y un HUD con recorrido y velocidad. Encaja como fondo de un hero, una portada de estudio creativo, la página de un observatorio o de turismo de cielo oscuro, un lanzamiento tecnológico o una sección de manifiesto. WebGL puro, sin librerías y con 24 KB.

Prompt
≈10.939 tokens · con Lite
Librerías
ninguna
Demo
23,8 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.515 palabras · ≈10.939 tokens
Lite
1.155 palabras · ≈2.116 tokens
Librerías
ninguna
Peso de la demo
23,8 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: Campo estelar

Un vuelo lento entre estrellas cercanas, con paralaje de verdad, destellos en cruz y una nebulosa tenue al fondo: la sensación de apagar la luz y mirar hacia arriba desde un sitio sin contaminación lumínica.

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 del alto visible) pintada con WebGL 1 a mano, sin three.js ni ninguna otra librería. Tiene tres capas que se componen en cada fotograma:

1. **Nebulosa** (fondo lejano). Un shader de pantalla completa con ruido fractal (fbm de 5 octavas y un nivel de *domain warping*) que dibuja nubes de gas en dos colores, un brillo cálido donde se cruzan y filamentos de polvo oscuro. Se concentra en una banda diagonal suave que cruza la pantalla de arriba a la izquierda hacia abajo a la derecha (como la Vía Láctea). Se renderiza a **media resolución** (0,5 × los píxeles CSS; 0,4 en móvil) en un framebuffer y se escala con filtrado lineal, porque es suave y así cuesta cuatro veces menos.
2. **Compuesto** (a resolución completa). Lee la textura de la nebulosa y le añade dos capas de **estrellas de fondo procedurales** (rejilla con hash, una estrella como mucho por celda) cuya densidad sube donde la nebulosa es más brillante y que se apagan detrás del polvo; después, viñeta y grano animado. También hace el fundido de entrada desde negro.
3. **Estrellas 3D** (el sujeto). 8.220 puntos (`gl.POINTS`, 4.580 en móvil) en dos poblaciones: un **campo lejano** de 7.600 estrellas entre 52 y 1,4 unidades de profundidad y un **campo cercano** de 620 entre 12 y 1,4. Avanzan hacia la cámara a velocidad constante en unidades de mundo; cuando una pasa la cámara renace al fondo en otra posición (el renacer es un hash del número de ciclo, todo en el vertex shader, sin tocar buffers). El tamaño en píxeles es inversamente proporcional a la profundidad; las más brillantes (≈4 % del campo lejano y ≈20 % del cercano) llevan destello en cruz. Mezcla aditiva.

El cursor mueve la cámara (traslación, no rotación), así que el paralaje es real: una estrella a 2 unidades se desplaza veinte veces más que una a 40, las estrellas de fondo se desplazan unos pocos píxeles y la nebulosa casi nada. Mantener pulsado acelera el vuelo ×5 y abre el campo de visión un 7 %. Si el puntero no se mueve en 3,5 s, la cámara deriva sola en una curva de Lissajous.

Encima, una interfaz mínima de estudio: rótulo en mono arriba a la izquierda, selector de **tres paletas** (Índigo, Brasa, Glaciar) y botón de pausa arriba a la derecha, titular en Inter Tight abajo a la izquierda con la segunda línea en negrita y color de acento (sin cursiva), y un HUD con recorrido, deriva y número de estrellas abajo a la derecha.

## Librerías y esqueleto

Ninguna librería de JS. Solo dos familias de Google Fonts (ambas con ñ, tildes, ¿ y ¡):

```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Campo estelar</title>
<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=IBM+Plex+Mono:wght@400;500&family=Inter+Tight:wght@500;600;700&display=swap" rel="stylesheet">
<style> /* CSS completo más abajo */ </style>
</head>
<body>
  <!-- HTML de «Estructura y contenido» -->
  <!-- 5 bloques <script type="x-shader"> con los shaders de «Código clave» -->
  <!-- 1 <script> clásico (IIFE) con el JS de «Código clave» -->
</body>
</html>
```

Los shaders van en `<script id="…" type="x-shader">` y el JS los lee con `textContent`. El JS es un script clásico envuelto en una IIFE `(() => { … })();` para poder hacer `return` temprano si no hay WebGL.

## Tokens de diseño

```css
:root{
  --fondo:#05060d;
  --tinta:#eef0f7;
  --tenue:rgba(238,240,247,.62);
  --linea:rgba(238,240,247,.16);
  --neb-a:#3b3fa8;
  --neb-b:#b04a9a;
  --grotesca:"Inter Tight", system-ui, sans-serif;
  --acento:#8a6bff;
  --mono:"IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
  --margen:clamp(18px, 3.2vw, 48px);
  --ease:cubic-bezier(.2,.7,.1,1);
}
```

- **Inter Tight** (500, 600 y 700): solo el titular, sin cursivas, tracking −0,035 em.
- **IBM Plex Mono** (400 y 500): todo lo demás, siempre en mayúsculas, 11 px, con tracking 0,08–0,16 em.
- Colores del texto: `--tinta` para lo principal y `--tenue` (62 % de opacidad) para lo secundario.
- Las **tres paletas** de la escena (cada una: cielo, nebulosa A, nebulosa B, brillo C y tinte de las estrellas):

| Paleta | fondo | a | b | c | tinte |
|---|---|---|---|---|---|
| Índigo (defecto) | `#05060d` | `#3b3fa8` | `#b04a9a` | `#ffd9c2` | `#cfd8ff` |
| Brasa | `#0a0605` | `#a8432a` | `#e0a24a` | `#ffe6c4` | `#ffe2c8` |
| Glaciar | `#030a0c` | `#1f6f86` | `#48c2ae` | `#d9fff6` | `#d6f4ff` |

- Colores propios de las estrellas (temperatura, independiente de la paleta): azul `0.66, 0.78, 1.0` (12 %), blanco azulado `0.86, 0.9, 1.0` (43 %), blanco cálido `1.0, 0.96, 0.9` (27 %), amarillo `1.0, 0.86, 0.66` (13 %), naranja `1.0, 0.72, 0.5` (5 %). En el shader se mezclan un 30 % con el tinte de la paleta.

## Estructura y contenido

HTML literal del `<body>` (antes de los shaders):

```html
<main class="escena" id="escena">
  <div class="fondo" aria-hidden="true"></div>
  <canvas id="cielo" aria-hidden="true"></canvas>
  <div class="ui">
    <header class="arriba">
      <p class="etiqueta entra d1">Campo estelar<span>Mag. límite 6,5</span></p>
      <div class="controles entra d2" role="group" aria-label="Paleta de color">
        <button class="paleta" type="button" data-p="0" aria-pressed="true" aria-label="Paleta índigo" style="--a:#3b3fa8;--b:#b04a9a"><i></i><b>Índigo</b></button>
        <button class="paleta" type="button" data-p="1" aria-pressed="false" aria-label="Paleta brasa" style="--a:#a8432a;--b:#e0a24a"><i></i><b>Brasa</b></button>
        <button class="paleta" type="button" data-p="2" aria-pressed="false" aria-label="Paleta glaciar" style="--a:#1f6f86;--b:#48c2ae"><i></i><b>Glaciar</b></button>
        <button class="paleta pausa" type="button" id="pausa" aria-pressed="false" aria-label="Pausar movimiento"><svg viewBox="0 0 12 12" aria-hidden="true"><path id="icono" d="M2 1h3v10H2zM7 1h3v10H7z"/></svg></button>
      </div>
    </header>
    <div></div>
    <footer class="abajo">
      <div>
        <h1 class="entra d3">La noche, <em>de cerca.</em></h1>
        <p class="lugar entra d4">Desierto de Atacama, Chile · 24°37′S 69°15′O</p>
      </div>
      <dl class="hud entra d4" aria-label="Datos del vuelo">
        <dt>Recorrido</dt><dd id="recorrido">0,000 al</dd>
        <dt>Deriva</dt><dd id="deriva">×1,0</dd>
        <dt>Estrellas</dt><dd id="estrellas">—</dd>
        <dd class="pista" id="pista">Mueve el cursor · mantén pulsado para avanzar</dd>
      </dl>
    </footer>
  </div>
</main>
```

Textos literales: rótulo «Campo estelar» / «Mag. límite 6,5»; botones «Índigo», «Brasa», «Glaciar» (con `aria-label` «Paleta índigo», etc.) y el de pausa «Pausar movimiento» ↔ «Reanudar movimiento»; titular «La noche,» + «de cerca.» (en peso 700 y color de acento `#8a6bff`, sin cursiva, sangrada 0,9 em); lugar «Desierto de Atacama, Chile · 24°37′S 69°15′O»; HUD «Recorrido», «Deriva», «Estrellas»; pista «Mueve el cursor · mantén pulsado para avanzar» (en táctil: «Arrastra · mantén pulsado para avanzar»). En la demo, las coordenadas del lugar van en un `<span class="nw">` con `white-space:nowrap` para que no se partan en móvil.

CSS completo (va después del bloque `:root` de arriba, en el mismo `<style>`):

```css
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--fondo);color:var(--tinta);overflow:hidden}
body{font-family:var(--mono);-webkit-font-smoothing:antialiased;transition:background-color .9s var(--ease)}
.escena{position:fixed;inset:0;height:100svh;height:100dvh;overflow:hidden;touch-action:none;user-select:none;-webkit-user-select:none;cursor:crosshair}
.fondo{position:absolute;inset:0;background:
  radial-gradient(1px 1px at 13% 27%, #fff 50%, transparent 51%),
  radial-gradient(1px 1px at 71% 83%, #dfe6ff 50%, transparent 51%),
  radial-gradient(1.5px 1.5px at 41% 9%, #fff 50%, transparent 51%),
  radial-gradient(1px 1px at 88% 44%, #ffe9d2 50%, transparent 51%),
  radial-gradient(2px 2px at 57% 61%, #fff 50%, transparent 51%),
  radial-gradient(1px 1px at 29% 72%, #fff 50%, transparent 51%),
  radial-gradient(60% 45% at 62% 42%, color-mix(in srgb, var(--neb-a) 38%, transparent), transparent 70%),
  radial-gradient(45% 35% at 34% 64%, color-mix(in srgb, var(--neb-b) 24%, transparent), transparent 70%),
  var(--fondo);
  background-size:139px 157px,211px 181px,293px 331px,173px 127px,419px 389px,97px 113px,auto,auto,auto}
.sin-webgl .hud,.sin-webgl .pausa{display:none}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.sin-webgl canvas{display:none}
.ui{position:absolute;inset:0;pointer-events:none;padding:var(--margen);padding-top:max(var(--margen), env(safe-area-inset-top));padding-bottom:max(var(--margen), env(safe-area-inset-bottom));display:grid;grid-template-rows:auto 1fr auto}
.ui > *{pointer-events:auto}
.arriba{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
.etiqueta{font-size:11px;letter-spacing:.16em;text-transform:uppercase;line-height:1.7}
.etiqueta span{display:block;color:var(--tenue);letter-spacing:.12em}
.controles{display:flex;gap:6px;align-items:center}
.paleta{appearance:none;border:1px solid var(--linea);background:rgba(5,6,13,.35);color:var(--tinta);font:500 11px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;height:34px;padding:0 12px 0 8px;border-radius:999px;display:inline-flex;align-items:center;gap:8px;cursor:pointer;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);transition:border-color .3s var(--ease),background-color .3s var(--ease)}
.paleta:hover{border-color:rgba(238,240,247,.4)}
.paleta[aria-pressed="true"]{border-color:rgba(238,240,247,.75);background:rgba(238,240,247,.08)}
.paleta i{width:16px;height:16px;border-radius:50%;background:radial-gradient(circle at 35% 35%, var(--b) 0 30%, var(--a) 70%);box-shadow:0 0 12px var(--a)}
.pausa{padding:0;width:34px;justify-content:center}
.pausa svg{width:12px;height:12px;fill:currentColor}
button:focus-visible{outline:2px solid var(--tinta);outline-offset:3px}
.abajo{display:flex;justify-content:space-between;align-items:flex-end;gap:24px}
h1{font-family:var(--grotesca);font-weight:600;font-size:clamp(44px, 6.4vw, 112px);line-height:.92;letter-spacing:-.035em;margin:0 0 .28em;text-shadow:0 2px 30px rgba(0,0,0,.45)}
h1 em{font-style:normal;font-weight:700;color:var(--acento);display:block;padding-left:.9em}
.lugar{margin:0;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--tenue)}
.hud{text-shadow:0 1px 12px rgba(0,0,0,.6);margin:0;display:grid;grid-template-columns:auto auto;gap:4px 18px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;text-align:right}
.hud dt{color:var(--tenue)}
.hud dd{margin:0;font-variant-numeric:tabular-nums;min-width:9ch}
.pista{grid-column:1 / -1;margin-top:10px;color:var(--tenue);letter-spacing:.1em}
.nw{white-space:nowrap}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* entrada */
.entra{opacity:0;transform:translateY(14px);filter:blur(6px);animation:entra 1.4s var(--ease) forwards}
.d1{animation-delay:.5s}.d2{animation-delay:.75s}.d3{animation-delay:1s}.d4{animation-delay:1.2s}
@keyframes entra{to{opacity:1;transform:none;filter:none}}
@media (max-width:640px){
  .paleta b{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
  .paleta{padding:0;width:34px;justify-content:center}
  .abajo{flex-direction:column;align-items:flex-start;gap:18px}
  .hud{text-align:left;grid-template-columns:auto 1fr}
  h1 em{padding-left:.6em}
}
@media (prefers-reduced-motion:reduce){
  .entra{animation:none;opacity:1;transform:none;filter:none}
  .pista,.pausa{display:none}
  .escena{cursor:default}
}
```

## Movimiento

| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Rótulo superior | opacity, translateY, blur | 0, 14 px, 6 px → 1, 0, 0 | 1,4 s | `cubic-bezier(.2,.7,.1,1)` | 0,5 s | carga |
| Selector de paleta | ídem | ídem | 1,4 s | ídem | 0,75 s | carga |
| Titular | ídem | ídem | 1,4 s | ídem | 1,0 s | carga |
| Lugar y HUD | ídem | ídem | 1,4 s | ídem | 1,2 s | carga |
| Escena (uniform `uFade`) | brillo global | 0 → 1 | 1,8 s lineal | — | 0 | carga |
| Estrellas 3D | profundidad | avanzan 0,75 u/s × deriva | continuo | — | — | siempre |
| Deriva (velocidad) | multiplicador | 1 → 5 al pulsar; 5 → 1 al soltar | exponencial, k = 1,6 (sube) y 1,1 (baja) | `1 − e^(−dt·k)` | — | pointerdown / Shift |
| Campo de visión | focal | 1,25 → 1,25 × 0,93 | ligado a la deriva | — | — | deriva |
| Cámara | x, y | hacia el puntero normalizado (−1…1) | exponencial, k = 3,2 (1,2 en reposo) | `1 − e^(−dt·k)` | — | pointermove |
| Cámara en reposo | x, y | `sin(t·0.13)·0.55`, `sin(t·0.21+1.3)·0.4` | continuo | — | 3,5 s sin puntero | automático |
| Balanceo (roll) | ángulo | `cam.x · 0.035` rad | ligado a la cámara | — | — | pointermove |
| Paleta | 5 colores | actual → nueva | exponencial, k = 2,6 (≈1 s) | `1 − e^(−dt·k)` | — | clic en botón |
| Titileo estrellas 3D | brillo | 0,56 → 1 (0,78 ± 0,22) | 0,9–3,3 rad/s según semilla | seno | — | siempre |
| Titileo estrellas de fondo | brillo | 0,3 → 1 (0,65 ± 0,35) | 1,2–4,2 rad/s | seno | — | siempre |
| Nebulosa | desplazamiento del ruido | `uTime · 0.012` | continuo | — | — | siempre |
| HUD | texto | se refresca cada 0,12 s | — | — | — | siempre |

## Código clave

Todo lo que sigue es literal. Copia los shaders y el JS sin cambiar números.

### Shaders

```html
<script id="vs-tri" type="x-shader">
attribute vec2 aPos;
varying vec2 vUv;
void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }
</script>

<script id="fs-nebulosa" type="x-shader">
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform vec2 uCam;
uniform vec3 uBg, uA, uB, uC;
varying vec2 vUv;
float h(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float n(vec2 p){
  vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(h(i), h(i + vec2(1.0, 0.0)), f.x), mix(h(i + vec2(0.0, 1.0)), h(i + vec2(1.0, 1.0)), f.x), f.y);
}
float fbm(vec2 p){
  float s = 0.0, a = 0.5;
  mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
  for (int i = 0; i < 5; i++){ s += a * n(p); p = m * p; a *= 0.5; }
  return s;
}
void main(){
  vec2 p = (vUv - 0.5) * vec2(uRes.x / uRes.y, 1.0);
  p += uCam * 0.035;
  float t = uTime * 0.012;
  vec2 q = vec2(fbm(p * 1.4 + vec2(0.0, t)), fbm(p * 1.4 + vec2(5.2, 1.3) - t));
  float d  = fbm(p * 1.2 + q * 1.8 + vec2(t * 0.6, 0.0));
  float d2 = fbm(p * 2.4 - q * 1.3 + 3.1);
  float band = exp(-pow((p.y + p.x * 0.38 - 0.04) * 2.1, 2.0));
  float cloud = smoothstep(0.26, 0.7, d) * (0.22 + 0.78 * band);
  float glow  = smoothstep(0.36, 0.7, d2) * band;
  float dust  = smoothstep(0.53, 0.68, fbm(p * 3.4 + q * 2.4 + 7.0)) * band;
  vec3 col = uBg;
  col += uA * band * 0.1;
  col += uA * cloud * 1.15;
  col += uB * glow * cloud * 1.25;
  col += uC * pow(cloud * glow, 1.8) * 0.9;
  col *= 1.0 - dust * 0.62;
  gl_FragColor = vec4(col, dust);
}
</script>

<script id="fs-compuesto" type="x-shader">
precision highp float;
uniform sampler2D uNeb;
uniform vec2 uRes;
uniform float uTime, uDpr, uFade;
uniform vec2 uCam;
uniform vec3 uTint;
varying vec2 vUv;
float h(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float capa(vec2 px, float celda, float umbral, float semilla){
  vec2 g = px / celda, i = floor(g), f = fract(g);
  float r = h(i + semilla);
  if (r < umbral) return 0.0;
  vec2 c = vec2(h(i + 7.1 + semilla), h(i + 3.3 + semilla)) * 0.8 + 0.1;
  float d = length(f - c) * celda;
  float b = (r - umbral) / (1.0 - umbral);
  float tw = 0.65 + 0.35 * sin(uTime * (1.2 + r * 3.0) + r * 91.0);
  return smoothstep(1.15, 0.0, d) * (0.25 + 0.75 * b) * tw;
}
void main(){
  vec4 neb = texture2D(uNeb, vUv);
  vec3 col = neb.rgb;
  vec2 px = gl_FragCoord.xy / uDpr;
  float lum = dot(neb.rgb, vec3(0.3, 0.5, 0.2));
  float s = capa(px + uCam * 4.0, 5.0, 0.965 - lum * 0.5, 0.0) * 0.6 + capa(px + uCam * 9.0, 11.0, 0.93 - lum * 0.2, 17.0) * 0.85;
  col += mix(vec3(1.0), uTint, 0.35) * s * (1.0 - neb.a * 0.85) * uFade;
  vec2 v = vUv - 0.5;
  col *= 1.0 - 0.42 * pow(length(v * vec2(1.1, 1.3)), 2.2);
  col = col * mix(0.0, 1.0, min(1.0, uFade * 1.4));
  col += (h(gl_FragCoord.xy + fract(uTime * 7.0) * 311.0) - 0.5) * 0.022;
  gl_FragColor = vec4(col, 1.0);
}
</script>

<script id="vs-estrellas" type="x-shader">
attribute vec4 aSemilla;   // x: z inicial (0-1) · y: semilla · z: magnitud · w: destello (0/1)
attribute vec4 aColor;     // rgb: color de la estrella · a: 0 = campo lejano, 1 = campo cercano
uniform float uDist, uTime, uAspect, uFocal, uK, uMaxPx, uDpr, uFade, uRoll;
uniform vec2 uCam;
uniform vec3 uTint;
varying vec3 vCol;
varying float vDest, vPx;
float h1(float n){ return fract(sin(n) * 43758.5453); }
void main(){
  float dNear = 1.4;
  float dFar = mix(52.0, 12.0, aColor.a);
  float tramo = dFar - dNear;
  float s = aSemilla.x * tramo + uDist;
  float ciclo = floor(s / tramo);
  float z = fract(s / tramo);
  float prof = dFar - z * tramo;
  vec2 xy = vec2(h1(aSemilla.y * 17.13 + ciclo * 3.71), h1(aSemilla.y * 29.71 + ciclo * 7.13)) * 2.0 - 1.0;
  xy *= vec2(dFar * max(uAspect, 1.0), dFar * max(1.0 / uAspect, 1.0)) * 1.05;
  xy -= uCam;
  float c = cos(uRoll), sn = sin(uRoll);
  xy = mat2(c, sn, -sn, c) * xy;
  gl_Position = vec4(xy.x * uFocal / uAspect, xy.y * uFocal, 0.0, prof);
  float mag = aSemilla.z;
  float px = uK * 0.042 * (0.5 + mag) / prof;
  px *= mix(1.0, 1.45, aColor.a) * mix(1.0, 2.8, aSemilla.w);
  float minPx = 1.6 * uDpr;
  float energia = min(1.0, px / minPx);
  vPx = clamp(px, minPx, uMaxPx);
  gl_PointSize = vPx;
  float fade = smoothstep(0.0, 0.16, z) * smoothstep(dNear, dNear + 1.6, prof);
  float tw = 0.78 + 0.22 * sin(uTime * (0.9 + fract(aSemilla.y * 7.3) * 2.4) + aSemilla.y * 6.2831);
  vCol = mix(aColor.rgb, aColor.rgb * uTint * 1.1, 0.3) * (0.5 + 0.9 * mag) * fade * tw * energia * uFade;
  vDest = aSemilla.w;
}
</script>

<script id="fs-estrellas" type="x-shader">
precision mediump float;
varying vec3 vCol;
varying float vDest, vPx;
void main(){
  vec2 c = gl_PointCoord * 2.0 - 1.0;
  float r2 = dot(c, c);
  if (r2 > 1.0) discard;
  float k = mix(1.0, 2.8, vDest);
  float rr = r2 * k * k;
  float nucleo = exp(-rr * 16.0);
  float halo = exp(-sqrt(rr) * 4.2) * 0.42 * (1.0 - r2);
  float rayos = 0.0;
  if (vDest > 0.5){
    vec2 a = abs(c);
    float w = vPx * 0.55;
    rayos = (exp(-a.x * w) * pow(1.0 - a.y, 3.0) + exp(-a.y * w) * pow(1.0 - a.x, 3.0)) * 0.6;
    rayos *= smoothstep(10.0, 28.0, vPx);
  }
  gl_FragColor = vec4(vCol * (nucleo + halo + rayos), 1.0);
}
</script>
```

Notas sobre los shaders:
- **Proyección sin matrices**: `gl_Position = vec4(x·focal/aspect, y·focal, 0, profundidad)`. Al poner la profundidad en `w`, la GPU divide por ella y sale la perspectiva. No hay test de profundidad (mezcla aditiva, el orden no importa).
- **Renacer por ciclo**: `s = zInicial·tramo + uDist`; `ciclo = floor(s/tramo)`; la posición x/y es un hash de (semilla, ciclo), así que cada pasada la estrella aparece en otro sitio sin que el JS toque el buffer. El área de aparición es exactamente el tronco de visión en el fondo (`dFar × aspect`) con un 5 % de margen.
- **Energía de los puntos pequeños**: el tamaño mínimo es 1,6 px × DPR; si el tamaño real es menor, el punto se dibuja a 1,6 px pero con el brillo multiplicado por `tamaño/1,6`. Así las estrellas lejanas no parpadean ni desaparecen al cruzar píxeles.
- Los destellos en cruz se afinan con el tamaño del punto (`w = vPx·0.55`) y solo aparecen a partir de 10 px (plenos a 28 px).
- La nebulosa guarda en el canal alfa la cantidad de polvo, que el compuesto usa para apagar las estrellas de fondo que quedan detrás.

### JavaScript (script clásico, al final del body)

```js
(() => {
  const PALETAS = [
    { fondo: "#05060d", a: "#3b3fa8", b: "#b04a9a", c: "#ffd9c2", tinte: "#cfd8ff" },
    { fondo: "#0a0605", a: "#a8432a", b: "#e0a24a", c: "#ffe6c4", tinte: "#ffe2c8" },
    { fondo: "#030a0c", a: "#1f6f86", b: "#48c2ae", c: "#d9fff6", tinte: "#d6f4ff" },
  ];
  const $ = (s) => document.querySelector(s);
  const root = document.documentElement;
  const escena = $("#escena"), cvs = $("#cielo");
  const reducido = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const tactil = matchMedia("(hover: none)").matches;
  const movil = Math.min(innerWidth, innerHeight) < 700 || tactil;
  const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
  const aRgb = (p) => ({ fondo: hex(p.fondo), a: hex(p.a), b: hex(p.b), c: hex(p.c), tinte: hex(p.tinte) });
  let objetivo = aRgb(PALETAS[0]);
  const actual = JSON.parse(JSON.stringify(objetivo));

  const botones = [...document.querySelectorAll(".paleta[data-p]")];
  const fijarPaleta = (i) => {
    const p = PALETAS[i];
    objetivo = aRgb(p);
    botones.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.p == i)));
    root.style.setProperty("--fondo", p.fondo);
    root.style.setProperty("--neb-a", p.a);
    root.style.setProperty("--neb-b", p.b);
    if (gl && (reducido || pausado)) { copiarPaleta(); pintar(); }
  };
  botones.forEach((b) => b.addEventListener("click", () => fijarPaleta(+b.dataset.p)));
  if (tactil) $("#pista").textContent = "Arrastra · mantén pulsado para avanzar";

  const gl = cvs.getContext("webgl", { antialias: false, alpha: false, depth: false, stencil: false, powerPreference: "high-performance" });
  if (!gl) { root.classList.add("sin-webgl"); return; }
  cvs.addEventListener("webglcontextlost", (e) => { e.preventDefault(); root.classList.add("sin-webgl"); cancelAnimationFrame(raf); raf = 0; });

  // ---------- programas
  const src = (id) => document.getElementById(id).textContent;
  const programa = (vs, fs) => {
    const p = gl.createProgram();
    for (const [tipo, s] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
      const sh = gl.createShader(tipo); gl.shaderSource(sh, src(s)); gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
      gl.attachShader(p, sh);
    }
    gl.linkProgram(p);
    const u = {}, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < n; i++) { const nom = gl.getActiveUniform(p, i).name; u[nom] = gl.getUniformLocation(p, nom); }
    return { p, u };
  };
  let pNeb, pComp, pEst;
  try {
    pNeb = programa("vs-tri", "fs-nebulosa");
    pComp = programa("vs-tri", "fs-compuesto");
    pEst = programa("vs-estrellas", "fs-estrellas");
  } catch (e) { root.classList.add("sin-webgl"); return; }

  const tri = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, tri);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);

  // ---------- estrellas: campo lejano + campo cercano
  const N_LEJOS = movil ? 4200 : 7600, N_CERCA = movil ? 380 : 620, N = N_LEJOS + N_CERCA;
  $("#estrellas").textContent = N.toLocaleString("es-ES");
  const COLORES = [[0.66, 0.78, 1.0], [0.86, 0.9, 1.0], [1.0, 0.96, 0.9], [1.0, 0.86, 0.66], [1.0, 0.72, 0.5]];
  const datos = new Float32Array(N * 8);
  let semilla = 7;
  const rnd = () => ((semilla = (semilla * 16807) % 2147483647) / 2147483647);
  for (let i = 0; i < N; i++) {
    const o = i * 8, t = rnd();
    const col = COLORES[t < 0.12 ? 0 : t < 0.55 ? 1 : t < 0.82 ? 2 : t < 0.95 ? 3 : 4];
    const u = rnd(), mag = i < N_LEJOS ? 0.15 + 2.6 * Math.pow(u, 4.5) : 0.35 + 2.45 * Math.pow(u, 2.2);
    datos[o] = rnd(); datos[o + 1] = rnd() * 100; datos[o + 2] = mag; datos[o + 3] = mag > (i < N_LEJOS ? 2.3 : 1.85) ? 1 : 0;
    datos[o + 4] = col[0]; datos[o + 5] = col[1]; datos[o + 6] = col[2]; datos[o + 7] = i < N_LEJOS ? 0 : 1;
  }
  const bufEst = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, bufEst);
  gl.bufferData(gl.ARRAY_BUFFER, datos, gl.STATIC_DRAW);
  const locSem = gl.getAttribLocation(pEst.p, "aSemilla"), locCol = gl.getAttribLocation(pEst.p, "aColor");
  const maxPunto = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE)[1];

  // ---------- nebulosa a media resolución
  const texNeb = gl.createTexture(), fbo = gl.createFramebuffer();
  let W = 1, H = 1, NW = 1, NH = 1, dpr = 1;
  const redimensionar = () => {
    dpr = Math.min(devicePixelRatio || 1, movil ? 1.5 : 2);
    const r = escena.getBoundingClientRect();
    W = Math.max(1, Math.round(r.width * dpr)); H = Math.max(1, Math.round(r.height * dpr));
    cvs.width = W; cvs.height = H;
    const escala = movil ? 0.4 : 0.5;
    NW = Math.max(2, Math.round(r.width * escala)); NH = Math.max(2, Math.round(r.height * escala));
    gl.bindTexture(gl.TEXTURE_2D, texNeb);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, NW, NH, 0, gl.RGBA, gl.UNSIGNED_BYTE, 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);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texNeb, 0);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  };

  // ---------- estado
  const FOCAL = 1.25;
  let tiempo = reducido ? 14 : 0, dist = reducido ? 9 : 0, velocidad = 1, velObj = 1, fundido = reducido ? 1 : 0;
  const cam = { x: 0, y: 0, tx: 0, ty: 0 };
  let ultimoPuntero = -1e9, pulsado = false, pausado = false;

  const copiarPaleta = () => { for (const k in objetivo) actual[k] = objetivo[k].slice(); };
  const mezclarPaleta = (f) => { for (const k in objetivo) for (let i = 0; i < 3; i++) actual[k][i] += (objetivo[k][i] - actual[k][i]) * f; };

  const triangulo = (prog) => {
    gl.bindBuffer(gl.ARRAY_BUFFER, tri);
    const l = gl.getAttribLocation(prog.p, "aPos");
    gl.enableVertexAttribArray(l); gl.vertexAttribPointer(l, 2, gl.FLOAT, false, 0, 0);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.disableVertexAttribArray(l);
  };

  const pintar = () => {
    const P = actual, aspect = W / H;
    // 1 · nebulosa
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.viewport(0, 0, NW, NH);
    gl.useProgram(pNeb.p);
    gl.uniform2f(pNeb.u.uRes, NW, NH);
    gl.uniform1f(pNeb.u.uTime, tiempo);
    gl.uniform2f(pNeb.u.uCam, cam.x, cam.y);
    gl.uniform3fv(pNeb.u.uBg, P.fondo); gl.uniform3fv(pNeb.u.uA, P.a); gl.uniform3fv(pNeb.u.uB, P.b); gl.uniform3fv(pNeb.u.uC, P.c);
    triangulo(pNeb);
    // 2 · compuesto a pantalla
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, W, H);
    gl.useProgram(pComp.p);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texNeb);
    gl.uniform1i(pComp.u.uNeb, 0);
    gl.uniform2f(pComp.u.uRes, W, H);
    gl.uniform1f(pComp.u.uTime, tiempo);
    gl.uniform1f(pComp.u.uDpr, dpr);
    gl.uniform1f(pComp.u.uFade, fundido);
    gl.uniform2f(pComp.u.uCam, cam.x, cam.y);
    gl.uniform3fv(pComp.u.uTint, P.tinte);
    triangulo(pComp);
    // 3 · estrellas, mezcla aditiva
    gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
    gl.useProgram(pEst.p);
    const focal = FOCAL * (1 - 0.07 * (velocidad - 1) / 4);
    gl.uniform1f(pEst.u.uDist, dist);
    gl.uniform1f(pEst.u.uTime, tiempo);
    gl.uniform1f(pEst.u.uAspect, aspect);
    gl.uniform1f(pEst.u.uFocal, focal);
    gl.uniform1f(pEst.u.uK, focal * H * 0.5);
    gl.uniform1f(pEst.u.uMaxPx, Math.min(maxPunto, 150 * dpr));
    gl.uniform1f(pEst.u.uDpr, dpr);
    gl.uniform1f(pEst.u.uFade, fundido);
    gl.uniform1f(pEst.u.uRoll, cam.x * 0.035);
    gl.uniform2f(pEst.u.uCam, cam.x * 0.9, cam.y * 0.6);
    gl.uniform3fv(pEst.u.uTint, P.tinte);
    gl.bindBuffer(gl.ARRAY_BUFFER, bufEst);
    gl.enableVertexAttribArray(locSem); gl.vertexAttribPointer(locSem, 4, gl.FLOAT, false, 32, 0);
    gl.enableVertexAttribArray(locCol); gl.vertexAttribPointer(locCol, 4, gl.FLOAT, false, 32, 16);
    gl.drawArrays(gl.POINTS, 0, N);
    gl.disableVertexAttribArray(locSem); gl.disableVertexAttribArray(locCol);
    gl.disable(gl.BLEND);
  };

  // ---------- HUD
  const elRec = $("#recorrido"), elDer = $("#deriva");
  const fmt3 = new Intl.NumberFormat("es-ES", { minimumFractionDigits: 3, maximumFractionDigits: 3 });
  const fmt1 = new Intl.NumberFormat("es-ES", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
  let hudT = 0;
  const hud = () => { elRec.textContent = fmt3.format(dist * 0.0021) + " al"; elDer.textContent = "×" + fmt1.format(velocidad); };

  // ---------- bucle
  let raf = 0, ultimo = 0, enPantalla = true;
  const paso = (dt) => {
    tiempo += dt;
    fundido = Math.min(1, fundido + dt / 1.8);
    const idle = performance.now() - ultimoPuntero > 3500;
    if (idle) { cam.tx = Math.sin(tiempo * 0.13) * 0.55; cam.ty = Math.sin(tiempo * 0.21 + 1.3) * 0.4; }
    const k = 1 - Math.exp(-dt * (idle ? 1.2 : 3.2));
    cam.x += (cam.tx - cam.x) * k; cam.y += (cam.ty - cam.y) * k;
    velObj = pulsado ? 5 : 1;
    velocidad += (velObj - velocidad) * (1 - Math.exp(-dt * (pulsado ? 1.6 : 1.1)));
    dist += dt * 0.75 * velocidad;
    mezclarPaleta(1 - Math.exp(-dt * 2.6));
    if ((hudT += dt) > 0.12) { hudT = 0; hud(); }
  };
  const bucle = (ahora) => {
    raf = 0;
    const dt = ultimo ? Math.min(0.05, (ahora - ultimo) / 1000) : 1 / 60;
    ultimo = ahora;
    paso(dt); pintar();
    raf = requestAnimationFrame(bucle);
  };
  const arrancar = () => {
    const toca = !reducido && !pausado && enPantalla && !document.hidden;
    if (toca && !raf) { ultimo = 0; raf = requestAnimationFrame(bucle); }
    if (!toca && raf) { cancelAnimationFrame(raf); raf = 0; }
  };

  // ---------- entrada del usuario
  const apuntar = (x, y) => {
    const r = escena.getBoundingClientRect();
    cam.tx = ((x - r.left) / r.width * 2 - 1);
    cam.ty = -((y - r.top) / r.height * 2 - 1);
    ultimoPuntero = performance.now();
  };
  if (!reducido) {
    escena.addEventListener("pointermove", (e) => apuntar(e.clientX, e.clientY));
    escena.addEventListener("pointerdown", (e) => {
      if (e.target.closest("button")) return;
      pulsado = true; apuntar(e.clientX, e.clientY);
    });
    addEventListener("pointerup", () => { pulsado = false; });
    addEventListener("pointercancel", () => { pulsado = false; });
    addEventListener("blur", () => { pulsado = false; });
    addEventListener("keydown", (e) => {
      const d = { ArrowLeft: [-0.25, 0], ArrowRight: [0.25, 0], ArrowUp: [0, 0.25], ArrowDown: [0, -0.25] }[e.key];
      if (d) { cam.tx = Math.max(-1, Math.min(1, cam.tx + d[0])); cam.ty = Math.max(-1, Math.min(1, cam.ty + d[1])); ultimoPuntero = performance.now(); e.preventDefault(); }
      if (e.key === "Shift") pulsado = true;
    });
    addEventListener("keyup", (e) => { if (e.key === "Shift") pulsado = false; });
  }
  const pausa = $("#pausa"), icono = $("#icono");
  pausa.addEventListener("click", () => {
    pausado = !pausado;
    pausa.setAttribute("aria-pressed", String(pausado));
    pausa.setAttribute("aria-label", pausado ? "Reanudar movimiento" : "Pausar movimiento");
    icono.setAttribute("d", pausado ? "M3 1l8 5-8 5z" : "M2 1h3v10H2zM7 1h3v10H7z");
    if (pausado) { fundido = 1; copiarPaleta(); pintar(); }
    arrancar();
  });

  new IntersectionObserver((es) => { enPantalla = es[0].isIntersecting; arrancar(); }).observe(escena);
  document.addEventListener("visibilitychange", arrancar);
  let rt = 0;
  const alRedimensionar = () => { clearTimeout(rt); rt = setTimeout(() => { redimensionar(); if (!raf) pintar(); }, 80); };
  addEventListener("resize", alRedimensionar);
  visualViewport && visualViewport.addEventListener("resize", alRedimensionar);

  redimensionar();
  const pIni = +(new URLSearchParams(location.search).get("paleta") || 0);
  if (PALETAS[pIni] && pIni) { fijarPaleta(pIni); copiarPaleta(); }
  if (reducido) { copiarPaleta(); hud(); pintar(); } else { hud(); arrancar(); }
})();
```

## Interacción

- **Puntero (ratón o dedo)**: `pointermove` sobre la escena lleva la cámara a `x = 2·(clientX/ancho) − 1`, `y = −(2·(clientY/alto) − 1)`. A los shaders llegan `uCam = (x·0.9, y·0.6)` para las estrellas 3D, `uCam·4` y `uCam·9` px (CSS) para las dos capas de fondo, y `uCam·0.035` en unidades de ruido para la nebulosa.
- **Mantener pulsado** en cualquier punto que no sea un botón: la deriva sube a ×5. Se suelta con `pointerup`, `pointercancel` o al perder el foco la ventana.
- **Táctil**: la escena tiene `touch-action:none`; arrastrar mueve la cámara y mantener el dedo acelera. Si no se toca en 3,5 s, deriva automática.
- **Teclado**: las flechas desplazan la cámara 0,25 por pulsación (limitada a −1…1); mantener **Shift** acelera. Los botones de paleta y pausa son `<button>` nativos con foco visible (contorno de 2 px, separación 3 px) y `aria-pressed`.
- **Paletas**: clic en un botón → funde los cinco colores en ≈1 s y actualiza `--fondo`, `--neb-a` y `--neb-b` en `:root` (el fallback CSS y el color del body cambian a la vez). La URL admite `?paleta=1` o `?paleta=2` para abrir directamente en Brasa o Glaciar.
- **Pausa**: congela el vuelo, completa el fundido y deja un fotograma fijo; los cambios de paleta repintan ese fotograma.

## Adaptación a móvil y tablet

- Se considera **móvil** si el lado corto de la ventana mide menos de 700 px o si `(hover: none)`: DPR máximo 1,5, nebulosa a 0,4 × píxeles CSS, 4.200 estrellas lejanas y 380 cercanas.
- **≤ 640 px**: los botones de paleta muestran solo el círculo de color (34 × 34 px, el nombre queda para lectores de pantalla), el pie pasa a columna (titular, lugar y HUD alineado a la izquierda) y la cursiva del titular se sangra 0,6 em.
- El titular escala con `clamp(44px, 6.4vw, 112px)`; el margen con `clamp(18px, 3.2vw, 48px)` y respeta `env(safe-area-inset-*)`.
- La escena usa `100dvh` (con `100svh` antes como respaldo); nunca `100vh`. Sin scroll en ningún ancho de 360 a 1920 px.
- En vertical la banda de nebulosa queda casi horizontal a media altura, que en móvil funciona mejor que la diagonal.

## Accesibilidad y movimiento reducido

- `<main>` como contenedor, `<h1>` para el titular, `<dl>` para el HUD con `aria-label="Datos del vuelo"`, grupo de botones con `role="group"` y `aria-label="Paleta de color"`. Canvas y fondo con `aria-hidden="true"`.
- Contraste: texto `#eef0f7` sobre cielo casi negro; el secundario al 62 % sigue por encima de AA en 11 px mayúsculas; el HUD lleva `text-shadow` para leerse sobre la nebulosa.
- **`prefers-reduced-motion: reduce`**: no hay bucle. Se pinta **un único fotograma** con `tiempo = 14` y `dist = 9` (nebulosa y estrellas ya colocadas, fundido completo), el puntero no mueve nada, se ocultan la pista y el botón de pausa, y las entradas de texto aparecen directamente. Las paletas siguen funcionando y repintan ese fotograma.
- Botón de pausa para quien quiera parar el movimiento sin cambiar la preferencia del sistema.

## Rendimiento

- WebGL 1 con `antialias:false, alpha:false, depth:false, stencil:false`.
- **DPR** limitado a 2 (1,5 en móvil). La nebulosa, que es lo caro, va a media resolución.
- Bucle con `requestAnimationFrame` que se **detiene** si la escena sale de pantalla (IntersectionObserver), si la pestaña se oculta (`visibilitychange`), en pausa o con movimiento reducido. `dt` limitado a 0,05 s.
- `resize` y `visualViewport.resize` con *debounce* de 80 ms; si el bucle está parado, repinta un fotograma.
- **Fallback sin WebGL** (o si falla un shader o se pierde el contexto): se añade `sin-webgl` a `<html>`, se oculta el canvas y queda el `.fondo` CSS con dos degradados radiales de la paleta y seis capas de estrellas de 1–2 px con tamaños de mosaico primos (para que no se note la repetición); se ocultan el HUD y la pausa.
- Peso: ≈24 KB en total, sin peticiones aparte de las fuentes.

## Personalización

- **Textos**: `La noche,`, `de cerca.` y `Desierto de Atacama, Chile · 24°37′S 69°15′O` en el HTML. El rótulo de arriba y el HUD pueden quitarse sin tocar el JS salvo las líneas que escriben en `#recorrido`, `#deriva` y `#estrellas`.
- **Paleta principal**: cambia el primer objeto de `PALETAS` y los valores equivalentes de `:root` (`--fondo`, `--neb-a`, `--neb-b`) y del primer botón (`--a`, `--b`). `c` es el brillo donde se cruzan las nubes (un tono muy claro y cálido queda bien) y `tinte` el color hacia el que viran un 30 % las estrellas.
- **Añadir una paleta**: un objeto más en `PALETAS` y un botón más con `data-p` correlativo.
- **Densidad**: `N_LEJOS` y `N_CERCA`. Más estrellas cercanas = más protagonismo del paralaje.
- **Velocidad**: `0.75` en `dist += dt * 0.75 * velocidad`; la aceleración máxima es el `5` de `velObj`.
- **Tamaño de las estrellas**: `0.042` en el vertex shader (tamaño base) y `1.45` (extra del campo cercano).
- **Cantidad de nebulosa**: los multiplicadores `1.15`, `1.25` y `0.9` del shader de la nebulosa; la banda se inclina con el `0.38` de `band` y se ensancha bajando el `2.1`.
- **Solo fondo**: borra `.ui` y deja `.escena` como `position:fixed` detrás de tu contenido; el paralaje sigue funcionando porque escucha `pointermove` en la escena (muévelo a `window` si tu contenido la tapa).

## Comprobación final

1. Se abre sin errores en consola y sin ninguna petición salvo Google Fonts.
2. A los 2 s se ve la nebulosa en banda diagonal, miles de estrellas pequeñas y unas decenas grandes con halo; algunas con destello en cruz de 4 puntas.
3. Las estrellas avanzan despacio hacia el espectador y salen por los bordes; ninguna aparece de golpe (fundido al fondo) ni revienta al pasar la cámara (fundido cerca).
4. Al mover el cursor, las estrellas cercanas se desplazan mucho más que las lejanas y la nebulosa casi nada; hay un ligero balanceo.
5. Mantener pulsado acelera hasta ×5 y el HUD muestra «Deriva ×5,0»; al soltar vuelve a ×1,0 en unos 4 s.
6. Sin tocar el puntero 3,5 s, la cámara deriva sola.
7. Los tres botones cambian la paleta con un fundido de ≈1 s y marcan `aria-pressed="true"`; `?paleta=2` abre en Glaciar.
8. El botón de pausa congela la escena y cambia su icono y su etiqueta.
9. Con movimiento reducido: una imagen fija completa, sin pista ni botón de pausa.
10. Sin WebGL: degradado de la paleta con estrellas CSS y el titular legible.
11. A 390 × 844: botones solo con color, pie en columna, nada se sale ni se solapa.
12. El bucle se para al cambiar de pestaña y al sacar la escena de pantalla.
13. El HUD usa formato español: «0,012 al», «×1,0».

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:
    • «Quita el titular y el HUD y déjalo solo como fondo»
    • «Haz el vuelo el doble de lento y con menos estrellas cercanas»
    • «Cambia la nebulosa a verde esmeralda y oro»
    • «Que la nebulosa sea más densa y brillante en el centro»
  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 usarlo de fondo detrás de mi hero?
Sí. El canvas va en posición fija a pantalla completa y el contenido se coloca encima. Quita el titular y el HUD de la demo, deja las estrellas y pon tu texto; la nebulosa tiene poca luz en el centro para que se lea bien.
¿Pesa mucho o gasta batería?
No usa librerías: son 24 KB de HTML. La nebulosa se pinta a media resolución, el DPR está limitado a 2 (1,5 en móvil) y el bucle se detiene cuando la pieza sale de pantalla o la pestaña se oculta.
¿Cómo cambio los colores?
Cada paleta es un objeto con cinco colores (cielo, nebulosa principal, secundaria, brillo y tinte de estrellas). Edita el primero para tu marca o añade más; el cambio entre paletas se funde solo en un segundo.
¿Qué ve alguien con el movimiento reducido activado?
Un único fotograma fijo con la nebulosa y las estrellas ya colocadas, sin vuelo ni titileo. Los botones de paleta siguen funcionando y repintan la imagen.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈10939 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈2116 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.