
Gota de aceite
GratisFluidos · three.js
La pieza
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.
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: 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
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