Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

Un terreno infinito hecho de una cuadrícula de puntos que ondula bajo tres olas de cresta afilada y se pierde en una bruma de color sobre un cielo casi negro con estrellas. Las crestas se encienden y crecen, los valles se apagan, y la mezcla aditiva hace brillar las laderas y el horizonte sin posprocesado. El cursor levanta un montículo con anillos y cada clic lanza una onda circular que recorre el mar; en reposo, las ondas nacen solas. Trae tres paletas conmutables (Clorofila, Abisal y Brasa) con fundido suave. Funciona como hero de una marca tecnológica, portada de un estudio creativo, fondo de un evento nocturno o sección de producto. WebGL a mano, sin librerías, unos 22 KB.

Prompt
≈10.329 tokens · con Lite
Librerías
ninguna
Demo
22,1 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.736 palabras · ≈10.329 tokens
Lite
1.280 palabras · ≈2.071 tokens
Librerías
ninguna
Peso de la demo
22,1 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: Ola de puntos

Un mar nocturno hecho de más de cien mil puntos de luz que ondula hasta el horizonte, levanta el agua bajo el cursor y lanza ondas circulares con cada clic: hipnótico, cinematográfico, silencioso.

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) para usar como hero, portada de sección o fondo de un bloque de marca. Se ve un terreno infinito de puntos que se pierde en una bruma verde sobre un cielo casi negro con estrellas mínimas. El terreno es una **cuadrícula regular de puntos en el plano del suelo**, desplazada en altura en el vertex shader por tres olas direccionales de cresta afilada más ruido de baja frecuencia. Las crestas se encienden (color claro y puntos más grandes) y los valles se apagan hacia un verde profundo; la mezcla es aditiva, así que donde las filas se aprietan (las laderas vistas de canto y el horizonte) la luz se acumula y brilla sola, sin posprocesado.

Capas, de atrás hacia delante:

1. **Fondo** (un triángulo a pantalla completa en WebGL): color de fondo, un halo del color de valle centrado en la línea del horizonte, una línea finísima del color de cresta sobre ella, estrellas de 1 px solo por encima del horizonte, viñeta suave y un dither de ±0,5/255 para que el degradado no haga bandas.
2. **Puntos** (`gl.POINTS`, mezcla `ONE, ONE`): la cuadrícula ondulante.
3. **Velo** (CSS): degradado del color de fondo desde la esquina inferior izquierda para que el texto se lea.
4. **Grano** (CSS): ruido `feTurbulence` en SVG en línea, opacidad 0,09, `mix-blend-mode: overlay`.
5. **Interfaz mínima**: abajo a la izquierda, etiqueta en mono + titular en grotesca (Inter Tight 600, sin cursiva) + una línea de ayuda; abajo a la derecha, un selector de 3 paletas en una píldora translúcida.

No usa three.js: es WebGL 1 a mano (dos programas, dos buffers), unos 22 KB en total. Nada de imágenes, vídeos ni texturas externas.

## Librerías y esqueleto

Sin librerías JS. Solo dos familias de Google Fonts (ambas con ñ, tildes, ¿ y ¡): **Inter Tight** (500, 600 y 700; el titular usa 600, interletraje -0,03em, sin cursivas) para el titular e **IBM Plex Mono** (400 y 500) para todo lo demás.

Cabecera literal:

```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>Ola de puntos</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=Inter+Tight:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
```

Todo el JS va en un único `<script>` clásico (no módulo) al final del `<body>`, envuelto en una IIFE con `"use strict"`.

## Tokens de diseño

Bloque `:root` literal (el resto del CSS está en «Código clave»):

```css
  :root {
    --fondo: #010403;
    --valle: #0a4a2c;
    --cresta: #7dffa8;
    --texto: #eef6f1;
    --texto-suave: rgba(238, 246, 241, 0.66);
    --linea: rgba(238, 246, 241, 0.14);
    --grotesca: "Inter Tight", "Helvetica Neue", Arial, sans-serif;
    --mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
    --margen: clamp(20px, 3.4vw, 48px);
    --curva: cubic-bezier(0.22, 1, 0.36, 1);
  }
```

Las tres paletas (el `:root` y la primera paleta del JS usan las variables del personalizador; las otras dos son fijas):

| Paleta | fondo | valle | cresta | Carácter |
|---|---|---|---|---|
| Clorofila (defecto) | `#010403` | `#0a4a2c` | `#7dffa8` | verde fósforo, bioluminiscencia |
| Abisal | `#01030a` | `#0b2f6b` | `#7fd8ff` | azul de fosa marina, cian frío en las crestas |
| Brasa | `#060201` | `#5c1a08` | `#ffb46b` | ceniza y ámbar, dunas al rojo |

- **fondo**: color del lienzo y del `body`.
- **valle**: color de los puntos bajos y del halo del horizonte.
- **cresta**: color de los puntos altos, de la línea del horizonte, del punto de la etiqueta y del foco.
- Por encima de `luz = 1` (cursor u ondas) el color de los puntos se suma hacia el blanco (`+ vec3(0.9) * (luz − 1)`).
- Texto: `#eef6f1` y el mismo al 66 % para lo secundario; líneas al 14 %.

## Estructura y contenido

Marcado literal del `<body>` (antes del `<script>`):

```html
<main class="escena" id="escena">
  <canvas id="lienzo" aria-hidden="true"></canvas>
  <div class="fallback" aria-hidden="true"></div>
  <div class="velo" aria-hidden="true"></div>
  <div class="grano" aria-hidden="true"></div>

  <header class="pie">
    <p class="etiqueta">Escena 3D · tiempo real</p>
    <h1>Mar de fondo</h1>
    <p class="ayuda"><span class="raton">Mueve el cursor para levantar el agua y haz clic para lanzar una onda.</span><span class="tactil">Toca el mar para lanzar una onda.</span></p>
  </header>

  <nav class="paletas" aria-label="Paleta de color">
    <button type="button" data-paleta="clorofila" aria-pressed="true"><span class="muestra" style="--c1:#0a4a2c;--c2:#7dffa8"></span>Clorofila</button>
    <button type="button" data-paleta="abisal" aria-pressed="false"><span class="muestra" style="--c1:#0b2f6b;--c2:#7fd8ff"></span>Abisal</button>
    <button type="button" data-paleta="brasa" aria-pressed="false"><span class="muestra" style="--c1:#5c1a08;--c2:#ffb46b"></span>Brasa</button>
  </nav>
</main>
```

Textos exactos:

- Etiqueta: «Escena 3D · tiempo real» (en mayúsculas por CSS, con un punto de 6 px del color de cresta con halo de 12 px).
- Titular (`h1`): «Mar de fondo».
- Ayuda con ratón: «Mueve el cursor para levantar el agua y haz clic para lanzar una onda.»
- Ayuda en táctil (`@media (hover: none)`): «Toca el mar para lanzar una onda.»
- Selector (`nav` con `aria-label="Paleta de color"`): «Clorofila», «Abisal», «Brasa», cada botón con `aria-pressed` y una muestra circular de 18 px (degradado radial de cresta a valle).

## Movimiento

| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| Terreno (entrada) | amplitud `uEntrada` | 0 → 1 | 2,6 s | `1 − (1 − e)³` | 0 | carga |
| Terreno (entrada) | frente de aparición `d < uEntrada·60` | 0 → 60 unidades, borde suave de 10 | 2,6 s | mismo | 0 | carga |
| Ola 1 | fase `dot(p,(0.243,0.970))·0.52 − t·1.10` | continuo | periodo ≈ 5,7 s | — | — | siempre |
| Ola 2 | fase `dot(p,(−0.659,0.753))·0.83 − t·1.45` | continuo | ≈ 4,3 s | — | — | siempre |
| Ola 3 | fase `dot(p,(0.932,0.362))·1.37 − t·2.00` | continuo | ≈ 3,1 s | — | — | siempre |
| Ruido | desplazamiento `(t·0.03, −t·0.07)` sobre `p·0.16` | continuo | — | — | — | siempre |
| Cámara | posición x `sin(t·0.07)·0.35`, y `1.55 + sin(t·0.11)·0.05` | continuo | ≈ 90 s | — | — | siempre |
| Cámara | guiñada `sin(t·0.05)·0.03` + cursor `nx·0.05`; cabeceo cursor `ny·0.03` | → objetivo | constante 4/s | exponencial `1 − e^(−4·dt)` | — | `pointermove` |
| Montículo del cursor | `uPunteroAmt` | 0 ↔ 1 | constante 3/s | exponencial | se apaga 2,5 s después del último movimiento | `pointermove` / `pointerleave` |
| Montículo del cursor | posición en el suelo | → punto bajo el cursor | constante 7/s | exponencial | — | `pointermove` |
| Onda (clic) | radio `edad·4.2`, amplitud `0.55·e^(−edad·0.5)` | 0 → 7 s de vida | 7 s | — | — | `pointerdown`, tecla O |
| Onda automática | una onda en `x ∈ [−3,5; 3,5]`, `z ∈ [−13; −5]` | — | cada 3,8 s | — | tras 4 s sin interacción y con `reloj > 2,2` | reposo |
| Paleta | los 3 colores (uniforms) | actual → destino | constante 4/s | exponencial | — | botón o teclas 1/2/3 |
| Puntos | parpadeo `0.8 + 0.2·sin(t·(1.5 + s·3) + s·40)` | — | por punto | — | — | siempre |
| Pie y selector | `opacity` 0 → 1, `translateY` 14 px → 0 | — | 1,4 s | `cubic-bezier(0.22,1,0.36,1)` | 0,9 s / 1,1 s | carga |
| Colores CSS | `background`, punto de la etiqueta | — | 0,9 s | misma curva | — | cambio de paleta |

El tiempo que recibe el shader es `reloj + 4.0` (así la primera imagen ya tiene olas formadas). `dt` se limita a 0,05 s.

## Código clave

### CSS completo (tras el bloque `:root`)

```css
* { box-sizing: border-box; margin: 0; padding: 0; }
  html, body { height: 100%; background: var(--fondo); color: var(--texto); overflow: hidden; }
  body { font-family: var(--mono); -webkit-font-smoothing: antialiased; transition: background-color 0.9s var(--curva); }
  .escena { position: relative; width: 100%; height: 100svh; min-height: 100dvh; overflow: hidden; isolation: isolate; }
  canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: manipulation; }
  .grano {
    position: absolute; inset: 0; pointer-events: none; z-index: 1; opacity: 0.09; mix-blend-mode: overlay;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23g)'/></svg>");
  }
  /* Sin WebGL: plano de puntos en perspectiva CSS con los mismos colores */
  .fallback { display: none; position: absolute; inset: 0; overflow: hidden;
    background: radial-gradient(120% 55% at 50% 36%, color-mix(in srgb, var(--valle) 60%, transparent), transparent 62%), var(--fondo); }
  .fallback::after { content: ""; position: absolute; left: -60%; right: -60%; top: 36%; height: 90%;
    background-image: radial-gradient(circle, var(--cresta) 1.3px, transparent 1.8px); background-size: 20px 20px;
    transform: perspective(360px) rotateX(64deg); transform-origin: 50% 0;
    -webkit-mask-image: linear-gradient(to bottom, transparent, #000 45%); mask-image: linear-gradient(to bottom, transparent, #000 45%); opacity: 0.75; }
  .velo { position: absolute; inset: 0; z-index: 1; pointer-events: none;
    background: radial-gradient(70% 55% at 0% 100%, color-mix(in srgb, var(--fondo) 82%, transparent), transparent 70%); }
  .sin-gl .fallback { display: block; }
  .sin-gl canvas { display: none; }

  .pie { position: absolute; z-index: 2; left: var(--margen); bottom: var(--margen); max-width: min(560px, 70vw);
    opacity: 0; transform: translateY(14px); animation: sube 1.4s var(--curva) 0.9s forwards; }
  .etiqueta { display: flex; align-items: center; gap: 10px; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--texto-suave); }
  .etiqueta::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--cresta); box-shadow: 0 0 12px var(--cresta); transition: background 0.9s var(--curva), box-shadow 0.9s var(--curva); }
  h1 { font-family: var(--grotesca); font-weight: 600; font-size: clamp(38px, 5.4vw, 78px); line-height: 0.98; letter-spacing: -0.03em; margin: 14px 0 12px; text-wrap: balance; }
  .ayuda { font-size: 12px; line-height: 1.6; color: var(--texto-suave); max-width: 38ch; }
  .ayuda .tactil { display: none; }
  @media (hover: none) { .ayuda .raton { display: none; } .ayuda .tactil { display: inline; } }

  .paletas { position: absolute; z-index: 2; right: var(--margen); bottom: var(--margen); display: flex; gap: 6px; padding: 5px;
    border: 1px solid var(--linea); border-radius: 999px; background: rgba(0, 0, 0, 0.32); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
    opacity: 0; transform: translateY(14px); animation: sube 1.4s var(--curva) 1.1s forwards; }
  .paletas button { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px 0 8px; border: 0; border-radius: 999px; cursor: pointer;
    background: transparent; color: var(--texto-suave); font: 500 11px/1 var(--mono); letter-spacing: 0.08em; text-transform: uppercase;
    transition: background 0.4s var(--curva), color 0.4s var(--curva); }
  .paletas button:hover { color: var(--texto); }
  .paletas button[aria-pressed="true"] { background: rgba(255, 255, 255, 0.1); color: var(--texto); }
  .paletas button:focus-visible { outline: 2px solid var(--cresta); outline-offset: 2px; }
  .muestra { width: 18px; height: 18px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, var(--c2) 0 22%, var(--c1) 70%); box-shadow: 0 0 0 1px rgba(255,255,255,0.12) inset; }
  @keyframes sube { to { opacity: 1; transform: none; } }

  @media (max-width: 720px) {
    .pie { right: var(--margen); max-width: none; bottom: calc(var(--margen) + 64px); }
    .paletas { left: var(--margen); right: auto; }
    .paletas button { padding: 0 12px 0 7px; }
    .velo { background: linear-gradient(to top, color-mix(in srgb, var(--fondo) 85%, transparent) 0%, transparent 42%); }
  }
  @media (max-width: 380px) { .paletas button { font-size: 10px; letter-spacing: 0.04em; gap: 6px; padding: 0 9px 0 6px; } }
  @media (prefers-reduced-motion: reduce) {
    .pie, .paletas { animation: none; opacity: 1; transform: none; }
    body, .etiqueta::before { transition: none; }
  }
```

### Script completo

Es la pieza entera: shaders, rejilla, cámara, interacción y ciclo de vida. Cópialo tal cual.

```js
(() => {
  "use strict";
  const PALETAS = {
    clorofila: { fondo: "#010403", valle: "#0a4a2c", cresta: "#7dffa8" },
    abisal:    { fondo: "#01030a", valle: "#0b2f6b", cresta: "#7fd8ff" },
    brasa:     { fondo: "#060201", valle: "#5c1a08", cresta: "#ffb46b" },
  };
  let FOV = 46 * Math.PI / 180;         // campo de visión vertical (58° en vertical/retrato)
  const ALTURA_CAMARA = 1.55;
  let INCLINACION = -0.21;              // radianes, mirando hacia abajo (-0,30 en retrato)
  const D0 = 1.2, DMAX = 46;            // filas desde 1,2 hasta 46 unidades
  const DURACION_ENTRADA = 2.6;         // s

  const html = document.documentElement;
  const escena = document.getElementById("escena");
  const canvas = document.getElementById("lienzo");
  const botones = [...document.querySelectorAll("[data-paleta]")];
  const reducido = matchMedia("(prefers-reduced-motion: reduce)");
  const tactil = matchMedia("(pointer: coarse)").matches;

  const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
  let paleta = "clorofila";
  const col = { fondo: hex(PALETAS.clorofila.fondo), valle: hex(PALETAS.clorofila.valle), cresta: hex(PALETAS.clorofila.cresta) };

  function aplicarCSS(nombre) {
    const p = PALETAS[nombre];
    html.style.setProperty("--fondo", p.fondo);
    html.style.setProperty("--valle", p.valle);
    html.style.setProperty("--cresta", p.cresta);
  }
  function elegir(nombre) {
    if (!PALETAS[nombre]) return;
    paleta = nombre;
    botones.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.paleta === nombre)));
    aplicarCSS(nombre);
    if (reducido.matches || !gl) { for (const k in col) col[k] = hex(PALETAS[nombre][k]); pintarEstatico(); }
  }
  botones.forEach((b) => b.addEventListener("click", () => elegir(b.dataset.paleta)));

  const gl = canvas.getContext("webgl", { antialias: false, alpha: false, depth: false, powerPreference: "high-performance" });
  if (!gl) { html.classList.add("sin-gl"); return; }

  // ---------- Shaders ----------
  const VS_FONDO = `attribute vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }`;
  const FS_FONDO = `precision mediump float;
uniform vec2 uRes; uniform float uHorizonte, uDpr;
uniform vec3 uFondo, uValle, uCresta;
float hash(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main(){
  vec2 uv = gl_FragCoord.xy / uRes;
  float dy = uv.y - uHorizonte;
  float centro = 1.0 - 0.6 * abs(uv.x - 0.5) * 2.0;
  vec3 c = uFondo;
  c += uValle * exp(-dy * dy * 26.0) * 0.45 * centro;
  c += uCresta * exp(-dy * dy * 420.0) * 0.07 * centro;
  vec2 celda = floor(gl_FragCoord.xy / (1.6 * uDpr));
  float e = step(0.9982, hash(celda)) * hash(celda + 7.3) * smoothstep(0.02, 0.25, dy);
  c += mix(vec3(1.0), uCresta, 0.35) * e * 0.55;
  vec2 v = (uv - 0.5) * vec2(1.15, 1.0);
  c *= 1.0 - 0.55 * pow(dot(v, v) * 2.0, 1.4) * 0.5;
  c += (hash(gl_FragCoord.xy + fract(uHorizonte)) - 0.5) / 255.0;
  gl_FragColor = vec4(c, 1.0);
}`;
  const VS_PUNTOS = `attribute vec3 aPos;
uniform mat4 uVP; uniform vec3 uOjo;
uniform float uT, uEntrada, uProy, uTam;
uniform vec2 uPuntero; uniform float uPunteroAmt;
uniform vec3 uOndas[4];
uniform vec3 uValle, uCresta;
varying vec3 vCol; varying float vA;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float ruido(vec2 p){
  vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float mar(vec2 p, vec2 dir, float k, float w, float t){ return exp(sin(dot(p, dir) * k - t * w) - 1.0); }
float altura(vec2 p, float t){
  float h = 0.58 * mar(p, vec2(0.243, 0.970), 0.52, 1.10, t)
          + 0.36 * mar(p, vec2(-0.659, 0.753), 0.83, 1.45, t)
          + 0.20 * mar(p, vec2(0.932, 0.362), 1.37, 2.00, t);
  vec2 q = p * 0.16 + vec2(t * 0.03, -t * 0.07);
  h += (ruido(q) * 0.7 + ruido(q * 2.3 + 4.1) * 0.3) * 0.9;
  return h;
}
void main(){
  vec2 p = aPos.xy; float t = uT;
  float cruda = altura(p, t);
  float n = smoothstep(0.4, 1.75, cruda);
  float h = (cruda - 1.05) * 0.95 * uEntrada;
  float r = distance(p, uPuntero);
  float m = uPunteroAmt * exp(-r * r * 0.32);
  h += m * (0.5 + 0.16 * sin(r * 4.2 - t * 5.0));
  float brillo = m * 0.55;
  for (int i = 0; i < 4; i++) {
    float edad = t - uOndas[i].z;
    if (edad > 0.0 && edad < 7.0) {
      float frente = distance(p, uOndas[i].xy) - edad * 4.2;
      float o = exp(-frente * frente * 1.3) * exp(-edad * 0.5);
      h += 0.55 * o; brillo += o * 0.8;
    }
  }
  vec3 mundo = vec3(p.x, h, p.y);
  vec4 clip = uVP * vec4(mundo, 1.0);
  gl_Position = clip;
  float d = distance(mundo, uOjo);
  float luz = clamp(n + brillo, 0.0, 1.4);
  vCol = mix(uValle, uCresta, pow(clamp(luz, 0.0, 1.0), 1.35)) * (0.5 + 1.1 * luz) + vec3(0.9) * max(luz - 1.0, 0.0);
  float niebla = exp(-max(d - 5.0, 0.0) * 0.06);
  float cerca = smoothstep(1.3, 3.2, d);
  float frente = uEntrada * 60.0;
  float entrada = smoothstep(frente, frente - 10.0, d);
  float titilar = 0.8 + 0.2 * sin(t * (1.5 + aPos.z * 3.0) + aPos.z * 40.0);
  vec2 ndc = clip.xy / clip.w;
  float vi = 1.0 - 0.25 * dot(ndc, ndc);
  float tam = uTam * uProy / d * (0.75 + 0.9 * luz) * mix(1.0, 1.15, smoothstep(9.0, 2.5, d));
  vA = niebla * cerca * entrada * (0.4 + 0.6 * clamp(luz, 0.0, 1.0)) * titilar * vi;
  if (tam < 1.2) { vA *= tam / 1.2; tam = 1.2; }
  gl_PointSize = min(tam, 40.0);
}`;
  const FS_PUNTOS = `precision mediump float;
varying vec3 vCol; varying float vA;
void main(){
  vec2 c = gl_PointCoord - 0.5;
  float r2 = dot(c, c) * 4.0;
  if (r2 > 1.0) discard;
  float a = exp(-r2 * 3.2) * (1.0 - r2) * vA;
  gl_FragColor = vec4(vCol * a, 1.0);
}`;

  function programa(vs, fs) {
    const p = gl.createProgram();
    for (const [tipo, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
      const s = gl.createShader(tipo); gl.shaderSource(s, src); gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
      gl.attachShader(p, s);
    }
    gl.linkProgram(p);
    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
    const u = {}; const nu = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < nu; i++) { const n = gl.getActiveUniform(p, i).name.replace("[0]", ""); u[n] = gl.getUniformLocation(p, n + (gl.getActiveUniform(p, i).size > 1 ? "[0]" : "")); }
    return { p, u, a: gl.getAttribLocation(p, "aPos") };
  }
  let fondo, puntos;
  try { fondo = programa(VS_FONDO, FS_FONDO); puntos = programa(VS_PUNTOS, FS_PUNTOS); }
  catch (e) { html.classList.add("sin-gl"); return; }

  const bufQuad = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, bufQuad);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  const bufPuntos = gl.createBuffer();
  let nPuntos = 0;

  // ---------- Rejilla: cuadrícula regular en el suelo, recortada al trapecio que ve la cámara ----------
  function azar(sem) { return () => { sem |= 0; sem = (sem + 0x6D2B79F5) | 0; let t = Math.imul(sem ^ (sem >>> 15), 1 | sem); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
  function construirRejilla(aspecto, movil) {
    const paso = movil ? 0.13 : 0.1;
    const k = Math.tan(FOV / 2) * aspecto * 1.22 + 0.08;
    const rnd = azar(7);
    const datos = [];
    for (let z = D0; z < DMAX; z += paso) {
      const media = z * k + 0.9;
      const n = Math.ceil(media / paso);
      const j = paso * 0.45 * Math.min(Math.max((z - 8) / 16, 0), 1);
      for (let i = -n; i <= n; i++) datos.push(i * paso + (rnd() - 0.5) * 2 * j, -z + (rnd() - 0.5) * 2 * j, rnd());
    }
    nPuntos = datos.length / 3;
    gl.bindBuffer(gl.ARRAY_BUFFER, bufPuntos);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(datos), gl.STATIC_DRAW);
  }

  // ---------- Cámara ----------
  const norm = (v) => { const l = Math.hypot(v[0], v[1], v[2]); return [v[0] / l, v[1] / l, v[2] / l]; };
  const cruz = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
  const punto = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  function perspectiva(f, a, n, l) { const t = 1 / Math.tan(f / 2); return [t / a, 0, 0, 0, 0, t, 0, 0, 0, 0, (l + n) / (n - l), -1, 0, 0, 2 * l * n / (n - l), 0]; }
  function mirar(ojo, obj) {
    const z = norm([ojo[0] - obj[0], ojo[1] - obj[1], ojo[2] - obj[2]]);
    const x = norm(cruz([0, 1, 0], z)); const y = cruz(z, x);
    return { m: [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -punto(x, ojo), -punto(y, ojo), -punto(z, ojo), 1], x, y, z };
  }
  function mult(a, b) { const o = new Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s; } return o; }

  // ---------- Estado ----------
  let W = 1, H = 1, dpr = 1, aspecto = 1, movil = false, clave = "";
  const cam = { guinada: 0, cabeceo: 0, obG: 0, obC: 0 };
  const puntero = { x: 0, z: -6, ox: 0, oz: -6, amt: 0, ob: 0, ultimo: -99, nx: 0, ny: 0, dentro: false };
  const ondas = new Float32Array(12).fill(-99);
  let iOnda = 0, ultimaAuto = 0, reloj = 0, t0 = null, previo = 0, visible = true, enMarcha = false;
  let horizonte = 0.7;

  function tamano() {
    const r = escena.getBoundingClientRect();
    movil = tactil || r.width < 768;
    dpr = Math.min(window.devicePixelRatio || 1, movil ? 1.5 : 2);
    W = Math.max(1, Math.round(r.width * dpr)); H = Math.max(1, Math.round(r.height * dpr));
    canvas.width = W; canvas.height = H; aspecto = W / H;
    FOV = (aspecto < 1 ? 58 : 46) * Math.PI / 180;
    INCLINACION = aspecto < 1 ? -0.3 : -0.21;
    const nueva = (movil ? "m" : "d") + Math.round(aspecto * 20);
    if (nueva !== clave) { clave = nueva; construirRejilla(aspecto, movil); }
    gl.viewport(0, 0, W, H);
    if (!enMarcha) pintarEstatico();
  }

  function camara(t) {
    const ojo = [Math.sin(t * 0.07) * 0.35, ALTURA_CAMARA + Math.sin(t * 0.11) * 0.05, 0];
    const g = cam.guinada + Math.sin(t * 0.05) * 0.03, c = INCLINACION + cam.cabeceo;
    const obj = [ojo[0] + Math.sin(g) * Math.cos(c), ojo[1] + Math.sin(c), ojo[2] - Math.cos(g) * Math.cos(c)];
    const v = mirar(ojo, obj);
    const vp = mult(perspectiva(FOV, aspecto, 0.05, 120), v.m);
    const lejos = [ojo[0] + Math.sin(g) * 400, 0, ojo[2] - Math.cos(g) * 400];
    const cl = [0, 1, 3].map((r) => vp[r] * lejos[0] + vp[4 + r] * lejos[1] + vp[8 + r] * lejos[2] + vp[12 + r]);
    horizonte = (cl[1] / cl[2]) * 0.5 + 0.5;
    return { ojo, vp, v };
  }

  function rayoAlSuelo(c) {
    const th = Math.tan(FOV / 2), f = [-c.v.z[0], -c.v.z[1], -c.v.z[2]];
    const d = [0, 1, 2].map((i) => f[i] + c.v.x[i] * puntero.nx * th * aspecto + c.v.y[i] * puntero.ny * th);
    if (d[1] > -0.02) return null;
    const s = Math.min(-c.ojo[1] / d[1], 30);
    return [c.ojo[0] + d[0] * s, c.ojo[2] + d[2] * s];
  }

  function lanzarOnda(x, z) { ondas[iOnda * 3] = x; ondas[iOnda * 3 + 1] = z; ondas[iOnda * 3 + 2] = reloj; iOnda = (iOnda + 1) % 4; }

  function dibujar(t, entrada) {
    const c = camara(t);
    if (puntero.dentro) { const hit = rayoAlSuelo(c); if (hit) { puntero.ox = hit[0]; puntero.oz = hit[1]; } }
    gl.disable(gl.BLEND);
    gl.useProgram(fondo.p);
    gl.bindBuffer(gl.ARRAY_BUFFER, bufQuad);
    gl.enableVertexAttribArray(fondo.a); gl.vertexAttribPointer(fondo.a, 2, gl.FLOAT, false, 0, 0);
    gl.uniform2f(fondo.u.uRes, W, H); gl.uniform1f(fondo.u.uHorizonte, horizonte); gl.uniform1f(fondo.u.uDpr, dpr);
    gl.uniform3fv(fondo.u.uFondo, col.fondo); gl.uniform3fv(fondo.u.uValle, col.valle); gl.uniform3fv(fondo.u.uCresta, col.cresta);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.disableVertexAttribArray(fondo.a);

    gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
    gl.useProgram(puntos.p);
    gl.bindBuffer(gl.ARRAY_BUFFER, bufPuntos);
    gl.enableVertexAttribArray(puntos.a); gl.vertexAttribPointer(puntos.a, 3, gl.FLOAT, false, 0, 0);
    const u = puntos.u;
    gl.uniformMatrix4fv(u.uVP, false, new Float32Array(c.vp));
    gl.uniform3fv(u.uOjo, c.ojo);
    gl.uniform1f(u.uT, t); gl.uniform1f(u.uEntrada, entrada);
    gl.uniform1f(u.uProy, H / (2 * Math.tan(FOV / 2)));
    gl.uniform1f(u.uTam, movil ? 0.032 : 0.026);
    gl.uniform2f(u.uPuntero, puntero.x, puntero.z); gl.uniform1f(u.uPunteroAmt, puntero.amt);
    gl.uniform3fv(u.uOndas, ondas);
    gl.uniform3fv(u.uValle, col.valle); gl.uniform3fv(u.uCresta, col.cresta);
    gl.drawArrays(gl.POINTS, 0, nPuntos);
  }

  function pintarEstatico() { if (!gl) return; dibujar(6.0, 1); }

  function fotograma(ahora) {
    if (!enMarcha) return;
    requestAnimationFrame(fotograma);
    if (t0 === null) { t0 = ahora; previo = ahora; }
    const dt = Math.min((ahora - previo) / 1000, 0.05); previo = ahora;
    reloj += dt;
    const k = 1 - Math.exp(-dt * 4), k2 = 1 - Math.exp(-dt * 7);
    const destino = PALETAS[paleta];
    for (const n in col) { const d = hex(destino[n]); for (let i = 0; i < 3; i++) col[n][i] += (d[i] - col[n][i]) * k; }
    cam.guinada += (cam.obG - cam.guinada) * k; cam.cabeceo += (cam.obC - cam.cabeceo) * k;
    puntero.ob = puntero.dentro && reloj - puntero.ultimo < 2.5 ? 1 : 0;
    puntero.amt += (puntero.ob - puntero.amt) * (1 - Math.exp(-dt * 3));
    puntero.x += (puntero.ox - puntero.x) * k2; puntero.z += (puntero.oz - puntero.z) * k2;
    if (reloj - puntero.ultimo > 4 && reloj - ultimaAuto > 3.8 && reloj > 2.2) {
      ultimaAuto = reloj;
      lanzarOnda((Math.random() - 0.5) * 7, -5 - Math.random() * 8);
    }
    const e = Math.min(reloj / DURACION_ENTRADA, 1);
    dibujar(reloj + 4.0, 1 - Math.pow(1 - e, 3));
  }

  function arrancar() { if (enMarcha || reducido.matches || !visible || document.hidden) return; enMarcha = true; t0 = null; requestAnimationFrame(fotograma); }
  function parar() { enMarcha = false; }

  // ---------- Entrada ----------
  function aNdc(ev) { const r = canvas.getBoundingClientRect(); puntero.nx = ((ev.clientX - r.left) / r.width) * 2 - 1; puntero.ny = -(((ev.clientY - r.top) / r.height) * 2 - 1); }
  escena.addEventListener("pointermove", (ev) => {
    aNdc(ev); puntero.dentro = true; puntero.ultimo = reloj;
    cam.obG = puntero.nx * 0.05; cam.obC = puntero.ny * 0.03;
  });
  escena.addEventListener("pointerleave", () => { puntero.dentro = false; cam.obG = 0; cam.obC = 0; });
  canvas.addEventListener("pointerdown", (ev) => {
    aNdc(ev); puntero.ultimo = reloj;
    const hit = rayoAlSuelo(camara(reloj + 4.0));
    if (hit) lanzarOnda(hit[0], hit[1]);
  });
  window.addEventListener("keydown", (ev) => {
    if (ev.key === "1") elegir("clorofila");
    else if (ev.key === "2") elegir("abisal");
    else if (ev.key === "3") elegir("brasa");
    else if (ev.key === "o" || ev.key === "O") { puntero.ultimo = reloj; lanzarOnda(0, -6); }
  });

  new IntersectionObserver((e) => { visible = e[0].isIntersecting; visible ? arrancar() : parar(); }).observe(escena);
  document.addEventListener("visibilitychange", () => (document.hidden ? parar() : arrancar()));
  reducido.addEventListener("change", () => { if (reducido.matches) { parar(); pintarEstatico(); } else arrancar(); });
  let espera; new ResizeObserver(() => { clearTimeout(espera); espera = setTimeout(tamano, 60); }).observe(escena);
  canvas.addEventListener("webglcontextlost", (ev) => { ev.preventDefault(); parar(); html.classList.add("sin-gl"); });

  const inicial = new URLSearchParams(location.search).get("paleta");
  if (PALETAS[inicial]) { elegir(inicial); for (const k in col) col[k] = hex(PALETAS[inicial][k]); }
  tamano();
  arrancar();
})();
```

### Por qué la rejilla es así

- **Cuadrícula regular en el mundo**, paso 0,1 (0,13 en móvil), filas desde `z = −1,2` hasta `z = −46`. Cada fila solo abarca el ancho que ve la cámara: media anchura `z·k + 0,9` con `k = tan(FOV/2)·aspecto·1,22 + 0,08` (el 1,22 y el 0,08 dejan margen para la guiñada). Así no se gastan vértices fuera de cuadro: unos 140.000 puntos en 1440×900 y unos 40.000 en un móvil vertical.
- Las columnas quedan alineadas (`i·paso`), por eso de cerca se leen como hileras rectas que convergen al fondo.
- A partir de 8 unidades se añade un temblor aleatorio creciente (hasta ±0,45·paso a 24 unidades) para romper el moiré de las filas lejanas. El azar es determinista (mulberry32, semilla 7).
- El tercer componente del atributo es una semilla 0–1 por punto (parpadeo).
- La rejilla se reconstruye solo si cambia el tipo de pantalla o el aspecto (clave `m|d` + `round(aspecto·20)`).

### Por qué la ola es así

- `mar(p, dir, k, w, t) = exp(sin(dot(p, dir)·k − t·w) − 1)` da valores entre e⁻² y 1 con **crestas afiladas y valles anchos**, como el mar real; el signo menos hace que las olas viajen hacia el espectador.
- La altura final es `(cruda − 1,05)·0,95·uEntrada`; la luz de cada punto es `n = smoothstep(0,4, 1,75, cruda)` más el brillo del cursor (`m·0,55`) y de las ondas (`o·0,8`), limitada a 1,4.
- Tamaño en píxeles: `uTam·uProy/d·(0,75 + 0,9·luz)`, con `uTam = 0,026` (0,032 en móvil), `uProy = altoEnPíxeles / (2·tan(FOV/2))` y un extra de ×1,15 en los primeros metros. Si sale menor de 1,2 px se fija en 1,2 y se baja la opacidad en proporción (evita parpadeo subpíxel). Máximo 40 px.
- Opacidad: niebla `exp(−max(d − 5, 0)·0,06)` × fundido cercano `smoothstep(1,3, 3,2, d)` × frente de entrada × `(0,4 + 0,6·luz)` × parpadeo × viñeta `1 − 0,25·|ndc|²`.
- Fragment: disco con núcleo gaussiano `exp(−r²·3,2)·(1 − r²)` y `discard` fuera del círculo; se escribe `vec4(color·a, 1)` y la mezcla `ONE, ONE` lo suma.

## Interacción

- **Puntero**: `pointermove` sobre la escena convierte la posición a NDC, lanza un rayo desde la cámara y lo corta con el plano `y = 0` (máximo 30 unidades; si el rayo mira por encima del horizonte, se ignora). Ahí aparece un montículo gaussiano `exp(−r²·0,32)` con anillos `0,5 + 0,16·sin(r·4,2 − t·5)` que sigue al cursor con suavizado y enciende los puntos. La cámara se inclina un poco hacia el cursor (guiñada ±0,05 rad, cabeceo ±0,03 rad). Al salir (`pointerleave`) todo vuelve a reposo.
- **Clic / toque** (`pointerdown` en el canvas): onda circular desde el punto del suelo tocado. Hay 4 ondas simultáneas como máximo (búfer circular `uOndas[4]` con `x, z, instante`).
- **Reposo**: si nadie toca nada en 4 s, cada 3,8 s nace sola una onda en la zona media del mar, para que la escena nunca parezca congelada (también en móvil).
- **Teclado**: `1`, `2`, `3` cambian de paleta; `O` lanza una onda en `(0, −6)`. Los botones de paleta son `<button>` nativos con foco visible (contorno de 2 px del color de cresta, separación 2 px).
- **URL**: `?paleta=abisal` o `?paleta=brasa` abre la pieza directamente con esa paleta (sin transición).

## Adaptación a móvil y tablet

- **Retrato** (`aspecto < 1`): FOV vertical 58° (46° en horizontal) e inclinación −0,30 rad (−0,21 en horizontal), para que el horizonte quede en el tercio superior y el mar llene la pantalla.
- **Móvil** (`pointer: coarse` o ancho < 768 px): paso de rejilla 0,13 y puntos un 23 % mayores (`uTam` 0,032). DPR máximo 1,5 (2 en escritorio).
- **≤ 720 px**: el pie ocupa todo el ancho y sube 64 px; el selector pasa a la izquierda debajo del pie; el velo pasa a ser un degradado lineal desde abajo (85 % del fondo → transparente al 42 %).
- **≤ 380 px**: botones de paleta a 10 px con menos espaciado para que quepan los tres en 360 px.
- Sin scroll horizontal: `html, body { overflow: hidden }` y la escena mide `100svh` con `min-height: 100dvh`.

## Accesibilidad y movimiento reducido

- Canvas, velo, grano y fallback llevan `aria-hidden="true"`; el contenido real es el `header` con `h1` y el `nav` de paletas con `aria-pressed`.
- Contraste: texto `#eef6f1` y secundario al 66 % sobre fondos casi negros (AA holgado); el velo garantiza la lectura aunque pase una cresta por detrás.
- Con `prefers-reduced-motion: reduce`: no arranca el bucle; se pinta **un solo fotograma** con `t = 6` y entrada completa (el mar queda quieto y legible), el pie y el selector aparecen sin animación, los cambios de paleta son instantáneos. Si la preferencia cambia en caliente, se para o se reanuda.

## Rendimiento

- WebGL 1, `antialias: false`, `alpha: false`, `depth: false`, `powerPreference: "high-performance"`. Dos llamadas de dibujo por fotograma.
- DPR máximo 2 (1,5 en móvil). `ResizeObserver` con espera de 60 ms: reajusta lienzo, FOV e inclinación y solo reconstruye la rejilla si cambia la clave de aspecto.
- El bucle se detiene con `IntersectionObserver` (escena fuera de pantalla) y con `visibilitychange` (pestaña oculta); al volver se reinicia `dt` para no dar saltos.
- **Fallback sin WebGL** (o si falla un shader o se pierde el contexto): se añade `sin-gl` a `<html>`, se oculta el canvas y se muestra `.fallback`: halo radial del color de valle y un plano de puntos CSS (`radial-gradient` de 1,3 px cada 20 px) inclinado con `perspective(360px) rotateX(64deg)` y enmascarado hacia el horizonte. Mismos colores, mismo encuadre.

## Personalización

- **Colores**: cambia `#010403`, `#0a4a2c` y `#7dffa8` (hex de 6 cifras, la función `hex()` no acepta otro formato). Deben ir en los tres sitios donde aparecen: `:root`, `PALETAS.clorofila` y la muestra del primer botón. Regla para que funcione: fondo casi negro (luminosidad < 3 %), valle oscuro y saturado, cresta clara y saturada; si la cresta es muy oscura, las olas no brillan.
- **Paletas 2 y 3**: edita `PALETAS.abisal` y `PALETAS.brasa`, su botón y su muestra. Para añadir una cuarta, añade una entrada a `PALETAS`, un botón con `data-paleta` y una tecla.
- **Titular**: `Mar de fondo`. Si no quieres texto, borra el `header` y el velo.
- **Mar más bravo o más calmo**: el factor `0.95` de la altura (1,3 = temporal; 0,6 = balsa de aceite) y las velocidades `1.10 / 1.45 / 2.00` de las olas.
- **Más o menos puntos**: `paso` en `construirRejilla` (0,08 = más denso y pesado; 0,14 = más ligero y gráfico).
- **Horizonte**: `INCLINACION` (más negativo = horizonte más alto) y `ALTURA_CAMARA`.
- **Como fondo de una sección**: cambia `.escena` a la altura que quieras; el bucle ya se pausa cuando sale de pantalla.

## Comprobación final

1. A 1440×900 se ve un mar de puntos hasta el horizonte (en torno al 30 % desde arriba), con halo del color de valle y cielo casi negro con alguna estrella.
2. En la carga, el terreno crece desde plano y aparece de cerca hacia lejos en 2,6 s; el pie y el selector suben con 0,9 s y 1,1 s de retardo.
3. Las crestas son claras y con puntos más grandes; los valles, oscuros; las olas avanzan hacia el espectador y nunca se repiten de forma evidente.
4. Al mover el ratón aparece un montículo con anillos justo bajo el cursor y la cámara se inclina levemente; a los 2,5 s sin moverlo se desvanece.
5. Cada clic lanza una onda circular que se expande (4,2 unidades/s) y se apaga en unos 7 s; caben 4 a la vez.
6. Sin tocar nada, cada 3,8 s aparece una onda automática.
7. Los botones Clorofila, Abisal y Brasa (y las teclas 1, 2, 3) cambian los colores con un fundido suave; `aria-pressed` se actualiza.
8. En 390×844 el horizonte queda en el tercio superior, el pie y el selector no se solapan y no hay scroll horizontal.
9. Con movimiento reducido se ve un fotograma quieto y completo, sin bucle.
10. Sin WebGL se ve el plano de puntos CSS con los mismos colores.
11. El bucle se para al ocultar la pestaña o sacar la escena de pantalla.
12. Cero errores en consola; 60 fps en un portátil normal.

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:
    • «Haz el mar más bravo, con olas más altas y rápidas»
    • «Cambia la paleta principal a violeta y rosa neón»
    • «Pon el horizonte más alto y la cámara más cerca del agua»
    • «Que las ondas del clic sean más grandes y duren más»
  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 usarla como fondo de una sección en vez de a pantalla completa?
Sí. Cambia la altura de .escena a la que necesites; el lienzo se adapta solo y el bucle se pausa cuando la sección sale de pantalla.
¿Va bien en móviles?
Sí. En móvil usa menos puntos (unos 40.000), limita la resolución a 1,5× y ajusta el campo de visión en vertical. El toque lanza ondas y, en reposo, aparecen solas.
¿Cómo cambio los colores?
Con las tres variables de color de la paleta principal; las otras dos paletas se editan en el objeto PALETAS. Conviene un fondo casi negro y una cresta clara y saturada para que las olas brillen.
¿Necesita three.js u otra librería?
No. Es WebGL 1 escrito a mano, con dos programas y dos buffers, así que pesa muy poco y se integra en cualquier web.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈10329 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈2071 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.