Y derrepente, las líneas.
Hace muchísimo que no escribo un post en un blog, creo que hace más de diez (10!) años, así que permítanme desvariar un rato
Hay una app japonesa que se llama zakomemo que hace algo ridículamente simple: uno dibuja… y las líneas tiemblan. Siempre me ha gustado ese tipo de efecto en las ilustraciones y la verdad es que, siendo una persona más de código que de lápiz, aunque lo intente nunca me queda bien hacerlo frame por frame.
Y bueh, usando la app y tonteando el buen @Icefox tiró la pregunta al aire:

SERÁ UN SHADER?
5 minutos después de meter la nariz en devtools ya tenía una respuesta, un simple render loop y un sistema de coords y… nada más. Así que ahí nació WiggleMemo, como un intento de estudiar el efecto y calentarme con otras features (Quería poder tener líneas fijas y líneas moviéndose así que obvio le agregué capas… y más… orz)
El proyecto comenzó a distanciarse lo suficiente del original y terminó siendo una reimplementación que ocupa WASM para el render loop mientras intenta agregar un par de cosas extras que iré explicando ahora.
”Solo voy a ver cómo está hecho”
“Vamos, entramos y salimos, una aventura de 20 minutos”
La app original estaba hecha en Next.js y el código venía minificado, en chunks y un poquitín ofuscado. Así que en vez de forkearla o copiarla directo, lo hice desde cero. Vanilla JS, HTML, CSS. Sin framework, sin bundler, sin nada. Solo tres archivos y las puras ganas de que las líneas temblaran como chancho de Wario Ware.

Al leer el código lo importante fue entender el efecto en sí. ¿Cómo se puede lograr que una línea se vea “temblorosa”? Básicamente:
-
Béziers cuadráticas. En vez de dibujar línea recta entre cada punto del puntero, la aplicación tomaa el punto medio entre dos puntos consecutivos y usás eso como punto de control de una curva. El resultado son líneas suaves que no se ven tan mecánicas y que son más parecidas a un vector que a un trazo renderizado.
-
Jitter por punto. A cada punto de control se le aplica un desplazamiento pseudo-aleatorio que depende de su posición
(x, y)y del tiempo. La gracia es usar un hash determinístico basado ensin():
const pseudoRandom = (a, b, c) => {
const n = 43758.5453 * Math.sin(12.9898 * a + 78.233 * b + 37.719 * c);
return n - Math.floor(n);
};
Esas constantes son los mismos “números mágicos” que usa GLSL para pseudo-aleatorios desde hace años; Se supone funcionan bien porque sin() de números grandes produce resultados que parecen aleatorios pero son perfectamente reproducibles.
Y con esto… ya está funcionando el efecto base. Las líneas temblaban. Yo también (era uno de los días más helados en mi ciudad en más de 60 años). Misión cumplida. Podría haber parado ahí.

Dos modos de renderizado porque uno no era suficiente
La verdad es que intentando pensar en cómo optimizar un poco el render loop (que con muchas layers y trazos iría a 2 fps) intenté ver la posibilidad de ocupar la GPU… o liberar el main thread de JS. Así que por ahora, la app tiene dos formas de dibujar:
CPU Loop (modo Canvas 2D): Todo corre en JavaScript. Por cada frame, se recorres cada trazado, calcula el jitter para cada punto de control, y dibuja las curvas con quadraticCurveTo. Con esto y el pseudo-random el efecto se ve exactamente igual en todos lados.
GPU Shader (modo WebGL): Acá el truco es distinto. El dibujo se hace primero en un canvas oculto sin jitter. Ese canvas se sube como textura a WebGL, y el fragment shader aplica el desplazamiento pixel a pixel en la GPU. Es mucho más eficiente para dibujos complejos porque le dejamos el trabajo a la GPU, la cual hace cada pixel en paralelo.
Lo malo? El shader (inicialmente implementado con Simplex Noise) se comporta mal con las curvas vectoriales así que no se parecia nada al modo CPU. Terminé reemplazándolo con el mismo pseudoRandom pero en GLSL, usando una grilla de celdas de 8px para que los bloques de desplazamiento tuvieran un tamaño similar a la distancia entre puntos de control en el modo JS. El problema es que tampoco se vé tan parecido por que al ser pixel por pixel la matemática debería ser distinta, así que por ahora el GPU Shader está como experimental y sin terminar :( Sé que hay alguna mejor solución, sólo hay que buscarla.
Me pongo mi capa y mi sombrero de mago
Lo siguiente fue agregar capas. La idea es que cada capa tenga sus propios trazados y su propia configuración de jitter (amplitud, velocidad, tamaño de celda). Así uno puede usar una capa que tiembla mucho y otra capa casi estática y se compondrán una encima de la otra. En modo GPU, cada capa tiene su propio canvas oculto y su propia textura WebGL. Por cada frame visible, el shader hace un drawArrays separado por capa, y WebGL las compone con alpha blending. El resultado es que las capas se mezclan correctamente y sin tener que hacer nada especial.
Un detalle que casi me mata: los puntos medios que generan las uniones entre segmentos Bézier tienen que tener el mismo t (timestamp) en ambos segmentos que los comparten. Si no, el jitter calcula valores distintos para el mismo punto físico según desde qué segmento lo mires, y la línea aparece con huecos. La solución fue promediar los timestamps al crear el midpoint:
const mid = {
x: (prev.x + p.x) / 2,
y: (prev.y + p.y) / 2,
t: (prev.t + p.t) / 2, // ← esta línea
};
Como computeJitter es determinístico (dado (x, y, t)), el mismo objeto siempre produce el mismo desplazamiento.
El rediseño estético, también conocido como EXTRAÑO USAR SWAPNOTE
La UI de la primera versión era demasiado simple: Blanco, translúcido, sin carácter. Ahí fue cuando con demasiado tiempo en las manos me puse de a poco a darle una intención hacia Swapnote, la aplicación de dibujo que tenía Nintendo en la 3DS y que extraño muchísimo…
El panel pasó al lado derecho, se abre por defecto, y cada capa tiene un thumbnail en miniatura que se actualiza en tiempo real mientras dibujas. Los controles de jitter pasaron a una sección fija en la parte inferior del panel.
El truco de los thumbnails es usar los mismos canvas ocultos que el shader usa para sus texturas — siempre están al día porque se actualizan en cada evento de dibujo, sin importar en qué modo estés. Así los thumbnails funcionan tanto en modo CPU como en modo GPU.
updateThumbnails(layers, getLayerCanvas) {
for (const layer of layers) {
const thumb = this.#listEl.querySelector(`canvas[data-layer-id="${layer.id}"]`);
const src = getLayerCanvas(layer.id);
if (!thumb || !src || src.width === 0) continue;
const ctx = thumb.getContext('2d');
ctx.clearRect(0, 0, thumb.width, thumb.height);
ctx.drawImage(src, 0, 0, thumb.width, thumb.height);
}
}
WebAssembly: porqué y cuándo
Aquí viene una parte entretenida:
El modo CPU crea objetos {dx, dy} por cada punto de control, en cada frame. Con tres capas, diez trazos por capa, cien segmentos por trazado y 60 fps, eso es (y aquí ocuparé napkin math porque me da pereza) aproximadamente 540.000 objetos temporales por segundo. El garbage collector de V8 los elimina rápido, pero no gratis, así que con dibujos complejos se comienza a sentir muchísimo el stuttering.
La solución: pasar todo ese cálculo matemático a WebAssembly.
Hace poco había leído acerca de AssemblyScript, que es básicamente TypeScript compilado a WASM. Sin lenguajes distintos al que se está ocupando en el proyecto, sin complejizar el toolchain. Basta con un npm i assemblyscript y npx asc.
La idea es la siguiente: en vez de calcular el jitter punto por punto en JS (creando un objeto por resultado), se mandan todos los puntos de un trazado de un tirón a una función WASM que trabaja directamente sobre memoria lineal:
// src/jitter.ts (AssemblyScript)
export function computeJitterBatch(
inPtr: usize, // puntero al buffer de entrada [x, y, t, x, y, t, ...]
outPtr: usize, // puntero al buffer de salida [dx, dy, dx, dy, ...]
nPoints: i32,
amplitude: f64,
frequency: f64,
elapsedMs: f64,
): void {
for (let i = 0; i < nPoints; i++) {
const x = load<f64>(inPtr + i * 24);
const y = load<f64>(inPtr + i * 24 + 8);
const t = load<f64>(inPtr + i * 24 + 16);
const tStep = Math.floor((t + elapsedMs) * frequency);
store<f64>(outPtr + i * 16, (2.0 * pseudoRandom(x, y, tStep) - 1.0) * amplitude);
store<f64>(outPtr + i * 16 + 8, (2.0 * pseudoRandom(y, x, tStep + 1.0) - 1.0) * amplitude);
}
}
Mientras tanto, en JS, la clase JitterWasm pre-aloca dos regiones de memoria en el heap de WASM para input y output, en conjunto con exponer una vista Float64Array directa sobre ellas. No hay copia de datos entre JS y WASM: se escribe en el buffer, llamas a la función, lees el resultado. Saltando la necesidad de objetos temporales.
const offsets = wasm.computeBatch(
flat, nSegs * 3,
jitterCfg.amplitude,
jitterCfg.frequency,
elapsedMs
);
// offsets es una vista Float64Array a memoria WASM
El WASM compilado termina pesando como 2kb y termina haciendo un montón del trabajo duro. Viva La WASM.
Lo que se aprendió
- El GPU shader es la solución correcta para dibujos complejos, pero hasta no encontrar una matemática correcta para el Shader el modo CPU tiene su gracia, sin mencionar que es más fácil para entender qué está pasando punto a punto.
- AssemblyScript es sorprendentemente simple! Para casos de uso matemáticos puros está perfect. Si no se necesita manipular el DOM ni interactuar con APIs del browser, compilar TypeScript a WASM es chef kiss.
- Las constantes mágicas de los hashes están en todas partes. Esos números (
43758.5453,12.9898,78.233) aparecen en shaders de Shadertoy, en papers de gráficos, en memes de GLSL. Recomiendo caleta leer acerca de ellos, realmente la magia negra de los shaders me gana pero tener idea del concepto me ayudó montón. - Pensé en poder ocupar los canvas ocultos con un Web Worker pero después de evaluarlo me dí cuenta que el costo de la serialización de data por trazo no escala lo suficiente como para que sea una optimización a considerar.
- En el
updateLayerTexturese ocupaclearRecty se redibuja cada trazo en la capa, lo cual obviamente después de mucho dibujar esO(trazos x segmentos)por llamada, así que se puede ocupar el delta entre lo que ya se ha dibujado y el canvas oculto. - RECUERDEN PAUSAR EL LOOP EN CAPAS QUE ESTÁN OCULTAS