Un arrecife de coral en el escritorio: cómo se construyó el fondo de pantalla Aquarium
Un arrecife 3D en tiempo real que vive detrás de los iconos del escritorio en macOS y Windows: peces que nadan en el vertex shader, un núcleo de simulación en Rust compilado a WebAssembly, un ecosistema que se reproduce, crece y envejece, comida que cae donde se tira un archivo a la papelera y tickets de licencia Ed25519.

Un fondo de escritorio suele ser una foto y, de vez en cuando, un vídeo en bucle. Aquarium, en cambio, es un arrecife de coral vivo que funciona en tiempo real: el nado de los peces se calcula en cada fotograma, buscan comida cuando tienen hambre, crecen, envejecen, se reproducen y mueren. La aplicación no tiene ventana, porque es el escritorio. En macOS y en Windows se ejecuta justo detrás de los iconos del escritorio.
En este artículo recorreré el proyecto capa a capa: cómo nadan los peces, el núcleo de simulación escrito en Rust, cómo colocar una ventana detrás de los iconos del escritorio en dos plataformas y por qué los peces acuden al lugar donde se acaba de borrar un archivo del Escritorio.

La versión web se puede probar ahora mismo en el navegador: deskreef.kartex.net. Al mover el ratón, los peces se asustan y salen disparados. Todas las capturas de este artículo se tomaron de esa página, de la escena en ejecución.
¿Qué hay en la escena?
En el arrecife viven seis especies de peces: pez ángel, cirujano azul, pez payaso, disco, guppy y tetra neón. Junto a ellos hay "residentes" que viven entre las rocas: una morena, caballitos de mar y un lenguado que se desliza sobre la arena. Los peces payaso vuelven una y otra vez a su anémona, y los tetras neón nadan en banco.
La mayor parte del entorno se genera de forma procedural en tiempo de ejecución: la arena, las rocas, las plantas, la anémona y los shaders. Los modelos de los peces proceden de un paquete CC0; los corales, de los ejemplos WebGL Aquarium de Google y de un escaneo 3D del Smithsonian; y las fotos de fondo, de fotografías de arrecifes de la NOAA y el USFWS. Hay seis fondos para elegir:

En cuanto a la iluminación, están los efectos que se esperan bajo el agua: cáusticas que bailan sobre la arena, rayos de luz que bajan desde la superficie, burbujas que refractan la luz como verdaderas lentes de aire y plantas que se mecen.
¿Cómo nadan los peces?
Los modelos de los peces son mallas estáticas sin esqueleto. Todo el nado se produce en el vertex shader. Una onda que recorre el cuerpo mueve la cola, la parte trasera del cuerpo se dobla en torno a la cabeza en los giros, las aletas dorsal y anal ondulan y las aletas pectorales aletean. Cada uno de estos movimientos puede ajustarse por especie: frecuencia y amplitud de la cola, número de ondas, rigidez de la cabeza, dónde se dobla el cuello.
Lo difícil es distinguir las aletas del cuerpo. El modelo es de una sola pieza; nada indica qué vértice pertenece a una aleta. Una pequeña herramienta en Python (tools/finmask.py) lanza rayos a través de la malla desde cada vértice para medir su grosor. Las superficies finas se consideran aletas, y se escribe un archivo auxiliar .fin de 4 bytes por vértice. El shader usa esos pesos para mover las aletas de forma independiente del cuerpo:
attribute vec4 aFin; // median-fin weight, median edge, pectoral weight, pectoral edge
// swimming wave
float env = uEnv.x + (1.0 - uEnv.x) * pow(tW, uEnv.y);
float ampT = uSwim.y * env;
float ph = uSwim.x - uSwim.w * tW;
float dz = ampT * sin(ph);
La amplitud de la onda crece desde el morro hasta la cola con una envolvente (env), y la fase recorre el cuerpo (uSwim.w * tW). Así el pez no se mueve como un látigo, sino como un pez de verdad, haciendo la mayor parte del trabajo con su mitad trasera. El shader también corrige la longitud de arco para que la onda no estire el cuerpo y, en las especies de cabeza rígida (como el disco), detiene la onda en la articulación de la cabeza: la cabeza oscila como una sola pieza, ligeramente en sentido contrario a la cola. Las aletas del betta, por su parte, fluyen como tela, con inercia.
El cerebro: un núcleo de simulación en Rust
Lo que hacen los peces no vive en el JavaScript de la página, sino en el código Rust de core/: la dirección del movimiento, la alimentación, el ataque a los gusanos, el crecimiento, el envejecimiento, los nacimientos, los bancos; las escamas de comida y los gusanos; los paseos de los caballitos de mar y los deslizamientos del lenguado. El núcleo se compila a wasm32-unknown-unknown y se incrusta en la página como un módulo WebAssembly de 140-195 KB. npm run build funciona sin toolchain de Rust, porque el módulo compilado está incluido en el repositorio.

La página solo dibuja. fish.js guarda los materiales y los uniforms del shader de cada pez y, en cada fotograma, lee el registro de ese pez en el núcleo a través de un Float32Array superpuesto a la memoria del módulo. No hay copias ni serialización de por medio. El mundo está hecho de bloques con comprobación de límites, dispuestos palabra por palabra tal como los lee la página; la única línea unsafe es la importación que lee el nombre del host.
La dirección del movimiento se basa en la idea clásica de los steering behaviours: para cada pez se calculan unas cuantas fuerzas y se suman. El paso de separación evita que los peces choquen entre sí, junta a los peces de un banco y los orienta en la misma dirección, y hace que un pez que pasa a toda velocidad sobresalte a sus vecinos:
pub fn steer_separate(w: &mut World, i: i64) {
let s = w.sb_of(i);
let school = f2b(w.sg(s, s_school()));
let pos = w.fgv(i, f_pos());
let size = w.fg(i, f_size());
// ...
for j in 0..capacity() {
// ...
let v = vsub(pos, fpos);
let d = vlen(v);
let min_d = (size + fsize) * (if school && same { 0.6 } else { 1.0 });
if d < min_d && d > 0.0001 && !feeding {
let q = min_d / max(d, min_d * 0.2);
steer_add(w, i, vscale(v, 1.0 / d), q * q * (if chasing { 0.15 } else { 0.6 }));
}
// a fish dashing past startles the ones it comes close to:
// they shy away a little for half a second
// ...
}
if n_coh > 0 {
let c = vsub(vscale(coh, 1.0 / to_float(n_coh)), pos);
let dc = if vlen(c) > 0.0 { vlen(c) } else { 1.0 };
steer_add(w, i, vscale(c, 1.0 / dc), min(dc / (size * 3.0), 1.0) * 0.8);
steer_add(w, i, vnorm(ali), 1.0);
}
}
Además hay pasos para mantenerse dentro del acuario, esquivar las rocas, evitar que el centro se llene, dirigirse hacia la comida, atacar a los gusanos y huir del ratón. Un detalle interesante: el núcleo se escribió primero en Rill y después se portó a Rust línea a línea. Para asegurarme de que ambos se comportan igual, el parámetro ?trace registra cada llamada al núcleo; tools/core-diff.mjs reproduce una grabación contra dos compilaciones distintas y se detiene en el primer fotograma en el que difiere cualquier cosa que lee la página. La altura de la arena también se calcula en ambos lados a partir del mismo ruido de Perlin, de modo que donde el núcleo cree que está el fondo coincide exactamente con donde se dibuja.
Un ecosistema: nacimiento, crecimiento, vejez
El acuario cambia por sí solo con el tiempo. Los alevines crecen a medida que comen; un alevín que pasa hambre demasiado tiempo muere. Los adultos envejecen y mueren cuando se agota su esperanza de vida, pero la población de una especie nunca baja de su número inicial. Un nacimiento requiere un pez que haya comido lo suficiente; las especies difieren en fertilidad y apetito. Los guppys son vivíparos, con 2-4 alevines por parto. El cirujano azul se salta la mayoría de las comidas, y el pez ángel solo participa en una cuarta parte de ellas.
Al abrir el panel de información se ven en directo el total de cada especie, sus adultos, ancianos y alevines, su probabilidad de nacimiento y lo bien alimentado que está cada pez:

El corazón del ciclo de vida es la función life_tick. El crecimiento de un alevín está ligado a lo alimentado que esté: un pez solo puede crecer tanto como haya comido. La muerte tampoco llega de golpe; un pez moribundo se va ralentizando poco a poco:
pub fn life_tick(w: &mut World, i: i64, dt: f64) {
let s = w.sb_of(i);
let life = w.wf(w_life());
let age = w.fg(i, f_age());
if age < 1.0 {
let fed = min(1.0, w.fg(i, f_fed()) + dt * life * 0.00025);
w.fput(i, f_fed(), fed);
if fed > age {
let age2 = min(fed, age + dt * life / w.sg(s, s_grow_time()));
w.fput(i, f_age(), age2);
// ...
}
}
// ...
}
Desde el menú se puede ralentizar el ecosistema a la mitad de velocidad o acelerarlo diez veces, hacer que una especie dé a luz a mano o que muera un pez. Aquí también se ajustan la calidad (baja/media/alta), la tasa de fotogramas (24/30/60), el fondo, el sonido y el contador de FPS:

Alimentar desde el Escritorio
Esta es mi función favorita: cuando se envía a la papelera un archivo del Escritorio (arrastrándolo a la Papelera o pulsando ⌘⌫), los peces acuden a donde estaba ese icono. Desde ese punto caen escamas de comida, los peces se agolpan y comen un rato, y después se dispersan.

No hace falta ningún permiso adicional. La aplicación de macOS vigila la carpeta ~/Desktop con un DispatchSource. Cuando desaparece un elemento, toma la posición del último clic como ubicación del icono, comprueba en la lista de ventanas que ese punto es realmente el escritorio sin ninguna otra ventana encima y entonces le dice a la vista web "suelta comida aquí". La versión de Windows hace lo mismo con su propio DesktopWatcher.
Colocar una ventana detrás del escritorio
La aplicación dibuja una vista web en cada pantalla y la coloca detrás de los iconos del escritorio. Cómo se hace eso varía mucho entre las dos plataformas.
En macOS es bastante limpio: una NSWindow sin bordes colocada en el nivel de ventana del escritorio, visible en todos los Spaces y que ignora los eventos del ratón:
window = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false, screen: screen)
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)))
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.isOpaque = true
window.hasShadow = false
window.ignoresMouseEvents = true
La aplicación se pausa sola cuando la pantalla entra en reposo o se bloquea y cuando la ventana queda totalmente cubierta (occlusionState); no gasta CPU en un acuario que nadie puede ver. Desde el icono 🐠 de la barra de menús se puede pausar, elegir la calidad y la tasa de fotogramas, iniciarla al arrancar la sesión, recargar y salir.
En Windows no hay una forma oficial. Se usa el truco clásico de las aplicaciones al estilo de Wallpaper Engine: enviar el mensaje no documentado 0x052C a la ventana Progman para que genere un WorkerW, después buscar el WorkerW que queda detrás de la vista de iconos (SHELLDLL_DefView) y hacer que la ventana sea hija suya. Dónde acaba ese WorkerW depende de la versión de Windows; el código contempla las dos disposiciones conocidas:
public static IntPtr FindWorkerW()
{
IntPtr progman = FindWindow("Progman", null);
if (progman != IntPtr.Zero)
{
SendMessageTimeout(progman, 0x052C, IntPtr.Zero, IntPtr.Zero, 0, 1000, out _);
}
// Windows 11 (observed on build 26200): Progman hosts the spawned WorkerW as its own
// child, below SHELLDLL_DefView in z-order.
IntPtr child = FindWindowEx(progman, IntPtr.Zero, "WorkerW", null);
if (child != IntPtr.Zero) return child;
// Windows 10 and earlier: the WorkerW is a top-level window sitting just behind the one
// that hosts the icon view.
// ...
}
El host de Windows está escrito con .NET 8 y WebView2 y reutiliza el mismo bundle web/. El runtime de .NET va dentro del exe, y el instalador no pide derechos de administrador. El WebView2 Runtime ya viene incluido en Windows 11 y en las versiones actuales de Windows 10.
La versión web y los tickets de licencia
La versión web tiene un problema propio: la página y el módulo WebAssembly son públicos, y podrían copiarse y alojarse en otro sitio. Para evitarlo, el núcleo no se licencia a sí mismo. Cada cinco minutos, la página obtiene un ticket del servidor y se lo entrega al núcleo. Un ticket tiene la forma host|issued|expires|Ed25519 signature. El núcleo lo verifica con el nombre de host que ha leído por sí mismo: si la firma es válida, si el ticket es más reciente que el anterior, si no ha caducado y si ha llegado al menos uno por cada 25 minutos de ejecución.
Como el núcleo solo lleva la clave pública, los tickets no pueden falsificarse. El endpoint no envía ninguna cabecera CORS y tiene limitación de tasa por cliente; una copia del sitio en otro lugar no recibe ningún ticket y se apaga a los 150 segundos: todos los peces enferman y se hunden. La clave de firma no está en el código fuente; solo vive en la máquina y en el servidor, y cada despliegue genera una nueva. El núcleo de las aplicaciones de escritorio, por su parte, licencia sus propios orígenes sin conexión.

El proyecto en cifras
| Rust (núcleo de simulación) | ~5.300 líneas |
| JavaScript (renderizado, shaders, menú) | ~3.400 líneas |
| C# (host de Windows) | ~1.200 líneas |
| Swift (host de macOS) | ~570 líneas |
| Python (máscara de aletas y herramientas de modelos) | ~500 líneas |
| Commits | 173 |
| Núcleo WebAssembly | 140-195 KB |
| Especies | 6 especies de peces + morena, caballito de mar, lenguado |
La versión de macOS es un único binario universal que funciona tanto en Intel como en Apple Silicon. Para Windows hay un instalador y un zip portable. Ninguno está firmado digitalmente, así que en el primer arranque hay que hacer clic derecho y elegir "Abrir" en macOS, y elegir "Ejecutar de todas formas" en SmartScreen en Windows.
Conclusión
Aquarium me enseñó algunas cosas. Un fondo de pantalla es un programa que siempre está encendido y se ejecuta incluso cuando nadie lo mira; por eso el rendimiento y las reglas de pausa importan tanto como el aspecto visual. Escribir la imagen con tecnología web y el cerebro en Rust reunió lo mejor de ambos mundos: la misma escena funciona en dos sistemas operativos y en el navegador, y la simulación se comporta exactamente igual en todas partes. Y que los peces corran al lugar donde se acaba de tirar un archivo a la papelera resultó ser una interacción mucho más encantadora de lo que esperaba.
El software pertenece a Kartex Teknoloji ve Bilişim A.Ş. Los modelos, texturas, fotografías y sonidos de terceros conservan sus propias licencias, enumeradas en NOTICES.txt. La versión web se puede probar en deskreef.kartex.net. Me alegrará recibir preguntas en X o LinkedIn.